谷本 心 in せろ部屋

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

Spring Data JDBCを拡張してみる その1 - クエリを受け取れるメソッドを増やす

ここまでSpring Dataの自作を行ってきましたが、Spring DataやSpring Data JDBCの知識がついてきたおかげで、Spring Data JDBCそのものを拡張することもできそうだなと分かってきました。

元々あるものは再実装せずにそのまま使ったほうが良いに決まってるので、Spring Data JDBCに乗っかった上で好きな機能を追加してみることにします。

TL;DR

  • いままでのエントリーをきちんと読んでなくても、今回から話が変わるのできっと読めると思うよ
  • Spring Data JDBCの拡張は難しくなかったよ
  • 作ったものはGitHubに置いてあるよ(リンク
  • 作る様子を動画でもライブ配信したよ

やりたいこと

先にゴールを明確にしておきます。

  • Spring Data JDBCに機能追加して query(String query, Object... args) メソッドを追加できること
  • Spring Data JDBCを使ったうえで @SqlFile アノテーションで指定したSQLファイルを読み込んで実行できること

今回は1つ目を実現するところまで説明します。

1. Spring Data JDBCの拡張ポイント

まずはSpring Data JDBCをどうやって拡張できるかという方向性の確認です。

入口の EnableJdbcRepositories に拡張ポイントがある

Spring Data JDBC@EnableJdbcRepositories アノテーションに、次のような定義があります。

public @interface EnableJdbcRepositories {
    Class<?> repositoryFactoryBeanClass() default JdbcRepositoryFactoryBean.class;
}

(in GitHub)

RepositoryFactoryを提供するクラスを指定できるもので、デフォルトでは JdbcRepositoryFactoryBean を利用するようになっています。RepositoryFactoryはRepositoryを作成する入口となるクラスですから、これを自作のものに置き換えればいくらでもカスタマイズできることが分かります。

RepositoryBaseを置き換えるだけならもっと簡単

ちなみに @EnableJdbcRepositories アノテーションにはもう一つカスタマイズしやすいポイントがあります。

public @interface EnableJdbcRepositories {
    Class<?> repositoryBaseClass() default DefaultRepositoryBaseClass.class;
}

(in GitHub)

RepositoryBaseのクラスです。Spring Data JDBCでは、RepositoryBaseは SimpleJdbcRepository が利用されますが、このアノテーションで指定することで、それを別のものに置き換えることが可能だということです。SimpleJdbcRepository を継承してメソッドを追加したものを提供することもできるということですね。

ただこの repositoryBaseClass だけを指定してもRepository自身のコンストラクタの引数を変えることはできませんし、前回実装した QueryLookupResolver を使った独自アノテーションを読み取るような処理もできず、カスタマイズできる範疇に限界があります。なので今回はこの部分は使わず、上で説明した repositoryFactoryBeanClass を使うことにします。

2. Spring Data JDBCの独自拡張を実装する

それでは実際にライブラリを作っていきましょう。

Spring Data JDBCに依存したモジュールを作成する

これまではSpring Dataを自作するために spring-data-commons というSpring Dataの共通ライブラリと spring-data-relational というRDBMS用の共通ライブラリと spring-jdbc というJDBCアクセスライブラリの3つに依存したモジュールを作成していました。

今回はそれとは別に spring-data-jdbc だけに依存したモジュールを作成します。pom.xml に入れる依存はこれだけです。

<groupId>ninja.cero.data.sql</groupId>
<artifactId>data-jdbc-ext</artifactId>

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

(in GitHub)

共通Repositoryインターフェイスを作る

まずは今回作るSpring Data JDBC拡張のRepositoryの共通インターフェイスとして JdbcExtRepository を作成します。

public interface JdbcExtRepository <T, ID> extends CrudRepository<T,ID>, PagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> {
    List<T> query(String query, Object... args);
}

(in GitHub)

前回のエントリーでも少し振れましたが、Spring Data JPAではこのように基底リポジトリインターフェイスを集約した JpaRepository というインターフェイスを提供しています。Spring Data JDBCではこのようなインターフェイスは提供していませんが、あった方が便利だし拡張もしやすいので、まずはこれを作りました。

そして独自拡張となる query メソッドを定義して、Spring Data JDBCの基本機能に加えて query メソッドを使えるように定義しておきます。

Repositoryの実装クラスを作る

続いて、この共通インターフェイスの実装クラスを作成します。実装クラスは spring-data-jdbc が提供する SimpleJdbcRepository を継承したうえで、先ほど作成した JdbcExtRepository の実装として提供します。

public class JdbcExtDefaultRepository<T, ID> extends SimpleJdbcRepository<T, ID> implements JdbcExtRepository<T, ID> {
    public List<T> query(String query, Object... args) {
        return null;
    }
}

(in GitHub)

この query メソッドの中身をきちんと実装すれば、アプリケーション側のRepositoryを作成する際に JdbcExtRepository インターフェイスを継承するだけで、この query メソッドが使えるようになるという仕組みです。早速、実装してみましょう。

public class JdbcExtDefaultRepository<T, ID> extends SimpleJdbcRepository<T, ID> implements JdbcExtRepository<T, ID> {
    JdbcOperations jdbcOperations;

    EntityRowMapper<T> entityRowMapper;

    public JdbcExtDefaultRepository(JdbcOperations jdbcOperations, JdbcAggregateOperations entityOperations, RelationalPersistentEntity<T> entity, JdbcConverter converter) {
        super(entityOperations, entity, converter);
        this.jdbcOperations = jdbcOperations;
        this.entityRowMapper = new EntityRowMapper<>(entity, converter);
    }

    @Override
    public List<T> query(String query, Object... args) {
        return jdbcOperations.query(query, entityRowMapper, args);
    }
}

(in GitHub)

3分間クッキングばりに一気にできあがりました。中核となる部分は query メソッドの実装ですね。

return jdbcOperations.query(query, entityRowMapper, args);

この処理を実行するために必要となるインスタンスたちをコンストラクタで受け取るようにします。具体的には次のものになります。

  • JdbcOperationsquery メソッドを呼び出すための JdbcOperations
  • EntityRowMapper を作るための RelationalPersistentEntityJdbcConverter

JdbcOperations は実際には JdbcTemplate なのですが、Spring Data JDBCでは JdbcTemplateNamedParameterJdbcTemplate もいずれもインターフェイスである JdbcOprationsNamedParameterOperations のまま扱っているため、それに合わせるようにしました。

また今回の継承元にした SimpleJdbcRepository のコンストラクタ引数は JdbcAggregateOperations RelationalPersistentEntity JdbcConverter の3つでしたが、それに加えて JdbcOperations が必要となるため、コンストラクタに追加した形です。

ちなみに前回までの独自実装では RecordMapper という自作の RowMapper を利用していましたが、ここでは spring-data-jdbc が提供する EntityRowMapper を利用するようにしました。こういうクラスを利用できるのも、独自Spring Dataを作らずに、Spring Data JDBCの拡張として作るメリットの一つですね。

どうあれこれでベースとなるRepositoryクラスはできあがりました。

Repositoryを提供するRepositoryFactoryを作る

続いて、いま作成した JdbcExtDefaultRepositoryインスタンスを生成するためのFactoryクラスを作成します。これも spring-data-jdbcJdbcRepositoryFactory クラスを継承して作成します。

public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory {
    @Override
    protected Object getTargetRepository(RepositoryInformation repositoryInformation) {
        return null;
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata repositoryMetadata) {
        return JdbcExtDefaultRepository.class;
    }
}

(in GitHub)

まず一つ目のポイントが getRepositoryBaseClass でいま作成した JdbcExtDefaultRepository.class を返すことです。継承元である JdbcRepositoryFactory ではここで SimpleJdbcRepository.class を返しており、要するにRepositoryの実体クラスを指定するわけです。この getRepositoryBaseClass を書き換えることでその実体クラスを変更できるということです。

ちなみに冒頭の方で @EnableJdbcRepositories アノテーションrepositoryBaseClass を指定すれば基底クラスを変更できると書きましたが、そちらで指定されていた場合は、ここの getRepositoryBaseClass で返すクラスよりも優先して使われるようです。

RepositoryFactoryの getTargetRepository を実装する

続いて getTargetRepository を実装しましょう。

@Override
protected Object getTargetRepository(RepositoryInformation repositoryInformation) {
    JdbcAggregateTemplate template = new JdbcAggregateTemplate(publisher, context, converter, accessStrategy);

    if (entityCallbacks != null) {
        template.setEntityCallbacks(entityCallbacks);
    }

    RelationalPersistentEntity<?> persistentEntity = context
            .getRequiredPersistentEntity(repositoryInformation.getDomainType());

    return getTargetRepositoryViaReflection(repositoryInformation, jdbcOperations, template, persistentEntity,
            converter);
}

(in GitHub)

ここは継承元の JdbcRepositoryFactory の実装とほぼ同じなのですが、最後の getTargetRepositoryViaReflection のところで引数に jdbcOperations を増やしています。ここで指定することで JdbcExtDefaultRepository のコンストラクタに JdbcOperationsインスタンスを渡すことができるのです。

RepositoryFactoryのコンストラクタ群を実装する

ちなみにここまでで説明していませんでしたが JdbcExtRepositoryFactory のフィールドやコンストラクタは次のように実装しています。

public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory {
    private final RelationalMappingContext context;
    private final JdbcConverter converter;
    private final ApplicationEventPublisher publisher;
    private final DataAccessStrategy accessStrategy;

    private final NamedParameterJdbcOperations operations;
    private EntityCallbacks entityCallbacks;

    public JdbcExtRepositoryFactory(DataAccessStrategy dataAccessStrategy, RelationalMappingContext context, JdbcConverter converter, Dialect dialect, ApplicationEventPublisher publisher, NamedParameterJdbcOperations operations) {
        super(dataAccessStrategy, context, converter, dialect, publisher, operations);

        this.publisher = publisher;
        this.context = context;
        this.converter = converter;
        this.accessStrategy = dataAccessStrategy;
        this.operations = operations;
    }
}

(in GitHub)

継承元である JdbcRepositoryFactory の各フィールドが protected になっていればそれをそのまま使いたかったんですが、残念ながらすべて private であるために、このクラスでもフィールドとして保持するようにしています。しかも JdbcRepositoryFactory はイミュータブルでも何でもなく、後からフィールドが書き換えられる可能性があるので、その辺りの対応のためにセッターを書いたりする必要もありました。

@Override
public void setEntityCallbacks(EntityCallbacks entityCallbacks) {
    this.entityCallbacks = entityCallbacks;
    super.setEntityCallbacks(entityCallbacks);
}

(in GitHub)

もうちょいSpring Data JDBC自身が拡張しやすい形になってくれていれば嬉しかったのですけどね。

どうあれこれで、Factoryも作成できました。

RepositoryFactoryを提供するRepositoryFactoryBeanを作る

続いて、いま作成した JdbcExtRepositoryFactoryインスタンスを生成するためのFactoryBeanクラスを作成します。これは spring-data-jdbcJdbcRepositoryFactoryBean クラスを参考にしながら・・・全コピペで作成しました。

コピペした理由は後で説明するとして、コピペしたうえで修正した部分は次の箇所のみです。

public class JdbcExtRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> implements ApplicationEventPublisherAware {
    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        JdbcRepositoryFactory jdbcRepositoryFactory = new JdbcExtRepositoryFactory(dataAccessStrategy,
                mappingContext, converter, dialect, publisher, operations);
        jdbcRepositoryFactory.setQueryMappingConfiguration(queryMappingConfiguration);
        jdbcRepositoryFactory.setEntityCallbacks(entityCallbacks);
        jdbcRepositoryFactory.setBeanFactory(beanFactory);

        return jdbcRepositoryFactory;
    }
}

