かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot でメール送信する Web アプリケーションを作る ( その15 )( Thymeleaf を利用して HTML メールを送信する2 )

概要

Spring Boot でメール送信する Web アプリケーションを作る ( その14 )( Thymeleaf を利用して HTML メールを送信する ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Thymeleaf による HTML メール送信機能を実装します。
    • 前回からの続きです。メール送信画面の変更と Controller クラスの実装を行います。

ソフトウェア一覧

参考にしたサイト

  1. Thymeleaf - User Forum › General Usage - String replace
    http://forum.thymeleaf.org/String-replace-td4026357.html

    • Thymeleaf に出力するデータの中の改行コードを <br /> へ変換する方法を参考にしました。

手順

mailsend.html の変更

  1. src/main/resources/templates/mailsend の下の mailsend.html を リンク先の内容 に変更します。画面の下に「HTMLメール送信」ボタンが追加されます。

    f:id:ksby:20150527213845p:plain

MailsendController クラスの変更

  1. src/main/java/ksbysample/webapp/email/web/mailsend の下の MailsendController.javaリンク先の内容 に変更します。

  2. テストを作成します。src/test/java/ksbysample/webapp/web/mailsend の下の MailsendControllerTest.javaリンク先の内容 に変更します。

  3. テストを実行します。MailsendControllerTest クラス内の「正常処理時のテスト」クラスのクラス名にカーソルを移動した後、コンテキストメニューを表示して「Run '正常処理時のテスト' with Coverage」メニューを選択します。

    テストが全て成功することを確認します。

    f:id:ksby:20150527213414p:plain

動作確認

  1. メールサーバ smtp4dev を起動します。

  2. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  3. ブラウザを起動し http://localhost:8080/mailsend へアクセスします。以下の画像の値を入力後、「HTMLメール送信」ボタンをクリックします。

    ※この画像は入力データを示すために以前のものを持ってきたものです。そのため「HTMLメール送信」ボタンはありません。

    f:id:ksby:20150505091551p:plain

    smtp4dev に HTML メールが送信されますので、Windows Live メールで表示してみます。

    f:id:ksby:20150527215325p:plain

    以下の問題がありました。

    • 「内容」欄に出力するデータに改行が反映されていません。後で修正します。
    • 画面の幅を狭くしてみたのですが、レイアウトが変わりません。Site Demo - Ink Basic Template > ZURBian Engineers のメールサンプルを見ても Windows Live メールのサンプルはなかったので、対応していないのかもしれません。こちらは今回はこのままにします。
  4. src/main/resources/templates/mailsend の下の mailsend.html を リンク先の内容 に変更します。

  5. 再度ブラウザから http://localhost:8080/mailsend へアクセスした後、データを入力して「HTMLメール送信」ボタンをクリックします。

    今度は改行が反映されて表示されました。

    f:id:ksby:20150528031903p:plain

  6. Run View で Ctrl+F2 を押して Tomcat を停止します。smtp4dev も終了します。

  7. Project View から「Run 'Tests in 'ksbysample...' with Coverage」を実行し、テストが全て成功することを確認します。

    f:id:ksby:20150528034023p:plain

  8. Gradle projects View から build タスクを実行し、"BUILD SUCCESSFUL" が出力されることを確認しようとしましたが、なぜかエラーが出ました。原因を調査します。

    f:id:ksby:20150528035459p:plain

  9. まずは一番最初に出力されている java.lang.IllegalStateException のエラーを調査します。

    f:id:ksby:20150529002643p:plain

    1. ログの最後の方に Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. と出力されていますので、--debug オプションを指定してみます。
    2. 最初に画面下部の Run View で Ctrl+F4 を押してクローズします。
    3. 次に IntelliJ IDEA のメインメニューから「Run」-「Edit Configurations...」を選択します。
    4. 「Run/Debug Configurations」ダイアログが表示されます。画面左側のツリーから「Gradle」の下に表示されている Configuration を全て削除して「Defaults」だけにします。
    5. 「Defaults」-「Gradle」を選択した後、画面右側の「Script parameters」に "--debug" と入力した後「OK」ボタンをクリックします。 f:id:ksby:20150529004337p:plain
    6. Gradle projects View の build タスクを選択してコンテキストメニューを表示した後、「Run 'ksbysample-webapp-em...'」を選択します。 f:id:ksby:20150529004637p:plain
    7. Run View が開き大量の DEBUG 文が出力されます。build タスクが完了したら "IllegalStateException" で検索します。java.lang.IllegalStateException: SMTPServer can only be started once のログが出力されていることが確認できます。 f:id:ksby:20150529004946p:plain
    8. どうもきちんと stop できていないのに start しようとしているため、IllegalStateException が発生したようです。今の Wiser の start, stop の実装だと問題があるようですので修正します。
  10. src/test/java/ksbysample/webapp/email/test の下の MailServerWiserResource.javaリンク先の内容 に変更します。

  11. Gradle projects View から build タスクを実行します。今度は java.lang.IllegalStateException は出力されませんでした。

  12. 次に junit.framework.ComparisonFailure at MailsendServiceTest.java:143 のエラーを調査します。

    f:id:ksby:20150529011718p:plain

    1. 再度 Gradle projects View の build タスクを選択してコンテキストメニューを表示した後、「Run 'ksbysample-webapp-em...'」を選択します。
    2. Run View が開き大量の DEBUG 文が出力されます。build タスクが完了したら "MailsendServiceTest.java:143" で検索します。junit.framework.ComparisonFailure: value (table=email, row=0, col=subject) expected:<[繝?繧ケ繝?]> but was:<[テスト]> のログが出力されていることが確認できます。 f:id:ksby:20150529012104p:plain
    3. DbUnit でテストデータの CSV ファイルから取得した日本語が文字化けしているのが原因のようです。Spring Boot でメール送信する Web アプリケーションを作る ( その11 )( メール送信画面の作成5 ) で、JUnit でも文字化けしたので -Dfile.encoding=UTF-8 を指定するようにしていました。Gradle でも同様に指定してみます。
    4. IntelliJ IDEA のメインメニューから「Run」-「Edit Configurations...」を選択します。
    5. 「Run/Debug Configurations」ダイアログが表示されます。画面左側のツリーから「Gradle」の下に表示されている Configuration を全て削除して「Defaults」だけにします。
    6. 「Defaults」-「Gradle」を選択した後、画面右側の「VM options」に "-Dfile.encoding=UTF-8" と入力した後「OK」ボタンをクリックします。 f:id:ksby:20150529051358p:plain
    7. 画面下部の Run View で Ctrl+F4 を押してクローズします。
    8. Gradle projects View から build タスクを実行します。が、まだ junit.framework.ComparisonFailure at MailsendServiceTest.java:143 のエラーが出力されます。
    9. 再度 Gradle projects View の build タスクを選択してコンテキストメニューを表示した後、「Run 'ksbysample-webapp-em...'」を選択して DEBUG ログを出力してみます。が、なぜかテストが全て成功して "BUILD SUCCESSFUL" のログが出力されました。なぜ???
    10. build タスクをダブルクリックして実行した場合と「Run 'ksbysample-webapp-em...'」を選択して実行した場合の違いを調査してみると、以下のことが分かりました。
      • ダブルクリックして実行した場合、ログの最初に 5:23:51: Executing external task 'build'... と出力されます。
      • 「Run 'ksbysample-webapp-em...'」を選択して実行した場合、ログの最初に 5:25:08: Executing external task 'build -Dfile.encoding=UTF-8'... と出力されます。
      • ダブルクリックして実行した場合、VM options で設定した -Dfile.encoding=UTF-8 の設定が反映されないようです。
    11. この後いろいろ調査して判明したのは、Gradle の文字コードVM options で指定する場合、「Run/Debug Configurations」ダイアログで指定するのではなく、「Settins」ダイアログの「Gradle-Android Compiler」で指定する、ということでした。手順を記載します。
    12. まず「Run/Debug Configurations」ダイアログで設定した「Gradle」の VM options と Script parameters の設定をクリアします。 f:id:ksby:20150529053931p:plain
    13. 次に IntelliJ IDEA のメインメニューから「File」-「Settings...」を選択します。
    14. 「Settings」ダイアログが表示されます。検索フィールドに "Gradle" と入力した後、画面左側のツリーの一番下に表示される「Gradle-Android Compiler」を選択します。画面右側に「VM Options」の欄がありますので、ここに "-Dfile.encoding=UTF-8" を入力して「OK」ボタンをクリックします。 f:id:ksby:20150529054459p:plain
    15. 動作確認します。画面下部の Run View で Ctrl+F4 を押してクローズします。
    16. Gradle projects View から clean タスク → build タスクの順にそれぞれダブルクリックして実行します。今度は "BUILD SUCCESSFUL" が出力されました。
    17. 次に Gradle projects View から clean タスクをダブルクリックで実行した後、build タスクを「Run 'ksbysample-webapp-em...'」を選択して実行します。こちらも "BUILD SUCCESSFUL" が出力されました。

    ※あとで Spring Boot でメール送信する Web アプリケーションを作る ( その3 )( Project の作成 ) にこの手順を追加しておきます。

commit、Push、Pull Request、マージ

  1. commit します。commit 時に Code Analysis のダイアログが表示されますので、「Review」ボタンをクリックして以下の対応をします。

    • Attribute th:... is not allowed here の Warning が出ていましたので、出なくなるようにします。対象は th:utext です。
  2. commit、GitHub へ Push、1.0.x-send-htmlmail -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-send-htmlmail ブランチを削除、をします。

ソースコード

mailsend.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>ksbysample-webapp-email</title>
    <meta content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' name='viewport'/>

    <meta th:replace="common/head-cssjs"/>
    <style>
    <!--
    .checkbox label,
    .radio label {
        padding-right: 10px;
    }
    .form-control-static {
        padding-top: 0px;
        padding-bottom: 0px;
    }
    .has-error .form-control {
        background-color: #fff5ee;
    }
    -->
    </style>
</head>
<body class="skin-blue">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Left side column. contains the logo and sidebar -->
    <div th:replace="common/mainparts :: main-sidebar (active='mailsend')"></div>

    <!-- Content Wrapper. Contains page content -->
    <div class="content-wrapper">
        <!-- Content Header (Page header) -->
        <section class="content-header">
            <h1>
                メール送信画面
            </h1>
        </section>

        <!-- Main content -->
        <section class="content">
            <div class="row">
                <div class="col-xs-12">
                    <form id="mailsendForm" method="post" action="/mailsend/send" th:action="@{/mailsend/send}" th:object="${mailsendForm}" class="form-horizontal">
                        <div class="callout callout-danger" th:if="${#fields.hasGlobalErrors()}">
                            <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">共通エラーメッセージ表示エリア</p>
                        </div>

                        <div class="box box-primary">
                            <div class="box-body">
                                <div class="form-group" th:classappend="${#fields.hasErrors('*{fromAddr}')} ? 'has-error' : ''">
                                    <label for="fromAddr" class="control-label col-sm-2">From</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-8"><div class="input-group"><span class="input-group-addon"><i class="fa fa-envelope"></i></span><input type="text" name="fromAddr" id="fromAddr" class="form-control input-sm" value="" placeholder="Fromアドレスを入力して下さい" th:field="*{fromAddr}"/></div></div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{fromAddr}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{fromAddr}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{toAddr}')} ? 'has-error' : ''">
                                    <label for="toAddr" class="control-label col-sm-2">To</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-8"><div class="input-group"><span class="input-group-addon"><i class="fa fa-envelope"></i></span><input type="text" name="toAddr" id="toAddr" class="form-control input-sm" value="" placeholder="Toアドレスを入力して下さい" th:field="*{toAddr}"/></div></div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{toAddr}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{toAddr}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{subject}')} ? 'has-error' : ''">
                                    <label for="subject" class="control-label col-sm-2">Subject</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-12"><input type="text" name="subject" id="subject" class="form-control input-sm" value="" placeholder="件名を入力して下さい" th:field="*{subject}"/></div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{subject}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{subject}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{name}')} ? 'has-error' : ''">
                                    <label for="name" class="control-label col-sm-2">氏名</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-8"><input type="text" name="name" id="name" class="form-control input-sm" value="" placeholder="(例) 田中 太郎" th:field="*{name}"/></div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{name}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{name}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{sex}')} ? 'has-error' : ''">
                                    <label class="control-label col-sm-2">性別</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-12">
                                            <div class="radio">
                                                <label th:each="sex : ${T(ksbysample.webapp.email.config.Constant).getInstance().SEX_MAP.entrySet()}">
                                                    <input type="radio" name="sex" th:value="${sex.getKey()}" th:text="${sex.getValue()}" th:field="*{sex}"/>
                                                </label>
                                            </div>
                                        </div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{sex}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{sex}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{type}')} ? 'has-error' : ''">
                                    <label class="control-label col-sm-2">項目</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-8">
                                            <select name="type" id="type" class="form-control input-sm" th:field="*{type}">
                                                <option th:each="type : ${T(ksbysample.webapp.email.config.Constant).getInstance().TYPE_MAP.entrySet()}"
                                                        th:value="${type.getKey()}"
                                                        th:text="${type.getValue()}">sex</option>
                                            </select>
                                        </div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{type}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{type}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{item}')} ? 'has-error' : ''">
                                    <label class="control-label col-sm-2">商品</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-12">
                                            <div class="checkbox">
                                                <label th:each="item : ${T(ksbysample.webapp.email.config.Constant).getInstance().ITEM_MAP.entrySet()}">
                                                    <input type="checkbox" name="item" th:value="${item.getKey()}" th:text="${item.getValue()}" th:field="*{item}"/>
                                                </label>
                                            </div>
                                        </div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{item}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{item}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>

                                <div class="form-group" th:classappend="${#fields.hasErrors('*{naiyo}')} ? 'has-error' : ''">
                                    <label for="naiyo" class="control-label col-sm-2">内容</label>
                                    <div class="col-sm-10">
                                        <div class="row"><div class="col-sm-12"><textarea rows="5" name="naiyo" id="naiyo" class="form-control input-sm" placeholder="お問い合わせ内容を入力して下さい" th:field="*{naiyo}"></textarea></div></div>
                                        <div class="row" th:if="${#fields.hasErrors('*{naiyo}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{naiyo}">ここにエラーメッセージを表示します</small></p></div></div>
                                    </div>
                                </div>
                            </div>
                            <div class="box-footer">
                                <div class="text-center">
                                    <button type="button" id="send" value="send" class="btn btn-primary">送信</button>
                                    <button type="button" id="sendhtml" value="sendhtml" class="btn btn-default">HTMLメール送信</button>
                                </div>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </section>
        <!-- /.content -->
    </div>
    <!-- /.content-wrapper -->

</div>
<!-- ./wrapper -->

<!-- REQUIRED JS SCRIPTS -->

<div th:replace="common/bottom-js"></div>
<script type="text/javascript">
<!--
$(document).ready(function() {
    $('#fromAddr').focus();

    $('#send').bind('click', function(){
        $('#mailsendForm').submit();
    });

    $('#sendhtml').bind('click', function(){
        $('#mailsendForm').attr('action', '/mailsend/sendhtml');
        $('#mailsendForm').submit();
    });
});
-->
</script>
</body>
</html>
  • 「送信」ボタンの button タグの下に <button type="button" id="sendhtml" value="sendhtml" class="btn btn-default">HTMLメール送信</button> を追加する。
  • </body> 直前の画面独自の Javascript を記述している部分に $('#sendhtml').bind('click', function(){ ... } を追加する。

MailsendController.java

    @RequestMapping("/sendhtml")
    public String sendhtml(@Validated MailsendForm mailsendForm
            , BindingResult bindingResult
            , Model model) throws MessagingException {
        if (bindingResult.hasErrors()) {
            return "mailsend/mailsend";
        }

        // 入力されたデータをDBに保存した後、HTMLメールを送信する
        mailsendService.saveAndSendHtmlEmail(mailsendForm);

        return "redirect:/mailsend";
    }

MailsendControllerTest.java

    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringApplicationConfiguration(classes = Application.class)
    @WebAppConfiguration
    public static class 正常処理時のテスト {

        private final MailsendForm mailsendFormMinimum
                = (MailsendForm) new Yaml().load(getClass().getResourceAsStream("mailsendForm_minimum.yml"));
        private final MailsendForm mailsendFormMin
                = (MailsendForm) new Yaml().load(getClass().getResourceAsStream("mailsendForm_min.yml"));
        private final MailsendForm mailsendFormMax
                = (MailsendForm) new Yaml().load(getClass().getResourceAsStream("mailsendForm_max.yml"));

        @Rule
        @Autowired
        public TestDataResource testDataResource;

        @Rule
        @Autowired
        public MailServerResource mailServer;

        @Rule
        @Autowired
        public MockMvcResource mvc;

        @Test
        public void 最小値空ありで送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/send", this.mailsendFormMinimum))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }

        @Test
        public void 最小値で送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/send", this.mailsendFormMin))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }

        @Test
        public void 最大値で送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/send", this.mailsendFormMax))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }

        @Test
        public void 最小値空ありでHTML送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/sendhtml", this.mailsendFormMinimum))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }

        @Test
        public void 最小値でHTML送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/sendhtml", this.mailsendFormMin))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }

        @Test
        public void 最大値でHTML送信ボタンをクリックした場合() throws Exception {
            mvc.nonauth.perform(TestHelper.postForm("/mailsend/sendhtml", this.mailsendFormMax))
                    .andExpect(status().isFound())
                    .andExpect(redirectedUrl("/mailsend"))
                    .andExpect(model().hasNoErrors())
                    .andExpect(model().errorCount(0));
        }
        
    }
  • 「最小値空ありでHTML送信ボタンをクリックした場合」「最小値でHTML送信ボタンをクリックした場合」「最大値でHTML送信ボタンをクリックした場合」のテストを追加します。

mailsend.html

                            <table class="row">
                                <tr>
                                    <td class="wrapper last">
                                        <table class="twelve columns">
                                            <tr>
                                                <td id="naiyo" th:utext="${naiyo} ? ${#strings.replace(naiyo, T(java.lang.System).getProperty('line.separator'), '&lt;br /&gt;')} : ''">これはテストです。</td>
                                                <td class="expander"></td>
                                            </tr>
                                        </table>
                                    </td>
                                </tr>
                            </table>
  • <td id="naiyo" th:text="${naiyo}">これはテストです。</td><td id="naiyo" th:utext="${naiyo} ? ${#strings.replace(naiyo, T(java.lang.System).getProperty('line.separator'), '&lt;br /&gt;')} : ''">これはテストです。</td> へ変更します。改修のポイントは以下3点です。
    • th:text ではなく th:utext を使用して出力する文字列がエスケープされないようにします。
    • strings.replace を使用して改行コードを <br /> へ変換します。
    • null の場合には strings.replace が実行されないようにします。

MailServerWiserResource.java

package ksbysample.webapp.email.test;

import org.junit.rules.ExternalResource;
import org.springframework.stereotype.Component;
import org.subethamail.wiser.Wiser;

@Component
public class MailServerWiserResource extends ExternalResource {

    private Wiser wiser;

    @Override
    protected void before() {
        this.wiser = new Wiser(); 
        this.wiser.setHostname("localhost");
        this.wiser.setPort(25);
        this.wiser.start();
    }

    @Override
    protected void after() {
        this.wiser.stop();
        this.wiser = null;
    }

    public Wiser getWiser() {
        return this.wiser;
    }

}
  • private Wiser wiser = new Wiser();private Wiser wiser; へ変更します。フィールドではインスタンスを生成しないようにします。
  • before メソッドの最初に this.wiser = new Wiser(); を追加します。
  • after メソッドの最後に this.wiser = null; を追加します。

履歴

2015/05/29
初版発行。