読者です 読者をやめる 読者になる 読者になる

かんがるーさんの日記

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

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その25 )( jar ファイルを作成して動作確認する )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その24 )( Spring Boot を 1.4.5 → 1.4.6 にバージョンアップする ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • jar ファイルを作成して動作確認する。

参照したサイト・書籍

  1. Windowsのバッチファイル中で日付をファイル名に使用する
    http://www.atmarkit.co.jp/ait/articles/0405/01/news002.html

  2. Failure to find creator property with Lombok and an unwrapping mixin involved
    https://github.com/FasterXML/jackson-databind/issues/1239

  3. FreeMarker - Remove comma from milliseconds
    http://stackoverflow.com/questions/21577407/freemarker-remove-comma-from-milliseconds

  4. FREEMARKER Manual - Built-ins for numbers - c (when used with numerical value)
    http://freemarker.org/docs/ref_builtins_number.html#ref_builtin_c

  5. FREEMARKER Manual - setting
    http://freemarker.org/docs/ref_directive_setting.html

  6. Spring Boot Reference Guide - Appendix E. The executable jar format
    https://docs.spring.io/spring-boot/docs/current/reference/html/executable-jar.html

  7. Why did Spring Boot 1.4 change its jar layout to locate application classes under BOOT-INF?
    http://stackoverflow.com/questions/40292816/why-did-spring-boot-1-4-change-its-jar-layout-to-locate-application-classes-unde

目次

  1. jar ファイルを作成、配置する
  2. サービスに登録する
  3. 動作確認
  4. CSVファイルアップロード後に「貸出状況を確認しました」のメールが送信されない原因を調査する
  5. メールの中の lendingAppId の数値がカンマ区切りされる理由とは?
  6. 動作確認2
  7. 「貸出状況を確認しました」のメールに記載された URL にアクセスするとエラーになる原因を調査する
  8. 次回は。。。

手順

jar ファイルを作成、配置する

clean タスク実行 → Rebuild Project 実行 → build タスク実行します。

C:\project-springboot\ksbysample-webapp-lending\build\libs の下に ksbysample-webapp-lending-1.4.6-RELEASE.jar が作成されますので、C:\webapps\ksbysample-webapp-lending\lib の下に配置します。

webapps/ksbysample-webapp-lending/bat/webapp_startup.bat を リンク先の内容 に変更した後、C:\webapps\ksbysample-webapp-lending\bat の下にコピーします。

サービスに登録する

コマンドプロンプトを「管理者として実行…」モードで起動した後、以下のコマンドを実行します。

> cd /d C:\webapps\ksbysample-webapp-lending\nssm
> nssm.exe install ksbysample-webapp-lending

「NSSM service installer」画面が表示されます。以下の画像の値を入力した後、「Install service」ボタンをクリックします。サービスの登録に成功すると「Service “ksbysample-webapp-lending” installed successfully!」のダイアログが表示されますので「OK」ボタンをクリックします。

f:id:ksby:20170505085051p:plain

  • 「Path」に C:\webapps\ksbysample-webapp-lending\bat\webapp_startup.bat を入力します。
  • 「Startup directory」に C:\webapps\ksbysample-webapp-lending\bat を入力します。

動作確認

  1. 動作確認前に DB のデータを以下の状態にします。

    • user_info, user_role テーブルのデータは開発時のままにします。
    • lending_app, lending_book, library_forsearch テーブルのデータはクリアします。
  2. サービス画面を開きます。サービス一覧から「ksbysample-webapp-lending」を選択し、「サービスの開始」リンクをクリックしてサービスを開始します。

    f:id:ksby:20170505184650p:plain

    f:id:ksby:20170505184752p:plain

  3. C:\webapps\ksbysample-webapp-lending\logs の下の ksbysample-webapp-lending.log をエディタで開き、最後に “Started Application in …” のログが出力されていることを確認します。

  4. メールを受信するので smtp4dev を起動します。

  5. 以下の手順で動作確認します ( 画面キャプチャは省略します )。

    • ブラウザを起動して http://localhost:8080/ にアクセスしてログイン画面を表示します。tanaka.taro@sample.com / taro でログインします。
    • 検索対象図書館登録画面が表示されます。"東京都" で検索した後、一覧表示されている図書館から「国立国会図書館東京本館」を選択します。
    • ログアウトします。
    • ログイン画面に戻るので suzuki.hanako@test.co.jp / hanako でログインします。
    • 貸出希望書籍 CSV ファイルアップロード画面が表示されます。以下の内容が記述された CSV ファイルをアップロードします。

      “ISBN”,“書名”
      “978-4-7741-6366-6”,“GitHub実践入門”
      “978-4-7741-5377-3”,“JUnit実践入門”
      “978-4-7973-8014-9”,“Java最強リファレンス”
      “978-4-7973-4778-4”,“アジャイルソフトウェア開発の奥義”
      “978-4-87311-704-1”,“Javaによる関数型プログラミング

    • 「貸出状況を確認しました」のメールが送信される。。。はずでしたが、メールが送信されませんでした。ksbysample-webapp-lending.log を見ると、以下のログが出力されていました。原因を調査します。
      • org.springframework.amqp.rabbit.listener.exception.ListenerExecutionFailedException: Listener method 'public void ksbysample.webapp.lending.listener.rabbitmq.InquiringStatusOfBookQueueListener.receiveMessage(org.springframework.amqp.core.Message) throws javax.mail.MessagingException' threw exception
      • Caused by: java.lang.IllegalArgumentException: Not enough variable values available to expand 'systemid'
      • at ksbysample.webapp.lending.service.calilapi.CalilApiService.lambda$getForEntityWithRetry$0(CalilApiService.java:137)
  6. 一旦サービス画面で「ksbysample-webapp-lending」サービスを停止します。

CSVファイルアップロード後に「貸出状況を確認しました」のメールが送信されない原因を調査する

デバッガで処理を何度か追った結果、原因は以下の内容であることが分かりました。

  • ksbysample.webapp.lending.service.calilapi.CalilApiService#check 内で RestTemplate#getForEntity をリトライするために Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その13 )( RestTemplate で WebAPI を呼び出している処理に spring-retry でリトライ処理を入れる ) において CalilApiService#getForEntityWithRetry メソッドを定義して、それに置き換えた。
  • RestTemplate#getForEntity には public <T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Object... uriVariables)public <T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Map<String, ?> uriVariables) の2つが存在する。
  • CalilApiService#check 内で呼び出していた RestTemplate#getForEntity は public <T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Map<String, ?> uriVariables) だったが、CalilApiService#getForEntityWithRetry メソッドは public <T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Object... uriVariables) を呼び出す方しか定義していなかったので、処理が適切に行われずエラーが発生した。

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.javaprivate <T> ResponseEntity<T> getForEntityWithRetry(RestTemplate restTemplate, String url, Class<T> responseType, Map<String, ?> uriVariables) を追加します。リンク先の内容 に変更します。

変更後に IntelliJ IDEA から bootRun を起動して CSVファイルアップロードすると、今度は Failed to evaluate Jackson deserialization for type [[simple type, class ksbysample.webapp.lending.service.calilapi.response.CheckApiResponse]]: com.fasterxml.jackson.databind.JsonMappingException: Invalid definition for property "" (of type Lksbysample/webapp/lending/service/calilapi/response/Libkey;): Could not find creator property with name '' (known Creator properties: [name, value]) というログが出力されました。

Web で調べると GitHub の Issue で Failure to find creator property with Lombok and an unwrapping mixin involved を見つけました。この Issue によると、

  • lombok の @AllArgsConstructor を付けたクラスをネストしていて、そのクラスを Jackson で使用していると、この問題が発生する。
  • Project の root 直下に lombok.config というファイルを作成し、lombok.anyConstructor.suppressConstructorProperties = true を記述すれば解決するとのこと。

ということで root 直下に lombok.config というファイルを新規作成し、リンク先の内容 を記述します。

再度 IntelliJ IDEA から bootRun を起動して CSVファイルアップロードすると、今度は「貸出状況を確認しました」のメールが送信されました。が、メールを見ると http://localhost:8080/lendingapp?lendingAppId=1,739 と lendingAppId の数値がカンマ区切りされていました。

f:id:ksby:20170506075914p:plain

メールの中の lendingAppId の数値がカンマ区切りされる理由とは?

stackoverflow の FreeMarker - Remove comma from milliseconds という QA を見つけました。FreeMarker のテンプレートの中で ${number} と書いていて、数値→文字列に変換される場合、デフォルトでは3桁毎に “,” で区切られるようです。またこれを回避するには ${number?c} のように末尾に ?c を付ければよいとのこと。

ただしデフォルトで “,” 区切りになるのは避けたいので、もう少し調べてみたところ、FREEMARKER Manual - settingnumber_format の設定を computer にすればデフォルトで ?c を付けた状態になると記述がありました。この設定を追加することにします。

src/main/resources/application.properties を リンク先のその1の内容 に変更します。

動作確認すると「貸出状況を確認しました」のメールで lendingAppId の数値がカンマ区切りされなくなりました。

f:id:ksby:20170506080643p:plain

clean タスク実行 → Rebuild Project 実行 → build タスク実行し、作成された ksbysample-webapp-lending-1.4.6-RELEASE.jar を C:\project-springboot\ksbysample-webapp-lending\build\libs の下にコピーします。

動作確認2

最初から動作確認し直します。

  1. 動作確認前に DB のデータを以下の状態にします。

    • user_info, user_role テーブルのデータは開発時のままにします。
    • lending_app, lending_book, library_forsearch テーブルのデータはクリアします。
  2. サービス画面を開きます。サービス一覧から「ksbysample-webapp-lending」を選択し、「サービスの開始」リンクをクリックしてサービスを開始します。

  3. 以下の手順で動作確認します ( 画面キャプチャは省略します )。

    • ブラウザを起動して http://localhost:8080/ にアクセスしてログイン画面を表示します。tanaka.taro@sample.com / taro でログインします。
    • 検索対象図書館登録画面が表示されます。"東京都" で検索した後、一覧表示されている図書館から「国立国会図書館東京本館」を選択します。
    • ログアウトします。
    • ログイン画面に戻るので suzuki.hanako@test.co.jp / hanako でログインします。
    • 貸出希望書籍 CSV ファイルアップロード画面が表示されます。以下の内容が記述された CSV ファイルをアップロードします。

      “ISBN”,“書名”
      “978-4-7741-6366-6”,“GitHub実践入門”
      “978-4-7741-5377-3”,“JUnit実践入門”
      “978-4-7973-8014-9”,“Java最強リファレンス”
      “978-4-7973-4778-4”,“アジャイルソフトウェア開発の奥義”
      “978-4-87311-704-1”,“Javaによる関数型プログラミング

    • 「貸出状況を確認しました」のメールが送信されるので、メールに記述されている URL にアクセスします。。。とエラー画面が表示されました。ksbysample-webapp-lending.log を見ると、java.lang.Exception: null のログが出力されていました。原因を調査します。

    f:id:ksby:20170506082934p:plain

「貸出状況を確認しました」のメールに記載された URL にアクセスするとエラーになる原因を調査する

いろいろ試した結果、以下のことが分かりました。

  • jar ファイルを作成してサービスから起動すると発生するが、IntelliJ IDEA から bootRun で起動すると発生しない。
  • webapp_startup.bat 内で -Dspring.profiles.active=product-Dspring.profiles.active=develop に変更して起動してから http://localhost:8080/lendingapp?lendingAppId=... の URL にアクセスすると org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "@vh.getText('LendingAppStatusValues', lendingappForm.lendingApp.status)" (lendingapp/lendingapp:78) というログが出力される。

ksbysample.webapp.lending.values.ValuesHelper クラスで問題が発生しているようです。

src/main/java/ksbysample/webapp/lending/values/ValuesHelper.javaSystem.out.println("★★★... でログを出力するように変更して、valuesObjList に読み込まれているデータを出力してみます。

@Component("vh")
public class ValuesHelper {

    private final Map<String, String> valuesObjList;

    private ValuesHelper() throws IOException {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        System.out.println("★★★ this.getClass().getPackage().getName() = " + this.getClass().getPackage().getName());
        valuesObjList = ClassPath.from(loader).getTopLevelClassesRecursive(this.getClass().getPackage().getName())
                .stream()
                .filter(classInfo -> {
                    try {
                        Class<?> clazz = Class.forName(classInfo.getName());
                        return !clazz.equals(Values.class) && Values.class.isAssignableFrom(clazz);
                    } catch (ClassNotFoundException e) {
                        throw new RuntimeException(e);
                    }
                })
                .collect(Collectors.toMap(ClassPath.ClassInfo::getSimpleName, ClassPath.ClassInfo::getName));
        valuesObjList.entrySet().stream()
                .forEach(e -> {
                    System.out.println(String.format("★★★ key = %s, value = %s", e.getKey(), e.getValue()));
                });
    }

IntelliJ IDEA から bootRun で起動した時には、以下のように出力されました。

★★★ this.getClass().getPackage().getName() = ksbysample.webapp.lending.values
★★★ key = LendingBookLendingAppFlgValues, value = ksbysample.webapp.lending.values.lendingbook.LendingBookLendingAppFlgValues
★★★ key = LendingBookApprovalResultValues, value = ksbysample.webapp.lending.values.lendingbook.LendingBookApprovalResultValues
★★★ key = LendingAppStatusValues, value = ksbysample.webapp.lending.values.lendingapp.LendingAppStatusValues

jar ファイルを作成してサービスから起動した時には、以下のように出力されました。

★★★ this.getClass().getPackage().getName() = ksbysample.webapp.lending.values

サービスから起動した時には valuesObjList に何も読み込まれていないようです。

Spring Boot 1.3.5 を使用していた頃に戻して同じことを試してみると、IntelliJ IDEA から bootRun で起動した時と同じように出力されました。

★★★ this.getClass().getPackage().getName() = ksbysample.webapp.lending.values
★★★ key = LendingBookLendingAppFlgValues, value = ksbysample.webapp.lending.values.lendingbook.LendingBookLendingAppFlgValues
★★★ key = LendingBookApprovalResultValues, value = ksbysample.webapp.lending.values.lendingbook.LendingBookApprovalResultValues
★★★ key = LendingAppStatusValues, value = ksbysample.webapp.lending.values.lendingapp.LendingAppStatusValues

ksbysample-webapp-lending-1.1.0-RELEASE.jar と ksbysample-webapp-lending-1.4.6-RELEASE.jar を解凍してディレクトリ構成を比較してみると、ksbysample-webapp-lending-1.1.0-RELEASE.jar の時は解凍したディレクトリの直下に ksbysample ディレクトリが出来ていたのに対し、ksbysample-webapp-lending-1.4.6-RELEASE.jar では BOOT-INF\classes の下に ksbysample ディレクトリが出来ていました。jar ファイル内のディレクトリ構成が変更されていました。

Spring Boot 1.4 Release Notes の Executable jar layout に確かにレイアウトが変更されたことの記述がありました。

for (final ClassPath.ClassInfo info : ClassPath.from(loader).getTopLevelClasses()) { ... } のコードを追加して取得されるクラス一覧を出力してみると、BOOT-INF.classes. の文字列が追加されています。

@Component("vh")
public class ValuesHelper {

    private final Map<String, String> valuesObjList;

    private ValuesHelper() throws IOException {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        for (final ClassPath.ClassInfo info : ClassPath.from(loader).getTopLevelClasses()) {
            System.out.println("★★★ " + info.getName());
        }
        ..........
★★★ BOOT-INF.classes.ksbysample.webapp.lending.values.lendingapp.LendingAppStatusValues

分かったことをまとめると、以下のようになります。

  • jar ファイルから起動された場合、com.google.common.reflect.ClassPath#getTopLevelClassesRecursive でクラス一覧を取得するにはパッケージの前に BOOT-INF.classes. を付ける必要がある。com.google.common.reflect.ClassPath#getTopLevelClassesRecursive ではなく Spring で特定パッケージ配下のクラス一覧を取得する方法がありそうな気がするが、全く分かりませんでした。。。
  • IntelliJ IDEA から bootRun で起動する場合には、BOOT-INF.classes. を付ける必要はなし。
  • Class.forName(...) を呼び出す場合には BOOT-INF.classes. は常に不要である。

設定ファイルに設定項目を追加し jar ファイルから起動された時だけ BOOT-INF.classes. が付くようにして、その設定項目を ksbysample.webapp.lending.values.ValuesHelper のコンストラクタの処理で使用するようにします。

application.properties を リンク先のその2の内容 に変更します。

application-product.properties を リンク先の内容 に変更します。

src/main/java/ksbysample/webapp/lending/values/ValuesHelper.javaリンク先の内容 に変更します。

clean タスク実行 → Rebuild Project 実行 → build タスク実行し、作成された ksbysample-webapp-lending-1.4.6-RELEASE.jar を C:\project-springboot\ksbysample-webapp-lending\build\libs の下にコピーします。

次回は。。。

長くなったので、一旦ここで終了し次へ続きます。

ソースコード

webapp_startup.bat

@echo on

setlocal
set JAVA_HOME=C:\Java\jdk1.8.0_131
set PATH=%JAVA_HOME%\bin;%PATH%
set WEBAPP_HOME=C:\webapps\ksbysample-webapp-lending
set WEBAPP_JAR=ksbysample-webapp-lending-1.4.6-RELEASE.jar

cd /d %WEBAPP_HOME%
java -server ^
     -Xms1024m -Xmx1024m ^
     -XX:MaxMetaspaceSize=384m ^
     -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled ^
     -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=75 ^
     -XX:+ScavengeBeforeFullGC -XX:+CMSScavengeBeforeRemark ^
     -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps ^
     -Xloggc:%WEBAPP_HOME%/logs/gc.log ^
     -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M ^
     -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=%WEBAPP_HOME%/logs/%date:~0,4%%date:~5,2%%date:~8,2%.hprof ^
     -XX:ErrorFile=%WEBAPP_HOME%/logs/hs_err_pid_%p.log ^
     -Dsun.net.inetaddr.ttl=100 ^
     -Dcom.sun.management.jmxremote ^
     -Dcom.sun.management.jmxremote.port=7900 ^
     -Dcom.sun.management.jmxremote.ssl=false ^
     -Dcom.sun.management.jmxremote.authenticate=false ^
     -Dspring.profiles.active=product ^
     -jar lib\%WEBAPP_JAR%
  • set JAVA_HOME=C:\Java\jdk1.8.0_121set JAVA_HOME=C:\Java\jdk1.8.0_131 に変更します。
  • set WEBAPP_JAR=ksbysample-webapp-lending-1.4.5-RELEASE.jarset WEBAPP_JAR=ksbysample-webapp-lending-1.4.6-RELEASE.jar に変更します。
  • Windows の場合、date コマンドがないため今の記述だとファイル名が日付にならないことに気付いたので、-XX:HeapDumpPath=%WEBAPP_HOME%/logs/%date:~0,4%%date:~5,2%%date:~8,2%.hprof に変更します。

CalilApiService.java

    private <T> ResponseEntity<T> getForEntityWithRetry(RestTemplate restTemplate, String url
            , Class<T> responseType, Object... uriVariables) {
        ResponseEntity<T> response = this.simpleRetryTemplate.execute(context -> {
            if (context.getRetryCount() > 0) {
                logger.info("★★★ リトライ回数 = " + context.getRetryCount());
            }
            ResponseEntity<T> innerResponse = restTemplate.getForEntity(url, responseType, uriVariables);
            return innerResponse;
        });

        return response;
    }

    private <T> ResponseEntity<T> getForEntityWithRetry(RestTemplate restTemplate, String url
            , Class<T> responseType, Map<String, ?> uriVariables) {
        ResponseEntity<T> response = this.simpleRetryTemplate.execute(context -> {
            if (context.getRetryCount() > 0) {
                logger.info("★★★ リトライ回数 = " + context.getRetryCount());
            }
            ResponseEntity<T> innerResponse = restTemplate.getForEntity(url, responseType, uriVariables);
            return innerResponse;
        });

        return response;
    }
  • private <T> ResponseEntity<T> getForEntityWithRetry(RestTemplate restTemplate, String url, Class<T> responseType, Map<String, ?> uriVariables) メソッドを追加します。

lombok.config

lombok.anyConstructor.suppressConstructorProperties = true

application.properties

■その1

..........

spring.freemarker.cache=true
spring.freemarker.settings.number_format=computer
spring.freemarker.charset=UTF-8
spring.freemarker.enabled=false
spring.freemarker.prefer-file-system-access=false
  • spring.freemarker.settings.number_format=computer を追加します。

■その2

..........

spring.freemarker.cache=true
spring.freemarker.settings.number_format=computer
spring.freemarker.charset=UTF-8
spring.freemarker.enabled=false
spring.freemarker.prefer-file-system-access=false

valueshelper.classpath.prefix=
  • valueshelper.classpath.prefix= を追加します。

application-product.properties

..........

spring.redis.sentinel.master=mymaster
spring.redis.sentinel.nodes=localhost:6381,localhost:6382,localhost:6383

valueshelper.classpath.prefix=BOOT-INF.classes.
  • valueshelper.classpath.prefix=BOOT-INF.classes. を追加します。

ValuesHelper.java

@Component("vh")
public class ValuesHelper {

    private final Map<String, String> valuesObjList;

    private ValuesHelper(@Value("${valueshelper.classpath.prefix:}") String classpathPrefix) throws IOException {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        valuesObjList = ClassPath.from(loader)
                .getTopLevelClassesRecursive(classpathPrefix + this.getClass().getPackage().getName())
                .stream()
                .filter(classInfo -> {
                    try {
                        Class<?> clazz = Class.forName(classInfo.getName().replace(classpathPrefix, ""));
                        return !clazz.equals(Values.class) && Values.class.isAssignableFrom(clazz);
                    } catch (ClassNotFoundException e) {
                        throw new RuntimeException(e);
                    }
                })
                .collect(Collectors.toMap(classInfo -> classInfo.getSimpleName()
                        , classInfo -> classInfo.getName().replace(classpathPrefix, "")));
    }
  • コンストラクタの引数に @Value("${valueshelper.classpath.prefix:}") String classpathPrefix を追加します。
  • .getTopLevelClassesRecursive(...) の引数を this.getClass().getPackage().getName()classpathPrefix + this.getClass().getPackage().getName() に変更します。
  • Class.forName(classInfo.getName())Class.forName(classInfo.getName().replace(classpathPrefix, "")) に変更します。
  • .collect(Collectors.toMap(ClassPath.ClassInfo::getSimpleName, ClassPath.ClassInfo::getName));.collect(Collectors.toMap(classInfo -> classInfo.getSimpleName(), classInfo -> classInfo.getName().replace(classpathPrefix, ""))); に変更します。

履歴

2017/05/06
初版発行。