谷本 心 in せろ部屋

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

独自Spring Dataを作ってみる その1 - まずは入口

僕は約束を守る男なので、Spring Data SQLを作り始めたというお話です。

背景

これまでBootiful SQL TemplateというJdbcTemplateの薄いラッパーを(作って)使っていたのですが、INSERT文とかSELECT ALLくらいは自動生成して欲しいよなと思い、Spring Dataを使おうかなと思って調べ始めました。

Spring Dataの中でも最も素直に動いてくれそうな Spring Data JDBCを試したところ、簡単なクエリはSQLを書かずに生成できるし、アノテーションSQL文を渡すこともできるのですが、動的に組み立てたクエリを渡すことができないようでした。

さすがにそこまで割り切った仕様のフレームワークは使いづらく、かと言って他に好みのO/Rマッパーがあるわけでもありません。

「好みのO/Rマッパーがないなら、作れば良いのよ」とはもう数百年くらい言い古された言葉ですが、はい、試しに作ってみることにしました。

作り始めるまでの流れ

まずは作り始めるまでに至った流れや構想などを書いておきます。

構想

INSERT文の自動生成やページングなどに対応したものが欲しいので、Spring Dataのお作法に則るのが良いと思いました。具体的には次のような形になります。

これくらいなら割と簡単にできそうじゃないですか? フラグ?

入口が分からないため、頓挫

さて、いきなりですが、入口が分からない。

Spring Data JDBCのコードを読むと JdbcRepositoryFactoryJdbcRepositoryFactoryBean というクラスでRepositoryのinterfaceに対する実装を提供しているようなのですが、何をどうしたら自前のファクトリクラスが呼ばれるようになるのかが分かりません。spring-data-commons というSpring Dataを作るための共通モジュールがあるものの、これの使い方がよく分かりません。

Spring Data JDBCMETA-INF/spring.factories というファイルに org.springframework.data.repository.core.support.RepositoryFactorySupport=org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory という記述があったので、これか!? と思って真似てみたものの、やっぱり自前のファクトリクラスが呼ばれることはありませんでした。

何も分かりません。入口が分からないので、どうすることもできません。俺の物語はここで終わるのか?

アルゼンチンが優勝したので仕方なく

自分で作ることを諦めた1日後、ワールドカップの決勝戦がありました。これまでワールドカップをほぼ全試合見ているサッカー通であり事情通*1である僕としては、フランスの優勝間違いなしと判断をしたため、こんなツイートをしました。

結果はご存じの通り、サッカーの女神はリオネルメッシに微笑みました。エンバペ、凄かったのに。今後に期待するよ。

そんなわけで僕は時間と約束は守る男ですから(初耳)Spring Data SQLを開発すべく、調査を再開しました。

Spring Dataづくりの最初の第一歩

数時間ほどソースコードを読んだり、自分でコードを書いて無理矢理動かしてみたところ、ようやくSpring Dataの作り方が分かりました。

入口はアノテーション

最初の入口になるのは、アプリケーションの起動クラスに書く @EnableJdbcRepositories のようなアノテーションでした。いやさ、独自アノテーションを作ろうが、そのアノテーションを読む所の方が核心なんだから、アノテーションだけ作っても仕方ないでしょ、と思っていたのですが、そうではありませんでした。

独自アノテーションを作ることで、それをきっかけとして各クラスが呼べるようになるようです。

最低限必要なアノテーションは次の通りです。ここに書いたアノテーションがないと起動時に AnnotationRepositoryConfigurationSource というクラスのチェックで「このフィールドがないよ!」って怒られます。いや怒るくらいだったらアノテーションのベースを用意してくれよと思わなくもないのですが、とりあえず怒られるがまま最低限のアノテーションを作ります。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(SqlRepositoriesRegistrar.class)
public @interface EnableSqlRepositories {
    String[] value() default {};
    String[] basePackages() default {};
    Class<?>[] basePackageClasses() default {};
    ComponentScan.Filter[] includeFilters() default {};
    ComponentScan.Filter[] excludeFilters() default {};
    String repositoryImplementationPostfix() default "Impl";
    String namedQueriesLocation() default "";
    Class<?> repositoryFactoryBeanClass() default SqlRepositoryFactoryBean.class;
}

