谷本 心 in せろ部屋

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

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

(in GitHub)

これはアプリケーション側の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);
        }
    }
}

(in GitHub)

中核となる部分は execute メソッドです。

@Override
public Object execute(Object[] parameters) {
    return jdbcOperations.query(query.get(), rowMapper);
}

(in GitHub)

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

(in GitHub)

動作検証のためだけなら特に遅延ローディングする必要もなかったのですが、カッコイイので真似してみた感じです。

QueryLookupStrategy の実装

さらに上で作った SqlFileQuery (implements RepositoryQuery)インスタンスを生成するための QueryLookupStrategy を作成します。

QueryLookupStrategyRepositoryQuery を返す 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);
    }
}

(in GitHub)

メインとなる部分は、SqlFileQueryインスタンスを生成して返す部分ですね。

return new SqlFileQuery(annotation, jdbcOperations, rowMapper, queryMethod);

(in GitHub)

このために必要なインスタンスをコンストラクタで受け取って、フィールドとして保持しています。

ただここで、@SqlFile アノテーションのついたメソッドを処理する分には問題ありませんが、それ以外のアノテーションがついている場合などにはSpring Data JDBCの標準機能で処理すべきです。そのために、コンストラクタで QueryLookupStrategy originalQueryLookupStrategy というSpring Data JDBCが作成する QueryLookupStrategy を受け取ってフィールドとして保持しておいて、@SqlFile アノテーションがついていないメソッドの場合場合には、その originalQueryLookupStrategy を使って RepositoryQuery をルックアップするようにしました。

独自拡張あるあるな実装ですね。

JdbcExtRepositoryFactory の実装修正

さらにこの JdbcExtQueryLookupStrategy を使えるよう、前回作成した JdbcExtRepositoryFactorygetQueryLookupStrategy メソッドを追加します。具体的には次のメソッドを追加しました。

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

(in GitHub)

ここで先に 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();
}

(in GitHub)

既存のアノテーションが動くことも確認するためにSpring Data JDBC@Query アノテーションを利用したメソッドも追加しています。

また /src/main/resources/sql/selectOdd.sql ファイルの中には次のようなクエリを書きました。

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

(in GitHub)

selectOddid が奇数のレコードを返す、selectEvenid が偶数のレコードを返すというクエリを実行します。

Controllerにメソッド追加

続いて、コントローラー側にも処理を追加します。

public class 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"}]
% curl localhost:8080/even
[{"id":2,"name":"Kikuchi"},{"id":4,"name":"Sayashi"},{"id":6,"name":"Okazaki"}]

/odd /even とも期待通りの結果となりました。

前回のエントリーでカスタマイズできる仕組みまで作っており、今回はそれに乗っかって機能を追加しただけですので、簡単でしたね。

まとめ

今回作ったものはGitHubに置いています。

github.com

これまで5回に分けてSpring Dataを自作する方法とSpring Data JDBCを拡張する方法を説明してきました。最初は入口すら分からなくて難儀しましたが、構造や挙動が分かってしまえば後はサクッと実装してしまえるくらいの難易度でした。

実際にやってみた感想として、RDBMSにアクセスするためのSpring Dataを自作するのはコストとメリットが合わないので、Spring Data JDBCを拡張して好きなようにアノテーションやメソッドを追加する方が良さそうだなと思いました。Spring Dataの自作を考えるのは、RDBMS以外の新しいNoSQLにSpring Dataでアクセスしたいと思った時くらいでしょう。

そんなわけで長文を書き続けてきましたが、今回の取り組みはいったんここで区切りをつけます。これからしばらくは、どんな風にSpring Dataを拡張すると良いか考えて実装したいと思います。また形になってきたらブログで報告します。

それでは、Enjoy Spring Data!