kagamihogeの日記

kagamihogeの日記です。

HTTP Interface(WebClient)のリトライ

build.gradle

plugins {
  id 'java'
  id 'org.springframework.boot' version '3.0.6'
  id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
  compileOnly {
    extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-webflux'
  implementation 'org.springframework.boot:spring-boot-starter-aop'
  implementation 'org.springframework.retry:spring-retry'
  
  
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
  testImplementation 'io.projectreactor:reactor-test'
}

tasks.named('test') {
  useJUnitPlatform()
}

config

import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

@Configuration
public class HttpClientConfig {
  @Bean
  public SampleClient sampleClient() {
    WebClient webClient = WebClient
        .builder()
        .baseUrl("http://localhost:8080/")
        .filter(withRetryableRequests())
        .build();

    HttpServiceProxyFactory proxyFactory =
        HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient)).build();

    return proxyFactory.createClient(SampleClient.class);
  }

  private ExchangeFilterFunction withRetryableRequests() {
    return (request, next) -> next.exchange(request)
        .flatMap(clientResponse -> Mono.just(clientResponse)
            .filter(response -> clientResponse.statusCode().isError())
            .flatMap(response -> clientResponse.createException()).flatMap(Mono::error)
            .thenReturn(clientResponse))
        .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1L))
            .doAfterRetry(retrySignal -> System.out.println("retrying")));
  }
}

ソースコードhttps://medium.com/@robert.junklewitz/global-webclient-retry-logic-for-all-endpoints-in-spring-webflux-dbbe54206b63 からコピペしている。俺自身がreativeな書き方マッタク知らないので、ぶっちゃけ何やってるのかサッパリわからない。解説部分は以下の通り。

(1) I wrap the whole logic in a dedicated flatMap so that I still have access to the clientResponse object in step 5.
(2) I check if the clientResponse returned with an error code. That means if the response code was a 4xx or 5xx error. If there is no error I just want to proceed to step (5) because thenReturn returns this value after the Mono ended successfully. And if everything gets filtered out, the resulting empty Mono counts as successful.
(3) Now we are in the error processing mode. The clientResponse has a createException() method which will create an exception object (WebClientResponseException) and return a Mono of it. This is important because at this point the response is only a response object, no exception. Usually, when you look at the first code sample I posted, according to the documentation the retrieve() method will eventually map 4xx and 5xx responses to exceptions.
(4) This step also took me a bit to figure out. We have now a Mono of the exception, but the retryWhen still didn’t trigger. This happened because we didn’t trigger an onError signal yet. After all, we didn’t throw this exception. This flatMap will take care of it.
(5) Here we just return the clientResponse in case, we didn’t have an error in the first place, so logic continues as expected.
(6) Here we have our actual retry logic. This retryWhen mustn't be in the inner Mono definition, because you would just retry everything since Mono.just(clientResponse) which would just lead to a useless loop.


https://medium.com/@robert.junklewitz/global-webclient-retry-logic-for-all-endpoints-in-spring-webflux-dbbe54206b63

WebClientは従来のRestTemplate等と異なりレスポンスだけが単に返る訳では無いので、型をあれこれいじくり回す必要がある……らしい。

spring-retry

これとは別にspring-retryを使う方法もある。@EnableRetryとか設定は省略。

import org.springframework.retry.annotation.Retryable;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.service.annotation.GetExchange;

public interface SampleClient {  
  @Retryable(retryFor = WebClientResponseException.class, maxAttempts = 3)
  @GetExchange("/sample503")
  String sample503();
}

ただし、WebClient内部でリトライするのと、メソッドの外側からリトライするのとでは挙動が異なるはず。要件次第では合わないケースが出てくるかもしれない。

MyBatis ThymeleafのSQL Generator

MyBatis ThymeleafSQL生成機能であるSQL GeneratorはMyBatisとは独立して使用できる。基本的には他のO\Rマッパーと共に使用する。今回はmybatis-thymeleaf単体で使用可能な事を示すためにあえて他の依存性は何も追加せずにSQL生成のみ試す。

ソースコードなど

build.gradle

plugins {
  id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.mybatis.scripting:mybatis-thymeleaf:1.0.4'
}

使ってみる

import java.util.Map;
import org.mybatis.scripting.thymeleaf.SqlGenerator;

public class SqlGeneratorSample1 {
  public static void main(String[] args) {
    SqlGenerator sqlGenerator = new SqlGenerator();

    Map<String, Object> params = Map.of("id", 100);
    String sql = sqlGenerator.generate("""
        select
            id
            , username
        from
            my_users
        where
            id = /*[# mb:p="id"]*/ 1 /*[/]*/
        """, params);

    System.out.println(sql);
  }
}

