markdown
はじめに
Maven Centralに公開されている「〇〇-util-1.0.0.jar」のように、依存を一切持たせずに作られたライブラリの中に、不意に具象クラスが眠っていることはありませんか?
new FooServiceImpl() でハードコーディングされてしまっていると、単体テストでモックに差し替えたり、本番で別実装を使い分けたりするのが面倒です。
この記事では、そんな「依存のないJAR」に紛れ込んだ具象クラスを、SpringのDIコンテナに登録して@Autowiredで注入できるようにする方法を解説します。Springを使わない環境でもServiceLoaderで同じことを実現する裏技も併せて紹介します。
前提知識
- Java 11以上、Spring Boot 3系の基本文法
spring.factoriesやServiceLoaderの存在を聞いたことがある- JARを
mvn installしてローカルリポジトリに登録できる
なぜ「依存なしJAR」の具象クラスをDIしたいのか
「依存を入れたくない」という設計思想のもとに作られたライブラリは、インターフェースだけを公開して実装クラスをinternalパッケージに隠すことが多いのですが、中には「一応実装も同梱しておくよ」というスタンスで、publicな具象クラスが露出しているケースがあります。
これをそのまま使ってしまうと、ビジネスコード側がnewしてしまい、テスト時に Mockito で置き換えるために@Spyや PowerMock を使わざるを得なくなります。
DIコンテナに登録しておけば、本番は本物、テストはスタブという切り替えが@Primaryや@Profileだけで済み、テストが高速で安全になります。
SpringなしでもServiceLoaderで「軽量DI」を実現する
Springを使っていないプロジェクトでも、JDKに標準で搭載されているjava.util.ServiceLoaderを使えば、依存関係なしに「インターフェース⇔実装」のマッピングを外部化できます。
以下は、ライブラリ側にServiceLoader対応のプロバイダー設定を追加し、アプリケーション側で好きな実装を差し替える手順です。
ステップ1:ライブラリ側でプロバイダー定義ファイルを追加
- ライブラリプロジェクトの
src/main/resources/META-INF/services/に
com.example.lib.FooServiceという名前のファイルを作成 - そのファイルに実装クラスのFQCNを記載
com.example.lib.internal.FooServiceImpl FooServiceImplにpublicコンストラクタ(引数なし)を用意しておく- ライブラリを
mvn installしてローカルリポジトリに登録
ステップ2:アプリケーション側でServiceLoader経由で取得
Javapublic enum FooServiceProvider { INSTANCE; private final FooService service = ServiceLoader .load(FooService.class) .findFirst() .orElseThrow(() -> new IllegalStateException("FooService not found")); public FooService get() { return service; } }
これでビジネスコードはFooServiceProvider.INSTANCE.get()だけに依存し、実装クラスには手を触れなくて済みます。
ステップ3:Spring環境では@Configurationで登録
Spring Bootを使っているなら、上記のServiceLoader結果を@Beanとして登録してあげるだけで@Autowired可能になります。
Java@Configuration public class LibraryConfig { @Bean public FooService fooService() { return FooServiceProvider.INSTANCE.get(); } }
テスト時は
Java@TestConfiguration @Primary public class StubConfig { @Bean public FooService fooService() { return new StubFooService(); } }
とすれば、自動的にスタブが注入されます。
ハマった点:モジュールシステム(JPMS)でServiceLoaderが見つからない
Java 9以降のモジュール環境では、module-info.javaにprovides ... with ...の記述が必要です。
これを忘れるとServiceLoader#findFirstが空のOptionalを返してしまい、エラーメッセージも出ないので原因特定に時間がかかります。
解決策
ライブラリ側のmodule-info.javaに以下を追記
module com.example.lib {
exports com.example.lib;
provides com.example.lib.FooService
with com.example.lib.internal.FooServiceImpl;
}
アプリケーション側はrequires com.example.lib;だけで自動的にプロバイダーが読み込まれます。
まとめ
本記事では、依存を持たせたくないJARに紛れ込んだ具象クラスを、ServiceLoaderとSpringの@Bean登録でDI可能にする方法を解説しました。
- ServiceLoaderを使えば、Springを使わなくても「インターフェース⇔実装」の差し替えが外部化できる
- ライブラリ側に
META-INF/services/ファイルを追加しておけば、利用側は@Bean1行でDIコンテナに登録可能 - JPMS環境では
provides宣言を忘れると見つからないので注意
このテクニックを使うと、古いライブラリでもモックテストが簡単になり、本実装を差し替えるときも@Profileで切り替えるだけで済みます。
次回は、JPMSとSpringのmodulesビーン登録を組み合わせて、ネイティブイメージ(GraalVM)でも動く軽量DIコンテナを作ってみます。
参考資料
- ServiceLoader (Java Platform SE 17)
- Spring Framework Documentation - @Bean
- Mockito + ServiceLoader 活用事例
