かんがるーさんの日記

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

Spring Boot + Spring Integration でいろいろ試してみる ( その21 )( MessageChannel に Redis を使用する2 )

概要

記事一覧はこちらです。

参照したサイト・書籍

目次

  1. org.springframework.integration.redis package の Diagram を作成してみる
  2. Redis へ保存する時のフォーマットを JSON にしてみる
  3. 1プロセス内で受信・出力側をマルチスレッドにしてみる
  4. メモ書き
    1. build.gradle に記述する compile("org.springframework.integration:spring-integration-...") に何があるかは、どこを見ると分かるのか?
    2. Spring IO Platform の BOM の後に Spring Cloud の BOM を記述すると Gradle の checkstyle plugin に適用される Guava のバージョンが 21.0 にならない

手順

org.springframework.integration.redis package の Diagram を作成してみる

Spring Integration Reference Manual - 24. Redis Support の説明は XML ベースで、Spring Integration が Redis 用に用意しているクラスがよく分からないので、IntelliJ IDEA で org.springframework.integration.redis package の Diagram を生成してみます。

RedisChannelMessageStore クラスのソースを表示した後、import 文の “redis” の部分にカーソルを移動してから、コンテキストメニューを表示して「Diagrams」-「Show Diagram…」を選択します。

f:id:ksby:20170319173618p:plain

Diagram が作成されますが package しか表示されていないので展開します。Ctrl+A で全て選択した後、コンテキストメニューを表示して「Expand Nodes」を選択します。

f:id:ksby:20170319180552p:plain

クラスやインターフェースが表示されます。

f:id:ksby:20170319181053p:plain f:id:ksby:20170319181201p:plain

Redis へ保存する時のフォーマットを JSON にしてみる

Redis に保存される時のフォーマットが今のままでは見にくかったので、別のフォーマットにしてみます。

org.springframework.integration.redis.store.RedisChannelMessageStore のソースを見ると、以下のように実装されていました。

  • Key, Value で異なる Serializer がセットされている。
  • Key は RedisTemplate#setKeySerializer で StringRedisSerializer クラスがセットされている。ただし RedisChannelMessageStore には KeySerializer を変更するメソッドは用意されていない。
  • Value は RedisTemplate#setValueSerializer で JdkSerializationRedisSerializer クラスがセットされている。RedisChannelMessageStore にも RedisChannelMessageStore#setValueSerializer が用意されている。

~RedisSerializer クラスが何種類かあるようなので、org.springframework.data.redis.serializer package を Diagram で表示してみます。

f:id:ksby:20170319183944p:plain

Jackson2JsonRedisSerializer というクラスがあったので、これを使用して Value を JSON フォーマットで保存してみます。

src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。

    @Bean
    public RedisChannelMessageStore redisMessageStore() {
        RedisChannelMessageStore messageStore = new RedisChannelMessageStore(this.redisConnectionFactory);
        messageStore.setValueSerializer(new Jackson2JsonRedisSerializer<>(SampleData.class));
        return messageStore;
    }

clean タスク → Rebuild Project → build タスクを実行した後 bootRun を実行すると、以下の例外が出力されました。

2017-03-19 19:38:10.014  INFO [redisqueue,,,] 2864 --- [ask-scheduler-2] o.s.integration.handler.LoggingHandler   : GenericMessage [payload=FlowConfig.SampleData(id=0, createDateTime=2017-03-19T19:38:09.963, message=idGenerator = 02017/03/19 19:38:09 に生成されました), headers={id=439e6e05-e1e5-7db0-f2e1-70e3f0d78b80, timestamp=1489919890012}]
2017-03-19 19:38:10.296 ERROR [redisqueue,de805a358b6b6e2b,de805a358b6b6e2b,true] 2864 --- [ask-scheduler-1] o.s.integration.handler.LoggingHandler   : org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Unrecognized field "payload" (class ksbysample.eipapp.redisqueue.FlowConfig$SampleData), not marked as ignorable (3 known properties: "id", "createDateTime", "message"])
 at [Source: [B@59d3beef; line: 1, column: 1374] (through reference chain: ksbysample.eipapp.redisqueue.FlowConfig$SampleData["payload"]); nested exception is com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "payload" (class ksbysample.eipapp.redisqueue.FlowConfig$SampleData), not marked as ignorable (3 known properties: "id", "createDateTime", "message"])
 at [Source: [B@59d3beef; line: 1, column: 1374] (through reference chain: ksbysample.eipapp.redisqueue.FlowConfig$SampleData["payload"])
    at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.deserialize(Jackson2JsonRedisSerializer.java:73)
    at org.springframework.data.redis.core.AbstractOperations.deserializeValue(AbstractOperations.java:315)
    at org.springframework.data.redis.core.AbstractOperations$ValueDeserializingRedisCallback.doInRedis(AbstractOperations.java:55)
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:204)
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:166)
    at org.springframework.data.redis.core.AbstractOperations.execute(AbstractOperations.java:88)
    at org.springframework.data.redis.core.DefaultListOperations.rightPop(DefaultListOperations.java:161)
    at org.springframework.data.redis.core.DefaultBoundListOperations.rightPop(DefaultBoundListOperations.java:88)
    at org.springframework.integration.redis.store.RedisChannelMessageStore.pollMessageFromGroup(RedisChannelMessageStore.java:138)
    at org.springframework.integration.store.MessageGroupQueue.doPoll(MessageGroupQueue.java:318)
    at org.springframework.integration.store.MessageGroupQueue.poll(MessageGroupQueue.java:162)
    at org.springframework.integration.store.MessageGroupQueue.poll(MessageGroupQueue.java:49)
    at org.springframework.integration.channel.QueueChannel.doReceive(QueueChannel.java:116)
    at org.springframework.integration.channel.AbstractPollableChannel.receive(AbstractPollableChannel.java:105)
    at org.springframework.integration.endpoint.PollingConsumer.receiveMessage(PollingConsumer.java:192)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint.doPoll(AbstractPollingEndpoint.java:245)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint.access$000(AbstractPollingEndpoint.java:58)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint$1.call(AbstractPollingEndpoint.java:190)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint$1.call(AbstractPollingEndpoint.java:186)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint$Poller$1.run(AbstractPollingEndpoint.java:353)
    at org.springframework.integration.util.ErrorHandlingTaskExecutor$1.run(ErrorHandlingTaskExecutor.java:55)
    at org.springframework.core.task.SyncTaskExecutor.execute(SyncTaskExecutor.java:50)
    at org.springframework.integration.util.ErrorHandlingTaskExecutor.execute(ErrorHandlingTaskExecutor.java:51)
    at org.springframework.integration.endpoint.AbstractPollingEndpoint$Poller.run(AbstractPollingEndpoint.java:344)
    at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54)
    at org.springframework.scheduling.concurrent.ReschedulingRunnable.run(ReschedulingRunnable.java:81)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$201(ScheduledThreadPoolExecutor.java:180)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:293)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)
Caused by: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "payload" (class ksbysample.eipapp.redisqueue.FlowConfig$SampleData), not marked as ignorable (3 known properties: "id", "createDateTime", "message"])
 at [Source: [B@59d3beef; line: 1, column: 1374] (through reference chain: ksbysample.eipapp.redisqueue.FlowConfig$SampleData["payload"])
    at com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException.from(UnrecognizedPropertyException.java:62)
    at com.fasterxml.jackson.databind.DeserializationContext.handleUnknownProperty(DeserializationContext.java:834)
    at com.fasterxml.jackson.databind.deser.std.StdDeserializer.handleUnknownProperty(StdDeserializer.java:1093)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownProperty(BeanDeserializerBase.java:1485)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownProperties(BeanDeserializerBase.java:1439)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:487)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1198)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:314)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:148)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3798)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2967)
    at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.deserialize(Jackson2JsonRedisSerializer.java:71)
    ... 32 more

Caused by: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "payload" (class ksbysample.eipapp.redisqueue.FlowConfig$SampleData), not marked as ignorable (3 known properties: "id", "createDateTime", "message"]) というエラーメッセージが出ています。

Redis を見ると Message は入っていました。

f:id:ksby:20170319194436p:plain

Redis に格納されている Message を以下の操作を行って整形してみます。

  • コマンドプロンプトから文字列をコピーする。
  • 不要な改行コードを削除して 1行にする。
  • \"" へ置換する。
  • 一番最初と最後の " を削除する。
  • IntelliJ IDEA で拡張子が .json のファイルを作成し、そこに編集した文字列をペーストした後、Ctrl+Alt+L を押して整形する。
{
  "payload": {
    "id": 58,
    "createDateTime": {
      "dayOfMonth": 19,
      "dayOfWeek": "SUNDAY",
      "dayOfYear": 78,
      "month": "MARCH",
      "monthValue": 3,
      "year": 2017,
      "hour": 19,
      "minute": 38,
      "nano": 243000000,
      "second": 22,
      "chronology": {
        "id": "ISO",
        "calendarType": "iso8601"
      }
    },
    "message": "idGenerator = 58 \xe3\x81\xaf 2017/03/19 19:38:22 \xe3\x81\xab\xe7\x94\x9f\xe6\x88\x90\xe3\x81\x95\xe3\x82\x8c\xe3\x81\xbe\xe3\x81\x97\xe3\x81\x9f"
  },
  "headers": {
    "X-B3-ParentSpanId": "59580c3007f1dfc4",
    "X-Message-Sent": true,
    "messageSent": true,
    "spanName": "message:redisQueue",
    "spanTraceId": "59580c3007f1dfc4",
    "spanId": "ed3685c62127a187",
    "spanParentSpanId": "59580c3007f1dfc4",
    "X-Span-Name": "message:redisQueue",
    "X-B3-SpanId": "ed3685c62127a187",
    "currentSpan": {
      "begin": 1489919902245,
      "name": "message:redisQueue",
      "traceId": 6437909067757379524,
      "parents": [
        6437909067757379524
      ],
      "spanId": -1353747551971991161,
      "exportable": true,
      "tags": {
        "message/payload-type": "ksbysample.eipapp.redisqueue.FlowConfig.SampleData"
      },
      "logs": [
        {
          "timestamp": 1489919902245,
          "event": "sr"
        }
      ]
    },
    "X-B3-Sampled": "1",
    "X-B3-TraceId": "59580c3007f1dfc4",
    "id": "45f64e56-471e-a2c2-2fe5-09bffc92670a",
    "X-Current-Span": {
      "begin": 1489919902245,
      "name": "message:redisQueue",
      "traceId": 6437909067757379524,
      "parents": [
        6437909067757379524
      ],
      "spanId": -1353747551971991161,
      "exportable": true,
      "tags": {
        "message/payload-type": "ksbysample.eipapp.redisqueue.FlowConfig.SampleData"
      },
      "logs": [
        {
          "timestamp": 1489919902245,
          "event": "sr"
        }
      ]
    },
    "spanSampled": "1",
    "timestamp": 1489919902245
  }
}
  • Redis には入っていることから送信はできるが受信ができていない。
  • JdkSerializationRedisSerializer クラスの場合には問題なかったが、Jackson2JsonRedisSerializer クラスに変えただけだとこの問題が発生する。

ということでしょうか。。。

そしていろいろ調べた結果、分かったことは。。。簡単にやるのは無理!ということでした。

  • Jackson2JsonRedisSerializer クラスは Spring Data Redis の機能として用意されているクラスであり、Spring Integration の Redis Support 用に作られたものではない。
  • serializer に Jackson2JsonRedisSerializer クラスを指定した場合、Message の serialize は行われるが deserialize で引っかかる。いろいろ試してみたら payload はまだ何とかなるが、MessageHeaders クラスの deserialize がうまくいかない。Jackson 用に専用の converter か何かを作り込めば何とかなるのかもしれないが、そこまではやりたくないな。。。

とりあえず現時点での結論は、Redis に Message を入れる時はデフォルトでセットされている JdkSerializationRedisSerializer クラスをそのまま使いましょう、ということにします。

1プロセス内で受信・出力側をマルチスレッドにしてみる

マルチプロセスでの受信・出力は問題なかったので、今度は1つのプロセス内でマルチスレッドで受信・出力してみます。

上で org.springframework.integration.redis package の Diagram を作成した時に RedisQueueMessageDrivenEndpoint クラスというのを見つけました。コードを見てみると MessageProducerSupport の継承クラスでしたので、MessageProducer として使えそうです。今回はこれを使用してみます。

src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。

@Slf4j
@Configuration
public class FlowConfig {

    private static final String REDIS_LISTS_NAME = "REDIS_QUEUE";

    ..........

    @Bean
    public QueueChannel redisQueue() {
        return MessageChannels.queue("redisQueue", redisMessageStore(), REDIS_LISTS_NAME).get();
    }

    @Bean
    public MessageProducerSupport redisMessageProducer() {
        RedisQueueMessageDrivenEndpoint messageProducer
                = new RedisQueueMessageDrivenEndpoint(REDIS_LISTS_NAME, this.redisConnectionFactory);
        messageProducer.setTaskExecutor(Executors.newFixedThreadPool(5));
        messageProducer.setRecoveryInterval(1000);
        // setExpectMessage(true) を入れないと Redis Lists から取得したデータ ( headers + SampleData オブジェクトの payload )
        // が全て payload にセットされる
        messageProducer.setExpectMessage(true);
        return messageProducer;
    }

    ..........

    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisMessageProducer())
                .<SampleData>handle((p, h) -> {
                    log.info("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }

}
  • クラスに @Slf4j アノテーションを付加します。
  • redisQueue メソッドと redisMessageProducer メソッドで同じ MessageChannel を指定するので、private static final String REDIS_LISTS_NAME = "REDIS_QUEUE"; を追加します。
  • redisQueue メソッド内で "REDIS_QUEUE"REDIS_LISTS_NAME へ変更します。
  • redisMessageProducer メソッドを追加します。メソッド内で messageProducer.setTaskExecutor(Executors.newFixedThreadPool(5)); を記述して5スレッドでメッセージを受信するようにします。
  • printFlow メソッドの以下の点を変更します。
    • .from(redisQueue()).from(redisMessageProducer()) へ変更します。
    • .bridge(e -> e.poller(Pollers.fixedDelay(1000))) を削除します。
    • System.out.println("★★★ " + p.getMessage());log.info("★★★ " + p.getMessage()); へ変更し、スレッド名がログに出力されるようにします。

clean タスク → Rebuild Project → build タスクを実行した後 bootRun を実行します。

f:id:ksby:20170320081003p:plain

Message は受信できていますが、全然マルチスレッドになっていませんね。。。 全て [pool-1-thread-1] でした。

この後送受信の時間を変更したりもしてみましたが、結果は同じでマルチスレッドにはなりませんでした。RedisQueueMessageDrivenEndpoint#setTaskExecutor に Executors.newFixedThreadPool(5) とかをセットしてもマルチスレッドにはならないようです。

src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更すれば、Message の受信は1スレッドですが、その後の処理はマルチスレッドにはなります。

    @Bean
    public MessageProducerSupport redisMessageProducer() {
        RedisQueueMessageDrivenEndpoint messageProducer
                = new RedisQueueMessageDrivenEndpoint(REDIS_LISTS_NAME, this.redisConnectionFactory);
        messageProducer.setRecoveryInterval(1000);
        // setExpectMessage(true) を入れないと Redis Lists から取得したデータ ( headers + SampleData オブジェクトの payload )
        // が全て payload にセットされる
        messageProducer.setExpectMessage(true);
        return messageProducer;
    }

    ..........

    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisMessageProducer())
                .log()
                .channel(c -> c.executor(Executors.newFixedThreadPool(5)))
                .<SampleData>handle((p, h) -> {
                    log.info("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }
  • redisMessageProducer メソッドから messageProducer.setTaskExecutor(Executors.newFixedThreadPool(5)); を削除します。
  • printFlow メソッドの以下の点を変更します。
    • .from(...) の直後に .log().channel(c -> c.executor(Executors.newFixedThreadPool(5))) を追加します。

bootRun を実行してみます。

f:id:ksby:20170320082154p:plain

メッセージの受信は全て [hannel-adapter1] ですが、その後の処理は全て異なるスレッドになりました。

MessageChannel からの受信処理をマルチスレッドにする方法が調べてもよく分かりませんでした。上の結果も当初の想定とは異なりますが、今回はこれで終了にします。

メモ書き

build.gradle に記述する compile("org.springframework.integration:spring-integration-...") に何があるかは、どこを見ると分かるのか?

どこかに一覧があるのか探してみたところ、spring-projects/spring-integration-java-dslbuild.gradle の dependencies に記載がありました。通常はこれを見るのが早そうです。

また、この build.gradle の dependencies ですが、[ ... ].each { compile( ... ) } という書き方が出来ることを初めて知りました。それ以外にも、

  • apply from: "${rootProject.projectDir}/publish-maven.gradle" という記述があり、publish-maven.gradle も見た感じでは Maven への公開を Gradle で書く方法のようです。
  • Spring Integration の 8.7 Scripting support の関連だと思いますが、org.jruby:jrubyorg.python:jython-standalone が書いてありました。これを入れると Ruty や Python で書いたものが実行できるのでしょうか?

Spring IO Platform の BOM の後に Spring Cloud の BOM を記述すると Gradle の checkstyle plugin に適用される Guava のバージョンが 21.0 にならない

最初 build.gradle の dependencyManagement に以下のように記述していたのですが、

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Athens-SR4") {
            bomProperty 'commons-lang3.version', '3.5'
            bomProperty 'guava.version', '21.0'
        }
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:Camden.RELEASE")
    }
}

この状態で gradlew dependencies を実行してみたところ、checkstyle plugin の Guava のバージョンが 21.0 ではなく 18.0 になっていました。bomProperty 'guava.version', '21.0' の変更がなぜか反映されなくなっています。

checkstyle - The Checkstyle libraries to be used for this project.
\--- com.puppycrawl.tools:checkstyle:7.6
     +--- antlr:antlr:2.7.7
     +--- org.antlr:antlr4-runtime:4.6
     +--- commons-beanutils:commons-beanutils:1.9.3
     |    \--- commons-collections:commons-collections:3.2.2
     +--- commons-cli:commons-cli:1.3.1
     \--- com.google.guava:guava:19.0 -> 18.0

試しに BOM の順番を以下のように変更したところ、

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:Camden.RELEASE")
        mavenBom("io.spring.platform:platform-bom:Athens-SR4") {
            bomProperty 'commons-lang3.version', '3.5'
            bomProperty 'guava.version', '21.0'
        }
    }
}