(in GitHub)

具体的には new JdbcRepositoryFactorynew JdbcExtRepositoryFactory にしただけです。

たったそれだけのためにクラスの全コードをコピペしなければならなかった理由は、上で説明した JdbcRepositoryFactory と同じで、すべてのフィールドが private かつミュータブルなので、いつどのように値が書き換えられるか分からないためです。@EnableJdbcRepositories アノテーションでカスタマイズ可能な部分なんだから、もうちょいカスタマイズしやすい形にしてくれよという気持ちになります。

さすがにコピペしてしまうとメンテナンス性に疑問もありますから、リフレクションでprivateフィールドにアクセスしてしまった方が良いのかな、いやでもそんなことするとGraalVMでAOTコンパイルする時にまた面倒なことになるよな、なんて過剰に将来性を考えたりしつつ、とりあえずは全コピペする方向にしました。

全フィールドをprotectedにするとか、FactoryBeanだけでなくFactoryをカスタマイズ可能にする、みたいなことをSpring Data JDBCの開発者にお願いしたら通るもんなんですかね。もうちょっと真面目に開発・メンテナンスするつもりになったら、issueでも立てて相談してみます。

どうあれほぼほぼコピペですが、JdbcExtRepositoryFactoryBean まで作成できました。これで作るものは完成です。

