Spring Data JDBCの使い方メモ
はじめに
概要
今までMyBatis3を使ってきましたが、自動生成したソースコードのバージョン管理に悩まされていたので別のSpring周りのO/Rマッパーを探していました。そこでこの資料にあるSpring Data JDBCを知り、自分がよく使う機能拡張の部分についてメモに書き起こしました。
環境
Java 11
Gradle 5.4.1
Spring Boot 2.3.1.RELEASE
Spring Data JDBC 2.0.1.RELEASE
Spring Data JDBCの特徴
今までもSpring Dataには、Javaアプリケーションで最もよく使われるRDBへの永続化APIであるJPAに対応するモジュールが公開されていました。
新たにリリースされたSpring Data JDBCは、JPAよりもシンプルで分かりやすいモジュールとして公開されています。
公式ドキュメントでは具体的に以下の点を挙げています。
- エンティティの読み込みに遅延ロードやキャッシュを使わない。毎回SQLを発行し、エンティティの全てのフィールドが読み込まれる。
- Spring Data JDBC側でエンティティのインスタンスのライフサイクルを管理しない。エンティティを保存すればデータベースに保存されるし、明示的にエンティティを保存しなければデータベースに変更は反映されない。他のスレッドでエンティティが書き換えられても、それを検知しない。
- エンティティとテーブルのマッピングは単純なマッピングの方法を使う。予め用意されているマッピングの仕方に沿わないものは、自分でマッピングをコーディングする必要がある。
Spring Data JPAと違って機能がそこまで多くはないので、Spring Data JDBCが用意しているやり方に沿うように設計をすることが、Spring Data JDBCを活かすコツになりそうです。
導入方法
プロジェクト作成
Spring Initializrで以下の内容を選択し、プロジェクトをダウンロードします。
項目 | 選択内容 |
---|---|
Project | Gradle Project |
Language | Java |
Spring Boot | 2.2.1 |
Dependencies | Spring Data JDBC、Lombok |
ProjectとLanguageは別のものを選択していただいても大丈夫ですが、今回紹介するソースを一部読み替えていただく形になります。
Spring Bootのバージョンは時期によって選択できるバージョンが変わってしまいますが、デフォルトのバージョンを選んでいただければ大丈夫です。
build.gradleは以下のような内容になっているはずです。
plugins {
id 'org.springframework.boot' version '2.2.2.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
runtimeOnly 'com.h2database:h2'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
}
test {
useJUnitPlatform()
}
Springにデータソースを設定
application.yml
をsrc/main/resources
配下に作成し、以下のようにデータソースの設定をします。今回はH2DatabaseをPostgreSQLモードで起動するよう設定しました。
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL
username: sa
password:
schema.sqlを作成
テスト用のDDLを記述したSQLファイルschema.sql
をsrc/main/resources
配下に作成します。
create table member
(
id varchar not null
constraint member_pk
primary key auto_increment,
name varchar not null
);
エンティティとリポジトリを作成
テーブルのプライマリキーに当たるプロパティには@Id
というアノテーションを忘れずに付けましょう。永続化していないときのMemberクラスはIdがNullなので、@Wither
アノテーションも付与しておきます。
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode(of = {"id"})
@ToString
public class Member {
@Id
@Wither
private final String id;
private final String name;
}
リポジトリはCrudRepository
を継承したものを作成します。
型引数はEntityの型、Idの型の順に指定します。
public interface MemberRepository extends CrudRepository<Member, String> {
}
これだけでなんと以下のメソッドがMemberCredentialRepositoryに定義されます。
- Member save(Member entity)
- Iterable saveAll(Iterable entities)
- Optional findById(String id)
- boolean existsById(String id)
- Iterable findAll()
- Iterable findAllById(Iterable ids)
- long count()
- void deleteById(String id)
- delete(Member entity)
- deleteAll(Iterable entities)
- deleteAll()
JPAと似たような感じですね。
saveメソッドでINSERT文とUPDATE文を実行することになるのですが、どちらを実行するかを判定しているロジックが以下のとおりです。
-
@Id
アノテーションが付与されたカラムがNullである. - Entityが
Persistable
インターフェース実装クラスで、isNew()メソッドがtrueである.
今回はパターン1を採用してみました.
動作確認をする
動作を確認するためのテストクラスを用意します。
とりあえずデータを登録して、そのデータが入っているかどうかを確認できれば大丈夫かしら。
@DataJdbcTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
void test() {
String name = "くちた";
Member save = memberRepository.save(new Member(null, name));
assertNotNull(save.getId());
Optional<Member> maybeMember = memberRepository.findById(save.getId());
assertTrue(maybeMember.isPresent());
maybeMember
.ifPresent(member -> assertEquals(save.getName(), member.getName()));
}
}
Tips: Testで組み込み用のデータベースを使わない場合
@DataJdbcTest
を付与するとデフォルトで組み込みデータベースが起動します。
今回はh2を使ったので良いですが外部のデータベースサーバーに接続する場合は、application.properties
に以下のように追記してください。
spring.test.database.replace=none
このように追記すれば、テストの時に自分の好きなデータベースサーバーを使うことができます。
細かい使い方
リポジトリ
基本的な使い方
Spring Data JDBCは、予め用意されているRepositoryインターフェースを継承したインターフェース作成することでデータベースアクセスを実現します。型引数に指定したIDとエンティティの型に応じて、基本的なメソッドを用意してくれるような便利なインターフェースが標準で用意されています。
リポジトリインターフェースのバリエーション
予め用意されているクラスは以下の通りです。
インターフェース名 | 仕様 |
---|---|
Repository | 空の最も基本となるリポジトリインターフェースを提供する。 |
CrudRepository | CRUDの他、count やexistsById などのメソッドを提供する。 |
PagingAndSortingRepository | 上記に加え、ページングやソートをした結果を戻すメソッドを提供する。 |
以下のような使い分けをすれば大丈夫かと思います。
- 読み込み専用のリポジトリを作るならRepositoryを継承する。
- オリジナルのリポジトリ基底クラスを作るならRepositoryを継承する。
- CRUD全部使うならCrudRepositoryを継承する。
- さらにページングやソートも行うならPagingAndSortingRepositoryを継承する。
Spring Data JDBC 1.1.1.RELEASE現在
PagingAndSortRepositoryが正常に動作しませんでした。
stack overflow -PagingAndSortingRepository methods throw error when used with spring data jdbc-
カスタム基底リポジトリ
標準で用意されているインターフェースとは別にプロジェクト共通で使うインターフェースを定義したいことがあると思います。その場合はインターフェースを拡張しましょう。このとき、クラスに@NoRepositoryBean
アノテーションを付与してください。
以下はCrudリポジトリのうちエンティティを読み込むメソッドだけを定義したリポジトリの基底インターフェースです。
@NoRepositoryBean
public interface ReadOnlyRepository extends Repository<Member, String> {
Iterable<Member> findAll();
Optional<Member> findById(String id);
}
カスタムクエリ
標準で用意されいないようなメソッドは、メソッドに@Query
アノテーションを付与し、アノテーションの引数にSQLを記述して定義します。SQLに渡したいパラメータは:引数名
で指定できます。
public interface MemberRepository extends CrudRepository<Member, String> {
@Query("SELECT * FROM member WHERE name = :name")
List<Member> getMembersByNameEquals(String name);
}
エンティティ
基本的な定義の仕方
イミュータブルオブジェクトかJavaBeansを定義するのが基本的な実装パターンです。
IDが自動生成だという前提で記載していきます。
イミュータブルオブジェクト
イミュータブルなフィールドとフィールド全てを引数にとるコンストラクタを定義するエンティティです。
引数有りコンストラクタを複数定義するには、Spring Data JDBCでインスタンス生成に使うコンストラクタに@PersistenceConstructor
アノテーションをつける必要があります。
Spring Data JDBCのアノテーションをつけたくない場合は、ファクトリメソッドを別途定義すればよいです。
識別子となるカラムには@Id
アノテーションを付与します。
idはエンティティを保存した後にデータベースで発行された識別子を代入することになるので、idを更新できるようにwitherを定義しておきます。
※リファレンスには全引数コンストラクタを使うか、witherを使うか2つの方法が示されていたのですが、手元の環境で前者の方法がうまくいかなかったのでwitherを使った方法を紹介しています。
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode(of = {"id"})
@ToString
public class Member {
@Id
@With
private final String id;
private final String name;
public static Member createInstance(String name) {
return new Member(null, name);
}
}
JavaBeansパターン
デフォルトコンストラクタとフィールドにアクセッサを定義するエンティティです。
@Getter
@Setter
@EqualsAndHashCode(of = {"id"})
@ToString
public class Member {
@Id
private String id;
private String name;
public static Member createInstance(String name) {
return new Member(null, name);
}
}
オブジェクト生成に関する仕様
- 引数なしコンストラクタが定義されていたら、引数なしコンストラクタでインスタンス生成をする
- 1以外で引数ありコンストラクタが一つ定義されていれば、引数ありコンストラクタでインスタンス生成をする
- 引数ありコンストラクタが複数あれば、
@PersistenceConstructor
が付与されているコンストラクタでインスタンス生成をする
フィールドへの代入に関する仕様
- イミュータブルなフィールドにwitherが定義されていれば、witherを使ってフィールドに値を設定する
- setterがあれば、setterを使ってフィールドに値を設定する
- フィールドがミュータブルだったら直接フィールドに値を設定する
エンティティのライフサイクル管理についての仕様
Spring Data JDBCではエンティティの保存も更新もsaveメソッドを使って行う。INSERT文を発行するかUPDATE文を発行したいかはエンティティが既に永続化されているのか、まだ永続化されていないのかによって変わる。
Spring Data JDBCでの主な判定ロジックは以下の2つです。
- エンティティの識別子がnullかどうかで判定する。
- エンティティが
Persistable#isNew
を実装している場合、メソッドの戻り値をもとにエンティティが永続化されているか、されていないかを判定する
もしIDをデータベース側で自動生成しない場合は、2番の方法に則ってエンティティを定義するとよいです。
データ型の変換について
デフォルトで対応している型
- 基本型
- String
- Enum(数値はordinal、文字列はnameをもとに変換)
- Date, LocalDate, LocalTime, LocalDateTime
- その他JDBCドライバがサポートしている型
- 上記の型の配列とList
- エンティティ、もしくはエンティティのSet
- エンティティをValueにもつMap
Spring Data JDBCでは、hasOneやhasManyのリレーションを限定的にサポートしています。エンティティやその集合をフィールドに持つのは、ドメイン駆動設計でいうところのルートエンティティとその集約内のエンティティという関係性が成り立つ場合だけに留めておいてください。
無秩序にhasOneやhasManyの関係を定義すると、「実際にデータが存在するかしないか」ではなく「SQLでJOINしたかしていないか」によってNULLやEmptyが定義されることになります。Spring Data JDBCを使っていなくても、これは重大なバグや生産性低下の原因になり得ます。
型変換のカスタマイズ
型変換はConverterもしくはConverterFactoryを使うことでカスタマイズできます。
作成したConverterとConverterFactoryを、AbstractJdbcConfigurationを継承した設定クラスで適用します。
jdbcCustomConversions
メソッドを上書きすることで独自の変換用クラスを適用できます。
@Configuration
public class JdbcConfiguration extends AbstractJdbcConfiguration {
@Override
public JdbcCustomConversions jdbcCustomConversions() {
return new JdbcCustomConversions(List.of(
// Converter/ConverterFactoryのbeanを登録
));
}
}
変換先が特定のクラスの場合
EnumをStringに変換するなど、変換先が特定の単一クラスの場合はConveterを実装します。
定義したConveterには@ReadingConverter
か@WritingConveter
を付与します。データベースからの読み込み時に使うConverterであれば@ReadingConverter
を、データベースへの書き込み時に使うConverterであれば@WritingConverter
を付与します。
@WritingConverter
public enum EnumToStringConverter implements Converter<Enum, String> {
INSTANCE;
@Override
public String convert(Enum e) {
return e.name();
}
}
変換先がインターフェースの実装や特定クラスのサブクラスなどの場合
StringをEnumに変換するなど、変換先がインターフェースの実装や特定クラスのサブクラスなどの場合はConverterFactoryを実装します。
ConverterFactory#getConverter
の仮引数が変換先クラスの情報なので、Converterのインスタンスを生成する処理で変換先クラスの情報を扱えるのが利点です。
アノテーションについてはConverterと同様です。
@ReadingConverter
public enum StringToEnumFactory implements ConverterFactory<String, Enum> {
INSTANCE;
@Override
public <T extends Enum> Converter<String, T> getConverter(Class<T> aClass) {
return new StringToEnum<T>(aClass);
}
@RequiredArgsConstructor
private static class StringToEnum<T extends Enum> implements Converter<String, T> {
private final Class<T> enumType;
@Override
public T convert(String s) {
return s == null ? null : Enum.valueOf(enumType, s);
}
}
}
ネストしたオブジェクトに複数のカラムをマッピングする
@Embedded
アノテーションを使うことで、ユーザー定義の値オブジェクトとカラムをマッピングすることができます。
値オブジェクトAddress
をフィールドにもつエンティティMember
は以下のように定義するとテーブルのカラムとマッピングすることができます。
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
public class Address {
private final String postcode;
private final String prefecture;
private final String addressLine;
}
@Getter
@EqualsAndHashCode(of = {"id"})
@ToString
public class Member {
@Id
@With
private final String id;
private final String name;
@Embedded(prefix = "address_", onEmpty = Embedded.OnEmpty.USE_NULL)
private final Address address;
public static Member createInstance(String name, Address address) {
return new Member(null, name, address);
}
}
例のように@Embedded
アノテーションにはprefix
とonEmpty
の2つの引数を指定する必要があります。
prefix
値オブジェクトの各フィールドは、「prefix」と「値オブジェクトのフィールド名」を使ってカラムとマッピングされます。
ここでAddress
のフィールドとカラムのマッピングは以下のように解決されます。
フィールド名 | カラム名 |
---|---|
postcode | address_postcode |
prefecture | address_prefecture |
addressLine | address_address_line |
onEmpty
もし値オブジェクトに対応するフィールドがNULLだったときにどのような値をエンティティのフィールドに設定するかを指定します。
設定値 | 内容 |
---|---|
USE_NULL | NULLを設定する |
USE_EMPTY | 空の値オブジェクトを設定する |
基本的にはUSE_NULLを使うのが無難かなと思っています。
さいごに
初版はここまでとして、今後運用していきながら知見を整理していければと思います。
参考URL
Spring Data JDBC 公式リファレンス
stack overflow -PagingAndSortingRepository methods throw error when used with spring data jdbc-
Spring Data JDBC で Enum を序数でない数値に変換する