checkstyle plugin の Guava のバージョンが 21.0 になりました。

checkstyle - The Checkstyle libraries to be used for this project.
\--- com.puppycrawl.tools:checkstyle:7.6
     +--- antlr:antlr:2.7.7
     +--- org.antlr:antlr4-runtime:4.6
     +--- commons-beanutils:commons-beanutils:1.9.3
     |    \--- commons-collections:commons-collections:3.2.2
     +--- commons-cli:commons-cli:1.3.1
     \--- com.google.guava:guava:19.0 -> 21.0

そもそも BOM を複数書いているサンプルを見たことがなく、BOM を書く順番ってあるのかな?とは思っていましたが、あるようです。

ソースコード

履歴

2017/03/20
初版発行。

Spring Boot + Spring Integration でいろいろ試してみる ( その20 )( MessageChannel に Redis を使用する )

概要

記事一覧はこちらです。

参照したサイト・書籍

  1. Spring Integration Reference Manual - 24. Redis Support
    http://docs.spring.io/spring-integration/reference/html/redis.html

  2. RDB技術者のためのNoSQLガイド

    RDB技術者のためのNoSQLガイド

    RDB技術者のためのNoSQLガイド

  3. logback+MDCでWebアプリのリクエスト内容を簡単にログに出力する方法
    http://qiita.com/namutaka/items/c35c437b7246c5e4d729

目次

  1. ksbysample-eipapp-redisqueue プロジェクトを作成する
  2. サンプルの Flow を作成する
  3. 動作確認
  4. Deprecated trace headers detected. Please upgrade Sleuth to 1.1 or start sending headers present in the TraceMessageHeaders class というログが出力される理由とは?
  5. X-B3-TraceId, X-B3-SpanId がログに出力されない理由とは?
  6. 動作確認2
  7. 動作確認3 ( Message の送信側と受信・出力側を別プロセスにしてみる )
  8. 続く。。。

手順

ksbysample-eipapp-redisqueue プロジェクトを作成する

  1. IntelliJ IDEA で Gradle プロジェクトを作成し、build.gradle を リンク先の内容 に変更します。

  2. ksbysample-eipapp-wiretap プロジェクトのルート直下に config/checkstyle, config/findbugs ディレクトリを作成します。

  3. config/checkstyle の下に ksbysample-eipapp-messaginggateway プロジェクトの config/checkstyle の下にある google_checks.xml をコピーします。

  4. config/findbugs の下に findbugs-exclude.xml を新規作成し、リンク先の内容 の内容に変更します。

  5. src/main/java の下に ksbysample.eipapp.redisqueue パッケージを作成します。

  6. src/main/java/ksbysample/eipapp/redisqueue の下に Application.java を作成し、リンク先の内容 を記述します。

  7. src/main/resources の下に application.properties を作成し、リンク先の内容 を記述します。

  8. src/main/resources の下に logback-spring.xml を作成し、リンク先のその1の内容 を記述します。

サンプルの Flow を作成する

  1. src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を新規作成し、リンク先の内容 を記述します。

動作確認

  1. まずは clean タスク → Rebuild Project → build タスクを実行して正常終了することを確認します。

    f:id:ksby:20170318091131p:plain

  2. http://zipkin.io/pages/quickstart.html から最新の ZipkinServer ( zipkin-server-1.21.0-exec.jar ) をダウンロードして C:\zipkin に保存した後、起動します。今回は起動するだけです。

  3. redis-cli コマンドで Redis の中にデータが入っていないことを確認します。

    f:id:ksby:20170318092849p:plain

  4. bootRun を実行して ksbysample-eipapp-redisqueue を起動します。

    起動後 sampleDataMessageSource() から Message を受信して出力するところまで動いてはいるようですが、その確認の前に以下2点の問題が出ていることに気づきました。

    • Deprecated trace headers detected. Please upgrade Sleuth to 1.1 or start sending headers present in the TraceMessageHeaders class という WARN ログが出力されています。
    • traceId, spanId がログに出力されていません。

    f:id:ksby:20170318133056p:plain

    先にこの2点の問題を解消します。

Deprecated trace headers detected. Please upgrade Sleuth to 1.1 or start sending headers present in the TraceMessageHeaders class というログが出力される理由とは?

Web で検索すると spring-cloud/spring-cloud-sleuth の Deprecated trace headers #467 の Issue がヒットしました。いろいろ書かれていますが、WARN なので気にしなくてよいようです。ログを出さないようにします。

src/main/resources/logback-spring.xmlリンク先のその2の内容 に変更します。

clean タスク → Rebuild Project → build タスクを実行した後、bootRun を実行すると今度はログは出ませんでした。

f:id:ksby:20170318161134p:plain

X-B3-TraceId, X-B3-SpanId がログに出力されない理由とは?

logback+MDCでWebアプリのリクエスト内容を簡単にログに出力する方法 の記事を見つけました。%X{...} は MDC のデータを出力するためのもののようなので、%X{X-B3-TraceId:-} で値が出力されないということは MDC に X-B3-TraceId がセットされていないということでしょうか?