出力結果は以下になる。

select
    id
    , username
from
    my_users
where
    id = #{id}

プレースホルダの形式をMyBatisから変更

デフォルト設定だとプレースホルダの形式はMyBatisの#{xxx}なので、これをJdbcTemplateなどで使われる:xxxに変更する。

import org.mybatis.scripting.thymeleaf.SqlGenerator;
import org.mybatis.scripting.thymeleaf.SqlGeneratorConfig;
import org.mybatis.scripting.thymeleaf.processor.BindVariableRender;

    SqlGeneratorConfig config = SqlGeneratorConfig.newInstanceWithCustomizer(c -> c.getDialect()
        .setBindVariableRenderInstance(BindVariableRender.BuiltIn.SPRING_NAMED_PARAMETER));
    SqlGenerator sqlGenerator = new SqlGenerator(config);

出力結果は以下になる。

(略)
where
    id = :id

カスタム変数

パラメータとは別にカスタム変数としてSQL生成時に使用する値を渡せる。カスタム変数は二種類ありSqlGeneratorに指定するデフォルトカスタム変数と、生成時に指定するカスタム変数がある。

import java.time.LocalDateTime;
import java.util.Map;
import org.mybatis.scripting.thymeleaf.SqlGenerator;
import org.mybatis.scripting.thymeleaf.SqlGeneratorConfig;
import org.mybatis.scripting.thymeleaf.processor.BindVariableRender;

public class SqlGeneratorSample1 {
  public static void main(String[] args) {
    SqlGeneratorConfig config = SqlGeneratorConfig.newInstanceWithCustomizer(c -> c.getDialect()
        .setBindVariableRenderInstance(BindVariableRender.BuiltIn.SPRING_NAMED_PARAMETER));
    SqlGenerator sqlGenerator = new SqlGenerator(config);

    Map<String, Object> defaultCustomVariables = Map.of("anyStaticValue", "hoge");
    sqlGenerator.setDefaultCustomVariables(defaultCustomVariables);

    Map<String, Object> customVariables = Map.of("now", LocalDateTime.now());
    Map<String, Object> params = Map.of("id", 100);

    String sql = sqlGenerator.generate("""
        select
            id
            , /*[# th:utext=\"${now} "]*/ '2023-05-07T20:20:47' /*[/]*/
        from
            my_users
        where
            id = /*[# mb:p="id"]*/ 1 /*[/]*/
            /*[# th:if="${anyStaticValue} == 'hoge'"]*/and hoge = 'hoge'/*[/]*/

        """, params, customVariables);

    System.out.println(sql);
  }
}

出力結果は以下になる。

select
    id
    , 2023-05-07T20:25:20.484348200
from
    my_users
where
    id = :id
    and hoge = 'hoge'

カスタムバインド変数の追加

SQLテンプレート処理でバインド変数を追加生成する場合にこれを使う。たとえば、以下はドキュメントの例そのままだが、nameLIKEで使うためにエスケープ処理したバインド変数patternNameを追加している。このためにSqlGeneratorcustomBindVariableBinderにバインド変数追加用のBiConsumer<String, Object>を指定する。

import java.util.HashMap;
import java.util.Map;
import org.mybatis.scripting.thymeleaf.SqlGenerator;
import org.mybatis.scripting.thymeleaf.SqlGeneratorConfig;
import org.mybatis.scripting.thymeleaf.processor.BindVariableRender;

public class SqlGeneratorSample2 {
  public static void main(String[] args) {
    SqlGeneratorConfig config = SqlGeneratorConfig.newInstanceWithCustomizer(c -> c.getDialect()
        .setBindVariableRenderInstance(BindVariableRender.BuiltIn.SPRING_NAMED_PARAMETER));
    SqlGenerator sqlGenerator = new SqlGenerator(config);

    Map<String, Object> params = new HashMap<>();
    params.put("name", "ho%_\\ge");

    String sql = sqlGenerator.generate("""
        /*[# mb:bind='patternName=|${#likes.escapeWildcard(name)}%|' /]*/
        select
            id,
            name
        from
            my_users
        where
            name = /*[# mb:p='patternName']*/ 'Sato' /*[/]*/
        """, params, params::put);

    System.out.println(sql);
    System.out.println(params);
  }
}
select
    id,
    name
from
    my_users
where
    name = :patternName

以下のように、バインド変数paramsnamepatternNameの2つになる。

