谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

独自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に置いといたよ(リンク
  • RepositoryQueryQueryLookupStrategy を作成すれば、メソッドに対するクエリを自分で作れるようになるよ
  • 共通の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 "";
    // (略)
}

(in GitHub)

IntelliJでこのクラスのFind Usageすると、JdbcQueryMethod というクラスで利用されていることが分かりました。

public boolean hasAnnotatedQueryName() {
    return lookupQueryAnnotation() //
            .map(Query::name) //
            .map(StringUtils::hasText).orElse(false);
}

(in GitHub)

さらにこの呼び出し元をたどると JdbcQueryLookupStrategy (implements QueryLookupStrategy) というクラスの resolveQuery メソッドから呼ばれていることが分かりました。

JdbcQueryMethod#hasAnnotatedQueryNameの呼び出し階層

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));
        }

(in GitHub)

何やら見た感じ、アノテーションの値を利用して StringBasedJdbcQuery というクエリを作っている感じですね。

また resolveQuery メソッドの第一引数として Method が渡されているあたり、Repositoryに追加したメソッドが呼ばれたときにこのメソッドが呼ばれて、このメソッドに対応するクエリを返すような処理であることが何となく分かります。

さらに、この resolveQuery メソッドは spring-data-commonsQueryExecutorMethodInterceptor というInterceptorから呼ばれています。

QueryLookupStrategy#resolveQueryの呼び出し階層

   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);
        }
    }

(in GitHub)

Spring Dataのように interface だけ定義して動くようなフレームワークでは、Interceptor を利用して処理を行わせることは王道中の王道ですね。

この一連の流れを見る限り、Repositoryに実装したメソッドの実行時に発行すべきクエリを決めるために QueryLookupStrategyresolveQuery メソッドが呼ばれると考えて差し支えなさそうです。

RepositoryQueryって何ぞや?

QueryLookupStrategyresolveQuery の処理をよく見ていきます。

まず戻り値の 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();
}

(in GitHub)

えらくシンプルですね。execute が実際にクエリを実行する処理であろうことが推測できます。

StringBasedJdbcQueryを読、、、まず

resolveQuery メソッドでは RepositoryQuery の実装として StringBasedJdbcQueryインスタンスを生成して返していました。

StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper,
        getConverter(), evaluationContextProvider);

(in GitHub)

名前からして明らかに文字列を使ったクエリという感じです。

これは間違いなく参考になるだろうと思って少し実装を眺めたところ、それなりに色々な処理をしていたので、よくわからんなと思って閉じました。そういうこともありますよ、ハハッ。

とりあえずクエリを発行したいだけであれば RepositoryQuery の実装はさほど難しくなさそうなので、後ほど自力で実装したいと思います。

QueryLookupStrategyのインスタンスはどこで作られるのか?

一方で JdbcQueryLookupStrategy (implements QueryLookupStrategy)インスタンスはどこで作られているのでしょうか。

これまでの経験で、なんとなく JdbcRepositoryFactoryJdbcRepositoryFactoryBean か、あるいは AbstractJdbcConfiguration だろうなという予想はつきますが、JdbcQueryLookupStrategy のコンストラクタの呼び出し階層を辿っていったところ JdbcRepositoryFactory で行っていることが分かりました。

JdbcQueryLookupStrategyのコンストラクタの呼び出し階層

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));
    }
    // (略)

(in GitHub)

このメソッドでインスタンスを作成して返せば、あとは RepositoryFactorySupport がよしなにやってくれそうな感じがしますね。

となると、作る必要がある主たるクラスは QueryLookupStrategyRepositoryQuery の2つだけで済みそうです。

2. おもむろに実装してみる

それでは、サクッと実装してみましょう。

独自アノテーションの作成

まずはSQLファイル名やクエリを指定するためのアノテーションを作成します。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface SqlFile {
    String value() default "";
}

(in GitHub)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface Query {
    String value() default "";
}

(in GitHub)

これらをアプリケーション側で使ってもらう想定です。

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;
    }
}

(in GitHub)

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);
}

(in GitHub)

この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);
        }
    }
}

(in GitHub)

これまで書いたコードの中で一番長いのですが、大した処理はしていません。

実行しようとしたRepositoryのメソッドに Query アノテーションがついていれば、そのアノテーションの文字列をそのままクエリにして、SqlFile アノテーションがついていれば、そのファイルを読み込んだものをクエリにする、という処理です。ゆくゆくはこの辺りにテンプレートエンジンを適用する機能も盛り込みたいところですね。

ここで StringQueryインスタンスを生成するために JdbcTemplateインスタンスが必要となるため、コンストラクタで受け取るようにしています。

さて、ここまでで StringQuery (implements RepositoryQuery)SqlQueryLookupStrategy (implements QueryLookupStrategy) という主要な登場人物の実装クラスを作成したので、あとは SqlRepositoryFactorySqlQueryLookupStrategyインスタンスを生成するように実装すれば良いはずです。