MDC の値を出力してみます。src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。

    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisQueue())
                .bridge(e -> e.poller(Pollers.fixedDelay(30000)))
                .<SampleData>handle((p, h) -> {
                    // MDC のデータを出力する
                    Map<String, String> mdcMap = MDC.getCopyOfContextMap();
                    mdcMap.entrySet().forEach(entry -> System.out.println("[MDC ] " + entry.getKey() + " = " + entry.getValue()));

                    System.out.println("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }

bootRun を実行して MDC にセットされているデータを出力してみましたが、X-B3-TraceId, X-B3-SpanId はセットされていますね。。。

f:id:ksby:20170318184923p:plain

.<SampleData>handle((p, h) -> {...} の中で log.info(...) でログを出力してみます。src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。

@Slf4j
@Configuration
public class FlowConfig {

    ..........

    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisQueue())
                .bridge(e -> e.poller(Pollers.fixedDelay(30000)))
                .<SampleData>handle((p, h) -> {
                    log.info("●●●");

                    System.out.println("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }

}

bootRun を実行すると、今度は X-B3-TraceId, X-B3-SpanId が出力されました。

f:id:ksby:20170318200509p:plain

MessageSource からデータを取得した時 ( GenericMessage [payload=... のログが出力される時 ) から X-B3-TraceId, X-B3-SpanId が出力されるものと思い込んでいましたが、このタイミングではまだ出力されないようです。FlowConfig#printFlow の方でもログを出力していなかったので、X-B3-TraceId, X-B3-SpanId が出力されないのは単に出力されない実装になっていたからでした。

今回は X-B3-TraceId, X-B3-SpanId を確認する必要はないので、このまま進めます。上で修正した内容は元に戻します。

動作確認2

動作確認に戻ります。

  1. redis-cli コマンドで Redis の中にデータが入っていないことを確認します。

    f:id:ksby:20170318203012p:plain

  2. bootRun を実行して ksbysample-eipapp-redisqueue を起動します。Redis にどのようにデータが格納されるのか確認したいので、Message が1~2個 Redis に格納されたと思われるタイミングで ksbysample-eipapp-redisqueue を停止します。

    f:id:ksby:20170318203439p:plain

    Message が2個 Redis に入ったタイミングで停止しました。Redis の状態を確認してみます。

    f:id:ksby:20170318204143p:plain

    “REDIS_QUEUE” という名称の list が作成されており、データが2件存在します。2件のデータを表示してみます。

    f:id:ksby:20170318212143p:plain

    Message が header, payload 全て入っているようですが、これでは内容がほとんど分かりませんね。。。

  3. 再び bootRun を実行して ksbysample-eipapp-redisqueue を起動します。Redis にメッセージが2個入っている状態で起動するので、起動時にデフォルトで出力される最初の1個と合わせて計3個出力されるはずです。

    f:id:ksby:20170318212911p:plain

    Redis に残っていた ID = 1, 2 のデータと、今回起動時に追加された ID = 0 のデータの計3個出力されました。

  4. Redis に残っているデータを削除します。

    f:id:ksby:20170318213508p:plain

動作確認3 ( Message の送信側と受信・出力側を別プロセスにしてみる )

Message を Redis に入れるようにしたので、Message の送信側と受信・出力側を別プロセスにしても動作するはずです。試してみます。

最初に jar ファイルを入れる C:\eipapp\ksbysample-eipapp-redisqueue ディレクトリを作成します。

次に送信側の jar ファイルを作成します。build.gradle を以下のように変更します。

group 'ksbysample'
version '1.0.0-RELEASE-send'

src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。FlowConfig#printFlow をコメントアウトし、送信タイミングを 200ミリ秒へ、受信タイミングを 1000ミリ秒へ変更します。

    @Bean
    public IntegrationFlow mainFlow() {
        return IntegrationFlows
                .from(sampleDataMessageSource()
                        , e -> e.poller(Pollers.fixedDelay(200)))
                .log()
                .channel(redisQueue())
                .get();
    }

//    @Bean
//    public IntegrationFlow printFlow() {
//        return IntegrationFlows
//                .from(redisQueue())
//                .bridge(e -> e.poller(Pollers.fixedDelay(1000)))
//                .<SampleData>handle((p, h) -> {
//                    System.out.println("★★★ " + p.getMessage());
//                    return null;
//                })
//                .get();
//    }

clean タスク → Rebuild Project → build タスクを実行して ksbysample-eipapp-redisqueue-1.0.0-RELEASE-send.jar を作成した後、C:\eipapp\ksbysample-eipapp-redisqueue へコピーします。

受信・出力側の jar ファイルを作成します。build.gradle を以下のように変更します。

group 'ksbysample'
version '1.0.0-RELEASE-recv'

src/main/java/ksbysample/eipapp/redisqueue/FlowConfig.java を以下のように変更します。今度は FlowConfig#mainFlow をコメントアウトします。

//    @Bean
//    public IntegrationFlow mainFlow() {
//        return IntegrationFlows
//                .from(sampleDataMessageSource()
//                        , e -> e.poller(Pollers.fixedDelay(200)))
//                .log()
//                .channel(redisQueue())
//                .get();
//    }

    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisQueue())
                .bridge(e -> e.poller(Pollers.fixedDelay(1000)))
                .<SampleData>handle((p, h) -> {
                    System.out.println("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }

clean タスク → Rebuild Project → build タスクを実行して ksbysample-eipapp-redisqueue-1.0.0-RELEASE-recv.jar を作成した後、C:\eipapp\ksbysample-eipapp-redisqueue へコピーします。

実行してみます。コマンドプロンプトを3つ開き、1つは送信側の jar ファイルを起動し、残りの2つは受信・出力側の jar ファイルを起動します。

f:id:ksby:20170318222520p:plain

一定数出力されたところで送信側の jar ファイルを止めます。結果を見ると、

  • Message は重複して受信・出力されることはありませんでした。片方が受信した Message はもう片方では受信されません。
  • 標準出力に出力するだけの処理で時間がほとんどかからないので、Message の受信は一方のプロセスに偏ってしまいました。

f:id:ksby:20170318223243p:plain

続く。。。

もう少しいろいろ試してみたいことがあるので、続きます。

ソースコード

build.gradle

group 'ksbysample'
version '1.0.0-RELEASE'

buildscript {
    ext {
        springBootVersion = '1.4.5.RELEASE'
    }
    repositories {
        mavenCentral()
        maven { url "http://repo.spring.io/repo/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("io.spring.gradle:dependency-management-plugin:0.6.1.RELEASE")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.9")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'groovy'
apply plugin: 'net.ltgt.errorprone'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'

sourceCompatibility = 1.8
targetCompatibility = 1.8

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += ['-Xep:RemoveUnusedImports:WARN']

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = '7.6'
    sourceSets = [project.sourceSets.main]
}

findbugs {
    toolVersion = '3.0.1'
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    effort = "max"
    excludeFilter = file("${rootProject.projectDir}/config/findbugs/findbugs-exclude.xml")
}

tasks.withType(FindBugs) {
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

repositories {
    mavenCentral()
    maven { url "http://repo.spring.io/repo/" }
}

dependencyManagement {
    imports {
        // Spring Could の BOM を先に書かないと Spring IO Platform の bomProperty でのバージョン変更が適用されない
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:Camden.RELEASE")
        mavenBom("io.spring.platform:platform-bom:Athens-SR4") {
            bomProperty 'commons-lang3.version', '3.5'
            bomProperty 'guava.version', '21.0'
        }
    }
}

dependencies {
    def spockVersion = "1.1-groovy-2.4-rc-3"
    def lombokVersion = "1.16.12"
    def errorproneVersion = '2.0.15'

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    compile("org.springframework.boot:spring-boot-starter-integration")
    compile("org.springframework.boot:spring-boot-starter-data-redis")
    compile("org.springframework.integration:spring-integration-redis")
    compile("org.codehaus.janino:janino")
    compile("org.apache.commons:commons-lang3")
    compile("com.google.guava:guava")
    testCompile("org.springframework.boot:spring-boot-starter-test")

    // org.springframework.cloud:spring-cloud-dependencies によりバージョン番号が自動で設定されるもの
    // http://projects.spring.io/spring-cloud/ の「Release Trains」参照
    compile("org.springframework.cloud:spring-cloud-starter-zipkin") {
        exclude module: 'spring-boot-starter-web'
    }

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    compile("org.springframework.integration:spring-integration-java-dsl:1.2.1.RELEASE")
    testCompile("org.assertj:assertj-core:3.6.2")
    testCompile("org.spockframework:spock-core:${spockVersion}")
    testCompile("org.spockframework:spock-spring:${spockVersion}")

    // for lombok
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")
}
  • 24. Redis Support を使用するため、以下の2行を記述します。
    • compile(“org.springframework.boot:spring-boot-starter-data-redis”)
    • compile(“org.springframework.integration:spring-integration-redis”)

findbugs-exclude.xml

<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
</FindBugsFilter>

Application.java

package ksbysample.eipapp.redisqueue;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

application.properties

spring.application.name=redisqueue
spring.zipkin.base-url=http://localhost:9411/
spring.sleuth.sampler.percentage=1.0

spring.redis.sentinel.master=mymaster
spring.redis.sentinel.nodes=localhost:6381,localhost:6382,localhost:6383
  • Redis への接続設定として以下の2行を記述します。
    • spring.redis.sentinel.master
    • spring.redis.sentinel.nodes

logback-spring.xml

■その1

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <springProperty scope="context" name="springAppName" source="spring.application.name"/>
    <property name="CONSOLE_LOG_PATTERN"
              value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${level:-%5p}) %clr([${springAppName:-},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}]){yellow} %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

■その2

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    ..........

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
    <logger name="org.springframework.cloud.sleuth" level="ERROR"/>
</configuration>
  • <logger name="org.springframework.cloud.sleuth" level="ERROR"/> を追加します。

FlowConfig.java

package ksbysample.eipapp.redisqueue;

import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.core.MessageSource;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.dsl.channel.MessageChannels;
import org.springframework.integration.dsl.core.Pollers;
import org.springframework.integration.redis.store.RedisChannelMessageStore;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.Message;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;

@Configuration
public class FlowConfig {

    private final RedisConnectionFactory redisConnectionFactory;

    public FlowConfig(RedisConnectionFactory redisConnectionFactory) {
        this.redisConnectionFactory = redisConnectionFactory;
    }

    /**
     * Message の palyload にセットする SampleData クラス
     */
    @Data
    @AllArgsConstructor
    public static class SampleData implements Serializable {

        private static final long serialVersionUID = 6103271054731633658L;

        private Integer id;

        private LocalDateTime createDateTime;

        private String message;

    }

    /**
     * SampleData オブジェクトを送信する MessageSource
     */
    public static class SampleDataMessageSource implements MessageSource<SampleData> {

        private AtomicInteger idGenerator = new AtomicInteger(0);

        @Override
        public Message<SampleData> receive() {
            Integer id = idGenerator.getAndIncrement();
            LocalDateTime createDateTime = LocalDateTime.now();
            String message = String.format("idGenerator = %d は %s に生成されました"
                    , id, createDateTime.format(DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss")));

            SampleData sampleData = new SampleData(id, createDateTime, message);
            return MessageBuilder.withPayload(sampleData).build();
        }

    }

    @Bean
    public MessageSource<SampleData> sampleDataMessageSource() {
        return new SampleDataMessageSource();
    }

    @Bean
    public RedisChannelMessageStore redisMessageStore() {
        return new RedisChannelMessageStore(this.redisConnectionFactory);
    }

    @Bean
    public QueueChannel redisQueue() {
        return MessageChannels.queue("redisQueue", redisMessageStore(), "REDIS_QUEUE").get();
    }

    /**
     * メインフロー
     * 10秒毎に {@link SampleDataMessageSource} から SampleData オブジェクトが payload にセットされた Message を受信し、
     * {@link FlowConfig#redisQueue()} へ送信する
     *
     * @return {@link IntegrationFlow} オブジェクト
     */
    @Bean
    public IntegrationFlow mainFlow() {
        return IntegrationFlows
                .from(sampleDataMessageSource()
                        , e -> e.poller(Pollers.fixedDelay(10000)))
                .log()
                .channel(redisQueue())
                .get();
    }

    /**
     * {@link SampleData} オブジェクトが payload にセットされた Message を受信し、
     * {@link SampleData#getMessage()} の出力結果を標準出力に出力する
     *
     * @return {@link IntegrationFlow} オブジェクト
     */
    @Bean
    public IntegrationFlow printFlow() {
        return IntegrationFlows
                .from(redisQueue())
                .bridge(e -> e.poller(Pollers.fixedDelay(30000)))
                .<SampleData>handle((p, h) -> {
                    System.out.println("★★★ " + p.getMessage());
                    return null;
                })
                .get();
    }

}

履歴

2017/03/18
初版発行。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その12 )( RestTemplateBuilder を使用するように変更したらテストが失敗するようになった理由とは? )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その11 )( Error Prone を 2.0.15 → 2.0.18 へバージョンアップ。。。できませんでした ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • RestTemplate オブジェクトを生成するのに RestTemplateBuilder を使用するように変更したら、 ksbysample.webapp.lending.webapi.library.LibraryControllerTest#間違った都道府県を指定した場合にはエラーが返る のテストが時々失敗する ( 成功する時もある ) ようになったので、その原因を調べます。

参照したサイト・書籍

  1. Spring Boot 1.4+でRestTemplate(HTTPクライアント)を使う
    http://qiita.com/kazuki43zoo/items/7cf3c8ca4f6f2283cefb

  2. square/okhttp
    https://github.com/square/okhttp

目次

  1. どこで、どのようなエラーが発生しているのか?
  2. RestTemplate オブジェクトの生成処理を以前の処理に戻してみる
  3. 失敗するテストを単独で実行してみる
  4. テストが成功する SimpleClientHttpRequestFactory を使った RestTemplate オブジェクトの中身を見てみる
  5. テストが失敗する RestTemplateBuilder を使った RestTemplate オブジェクトの中身を見てみる
  6. 2つの RestTemplate オブジェクトの違いは?
  7. RestTemplateBuilder#detectRequestFactory で RestTemplateBuilder の自動検出を無効にして HttpURLConnection を使うようにしたらテストは成功するのか?
  8. OkHttp 3 だとテストは成功するのか?
  9. Apache HttpComponents HttpClient でもリトライ処理を入れればテストは成功するのか?
  10. ログのレベルを DEBUG に変更してみる
  11. 結局どうするか?

手順

どこで、どのようなエラーが発生しているのか?

clean タスク → Rebuild Project → build タスクを実行すると以下のエラーメッセージが出力されます。

f:id:ksby:20170307234243p:plain

失敗しているテストは以下のコードです。

    @Test
    public void 間違った都道府県を指定した場合にはエラーが返る() throws Exception {
        mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東a京都"))
                .andExpect(status().isOk())
                .andExpect(content().contentType("application/json;charset=UTF-8"))
                .andExpect(jsonPath("$.errcode", is(-2)))
                .andExpect(jsonPath("$.errmsg", is("都道府県名が正しくありません。")))
                .andExpect(jsonPath("$.content", hasSize(0)));
    }

コンソールの最後に There were failing tests. See the report at: file:///C:/project-springboot/ksbysample-webapp-lending/build/reports/tests/index.html と出力されていますので、このファイルを見てみます。

f:id:ksby:20170308000306p:plain f:id:ksby:20170308000444p:plain

-2 が返ってくるはずが -1 が返ってきたのでテスト失敗と判定されています。

リクエストを送信している /webapi/library/getLibraryList のソース  src/main/java/ksbysample/webapp/lending/webapi/library/LibraryController.java を見ると、ValueRequiredException ではなくそれ以外の例外が throw されたので errorcode が -1 になっているようです。

    @RequestMapping("/getLibraryList")
    public CommonWebApiResponse<List<Library>> getLibraryList(String pref) throws Exception {
        CommonWebApiResponse<List<Library>> response = new CommonWebApiResponse<>();
        response.setContent(Collections.emptyList());

        try {
            Libraries libraries = calilApiService.getLibraryList(pref);
            response.setContent(libraries.getLibraryList());
        } catch (ValueRequiredException e) {
            response.setErrcode(-2);
            response.setErrmsg("都道府県名が正しくありません。");
        } catch (Exception e) {
            logger.error("システムエラーが発生しました。", e);
            response.setErrcode(-1);
            response.setErrmsg("システムエラーが発生しました。");
        }

        return response;
    }

発生している例外を調べます。コンソールにログが出力されるようにするために src/main/resources/logback-unittest.xml を以下のように修正します。

<?xml version="1.0" encoding="UTF-8"?>
<included>
    <!--<root level="OFF"/>-->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</included>

build タスクではコンソールに INFO ログは出力されないので、Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を実行します。

f:id:ksby:20170308002854p:plain

発生している例外は Caused by: java.net.SocketTimeoutException: Read timed out でした。タイムアウトの時間は以前とは変えていないのですが。。。

また他のテストを見ていて気づきましたが、src/main/test/ksbysample/webapp/lending/service/calilapi/CalilApiServiceTest.java で同じテストをしているはずなのに、こちらは ValueRequiredException が発生しています。

    @Test
    public void testGetLibraryList_都道府県名が間違っている場合() throws Exception {
        assertThatThrownBy(() -> {
            Libraries libraries = calilApiService.getLibraryList("東a京都");
        }).isInstanceOf(ValueRequiredException.class);
    }

RestTemplate オブジェクトの生成処理を以前の処理に戻してみる

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.java で今回失敗している処理用の RestTemplate オブジェクトを生成している処理を以前の処理に戻してみます。

        @Bean
        public RestTemplate restTemplateForCalilApi() {
//            return this.restTemplateBuilder
//                    .setConnectTimeout(CONNECT_TIMEOUT)
//                    .setReadTimeout(READ_TIMEOUT)
//                    .rootUri(URL_CALILAPI_ROOT)
//                    .build();

            SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
            factory.setConnectTimeout(CONNECT_TIMEOUT);
            factory.setReadTimeout(READ_TIMEOUT);
            RestTemplate restTemplate = new RestTemplate(factory);
            return restTemplate;
        }

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を何度も実行してみましたが1度もエラーになりません。

f:id:ksby:20170308010357p:plain

RestTemplateBuilder を使うコードにしてみると、

        @Bean
        public RestTemplate restTemplateForCalilApi() {
            return this.restTemplateBuilder
                    .setConnectTimeout(CONNECT_TIMEOUT)
                    .setReadTimeout(READ_TIMEOUT)
                    .rootUri(URL_CALILAPI_ROOT)
                    .build();

//            SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
//            factory.setConnectTimeout(CONNECT_TIMEOUT);
//            factory.setReadTimeout(READ_TIMEOUT);
//            RestTemplate restTemplate = new RestTemplate(factory);
//            return restTemplate;
        }

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を実行してみますが、5回実行して5回全てテストが失敗しました。

f:id:ksby:20170308011618p:plain

同じ処理だと思っていたのですが、何が違うんでしょうか?

失敗するテストを単独で実行してみる

単独でテストを実行するとどうなるのか、確認してみます。

f:id:ksby:20170311125150p:plain

何回繰り返しても全て成功します。ということは単独では成功するが、全てのテストを通しで実行するとエラーになる現象のようです。

f:id:ksby:20170311125531p:plain

テストが成功する SimpleClientHttpRequestFactory を使った RestTemplate オブジェクトの中身を見てみる

breakpoint を設定した後、LibraryControllerTest#間違った都道府県を指定した場合にはエラーが返る テストメソッドを Debug 実行して生成された RestTemplate オブジェクトがどのような構成になっているのかを確認します。

f:id:ksby:20170308012306p:plain f:id:ksby:20170308013234p:plain

restTemplateForCalilApi の中身を見ていると以下の内容でした。

f:id:ksby:20170308210033p:plain f:id:ksby:20170308210159p:plain

テストが失敗する RestTemplateBuilder を使った RestTemplate オブジェクトの中身を見てみる

RestTemplateBuilder を使った場合の restTemplateForCalilApi の中身を見ていると以下の内容でした。

f:id:ksby:20170308210752p:plain f:id:ksby:20170308210951p:plain f:id:ksby:20170308211112p:plain

2つの RestTemplate オブジェクトの違いは?

  • messageConverters の数が違います。成功する方は size = 7 なのに対し、失敗する方は size = 10 でした。失敗する方は以下の点が違っていました。
    • StringHttpMessageConverter が2つありますが、片方は defaultCharset = “UTF-8” で、もう片方は defaultCharset = “ISO-8859-1” です。ちなみに成功する方にあるのは defaultCharset = “ISO-8859-1” の方です。
    • MappingJackson2HttpMessageConverter, MappingJackson2XmlHttpMessageConverter もそれぞれ2つずつありますが、こちらはどちらも defaultCharset = “UTF-8” で、インスタンスが異なるだけで内容は同じオブジェクトです。
  • uriTemplateHandler も DefaultUriTemplateHandler と RootUriTemplateHandler の違いがありますが、これは RestTemplateBuilder を使うように変更した時に RestTemplateBuilder#rootUri を追加したためと思われます。
  • requestFactory も異なっており、成功する方は SimpleClientHttpRequestFactory、失敗する方は HttpComponentsClientHttpRequestFactory でした。

タイムアウトしているという状況を考えると requestFactory の違いが影響しているように思えます。SimpleClientHttpRequestFactory と HttpComponentsClientHttpRequestFactory がそれぞれ何なのか調べてみたところ、

Apache HttpComponents HttpClient を build.gradle に記述していないので入れていないつもりでいたのですが、どうも classpath に存在して RestTemplateBuilder により検知されて使用するよう設定されているようです。

gradlew dependencies コマンドを実行して確認したところ、org.springframework.boot:spring-boot-starter-amqp を入れると依存関係で org.apache.httpcomponents:httpclient が入っていました ( この中に org.apache.http.client.HttpClient クラスがあります。 )。

compile - Dependencies for source set 'main'.
..........
+--- org.springframework.boot:spring-boot-starter-amqp: -> 1.4.4.RELEASE
|    +--- org.springframework.boot:spring-boot-starter:1.4.4.RELEASE (*)
|    +--- org.springframework:spring-messaging:4.3.6.RELEASE
|    |    +--- org.springframework:spring-beans:4.3.6.RELEASE (*)
|    |    +--- org.springframework:spring-context:4.3.6.RELEASE (*)
|    |    \--- org.springframework:spring-core:4.3.6.RELEASE
|    \--- org.springframework.amqp:spring-rabbit:1.6.7.RELEASE
|         +--- org.springframework:spring-web:4.2.9.RELEASE -> 4.3.6.RELEASE (*)
|         +--- org.springframework.retry:spring-retry:1.1.5.RELEASE
|         |    \--- org.springframework:spring-core:4.0.4.RELEASE -> 4.3.6.RELEASE
|         +--- org.springframework:spring-messaging:4.2.9.RELEASE -> 4.3.6.RELEASE (*)
|         +--- org.springframework:spring-context:4.2.9.RELEASE -> 4.3.6.RELEASE (*)
|         +--- org.springframework:spring-tx:4.2.9.RELEASE -> 4.3.6.RELEASE (*)
|         +--- com.rabbitmq:http-client:1.0.0.RELEASE
|         |    +--- org.apache.httpcomponents:httpclient:4.3.6 -> 4.5.2
|         |    |    +--- org.apache.httpcomponents:httpcore:4.4.4 -> 4.4.6
|         |    |    \--- commons-codec:commons-codec:1.9 -> 1.10
|         |    \--- com.fasterxml.jackson.core:jackson-databind:2.5.1 -> 2.8.6 (*)
|         +--- org.springframework.amqp:spring-amqp:1.6.7.RELEASE
|         |    \--- org.springframework:spring-core:4.2.9.RELEASE -> 4.3.6.RELEASE
|         \--- com.rabbitmq:amqp-client:3.6.5 -> 3.6.6
..........

RestTemplateBuilder#detectRequestFactory で RestTemplateBuilder の自動検出を無効にして HttpURLConnection を使うようにしたらテストは成功するのか?

Spring Boot 1.4+でRestTemplate(HTTPクライアント)を使う の記事によると、RestTemplateBuilder#detectRequestFactory(false) を呼べば RestTemplateBuilder の自動検出が無効になり HttpURLConnection が使われるようになるようです。HttpURLConnection にすれば RestTemplateBuilder を使用する場合でもテストが成功するのか確認してみます。

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.java の CalilApiConfig.restTemplateForCalilApi メソッドを以下のように変更します。

        @Bean
        public RestTemplate restTemplateForCalilApi() {
            return this.restTemplateBuilder
                    .detectRequestFactory(false)
                    .setConnectTimeout(CONNECT_TIMEOUT)
                    .setReadTimeout(READ_TIMEOUT)
                    .rootUri(URL_CALILAPI_ROOT)
                    .build();

//            SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
//            factory.setConnectTimeout(CONNECT_TIMEOUT);
//            factory.setReadTimeout(READ_TIMEOUT);
//            RestTemplate restTemplate = new RestTemplate(factory);
//            return restTemplate;
        }
  • .detectRequestFactory(false) を追加します。

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を何度も実行してみましたが1度もエラーになりませんでした。

f:id:ksby:20170311130905p:plain

ここまでの確認結果をまとめてみると、

  • RestTemplateBuilder の問題ではなく Apache HttpComponents HttpClient の問題のようです。JDK 標準の HttpURLConnection だと発生しません。
  • テスト単独で実行すると発生しない。全てのテストを通しで実行した時だけ発生する問題のようです ( ただしたまに成功します )。

OkHttp 3 だとテストは成功するのか?

Apache HttpComponents HttpClient 以外のライブラリでも同じ現象が出るのか、OkHttp 3 で確認してみます。

build.gradle を以下のように変更します。変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    ..........
    compile("org.codehaus.janino:janino")
    compile("com.squareup.okhttp3:okhttp")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    ..........
  • compile("com.squareup.okhttp3:okhttp") を追加します。OkHttp 3 は Spring IO Platform の BOM に記載されているのでバージョン番号の記述は不要です。

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.java の CalilApiConfig.restTemplateForCalilApi メソッドを以下のように変更します。

        @Bean
        public RestTemplate restTemplateForCalilApi() {
            return this.restTemplateBuilder
                    .setConnectTimeout(CONNECT_TIMEOUT)
                    .setReadTimeout(READ_TIMEOUT)
                    .rootUri(URL_CALILAPI_ROOT)
                    .requestFactory(new OkHttp3ClientHttpRequestFactory())
                    .build();

//            SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
//            factory.setConnectTimeout(CONNECT_TIMEOUT);
//            factory.setReadTimeout(READ_TIMEOUT);
//            RestTemplate restTemplate = new RestTemplate(factory);
//            return restTemplate;
        }
  • .requestFactory(new OkHttp3ClientHttpRequestFactory()) を追加します。

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

問題の出るテストを単独で実行すると、これは何度試しても成功します。

f:id:ksby:20170316004333p:plain

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を実行してみると、5回実行して5回全てテストが失敗しました。エラーの原因も Caused by: java.net.SocketTimeoutException: Read timed out でした。

f:id:ksby:20170316005629p:plain

Apache HttpComponents HttpClient と変わりませんでした。変更したファイルは一旦元に戻します。

Apache HttpComponents HttpClient でもリトライ処理を入れればテストは成功するのか?

リトライを入れても失敗するのか確認してみます。簡単に試してみたいだけなので spring-retry ではなく for 文で実装します。

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.java の getLibraryList メソッドを以下のように変更します。

    public Libraries getLibraryList(String pref) throws Exception {
        // 図書館データベースAPIを呼び出して XMLレスポンスを受信する
        ResponseEntity<String> response = null;
        for (int i = 1; i <= 5; i++) {
            try {
                response = this.restTemplateForCalilApi.getForEntity(URL_CALILAPI_LIBRALY
                        , String.class, this.calilApiKey, pref);
            } catch (Exception e) {
                if (i <= 4) {
                    logger.warn("★★★ リトライ = {} 回目: {}", i, e.getCause().getMessage());
                } else {
                    throw e;
                }
            }
        }

        // 受信した XMLレスポンスを Javaオブジェクトに変換する
        Serializer serializer = new Persister();
        Libraries libraries = serializer.read(Libraries.class, response.getBody());

        return libraries;
    }

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を実行してみるとテストは常に成功するようになりました。ただし、ログを見ると常に1回リトライしていました。

f:id:ksby:20170316012319p:plain

ログのレベルを DEBUG に変更してみる

ログのレベルを DEBUG に変更して状況を見てみます。src/main/resources/logback-unittest.xml を以下のように修正します。

<?xml version="1.0" encoding="UTF-8"?>
<included>
    <!--<root level="OFF"/>-->
    <root level="DEBUG">
        <appender-ref ref="CONSOLE"/>
    </root>
</included>

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」を実行すると以下のログが出力されました。

2017-03-17 00:39:36.438  INFO : call : ksbysample.webapp.lending.service.calilapi.CalilApiService#getLibraryList({東a京都})
2017-03-17 00:39:36.438 DEBUG : Created GET request for "http://api.calil.jp/library?....."
2017-03-17 00:39:36.438 DEBUG : Setting request Accept header to [.....]
2017-03-17 00:39:36.438 DEBUG : CookieSpec selected: default
2017-03-17 00:39:36.438 DEBUG : Auth cache not set in the context
2017-03-17 00:39:36.438 DEBUG : Connection request: [route: {}->http://api.calil.jp:80][total kept alive: 1; route allocated: 1 of 5; total allocated: 1 of 10]
2017-03-17 00:39:36.470 DEBUG : http-outgoing-0 << "[read] I/O error: Read timed out"
2017-03-17 00:39:36.470 DEBUG : Connection leased: [id: 0][route: {}->http://api.calil.jp:80][total kept alive: 0; route allocated: 1 of 5; total allocated: 1 of 10]
2017-03-17 00:39:36.470 DEBUG : http-outgoing-0: set socket timeout to 5000
2017-03-17 00:39:36.470 DEBUG : Executing request GET /library?..... HTTP/1.1
2017-03-17 00:39:36.470 DEBUG : Target auth state: UNCHALLENGED
2017-03-17 00:39:36.470 DEBUG : Proxy auth state: UNCHALLENGED
2017-03-17 00:39:36.470 DEBUG : http-outgoing-0 >> GET /library?..... HTTP/1.1
..........
2017-03-17 00:39:41.482 DEBUG : http-outgoing-0 << "[read] I/O error: Read timed out"
2017-03-17 00:39:41.482 DEBUG : http-outgoing-0: Close connection
2017-03-17 00:39:41.482 DEBUG : http-outgoing-0: Shutdown connection
2017-03-17 00:39:41.482 DEBUG : Connection discarded
2017-03-17 00:39:41.482 DEBUG : Connection released: [id: 0][route: {}->http://api.calil.jp:80][total kept alive: 0; route allocated: 0 of 5; total allocated: 0 of 10]
2017-03-17 00:39:41.482  WARN : ★★★ リトライ = 1 回目: Read timed out
2017-03-17 00:39:41.482 DEBUG : Created GET request for "http://api.calil.jp/library?....."
2017-03-17 00:39:41.482 DEBUG : Setting request Accept header to [.....]
2017-03-17 00:39:41.482 DEBUG : CookieSpec selected: default
2017-03-17 00:39:41.482 DEBUG : Auth cache not set in the context
2017-03-17 00:39:41.482 DEBUG : Connection request: [route: {}->http://api.calil.jp:80][total kept alive: 0; route allocated: 0 of 5; total allocated: 0 of 10]
2017-03-17 00:39:41.482 DEBUG : Connection leased: [id: 2][route: {}->http://api.calil.jp:80][total kept alive: 0; route allocated: 1 of 5; total allocated: 1 of 10]
2017-03-17 00:39:41.482 DEBUG : Opening connection {}->http://api.calil.jp:80
2017-03-17 00:39:41.482 DEBUG : Connecting to api.calil.jp/160.16.122.86:80
2017-03-17 00:39:41.501 DEBUG : Connection established 192.168.2.100:58940<->160.16.122.86:80
2017-03-17 00:39:41.501 DEBUG : http-outgoing-2: set socket timeout to 5000
2017-03-17 00:39:41.502 DEBUG : Executing request GET /library?..... HTTP/1.1
2017-03-17 00:39:41.502 DEBUG : Target auth state: UNCHALLENGED
2017-03-17 00:39:41.502 DEBUG : Proxy auth state: UNCHALLENGED
2017-03-17 00:39:41.502 DEBUG : http-outgoing-2 >> GET /library?..... HTTP/1.1
..........
2017-03-17 00:39:41.866 DEBUG : http-outgoing-2 << "HTTP/1.1 200 OK[\r][\n]"

失敗時と成功時の違いを比較してみると、

  • 失敗時は total kept alive: 1 と出力されており、既存の接続済のコネクションを利用していますが、成功時は total kept alive: 0 と接続済のコネクションがないため、Connecting to api.calil.jp/160.16.122.86:80Connection established 192.168.2.100:58940<->160.16.122.86:80 と新規に接続しています。

結局どうするか?

ここまで調べた結果をまとめてみると、

  • 接続済のコネクションを再利用しようとすると失敗するときがある。失敗した時に発生している例外は java.net.SocketTimeoutException: Read timed out
  • リトライすれば2回目には成功する。3回目以降までリトライすることはない。
  • java.net.SocketTimeoutException: Read timed out の原因は分からず。デバッガで追ってもみたが、本当に単に time out しているだけだった。。。
  • JDK の HttpURLConnection で必ず成功するのは、おそらくコネクションプーリングの仕組みがないため。都度接続するので成功するのだろう。

という感じでしょうか。ライブラリの問題というより自分のところのネットワークの問題のような気がしてきました。WiFiiPhoneTwitter を見ていると、外では決して出ることがない「ツイートを読み込めません」というエラーメッセージがよく出ることがあり、同じように接続済のコネクションを再利用しようとして問題が発生しているものと思われます。RestTemplateBuilder や Apache HttpComponents HttpClient に問題がある訳ではないという結論です。

エラーが出ないようにするには JDK の HttpURLConnection を使うか、リトライ処理を入れるかするしかないようです。今回はリトライ処理を入れて対応します。

リトライ処理は上で入れた for 文の方式ではなく spring-retry で入れようと思います。長くなったのでリトライ処理は次回に入れます。またこれまで入れた修正は全て元に戻します。

ソースコード

履歴

2017/03/18
初版発行。

Spring Boot + Spring Integration でいろいろ試してみる ( その19 )( Flow の途中で一時的に別の Flow を実行したいなら wireTap! )

概要

記事一覧はこちらです。

参照したサイト・書籍

  1. Spring Integration Reference Manual - Wire Tap
    http://docs.spring.io/spring-integration/docs/4.3.8.RELEASE/reference/html/messaging-channels-section.html#channel-wiretap

目次

  1. wireTap になぜ気づいたのか?
  2. in, out ディレクトリを作成する
  3. ksbysample-eipapp-wiretap プロジェクトを作成する
  4. サンプルの Flow を作成する
  5. 動作確認
  6. 最後に

手順

wireTap になぜ気づいたのか?

  • まず Spring Integration Reference Manual を初めに読んだ時には全く頭に残っていませんでした。
  • Spring Integration DSL に興味を持つようになって、DSL が書いてあるところはざっと見直したのですが、Wire TapDSL のサンプルは以下のようなコードで、MessageChannel 絡みの何かかな?、必要に感じたらまた見直そう、程度にしか思っていなかったはず。
@Bean
public PollableChannel myChannel() {
    return MessageChannels.queue()
            .wireTap("loggingFlow.input")
            .get();
}
  • Spring Integration Java DSL Reference に書かれているものは以下のコードで、これでは .wireTap(...) というメソッドがあることは全然分かりません。
@Bean
public MessageChannel priorityChannel() {
   return MessageChannels.priority(this.mongoDbChannelMessageStore, "priorityGroup")
                        .interceptor(wireTap())
                        .get();
}
  • Spring Integration Java DSL: Line by line tutorial のサンプルでも wireTap は全然出てきません。そうすると Spring Integration を習得する上で覚えるべきものと認識されません。サンプルに出てくるメソッドからまず覚えようとします。

こんな感じで、wireTap は全く気にもしていませんでした。それがなぜ気づいたのかというと、

  • Java で Enterprise Integration Pattern 用のフレームワークとして Spring Integration 以外に Apache Camel があります。
  • Apache Camel には大量の Component が用意されていて、AWS 関連のコンポーネントとかが使えるとやれることが増えそうだな、と思いました。
  • Component の中に Spring Integration Component という Component を見かけて、Spring Integration –> Apache Camel 連携は普通にできそうな感じが。
  • でも Spring Support 等を見て少し使ってみようとしましたが、さっぱり分からず。。。 Spring Integration DSL が少しは使えるようになったので Apache Camel も分かるだろう、と思っていましたが、なんというか概要・全体像的なところは似ているのですが、詳細のところでどうしてよいのかがさっぱり分かりません。
  • Web を見ていても分からなそうだったので、英語の書籍がないか探して以下の2冊を kindle 版で購入。
    Apache Camel Developer's Cookbook (Solve Common Integration Tasks With Over 100 Easily Accessible Apache Camel Recipes)

    Apache Camel Developer's Cookbook (Solve Common Integration Tasks With Over 100 Easily Accessible Apache Camel Recipes)

    Mastering Apache Camel

    Mastering Apache Camel

  • Mastering Apache Camel を読んでいたところ、「Chaper 2.Message Routing」に “Wire Tap - sending a copy of the message elsewhere” の文章が! メッセージのコピーを他に送れるのかな?と思って本の Wire Tap の章を読んでみましたが、確かにメイン Flow のメッセージはそのままで、途中で別のフローにメッセージのコピーを送信できる仕組みでした。
  • Spring Integration でも WireTap ないのかな?と思って Spring Integration Reference Manual を見ると Wire Tap の記述が!
  • IntegrationFlow でも書けるのか?と思って試してみたところ、IntegrationFlowDefinition#wireTap がちゃんとありました。メイン Flow の Message は変更せずに、途中に別の Flow を実行できる仕組みでした。

すっごい遠回りでした。Spring Integration のマニュアルや記事だけ見ていると wireTap の使い方って分からないのでは。。。

in, out ディレクトリを作成する

サンプルアプリケーションを作成します。常駐型アプリケーション用として以下の構成のディレクトリを作成します。

C:\eipapp\ksbysample-eipapp-wiretap
├ in
└ out

ksbysample-eipapp-wiretap プロジェクトを作成する

  1. IntelliJ IDEA で Gradle プロジェクトを作成し、build.gradle を リンク先の内容 に変更します。

  2. ksbysample-eipapp-wiretap プロジェクトのルート直下に config/checkstyle, config/findbugs ディレクトリを作成します。

  3. config/checkstyle の下に ksbysample-eipapp-messaginggateway プロジェクトの config/checkstyle の下にある google_checks.xml をコピーします。

  4. config/findbugs の下に findbugs-exclude.xml を新規作成し、リンク先の内容 の内容に変更します。

  5. src/main/java の下に ksbysample.eipapp.wiretap パッケージを作成します。

  6. src/main/java/ksbysample/eipapp/wiretap の下に Application.java を作成し、リンク先の内容 を記述します。

  7. src/main/resources の下に application.properties を作成し、リンク先の内容 を記述します。

  8. src/main/resources の下に logback-spring.xml を作成し、リンク先の内容 を記述します。

サンプルの Flow を作成する

  1. サンプルの Flow は以下の仕様で作成します。

    • /in ディレクトリにファイルがあるかチェックします。
    • ファイルの内容をメールします。
    • /out ディレクトリに To, Subject, メール本文を出力したファイルを作成します。
    • /out ディレクトリに作成したファイルを SFTPサーバにアップロードします。
  2. src/main/java/ksbysample/eipapp/wiretap の下に FlowConfig.java を新規作成し、リンク先の内容 を記述します。

動作確認

動作確認します。テストには以下のファイルを使用します。

■2014.txt

http://ksby.hatenablog.com/entry/2014/12/27/233427
http://ksby.hatenablog.com/entry/2014/12/29/175849
  1. smtp4dev, freeFTPd を起動します。

    f:id:ksby:20170311231446p:plain f:id:ksby:20170311231600p:plain

  2. ZipkinServer を起動します。今回は起動するだけです。

  3. bootRun を実行して ksbysample-eipapp-wiretap を起動します。

  4. C:\eipapp\ksbysample-eipapp-wiretap\in の下に 2014.txt を置きます。

    f:id:ksby:20170311233054p:plain

  5. SFTP サーバに 2014.txt がアップロードされて /in ディレクトリからはファイルが削除されます。

    f:id:ksby:20170311233527p:plain

    /out ディレクトリにもファイルは残っていません。

    f:id:ksby:20170311233743p:plain

  6. メールも届いており、ファイルの中身がメール本文になっています。

    f:id:ksby:20170311234105p:plain f:id:ksby:20170311234216p:plain f:id:ksby:20170311234311p:plain

  7. SFTP サーバにアップロードされた 2014.txt は以下の内容です。メールで送信した To, Subject が出力されています。

    f:id:ksby:20170311234551p:plain

  8. 起動したサーバと ksbysample-eipapp-wiretap を停止します。

最後に

  • Spring Integration Java DSL Reference の Using Protocol Adapters に記載されている Adapter を使う前なら気にしなかったのですが、使うようになったら wireTap を覚えると便利です。個人的には OutboundAdapter って何か使い勝手悪いなあ、と思っていたのが解消されました。

  • Spring Integration というか Enterprise Integration Patterns ですが、最初何のためにそんな機能があるのかよく分からなくて、慣れてきてやっと使い方が分かるものがあります。もう少し具体的なサンプルで、そういうふうに使えばいいんだと分かるものがあるといいんですけどね。

    Spring Integratin にも Mastering Apache Camel のように簡単な例がいくつも載っている本があるといいな、とは思いました。できれば Spring Integration DSL ベースで。

ソースコード

build.gradle

group 'ksbysample'
version '1.0.0-RELEASE'

buildscript {
    ext {
        springBootVersion = '1.4.5.RELEASE'
    }
    repositories {
        mavenCentral()
        maven { url "http://repo.spring.io/repo/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("io.spring.gradle:dependency-management-plugin:0.6.1.RELEASE")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.9")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'groovy'
apply plugin: 'net.ltgt.errorprone'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'

sourceCompatibility = 1.8
targetCompatibility = 1.8

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += ['-Xep:RemoveUnusedImports:WARN']

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = '7.6'
    sourceSets = [project.sourceSets.main]
}

findbugs {
    toolVersion = '3.0.1'
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    effort = "max"
    excludeFilter = file("${rootProject.projectDir}/config/findbugs/findbugs-exclude.xml")
}

tasks.withType(FindBugs) {
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

repositories {
    mavenCentral()
    maven { url "http://repo.spring.io/repo/" }
}

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Athens-SR4") {
            bomProperty 'guava.version', '21.0'
        }
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:Camden.RELEASE")
    }
}

dependencies {
    def spockVersion = "1.1-groovy-2.4-rc-3"
    def lombokVersion = "1.16.12"
    def errorproneVersion = '2.0.15'

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    compile("org.springframework.boot:spring-boot-starter-integration")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.integration:spring-integration-mail")
    compile("org.springframework.integration:spring-integration-sftp")
    compile("org.codehaus.janino:janino")
    compile("com.google.guava:guava")
    testCompile("org.springframework.boot:spring-boot-starter-test")

    // org.springframework.cloud:spring-cloud-dependencies によりバージョン番号が自動で設定されるもの
    // http://projects.spring.io/spring-cloud/ の「Release Trains」参照
    compile("org.springframework.cloud:spring-cloud-starter-zipkin") {
        exclude module: 'spring-boot-starter-web'
    }

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    compile("org.springframework.integration:spring-integration-java-dsl:1.2.1.RELEASE")
    testCompile("org.assertj:assertj-core:3.6.2")
    testCompile("org.spockframework:spock-core:${spockVersion}")
    testCompile("org.spockframework:spock-spring:${spockVersion}")

    // for lombok
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")
}

findbugs-exclude.xml

<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
</FindBugsFilter>

Application.java

package ksbysample.eipapp.wiretap;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.integration.annotation.IntegrationComponentScan;

@SpringBootApplication
@IntegrationComponentScan
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

application.properties

spring.application.name=eipapp
spring.zipkin.base-url=http://localhost:9411/
spring.sleuth.sampler.percentage=1.0

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <springProperty scope="context" name="springAppName" source="spring.application.name"/>
    <property name="CONSOLE_LOG_PATTERN"
              value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${level:-%5p}) %clr([${springAppName:-},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}]){yellow} %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
    <logger name="org.springframework.integration.expression.ExpressionUtils" level="ERROR"/>
    <logger name="com.jcraft.jsch" level="ERROR"/>
</configuration>

FlowConfig.java

package ksbysample.eipapp.wiretap;

import com.jcraft.jsch.ChannelSftp;
import org.aopalliance.aop.Advice;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.dsl.core.Pollers;
import org.springframework.integration.dsl.support.Transformers;
import org.springframework.integration.file.FileHeaders;
import org.springframework.integration.file.FileReadingMessageSource;
import org.springframework.integration.file.filters.AcceptAllFileListFilter;
import org.springframework.integration.file.filters.IgnoreHiddenFileListFilter;
import org.springframework.integration.file.remote.session.SessionFactory;
import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice;
import org.springframework.integration.mail.MailHeaders;
import org.springframework.integration.sftp.session.DefaultSftpSessionFactory;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import static java.util.Collections.singletonMap;

@Configuration
public class FlowConfig {

    private static final String ROOT_DIR = "C:/eipapp/ksbysample-eipapp-wiretap";
    private static final String IN_DIR = ROOT_DIR + "/in";
    private static final String OUT_DIR = ROOT_DIR + "/out";

    private static final String MAIL_FROM = "system@sample.com";
    private static final String MAIL_TO = "download@test.co.jp";

    private static final String SFTP_UPLOAD_DIR = "/in";

    private static final String CRLF = "\r\n";

    @Value("${spring.mail.host:localhost}")
    private String mailHost;

    @Value("${spring.mail.port:25}")
    private int mailPort;

    @Value("${spring.mail.protocol:smtp}")
    private String mailProtocol;

    @Value("${spring.mail.default-encoding:UTF-8}")
    private String mailDefaultEncoding;

    /**
     * SFTP サーバに接続するための SessionFactory オブジェクトを生成する
     * 今回は CachingSessionFactory を使用せず処理毎に接続・切断されるようにする
     *
     * @return SFTP サーバ接続用の SessionFactory オブジェクト
     */
    @Bean
    public SessionFactory<ChannelSftp.LsEntry> sftpSessionFactory() {
        DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(true);
        factory.setHost("localhost");
        factory.setPort(22);
        factory.setUser("send01");
        factory.setPassword("send01");
        factory.setAllowUnknownKeys(true);
        return factory;
    }

    /**
     * EIP の1つ wireTap のサンプル Flow
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow wiretapSampleFlow() {
        return IntegrationFlows
                // C:/eipapp/ksbysample-eipapp-wiretap/in にファイルが作成されたか 1秒間隔でチェックする
                .from(s -> s.file(new File(IN_DIR))
                                // 同じファイルが置かれても処理する
                                .filter(new AcceptAllFileListFilter<>())
                                .filter(new IgnoreHiddenFileListFilter())
                                // ファイルが新規作成された時だけ Message を送信する
                                // これを入れないとファイルが存在する限り何度も Message が送信され続ける
                                .useWatchService(true)
                                .watchEvents(FileReadingMessageSource.WatchEventType.CREATE)
                        , e -> e.poller(Pollers.fixedDelay(1000)))
                // ファイル名とファイルの絶対パス、メール送信用の From, To, Subject を Message の header にセットする
                .enrichHeaders(h -> h
                        .headerExpression(FileHeaders.FILENAME, "payload.name")
                        .headerExpression(FileHeaders.ORIGINAL_FILE, "payload.absolutePath")
                        .header(MailHeaders.FROM, MAIL_FROM)
                        .header(MailHeaders.TO, MAIL_TO)
                        .headerExpression(MailHeaders.SUBJECT, "payload.name"))
                .wireTap(f -> f
                        // File の内容を読み込んで payload へセットする
                        .transform(Transformers.fileToString())
                        .wireTap(sf -> sf
                                // メールを送信する
                                .handleWithAdapter(a -> a.mail(this.mailHost)
                                        .port(this.mailPort)
                                        .protocol(this.mailProtocol)
                                        .defaultEncoding(this.mailDefaultEncoding)))
                        .wireTap(sf -> sf
                                // payload の内容をファイルに出力する内容に変更する
                                .handle((p, h) -> {
                                    StringBuilder sb = new StringBuilder();
                                    sb.append("To: " + h.get(MailHeaders.TO) + CRLF);
                                    sb.append("Subject: " + h.get(MailHeaders.SUBJECT) + CRLF);
                                    sb.append(CRLF);
                                    sb.append(p);

                                    return MessageBuilder.withPayload(sb.toString())
                                            .build();
                                })
                                // /out ディレクトリにファイルを生成する
                                // ファイル名は header 内の FileHeaders.FILENAME のキー名の文字列が使用される
                                .handleWithAdapter(a -> a.file(new File(OUT_DIR))))
                        .channel("nullChannel"))
                .wireTap(f -> f
                        // payload の File クラスを /out ディレクトリのファイルに変更する
                        .handle((p, h) -> Paths.get(OUT_DIR, (String) h.get(FileHeaders.FILENAME)).toFile())
                        // SFTP サーバにファイルをアップロードする
                        .handleWithAdapter(a -> a.sftp(sftpSessionFactory())
                                        .remoteDirectory(SFTP_UPLOAD_DIR)
                                , e -> e.advice(sftpUploadRetryAdvice())))
                // /in, /out ディレクトリのファイルを削除する
                .<File>handle((p, h) -> {
                    try {
                        Files.delete(Paths.get(p.getAbsolutePath()));
                        Files.delete(Paths.get(OUT_DIR, p.getName()));
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                    return null;
                })
                .get();
    }

    /**
     * リトライは最大5回 ( SimpleRetryPolicy で指定 )、
     * リトライ間隔は初期値2秒、最大10秒、倍数2.0 ( ExponentialBackOffPolicy で指定 )
     * の RequestHandlerRetryAdvice オブジェクトを生成する
     *
     * @return RequestHandlerRetryAdvice オブジェクト
     */
    @Bean
    public Advice sftpUploadRetryAdvice() {
        RetryTemplate retryTemplate = new RetryTemplate();
        retryTemplate.setRetryPolicy(
                new SimpleRetryPolicy(5, singletonMap(Exception.class, true)));
        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(2000);
        backOffPolicy.setMaxInterval(10000);
        backOffPolicy.setMultiplier(2.0);
        retryTemplate.setBackOffPolicy(backOffPolicy);

        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
        advice.setRetryTemplate(retryTemplate);

        return advice;
    }

}

履歴

2017/03/12
初版発行。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その11 )( Error Prone を 2.0.15 → 2.0.18 へバージョンアップ。。。できませんでした )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その10 )( インジェクションの方法を @Autowired によるフィールドインジェクション → コンストラクタインジェクションへ変更する ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Error Prone を 2.0.15 → 2.0.18 へバージョンアップします。
    • gradle-errorprone-plugin も 0.0.8 → 0.0.9 へバージョンアップします。
    • 。。。と思いましたが、Error Prone の 2.0.15 → 2.0.18 へのバージョンアップはできなかったというお話です。

参照したサイト・書籍

目次

  1. build.gradle を変更する
  2. clean タスク → Rebuild Project → build タスクを実行する
  3. java.lang.NoSuchMethodError: com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)V の原因は?
  4. build.gradle の resolutionStrategy.force で Guava の 21.0 を強制してみる
  5. なぜ Guava のバージョンが 17.0 にバージョンダウンされるのか?
  6. BOM で指定されているライブラリのバージョンを変更する
  7. java.lang.ClassCastException: lombok.javac.apt.Javac7BaseFileObjectWrapper cannot be cast to javax.tools.FileObject の原因は?
  8. 結局、どう対応するのか?
  9. 最後に

手順

build.gradle を変更する

  1. build.gradle を リンク先のその1の内容 へ変更します。

  2. Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク → Rebuild Project → build タスクを実行する

clean タスク → Rebuild Project → build タスクを実行します。。。が、compileJava でエラーが出て止まりました。

f:id:ksby:20170305073102p:plain

ログを見た感じでは com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)V が問題のような気がしますが、これだけではエラーの内容がよく分かりません。

コマンドプロンプトを開いて、プロジェクトのルートディレクトリに移動した 後 gradlew --stackstrace --debug build コマンドを実行してみます。

f:id:ksby:20170305073640p:plain

以下のログが出力されました。java.lang.NoSuchMethodError: com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)V と出ており、com.google.errorprone.BugCheckerInfo.create(BugCheckerInfo.java:98) からこのメソッドが呼び出されていますが、メソッドがないようです。

07:37:32.768 [ERROR] [org.gradle.BuildExceptionReporter] 
07:37:32.771 [ERROR] [org.gradle.BuildExceptionReporter] FAILURE: Build failed with an exception.
07:37:32.772 [ERROR] [org.gradle.BuildExceptionReporter] 
07:37:32.772 [ERROR] [org.gradle.BuildExceptionReporter] * What went wrong:
07:37:32.772 [ERROR] [org.gradle.BuildExceptionReporter] Execution failed for task ':compileJava'.
07:37:32.772 [ERROR] [org.gradle.BuildExceptionReporter] > java.lang.reflect.InvocationTargetException
07:37:32.773 [ERROR] [org.gradle.BuildExceptionReporter] 
07:37:32.773 [ERROR] [org.gradle.BuildExceptionReporter] * Exception is:
07:37:32.774 [ERROR] [org.gradle.BuildExceptionReporter] org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':compileJava'.
..........
07:37:32.787 [ERROR] [org.gradle.BuildExceptionReporter] Caused by: java.lang.NoSuchMethodError: com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)V
07:37:32.787 [ERROR] [org.gradle.BuildExceptionReporter]    at com.google.errorprone.BugCheckerInfo.create(BugCheckerInfo.java:98)
07:37:32.787 [ERROR] [org.gradle.BuildExceptionReporter]    at com.google.errorprone.scanner.BuiltInCheckerSuppliers.getSuppliers(BuiltInCheckerSuppliers.java:239)
07:37:32.788 [ERROR] [org.gradle.BuildExceptionReporter]    at com.google.errorprone.scanner.BuiltInCheckerSuppliers.<clinit>(BuiltInCheckerSuppliers.java:268)
07:37:32.788 [ERROR] [org.gradle.BuildExceptionReporter]    at com.google.errorprone.ErrorProneCompiler$Builder.<init>(ErrorProneCompiler.java:96)
07:37:32.788 [ERROR] [org.gradle.BuildExceptionReporter]    ... 77 more
07:37:32.788 [ERROR] [org.gradle.BuildExceptionReporter] 
07:37:32.788 [LIFECYCLE] [org.gradle.BuildResultLogger] 
07:37:32.789 [LIFECYCLE] [org.gradle.BuildResultLogger] BUILD FAILED
07:37:32.789 [LIFECYCLE] [org.gradle.BuildResultLogger] 

java.lang.NoSuchMethodError: com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)V の原因は?

IntelliJ IDEA で com.google.common.base.Preconditions クラスのソースを表示して checkArgument メソッドを見てみましたが、引数が (String, Object) のメソッドは存在しませんでした。必ず第1引数は boolean です。

f:id:ksby:20170307001538p:plain

checkArgument を呼び出している com.google.errorprone.BugCheckerInfo.create(BugCheckerInfo.java:98) を見てみましたが、どう見ても引数を3つ渡しています。エラーメッセージが出る意味がよく分かりません。。。

f:id:ksby:20170307001830p:plain

gradlew dependencies コマンドを実行して依存関係を確認してみます。

出力された結果を見たのですが、なぜか com.google.guava:guava:21.0 -> 17.0 と guava が 17.0 へバージョンダウンされています。出力結果を “com.google.guava:guava” で検索してもどこにも 17.0 が依存関係になっているものが見当たらず、なぜバージョンダウンされるのかが分かりません。。。

errorprone
\--- com.google.errorprone:error_prone_core:latest.release -> 2.0.18
     +--- com.google.errorprone:error_prone_annotation:2.0.18
     |    \--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.errorprone:error_prone_check_api:2.0.18
     |    +--- com.google.errorprone:error_prone_annotation:2.0.18 (*)
     |    +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     |    +--- org.checkerframework:dataflow:1.8.10
     |    |    \--- org.checkerframework:javacutil:1.8.10
     |    +--- com.google.errorprone:javac:9-dev-r3297-4
     |    +--- com.googlecode.java-diff-utils:diffutils:1.3.0
     |    \--- com.google.errorprone:error_prone_annotations:2.0.18
     +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
     +--- org.pcollections:pcollections:2.1.2
     +--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.auto:auto-common:0.7
     |    \--- com.google.guava:guava:19.0 -> 17.0
     +--- com.google.code.findbugs:jFormatString:3.0.0
     +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     +--- org.checkerframework:dataflow:1.8.10 (*)
     +--- com.google.errorprone:javac:9-dev-r3297-4
     \--- com.google.errorprone:error_prone_annotations:2.0.18

build.gradle の resolutionStrategy.force で Guava の 21.0 を強制してみる

resolutionStrategy.force を記述することでライブラリのバージョンを強制指定することができるはずなので、build.gradle の configuration に resolutionStrategy.force "com.google.guava:guava:21.0" を追加してみます。

configurations {
    // for Doma 2
    domaGenRuntime

    // for Error Prone ( http://errorprone.info/ )
    errorprone {
        resolutionStrategy.force "com.google.errorprone:error_prone_core:${errorproneVersion}"
        resolutionStrategy.force "com.google.guava:guava:21.0"
    }
}

追加後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

gradlew dependencies コマンドを実行してみましたが com.google.guava:guava:21.0 -> 17.0 のままでした。。。

errorprone
\--- com.google.errorprone:error_prone_core:latest.release -> 2.0.18
     +--- com.google.errorprone:error_prone_annotation:2.0.18
     |    \--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.errorprone:error_prone_check_api:2.0.18
     |    +--- com.google.errorprone:error_prone_annotation:2.0.18 (*)
     |    +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     |    +--- org.checkerframework:dataflow:1.8.10
     |    |    \--- org.checkerframework:javacutil:1.8.10
     |    +--- com.google.errorprone:javac:9-dev-r3297-4
     |    +--- com.googlecode.java-diff-utils:diffutils:1.3.0
     |    \--- com.google.errorprone:error_prone_annotations:2.0.18
     +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
     +--- org.pcollections:pcollections:2.1.2
     +--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.auto:auto-common:0.7
     |    \--- com.google.guava:guava:19.0 -> 17.0
     +--- com.google.code.findbugs:jFormatString:3.0.0
     +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     +--- org.checkerframework:dataflow:1.8.10 (*)
     +--- com.google.errorprone:javac:9-dev-r3297-4
     \--- com.google.errorprone:error_prone_annotations:2.0.18

clean タスク → Rebuild Project → build タスクを実行してみましたが、結果は同じでした。

f:id:ksby:20170305082427p:plain

効果がなかったので build.gradle は元に戻します。

なぜ Guava のバージョンが 17.0 にバージョンダウンされるのか?

build.gradle に Guava の 17.0 なんて記述していないのに Guava がバージョンダウンされる原因となりそうなものに1つ心あたりがありました。それは Spring IO Platform の BOM です。以前マニュアルの Web ページを見て Guava のバージョンが妙に低いな、と思った記憶があります。

現在使用中の Spring IO Platform の Athens-SR3 の Spring IO Platform Reference Guide を開き “guava” で検索してみると、思ったとおり 17.0 でした。

f:id:ksby:20170305140121p:plain

また gradlew dependencies コマンドの出力結果を見ていて気づきましたが、Spring IO Platform の BOM は gradle plugin の依存関係のライブラリだけ強制的に BOM に記述されているバージョンを利用させるようです。

例えば compile の場合には Guava は build.gradle で明示している 21.0 が使用されていますが、

compile - Dependencies for source set 'main'.
+--- org.springframework.boot:spring-boot-starter-web: -> 1.4.4.RELEASE
|    +--- org.springframework.boot:spring-boot-starter:1.4.4.RELEASE
..........
+--- com.google.guava:guava:21.0
..........

checkstyle プラグインでは 17.0 にバージョンダウンされており、

checkstyle - The Checkstyle libraries to be used for this project.
\--- com.puppycrawl.tools:checkstyle:7.5.1
     +--- antlr:antlr:2.7.7
     +--- org.antlr:antlr4-runtime:4.6
     +--- commons-beanutils:commons-beanutils:1.9.3
     |    \--- commons-collections:commons-collections:3.2.2
     +--- commons-cli:commons-cli:1.3.1
     \--- com.google.guava:guava:19.0 -> 17.0

errorprone プラグインでも同様に 17.0 にバージョンダウンされています。

errorprone
\--- com.google.errorprone:error_prone_core:2.0.18
     +--- com.google.errorprone:error_prone_annotation:2.0.18
     |    \--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.errorprone:error_prone_check_api:2.0.18
     |    +--- com.google.errorprone:error_prone_annotation:2.0.18 (*)
     |    +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     |    +--- org.checkerframework:dataflow:1.8.10
     |    |    \--- org.checkerframework:javacutil:1.8.10
     |    +--- com.google.errorprone:javac:9-dev-r3297-4
     |    +--- com.googlecode.java-diff-utils:diffutils:1.3.0
     |    \--- com.google.errorprone:error_prone_annotations:2.0.18
     +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
     +--- org.pcollections:pcollections:2.1.2
     +--- com.google.guava:guava:21.0 -> 17.0
     +--- com.google.auto:auto-common:0.7
     |    \--- com.google.guava:guava:19.0 -> 17.0
     +--- com.google.code.findbugs:jFormatString:3.0.0
     +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     +--- org.checkerframework:dataflow:1.8.10 (*)
     +--- com.google.errorprone:javac:9-dev-r3297-4
     \--- com.google.errorprone:error_prone_annotations:2.0.18

BOM で指定されているライブラリのバージョンを変更する

BOM で指定されているライブラリのバージョンを指定できる方法がないか Dependency management plugin のマニュアルを見てみたところ、Changing the value of a version propertybomProperty で変更できることが書いてありました。

http://repo.spring.io/repo/io/spring/platform/platform-bom/Athens-SR3/platform-bom-Athens-SR3.pom を見ると <guava.version>17.0</guava.version> という記述がありましたので、guava.version21.0 に変更すればよさそうです。

build.gradle を リンク先のその2の内容 に変更します。変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

gradlew dependencies コマンドを実行すると、今度は checkstyle plugin も errorprone plugin も Guava の 21.0 が使用されるようになりました。

checkstyle - The Checkstyle libraries to be used for this project.
\--- com.puppycrawl.tools:checkstyle:7.5.1
     +--- antlr:antlr:2.7.7
     +--- org.antlr:antlr4-runtime:4.6
     +--- commons-beanutils:commons-beanutils:1.9.3
     |    \--- commons-collections:commons-collections:3.2.2
     +--- commons-cli:commons-cli:1.3.1
     \--- com.google.guava:guava:19.0 -> 21.0

..........

errorprone
\--- com.google.errorprone:error_prone_core:2.0.18
     +--- com.google.errorprone:error_prone_annotation:2.0.18
     |    \--- com.google.guava:guava:21.0
     +--- com.google.errorprone:error_prone_check_api:2.0.18
     |    +--- com.google.errorprone:error_prone_annotation:2.0.18 (*)
     |    +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     |    +--- org.checkerframework:dataflow:1.8.10
     |    |    \--- org.checkerframework:javacutil:1.8.10
     |    +--- com.google.errorprone:javac:9-dev-r3297-4
     |    +--- com.googlecode.java-diff-utils:diffutils:1.3.0
     |    \--- com.google.errorprone:error_prone_annotations:2.0.18
     +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
     +--- org.pcollections:pcollections:2.1.2
     +--- com.google.guava:guava:21.0
     +--- com.google.auto:auto-common:0.7
     |    \--- com.google.guava:guava:19.0 -> 21.0
     +--- com.google.code.findbugs:jFormatString:3.0.0
     +--- com.google.code.findbugs:jsr305:3.0.0 -> 3.0.1
     +--- org.checkerframework:dataflow:1.8.10 (*)
     +--- com.google.errorprone:javac:9-dev-r3297-4
     \--- com.google.errorprone:error_prone_annotations:2.0.18

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

今度は lombok 関連と思われるエラーが出て BUILD FAILED になりました。

f:id:ksby:20170305145738p:plain f:id:ksby:20170305145845p:plain f:id:ksby:20170305145954p:plain

java.lang.ClassCastException: lombok.javac.apt.Javac7BaseFileObjectWrapper cannot be cast to javax.tools.FileObject の原因は?

エラーの原因を1つずつ調べていきます。まずは java.lang.ClassCastException: lombok.javac.apt.Javac7BaseFileObjectWrapper cannot be cast to javax.tools.FileObject からです。

lombok.javac.apt.Javac7BaseFileObjectWrapper クラスのソースを表示させてみたところ、継承元の com.sun.tools.javac.file.BaseFileObjectjavac が赤字で表示されています。com.sun.tools.javac.file.BaseFileObject クラスが含まれているライブラリが gradle により自動でロードされていないためのようです。

f:id:ksby:20170305175249p:plain

com.sun.tools.javac.file.BaseFileObject クラスが含まれるライブラリを調べたところ、com.sun.tools.javac.file.BaseFileObject - snacktrace のページがヒットしました。このライブラリを build.gradle に追記してみます。

build.gradle を リンク先のその3の内容 に変更します。変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

lombok.javac.apt.Javac7BaseFileObjectWrapper クラスのソースを表示させてみると、com.sun.tools.javac.file.BaseFileObjectjava の赤字は解消されていましたが、今度は class の行に赤波線が表示されていました。また LombokFileObject が赤字です。

f:id:ksby:20170305201258p:plain

IntelliJ IDEA の画面上部のパッケージ階層表示から lombok.javac.apt の下にある一覧を表示させてみましたが LombokFileObject は存在します。中のソースも見てみましたが、特におかしいところは見られませんでした。

f:id:ksby:20170305201607p:plain

Project Tool Window で org.projectlombok:lombok:1.16.12 の内容を表示させてみたところ、LombokFileObject は LombokFileObject.SCL.lombok というファイル名になっていてインターフェースとして表示されません。

f:id:ksby:20170305201926p:plain

通常は以下のようにインターフェースのアイコンが表示されます。

f:id:ksby:20170305202452p:plain

この状態で clean タスク → Rebuild Project → build タスクを実行しても以前と同じエラーが出ます。

f:id:ksby:20170305203000p:plain f:id:ksby:20170305203115p:plain

どうすればよいのか分からなくなったので Web でいろいろ検索してみたところ、以下の GitHub の Issue が見つかりました。

Issue を呼んだ感じでは、

  • どうやらこれは lombok + Error Prone を組み合わせた時のエラーらしい。
  • Error Prone のバグではなく lombok が javac 9 に対応していないためのようだ。Error Prone の出すメッセージは JDK 9 で問題となる箇所を指摘していたし、IntelliJ IDEA で使用する Java Compiler も「Javac」→「Javac with error-prone」へ変更したので、Error Prone を入れた後のコンパイルは素の JDK 8 のコンパイルとは違って JDK 9 用に何かしているようだ。
  • lombok は一旦対応して Error Prone の 2.0.15 ではエラーが出なくなったようだが ( 確かに 2.0.15 ではエラーは出ていなかった! )、2.0.18 でまた別のエラーが出ている模様。

という訳で、lombok が対応しないと Error Prone は 2.0.18 にバージョンアップできなさそうです。Error Prone の 2.0.16 以降でコンパイルエラーにならないバージョンがあるのか確認してみましたが、2.0.16 からダメでした。

結局、どう対応するのか?

以下のように対応することにします。

  • net.ltgt.gradle:gradle-errorprone-plugin は 0.0.8 → 0.0.9 へバージョンアップします。
  • com.google.errorprone:error_prone_core はバージョンアップせずに 2.0.15 のままとします。
  • com.google.errorprone:error_prone_core のバージョンの指定を configurations.errorprone に記述していましたが、Gradle error-prone plugin を見てみたら dependencies に書けることが分かったので、dependencies に書くようにします。
  • Guava のように Spring IO Platform の BOM に記述があるものは dependencyManagement の方に bomProperty でバージョンを指定して、dependencies にはバージョン番号を記述しないようにします。以下のライブラリを変更します。
    • com.google.guava:guava ( bomProperty で 21.0 を指定します )
    • org.apache.commons:commons-lang3 ( bomProperty で 3.5 を指定します )
  • com.google.errorprone:error_prone_core を 2.0.15 のままにすることにしたので compileOnly("org.checkerframework:compiler:2.1.9") は削除します。

build.gradle を リンク先の最終形の内容 の内容に変更します。変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク → Rebuild Project → build タスクを実行すると “BUILD SUCCESSFUL” が出力されました。

f:id:ksby:20170306020126p:plain

最後に

Error Prone ですが、JDK 9 対応のためなのか 2.0.16 以降はどうも素直にバージョンアップできないようです。以外に癖のあるライブラリでした。。。

普通に Web アプリケーションを作るだけならば入れない方が無難かもしれません。自分はもうしばらく入れたままにして様子を見たいと思います。

ソースコード

build.gradle

■その1

buildscript {
    ..........
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("io.spring.gradle:dependency-management-plugin:0.6.1.RELEASE")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.9")
        // for Grgit
        classpath("org.ajoberstar:grgit:1.8.0")
        // Gradle Download Task
        classpath("de.undercouch:gradle-download-task:3.2.0")
    }
}

..........

ext {
    errorproneVersion = '2.0.18'
}

configurations {
    // for Doma 2
    domaGenRuntime

    // for Error Prone ( http://errorprone.info/ )
    errorprone {
        resolutionStrategy.force "com.google.errorprone:error_prone_core:${errorproneVersion}"
    }
}

..........

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    ..........
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")
    ..........
}
  • buildscript の classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.8")classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.9") へ変更します。
  • ext { errorproneVersion = '2.0.18' } を追加します。configurations と dependencies それぞれに Error Prone のバージョンを記述していましたが、1箇所修正すればよいようにします。
  • configurations の resolutionStrategy.force 'com.google.errorprone:error_prone_core:2.0.15'resolutionStrategy.force "com.google.errorprone:error_prone_core:${errorproneVersion}" へ変更します。
  • dependencies の compileOnly("com.google.errorprone:error_prone_annotations:2.0.15")compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}") へ変更します。

■その2

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Athens-SR3") {
            bomProperty 'guava.version', '21.0'
        }
    }
}
  • mavenBom を () を付けて io.spring.platform:platform-bom:Athens-SR3 を渡すように変更した後、{ bomProperty 'guava.version', '21.0' } を追加します。

