Spring Data JPAとSpring Data JDBCの共存

現在、Spring Data JPAを利用してて、JPAからの脱却をするとなると現実的な移行方法を考えたときに、Spring Data JDBCが移行先かなと思い検証を実施しました。 レポジトリは、spring-data-jpa-and-jdbcです。

今回の環境

- Java 21
- Spring Boot 3.4.0

検証手順

- Spring Data JPAを利用したアプリケーションの実装
- Spring Data JDBCを依存関係に追加し、Spring Data JPAで実装されている処理と同等の処理を追加

検証

Spring Data JPAを利用したアプリケーションの実装

build.gradleに以下の依存関係を追加します。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
}

次に、以下のER図をものにJPA エンティティを実装します。

ER図

@Entity
@Table(name = "shop")
@EntityListeners(AuditingEntityListener.class)
public class JpaShop {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    @CreatedDate
    @ReadOnlyProperty
    private Instant createdAt;

    @LastModifiedDate
    @ReadOnlyProperty
    private Instant updatedAt;

    @OneToMany(mappedBy = "shop")
    private List<JpaUser> users;

   // getter, setterは省略
}
@Entity
@Table(name = "user")
@EntityListeners(AuditingEntityListener.class)
public class JpaUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    private Integer age;

    @CreatedDate
    @ReadOnlyProperty
    private Instant createdAt;

    @LastModifiedDate
    @ReadOnlyProperty
    private Instant updatedAt;

    @ManyToOne
    @JoinColumn(name = "shop_id")
    private JpaShop shop;

   // getter, setterは省略
}

設定の追加

@Configuration(proxyBeanMethods = false)
@EnableJpaRepositories(
        basePackages = {
                "com.b1a9idps.spring_data_jpa_and_jdbc.application.repository.jpa"
        }
)
@EnableJpaAuditing
public class JpaConfig {
}

あとは、JpaRepository.javaをextendsしたインターフェースを実装して、データアクセスします。

Spring Data JDBCの導入

まず、build.gradleにSpring Data JDBCを追加します

dependencies {
    // 追加
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
}

次に、先に実装したJPAエンティティと同等のクラスを実装します。

@Table("shop")
public class JdbcShop {
    @Id
    private Integer id;

    private String name;

    @MappedCollection(idColumn = "shop_id", keyColumn = "shop_id")
    private List<JdbcUser> users;

    @CreatedDate
    @ReadOnlyProperty
    private LocalDateTime createdAt;

    @LastModifiedDate
    @ReadOnlyProperty
    private LocalDateTime updatedAt;

   // getter, setterは省略
@Table("user")
public class JdbcUser {
    @Id
    private Integer id;

    private String name;

    private Integer age;

    private Integer shopId;

    @CreatedDate
    @ReadOnlyProperty
    private Instant createdAt;

    @LastModifiedDate
    @ReadOnlyProperty
    private Instant updatedAt;

   // getter, setterは省略

設定の追加

@Configuration(proxyBeanMethods = false)
@EnableJdbcRepositories(
        basePackages = {
                "com.b1a9idps.spring_data_jpa_and_jdbc.application.repository.jdbc"
        }
)
@EnableJdbcAuditing
public class JdbcConfig extends AbstractJdbcConfiguration {
}

あとは、ListPagingAndSortingRepository.javaをextendsしたインターフェースを実装して、データアクセスします。

Spring Data JPAとSpring Data JDBCを共存させるときの注意点

1. JDBCのエンティティには spring-data-relational@Tableを付与する

JDBCのエンティティには spring-data-relational@Tableを付与します。付与しない場合は、テーブルにマッピングするクラスを認識されません。

2. エンティティに付与する @Idアノテーションを間違えない

わかりづらいですが、Spring Data JPAのエンティティには、jakarta.persistence-api@Idを付与し、Spring Data JDBCのエンティティには、spring-data-commons@Idを付与します。

まとめ

Spring Data JDBCははじめて触ったが、Spring Data JDBCは、シンプルで規約に従ったデータ操作を優先しており、JPAのようなJOINや複雑なエンティティリレーションや動的クエリを扱うことを目的としてない設計のため、単体ではあまり使い物にならなず、クエリビルダーとの併用が必須だなと感じた。かなり薄い作りなので学習コストは低いように感じました。

技術選定としては、Spring Data JDBC + JOOQ(クエリビルダーとして)が個人的にはしっくりきました。

「キャッシュレス・ロードマップ 2023」を読んで

一般社団法人キャッシュレス推進協議会が毎年発表している、キャッシュレス・ロードマップの2023年版が発表されたので読みました。振り返えれるように関心が高い部分だけまとめます。

はじめに

「キャッシュレス・ロードマップ2023」の策定に向けては、社会の共通課題であるSDGsの達成においてもキャッシュレスが貢献できることを明確にするとともに、環境問題の原因として取り上げられる二酸化炭素の排出量におけるキャッシュレスの関与についても議論を重ねてきた。

1 キャッシュレスの動向

1.1 キャッシュレス決済比率

1.1.1 日本のキャッシュレス決済比率の推移

日本のキャッシュレス決済比率は、2022年に36.0%まで到達した。

期間 キャッシュレス決済の増加比率
2008~2013年 前年比 +1.0%pt 以下
2013~2017年 前年比 +2.0%pt 未満
2017~2021年 前年比 +2.0%pt 以上

政府は「2025年6月までに、キャッシュレス決済比率を倍増し、4割程度とすることを目指す。」というKPIを掲げており、達成はかなり現実味を帯びている。

我が国のキャッシュレス決済額及び比率の推移(2022年)

2022年のキャッシュレス決済比率を算出しました (METI/経済産業省) から転載)

コード決済は、2020年にはデビットカード、2022年には電子マネーを抜き、日本で2番目に利用されている決済手段となった。増加率も50%を2年連続で超え、急激に普及してきた。電子マネーのキャッシュレスに占める割合は現象傾向が続いている。要因としては、コロナ禍の影響に伴い電車等による人の移動が減少したことにより、日常的に交通系を始めとする電子マネーへのチャージや利用の機会の減少が影響していると考えられる。デビットカードは堅調に割合を増やしてきており、増加率も2番目の高水準で推移している。また、クレジットカードの増加率は2010年以降で最も大きな値となっている。クレジットカードのタッチ決済も普及しており、クレジットカードをより日常利用する機会が増えてきたことも影響として考えられる。

2022年はすべての決済手段で増加率がプラスとなっており、キャッシュレス決済手段を利用するシーンが着実に増えてきていることがわかる。2022年にはキャッシュレス決済において、おおよそ4回に1回がコード決済で行われていることとなる。

キャッシュレス全体の平均利用金額は約3,400円となっている。利用金額に合わせて決済手段を選択している可能性が高く、日々の生活シーンに合わせて使い分けが進んでいると考えられる。

1.1.2 一般社団法人全国銀行協会における調査

2022年は、個人の給与受取口座等からの出金のうち、57.5%が振込等を含めたキャッシュレスで行われている。なお、2021年調査では、55.4%であった。

キャッシュレスによる払出し比率の調査結果( 2022年通期)
キャッシュレスによる払出し比率の調査結果( 2022年通期)から転載)

1.2 主要国におけるキャッシュレスの状況

2021年の世界主要国におけるキャッシュレス決済比率

国名 決済比率
韓国 95.3%
中国 83.8%
オーストラリア 72.8%
イギリス 65.1%
シンガポール 63.8%
カナダ 63.6%
アメリ 53.2%
フランス 50.4%
スウェーデン 46.6%
日本 32.5%
ドイツ 22.2%

直近5年では、日本が1番キャッシュレス決済比率の成長が大きい。他国も同様に上昇傾向にあり、グローバル全体でのキャッシュレス利用が進展している。

1.2.2 各国の主要なキャッシュレス決済手段

主要国におきえるキャッシュレス決済手段保有状況について、2021年時点で日本は一人当たり平均してクレジットカードの保有数が2.4枚、デビットカードが3.7枚、そして電子マネーが4.2枚と、合計して約10.2枚を保有しており、諸外国と比べ、一人当たり多くのキャッシュレス決済手段保有している。

日本のキャッシュレス決済手段保有状況

決済手段 2018年 2019年 2020年 2021年
クレジットカード 2.3枚 2.3枚 2.3枚 2.4枚
デビットカード 3.5枚 3.6枚 3.6枚 3.7枚
電子マネー 3.2枚 3.6枚 3.9枚 4.2枚
合計 9.0枚 9.5枚 9.9枚 10.2枚

各国のキャッシュレス決済額を %とした場合の手段別の割合だが、ヨーロッパでは、デビットカードの利用割合が高く、その他の国では、おおよそクレジットカードとデビットカードの利用が均衡している。それに比べ、日本はクレジットカードの利用割合が非常に高いことに加え、電子マネーの利用割合も大きい。 海外、特にヨーロッパでは、比較的クレジットカードの審査が厳しい等が指摘されており、保有に制約の少ないデビットカードの利用が進んでいると考えられる。

2 我が国におけるキャッシュレスの動向

2.1 総務省「マイナポイント事業」

2.1.1 事業の概要

