Tagbangers Blog

Spring Data JDBC 101

Spring Data JDBC


タグバンガーズでは Spring Data JPA をデータベースアクセスに使用することが多いのですが、
今回は「シンプルに!」がコンセプトの Spring Data JDBC に触れて見たいと思います。
リリースされてから結構経ってしまいましたが早速いってみよう。

シンプル


JPA は便利で高機能です。それが故に複雑になりがちです。
複雑さを生むのは以下のような要素があるからだと言われます。

  • 遅延ロード
  • エンティティのプロキシ
  • セッション / 1st レベルキャッシュ
  • エンティティの監視

こんなに機能必要ない。そんなときは Spring Data JDBC を使いましょう

サンプルコード


以降記事内のコードは Github のレポジトリに公開しております。

https://github.com/DaiYamask/spring-data-jdbc-sample

Mavenの設定


依存関係に spring-data-jdbc を追加しましょう

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jdbc</artifactId>
</dependency>


Entity 


DDDに明るくないのですが、各プロパティは final で宣言し、イミュータブルな User オブジェクトを想定します。

public class User {
    @Id
    private final Long id;
    private final String name;
    private final Integer age;
}

ファクトリーメソッドを提供しよう

オブジェクトの生成のために引数を持つコンストラクタが複数ある場合は `@PersistenceConstructor` を付与することで Spring Data に使ってほしいコンストラクタを伝えることができます。

@Id
private final Long id;
private final String name;
private final Integer age;

public User(String name, Integer age) {
    this(null, name, age);
}

@PersistenceConstructor
public User(Long id, String name, Integer age) {
    this.id = id;
    this.name = name;
    this.age = age;
}

ですが、 `@PersistenceConstructor` がなければ更によいとされています。そのためにはファクトリーメソッドを提供しましょう。
また、コンストラクタが減ることによるパフォーマンスの向上も期待できます。

public static User of(String name, Integer age) {
    return new User(null, name, age);
}

@AllArgsConstructor を使う

今更ですが Lombok を使って、記述を減らしましょう。とドキュメントにも書いてあることを発見したのでおすすめしておきたいです。

@AllArgsConstructor
public class User {

    @Id
    private final Long id;
    private final String name;
    private final Integer age;

    public static User of(String name, Integer age) {
        return new User(null, name, age);
    }

wither メソッドを追加しよう

プロパティをイミュータブルにしているため、 wither メソッドも必要です。

public User withId(Long id){
    return new User(id, name, age);
}

どういうことか簡単なテストで確認してみましょう。

@Test
public void saveAndFindUser() {
    User user = User.of("タグバンガーズ太郎", 8);
    User savedUser = userRepository.save(user);
    Assertions.assertThat(user).isNotEqualTo(savedUser);
}

つまり、 User を生成して永続化すると、新しい User が返されるので wither が必要ということになります。
ちなみに、以下のように @Wither をプロパティに付与すると更に記述をへらすことも可能です。

@Id
@Wither
private final Long id;

One to Many - 1 対 多 -

User は Nickname を複数持っている状況を考えます

public class Nickname {
    @Id
    private Long id;
    private String name;

    private Nickname(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public static Nickname of(String name) {
        return new Nickname(null, name);
    }
}

User は Role を複数持てるので追加

public class User {

    @Id
    @Wither
    private final Long id;
    private final String name;
    private final Integer age;
    private List<Nickname> nicknames;