■その3

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    ..........
    compile("com.univocity:univocity-parsers:2.3.1")
    compileOnly("org.checkerframework:compiler:2.1.9")
    testCompile("org.dbunit:dbunit:2.5.3")
    ..........
  • compileOnly("org.checkerframework:compiler:2.1.9") を追加します。

■最終形

buildscript {
    ext {
        springBootVersion = '1.4.4.RELEASE'
    }
    repositories {
        jcenter()
        maven { url "http://repo.spring.io/repo/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("io.spring.gradle:dependency-management-plugin:0.6.1.RELEASE")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.9")
        // for Grgit
        classpath("org.ajoberstar:grgit:1.8.0")
        // Gradle Download Task
        classpath("de.undercouch:gradle-download-task:3.2.0")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'de.undercouch.download'
apply plugin: 'groovy'
apply plugin: 'net.ltgt.errorprone'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'

sourceCompatibility = 1.8
targetCompatibility = 1.8

task wrapper(type: Wrapper) {
    gradleVersion = '2.13'
}

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']

// for Doma 2
// JavaクラスとSQLファイルの出力先ディレクトリを同じにする
processResources.destinationDir = compileJava.destinationDir
// コンパイルより前にSQLファイルを出力先ディレクトリにコピーするために依存関係を逆転する
compileJava.dependsOn processResources

jar {
    baseName = 'ksbysample-webapp-lending'
    version = '1.1.0-RELEASE'
}

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

configurations {
    // for Doma 2
    domaGenRuntime
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = '7.5.1'
    sourceSets = [project.sourceSets.main]
}

findbugs {
    toolVersion = '3.0.1'
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    effort = "max"
    excludeFilter = file("${rootProject.projectDir}/config/findbugs/findbugs-exclude.xml")
}

tasks.withType(FindBugs) {
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

repositories {
    jcenter()
}

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Athens-SR3") {
            bomProperty 'commons-lang3.version', '3.5'
            bomProperty 'guava.version', '21.0'
        }
    }
}