このアノテーションで2つの自作クラスを指定しています。ひとつが @Import に指定した SqlRepositoriesRegistrar もう一つが repositoryFactoryBeanClass() に指定した SqlRepositoryFactoryBean です。

SqlRepositoriesRegistrarとSqlRepositoryConfigExtension

SqlRepositoriesRegistrar は大した実装ではありません。spring-data-commonsRepositoryBeanDefinitionRegistrarSupport を実装したクラスを作ることで、Repositoryの登録ができるようになるようです。とりあえずSpring Data JDBCを真似て、最低限の実装だけで済ませます。

public class SqlRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport {
    @Override
    protected Class<? extends Annotation> getAnnotation() {
        return EnableSqlRepositories.class;
    }

    @Override
    protected RepositoryConfigurationExtension getExtension() {
        return new SqlRepositoryConfigExtension();
    }
}

何やらカスタマイズするための RepositoryConfigExtension というクラスも必要になるようなので、spring-data-commonsRepositoryConfigurationExtensionSupport を継承して最低限の実装だけ済ませた SqlRepositoryConfigExtension というクラスを作りました。

public class SqlRepositoryConfigExtension extends RepositoryConfigurationExtensionSupport {
    @Override
    protected String getModulePrefix() {
        return "sql";
    }

    @Override
    public String getRepositoryFactoryBeanClassName() {
        return SqlRepositoryFactoryBean.class.getName();
    }
}

諸々の制御が必要になった時にはまた戻ってくるとして、今はいったん最低限の実装だけで突き進みます。

SqlRepositoryFactoryBeanとSqlRepositoryFactory

@EnableSqlRepository で指定していたもう一つのクラスが SqlRepositoryFactoryBean です。このクラスを経由して、独自のファクトリクラスのインスタンスを利用できる形になります。このクラスもSpring Data JDBCの実装を参考に TransactionalRepositoryFactoryBeanSupport の継承と ApplicationEventPublisherAware の実装として作成します。

public class SqlRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> implements ApplicationEventPublisherAware {
    protected SqlRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
        super(repositoryInterface);
    }

    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        return new SqlRepositoryFactory();
    }
}

↑このクラスの doCreateRepositoryFactory で返している SqlRepositoryFactory が核心です。Repositoryのインタフェースに対する実体を返すファクトリクラスになります。とりあえず突き進みたいので、すべて null を返す実装にします。null いいよね null。ぬるぬる。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    private final RelationalMappingContext context;

    @Override
    public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
        return null;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        return null;
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
        return null;
    }
}

サンプルアプリケーションから使ってみる

ここまでで作った(何もしてない)自作Spring Dataを、サンプルアプリケーションで利用してみます。サンプルアプリケーションのdependencyに↑で作った自作Spring Dataのモジュールを入れたうえで、Spring Bootのアプリケーションを作ります。

サンプルアプリケーションを作る

アプリケーションクラスに、@EnableSqlRepositories アノテーションをつけます。

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

続いて、テーブルと1:1のクラスを record で作ります。recode は楽で良いですよね。ビルダーを生成してくれない所は恨んでいますが。

public record Emp (Long id, String name) {
}

さらに、そのクラスを使ったRepositoryインターフェイスを定義します。

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

最後に、そのRepositoryを呼び出すControllerを作成します。もう慣れたもんです。

@RestController
public class EmpController {
    EmpRepository empRepository;

    public EmpController(EmpRepository empRepository) {
        this.empRepository = empRepository;
    }

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

絶対に動くことはなくエラーが出ることはわかりきっていますが、とりあえず起動してみます。

起動時のエラー

起動すると次のようなエラーが発生しました。ぬるぽー!

2022-12-23T00:32:37.874+09:00 ERROR 1285 --- [           main] o.s.boot.SpringApplication               : Application run failed

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'empRepository' defined in ninja.cero.data.sql.app.EmpRepository defined in @EnableSqlRepositories declared on App: Cannot invoke "Object.getClass()" because "target" is null
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1751) ~[spring-beans-6.0.2.jar:6.0.2]
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:599) ~[spring-beans-6.0.2.jar:6.0.2]
(略)
Caused by: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "target" is null
    at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:319) ~[spring-data-commons-3.0.0.jar:3.0.0]
    at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:279) ~[spring-data-commons-3.0.0.jar:3.0.0]
    at org.springframework.data.util.Lazy.getNullable(Lazy.java:229) ~[spring-data-commons-3.0.0.jar:3.0.0]