    ・
    ・
    ・

テストしてみる

@Test
public void oneToOne() {
    Nickname nickname1 = Nickname.of("たろー");
    Nickname nickname2 = Nickname.of("タグバン");
    User user = User.of("タグバンガーズ太郎", 33, Arrays.asList(nickname1, nickname2));

    User savedUser = userRepository.save(user);

    Assertions.assertThat(savedUser).isNotNull();

    Optional<Role> role1 = roleRepository.findById(savedUser.getNicknames().get(0).getId());
    Assertions.assertThat(role1.isPresent()).isTrue();

    userRepository.delete(savedUser);

    Optional<Role> role2 = roleRepository.findById(savedUser.getNicknames().get(0).getId());
    Assertions.assertThat(role2.isPresent()).isFalse();
}

ログを確認すると、保存して、さらに削除できていることがわかります。
ここで注目したいのは関連している Nickname もすべて削除されるということです。
Nickname は通常 User が消されれば消えても良さそうですが、 Role(権限)の用に他の User からも参照されるようなものはどうでしょうか。
この挙動では困ります。

Executing SQL update and returning generated keys
Executing prepared SQL statement [INSERT INTO user (name, age) VALUES (?, ?)]
Executing SQL update and returning generated keys
Executing prepared SQL statement [INSERT INTO nickname (nickname, user_key, user) VALUES (?, ?, ?)]
Executing SQL update and returning generated keys
Executing prepared SQL statement [INSERT INTO nickname (nickname, user_key, user) VALUES (?, ?, ?)]
Executing prepared SQL query
Executing prepared SQL statement [SELECT nickname.id AS id, nickname.nickname AS nickname FROM nickname WHERE nickname.id = ?]
Executing prepared SQL update
Executing prepared SQL statement [DELETE FROM nickname WHERE user = ?]
Executing prepared SQL update
Executing prepared SQL statement [DELETE FROM user WHERE id = ?]
Executing prepared SQL query
Executing prepared SQL statement [SELECT nickname.id AS id, nickname.nickname AS nickname FROM nickname WHERE nickname.id = ?]


Many to Many - 多 対 多 -

Repository


簡単な CRUD を実装(?)してみましょう

public interface UserRepository extends CrudRepository<User, Long> {
}

見慣れている人はなんの感動もないかもしれないですが、これだけで以下が自動で生成されます。
save や findByID などはよく使いますが、 exist、count もあるんだなと改めて感動。

<S extends T> S save(S var1);
<S extends T> Iterable<S> saveAll(Iterable<S> var1);
Optional<T> findById(ID var1);
boolean existsById(ID var1);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> var1);
long count();
void deleteById(ID var1);
void delete(T var1);
void deleteAll(Iterable<? extends T> var1);
void deleteAll();

テスト

@Test
public void save() {
    User user = User.of("タグバンガーズ太郎", 8);
    User savedUser = userRepository.save(user);

    Assertions.assertThat(savedUser).isNotNull();
    Assertions.assertThat(user).isNotEqualTo(savedUser);
}

@Test
public void saveAndFind() {
    User user = User.of("タグバンガーズ太郎", 8);
    User savedUser = userRepository.save(user);

    Optional<User> findUser = userRepository.findById(savedUser.getId());

    Assertions.assertThat(findUser.get()).isNotNull();
    Assertions.assertThat(findUser.get().getAge()).isEqualTo(8);
    Assertions.assertThat(findUser.get().getName()).isEqualTo("タグバンガーズ太郎");
}

@Test
public void saveAndDelete() {
    User user = User.of("タグバンガーズ太郎", 8);
    User savedUser = userRepository.save(user);
    userRepository.deleteById(savedUser.getId());

    Iterable<User> users = userRepository.findAll();

    Assertions.assertThat(users.iterator().hasNext()).isFalse();
}

@Test
public void saveAndCount() {
    User taro = User.of("タグバンガーズ太郎", 8);
    User hanako = User.of("タグバンガーズ花子", 10);
    userRepository.saveAll(Arrays.asList(taro, hanako));

    long countUser = userRepository.count();

    Assertions.assertThat(countUser).isEqualTo(2L);
}

@Test
public void saveAndExist() {
    User user = User.of("タグバンガーズ太郎", 8);
    User savedUser = userRepository.save(user);

    boolean exists = userRepository.existsById(savedUser.getId());

    Assertions.assertThat(exists).isTrue();
}

Spring Data REST


Spring Data REST もよく使うので確認しましょう。

CrudRepository は追加済なので、あとは Spring Data REST を依存関係を追加

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>

User を追加

User user1 = User.of("タグバンガーズたろう", 19);
User user2 = User.of("やまさきだい", 33);
repository.save(user1);
repository.save(user2);

Get してみる

curl http://localhost:8080/users
{
  "_embedded" : {
    "users" : [ {
      "name" : "タグバンガーズたろう",
      "age" : 19,
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/users/1"
        },
        "user" : {
          "href" : "http://localhost:8080/users/1"
        }
      }
    }, {
      "name" : "やまさきだい",
      "age" : 33,
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/users/2"
        },
        "user" : {
          "href" : "http://localhost:8080/users/2"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/users"
    },
    "profile" : {
      "href" : "http://localhost:8080/profile/users"
    }
  }
}

QueryDSL未対応問題 (2019年4月4日現在)


public interface UserRepository extends QuerydslPredicateExecutor<User> {
}

で、テストをしましたが、エラー。

java.lang.IllegalStateException: No query specified on findOne

Spring Data JDBC の実装が以下の通りで @Query アノテーションを期待されているので、やはり未対応。。

private String determineQuery() {
    String query = this.queryMethod.getAnnotatedQuery();
    if (StringUtils.isEmpty(query)) {
        throw new IllegalStateException(String.format("No query specified on %s", this.queryMethod.getName()));
    } else {
        return query;
    }
}

APT で生成

DBを参照して生成


jOOQ (じゅーく)を使ってみる

ATP ではなく データベースのスキーマ(DDL) ベースでメタモデルを作ってくれるようなのでこちらも試してみましょう

jOOQ の Config

@Configuration
public class Config {

    @Autowired
    DataSource dataSource;

    @Bean
    public DataSourceConnectionProvider connectionProvider() {
        return new DataSourceConnectionProvider(new TransactionAwareDataSourceProxy(dataSource));
    }

    @Bean
    DefaultDSLContext dsl() {
        return new DefaultDSLContext(configuration());
    }

    public DefaultConfiguration configuration() {
        DefaultConfiguration jooqConfiguration = new DefaultConfiguration();
        jooqConfiguration.set(connectionProvider());
        return jooqConfiguration;
    }
}

jOOQ の CustomRepository を作成

public interface JooqCustomUserRepository {
    List<User> findByName(String name);
}
public class JooqCustomUserRepositoryImpl implements JooqCustomUserRepository {

    @Autowired
    private DSLContext dslContext;

    @Override
    public List<User> findByName(String name) {
        return this.dslContext.select().from(USER).where(USER.NAME.equal(name))
            .fetchInto(User.class);
    }
}
public interface UserRepository extends CrudRepository<User, Long>, JooqCustomUserRepository {
}

テスト

@Test
public void findByName() {
    User user1 = User.of("タグバンガーズ太郎", 8);
    userRepository.save(user1);

    User user2 = User.of("やまさきだい", 8);
    userRepository.save(user2);

    List<User> users = userRepository.findByName("タグバンガーズ太郎");

    Assertions.assertThat(users.size()).isEqualTo(1);
}

さいごに

対 JPA で見た場合出来ることが限られ、キャッシュ、遅延ロードなどなど便利だけど複雑性を生んでいる機能が省かれています。

シンプルって難しいよねという議論(シンプルとイージーは違うよねという話)が弊社では持ち切りですが、Spring Data JDBC の発展に期待しつつ。また次回。