3. サンプルアプリケーションから動かしてみる

それでは、ここまでで作った箇所をサンプルアプリケーションから利用してみます。

pom.xmlの変更

まずはpom.xmlで今回作成した data-jdbc-ext モジュールに依存するようにします。

<dependency>
    <groupId>ninja.cero.data.sql</groupId>
    <artifactId>data-jdbc-ext</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

(in GitHub)

アプリケーション起動クラスの変更

アプリケーションの起動クラスにSpring Data JDBC標準の @EnableJdbcRepositories を付けたうえで今回作成した JdbcExtRepositoryFactoryBean を指定します。

@SpringBootApplication
@EnableJdbcRepositories(repositoryFactoryBeanClass = JdbcExtRepositoryFactoryBean.class)
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

(in GitHub)

これでSpring Data JDBCの元々のFactoryBeanである JdbcRepositoryFactoryBean の代わりに自作の JdbcExtRepositoryFactoryBean が使われるようになります。

Repositoryクラスの変更

リポジトリではインターフェイスに今回作成した JdbcExtRepository を指定します。

@Repository
public interface EmpRepository extends JdbcExtRepository<Emp, Long> {
}

(in GitHub)

これでSpring Dataが提供するメソッド群に加えて JdbcExtRepository が提供する query メソッドが使えるようになります。