bootRepackage {
    mainClass = 'ksbysample.webapp.lending.Application'
    excludeDevtools = true
}

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:9.4.1212"
    def spockVersion = "1.1-groovy-2.4-rc-3"
    def domaVersion = "2.15.0"
    def lombokVersion = "1.16.12"
    def errorproneVersion = '2.0.15'

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.thymeleaf.extras:thymeleaf-extras-springsecurity4")
    compile("org.thymeleaf.extras:thymeleaf-extras-java8time")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-freemarker")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-starter-data-redis")
    compile("org.springframework.boot:spring-boot-starter-amqp")
    compile("org.springframework.boot:spring-boot-devtools")
    compile("org.springframework.session:spring-session")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
    compile("com.google.guava:guava")
    compile("org.apache.commons:commons-lang3")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    testCompile("org.springframework.security:spring-security-test")
    testCompile("org.yaml:snakeyaml")

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    runtime("${jdbcDriver}")
    compile("org.seasar.doma:doma:${domaVersion}")
    compile("org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16")
    compile("org.simpleframework:simple-xml:2.7.1")
    compile("com.univocity:univocity-parsers:2.3.1")
    testCompile("org.dbunit:dbunit:2.5.3")
    testCompile("com.icegreen:greenmail:1.5.3")
    testCompile("org.assertj:assertj-core:3.6.2")
    testCompile("com.jayway.jsonpath:json-path:2.2.0")
    testCompile("org.jmockit:jmockit:1.30")
    testCompile("org.spockframework:spock-core:${spockVersion}") {
        exclude module: "groovy-all"
    }
    testCompile("org.spockframework:spock-spring:${spockVersion}") {
        exclude module: "groovy-all"
    }
    testCompile("com.google.code.findbugs:jsr305:3.0.1")

    // for lombok
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Doma-Gen
    domaGenRuntime("org.seasar.doma:doma-gen:${domaVersion}")
    domaGenRuntime("${jdbcDriver}")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")
}