{patternName=ho\%\_\\ge%, name=ho%_\ge}

Spring Batch 5さわる

Spring Batchは5.0から色々な変更が入った事を最近知った。

docs.spring.io

www.slideshare.net

なので、良くやる設定などでとりあえず触ってみる。何分初めてさわるバージョンなので間違ってる事書いていたら申し訳ない。

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-batch'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.batch:spring-batch-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

hello worldレベル

まずsystem.outするtaskletが一つだけのjobを作成する。

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

@SpringBootApplication
public class SampleBatch5App {
  public static void main(String[] args) {
    SpringApplication.run(SampleBatch5App.class, args);
  }
}
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class SampleBatch5Config {
  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job step");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

データソースは依存性にh2があれば自動的にそれに対して生成され、spring-batchのメタデータ用テーブルもそちらに自動的に生成される。transaction-managerも同様なので引数に指定すれば自動生成されたものがinjectionされる。

MapJobRepositoryFactoryBeanは削除された。メタデータテーブル使用しない場合にspring-batch5ではどうするかだが、依存性にh2追加すればこうなるので、これが修正方法の第一候補になるだろうか?

複数のjob

以下のように複数jobが有る場合はspring.batch.job.nameプロパティで実行したいjobのbean名を指定する。VM引数の場合は-Dspring.batch.job.name=sampleJob1のように指定する。

@Configuration
public class SampleBatch5Config {

  @Bean("job1")
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job 1");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob1", jobRepository)
        .start(step)
          .build();
  }

  @Bean("job2")
  Job sampleJob1(JobRepository jobRepository, PlatformTransactionManager txManager) {
    // 省略
  }

}

上述のプロパティを指定しない場合は下記のように実行時エラーになる。

Caused by: java.lang.IllegalArgumentException: Job name must be specified in case of multiple jobs
    at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.validate(JobLauncherApplicationRunner.java:116) ~[spring-boot-autoconfigure-3.0.4.jar:3.0.4]

明示的なh2データソースの指定

複数データソース指定の前に、ひとまずauto-configではなく明示的にh2のデータソースを指定する。以下のように src/main/resources/application.properties に指定する。javaはhello-worldレベルと同じなので省略。

spring.datasource.url jdbc:h2:mem:sampledb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false
spring.datasource.username sa
spring.datasource.password 
spring.datasource.driverClassName org.h2.Driver

複数データソース

メインDBをmysqlメタデータをH2と想定する。ここの設定方法はspring-batch 5より前と変更無い。

mysqlJDBCドライバの依存性を追加する。

runtimeOnly 'com.mysql:mysql-connector-j'

データソース2つ分のプロパティを作成する。

spring.datasource.h2metadata.url jdbc:h2:mem:sampledb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false
spring.datasource.h2metadata.username sa
spring.datasource.h2metadata.password 
spring.datasource.h2metadata.driverClassName org.h2.Driver

spring.datasource.mysqlmain.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.mysqlmain.url=jdbc:mysql://localhost:13306/mysql
spring.datasource.mysqlmain.username=root
spring.datasource.mysqlmain.password=password
import javax.sql.DataSource;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.boot.autoconfigure.batch.BatchDataSource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class SampleBatch5Config {

  @Bean
  @ConfigurationProperties("spring.datasource.h2metadata")
  DataSourceProperties h2Properties() {
    return new DataSourceProperties();
  }

  @Bean
  @ConfigurationProperties("spring.datasource.mysqlmain")
  DataSourceProperties mysqlProperties() {
    return new DataSourceProperties();
  }

  @BatchDataSource
  @Bean
  DataSource h2DataSource() {
    return h2Properties()
        .initializeDataSourceBuilder()
          .build();
  }

  @Primary
  @Bean
  DataSource mysqlDataSource() {
    return mysqlProperties()
        .initializeDataSourceBuilder()
          .build();
  }

  @Bean
  JdbcTemplate jdbcTemplate() {
    return new JdbcTemplate(mysqlDataSource());
  }

  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager mainTxManager) {
    System.out.println(mainTxManager);
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("asdf");
          jdbcTemplate().update("insert into sampletable(rname) values ('sample')");

          return RepeatStatus.FINISHED;
        }, mainTxManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

@ConfigurationPropertiesDataSourcePropertiesに各DB用設定をバインドする。さらにそれぞれの設定でDataSourceを作成する。メインであるmysqlには@Primaryを付与し、h2にはメタデータ用データソースを示す@BatchDataSourceを付与する。

