Spring Boot 1.5系から2.1系へのマイグレーションガイド


はじめに

あるWebサービスで利用していたSpring Bootを、サービスリリース時の1.5.4.RELEASEから(2019年5月時点の最新版である)2.1.4.RELEASEまでバージョンアップした際の知見集です。
バージョンアップ対応は昨年の5月頃に完了し、完了時点での知見を社内向けのesaにまとめていたのですが、こちらはそれをベースに公開用に編集したものになります。

(グラフは某社の決算発表をイメージして、バージョンアップのインパクトを視覚的に伝えるために作成したネタです)

弊プロダクトにおいては、Lombokで横着していた箇所の宣言順による問題と、Mockitoの挙動変更に伴うテストの修正が大きめの印象でした。
メトリクスの収集にPrometheusを使用していたのですが、Spring Boot 2系から標準になったMicrometerへの切り替えは挙動を理解しながら修正していくコストが少々大きかったですが、慣れると非常に便利で分かりやすいと感じました。
また、一部のサブシステムにおいてapplication.ymlを思いっきりキャメルケースで書いていたため、ケバブケースへの書き換えに伴って@ConfigurationProperties などを利用して直接YAMLのキーを指定して値を読み出している箇所の修正も必要でした。

実装周り

Lombokアノテーションの仕様変更

Lombok v1.16.22のCHANGELOGによると、

FEATURE: Private no-args constructor for @Data and @Value to enable deserialization frameworks (like Jackson) to operate out-of-the-box. 
Use lombok.noArgsConstructor.extraPrivate = false to disable this behavior.

簡単に言うと「@Data@Valueアノテーションを付与すると引数なしの(デフォルト)コンストラクタがprivateで宣言されるのがデフォルトの挙動になったよ」とのこと。

「lombok.propertiesにlombok.noArgsConstructor.extraPrivate = falseって書くとこの挙動が外れるよ」ともあるが、@Dataより先に@NoArgsConstructorを宣言することで先にpublicなデフォルトコンストラクタが作成されるので、影響範囲が小さかったり、余計なプロパティファイルを書きたくない場合は(今のところは)アノテーションの順番を変えるという解決策をとることもできます(出典)。

ただ、この変更の目的(Jacksonがデシリアライズするときにデフォルトコンストラクタが必要だから、という趣旨)と一致するのであれば、明示的にデフォルトコンストラクタを作らなくてもよくなったということですので、用途や現状のシステムにおけるユースケースによって判断する必要がありそうです。

Netty4ClientHttpRequestFactory is Deprecated

SpringFramework 5系から廃止されたため、Apache HttpClientを利用することにしました(参考)。
他にもOkHttp3なども利用できるようです。

メトリクスがMicrometer経由になった

弊プロダクトではio.prometheussimpleclient-spring-bootを使用してPrometheusフォーマットによるメトリクスの出力を行っていたのですが、これをSpring Boot 2系の標準メトリクスライブラリであるMicrometerに切り替える必要がありました。
ちょっとサンプルは書きづらいのですが、基本的にはMicrometerのセットアップ(どのメトリクス製品を使うか)と、GaugeやCounterなどを読み替えて適宜用途に合うように修正していきます。

// io.prometheus でのGaugeの作り方の例
Gauge gauge = Gauge.build() 
        .name("health_dsl_context") 
        .help("Health dsl context") 
        .register();

// io.micrometer でのGaugeの作り方の例
@Autowired /* 何らかの方法でBeanをInjectしてください */
private PrometheusMeterRegistry prometheusMeterRegistry;

AtomicDouble newGauge = prometheusMeterRegistry.gauge("health_dsl_context", new AtomicDouble(0.0));

@ConfigurationProperties のプレフィックス指定は小文字のケバブケースで書かないと例外を吐くよ

application.yml に記述したプロパティを @ConfigurationProperties によって読み出すことがあるかと思いますが、Spring Boot 2.0以降から参照する時にキャメルケース以外のケースで書くと InvalidConfigurationPropertyNameException を吐くようになりました。