..........

履歴

2017/03/06
初版発行。
2017/03/07
* com.google.common.base.Preconditions#checkArgument に関する記述でメソッドがないのにあると書いていたので、見直して修正しました。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その10 )( インジェクションの方法を @Autowired によるフィールドインジェクション → コンストラクタインジェクションへ変更する )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その9 )( 1.3系 → 1.4系で実装方法が変更された点を修正する ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • これまでは DI 対象のフィールドに @Autowired アノテーションを付加してインジェクションしていましたが、1.4 系からコンストラクタインジェクションが主流になったと聞いたので、コンストラクタインジェクションに変更します。

参照したサイト・書籍

  1. Spring 4.3 DIコンテナ関連の主な変更点
    http://qiita.com/kazuki43zoo/items/172d132ff8f4ba098888

  2. SpringでField InjectionよりConstructor Injectionが推奨される理由
    http://pppurple.hatenablog.com/entry/2016/12/29/233141

    • なぜコンストラクタインジェクションがよいのか、についてはこちらの記事が分かりやすかったです。
  3. Does JUnit4 testclasses require a public no arg constructor?
    http://stackoverflow.com/questions/1451496/does-junit4-testclasses-require-a-public-no-arg-constructor

目次

  1. コンストラクタインジェクションって面倒なのでは? と思ったが IntelliJ IDEA がきちんと補完してくれる!
  2. コンストラクタインジェクションに変更する
  3. clean タスク → Rebuild Project → build タスクを実行してみる
  4. Mail001Helper, Mail002Helper, Mail003Helper の3クラス全てに同じような修正をしたのに Mail003Helper だけ checkstyle のチェックで警告が出る理由は?
  5. 次回は。。。

手順

コンストラクタインジェクションって面倒なのでは? と思ったが IntelliJ IDEA がきちんと補完してくれる!

コンストラクタインジェクションが主流になったと聞いた時は、フィールドに @Autowired アノテーションを付けるだけと比較して、わざわざコンストラクタに引数を追加して、かつコンストラクタ内でフィールドにセットするのは書くのが面倒そうだなと思ったのですが、さすがは IntelliJ IDEA、サポートは万全でした。

例えばクラス内に final が付いたフィールドを記述すると、以下の画像のようにフィールドの下に赤波線が表示されます。

f:id:ksby:20170301014351p:plain

赤波線が表示されたフィールドにカーソルを移動して Alt+Enter を押すとコンテキストメニューが表示されます。

f:id:ksby:20170301014604p:plain

「Add constructor parameter」を選択するとコンストラクタが自動生成されて、コンストラクタインジェクションの処理が記述されます。

f:id:ksby:20170301020240p:plain

この補完はフィールドの出現順通りにコンストラクタの引数にセットしてくれます。例えば先程記述したフィールドの上に別のフィールドを記述して、

f:id:ksby:20170301021218p:plain

Alt+Enter を押して「Add constructor parameter」を選択すると、

f:id:ksby:20170301021325p:plain

コンストラクタの引数も先程記述された引数の前に追加されます。コンストラクタ内の記述もフィールドと同じ順に記述されます。

f:id:ksby:20170301021445p:plain

Javadoc も自動的に補完してくれます。コンストラクタに Javadoc を記述した後、記述済のフィールドの間に別のフィールドを記述します。

f:id:ksby:20170301021832p:plain

Alt+Enter を押して「Add constructor parameter」を選択すると、

f:id:ksby:20170301022006p:plain

引数やコンストラクタ内の記述の位置を中間にしてくれるだけでなく、Javadoc の記述の位置も中間になります。

f:id:ksby:20170301022109p:plain

また最初にフィールドだけ全て書いて、後からコンストラクタを生成することもできます。まずフィールドだけ書きます。

f:id:ksby:20170301023734p:plain

赤波線にカーソルを移動してから Alt+Enter を押します。コンテキストメニューが表示されますので、「Add constructor parameters」を選択します。複数だと parameter ではなく parameters になるとは。。。

f:id:ksby:20170304002702p:plain

「Choose Fields to Generate Constructor Parameters for」ダイアログが表示されます。デフォルトではフィールドは1つしか選択されていませんので、全て選択してから「OK」ボタンをクリックします。

f:id:ksby:20170304003032p:plain

コンストラクタが生成されます。

f:id:ksby:20170304003146p:plain

Lombok の @RequiredArgsConstructor アノテーションを付ければ final のフィールドだけを引数に持つコンストラクタを自動生成してくれるのでコンストラクタ自体を省略することが可能ですが、自分はソースを見たときの分かりやすさや、インジェクション対象のフィールドが多い時にはそのことに気付いた方がよい(依存関係が多くクラスの作りを見直すべき)という点を考慮すると、コンストラクタは書いた方が良さそうな気がします。

尚、削除する時はフィールドを削除して何かキーを押せばコンストラクタの方も自動で削除してくれるということはありませんでした。1つずつ削除しましょう。

コンストラクタインジェクションに変更する