SqlRepositoryFactory クラスで RepositoryFactorySupport インターフェイスgetQueryLookupStrategy メソッドをオーバーライドして実装します。

    @Override
    protected Optional<QueryLookupStrategy> getQueryLookupStrategy(QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) {
        return Optional.of(new SqlQueryLookupStrategy(jdbcTemplate));
    }

(in GitHub)

コンストラクタで渡したかった 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();
}

(in GitHub)

@SqlFile アノテーションをつけてSQLファイル名を指定した selectOdd メソッドと、@Query アノテーションをつけてクエリを指定した selectEven メソッドです。

src/resources/sql/selectOdd.sql にはこんなクエリを書いています。

select * from emp where id in (1, 3, 5)

(in GitHub)

ちなみに id % 2 みたいな書き方をしたらSQLでエラーが起きました。剰余はRDBMSごとに方言が違ってて難しいですね。

EmpControllerにメソッドを追加

EmpRepository に追加したメソッドを呼び出すよう EmpController にもメソッドを追加します。

    @GetMapping("/odd")
    List<Emp> odd() {
        return empRepository.selectOdd();
    }

    @GetMapping("/even")
    List<Emp> even() {
        return empRepository.selectEven();
    }

(in GitHub)

/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> {
}

(in GitHub)

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);
}

(in GitHub)

また 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);
    }

(in GitHub)

ただただ JdbcTempalte に処理を流すだけの簡単なものです。

これだけで、SqlRepositoryquery メソッドが使えるようになります。

サンプルアプリケーション側にも適用する

サンプルアプリケーションの EmpRepository も同じインターフェイスを指定します。

public interface EmpRepository extends SqlRepository<Emp, Long> {

(in GitHub)

ただこれだけです。特に実装を追加する必要はありません。

そして、SqlRepository インターフェイスに追加した query メソッドを利用するためのエンドポイントを EmpController に追加します。

@GetMapping("/search/{name}")
List<Emp> search(@PathVariable String name) {
    return empRepository.query("select * from emp where name like '%' || ? || '%'", name);
}

(in GitHub)

中間一致のlike検索をすることにしました。ここでは固定の文字列としてクエリを渡していますが「Repositoryの外側で組み立てた文字列を渡すことができる」というところがポイントですね。

これでアプリケーションを起動して実行してみます。

% curl localhost:8080/search/ka
[{"id":1,"name":"Nakamoto"},{"id":6,"name":"Okazaki"}]

完全に期待通り name に「ka」という文字列を含む結果が返ってきました。

欲しいメソッドをインターフェイスに定義して、DefaultRepository で実装すればいくらでも拡張できるということです。

Bootful SQL Tempalteを作成した時は、引数やパラメータをうまく渡せるようなメソッドをたくさん用意していましたが、それを同じような、いわゆるシンタックスシュガー的なメソッドをたくさん作成していけば、Spring Dataでは物足りないなと感じていた部分を補えるということですね。

まとめ

今回の話をまとめます

  • メソッドに対応したクエリを自作したい場合
    1. RepositoryQuery インターフェイスの実装として、任意のクエリを実行するようにする
    2. QueryLookupStrategy インターフェイスを実装して、メソッドに対応した 1. の RepositoryQuery の実装を返す
    3. RepositoryFactory (extends RepositoryFactorySupport)getQueryLookupStrategy にて 2. で作成した QueryLookupStrategy の実装を返す
  • 独自の共通メソッドを作成したい場合
    1. CrudRepository などを継承した共通の Repository インターフェイスを定義する
      1. で作成したインターフェイスにメソッドを追加する
    2. デフォルトRepositoryクラスを 1. で作成したインターフェイスの実装として作成し、追加したメソッドを実装する
    3. アプリケーション側のRepositoryも 1. で作成したインターフェイスを継承して作る

この2つの方法によって、自作のSpring Dataに機能を追加することができるようになります。前者の方法は主にメソッド名やアノテーションなどを使ったクエリのルールを作成でき、後者の方法は共通のメソッドを作成できます。たとえば後者の方法を用いて、Bootiful SQL Templateの全機能を提供することだってできるでしょう。

ここまでの実装はGitHubに置いています。

github.com

さて、次はどうしようかなと考えているときに気づいたのですが、わざわざSpring Dataを自作しなくとも、Spring Data JDBCに対して上に書いた2つの方法を使って拡張することができるのではないでしょうか。ここまでSpring Data JDBCのコードを読んできて、どうすれば拡張ができるのかが大体分かるようになってきました。また逆に、自作Spring Dataの DefaultRepository を実装するために、延々とSpring Data JDBCから処理をコピーしてくるというのも大変なことです。

そこで次回はいったん自作Spring Dataという方向を止め、Spring Data JDBCを拡張するという方向に進んでみます。