独自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のコードを読むと JdbcRepositoryFactory
や JdbcRepositoryFactoryBean
というクラスでRepositoryのinterfaceに対する実装を提供しているようなのですが、何をどうしたら自前のファクトリクラスが呼ばれるようになるのかが分かりません。spring-data-commons
というSpring Dataを作るための共通モジュールがあるものの、これの使い方がよく分かりません。
Spring Data JDBCの META-INF/spring.factories
というファイルに org.springframework.data.repository.core.support.RepositoryFactorySupport=org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory
という記述があったので、これか!? と思って真似てみたものの、やっぱり自前のファクトリクラスが呼ばれることはありませんでした。
何も分かりません。入口が分からないので、どうすることもできません。俺の物語はここで終わるのか?
アルゼンチンが優勝したので仕方なく
自分で作ることを諦めた1日後、ワールドカップの決勝戦がありました。これまでワールドカップをほぼ全試合見ているサッカー通であり事情通*1である僕としては、フランスの優勝間違いなしと判断をしたため、こんなツイートをしました。
アルゼンチン勝ったらSpring Data SQLを作ってみるわwww
— Shin Tanimoto / CERO-METAL (@cero_t) 2022年12月18日
みたいなツイートすればいっすか。
結果はご存じの通り、サッカーの女神はリオネルメッシに微笑みました。エンバペ、凄かったのに。今後に期待するよ。
そんなわけで僕は時間と約束は守る男ですから(初耳)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-commons
の RepositoryBeanDefinitionRegistrarSupport
を実装したクラスを作ることで、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-commons
の RepositoryConfigurationExtensionSupport
を継承して最低限の実装だけ済ませた 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であることを怒られているため、SqlRepositoryFactory
の getTargetRepository
と getRepositoryBaseClass
に最低限の実装を追加します。
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 JDBCの SimpleJdbcRepository
というクラスを参考に、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
特に何のエラーがでることもなく、レスポンスが返ってきました!!
上で作成した DefaultRepository
の findAll
が呼ばれて null
が返ってきた結果なのでしょうね。
扉は開きました
ここまでの話をまとますと、Spring Dataを自作する入口は次のようになります。
- アプリケーションの起動クラスにつける
@EnableHogeHoge
のようなアノテーションを作るところが入口 - アノテーションの
@Import
にRepositoryBeanDefinitionRegistrarSupport
を継承したクラスを指定する - アノテーションの
repositoryFactoryBeanClass
でTransactionalRepositoryFactoryBeanSupport
を継承したRepositoryFactoryBean
クラスを指定する RepositoryFactoryBean
クラスでRepositoryFactory
を作って、Repository
に対する実装クラスを返す
これで独自Spring Dataの扉を開くことができました。
しかし、セロさんの冒険はまだ始まったばかりです。
今後のセロ先生の活躍にご期待ください!
*1:一般的にはニワカと分類されるやつです