以下の方法・方針で変更します。

  • 変更は以下の手順で行います。
    • @Autowired アノテーションを削除する。
    • フィールドの宣言に final を追加する。
    • コンストラクタインジェクションを記述する。
  • @Autowired によるフィールドインジェクションの数が多いクラスがあってもコンストラクタインジェクションに変更するだけで、クラスの実装は見直しません。
  • テストクラスは @Autowired によるフィールドインジェクションのままとします。JUnit4 のテストクラスには引数なしのコンストラクタが必ず必要らしいからです ( 実際にコンストラクタインジェクションに修正したら引数なしのコンストラクタがないというエラーメッセージが出ました )。

以下のソースを修正しました。

  • src/main/java/ksbysample/webapp/lending/config/ApplicationConfig.java
  • src/main/java/ksbysample/webapp/lending/config/WebSecurityConfig.java
  • src/main/java/ksbysample/webapp/lending/helper/library/LibraryHelper.java
  • src/main/java/ksbysample/webapp/lending/helper/mail/EmailHelper.java
  • src/main/java/ksbysample/webapp/lending/helper/mail/Mail001Helper.java
  • src/main/java/ksbysample/webapp/lending/helper/mail/Mail002Helper.java
  • src/main/java/ksbysample/webapp/lending/helper/mail/Mail003Helper.java
  • src/main/java/ksbysample/webapp/lending/helper/message/MessagesPropertiesHelper.java
  • src/main/java/ksbysample/webapp/lending/helper/user/UserHelper.java
  • src/main/java/ksbysample/webapp/lending/listener/rabbitmq/InquiringStatusOfBookQueueListener.java
  • src/main/java/ksbysample/webapp/lending/security/AuthenticationFailureBadCredentialsEventListener.java
  • src/main/java/ksbysample/webapp/lending/security/AuthenticationSuccessEventListener.java
  • src/main/java/ksbysample/webapp/lending/security/LendingUserDetailsService.java
  • src/main/java/ksbysample/webapp/lending/service/file/BooklistCsvFileService.java
  • src/main/java/ksbysample/webapp/lending/service/queue/InquiringStatusOfBookQueueService.java
  • src/main/java/ksbysample/webapp/lending/service/UserInfoService.java
  • src/main/java/ksbysample/webapp/lending/web/admin/library/AdminLibraryController.java
  • src/main/java/ksbysample/webapp/lending/web/admin/library/AdminLibraryService.java
  • src/main/java/ksbysample/webapp/lending/web/booklist/BooklistController.java
  • src/main/java/ksbysample/webapp/lending/web/booklist/BooklistService.java
  • src/main/java/ksbysample/webapp/lending/web/booklist/UploadBooklistFormValidator.java
  • src/main/java/ksbysample/webapp/lending/web/confirmresult/ConfirmresultController.java
  • src/main/java/ksbysample/webapp/lending/web/confirmresult/ConfirmresultService.java
  • src/main/java/ksbysample/webapp/lending/web/lendingapp/LendingappController.java
  • src/main/java/ksbysample/webapp/lending/web/lendingapp/LendingappService.java
  • src/main/java/ksbysample/webapp/lending/web/lendingapproval/LendingapprovalController.java
  • src/main/java/ksbysample/webapp/lending/web/lendingapproval/LendingapprovalService.java
  • src/main/java/ksbysample/webapp/lending/web/springmvcmemo/BeanValidationGroupController.java
  • src/main/java/ksbysample/webapp/lending/web/LoginController.java
  • src/main/java/ksbysample/webapp/lending/web/WebappErrorController.java
  • src/main/java/ksbysample/webapp/lending/webapi/library/LibraryController.java
  • src/main/java/ksbysample/webapp/lending/webapi/weather/WeatherController.java

clean タスク → Rebuild Project → build タスクを実行してみる

よく考えたら前回 clean タスク → Rebuild Project → build タスクを実行していませんでしたね。。。と思いつつ、実行してみます。

Error Prone の 2.0.18 がダウンロードされています。

f:id:ksby:20170304005232p:plain

その後で checkstyle の JavadocMethod の警告が出ています。追加したコンストラクタに Javadoc のコメントを付けていないからでしょう。でも警告が出力されたクラスを見ると Mail001Helper, Mail002Helper, Mail003Helper の3クラスに同じような修正をしたにも関わらず Mail003Helper しか出ていないのが気になりました。

f:id:ksby:20170304005629p:plain

あとテストが1つ失敗して “BUILD FAILED” の文字が出力されました。

f:id:ksby:20170304005743p:plain

Mail001Helper, Mail002Helper, Mail003Helper の3クラス全てに同じような修正をしたのに Mail003Helper だけ checkstyle のチェックで警告が出る理由は?

追加したコンストラクタを見ると、Mail001Helper クラスは、

    public Mail001Helper(FreeMarkerUtils freeMarkerUtils
            , JavaMailSender mailSender) {
        this.freeMarkerUtils = freeMarkerUtils;
        this.mailSender = mailSender;
    }

Mail003Helper クラスは、

    public Mail003Helper(FreeMarkerUtils freeMarkerUtils
            , JavaMailSender mailSender
            , ValuesHelper vh) {
        this.freeMarkerUtils = freeMarkerUtils;
        this.mailSender = mailSender;
        this.vh = vh;
    }

google_checks.xml の JavadocMethod の設定を見ると以下のように定義されていました。

        <module name="JavadocMethod">
            <property name="scope" value="public"/>
            <property name="allowMissingParamTags" value="true"/>
            <property name="allowMissingThrowsTags" value="true"/>
            <property name="allowMissingReturnTag" value="true"/>
            <property name="minLineCount" value="2"/>
            <property name="allowedAnnotations" value="Override, Test"/>
            <property name="allowThrowsTagsForSubclasses" value="true"/>
        </module>

おそらく原因は <property name="minLineCount" value="2"/> ですね。コンストラクタ内の行数により警告を出すか否かを判断しているようです。

<property name="minLineCount" value="3"/> に変更すると Mail003Helper クラスも警告されなくなりました。

f:id:ksby:20170304222035p:plain

<property name="minLineCount" value="1"/> に変更すると Mail001Helper, Mail002Helper, Mail003Helper の全てのクラスが警告されます。

f:id:ksby:20170304222346p:plain

さて、どうするかですが、

  • public, protected, private 問わず、メソッドには全てコメントは欲しい気がする。
  • Google Java Style Guide を見てみると、7.3 Where Javadoc is used に “At the minimum, Javadoc is present for every public class” と記載されていた。google_checks.xml<property name="scope" value="public"/> の定義が書かれているのは、このためだろう。これは Google Java Style Guide 通りでいいかな、と思った。
  • setter, getter のようなメソッド内に通常1行しか書かないものを対象外にしたいので <property name="minLineCount" value="2"/> のようなルールを設定しているような気がするが、自分は lombok を使うのでこの点は気にしなくていいはず。
  • そういえば Doma-Gen で自動生成した Entity クラスは lombok を使用せず独自に setter, getter を記述していたはずと思ったが、自動生成された Entity クラスのソースを見ると全てコメントが入っていた。
  • <property name="minLineCount" value="2"/> のルールのままにしておけば、依存関係が多くないコストラクタインジェクションが目的のコンストラクタは Javadoc がなくてもよくなるのか。。。 その方がいいかな、という気がする。

などと考えてみましたが、やっぱり public は全てコメントを付けるルールでいいかなと思ったので <property name="minLineCount" value="2"/> の設定は削除します。

google_checks.xml から <property name="minLineCount" value="2"/> の行を削除した後、警告が出ているメソッドに Javadoc を記述します。

次回は。。。

テストクラスのアノテーションの変更の前に以下2つの作業を行います。

  • Error Prone を 2.0.15 → 2.0.18 へバージョンアップします。
  • clean タスク → Rebuild Project → build タスク実行時に失敗したテストを見直します。

ソースコード

履歴

2017/03/05
初版発行。

Spring Boot + Spring Integration でいろいろ試してみる ( その18 )( @MessagingGateway でメソッド呼び出しのインターフェースで MessageChannel へ Message を送信する2 )

概要

記事一覧はこちらです。

参照したサイト・書籍

目次

  1. FTP アップロード処理にリトライ処理を追加する
  2. ErrorChannel の処理を作成する
  3. 動作確認
    1. 事前準備
    2. 1ファイルを SFTP サーバの /in ディレクトリに置いてみる
    3. 2ファイルを SFTP サーバの /in ディレクトリに置いてみる
    4. FTP サーバを停止して errorChannel に Message が送信されるようにしてみる
  4. メモ書き
    1. .handle(Mail.outboundAdapter(...).~).handleWithAdapter(a -> a.mail(...).~) は同じです&使い方の説明や感想など
    2. .handle(Mail.outboundAdapter(...).~) のメール送信処理は spring.mail.~ の設定が反映されない。。。と思って調べたら MailSendingMessageHandler って何?

手順

FTP アップロード処理にリトライ処理を追加する

前回作成した FTP サーバにアップロードする処理にリトライ処理を入れるのを忘れていたので、追加します。

  1. src/main/java/ksbysample/eipapp/messaginggateway の下の FlowConfig.javaリンク先のその1の内容 に変更します。

ErrorChannel の処理を作成する

Flow の処理中に例外が throw されると “errorChannel” という名前の MessageChannel に throw された例外がセットされた Message が送信されます。

“errorChannel” の MessageChannel は Spring Integration が自動的に生成します。生成しているのは org.springframework.integration.config.DefaultConfiguringBeanFactoryPostProcessor #postProcessBeanFactory です。

  1. まず throw された例外の stacktrace をログに出力されるのと同じ適宜インデントされたフォーマットの文字列で取得したいので、Guava の Throwables#getStackTraceAsString を使用できるようにします。build.gradle を リンク先の文字列 に変更します。

    変更後、Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

  2. src/main/java/ksbysample/eipapp/messaginggateway の下の FlowConfig.javaリンク先のその2の内容 に変更します。

動作確認

事前準備

まずは clean タスク → Rebuild Project → build タスクを実行して正常終了することを確認します。

f:id:ksby:20170226105416p:plain

次に Xlight FTP Server, FreeFTPd, smtp4dev, zipkin を起動します。zipkin は最新の zipkin-server-1.20.1-exec.jar をダウンロードしています。

f:id:ksby:20170226101713p:plain f:id:ksby:20170226101803p:plain f:id:ksby:20170226101843p:plain f:id:ksby:20170226105630p:plain

起動していて vagrant か docker で必要なサーバが起動する仮想サーバを作った方がいいな、と思いました。そのうち気が向いたらやってみます。

ログが見にくくなるので JavaMail の debug 機能を一旦 OFF にします。src/main/java/ksbysample/eipapp/messaginggateway の下の MailHelperConfig.java の sendMailFlow メソッドを以下のように変更します。

    @Bean
    public IntegrationFlow sendMailFlow() {
        return f -> f
                .handle(Mail.outboundAdapter(this.mailHost)
                        .port(this.mailPort)
                        .protocol(this.mailProtocol)
                        .defaultEncoding(this.mailDefaultEncoding)
                        .javaMailProperties(p -> p.put("mail.debug", "false")));
    }
  • .javaMailProperties(p -> p.put("mail.debug", "true")));.javaMailProperties(p -> p.put("mail.debug", "false"))); に変更します。

bootRun を実行して ksbysample-eipapp-messaginggateway を起動します。

1ファイルを SFTP サーバの /in ディレクトリに置いてみる

まずは testdata01.txt を SFTP サーバの send01 ユーザの /in ディレクトリに置いてみます。ファイルの文字コードUTF-8、改行コードは CRLF で以下の内容です。

これはテストです。
今日は晴れています。

f:id:ksby:20170226104932p:plain

SFTP サーバからファイルがダウンロードされて処理が行われ、FTP サーバの recv01 ユーザの /out ディレクトリにファイルがアップロードされました。

f:id:ksby:20170226110035p:plain

アップロードされた testdata01.txt の内容は以下のようになっていました。文字コードUTF-8、改行コードは CRLF です。

To: download@test.co.jp
Subject: testdata01.txt

これはテストです。
今日は晴れています。

IntelliJ IDEA のコンソールに出力されたログは以下のようになっており、特にエラーは出ていません。

f:id:ksby:20170226110420p:plain

smtp4dev に送信されたメールは以下の内容でした。こちらも特に問題ありませんでした。

f:id:ksby:20170226110520p:plain f:id:ksby:20170226111029p:plain f:id:ksby:20170226111120p:plain

Zipkin で処理状況を見ると以下のようになっていました。

f:id:ksby:20170226111659p:plain f:id:ksby:20170226111805p:plain

2ファイルを SFTP サーバの /in ディレクトリに置いてみる

次は testdata01.txt, testdata02.txt の2ファイルを SFTP サーバの send01 ユーザの /in ディレクトリに置いてみます。testdata02.txt の内容は以下のものです。

緊急警報です。

明日は、
雨ですね。

f:id:ksby:20170226112605p:plain

2ファイルとも SFTP サーバからファイルがダウンロードされて処理が行われ、FTP サーバの recv01 ユーザの /out ディレクトリにファイルがアップロードされました。

f:id:ksby:20170226112817p:plain

アップロードされた testdata01.txt, testdata02.txt の内容は以下のようになっていました。

■testdata01.txt

To: download@test.co.jp
Subject: testdata01.txt

これはテストです。
今日は晴れています。

■testdata02.txt

To: download@test.co.jp
Subject: testdata02.txt

緊急警報です。

明日は、
雨ですね。

IntelliJ IDEA のコンソールに出力されたログは以下のようになっており、特にエラーは出ていません。traceId は1ファイル毎に 1 ID 発行されています。

f:id:ksby:20170226113148p:plain

smtp4dev に送信されたメールは以下の内容でした。こちらも特に問題ありませんでした。

f:id:ksby:20170226113402p:plain f:id:ksby:20170226113454p:plain f:id:ksby:20170226113551p:plain

FTP サーバを停止して errorChannel に Message が送信されるようにしてみる

Xlight FTP Server を停止します。

f:id:ksby:20170226114000p:plain

testdata01.txt を SFTP サーバの send01 ユーザの /in ディレクトリに置きます。

今度は FTP アップロードできなかったため、C:\eipapp\ksbysample-eipapp-messaginggateway\send ディレクトリにファイルが残ったままになりました。

f:id:ksby:20170226114237p:plain

IntelliJ IDEA のコンソールに出力されたログは以下のようになっていました。例外が throw されています。

f:id:ksby:20170226114502p:plain

smtp4dev に送信されたメールは以下の内容でした。今回は「エラーが発生しました」メールが送信されており、送信されたメールの本文を確認すると例外の内容が出力されています。

f:id:ksby:20170226115052p:plain f:id:ksby:20170226115306p:plain

想定通りの動作になっていたので、動作確認を終わります。

メモ書き

.handle(Mail.outboundAdapter(...).~).handleWithAdapter(a -> a.mail(...).~) は同じです&使い方の説明や感想など

src/main/java/ksbysample/eipapp/messaginggateway の MailHelperConfig.java の中で以下のように記述していますが、

    @Bean
    public IntegrationFlow sendMailFlow() {
        return f -> f
                .handle(Mail.outboundAdapter(this.mailHost)
                        .port(this.mailPort)
                        .protocol(this.mailProtocol)
                        .defaultEncoding(this.mailDefaultEncoding)
                        .javaMailProperties(p -> p.put("mail.debug", "false")));
    }

これは以下と同じです。

    @Bean
    public IntegrationFlow sendMailFlow() {
        return f -> f
                .handleWithAdapter(a -> a.mail(this.mailHost)
                        .port(this.mailPort)
                        .protocol(this.mailProtocol)
                        .defaultEncoding(this.mailDefaultEncoding)
                        .javaMailProperties(p -> p.put("mail.debug", "false")));
    }

Mail 以外には Files, Ftp 等があり、以下の Web ページに説明があります。

Spring Integration Java DSL Reference - Using Protocol Adapters https://github.com/spring-projects/spring-integration-java-dsl/wiki/spring-integration-java-dsl-reference#using-protocol-adapters

.handleWithAdapter(a -> a.~) の “a” は “adapters” の略です。.handleWithAdapter(a -> a.~) の書き方の方が IDE の補完により使える Adapter が一覧表示されるので使いやすいと思います。

f:id:ksby:20170226142909p:plain

使い方ですが、Message に特定の header をセットして送信すると、その header と payload のデータを利用してメール送信したりファイル出力したりしてくれます。

例えば .handle(Mail.outboundAdapter(...).~) あるいは .handleWithAdapter(a -> a.mail(...).~) の場合ですが、

  • メール本文は payload にセットする。
  • From は header に “mail_from” をキーにしてセットする。
  • To は header に “mail_to” をキーにしてセットする。
  • Subject は header に “mail_subject” をキーにしてセットする。
  • “mail_from”, “mail_to”, “mail_subject” 等は org.springframework.integration.mail の MailHeaders クラスに定義されている。通常は MailHeaders.FROM, MailHeaders.TO, MailHeaders.SUBJECT を使用する。

という Message を送信すると header にセットされた From, To, Subject で payload の内容でメール送信します。

どのような header が使用できるのかは、

Spring Integration Reference Manual
http://docs.spring.io/spring-integration/reference/html/

の中の「V. Integration Endpoints」の下にある 21. Mail Support のような「~ Adapters」「~ Support」というタイトルのページの中に記述があります(たぶん)。

また .handleWithAdapter(a -> a.file(...).~).handleWithAdapter(a -> a.fileGateway(...).~) のように “Gateway” という文字列が付くものと付かないものが表示される Adapter がありますが、これはその次の処理に Message を送信するか否かで分けます。"Gateway" は次に Message を送信しますが、付かないものは送信しません ( outboundAdapter なので次に Message は送信されません )。

