52
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Weekend EngineerAdvent Calendar 2019

Day 9

Spring Data JDBCの使い方メモ

Last updated at Posted at 2019-12-10

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よりもシンプルで分かりやすいモジュールとして公開されています。
公式ドキュメントでは具体的に以下の点を挙げています。

  1. エンティティの読み込みに遅延ロードやキャッシュを使わない。毎回SQLを発行し、エンティティの全てのフィールドが読み込まれる。
  2. Spring Data JDBC側でエンティティのインスタンスのライフサイクルを管理しない。エンティティを保存すればデータベースに保存されるし、明示的にエンティティを保存しなければデータベースに変更は反映されない。他のスレッドでエンティティが書き換えられても、それを検知しない。
  3. エンティティとテーブルのマッピングは単純なマッピングの方法を使う。予め用意されているマッピングの仕方に沿わないものは、自分でマッピングをコーディングする必要がある。

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.ymlsrc/main/resources配下に作成し、以下のようにデータソースの設定をします。今回はH2DatabaseをPostgreSQLモードで起動するよう設定しました。

application.yml
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.sqlsrc/main/resources配下に作成します。

schema.sql
create table member
(
	id varchar not null
		constraint member_pk
			primary key auto_increment,
	name varchar not null
);

エンティティとリポジトリを作成

テーブルのプライマリキーに当たるプロパティには@Idというアノテーションを忘れずに付けましょう。永続化していないときのMemberクラスはIdがNullなので、@Witherアノテーションも付与しておきます。

Member.java
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode(of = {"id"})
@ToString
public class Member {
	@Id
	@Wither
	private final String id;
	private final String name;
}

リポジトリはCrudRepositoryを継承したものを作成します。
型引数はEntityの型、Idの型の順に指定します。

MemberRepository.java
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文を実行することになるのですが、どちらを実行するかを判定しているロジックが以下のとおりです。

  1. @Idアノテーションが付与されたカラムがNullである.
  2. EntityがPersistableインターフェース実装クラスで、isNew()メソッドがtrueである.

今回はパターン1を採用してみました.

動作確認をする

動作を確認するためのテストクラスを用意します。
とりあえずデータを登録して、そのデータが入っているかどうかを確認できれば大丈夫かしら。

MemberRepositoryTest.java
@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に以下のように追記してください。

application.properties
spring.test.database.replace=none

このように追記すれば、テストの時に自分の好きなデータベースサーバーを使うことができます。

細かい使い方

リポジトリ

基本的な使い方

Spring Data JDBCは、予め用意されているRepositoryインターフェースを継承したインターフェース作成することでデータベースアクセスを実現します。型引数に指定したIDとエンティティの型に応じて、基本的なメソッドを用意してくれるような便利なインターフェースが標準で用意されています。

リポジトリインターフェースのバリエーション

予め用意されているクラスは以下の通りです。

インターフェース名 仕様
Repository 空の最も基本となるリポジトリインターフェースを提供する。
CrudRepository CRUDの他、countexistsByIdなどのメソッドを提供する。
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リポジトリのうちエンティティを読み込むメソッドだけを定義したリポジトリの基底インターフェースです。

ReadOnlyRepository.java
@NoRepositoryBean
public interface ReadOnlyRepository extends Repository<Member, String> {
    Iterable<Member> findAll();
    Optional<Member> findById(String id);
}

カスタムクエリ

標準で用意されいないようなメソッドは、メソッドに@Queryアノテーションを付与し、アノテーションの引数にSQLを記述して定義します。SQLに渡したいパラメータは:引数名で指定できます。

MemberRepository.java

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を使った方法を紹介しています。

Member.java
@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パターン

デフォルトコンストラクタとフィールドにアクセッサを定義するエンティティです。

Member.java
@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. 引数なしコンストラクタが定義されていたら、引数なしコンストラクタでインスタンス生成をする
  2. 1以外で引数ありコンストラクタが一つ定義されていれば、引数ありコンストラクタでインスタンス生成をする
  3. 引数ありコンストラクタが複数あれば、@PersistenceConstructorが付与されているコンストラクタでインスタンス生成をする

フィールドへの代入に関する仕様

  1. イミュータブルなフィールドにwitherが定義されていれば、witherを使ってフィールドに値を設定する
  2. setterがあれば、setterを使ってフィールドに値を設定する
  3. フィールドがミュータブルだったら直接フィールドに値を設定する

エンティティのライフサイクル管理についての仕様

Spring Data JDBCではエンティティの保存も更新もsaveメソッドを使って行う。INSERT文を発行するかUPDATE文を発行したいかはエンティティが既に永続化されているのか、まだ永続化されていないのかによって変わる。
Spring Data JDBCでの主な判定ロジックは以下の2つです。

  1. エンティティの識別子がnullかどうかで判定する。
  2. エンティティが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メソッドを上書きすることで独自の変換用クラスを適用できます。

JdbcConfiguration.java
@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を付与します。

EnumToStringConverter.java
@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と同様です。

StringToEnumFactory.java
@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は以下のように定義するとテーブルのカラムとマッピングすることができます。

Address.java
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
public class Address {
	private final String postcode;
	private final String prefecture;
	private final String addressLine;
}
Member.java
@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アノテーションにはprefixonEmptyの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 を序数でない数値に変換する

52
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?