kagamihogeの日記

kagamihogeの日記です。

gradleのversion catalogでsubprojectで共通のバージョンを定義

JJUG CCC 2023 Spring で最近のGradleにはversion catalogというmultiple projectでバージョン定義を共有する仕組みがあるのを知った。その時に講演していた方のプレゼン資料は Gradleと仲良くなる第一歩 ~小規模PJから大規模PJへ~ にある。

https://docs.gradle.org/current/userguide/platforms.html 公式ドキュメントとしてはこのあたりを参照している。

ソースコードなど

以下の記述においてdependencyResolutionManagementsettings.gradleに記述する。

version

  • java 17
  • gradle 8.1.1

library - バージョン定義のエイリアス

settings.gradle にライブラリのバージョン定義にエイリアスをつけられる。各multiple projectのbuild.gradleはそのエイリアスでバージョン定義を参照できる。

以下はgroovyとcommon-lang3のエイリアスを作成している。

dependencyResolutionManagement {
    versionCatalogs {
        libs {
            library('groovy-core', 'org.apache.groovy:groovy:4.0.12')
            library('commons-lang3', 'org.apache.commons', 'commons-lang3').version('3.12.0')
        }
    }
}

build.gradleでは以下のようにエイリアスを使用する。ハイフン("-"), アンダースコア(".")はドットに読み替えの必要がある。公式ドキュメント的にはセパレータはハイフンを推奨している。

  implementation(libs.groovy.core)
  implementation libs.commons.lang3

セパレータで分かりやすく区切るかどうかは任意で、お気に召さなければgroovyCoreでも良いよ、と公式ドキュメントには書いてある。

version, versionRef - バージョン番号のエイリアス

バージョン番号のみのエイリアスversionを使用する。

以下のようにgroovy関連がすべて同一のバージョン番号の場合はversionでそのバージョン番号を定義してversionRefで参照する。

dependencyResolutionManagement {
    versionCatalogs {
        libs {
            version('groovy', '4.0.12')
            version('java-version', '17')
            
            library('groovy-core', 'org.apache.groovy', 'groovy').versionRef('groovy')
            library('groovy-json', 'org.apache.groovy', 'groovy-json').versionRef('groovy')
            library('groovy-nio', 'org.apache.groovy', 'groovy-nio').versionRef('groovy')
        }
    }
}

build.gradleでの使用方法はlibraryと変わらない。

  implementation(libs.groovy.core)
  implementation(libs.groovy.json)
  implementation(libs.groovy.nio)

また、dependencies以外でもversionは参照できる。以下はsettings.gradle に定義したjavaのversionをbuild.gradleで参照している。

sourceCompatibility = libs.versions.java.version.get()

bundle - 複数の定義をまとめる

複数のバージョン定義をまとめたグループに対してエイリアスを作れる。以下はspring-bootのjdbcとwebをspring-bundleというbundleにまとめている。この場合バージョンは不要なのでwithoutVersion()を付与している。

dependencyResolutionManagement {
    versionCatalogs {
        libs {
            library('spring-boot-starter-jdbc', 'org.springframework.boot', 'spring-boot-starter-jdbc').withoutVersion()
            library('spring-boot-starter-web', 'org.springframework.boot', 'spring-boot-starter-web').withoutVersion()
            
            bundle('spring-bundle', ['spring-boot-starter-jdbc', 'spring-boot-starter-web'])
        }
    }
}

build.gradleでは以下で参照する。

implementation libs.bundles.spring.bundle

pluginのエイリアス

pluginは以下のようにエイリアスを作成する。以下はpluginでspring-bootのバージョンのエイリアスを作成している。

dependencyResolutionManagement {
    versionCatalogs {
        libs {
            plugin('spring-boot-version', 'org.springframework.boot').version('3.1.0')
        }
    }
}

build.gradleでは以下のように使用する。

plugins {
  id 'java'
  // id 'org.springframework.boot' version '3.1.0' // これと同じ結果になる。
  alias(libs.plugins.spring.boot.version)
  id 'io.spring.dependency-management' version '1.1.0'
}

複数カタログ

基本的にはlibsを使用するが自前定義のカタログを作成できる。以下はspringLibsという自前定義のカタログを追加している。

dependencyResolutionManagement {
    versionCatalogs {
        libs {
          // (省略)
        }
        springLibs {
            library('spring-boot-starter-jdbc', 'org.springframework.boot', 'spring-boot-starter-jdbc').withoutVersion()
            library('spring-boot-starter-web', 'org.springframework.boot', 'spring-boot-starter-web').withoutVersion()
            
            bundle('spring-bundle', ['spring-boot-starter-jdbc', 'spring-boot-starter-web'])
        }
    }
}

自前定義のカタログは以下のように使用する。

implementation springLibs.bundles.spring.bundle

tomlファイル

ちゃんと試してないのだけど以下のような形式のファイルでversion catalogを定義できるらしい。別ファイルに切り出して、別プロジェクトでもバージョン定義を共有したい、場合に有用らしい。

[versions]
groovy = "3.0.5"
checkstyle = "8.37"

[libraries]
groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer="3.9" } }

[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]

[plugins]
versions = { id = "com.github.ben-manes.versions", version = "0.45.0" }

プロジェクト間のversion catalog共有の方法については https://docs.gradle.org/current/userguide/platforms.html#sec:version-catalog-plugin に自前のpluginを書いてgitにアップという方法も紹介している。

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}