Controllerクラスの変更

Controllerでは3つのエンドポイントを作りました。Spring Dataが標準で提供する findAllfindById を利用するものと、JdbcExtRepository が提供する query を利用するものです。

@RestController
public class EmpController {
    EmpRepository empRepository;

    @GetMapping("/")
    Iterable<Emp> findAll() {
        return empRepository.findAll();
    }

    @GetMapping("/{id}")
    Optional<Emp> findById(@PathVariable Long id) {
        return empRepository.findById(id);
    }

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

(in GitHub)

この3つのエンドポイントがすべて機能すれば、Spring Data JDBCの標準機能と、今回追加した独自拡張の両方がきちんと動いているということになります。

起動して試してみる

いざ、起動して試してみましょう!

% curl localhost:8080/
[{"id":1,"name":"Nakamoto"},{"id":2,"name":"Kikuchi"},{"id":3,"name":"Mizuno"},{"id":4,"name":"Sayashi"},{"id":5,"name":"Fujihira"},{"id":6,"name":"Okazaki"}]
% curl localhost:8080/2
{"id":2,"name":"Kikuchi"}
% curl localhost:8080/search/ka
[{"id":1,"name":"Nakamoto"},{"id":6,"name":"Okazaki"}]

全件検索、IDによる検索、そして「ka」という文字列を含んだ検索のすべてが正常に動作しました!!🎊🎊🎊🎉🎉🎉🥳🥳🥳🎉🎉🎉🎊🎊🎊

そりゃまぁ動くだろうと思って作ってはいますが、実際に動いてみると嬉しいもんですね。

まとめ

今回作ったものを振り返ると、次のようになります。

たったこれだけですので、いったん分かってしまえば独自Spring Data JDBC拡張するのはさほど難しくないと言えると思います。

今回作ったコードはすべてGitHubに置いています。

github.com

また今回は実装している様子をYoutubeライブ配信しました。

www.youtube.com

配信の動画を後から見て楽しいものだとは思わないのですが、僕がどんなことを考えながら実装しているかが分かると思いますし、一緒にコードを読みながら勉強してもらっても良いと思いますので、今後もちょくちょくライブ配信してみようかなと思います。

そんなわけで、好評価・チャンネル登録、よろしくお願いします!(お願いしません)