付かないものは .handle(...) の中で return null; を返しているのと同じです。

    .handle((p, h) -> {
        .....(ここに処理を記述する).....
        return null;
    })

Adapter 関連は使いこなせれば便利なのかもしれませんが、まだ以下のような感想を抱いていて使いこなせていない感があります。もう少しいろいろ強制的に使ってみて慣れないとダメかな。

  • outboundAdapter だとそこで処理が止まってしまうが、その後に処理は続けたい場合にどうしたらよいのか分かりづらい。
  • outboundGateway にすればよいのかもしれないが、outboundAdapter の処理だけやって次に処理を流してくれればよいだけなのに outboundGateway だと追加で指定をしないといけなかったりして何か使いずらい印象がある。gateway の考え方にまだ慣れていないだけなのか?

.handle(Mail.outboundAdapter(...).~) のメール送信処理は spring.mail.~ の設定が反映されない。。。と思って調べたら MailSendingMessageHandler って何?

今回メール送信用の共通処理では以下のように実装していますが、これは Spring Integration Java DSL Reference のサンプルを見て書いています。

                .handle(Mail.outboundAdapter(this.mailHost)
                        .port(this.mailPort)
                        .protocol(this.mailProtocol)
                        .defaultEncoding(this.mailDefaultEncoding)
                        .javaMailProperties(p -> p.put("mail.debug", "false")));

Spring Boot を使っていて、かつ compile("org.springframework.boot:spring-boot-starter-mail") を入れているのだから、自動生成されている javaMailSender Bean が利用されていて application.properties の spring.mail.~ の設定が反映されないのかな? と思い、

  • 上の .defaultEncoding(this.mailDefaultEncoding) の部分を削除する。
  • application.properties に spring.mail.default-encoding=UTF-8 を追加する。

としてみたのですが全く設定が反映されません。ソースを追ってみると、org.springframework.integration.dsl.mail.MailSendingMessageHandlerSpec の中で private final JavaMailSenderImpl sender = new JavaMailSenderImpl(); と実装されていて javaMailSender Bean は利用されていませんでした。

MailSendingMessageHandlerSpec クラスは MessageHandlerSpec<MailSendingMessageHandlerSpec, MailSendingMessageHandler> を継承していて、MailSendingMessageHandler クラスを見るとこちらはフィールドに private final MailSender mailSender; と書かれていて、かつコンストラクタに渡された MailSender インターフェースの実装クラスのインスタンスをセットしています。MailSendingMessageHandler クラスは javaMailSender Bean を DI しているんじゃ。。。と思いましたが、よく見たら MailSendingMessageHandler クラスには @Component アノテーションは付いていませんでした。こちらも javaMailSender Bean は使っていませんね。

なんか不便だな。。。 と思いましたが、javaMailSender Bean をコンストラクタに渡して MailSendingMessageHandler Bean を作成すればよいのでは?と思い、以下のテストコードを作成して動かしてみるとメールを送信することができました。

package ksbysample.eipapp.messaginggateway;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.mail.MailSendingMessageHandler;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.messaging.MessageChannel;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;

import javax.mail.internet.MimeMessage;

@ContextConfiguration
@RunWith(SpringRunner.class)
@DirtiesContext
@TestPropertySource(properties = {
        "spring.mail.host=localhost"
        , "spring.mail.default-encoding=UTF-8"
})
public class MailHelperConfigTest {

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    @Qualifier("testFlow.input")
    private MessageChannel testFlowInput;

    @Test
    public void sendMailFlow() throws Exception {
        MimeMessage mimeMessage = this.mailSender.createMimeMessage();
        MimeMessageHelper message = new MimeMessageHelper(mimeMessage, false, "UTF-8");
        message.setFrom("from@test.co.jp");
        message.setTo("to@sample.com");
        message.setSubject("これはテストです");
        message.setText("本文です。\r\n改行してみます。");

        this.testFlowInput.send(MessageBuilder.withPayload(message.getMimeMessage()).build());
    }

    @Configuration
    @EnableIntegration
    @ComponentScan
    public static class ContextConfiguration {

        @Autowired
        private JavaMailSender mailSender;

        @Bean
        public MailSendingMessageHandler mailSendingMessageHandler() {
            return new MailSendingMessageHandler(this.mailSender);
        }

        @Bean
        public IntegrationFlow testFlow() {
            return f -> f
                    .handle(mailSendingMessageHandler());
        }

    }

}

f:id:ksby:20170226200224p:plain f:id:ksby:20170226200325p:plain

メール送信には Mail.outboundAdapter(...) だけでなく MailSendingMessageHandler クラスが使えますね。発見です。このクラスだと SimpleMailMessage や MimeMailMessage クラスのインスタンスを payload にセットして Message を送信すればメール送信してくれるので添付ファイルがあるメールでも送れそうです。でもこのクラスって Spring Integration Reference Manual には記載されていないので、Spring Integration のソースを見ていないとさすがに分かりません。

またおそらくこれと同じように “~MessageHandler” というクラス名の AbstractMessageHandler インターフェースの実装クラスが他にも存在して、たぶん知っているとかなり便利なのでは?という気がしてきました。いつか調べてみたいと思います。

ソースコード

FlowConfig.java

■その1

@Configuration
public class FlowConfig {

    ..........

    /**
     * Message の payload にセットされた File オブジェクトが指し示すファイルを FTP サーバにアップロードする
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow ftpUploadFlow() {
        return f -> f
                .handleWithAdapter(a -> a.ftp(ftpSessionFactory()).remoteDirectory(PATH_FTP_UPLOAD_DIR)
                        , e -> e.advice(ftpUploadRetryAdvice()));
    }

    ..........

    /**
     * リトライは最大5回 ( SimpleRetryPolicy で指定 )、
     * リトライ間隔は初期値2秒、最大10秒、倍数2.0 ( ExponentialBackOffPolicy で指定 )
     * の RequestHandlerRetryAdvice オブジェクトを生成する
     *
     * @return RequestHandlerRetryAdvice オブジェクト
     */
    @Bean
    public Advice ftpUploadRetryAdvice() {
        RetryTemplate retryTemplate = new RetryTemplate();
        retryTemplate.setRetryPolicy(
                new SimpleRetryPolicy(5, singletonMap(Exception.class, true)));
        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(2000);
        backOffPolicy.setMaxInterval(10000);
        backOffPolicy.setMultiplier(2.0);
        retryTemplate.setBackOffPolicy(backOffPolicy);

        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
        advice.setRetryTemplate(retryTemplate);

        return advice;
    }

}
  • ftpUploadRetryAdvice メソッドを追加します。
  • ftpUploadFlow メソッドで , e -> e.advice(ftpUploadRetryAdvice()) を追加します。

■その2

@Configuration
public class FlowConfig {

    ..........

    private static final String ERRORMAIL_FROM = "system@sample.com";
    private static final String ERRORMAIL_TO = "alert@test.co.jp";
    private static final String ERRORMAIL_SUBJECT = "エラーが発生しました";

    ..........

    /**
     * errorChannel に送信された Message からエラーメッセージを取得してメールする
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow errorChannelFlow() {
        return IntegrationFlows.from("errorChannel")
                .<Exception>handle((p, h) -> {
                    String stacktrace = Throwables.getStackTraceAsString(p);
                    this.mailHelper.send(stacktrace
                            , new MapBuilder<>()
                                    .put(MailHeaders.FROM, ERRORMAIL_FROM)
                                    .put(MailHeaders.TO, ERRORMAIL_TO)
                                    .put(MailHeaders.SUBJECT, ERRORMAIL_SUBJECT)
                                    .get());

                    return null;
                })
                .get();
    }

}
  • errorChannelFlow メソッドを追加します。

■完成形

package ksbysample.eipapp.messaginggateway;

import com.google.common.base.Throwables;
import com.jcraft.jsch.ChannelSftp;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.aop.Advice;
import org.apache.commons.net.ftp.FTPFile;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.dsl.core.Pollers;
import org.springframework.integration.dsl.support.MapBuilder;
import org.springframework.integration.dsl.support.Transformers;
import org.springframework.integration.file.FileHeaders;
import org.springframework.integration.file.filters.AcceptAllFileListFilter;
import org.springframework.integration.file.filters.IgnoreHiddenFileListFilter;
import org.springframework.integration.file.remote.session.CachingSessionFactory;
import org.springframework.integration.file.remote.session.SessionFactory;
import org.springframework.integration.ftp.session.DefaultFtpSessionFactory;
import org.springframework.integration.handler.advice.ExpressionEvaluatingRequestHandlerAdvice;
import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice;
import org.springframework.integration.mail.MailHeaders;
import org.springframework.integration.sftp.session.DefaultSftpSessionFactory;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

import static java.util.Collections.singletonMap;

@Slf4j
@Configuration
public class FlowConfig {

    private static final String PATH_SFTP_DOWNLOAD_DIR = "/in";
    private static final String PATH_LOCAL_DOWNLOAD_DIR = "C:/eipapp/ksbysample-eipapp-messaginggateway/recv";
    private static final String PATH_LOCAL_UPLOAD_DIR = "C:/eipapp/ksbysample-eipapp-messaginggateway/send";
    private static final String PATH_FTP_UPLOAD_DIR = "/out";

    private static final String DOWNLOADFILEMAIL_FROM = "system@sample.com";
    private static final String DOWNLOADFILEMAIL_TO = "download@test.co.jp";

    private static final String ERRORMAIL_FROM = "system@sample.com";
    private static final String ERRORMAIL_TO = "alert@test.co.jp";
    private static final String ERRORMAIL_SUBJECT = "エラーが発生しました";

    private static final String CRLF = "\r\n";

    private final MailHelperConfig.MailHelper mailHelper;

    public FlowConfig(MailHelperConfig.MailHelper mailHelper) {
        this.mailHelper = mailHelper;
    }

    /**
     * SFTP サーバに接続するための SessionFactory オブジェクトを生成する
     *
     * @return SFTP サーバ接続用の SessionFactory オブジェクト
     */
    @Bean
    public SessionFactory<ChannelSftp.LsEntry> sftpSessionFactory() {
        DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(true);
        factory.setHost("localhost");
        factory.setPort(22);
        factory.setUser("send01");
        factory.setPassword("send01");
        factory.setAllowUnknownKeys(true);
        return new CachingSessionFactory<>(factory);
    }

    /**
     * FTP サーバに接続するための SessionFactory オブジェクトを生成する
     *
     * @return FTP サーバ接続用の SessionFactory オブジェクト
     */
    @Bean
    public SessionFactory<FTPFile> ftpSessionFactory() {
        DefaultFtpSessionFactory factory = new DefaultFtpSessionFactory();
        factory.setHost("localhost");
        factory.setPort(21);
        factory.setUsername("recv01");
        factory.setPassword("recv01");
        return new CachingSessionFactory<>(factory);
    }

    /**
     * SFTP サーバにあるファイルをダウンロードした後、ファイルの内容をメールで送信して、
     * 送信したメールの内容をファイルに出力して FTP サーバにアップロードする
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow sftpToMailToFtpFlow() {
        return IntegrationFlows
                // SFTP サーバの /in ディレクトリにファイルがあるか 5秒間隔でチェックする
                .from(s -> s.sftp(sftpSessionFactory())
                                .preserveTimestamp(true)
                                .deleteRemoteFiles(true)
                                .remoteDirectory(PATH_SFTP_DOWNLOAD_DIR)
                                .localDirectory(new File(PATH_LOCAL_DOWNLOAD_DIR))
                                .localFilter(new AcceptAllFileListFilter<>())
                                .localFilter(new IgnoreHiddenFileListFilter())
                        , e -> e.poller(Pollers.fixedDelay(5000)
                                .maxMessagesPerPoll(100)))
                .log()
                // ファイル名とファイルの絶対パスを Message の header にセットする
                .enrichHeaders(h -> h
                        .headerExpression(FileHeaders.FILENAME, "payload.name")
                        .headerExpression(FileHeaders.ORIGINAL_FILE, "payload.absolutePath"))
                // File の内容を読み込んで payload へセットする
                .transform(Transformers.fileToString())
                // ファイルの内容をメール本文とするメールを送信する
                .<String>handle((p, h) -> {
                    this.mailHelper.send(p
                            , new MapBuilder<>()
                                    .put(MailHeaders.FROM, DOWNLOADFILEMAIL_FROM)
                                    .put(MailHeaders.TO, DOWNLOADFILEMAIL_TO)
                                    .put(MailHeaders.SUBJECT, h.get(FileHeaders.FILENAME))
                                    .get());

                    return MessageBuilder.withPayload(p)
                            .setHeader(MailHeaders.TO, DOWNLOADFILEMAIL_TO)
                            .setHeader(MailHeaders.SUBJECT, h.get(FileHeaders.FILENAME))
                            .build();
                })
                .log()
                // メール送信した内容を payload にセットする
                .<String>handle((p, h) -> {
                    StringBuilder sb = new StringBuilder();
                    sb.append("To: " + h.get(MailHeaders.TO) + CRLF);
                    sb.append("Subject: " + h.get(MailHeaders.SUBJECT) + CRLF);
                    sb.append(CRLF);
                    sb.append(p);

                    return MessageBuilder.withPayload(sb.toString())
                            .build();
                })
                // /send ディレクトリの下に payload の内容を出力したファイルを生成する
                .handleWithAdapter(a -> a.fileGateway(new File(PATH_LOCAL_UPLOAD_DIR)))
                .log()
                // /recv ディレクトリの下のファイルを削除し、payload には /send ディレクトリの下の
                // ファイルを指す File オブジェクトをセットする
                .handle((p, h) -> {
                    try {
                        Files.delete(Paths.get((String) h.get(FileHeaders.ORIGINAL_FILE)));
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }

                    return MessageBuilder
                            .withPayload(Paths.get(PATH_LOCAL_UPLOAD_DIR
                                    , (String) h.get(FileHeaders.FILENAME)).toFile())
                            .build();
                })
                // /send ディレクトリの下に作成したファイルを FTP サーバにアップロードする
                .bridge(e -> e.advice(ftpUploadAdvice()))
                .log()
                // /send ディレクトリの下に作成したファイルを削除する
                .<File>handle((p, h) -> {
                    try {
                        Files.delete(Paths.get(p.getAbsolutePath()));
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }

                    return null;
                })
                .log()
                .get();
    }

    /**
     * Message の payload にセットされた File オブジェクトが指し示すファイルを FTP サーバにアップロードする
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow ftpUploadFlow() {
        return f -> f
                .handleWithAdapter(a -> a.ftp(ftpSessionFactory()).remoteDirectory(PATH_FTP_UPLOAD_DIR)
                        , e -> e.advice(ftpUploadRetryAdvice()));
    }

    /**
     * ftpUploadFlow へ Message を送信する ExpressionEvaluatingRequestHandlerAdvice Bean
     *
     * @return ExpressionEvaluatingRequestHandlerAdvice オブジェクト
     */
    @Bean
    public Advice ftpUploadAdvice() {
        ExpressionEvaluatingRequestHandlerAdvice advice
                = new ExpressionEvaluatingRequestHandlerAdvice();
        advice.setOnSuccessExpressionString("payload");
        advice.setSuccessChannelName("ftpUploadFlow.input");
        return advice;
    }

    /**
     * リトライは最大5回 ( SimpleRetryPolicy で指定 )、
     * リトライ間隔は初期値2秒、最大10秒、倍数2.0 ( ExponentialBackOffPolicy で指定 )
     * の RequestHandlerRetryAdvice オブジェクトを生成する
     *
     * @return RequestHandlerRetryAdvice オブジェクト
     */
    @Bean
    public Advice ftpUploadRetryAdvice() {
        RetryTemplate retryTemplate = new RetryTemplate();
        retryTemplate.setRetryPolicy(
                new SimpleRetryPolicy(5, singletonMap(Exception.class, true)));
        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(2000);
        backOffPolicy.setMaxInterval(10000);
        backOffPolicy.setMultiplier(2.0);
        retryTemplate.setBackOffPolicy(backOffPolicy);

        RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
        advice.setRetryTemplate(retryTemplate);

        return advice;
    }

    /**
     * errorChannel に送信された Message からエラーメッセージを取得してメールする
     *
     * @return IntegrationFlow オブジェクト
     */
    @Bean
    public IntegrationFlow errorChannelFlow() {
        return IntegrationFlows.from("errorChannel")
                .<Exception>handle((p, h) -> {
                    String stacktrace = Throwables.getStackTraceAsString(p);
                    this.mailHelper.send(stacktrace
                            , new MapBuilder<>()
                                    .put(MailHeaders.FROM, ERRORMAIL_FROM)
                                    .put(MailHeaders.TO, ERRORMAIL_TO)
                                    .put(MailHeaders.SUBJECT, ERRORMAIL_SUBJECT)
                                    .get());

                    return null;
                })
                .get();
    }

}

build.gradle

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    compile("org.springframework.integration:spring-integration-java-dsl:1.2.1.RELEASE")
    compile("org.projectlombok:lombok:1.16.14")
    compile("com.google.guava:guava:21.0")
    testCompile("org.assertj:assertj-core:3.6.2")
}
  • dependencies に compile("com.google.guava:guava:21.0") を追加します。

履歴

2017/02/26
初版発行。