消費の活性化、生活の質の向上、マイナンバーカードの普及促進及び官民キャッシュレス決済基盤の構築を行うことを目的としている。取得したマイナンバーカードを用いて、スマートフォンやパソコン、所定の店舗等において手続きを行うと、自身が選択したキャッシュレス決済手段のポイントが付与される。

2.1.2 消費者の状況

2022年度当初 43.3%であった人口に対するマイナンバーカードの交付枚数率は、2023年3月末時点で、67.0%まで増加した。

2.1.3 決済事業者の参加状況

マイナポイント事業には、2023年3月末現在、101の決済サービスにおいてマイナポイントの受取が可能となっている。これらの決済サービスのうち、電子マネーが約半数を占め、次いで、コード決済、プリペイドカードとなる。

2.2 経済産業省「キャッシュレスの将来像に関する検討会」

経済産業省は、2022 年 9 月より「キャッシュレスの将来像に関する検討会」を 開催した。本検討会における議論内容については、「キャッシュレスの将来像に関 する報告書」としてとりまとめられ、2023 年 3 月に公表された。

2.2.1 日本におけるキャッシュレスの現状

何らかのキャッシュレス決済手段を導入している店舗の割合は、80%であった。一方、クレジットカード、電子マネー、コード決済の全てを導入している店舗は23.4%となっており、店舗のおかれた状況により、導入する決済手段を選別していることがうかがえる。

キャッシュレス決済の導入状況

キャッシュレスの将来像に関する検討会 とりまとめより)

顧客単価が高まるにつれクレジットカードの導入率が高まり、反対に顧客単価が低い店舗ではコード決済の導入が進んでいる。また、顧客単価が5,000円以下となるとキャッシュレス決済そのものの導入率が低くなることが示されている。

店舗がキャッシュレス決済を導入した結果として、「レジ決済時間の短縮」といった効率化に関する要素が大きい。ただし、約半数の店舗では効果を実感されていないこともわかった。

2.2.2 キャッシュレスの社会的意義

検討会では、まずキャッシュレスには、「物理的な制約である『モノ・空間・時間・情報量』から解放される」特性があると分析している。 このような特性から、望ましいキャッシュレスの姿を「請求・認証・精算全てがデジタル化され、請求データに基づき精算が自動実行されるもの」と設定し、キャッシュレスの社会的意義を「『人々と企業の活動』に密接に関わる『決済』を変革することで、既存の課題を解決し、新たな未来を想像する」ことにあるとしている。

2.2.3 キャッシュレスによって目指す社会

キャッシュレスの目指す姿(将来像)に求められるコンセプトが、「支払を意識しない決済が広がり、データがシームレスに連携されるデジタル社会」となっていくと指摘している。また、目指す姿に変化していくことで、現状に おけるキャッシュレスの利用領域や決済方法、提供される主な付加価値も変化が生 じると想定されている。

2.3 その他の政府等における取組

2.3.1 インターチェンジフィーの公開

クレジットカードや他の決済方法の加盟店管理市場における加盟店・アクワイアラ間の加盟店手数料の交渉や、アクワイアラ間の競争を促進する観点から、自らがカード発行や加盟店管理を行わない国際ブランドにあっては、我が国においても、インターチェンジフィーの標準料率を公開することが適当である等の考え方が示されている。 これを踏まえ、経済産業省および公正取引委員会では、国際ブランドにおけるイ ンターチェンジフィーの標準料率の公開に向けた取組を進め、Mastercard、Union Pay (銀聯) 及び Visa がクレジットカードのインターチェンジフィーの標準料率 を公開している。

Amazon Auroraのフェイルオーバーに向けたMySQL JDBCドライバーの検証

業務でAmazon Auroraを利用しています。フェイルオーバーをサポートしてくれていたMariaDB Connector/Jを利用していたのですが、1.3.0からフェイルオーバーのサポートをしなくなったので、乗り換え先を探していました。

About MariaDB Connector/J - MariaDB Knowledge Base

調べてみたら、aws-mysql-jdbcと、aws-advanced-jdbc-wrapperが選択肢として上がりました。どちらもAWS謹製なのですが、ざっくり言うと前者がJDBCドライバーで、後者がJDBCドライバーのWrapperでMySQLPostgreSQLなどのJDBCドライバーと併せて使うものになります。

検証

先に挙げた2つのライブラリの検証をしました。結論から言うと、Read-Writeで接続先を振り分ける(読み込み時はReader、書き込み時はWriterに接続する)という要件を満たしているのが、aws-advanced-jdbc-wrapperだけだったので、これをを利用することを決めました。 aws-mysql-jdbcのこれに関する議論はこちらで行われています。

今回の環境

- Java 17
- Spring Boot 3.1.0
- aws-mysql-jdbc 1.1.7
- aws-advanced-jdbc-wrapper 2.2.2
- Amazon Aurora MySQL 8.0.mysql_aurora.3.03.1
  - Readerは1台

検証手順

1. アプリケーションの接続先URLをクラスターエンドポイントにする
2. APIを起動
3. curlで連続的にデータ書き込み用エンドポイントを叩く
4. curlで連続的にデータ参照用エンドポイントを叩く
5. 手動でフェイルオーバーを起こす

MySQL Connector/J

比較のために、MySQL Connector/Jについても検証してみました。

アプリケーションの設定

application.yml

spring:
  datasource:
    url: jdbc:mysql:replication://${クラスターエンドポイント},${Read-Onlyクラスターエンドポイント}/test_db

build.gradle

dependencies {
    runtimeOnly 'com.mysql:mysql-connector-j'
}
フェイルオーバー実行

フェイルオーバー実行すると、DB接続に失敗後に、降格した(WriterからReaderになった)インスタンスに接続しつづけるためread-onlyのトランザクションで書き込みしようとしていると怒られました。もちろんAuroraのフェイルオーバーには対応していませんでした。

2023-06-25T19:26:17.301+09:00  WARN 26196 --- [nio-8080-exec-1] com.zaxxer.hikari.pool.ProxyConnection   : HikariPool-1 - Connection com.mysql.cj.jdbc.ConnectionImpl@61e86192 marked as broken because of SQLSTATE(08S01), ErrorCode(0)

com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure

The last packet successfully received from the server was 34 milliseconds ago. The last packet sent successfully to the server was 37 milliseconds ago.

...

2023-06-25T19:26:17.397+09:00  WARN 26196 --- [nio-8080-exec-2] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@29bd6f67 (No operations allowed after connection closed.). Possibly consider using a shorter maxLifetime value.
Hibernate: insert into shop (name) values (?)
2023-06-25T19:26:27.762+09:00  WARN 26196 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1836, SQLState: HY000
2023-06-25T19:26:27.763+09:00 ERROR 26196 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Running in read-only mode

Amazon Web Services (AWS) JDBC Driver for MySQL

Amazon Web Services (AWS) JDBC Driver for MySQLクラスタリングされたMySQLデータベースを利用したアプリケーションにメリットがあります。これは、MySQL Connector/J driverをベースにしており、ドロップイン互換性があります。

The AWS JDBC Driver for MySQLは、MySQL互換のAmazon Auroraのための素早いフェイルオーバーをサポートしています。クラスタリングされたデータベースの機能に加えて、Amazon RDS for MySQLやオンプレミス環境のMySQLのための機能も含んでサポートしています。

より詳しいことは、公式ドキュメントを参照してください。

アプリケーションの設定

application.yml

spring:
  datasource:
    url: jdbc:mysql:aws://${クラスターエンドポイント}/test_db
    driver-class-name: software.aws.rds.jdbc.mysql.Driver

build.gradle

dependencies {
   runtimeOnly "software.aws.rds:aws-mysql-jdbc:1.1.7"
}
フェイルオーバー実行

常にクラスターエンドポイントに接続しているので、フェイルオーバーを実行後もそのままクラスターエンドポイントに接続し続けます。ただ、フェイルオーバー中に書き込みしていたコネクションは失われます。

2023-07-09T17:16:36.030+09:00  INFO 26316 --- [nio-8080-exec-6] c.b.j.controller.ShopController          : saved shop.(id = 27420)
2023-07-09T17:16:48.181+09:00  WARN 26316 --- [nio-8080-exec-3] com.zaxxer.hikari.pool.ProxyConnection   : HikariPool-1 - Connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@28814a75 marked as broken because of SQLSTATE(08007), ErrorCode(0)
java.sql.SQLException: Transaction resolution unknown. Please re-configure session state if required and try restarting transaction.

2023-07-09T17:16:48.204+09:00 ERROR 26316 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.orm.jpa.JpaSystemException: Unable to commit against JDBC Connection] with root cause
java.sql.SQLException: Transaction resolution unknown. Please re-configure session state if required and try restarting transaction.

2023-07-09T17:16:48.645+09:00  WARN 26316 --- [nio-8080-exec-7] com.zaxxer.hikari.pool.ProxyConnection   : HikariPool-1 - Connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@61d94a02 marked as broken because of SQLSTATE(08S02), ErrorCode(0)

java.sql.SQLException: The active SQL connection has changed due to a connection failure. Please re-configure session state if required.