リファレンス によると、

The prefix value for the annotation must be in kebab case (lowercase and separated by -, such as acme.my-project.person).

とのことで、YAMLファイルのほうは(上述の通り)キャメルケースなどで書いても問題ないのですが、コード側からの読み出し時に上記のような制約をかけられている以上、ケースを混ぜ書きしていると設定値の可読性が下がりそうなので、小文字ケバブケースでの記述に揃えることにしました。

allow-bean-definition-overriding は明示的に指定する

標準ではBeanのオーバーライドが無効になりました。
利用している場合は application.ymlspring.main.allow-bean-definition-overriding=true 指定を追加します。
(テストで使っているだけ、といった場合は環境プロファイルなどで切り替えるとよいかもしれません)

MySQLのConnector/JとjOOQのパッケージクラス変更

Connector/Jのバージョンアップに伴い、com.mysql.jdbc.Driver -> com.mysql.cj.jdbc.Driver に変更します(パッケージだけではなくドライバ指定もすべて)。

また、jOOQのコードジェネレータ設定は以下のように変更します。

org.jooq.util.JavaGenerator -> org.jooq.codegen.JavaGenerator
org.jooq.util.mysql.MySQLDatabase -> org.jooq.meta.mysql.MySQLDatabase

Jacksonでシリアライズするときのタイムスタンプフォーマット指定

application.ymlspring.jackson.datetime="yyyy-MM-dd'T'HH:mm:ss.SSSX" を指定しました。
これを書かないと、タイムゾーン識別子が +0000 のような形式になります。

SpringBootApplicationBuilderの起動モード

リアクティブモードが増えたことでEnumによる指定に切り替わったため .web(WebApplicationType.(SERVLET|NONE|REACTIVE)) といった形で書き直します。

public static void main(String... args) {
    new SpringApplicationBuilder()
        .web(WebApplicationType.NONE)
        .sources(ExampleConfiguration.class)
        .main(ExampleApp.class)
        .build()
        .run(args);
}

RequestInterceptorでHandlerMethodを受け取る場合

ちゃんとinstanceofで型チェックを入れないと死にます(むしろ、今までよく動いてたな…)。

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
    throws Exception {

  if (handler instanceof  HandlerMethod) {
    /* handlerを扱う処理 */
  }

}

Content-type: multipart/form-data;boundary= で完全一致のテストケースを書かない

小ネタですが、boudary=の前にencoding=UTF-8;が入るようになったため、完全一致を成立条件にしていたテストが落ちました。
テストケースの設定も悩みどころではありますが、containsStringで比較する等、ある程度柔軟に受け入れるようなケースを書いてあげるほうがよいでしょう。

@RestController を付与したコントローラーで返り値をString型にしているときのContent-typeが text/plain になりました

以前は(おそらく明示的な指定がない限り)application/json で返っていたため、この修正によってクライアントがレスポンスをパースできなくなるといった問題が発生しました。
文字列だけのJSONも構造としては許容されるので今までの挙動でも問題はなかったと思いますが、思わぬハマりポイントでした。

回避策としては、Content-type: application/json で返すことを正とすると、JacksonのTextNodeとして返してあげることで解決できます。
参考にしたStackOverflowの回答

@GetMapping("/v1/test")
public TextNode returnStringOnlyJson() {
    return new TextNode("responseText");
}

RestTemplateBuilderのタイムアウト指定はDurationによる指定になりました

今まではミリ秒をlong型で指定する形だったので、下記のように書き直しました。

private RestTemplateBuilder restTemplateBuilder(int connectTimeout, int readTimeout) {
    return new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofMillis(connectTimeout))
            .setReadTimeout(Duration.ofMillis(readTimeout));
}

テスト周り

MockitoのanyObject()anyListOf()はDeprecatedになりました。

any()anyList()any(HogeHoge.class) を使いましょう。

MockitoのanyInteger() などはそのまま使うとnull非許容になりました。

nullable(HogeHoge.class) を使いましょう。