transaction-managerは@Primaryを付与したmysqlデータソースに対して自動生成される。が、どちらのDBに対して自動生成されてるのかパッと見わかりにくいので、個人的には明示的にmysqlに対するJdbcTransactionManagerを作った方がいいかも……などと思う。

job parameter

job parameterのフォーマットが変更された。

@Configuration
public class SampleBatch5Config {
  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          Map<String, Object> p = chunkContext.getStepContext().getJobParameters();

          System.out.println(p.get("hoge(int)")); // hogehoge
          System.out.println(p.get("localdate").getClass()); // class java.time.LocalDate
          System.out.println(p.get("localdatetime").getClass()); // class java.time.LocalDateTime

          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }
}

上記のstepでの引数の指定例は以下の通り。前バージョンのフォーマットparameterName(parameterType)のままだとhoge(int)のように型ごとkeyになる。

hoge(int)=hogehoge  \
intkey=10,java.lang.Integer  \
localdate=2022-12-12,java.time.LocalDate  \
localdatetime=2011-12-03T10:15:30,java.time.LocalDateTime

parameterName=parameterValue,parameterType,identificationFlagidentificationFlagtrue(デフォルト)だとジョブ実行のキー生成にこのパラメータが使用される。spring-batchの同一ジョブ実行判定はjob parameterが同一かどうかで行う。このフラグの導入により、あるjob parameterを同一判定に含むかどうかを切り替えられる。

job parameter(json)

json形式でjob parameterを渡すこともできる。

localdate={\"value\":\"2022-12-12\",\"type\":\"java.time.LocalDate\"}

jsonの場合はJobParametersConverterJsonJobParametersConverterで置き換える。

@Configuration
public class SampleBatch5Config {

  @Bean
  public JobParametersConverter jobParametersConverter() {
    return new JsonJobParametersConverter();
  }

 // ... 省略

DefaultBatchConfiguration

spring-batch全体の設定変更するDefaultBatchConfigurationが追加された。とりあえず使ってみる。

import javax.sql.DataSource;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.batch.BatchDataSource;
import org.springframework.boot.autoconfigure.batch.BatchDataSourceScriptDatabaseInitializer;
import org.springframework.boot.autoconfigure.batch.BatchProperties;
import org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.sql.init.dependency.DatabaseInitializationDependencyConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.util.StringUtils;

@Configuration
@EnableConfigurationProperties(BatchProperties.class)
@Import(DatabaseInitializationDependencyConfigurer.class)
public class SampleBatch5Config extends DefaultBatchConfiguration {

  @Bean
  public JobLauncherApplicationRunner jobLauncherApplicationRunner(JobLauncher jobLauncher,
      JobExplorer jobExplorer,
      JobRepository jobRepository, BatchProperties properties) {
    JobLauncherApplicationRunner runner = new JobLauncherApplicationRunner(jobLauncher, jobExplorer,
        jobRepository);
    String jobNames = properties.getJob().getName();
    if (StringUtils.hasText(jobNames)) {
      runner.setJobName(jobNames);
    }
    return runner;
  }

  @Configuration(proxyBeanMethods = false)
  static class DataSourceInitializerConfiguration {
    @Bean
    BatchDataSourceScriptDatabaseInitializer batchDataSourceInitializer(DataSource dataSource,
        @BatchDataSource ObjectProvider<DataSource> batchDataSource, BatchProperties properties) {
      return new BatchDataSourceScriptDatabaseInitializer(
          batchDataSource.getIfAvailable(() -> dataSource),
          properties.getJdbc());
    }
  }

  @Bean
  Job sampleJob(JobRepository jobRepository, PlatformTransactionManager txManager) {
    System.out.println(txManager);
    Step step = new StepBuilder("sampleStep", jobRepository)
        .tasklet((contribution, chunkContext) -> {
          System.out.println("sample job step");
          return RepeatStatus.FINISHED;
        }, txManager)
          .build();

    return new JobBuilder("sampleJob", jobRepository)
        .start(step)
          .build();
  }

}

ちなみに、上のサンプルコードは何も設定変更していない(=何も@overrideしてない)。

ハマった点として、DefaultBatchConfigurationのbeanがあるとspring-bootのBatchAutoConfigurationが無効になる。なので、spring.batch.job.nameのjob起動とか、メタデータテーブル自動生成とか、その辺が実行されない。なので、上のサンプルコードはその辺の設定をBatchAutoConfigurationからコピペしている。

とはいえ、ジョブ起動はJobLauncherだったり、メタデータmysqlとかに永続化してたり、色々なのでこの辺の設定はプロジェクトによってそれぞれと思われる。