2023-07-09T17:16:50.323+09:00  WARN 26316 --- [nio-8080-exec-2] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@7708a0c5 (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:50.763+09:00  WARN 26316 --- [io-8080-exec-10] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@540afcc8 (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:52.421+09:00  WARN 26316 --- [nio-8080-exec-2] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@33480b36 (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:52.845+09:00  WARN 26316 --- [io-8080-exec-10] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@7a3aa305 (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:54.516+09:00  WARN 26316 --- [nio-8080-exec-2] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@2adac41f (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:54.929+09:00  WARN 26316 --- [io-8080-exec-10] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@4f352185 (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:56.601+09:00  WARN 26316 --- [nio-8080-exec-2] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Failed to validate connection software.aws.rds.jdbc.mysql.shading.com.mysql.cj.jdbc.ConnectionImpl@23c98d1f (The active SQL connection has changed due to a connection failure. Please re-configure session state if required.). Possibly consider using a shorter maxLifetime value.
2023-07-09T17:16:56.641+09:00  INFO 26316 --- [nio-8080-exec-2] c.b.j.controller.ShopController          : saved shop.(id = 27421)

Amazon Web Services (AWS) JDBC Driver

Amazon Web Services (AWS) JDBC Driverは、進化したJDBC wrapperとして再設計されました。このwrapperは、Amazon Auroraのようなクラスタリングされたデータベースの機能を活用するアプリケーションをサポートするために存在するJDBCドライバーを機能拡張したものです。AWS JDBC Driverはいくつかのデータベース自身に接続する実装はしていないですが、ユーザが選択しているJDBCドライバーをAWSやAuroraの機能性をサポートを有効にできます。

詳しいことは、公式ドキュメントを参照してください。

アプリケーションの設定

application.yml

spring:
  datasource:
    url: jdbc:aws-wrapper:mysql://${クラスターエンドポイント}/test_db
    driver-class-name: software.amazon.jdbc.Driver
    hikari:
      data-source-properties:
        # プラグインを有効化。
        # readWriteSplitting:readerとwriterの接続切り替え
        # failover: Auroraのフェイルオーバーサポート
        # efm: ホストコネクション失敗のモニタリング。失敗発見率の向上
        wrapperPlugins: readWriteSplitting,failover,efm
        # フェイルオーバー時、Readerにアクセスできない場合はWriterにアクセスする(デフォルトはstrict-writer)
        failoverMode: reader-or-writer
      # フェイルオーバーに関する例外をハンドリングするため
      exception-override-class-name: software.amazon.jdbc.util.HikariCPSQLException

# 挙動確認のためのログ出力
logging:
  level:
    software.amazon.jdbc.Driver: trace
    software.amazon.jdbc.plugin.readwritesplitting: trace
    software.amazon.jdbc.plugin.staledns: trace
    software.amazon.jdbc.plugin.failover: trace

build.gradle

dependencies {
    implementation 'software.amazon.jdbc:aws-advanced-jdbc-wrapper:2.2.2'
    runtimeOnly 'com.mysql:mysql-connector-j'
}
フェイルオーバー実行

クラスターエンドポイントを指定するだけで、ライターインスタンスエンドポイントとリーダーインスタンスエンドポイントを検出してくれます。 連続的にデータ書き込み&読み込み用エンドポイントを叩き中のログを見ると、ReadOnlyのコネクションはReaderに接続していることがわかりました。念の為、CloudWatchメトリクスでDatabaseConnectionsを見てみましたが、ちゃんとReaderに接続されていました。 フェイルオーバー開始から完了までほぼダウンタイムが発生しないことがわかりました。

# アプリケーション起動中
> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.1.0)


 2023-07-05T00:32:43.445+09:00 TRACE 44369 --- [           main] software.amazon.jdbc.Driver              : Opening connection to jdbc:aws-wrapper:mysql://${クラスターエンドポイント}/test_db
2023-07-05T00:32:43.509+09:00 DEBUG 44369 --- [           main] s.a.j.p.f.FailoverConnectionPlugin       : failoverMode=READER_OR_WRITER
2023-07-05T00:32:43.513+09:00 TRACE 44369 --- [           main] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${クラスターエンドポイント}/': [NODE_ADDED]
2023-07-05T00:32:43.971+09:00 TRACE 44369 --- [           main] s.a.j.p.staledns.AuroraStaleDnsHelper    : Cluster endpoint resolves to x.x.x.x.
2023-07-05T00:32:43.986+09:00 TRACE 44369 --- [           main] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${クラスターエンドポイント}': [NODE_DELETED]
        Host '${リーダーインスタンスエンドポイント}': [NODE_ADDED]
        Host '${ライターインスタンスエンドポイント}': [NODE_ADDED]
2023-07-05T00:32:43.988+09:00 TRACE 44369 --- [           main] s.a.j.p.staledns.AuroraStaleDnsHelper    : Topology:
   HostSpec[host=${リーダーインスタンスエンドポイント}, port=-1, READER, AVAILABLE, weight=2450, 2023-07-04 15:32:44.108693]
   HostSpec[host=${ライターインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:32:43.989+09:00 TRACE 44369 --- [           main] s.a.j.p.staledns.AuroraStaleDnsHelper    : Writer host: HostSpec[host=${ライターインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:32:43.991+09:00 TRACE 44369 --- [           main] s.a.j.p.staledns.AuroraStaleDnsHelper    : Writer host address: x.x.x.x
2023-07-05T00:32:43.998+09:00 TRACE 44369 --- [           main] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${クラスターエンドポイント}'
2023-07-05T00:32:44.027+09:00  INFO 44369 --- [           main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Added connection software.amazon.jdbc.wrapper.ConnectionWrapper@53a50b0a - com.mysql.cj.jdbc.ConnectionImpl@74ba6ff5
2023-07-05T00:32:44.029+09:00  INFO 44369 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2023-07-05T00:32:44.068+09:00  INFO 44369 --- [           main] o.f.c.i.database.base.BaseDatabaseType   : Database: jdbc:mysql://${クラスターエンドポイント}/test_db (MySQL 8.0)
2023-07-05T00:32:44.131+09:00 TRACE 44369 --- [onnection adder] software.amazon.jdbc.Driver              : Opening connection to jdbc:aws-wrapper:mysql://${クラスターエンドポイント}/test_db
2023-07-05T00:32:44.132+09:00 DEBUG 44369 --- [onnection adder] s.a.j.p.f.FailoverConnectionPlugin       : failoverMode=READER_OR_WRITER
2023-07-05T00:32:44.133+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${リーダーインスタンスエンドポイント}': [NODE_ADDED]
        Host '${ライターインスタンスエンドポイント}': [NODE_ADDED]
2023-07-05T00:32:44.217+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.staledns.AuroraStaleDnsHelper    : Cluster endpoint resolves to x.x.x.x.
2023-07-05T00:32:44.218+09:00  INFO 44369 --- [           main] o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.067s)
2023-07-05T00:32:44.224+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.staledns.AuroraStaleDnsHelper    : Topology:
   HostSpec[host=${リーダーインスタンスエンドポイント}, port=-1, READER, AVAILABLE, weight=2450, 2023-07-04 15:32:44.108693]
   HostSpec[host=${ライターインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:32:44.225+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.staledns.AuroraStaleDnsHelper    : Writer host: HostSpec[host=${ライターインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:32:44.225+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.staledns.AuroraStaleDnsHelper    : Writer host address: x.x.x.x
2023-07-05T00:32:44.231+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${クラスターエンドポイント}'
2023-07-05T00:32:44.248+09:00 TRACE 44369 --- [onnection adder] software.amazon.jdbc.Driver              : Opening connection to jdbc:aws-wrapper:mysql://${クラスターエンドポイント}/test_db
2023-07-05T00:32:44.249+09:00 DEBUG 44369 --- [onnection adder] s.a.j.p.f.FailoverConnectionPlugin       : failoverMode=READER_OR_WRITER
2023-07-05T00:32:44.250+09:00 TRACE 44369 --- [onnection adder] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${リーダーインスタンスエンドポイント}': [NODE_ADDED]
        Host '${ライターインスタンスエンドポイント}': [NODE_ADDED]

# 連続的にデータ書き込み&読み込み用エンドポイントを叩き中
2023-07-05T00:32:58.666+09:00  INFO 44369 --- [nio-8080-exec-4] c.b.j.controller.ShopController          : saved shop.(id = 22272)
2023-07-05T00:32:58.704+09:00  INFO 44369 --- [nio-8080-exec-6] c.b.j.controller.ShopController          : saved shop.(id = 22273)
2023-07-05T00:32:58.737+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Successfully connected to a new reader host: ${リーダーインスタンスエンドポイント}'
2023-07-05T00:32:58.738+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Reader connection set to '${リーダーインスタンスエンドポイント}'
2023-07-05T00:32:58.744+09:00  INFO 44369 --- [nio-8080-exec-9] c.b.j.controller.ShopController          : saved shop.(id = 22274)
2023-07-05T00:32:58.753+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Reader connection set to '${リーダーインスタンスエンドポイント}'
2023-07-05T00:32:58.754+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Setting the current connection to '${リーダーインスタンスエンドポイント}'
2023-07-05T00:32:58.755+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Switched from a writer to a reader host. New reader host: '${リーダーインスタンスエンドポイント}'
2023-07-05T00:32:58.782+09:00  INFO 44369 --- [nio-8080-exec-1] c.b.j.controller.ShopController          : saved shop.(id = 22275)
2023-07-05T00:32:58.822+09:00  INFO 44369 --- [nio-8080-exec-3] c.b.j.controller.ShopController          : saved shop.(id = 22276)
2023-07-05T00:32:58.860+09:00  INFO 44369 --- [nio-8080-exec-5] c.b.j.controller.ShopController          : saved shop.(id = 22277)
2023-07-05T00:32:58.900+09:00  INFO 44369 --- [nio-8080-exec-7] c.b.j.controller.ShopController          : saved shop.(id = 22278)
2023-07-05T00:32:58.961+09:00  INFO 44369 --- [io-8080-exec-10] c.b.j.controller.ShopController          : saved shop.(id = 22279)
2023-07-05T00:32:59.010+09:00  INFO 44369 --- [nio-8080-exec-2] c.b.j.controller.ShopController          : saved shop.(id = 22280)
2023-07-05T00:32:59.020+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${ライターインスタンスエンドポイント}'
2023-07-05T00:32:59.020+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Setting the current connection to '${ライターインスタンスエンドポイント}'
2023-07-05T00:32:59.020+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Switched from a reader to a writer host. New writer host: '${ライターインスタンスエンドポイント}'

# フェイルオーバー開始時
2023-07-05T00:33:33.372+09:00  INFO 44369 --- [nio-8080-exec-5] c.b.j.controller.ShopController          : saved shop.(id = 23087)
2023-07-05T00:33:33.387+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Detected an exception while executing a command: Communications link failure

# フェイルオーバー中
The last packet successfully received from the server was 124 milliseconds ago. The last packet sent successfully to the server was 137 milliseconds ago.
2023-07-05T00:33:33.387+09:00 TRACE 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のリーダーインスタンスエンドポイント}': [WENT_DOWN, NODE_CHANGED]
2023-07-05T00:33:33.388+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Starting reader failover procedure.
2023-07-05T00:33:33.390+09:00 DEBUG 44369 --- [pool-4-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Trying to connect to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}', with properties '{failoverMode=reader-or-writer, password=rootroot, database=test_db, wrapperPlugins=readWriteSplitting,failover,efm, user=root}'
2023-07-05T00:33:33.390+09:00 DEBUG 44369 --- [pool-4-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Trying to connect to reader: '${フェイルオーバー前のリーダーインスタンスエンドポイント}', with properties '{failoverMode=reader-or-writer, password=rootroot, database=test_db, wrapperPlugins=readWriteSplitting,failover,efm, user=root}'
2023-07-05T00:33:33.416+09:00  INFO 44369 --- [nio-8080-exec-2] c.b.j.controller.ShopController          : saved shop.(id = 23088)
2023-07-05T00:33:33.455+09:00  INFO 44369 --- [nio-8080-exec-9] c.b.j.controller.ShopController          : saved shop.(id = 23089)
2023-07-05T00:33:33.463+09:00 DEBUG 44369 --- [pool-4-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Connected to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:33.463+09:00 DEBUG 44369 --- [pool-4-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : New reader connection object: com.mysql.cj.jdbc.ConnectionImpl@1312cce0
2023-07-05T00:33:33.464+09:00 TRACE 44369 --- [nio-8080-exec-6] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:33.471+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Connected to: HostSpec[host=${フェイルオーバー前のライターインスタンスエンドポイント} port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:33:33.472+09:00 ERROR 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:33.475+09:00  WARN 44369 --- [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 08S02
2023-07-05T00:33:33.475+09:00 ERROR 44369 --- [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:33.482+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Detected an exception while executing a command: Can't call rollback when autocommit=true
2023-07-05T00:33:33.483+09:00 TRACE 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [WENT_DOWN, NODE_CHANGED]
2023-07-05T00:33:33.483+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Starting reader failover procedure.
2023-07-05T00:33:33.484+09:00 DEBUG 44369 --- [pool-6-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Trying to connect to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}', with properties '{failoverMode=reader-or-writer, password=rootroot, database=test_db, wrapperPlugins=readWriteSplitting,failover,efm, user=root}'
2023-07-05T00:33:33.484+09:00 DEBUG 44369 --- [pool-6-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Trying to connect to reader: '${フェイルオーバー前のリーダーインスタンスエンドポイント}', with properties '{failoverMode=reader-or-writer, password=rootroot, database=test_db, wrapperPlugins=readWriteSplitting,failover,efm, user=root}'
2023-07-05T00:33:33.499+09:00  INFO 44369 --- [nio-8080-exec-8] c.b.j.controller.ShopController          : saved shop.(id = 23090)
2023-07-05T00:33:33.515+09:00 DEBUG 44369 --- [pool-6-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Failed to connect to reader: '${フェイルオーバー前のリーダーインスタンスエンドポイント}'
2023-07-05T00:33:33.517+09:00 DEBUG 44369 --- [pool-4-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Failed to connect to reader: '${フェイルオーバー前のリーダーインスタンスエンドポイント}'
2023-07-05T00:33:33.536+09:00 TRACE 44369 --- [pool-6-thread-1] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [WENT_UP, NODE_CHANGED]
2023-07-05T00:33:33.537+09:00 DEBUG 44369 --- [pool-6-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Connected to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:33.537+09:00 DEBUG 44369 --- [pool-6-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : New reader connection object: com.mysql.cj.jdbc.ConnectionImpl@55e4bfde
2023-07-05T00:33:33.537+09:00 TRACE 44369 --- [nio-8080-exec-6] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:33.538+09:00  INFO 44369 --- [nio-8080-exec-1] c.b.j.controller.ShopController          : saved shop.(id = 23091)
2023-07-05T00:33:33.542+09:00 DEBUG 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : Connected to: HostSpec[host=${フェイルオーバー前のライターインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=4, 2023-07-04 15:32:43.890895]
2023-07-05T00:33:33.542+09:00 ERROR 44369 --- [nio-8080-exec-6] s.a.j.p.f.FailoverConnectionPlugin       : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:33.548+09:00 ERROR 44369 --- [nio-8080-exec-6] o.s.t.i.TransactionInterceptor           : Application exception overridden by rollback exception

2023-07-05T00:33:33.581+09:00  INFO 44369 --- [nio-8080-exec-3] c.b.j.controller.ShopController          : saved shop.(id = 23092)
2023-07-05T00:33:33.620+09:00  WARN 44369 --- [nio-8080-exec-9] s.a.j.p.r.ReadWriteSplittingPlugin       : Failed to connect to reader host: '${フェイルオーバー前のリーダーインスタンスエンドポイント}'
2023-07-05T00:33:33.623+09:00  INFO 44369 --- [nio-8080-exec-4] c.b.j.controller.ShopController          : saved shop.(id = 23093)
2023-07-05T00:33:33.628+09:00  WARN 44369 --- [nio-8080-exec-9] s.a.j.p.r.ReadWriteSplittingPlugin       : Failed to connect to reader host: '${フェイルオーバー前のリーダーインスタンスエンドポイント}'

# フェイルオーバー完了後
2023-07-05T00:33:39.373+09:00 TRACE 44369 --- [ool-11-thread-2] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [WENT_UP, NODE_CHANGED]
2023-07-05T00:33:39.374+09:00 DEBUG 44369 --- [ool-11-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Connected to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:39.374+09:00 DEBUG 44369 --- [ool-11-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : New reader connection object: com.mysql.cj.jdbc.ConnectionImpl@5d8bcb8e
2023-07-05T00:33:39.374+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Reader connection set to '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:39.384+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のリーダーインスタンスエンドポイント}': [PROMOTED_TO_WRITER, NODE_CHANGED]
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [NODE_DELETED]
2023-07-05T00:33:39.384+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Connected to: HostSpec[host=${フェイルオーバー前のリーダーインスタンスエンドポイント}, port=-1, READER, AVAILABLE, weight=2450, 2023-07-04 15:32:44.108693]
2023-07-05T00:33:39.384+09:00 ERROR 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:39.387+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Detected an exception while executing a command: Can't call rollback when autocommit=true
2023-07-05T00:33:39.388+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [WENT_DOWN, NODE_CHANGED]
2023-07-05T00:33:39.388+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Starting reader failover procedure.
2023-07-05T00:33:39.389+09:00 DEBUG 44369 --- [ool-14-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Trying to connect to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}', with properties '{failoverMode=reader-or-writer, password=rootroot, database=test_db, wrapperPlugins=readWriteSplitting,failover,efm, user=root}'
2023-07-05T00:33:39.391+09:00 DEBUG 44369 --- [ool-12-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : Connected to reader: '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:39.391+09:00 DEBUG 44369 --- [ool-12-thread-2] .j.p.f.ClusterAwareReaderFailoverHandler : New reader connection object: com.mysql.cj.jdbc.ConnectionImpl@6db7986c
2023-07-05T00:33:39.392+09:00 TRACE 44369 --- [io-8080-exec-10] s.a.j.p.r.ReadWriteSplittingPlugin       : Reader connection set to '${フェイルオーバー前のライターインスタンスエンドポイント}'
2023-07-05T00:33:39.398+09:00 TRACE 44369 --- [io-8080-exec-10] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のリーダーインスタンスエンドポイント}': [PROMOTED_TO_WRITER, NODE_CHANGED]
        Host '${フェイルオーバー前のライターインスタンスエンドポイント}': [NODE_DELETED]
2023-07-05T00:33:39.398+09:00 DEBUG 44369 --- [io-8080-exec-10] s.a.j.p.f.FailoverConnectionPlugin       : Connected to: HostSpec[host=${フェイルオーバー前のリーダーインスタンスエンドポイント}, port=-1, READER, AVAILABLE, weight=2450, 2023-07-04 15:32:44.108693]
2023-07-05T00:33:39.398+09:00 ERROR 44369 --- [io-8080-exec-10] s.a.j.p.f.FailoverConnectionPlugin       : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:39.399+09:00 DEBUG 44369 --- [io-8080-exec-10] s.a.j.p.r.ReadWriteSplittingPlugin       : Detected a failover exception while executing a command: 'Connection.setReadOnly'
2023-07-05T00:33:39.399+09:00 TRACE 44369 --- [io-8080-exec-10] s.a.j.p.r.ReadWriteSplittingPlugin       : Closing all internal connections except for the current one.
2023-07-05T00:33:39.444+09:00 TRACE 44369 --- [ool-14-thread-1] s.a.j.p.f.FailoverConnectionPlugin       : Changes:
        Host '${フェイルオーバー前のリーダーインスタンスエンドポイント}': [WENT_UP, NODE_CHANGED]
2023-07-05T00:33:39.445+09:00 DEBUG 44369 --- [ool-14-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : Connected to reader: '${フェイルオーバー前のリーダーインスタンスエンドポイント}'
2023-07-05T00:33:39.445+09:00 DEBUG 44369 --- [ool-14-thread-1] .j.p.f.ClusterAwareReaderFailoverHandler : New reader connection object: com.mysql.cj.jdbc.ConnectionImpl@4cadd5e1
2023-07-05T00:33:39.446+09:00 TRACE 44369 --- [nio-8080-exec-8] s.a.j.p.r.ReadWriteSplittingPlugin       : Writer connection set to '${フェイルオーバー前のリーダーインスタンスエンドポイント}'
2023-07-05T00:33:39.451+09:00 DEBUG 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : Connected to: HostSpec[host=${フェイルオーバー前のリーダーインスタンスエンドポイント}, port=-1, WRITER, AVAILABLE, weight=25, 2023-07-04 15:33:39.510702]
2023-07-05T00:33:39.451+09:00 ERROR 44369 --- [nio-8080-exec-8] s.a.j.p.f.FailoverConnectionPlugin       : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.
2023-07-05T00:33:39.452+09:00  WARN 44369 --- [nio-8080-exec-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 08S02
2023-07-05T00:33:39.452+09:00 ERROR 44369 --- [nio-8080-exec-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : The active SQL connection has changed due to a connection failure. Please re-configure session state if required.

Liks

LocalStack 2.0でAWSクラウドリソースをローカル環境でシミュレート

先日、LocalStack 2.0がリリースされたので、Spring Bootアプリケーションで利用してみます。 LocalStack 1.0と2.0の違いをChatGPTに聞いてみました。

LocalStack 1.0とLocalStack 2.0は、AWSクラウドリソースをローカル環境でシミュレートするために使用されるオープンソースプロジェクトです。これにより、開発者はオフラインでAWSサービスをテストできます。LocalStack 2.0は、1.0に比べていくつかの重要な改善点があります。

パフォーマンスと安定性:LocalStack 2.0は、より高速で安定したパフォーマンスが期待されるように設計されています。1.0に存在するいくつかのバグや問題が修正されています。
サポートされるサービス:LocalStack 2.0では、新しいAWSサービスと機能のサポートが追加されています。これにより、開発者は最新のAWS機能をローカルで利用できるようになります。
プロジェクト構造とアーキテクチャ:LocalStack 2.0では、プロジェクトの構造とアーキテクチャが再設計され、より維持管理が容易になり、拡張性が向上しています。
プロセス分離:LocalStack 1.0では、すべてのサービスが1つのプロセス内で実行されていましたが、LocalStack 2.0では、各サービスが独自のプロセス内で実行されるようになりました。これにより、サービス間の依存関係や問題が軽減され、安定性が向上しています。
コンフィギュレーションとデプロイメント:LocalStack 2.0では、設定とデプロイメントのプロセスが改善され、より使いやすくなっています。
コミュニティサポート:LocalStack 2.0は、1.0と比べてアクティブな開発とサポートが続いており、開発者が新しい機能やバグ修正を利用できるようになっています。

これらの改善点により、LocalStack 2.0は1.0よりも高速で安定し、より多くのAWSサービスと機能をサポートし、開発者がAWSアプリケーションをローカル環境でテストする際に優れた選択肢となります。

LocalStackの料金体系は、無料と有料があります。有料版でしか利用できないAWSクラウドリソースもあります。詳しくは、AWS Service Feature Coverageに書いてあります。

LocalStackを利用したSpring Bootアプリケーション

本記事の環境
- Java 17
- Spring Boot 3.0.6
- AWS SDK for Java 2.20.58
- LocalStack 2.0.2
- Testcontainers 1.18.0

Docker上でLocalStackを起動する

Docker上でLocalStackを起動するために、次のdocker-compose.ymlを用意しました。

version: "3.9"

services:
  localstack:
    image: localstack/localstack:2.0.2
    ports:
      - "127.0.0.1:4566:4566"
    environment:
      # LocalStackのログを出力するかどうかを指定
      - DEBUG=1
      # Docker Composeで起動されるコンテナがDockerデーモンと通信するために使用するDockerホストのアドレスを指定
      - DOCKER_HOST=unix:///var/run/docker.sock

起動します。

$ docker-compose up -d
[+] Running 1/1
 ✔ Container localstack2-java-localstack-1  Started 

AWS環境のセットアップ

AWS CLIのコマンドで、LocalStack内の全サービスにアクセスすることができます。また、LocalStackに特化したLocalStack AWS CLIというものもあります。

CLIのインストール

AWS CLIかLocalStack AWS CLIのどちらかをインストールしてください。

# AWS CLI
$ pip install awscli

# LocalStack AWS CLI
$ pip install awscli-local

クレデンシャルの設定

クレデンシャルとデフォルトリージョンの環境変数を設定してください。

$ export AWS_ACCESS_KEY_ID="test"
$ export AWS_SECRET_ACCESS_KEY="test"
$ export AWS_DEFAULT_REGION="ap-northeast-1"

このクレデンシャルはダミーなので、direnvを利用して、このリポジトリディレクトリのみ環境変数が適用されるようにしました。

export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
export AWS_DEFAULT_REGION="ap-northeast-1"

動作確認

AWS CLIかLocalStack AWS CLIのコマンドを叩いて動作確認をします。

$ aws --endpoint-url=http://localhost:4566 kinesis list-streams

    "StreamNames": []
}
$ awslocal kinesis list-streams

    "StreamNames": []
}

これでLocalStackを利用する環境は整いました。

Spring Boot アプリケーションの実装

サンプルとして、Amazon SQSを利用してキューにメッセージを格納するコマンドラインアプリケーションを実装します。 細かい実装は、GitHubをご覧ください。

SqsClientをBean登録します。今回、LocalStackを4566ポートで起動しているのでエンドポイントを書き換える必要があります。

@Configuration(proxyBeanMethods = false)
public class SqsConfig {

    @Bean
    public SqsClient sqsClient(SqsProperties properties) {
        var builder = SqsClient.builder()
                .region(Region.AP_NORTHEAST_1)
                .endpointOverride(properties.endpointUrl());
        if (properties.endpointUrl() != null) {
            // LocalStackのエンドポイントに書き換える
            builder.endpointOverride(properties.endpointUrl());
        }
        return builder.build();
    }
}

SQSのキューにメッセージを格納する処理を実装。

@Component
public class SqsQueueMessageSender implements ApplicationRunner {

    static final String DEFAULT_MESSAGE = "test-message";

    private final SqsClient sqsClient;
    private final SqsProperties sqsProperties;

    public SqsQueueMessageSender(SqsClient sqsClient, SqsProperties sqsProperties) {
        this.sqsClient = sqsClient;
        this.sqsProperties = sqsProperties;
    }

    @Override
    public void run(ApplicationArguments args) {
        var queueUrl = sqsClient.getQueueUrl(builder -> builder.queueName(sqsProperties.queueName()))
                .queueUrl();
        sqsClient.sendMessage(builder -> builder.queueUrl(queueUrl).messageBody(DEFAULT_MESSAGE));
    }
}

SQSキューの作成

AWS CLIかLocalStack AWS CLIのコマンドを叩いてキューを作成します。

$ aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name sample-queue
$ awslocal sqs create-queue --queue-name sample-queue
{
    "QueueUrl": "http://localhost:4566/000000000000/sample-queue"
}

アプリケーションの実行

Spring Bootアプリケーションを起動して、キューにメッセージを格納します。

./gradlew clean bootRun --args='--spring.profiles.active=local'                    

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.0.6)

2023-05-05T13:40:25.871+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : Starting Application using Java 17.0.6 with PID 21202 (/Users/ryosuke/workspace/localstack2-java/build/classes/java/main started by ryosuke in /Users/ryosuke/workspace/localstack2-java)
2023-05-05T13:40:25.873+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : The following 1 profile is active: "local"
2023-05-05T13:40:26.617+09:00  INFO 21202 --- [           main] c.b1a9idps.localstack2java.Application   : Started Application in 1.145 seconds (process running for 1.402)

BUILD SUCCESSFUL in 2s
5 actionable tasks: 5 executed

AWS CLIかLocalStack AWS CLIのコマンドを格納したメッセージを受信できることを確認します。

$ aws --endpoint-url=http://localhost:4566 sqs receive-message \
  --queue-url=http://queue.localhost.localstack.cloud:4566/000000000000/sample-queue
$ awslocal sqs receive-message \
  --queue-url=http://queue.localhost.localstack.cloud:4566/000000000000/sample-queue

無事にLocalStackを利用してAWSクラウドリソースをローカル環境でシミュレートすることができました。

Testcontainersを利用したテストコード

LocalStackをサポートしているTestcontainersを利用してテストコードを書きます。 SqsClientのクレデンシャル等をTestcontainers用のものを利用することで、テストコードを書くことができます。

@ExtendWith(SpringExtension.class)
@ActiveProfiles("unittest")
@ContextConfiguration(classes = {TestConfig.class}, initializers = ConfigDataApplicationContextInitializer.class)
@Testcontainers
class SqsQueueMessageSenderTest extends AbstractContainerBaseTest {

    @Container
    final LocalStackContainer localStack = new LocalStackContainer(LOCALSTACK_IMAGE_NAME)
            .withServices(LocalStackContainer.Service.SQS);

    SqsQueueMessageSender sqsQueueMessageSender;
    SqsClient sqsClient;
    @Autowired
    SqsProperties sqsProperties;

    @BeforeEach
    void beforeEach() {
        sqsClient = SqsClient.builder()
                .endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.SQS))
                .credentialsProvider(
                        StaticCredentialsProvider.create(
                                AwsBasicCredentials.create(localStack.getAccessKey(), localStack.getSecretKey())
                        )
                )
                .region(Region.of(localStack.getRegion()))
                .build();
        sqsClient.createQueue(builder -> builder.queueName(sqsProperties.queueName()));

        sqsQueueMessageSender = new SqsQueueMessageSender(sqsClient, sqsProperties);
    }

    @Test
    void sendMessage() {
        sqsQueueMessageSender.run(null);

        var queueUrl = sqsClient.getQueueUrl(builder -> builder.queueName(sqsProperties.queueName()))
                .queueUrl();

        var actual = sqsClient.receiveMessage(builder -> builder
                        .queueUrl(queueUrl)
                        .maxNumberOfMessages(10));
        Assertions.assertThat(actual.messages())
                .hasSize(1)
                .extracting(Message::body)
                .containsExactly(DEFAULT_MESSAGE);
    }

}

Links

ECS Fargate上で動いているJavaアプリケーションにDatadogを導入する

ECS Fargate上で動いているJavaアプリケーションにDatadogを導入する手順をまとめます。

本記事の環境
- Java 11
- Gradle 7.0.2
- Spring Boot 2.5.1
- Jib 3.3.1

大まかな流れ

  1. Gradleプロジェクトでアプリケーションを作る
  2. ECS Fargate環境を用意する
  3. ECS FargateにデプロイできるようにGitHub Actionsワークフローを用意する
  4. ECS Faragte上で動いているJavaアプリケーションにDatadogを導入する

本記事では4についてまとめます。JibのGradleプラグインを利用してDockerイメージを生成しています。

手順

1. Javaクライアントのインストール

Javaクライアントをインストールして、任意の場所に配置(この記事では .datadog/)します。

$ wget -O dd-java-agent.jar 'https://dtdg.co/latest-java-tracer'
もしくは
$ curl -L -o dd-java-agent.jar https://dtdg.co/latest-java-tracer

Java アプリケーションのトレースclasspath に dd-java-agent を追加しないでください。予期せぬ挙動が生じる場合があります。」とあるので、classpathには追加しないようにしてください。

2. タスク定義にDatadog Agent用サイドカーコンテナを追加

タスク定義にDatadog Agent用サイドカーコンテナを追加します。

{
  # ECS Fargate全体の設定は省略
  "containerDefinitions": [
    {
      # アプリケーション用コンテナの設定
    },
    {
      "name": "datadog-agent",
      "image": "public.ecr.aws/datadog/agent:latest",
      "cpu": 128,
      "memory": 512,
      "memoryReservation": 512,
      "portMappings": [
        {
          "containerPort": 8126
        }
      ],
      "essential": true,
      "environment": [
        {
          "name": "ECS_FARGATE",
          "value": "true"
        },
        # Trace Agentを有効に
        {
          "name": "DD_APM_ENABLED",
          "value": "true"
        },
        # Datadog上での環境識別子
        {
          "name": "DD_ENV",
          "value": "dev"
        }
      ],
      "secrets": [
        # AWS Systems Manager Parameter Storeに格納したAPI Key
        {
          "name": "DD_API_KEY",
          "valueFrom": "/datadog/DD_API_KEY"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-create-group": "true",
          "awslogs-group": "app-datadog",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "/datadog"
        }
      }
    }
  ]
}

3. dd-java-agentをDockerイメージに含めるように設定を追加

build.gradleに、dd-java-agentをDockerイメージに含めるように設定を追加してください。

jib {
    from {
        image = 'amazoncorretto:11-alpine-jdk'
    }
    to {
        // 任意のコンテナイメージ名
        image = 'app'
    }
    container {
        // アプリケーション実行時にJVMに渡すJVMフラグ
        jvmFlags = ['-javaagent:/opt/datadog/dd-java-agent.jar']
    }
    extraDirectories {
        paths {
            path {
                // dd-java-agent.jarをプロジェクトディレクトリからDockerイメージに追加
                from = file("${project.rootDir}/.datadog")
                into = '/opt/datadog'
            }
        }
    }
}

4. デプロイ

ECS Fargeteにデプロイし、Datadogダッシュボードで確認するとメトリクスが取れるようになります。

RestTemplateのエラーハンドリング

Spring FrameworkRestTemplate.class を使った通信のエラーハンドリングの仕方についてまとめます。

今回の環境

  • Java 17
  • Spring Boot 2.7.4

試してみた

Client APIからリクエストを受けるAPI(Server)

@RestController
@RequestMapping("/server")
public class ServerController {

    @GetMapping
    public ServerResponse index(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) {
        HttpStatus status = statusCode
                .map(HttpStatus::resolve)
                .orElse(null);
        if (status != null && status.isError()) {
            throw new CustomException(status.getReasonPhrase(), status);
        } else {
            return new ServerResponse(1, "name");
        }
    }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handle(CustomException e) {
        return ResponseEntity
                .status(e.getStatus())
                .body(new ErrorResponse(e.getMessage()));
    }
}
public record ServerResponse(Integer id, String name) {
}
public record ErrorResponse (String message) {
}

リクエストパラメータで指定したステータスコードが400系、500系だったらエラーレスポンスを、それ以外は正常系のレスポンスを返すAPIです。

Server APIにリクエストを投げるAPI(Client)

@RestController
@RequestMapping("/client")
public class ClientController {

    private final ServerService serverService;

    public ClientController(ServerService serverService) {
        this.serverService = serverService;
    }

    @GetMapping("/default")
    public ResponseEntity<ClientResponse> defaultHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
        ResponseEntity<ServerResponse> serverResponse = serverService.defaultHandlerGet(statusCode);
        return ResponseEntity.status(serverResponse.getStatusCode())
                .body(ClientResponse.newInstance(serverResponse.getBody()));
    }

    @GetMapping("/custom")
    public ResponseEntity<ClientResponse> customHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
        ResponseEntity<ServerResponse> serverResponse = serverService.customHandlerGet(statusCode);

        if (serverResponse.getStatusCode().isError()) {
            // 4xx, 5xxの場合、ResponseEntityのレスポンスボディは、new ServerResponse(null, null)となる
            throw new ServerRestTemplateException(new ErrorResponse("custom handler error"), serverResponse.getStatusCode());
        }

        return ResponseEntity.status(serverResponse.getStatusCode())
                .body(ClientResponse.newInstance(serverResponse.getBody()));
    }

    @ExceptionHandler(ServerRestTemplateException.class)
    public ResponseEntity<ErrorResponse> handleServerRestTemplateException(ServerRestTemplateException e) {
        return ResponseEntity.status(e.getStatus())
                .body(e.getResponse());
    }

}
@Service
public class ServerService {
    private static final String BASE_URI = "http://localhost:8081/api";
    private static final String BASE_PATH = "/server";

    private final RestTemplate defaultHandlerRestTemplate;
    private final RestTemplate customHandlerRestTemplate;
    private final ObjectMapper objectMapper;

    public ServerService(
            RestTemplateBuilder defaultHandlerRestTemplateBuilder,
            RestTemplateBuilder customHandlerRestTemplateBuilder,
            ObjectMapper objectMapper) {
        this.defaultHandlerRestTemplate = defaultHandlerRestTemplateBuilder.rootUri(BASE_URI).build();
        this.customHandlerRestTemplate = customHandlerRestTemplateBuilder.rootUri(BASE_URI).build();
        this.objectMapper = objectMapper;
    }

    /**
     * RestTemplateを使った通信のエラーハンドリングに {@link org.springframework.web.client.DefaultResponseErrorHandler} を利用
     */
    public ResponseEntity<ServerResponse> defaultHandlerGet(Optional<Integer> statusCode) throws ServerRestTemplateException {
        var requestEntity = buildRequestEntity(statusCode);

        try {
            return defaultHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
        } catch (HttpStatusCodeException e) {
            try {
                var errorResponse = objectMapper.readValue(e.getResponseBodyAsString(), ErrorResponse.class);
                throw new ServerRestTemplateException(errorResponse, e.getStatusCode());
            } catch (IOException ioException) {
                throw new ServerRestTemplateException("invalid response");
            }
        } catch (RestClientException e) {
            throw new ServerRestTemplateException("error");
        }
    }

    /**
     * RestTemplateを使った通信のエラーハンドリングに {@link com.b1a9idps.client.externals.handler.RestTemplateResponseErrorHandler} を利用
     */
    public ResponseEntity<ServerResponse> customHandlerGet(Optional<Integer> statusCode) {
        var requestEntity = buildRequestEntity(statusCode);

        return customHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
    }

    private RequestEntity<Void> buildRequestEntity(Optional<Integer> statusCode) {
        var builder = UriComponentsBuilder.fromPath(BASE_PATH);
        if (statusCode.isPresent()) {
            builder.queryParam("status_code", statusCode.get());
        }
        var uri = builder.toUriString();

        return RequestEntity.get(uri).build();
    }
}

実行

Server API、Client APIともに起動し、Server APIにリクエストを投げてみます。

GET /api/client/defaultDefaultResponseErrorHandlerでエラーハンドリングするエンドポイントで、 GET /api/client/customResponseErrorHandlerを実装したクラスでエラーハンドリングするエンドポイントです。

% curl 'http://localhost:8080/api/client/default' | jq .
{
  "id": 1,
  "name": "name"
}

% curl 'http://localhost:8080/api/client/custom' | jq .
{
  "id": 1,
  "name": "name"
}

% curl 'http://localhost:8080/api/client/default?status_code=403' | jq .
{
  "message": "Forbidden"
}

% curl 'http://localhost:8080/api/client/custom?status_code=403' | jq .
{
  "message": "custom handler error"
}

解説

それぞれのエラーハンドリングについて解説します。

DefaultResponseErrorHandlerでエラーハンドリング

デフォルトだと、RestTemplateで通信して起きたエラー(ステータスコード400系か500系)は、DefaultResponseErrorHandlerでハンドリングされます。

該当コードを見てみるとこのように実装されており、通信してステータスコード400系と500系が返ってくるとHttpClientErrorExceptionを継承した例外クラスが投げられます。

@Override
public void handleError(ClientHttpResponse response) throws IOException {
    HttpStatus statusCode = HttpStatus.resolve(response.getRawStatusCode());
    if (statusCode == null) {
        byte[] body = getResponseBody(response);
        String message = getErrorMessage(response.getRawStatusCode(),
                response.getStatusText(), body, getCharset(response));
        throw new UnknownHttpStatusCodeException(message,
                response.getRawStatusCode(), response.getStatusText(),
                response.getHeaders(), body, getCharset(response));
    }
    handleError(response, statusCode);
}

protected void handleError(ClientHttpResponse response, HttpStatus statusCode) throws IOException {
    String statusText = response.getStatusText();
    HttpHeaders headers = response.getHeaders();
    byte[] body = getResponseBody(response);
    Charset charset = getCharset(response);
    String message = getErrorMessage(statusCode.value(), statusText, body, charset);

    switch (statusCode.series()) {
        case CLIENT_ERROR:
            throw HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset);
        case SERVER_ERROR:
            throw HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset);
     default:
            throw new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset);
    }
}

なので、HttpStatusCodeExceptionをcatchして、エラー時のレスポンスをHttpStatusCodeException#getResponseBodyAsStringで取得しています。

public ResponseEntity<ServerResponse> defaultHandlerGet(Optional<Integer> statusCode) throws ServerRestTemplateException {
    var requestEntity = buildRequestEntity(statusCode);

    try {
        return defaultHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
    } catch (HttpStatusCodeException e) {
        try {
            var errorResponse = objectMapper.readValue(e.getResponseBodyAsString(), ErrorResponse.class);
            throw new ServerRestTemplateException(errorResponse, e.getStatusCode());
        } catch (IOException ioException) {
            throw new ServerRestTemplateException("invalid response");
        }
    } catch (RestClientException e) {
        throw new ServerRestTemplateException("error");
    }
}

ResponseErrorHandlerを実装したクラスでエラーハンドリング

RestTemplateで通信してエラーが発生した際に例外を投げてほしくないという場合には、ResponseErrorHandlerを実装したクラスを用意します。 この例では、ステータスコード400系と500系が返ってきても何もしないように実装しています。

public class RestTemplateResponseErrorHandler extends DefaultResponseErrorHandler {
    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
    }
}

そして、RestTemplateでこのErrorHandlerを利用するようにします。

@Configuration(proxyBeanMethods = false)
public class RestTemplateConfig {
    @Bean
    public RestTemplateBuilder defaultHandlerRestTemplateBuilder() {
        return new RestTemplateBuilder();
    }

    @Bean
    public RestTemplateBuilder customHandlerRestTemplateBuilder() {
        return new RestTemplateBuilder()
                .errorHandler(new RestTemplateResponseErrorHandler());
    }
}

先に説明したように、ステータスコード400系や500系が返ってきても何もしないので、 new ServerResponse(null, null) がレスポンスボディとして返されます。

public ResponseEntity<ServerResponse> customHandlerGet(Optional<Integer> statusCode) {
    var requestEntity = buildRequestEntity(statusCode);

    return customHandlerRestTemplate.exchange(requestEntity, ServerResponse.class);
}

レスポンスボディからはエラーかどうかを判断できないので、ステータスコードでエラーかどうかを判断しています。

GetMapping("/custom")
public ResponseEntity<ClientResponse> customHandler(@RequestParam(value = "status_code", required = false) Optional<Integer> statusCode) throws ServerRestTemplateException {
    ResponseEntity<ServerResponse> serverResponse = serverService.customHandlerGet(statusCode);

    if (serverResponse.getStatusCode().isError()) {
        // 4xx, 5xxの場合、ResponseEntityのレスポンスボディは、new ServerResponse(null, null)となる
        throw new ServerRestTemplateException(new ErrorResponse("custom handler error"), serverResponse.getStatusCode());
    }

    return ResponseEntity.status(serverResponse.getStatusCode())
            .body(ClientResponse.newInstance(serverResponse.getBody()));
}

@ExceptionHandler(ServerRestTemplateException.class)
public ResponseEntity<ErrorResponse> handleServerRestTemplateException(ServerRestTemplateException e) {
    return ResponseEntity.status(e.getStatus())
            .body(e.getResponse());
}

まとめ

ResponseErrorHandlerを実装したクラスでエラーハンドリングを行うと、例外処理を書かなくて良くなるなる反面、呼び出したAPIからのエラーレスポンスが失われるかなと思います。ステータスコードさえわかって呼び出す側のシステムでエラーレスポンスを完全に決める場合なら有効かなと思います。

DefaultResponseErrorHandlerでエラーハンドリングする場合は、例外処理を書くのが億劫ですが呼び出したAPIからのエラーレスポンスを返すことができます。(あまり戻ってきたエラーレスポンスをそのまま返すというケースはないかもしれないですが)

Links

Spring Bootアプリケーションが起動するまでにやっていること

本記事は、Spring Boot 2.7.3について書いてあります。

Spring Bootとは

Spring Bootはフレームワークでなく、Spring Frameworkを利用してアプリケーションを作りやすくしている仕組みのこと。 Spring Boot単体では何も作ることはできないので、Spring WebやSpring Dataなどのフレームワークを利用する。(Spring BootではStarterを利用することがほとんど)

Spring Bootと非Spring Bootの大きな違いは、Starterという依存関係便利パックが提供されている&デフォルトの設定(必要そうなクラスのBean登録)がされていること。

Starters

アプリケーションを作るときにあったら必要であろう依存関係を含んだ便利パック。例えば、SpringとJPAを利用してアプリケーションを作りたいとなったら、spring-boot-starter-data-jpaを依存関係に追加すればよい。 Starterを利用すると後述するauto-configurationの仕組みが提供されており、単にライブラリを依存関係に追加するよりも設定項目が減り、楽にアプリケーション開発が行える。 ※ 公式のスターターはspring-boot-starter-*という名前になっており多くのIDEでそれに適した設定が入っているため、サードパーティのスターターは頭にspring-bootという名前をつけてはいけない。

Starterの例

  • Spring Boot application starters
    • Spring Bootでアプリケーションを作るために利用
    • spring-boot-starter-data-jpaspring-boot-starter-webなど
  • Spring Boot production starters
    • production readyな機能を提供する
    • spring-boot-starter-actuator
  • Spring Boot technical starters
    • デフォルトで利用されているライブラリ等を置き換えたり除外したりするときに利用
    • spring-boot-starter-loggingspring-boot-starter-tomcatなど

コーディング

Spring Bootアプリケーションにおけるエントリーポイントは@SpringBootApplicationで、これをメインクラスに付与する。 なお、@SpringBootApplicationはパッケージ最上位に置くのがよい。

一般的なメインクラス。

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

設定クラス

Spring Bootの設定にはJavaベースの設定を推奨している。XMLを利用することもできる。

Auto-configuration

Spring Boot auto-configurationは、プロジェクトに追加されている依存関係に基づいて、Spring アプリケーションに自動的に設定を行う仕組みのことである。 @EnableAutoConfigurationまたは@SpringBootApplicationを付与すると、auto-configurationを有効にすることができる。

デフォルトで全AutoConfigurationが適用されるが、以下のように書くとauto-configurationから除外することができる。

@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class Application {
}

@SpringBootApplicationについて

@SpringBootApplicationは、@SpringBootConfiguration@EnableAutoConfiguration@ComponentScanを含み持つアノーテーションでこれらと同じ機能を提供する。ちなみに、複数の機能を持つアノテーションのことを複合アノテーションと呼ぶ。

  • @EnableAutoConfiguration
    • 先に説明したauto-configurationを有効にする
  • @ComponentScan
  • @SpringBootConfiguration
    • このクラス自身をBean登録する。
    • @Configurationとほぼ同じ機能ではあるが、インテグレーションテストを書く際に役立つ。

Auto configuration

AutoConfigurationのクラスに@AutoConfigurationを付与するとBean登録される。 auto-configurationを適用させるクラスはMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 2.7より前はMETA-INF/spring.factoriesだった)に列挙されている。

AutoConfigurationImportSelector#getCandidateConfigurationsを参照。

Condition Annotations

auto-configurationクラスに書いてある@Conditonalに従ってBean登録が行われる。 @Conditionalの仲間には次のものがある。

Class Condition

クラスパスに指定したクラスがあるかないか。@ConditionalOnClass@ConditionalOnMissingClassJacksonAutoConfiguration.javaだと、ObjectMapper.javaがクラスパスに存在したらauto-configurationが適用される。

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

Bean Conditions

指定したBeanがあるかないか。@ConditionalOnBean@ConditionalOnMissingBean

RestTemplateAutoConfiguration#restTemplateBuilderConfigurerだと、RestTemplateBuilderConfigurerがBean登録されてなかったらこのメソッドでRestTemplateBuilderConfigurerのBean登録が行われる。

@Bean
@Lazy
@ConditionalOnMissingBean
public RestTemplateBuilderConfigurer restTemplateBuilderConfigurer(

Property Conditions

application.ymlや起動オプションなどで指定するSpring 環境プロパティによってBean登録が行われる。

JacksonHttpMessageConvertersConfiguration.MappingJackson2HttpMessageConverterConfiguration.javaだと、spring.mvc.converters.preferred-json-mapper=jacksonのときBean登録が行われる

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ObjectMapper.class)
@ConditionalOnBean(ObjectMapper.class)
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY,
        havingValue = "jackson", matchIfMissing = true)
static class MappingJackson2HttpMessageConverterConfiguration {

Resource Conditions

指定したリソースが存在したらBean登録する。@ConditionalOnResource

このようにresourcesにパスを指定する。(ProjectInfoAutoConfiguration#buildProperties

@ConditionalOnResource(resources = "${spring.info.build.location:classpath:META-INF/build-info.properties}")

Web Application Conditions

Webアプリケーションかどうか。@ConditonalOnWebApplication@ConditionalOnNotWebAplication。 その他、クラウドプラットフォームかどうかで判定する@ConditionalOnCloudPlatformなどもある。CloudPlatform.javaに定義されている、CloudFoundry、Heroku、SAP、Kubernetes、Azure App Serviceが対象。

SpEL Expression Conditions

@ConditionalOnExpressionはSpEL(SPring Expression Language)で書かれたプロパティに応じてBean登録する。

Spring Boot が起動するまでにやっていること

@EnableAutoConfiguraion(@SpringBootApplication)が行っていることから、Spring Bootの特徴であるAutoConfigureの仕組みを説明する。

1. DIコンテナ(ApplicationContext)の作成

ApplicationContextは、BeanFactoryを継承したアプリケーションの設定を提供する主要なインターフェース。メイン機能の1つにDIコンテナがある。

2. spring.factories、AutoConfiguration.imports読込

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsにauto-configureするクラス(@AutoConfigureをつけたクラス)を列挙する。 以前はspring.factoriesに書いていたが、このファイルはauto-configureを利用するために必要なクラスを書くようになった(ぽい)

これらに書かれたクラスを読み込む。

3. Bean登録するAutoConfigurationクラスをソート

定義された順序で、Bean登録するAutoConfigurationクラスをソートする。AutoConfigurationSorter#getInPriorityOrderで行う。

  1. アルファベット順
  2. @Orderの順
  3. @AutoConfigureBefore@AutoConfigureAfterの順
    1. @AutoConfigureBeforeは指定したクラスの前
    2. @AutoConfigureAfterは指定したクラスの後

4. Bean登録

ConfigurationClassParser#processGroupImportsでDIコンテナにBean登録を行う

application.properties(yml)について

application.propterties(yml)は、設定値を書くファイル。 Spring Bootが提供している設定値の一覧はここに書いてある。

このファイルに書いても確実に設定されるわけではなく、@ConfigurationPropertiesが付与されたクラスに値がバインドされるだけである。

設定が行われる条件は、次の2つが満たされていることです。

  • バインドされる@ConfigurationPropertiesのクラスがBean登録されていること
  • そのAutoConfigurationが有効となっていこと

大抵、@AutoConfigurationを付与したクラスに@EnableConfigurationPropertiesも付与されている。つまり、AutoConfigurationが有効になっていればapplication.properties(yml)に書いた設定は適用される。

これはDataSourceAutoConfiguration.javaの例である。

@AutoConfiguration(before = SqlInitializationAutoConfiguration.class)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
@Import(DataSourcePoolMetadataProvidersConfiguration.class)
public class DataSourceAutoConfiguration {

Banner

Bannerとは、Spring Bootを起動すると見るこれのことで地味に変更がされ続けている。

歴史

バージョン 内容
1.0.x Bannerを表示するかどうかの設定のみ
1.1.x Text Bannerを表示できるようになる
1.2.x Banner変数が登場
1.3.x Text Bannerが色付け可能に
1.4.x Image Bannerを表示可能に
2.0.x アニメーションgifをサポート
2.2.x ASCII バナーは256色利用可能に

カスタマイズ

バナーのカスタマイズ方法は3通りで、デフォルトバナーは3で実現しているおり、SpringBootBanner.javaが実装。

  1. resources/banner.[ gif | jpg | png ]を置く
  2. resources/banner.txtを置く
  3. org.springframework.boot.Bannerインターフェースを実装する

優先順位

SpringApplicationBannerPrinter#getBannerで表示すバナーを決定している。

  1. Image BannerとText Bannerを探す
  2. 1がなかったら自作のBannerクラス
  3. 1も2もなかったらDefault Banner

Image Banner と Text Bannerは一緒に表示可能。

Bannerインターフェース実装してみた

public class StoresBanner implements Banner {

    private static final String[] BANNER = {
            "  ______    _________     ___     _______      ________    ______   \\n" +
            ".' ____ \\\\  |  _   _  |  .'   `.  |_   __ \\\\    |_   __  | .' ____ \\\\  \\n" +
            "| (___ \\\\_| |_/ | | \\\\_| /  .-.  \\\\   | |__) |     | |_ \\\\_| | (___ \\\\_| \\n" +
            " _.____`.      | |     | |   | |   |  __ /      |  _| _   _.____`.  \\n" +
            "| \\\\____) |    _| |_    \\\\  `-'  /  _| |  \\\\ \\\\_   _| |__/ | | \\\\____) | \\n" +
            " \\\\______.'   |_____|    `.___.'  |____| |___| |________|  \\\\______.' \\n"
    };

    @Override
    public void printBanner(Environment environment, Class<?> sourceClass, PrintStream printStream) {
        for (String line : BANNER) {
            printStream.println(line);
        }
    }
}
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application11.class);
        app.setBanner(new StoresBanner());
        app.run(args);
    }

}

※ バナーが好きだと思われる人が作ったOnline Spring Boot Banner Generatorというサイトがある。