Spring Data JDBCを拡張してみる その2 - アノテーションでクエリを受け取る
毎日のようにブログを書いていると、昨年の Dapr Advent Calendar を思い出しますね。とは言え、そろそろブログを毎日書くのもいったんキリがつきそうです。
やりたいこと
前回も書きましたが、ゴールを再確認しておきます。
- Spring Data JDBCに機能追加して
query(String query, Object... args)
メソッドを追加できること - Spring Data JDBCを使ったうえで
@SqlFile
アノテーションで指定したSQLファイルを読み込んで実行できること
今回は2つ目を説明します。
具体的には次のような実装ができるようになるイメージです。
@Repository public interface EmpRepository extends SqlRepository<Emp, Long> { @SqlFile("/sql/selectOdd.sql") List<Emp> selectOdd(); }
自作Spring Dataの方でも実装したので同じ感じでいけるでしょうね。
1. 独自アノテーションを読んで処理する部分を作る
独自アノテーションを作る
まずはSQLファイル名を指定するためのアノテーションを作成します。
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface SqlFile { String value() default ""; }
これはアプリケーション側のRepositoryで使ってもらうものです。
RepositoryQuery
の実装
続いて、そのアノテーションを使ってクエリを構築する RepositoryQuery
を作成します。
public class SqlFileQuery implements RepositoryQuery { private SqlFile annotation; private JdbcOperations jdbcOperations; private RowMapper<?> rowMapper; private QueryMethod queryMethod; Lazy<String> query = Lazy.of(this::getQuery); public SqlFileQuery(SqlFile annotation, JdbcOperations jdbcOperations, RowMapper<?> rowMapper, QueryMethod queryMethod) { this.annotation = annotation; this.jdbcOperations = jdbcOperations; this.rowMapper = rowMapper; this.queryMethod = queryMethod; } @Override public Object execute(Object[] parameters) { return jdbcOperations.query(query.get(), rowMapper); } @Override public QueryMethod getQueryMethod() { return queryMethod; } private String getQuery() { String fileName = annotation.value(); URL resource = getClass().getResource(fileName); if (resource == null) { throw new RuntimeException("SQL file cannot be read: " + fileName); } try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.openStream()))) { return reader.lines().collect(Collectors.joining()); } catch (IOException e) { throw new UncheckedIOException("SQL file cannot be read: " + fileName, e); } } }
中核となる部分は execute
メソッドです。
@Override public Object execute(Object[] parameters) { return jdbcOperations.query(query.get(), rowMapper); }
JdbcOperations
に処理を流すだけですが、この処理を行うために必要なインスタンスをコンストラクタで受け取ってフィールドとして保持しています。
また実行するSQLについては spring-data-jdbc
を真似して Lazy
を使って遅延ローディングしています。
Lazy<String> query = Lazy.of(this::getQuery); private String getQuery() { String fileName = annotation.value(); URL resource = getClass().getResource(fileName); if (resource == null) { throw new RuntimeException("SQL file not found: " + fileName); } try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.openStream()))) { return reader.lines().collect(Collectors.joining()); } catch (IOException e) { throw new UncheckedIOException("SQL file cannot be read: " + fileName, e); } }
動作検証のためだけなら特に遅延ローディングする必要もなかったのですが、カッコイイので真似してみた感じです。
QueryLookupStrategy
の実装
さらに上で作った SqlFileQuery (implements RepositoryQuery)
のインスタンスを生成するための QueryLookupStrategy
を作成します。
QueryLookupStrategy
は RepositoryQuery
を返す resolveQuery
を実装するだけで構いません。
public class JdbcExtQueryLookupStrategy implements QueryLookupStrategy { JdbcOperations jdbcOperations; RelationalMappingContext context; JdbcConverter converter; QueryLookupStrategy originalQueryLookupStrategy; public JdbcExtQueryLookupStrategy(JdbcOperations jdbcOperations, RelationalMappingContext context, JdbcConverter converter, QueryLookupStrategy originalQueryLookupStrategy) { this.jdbcOperations = jdbcOperations; this.context = context; this.converter = converter; this.originalQueryLookupStrategy = originalQueryLookupStrategy; } @Override public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) { QueryMethod queryMethod = new QueryMethod(method, metadata, factory); EntityRowMapper<?> rowMapper = new EntityRowMapper<>(context.getRequiredPersistentEntity(metadata.getDomainType()), converter); SqlFile annotation = method.getAnnotation(SqlFile.class); if (annotation != null) { return new SqlFileQuery(annotation, jdbcOperations, rowMapper, queryMethod); } return originalQueryLookupStrategy.resolveQuery(method, metadata, factory, namedQueries); } }
メインとなる部分は、SqlFileQuery
のインスタンスを生成して返す部分ですね。
return new SqlFileQuery(annotation, jdbcOperations, rowMapper, queryMethod);
このために必要なインスタンスをコンストラクタで受け取って、フィールドとして保持しています。
ただここで、@SqlFile
アノテーションのついたメソッドを処理する分には問題ありませんが、それ以外のアノテーションがついている場合などにはSpring Data JDBCの標準機能で処理すべきです。そのために、コンストラクタで QueryLookupStrategy originalQueryLookupStrategy
というSpring Data JDBCが作成する QueryLookupStrategy
を受け取ってフィールドとして保持しておいて、@SqlFile
アノテーションがついていないメソッドの場合場合には、その originalQueryLookupStrategy
を使って RepositoryQuery
をルックアップするようにしました。
独自拡張あるあるな実装ですね。
JdbcExtRepositoryFactory
の実装修正
さらにこの JdbcExtQueryLookupStrategy
を使えるよう、前回作成した JdbcExtRepositoryFactory
に getQueryLookupStrategy
メソッドを追加します。具体的には次のメソッドを追加しました。
public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory { // (略) @Override protected Optional<QueryLookupStrategy> getQueryLookupStrategy(QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { Optional<QueryLookupStrategy> original = super.getQueryLookupStrategy(key, evaluationContextProvider); return Optional.of(new JdbcExtQueryLookupStrategy(operations.getJdbcOperations(), context, converter, original.orElseThrow())); } // (略) }
ここで先に super.getQueryLookupStrategy
を使ってSpring Data JDBC標準の QueryLookupStrategy
を取得しておき、今回作った JdbcExtQueryLookupStrategy
のコンストラクタに渡すようにしました。理由は上に書いたとおり、今回作った @SqlFile
アノテーションがついていないメソッドを処理する際には、Spring Data JDBC標準通りの動きをさせるためです。
これで実装は完了です。
2. サンプルアプリケーションを修正する
続いて、サンプルアプリケーション側も修正します。
Repositoryにメソッド追加
まずはRepositoryクラスに、今回作った @SqlFile
アノテーションをつけたメソッドを作成します。
@Repository public interface EmpRepository extends JdbcExtRepository<Emp, Long> { @SqlFile("/sql/selectOdd.sql") List<Emp> selectOdd(); @Query("select * from emp where id in (2, 4, 6)") List<Emp> selectEven(); }
既存のアノテーションが動くことも確認するためにSpring Data JDBCの @Query
アノテーションを利用したメソッドも追加しています。
また /src/main/resources/sql/selectOdd.sql
ファイルの中には次のようなクエリを書きました。
select * from emp where id in (1, 3, 5)
selectOdd
は id
が奇数のレコードを返す、selectEven
は id
が偶数のレコードを返すというクエリを実行します。
Controllerにメソッド追加
続いて、コントローラー側にも処理を追加します。
public class EmpController { // (略) @GetMapping("/odd") List<Emp> odd() { return empRepository.selectOdd(); } @GetMapping("/even") List<Emp> even() { return empRepository.selectEven(); } // (略) }
/odd
と /even
というエンドポイントが、それぞれリポジトリの selectOdd
selectEven
を呼び出すという簡単なものです。
これでサンプルアプリケーション側の実装も完了です。
実際に動かしてみる
それではアプリケーションを起動して、エンドポイントを叩いてみましょう。
% curl localhost:8080/odd
[{"id":1,"name":"Nakamoto"},{"id":3,"name":"Mizuno"},{"id":5,"name":"Fujihira"}]
% curl localhost:8080/even
[{"id":2,"name":"Kikuchi"},{"id":4,"name":"Sayashi"},{"id":6,"name":"Okazaki"}]
/odd
/even
とも期待通りの結果となりました。
前回のエントリーでカスタマイズできる仕組みまで作っており、今回はそれに乗っかって機能を追加しただけですので、簡単でしたね。
まとめ
今回作ったものはGitHubに置いています。
これまで5回に分けてSpring Dataを自作する方法とSpring Data JDBCを拡張する方法を説明してきました。最初は入口すら分からなくて難儀しましたが、構造や挙動が分かってしまえば後はサクッと実装してしまえるくらいの難易度でした。
実際にやってみた感想として、RDBMSにアクセスするためのSpring Dataを自作するのはコストとメリットが合わないので、Spring Data JDBCを拡張して好きなようにアノテーションやメソッドを追加する方が良さそうだなと思いました。Spring Dataの自作を考えるのは、RDBMS以外の新しいNoSQLにSpring Dataでアクセスしたいと思った時くらいでしょう。
そんなわけで長文を書き続けてきましたが、今回の取り組みはいったんここで区切りをつけます。これからしばらくは、どんな風にSpring Dataを拡張すると良いか考えて実装したいと思います。また形になってきたらブログで報告します。
それでは、Enjoy Spring Data!