(略)

そりゃファクトリクラスの実装がぜんぶ return null; ですから、そうなるでしょうね。

ちなみにここで初めてヌルポが出たように書いていますが、実際には他の実装も全部 return null; にしていて、怒られたところだけ実装するというトライ&エラーの繰り返しでここまで進んでいます。エンジニアとして最低な仕事の仕方ですね!

せめてエラーがなくなるところまで実装する

エラーのまま放置しては面白くないので、もう少しだけ実装を追加してせめてエラーが出なくなるところまで進めてみます。

SqlRepositoryFactoryに実装を追加

targetがnullであることを怒られているため、SqlRepositoryFactorygetTargetRepositorygetRepositoryBaseClass に最低限の実装を追加します。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    @Override
    public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
        return null;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        return getTargetRepositoryViaReflection(metadata);
    }

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

getTargetRepository は親クラスに似たような名前のメソッドがあるのでそちらを使いました。また getRepositoryBaseClass では次のような実装のクラスを返しています。

public class DefaultRepository<T, ID> implements CrudRepository<T, ID>, PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    @Override
    public <S extends T> S save(S entity) {
        return null;
    }

    @Override
    public <S extends T> Iterable<S> saveAll(Iterable<S> entities) {
        return null;
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.empty();
    }

    @Override
    public boolean existsById(ID id) {
        return false;
    }

    @Override
    public Iterable<T> findAll() {
        return null;
    }
    // (略)
}

Spring Data JDBCSimpleJdbcRepository というクラスを参考に、3つのinterfaceを実装したクラスを作成しました。ただし全て空実装にしています。ここをしっかり実装すれば、基本的な機能は提供できるようになるのでしょうかね。

改めて起動してみる

ここまで実装できたので、またサンプルアプリケーションを起動してみます。

(略)
2022-12-23T01:04:24.576+09:00  INFO 4005 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-12-23T01:04:24.582+09:00  INFO 4005 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-12-23T01:04:24.582+09:00  INFO 4005 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.1]
2022-12-23T01:04:24.631+09:00  INFO 4005 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-12-23T01:04:24.632+09:00  INFO 4005 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 732 ms
2022-12-23T01:04:24.932+09:00  INFO 4005 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-12-23T01:04:24.939+09:00  INFO 4005 --- [           main] ninja.cero.data.sql.App                  : Started App in 1.323 seconds (process running for 1.723)

今度はエラーが出ることなく、無事に起動まで完了しました!

せっかくなので試しに動かしてみましょう。

% curl localhost:8080 -i

HTTP/1.1 200 
Content-Length: 0
Date: Thu, 22 Dec 2022 16:14:34 GMT

特に何のエラーがでることもなく、レスポンスが返ってきました!!

上で作成した DefaultRepositoryfindAll が呼ばれて null が返ってきた結果なのでしょうね。

扉は開きました

ここまでの話をまとますと、Spring Dataを自作する入口は次のようになります。

  • アプリケーションの起動クラスにつける @EnableHogeHoge のようなアノテーションを作るところが入口
  • アノテーション@ImportRepositoryBeanDefinitionRegistrarSupport を継承したクラスを指定する
  • アノテーションrepositoryFactoryBeanClassTransactionalRepositoryFactoryBeanSupport を継承した RepositoryFactoryBean クラスを指定する
  • RepositoryFactoryBean クラスで RepositoryFactory を作って、Repository に対する実装クラスを返す

これで独自Spring Dataの扉を開くことができました。

しかし、セロさんの冒険はまだ始まったばかりです。

今後のセロ先生の活躍にご期待ください!

*1:一般的にはニワカと分類されるやつです