独自Spring Dataを作ってみる その3 - SQLファイルを読んで実行する処理を作る
前回のエントリーで、findAllは無事に実行できたので、次は独自拡張として任意のSQLを実行することを目指します。
やりたいこと
先にゴールを明確にしておきます。
具体的には次のような実装ができるようになるイメージです。
@Repository public interface EmpRepository extends SqlRepository<Emp, Long> { @SqlFile("/sql/selectOdd.sql") List<Emp> selectOdd(); @Query("select * from emp where id in (2, 4, 6)") List<Emp> selectEven(); }
いったんクエリに渡す引数については考えないことにします。アノテーションを読んで処理する仕組みさえできれば、あとはどうとでもなると思いますので。
TL;DR
例によって長文ですので、先にまとめておきますね。
- もちろん作ろうと思ってたものは作れたし、余裕があったから、もう1機能つくったよ
- SqlFileアノテーションと、Queryアノテーションと、さらに任意のクエリを実行できるようになった独自Spring DataとサンプルアプリケーションをGitHubに置いといたよ(リンク)
RepositoryQuery
とQueryLookupStrategy
を作成すれば、メソッドに対するクエリを自分で作れるようになるよ- 共通のRepositoryインターフェイスと、それに対する
DefaultRepository
にメソッドを増やせば、好みの共通メソッドをどんどん増やせるよ - 今回のエントリーは前回よりも短いよ
1. Spring Data JDBCの実装を読む
今回もまずは spring-data-jdbc
のコードリーディングです。実はSpring Data JDBCにも @Query
アノテーションで指定したクエリをそのまま実行する機能があります。なので、その周辺のコードから読んでいけばよさそうです。
Queryアノテーションを利用している所を追う
Queryアノテーションは spring-data-jdbc
が提供しています。
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @QueryAnnotation @Documented public @interface Query { String value() default ""; // (略) }
IntelliJでこのクラスのFind Usageすると、JdbcQueryMethod
というクラスで利用されていることが分かりました。
public boolean hasAnnotatedQueryName() { return lookupQueryAnnotation() // .map(Query::name) // .map(StringUtils::hasText).orElse(false); }
さらにこの呼び出し元をたどると JdbcQueryLookupStrategy (implements QueryLookupStrategy)
というクラスの resolveQuery
メソッドから呼ばれていることが分かりました。
abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy { // (略) static class DeclaredQueryLookupStrategy extends JdbcQueryLookupStrategy { // 略 @Override public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repositoryMetadata, ProjectionFactory projectionFactory, NamedQueries namedQueries) { JdbcQueryMethod queryMethod = getJdbcQueryMethod(method, repositoryMetadata, projectionFactory, namedQueries); if (namedQueries.hasQuery(queryMethod.getNamedQueryName()) || queryMethod.hasAnnotatedQuery()) { if (queryMethod.hasAnnotatedQuery() && queryMethod.hasAnnotatedQueryName()) { LOG.warn(String.format( "Query method %s is annotated with both, a query and a query name; Using the declared query", method)); } StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper, getConverter(), evaluationContextProvider); query.setBeanFactory(getBeanFactory()); return query; } throw new IllegalStateException( String.format("Did neither find a NamedQuery nor an annotated query for method %s", method)); }
何やら見た感じ、アノテーションの値を利用して StringBasedJdbcQuery
というクエリを作っている感じですね。
また resolveQuery
メソッドの第一引数として Method
が渡されているあたり、Repositoryに追加したメソッドが呼ばれたときにこのメソッドが呼ばれて、このメソッドに対応するクエリを返すような処理であることが何となく分かります。
さらに、この resolveQuery
メソッドは spring-data-commons
の QueryExecutorMethodInterceptor
というInterceptorから呼ばれています。
private Pair<Method, RepositoryQuery> lookupQuery(Method method, RepositoryInformation information, QueryLookupStrategy strategy, ProjectionFactory projectionFactory) { try { return Pair.of(method, strategy.resolveQuery(method, information, projectionFactory, namedQueries)); } catch (QueryCreationException e) { throw e; } catch (RuntimeException e) { throw QueryCreationException.create(e.getMessage(), e, information.getRepositoryInterface(), method); } }
Spring Dataのように interface
だけ定義して動くようなフレームワークでは、Interceptor
を利用して処理を行わせることは王道中の王道ですね。
この一連の流れを見る限り、Repositoryに実装したメソッドの実行時に発行すべきクエリを決めるために QueryLookupStrategy
の resolveQuery
メソッドが呼ばれると考えて差し支えなさそうです。
RepositoryQueryって何ぞや?
QueryLookupStrategy
の resolveQuery
の処理をよく見ていきます。
まず戻り値の RepositoryQuery
についてですが、これは「Repositoryで定義するクエリ」を扱うクラスで、まさに今回のRepositoryに定義した @Query
アノテーションのついたメソッドはそれに該当します。むしろRepository以外のどこで定義するんだよと思って調べたところ、Spring Data JPAには NamedQuery
という仕組みがあり、それはRepositoryではなくEntity側で定義するようです。
たとえば、こんな感じで定義するそうです。
@Entity @NamedQuery(name = "myFindByAge", query = "select u from User u where u.age = ?1") public class User { //... }
さすがにこういう感じのEntity側で行う定義はNo thank youなので RepositoryQuery
のことだけを考えることにします。
ちなみに RepositoryQuery
インタフェースは次のように定義されています。
public interface RepositoryQuery { @Nullable Object execute(Object[] parameters); QueryMethod getQueryMethod(); }
えらくシンプルですね。execute
が実際にクエリを実行する処理であろうことが推測できます。
StringBasedJdbcQueryを読、、、まず
resolveQuery
メソッドでは RepositoryQuery
の実装として StringBasedJdbcQuery
のインスタンスを生成して返していました。
StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper, getConverter(), evaluationContextProvider);
名前からして明らかに文字列を使ったクエリという感じです。
これは間違いなく参考になるだろうと思って少し実装を眺めたところ、それなりに色々な処理をしていたので、よくわからんなと思って閉じました。そういうこともありますよ、ハハッ。
とりあえずクエリを発行したいだけであれば RepositoryQuery
の実装はさほど難しくなさそうなので、後ほど自力で実装したいと思います。
QueryLookupStrategyのインスタンスはどこで作られるのか?
一方で JdbcQueryLookupStrategy (implements QueryLookupStrategy)
のインスタンスはどこで作られているのでしょうか。
これまでの経験で、なんとなく JdbcRepositoryFactory
か JdbcRepositoryFactoryBean
か、あるいは AbstractJdbcConfiguration
だろうなという予想はつきますが、JdbcQueryLookupStrategy
のコンストラクタの呼び出し階層を辿っていったところ JdbcRepositoryFactory
で行っていることが分かりました。
public class JdbcRepositoryFactory extends RepositoryFactorySupport { // (略) @Override protected Optional<QueryLookupStrategy> getQueryLookupStrategy(@Nullable QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { return Optional.of(JdbcQueryLookupStrategy.create(key, publisher, entityCallbacks, context, converter, dialect, queryMappingConfiguration, operations, beanFactory, evaluationContextProvider)); } // (略)
このメソッドでインスタンスを作成して返せば、あとは RepositoryFactorySupport
がよしなにやってくれそうな感じがしますね。
となると、作る必要がある主たるクラスは QueryLookupStrategy
と RepositoryQuery
の2つだけで済みそうです。
2. おもむろに実装してみる
それでは、サクッと実装してみましょう。
独自アノテーションの作成
まずはSQLファイル名やクエリを指定するためのアノテーションを作成します。
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface SqlFile { String value() default ""; }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public @interface Query { String value() default ""; }
これらをアプリケーション側で使ってもらう想定です。
RepositoryQuery
の実装
続いて RepositoryQuery
の実装クラスとして StringQuery
を実装します。
public class StringQuery implements RepositoryQuery { private JdbcTemplate jdbcTemplate; private String query; private RecordMapper<?> recordMapper; private QueryMethod queryMethod; public StringQuery(JdbcTemplate jdbcTemplate, String query, RecordMapper<?> recordMapper, QueryMethod queryMethod) { this.jdbcTemplate = jdbcTemplate; this.query = query; this.recordMapper = recordMapper; this.queryMethod = queryMethod; } @Override public Object execute(Object[] parameters) { return jdbcTemplate.query(query, recordMapper); } @Override public QueryMethod getQueryMethod() { return queryMethod; } }
execute
メソッドで JdbcTemplate#query
を実行するためだけのクラスです。
クエリを実行するために必要なものは、SQL文字列と JdbcTemplate
と、結果をエンティティにマッピングするための RecordMapper (RowMapper)
の3つです。これらを外からコンストラクタ経由で受け取るようにします。冒頭にも書きましたが、いったんパラメータを渡さない前提で作るので execute
メソッドの引数である Object[] parameters
は無視します。
また getQueryMethod
の実装のために QueryMethod
のインスタンスを返す必要があるので、これもコンストラクタで受け取るようにしました。
QueryLookupStrategy
の実装
さらに、StringQuery
を生成する QueryLookupStrategy
も実装します。ちなみに QueryLookupStrategy
インターフェイスの定義は次のようになっています。
public interface QueryLookupStrategy { // (略) RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries); }
この1メソッドを実装するだけで良いようです。実際に実装してみましょう。
public class SqlQueryLookupStrategy implements QueryLookupStrategy { private final JdbcTemplate jdbcTemplate; public SqlQueryLookupStrategy(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) { QueryMethod queryMethod = new QueryMethod(method, metadata, factory); RecordMapper<?> recordMapper = new RecordMapper<>(metadata.getDomainType()); Query queryAnnotation = method.getAnnotation(Query.class); if (queryAnnotation != null) { return new StringQuery(jdbcTemplate, queryAnnotation.value(), recordMapper, queryMethod); } SqlFile sqlFileAnnotation = method.getAnnotation(SqlFile.class); if (sqlFileAnnotation != null) { return new StringQuery(jdbcTemplate, queryByFile(sqlFileAnnotation), recordMapper, queryMethod); } throw new RuntimeException("Query cannot be resolved: " + method.getName()); } private String queryByFile(SqlFile sqlFileAnnotation) { String fileName = sqlFileAnnotation.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); } } }
これまで書いたコードの中で一番長いのですが、大した処理はしていません。
実行しようとしたRepositoryのメソッドに Query
アノテーションがついていれば、そのアノテーションの文字列をそのままクエリにして、SqlFile
アノテーションがついていれば、そのファイルを読み込んだものをクエリにする、という処理です。ゆくゆくはこの辺りにテンプレートエンジンを適用する機能も盛り込みたいところですね。
ここで StringQuery
のインスタンスを生成するために JdbcTemplate
のインスタンスが必要となるため、コンストラクタで受け取るようにしています。
さて、ここまでで StringQuery (implements RepositoryQuery)
と SqlQueryLookupStrategy (implements QueryLookupStrategy)
という主要な登場人物の実装クラスを作成したので、あとは SqlRepositoryFactory
で SqlQueryLookupStrategy
のインスタンスを生成するように実装すれば良いはずです。
SqlRepositoryFactory
クラスで RepositoryFactorySupport
インターフェイスの getQueryLookupStrategy
メソッドをオーバーライドして実装します。
@Override protected Optional<QueryLookupStrategy> getQueryLookupStrategy(QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { return Optional.of(new SqlQueryLookupStrategy(jdbcTemplate)); }
コンストラクタで渡したかった JdbcTemplate
は前回の実装で SqlRepositoryFactory
クラスのインスタンス変数として保持するようにしていたので、それをそのまま渡しています。
これで独自Spring Data側の実装はおしまいです。
3. サンプルアプリケーション側も実装する
それでは、サンプルアプリケーション側からこの追加した機能を使えるよう、処理を追加します。
EmpRepositoryにメソッドを追加
まずは EmpRepository
に2つのメソッドを追加します。
@Repository public interface EmpRepository extends CrudRepository<Emp, Long> { @SqlFile("/sql/selectOdd.sql") List<Emp> selectOdd(); @Query("select * from emp where id in (2, 4, 6)") List<Emp> selectEven(); }
@SqlFile
アノテーションをつけてSQLファイル名を指定した selectOdd
メソッドと、@Query
アノテーションをつけてクエリを指定した selectEven
メソッドです。
src/resources/sql/selectOdd.sql
にはこんなクエリを書いています。
select * from emp where id in (1, 3, 5)
ちなみに id % 2
みたいな書き方をしたらSQLでエラーが起きました。剰余はRDBMSごとに方言が違ってて難しいですね。
EmpControllerにメソッドを追加
EmpRepository
に追加したメソッドを呼び出すよう 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"}]
きちんとidが奇数のデータのみ取れていますね!
% curl localhost:8080/even
[{"id":2,"name":"Kikuchi"},{"id":4,"name":"Sayashi"},{"id":6,"name":"Okazaki"}]
こちらはidが偶数のデータのみ取れています!
完全に期待通りの結果が返ってきました。目的を果たすためだけの手抜き実装とは言え、思った以上にあっさりと動いてしまいました。
4. 引数でクエリを指定する
思ったよりも簡単だったため、もう一つやってみます。
ここまでの実装で、SQLファイルやアノテーションでクエリを指定することができるようになりました。この部分で何かしらのテンプレートエンジンを適用するようにすれば、動的にクエリを書き換えることもできるようになるでしょう。
ただ、より複雑なクエリを構築したい場合にはRepositoryの外側でSQLの文字列を構築し、それをRepositoryに渡したいということもあるでしょう。次はそのようなパターンを考えてみます。
どうやって引数でクエリを受け取るか?
クエリを受け取るには、たとえばこんな実装が想定できます。
@Repository public interface EmpRepository extends SqlRepository<Emp, Long> { List<Emp> select(String query, Object... args); }
ただしこのままでは何が引数で、何がパラメータかをフレームワーク側では判断できないため、アノテーションをつけるのが良いかも知れません。
@Repository public interface EmpRepository extends SqlRepository<Emp, Long> { List<Emp> select(@Query String query, @Parameters Object... args); }
こうすれば、何となくできそうな感じがします。ただ、こんなアノテーションを毎回つけることが分かりやすいかというと微妙ですね。
どうしたもんかなと考えていた時に、ふと思いつきました。このようなパターンが固定されそうなメソッドは、ベースにしてるRepository側、つまり DefaultRepository
にメソッドを追加してしまえば良いのではないかと。
その方向で実装を進めてみます。
典型的な処理をBaseRepository側で提供する
これまでは CrudRepository
などを使っていたのですが、リポジトリの共通インターフェイスとなる SqlRepository
を作りました。
public interface SqlRepository<T, ID> extends ListCrudRepository<T,ID>, ListPagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> { }
Spring Data JDBCではこのような集約したインターフェイスは提供されていないのですが、Spring Data JPAでは JpaRepository
インターフェイスとして同じような実装が提供されています。
そして、ここに query
メソッドを追加します。
public interface SqlRepository<T, ID> extends ListCrudRepository<T,ID>, ListPagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> { List<T> query(String query, Object... args); }
また DefaultRepository
も同じインターフェイスを利用して、 query
メソッドの実装を提供します。
public class DefaultRepository<T, ID> implements SqlRepository<T, ID> { // 略 @Override public List<T> query(String query, Object... args) { return jdbcTemplate.query(query, recordMapper, args); }
ただただ JdbcTempalte
に処理を流すだけの簡単なものです。
これだけで、SqlRepository
で query
メソッドが使えるようになります。
サンプルアプリケーション側にも適用する
サンプルアプリケーションの EmpRepository
も同じインターフェイスを指定します。
public interface EmpRepository extends SqlRepository<Emp, Long> {
ただこれだけです。特に実装を追加する必要はありません。
そして、SqlRepository
インターフェイスに追加した query
メソッドを利用するためのエンドポイントを EmpController
に追加します。
@GetMapping("/search/{name}") List<Emp> search(@PathVariable String name) { return empRepository.query("select * from emp where name like '%' || ? || '%'", name); }
中間一致のlike検索をすることにしました。ここでは固定の文字列としてクエリを渡していますが「Repositoryの外側で組み立てた文字列を渡すことができる」というところがポイントですね。
これでアプリケーションを起動して実行してみます。
% curl localhost:8080/search/ka
[{"id":1,"name":"Nakamoto"},{"id":6,"name":"Okazaki"}]
完全に期待通り name
に「ka」という文字列を含む結果が返ってきました。
欲しいメソッドをインターフェイスに定義して、DefaultRepository
で実装すればいくらでも拡張できるということです。
Bootful SQL Tempalteを作成した時は、引数やパラメータをうまく渡せるようなメソッドをたくさん用意していましたが、それを同じような、いわゆるシンタックスシュガー的なメソッドをたくさん作成していけば、Spring Dataでは物足りないなと感じていた部分を補えるということですね。
まとめ
今回の話をまとめます
- メソッドに対応したクエリを自作したい場合
- 独自の共通メソッドを作成したい場合
この2つの方法によって、自作のSpring Dataに機能を追加することができるようになります。前者の方法は主にメソッド名やアノテーションなどを使ったクエリのルールを作成でき、後者の方法は共通のメソッドを作成できます。たとえば後者の方法を用いて、Bootiful SQL Templateの全機能を提供することだってできるでしょう。
ここまでの実装はGitHubに置いています。
さて、次はどうしようかなと考えているときに気づいたのですが、わざわざSpring Dataを自作しなくとも、Spring Data JDBCに対して上に書いた2つの方法を使って拡張することができるのではないでしょうか。ここまでSpring Data JDBCのコードを読んできて、どうすれば拡張ができるのかが大体分かるようになってきました。また逆に、自作Spring Dataの DefaultRepository
を実装するために、延々とSpring Data JDBCから処理をコピーしてくるというのも大変なことです。
そこで次回はいったん自作Spring Dataという方向を止め、Spring Data JDBCを拡張するという方向に進んでみます。