markdown

はじめに

Maven Centralに公開されている「〇〇-util-1.0.0.jar」のように、依存を一切持たせずに作られたライブラリの中に、不意に具象クラスが眠っていることはありませんか?
new FooServiceImpl() でハードコーディングされてしまっていると、単体テストでモックに差し替えたり、本番で別実装を使い分けたりするのが面倒です。
この記事では、そんな「依存のないJAR」に紛れ込んだ具象クラスを、SpringのDIコンテナに登録して@Autowiredで注入できるようにする方法を解説します。Springを使わない環境でもServiceLoaderで同じことを実現する裏技も併せて紹介します。

前提知識

  • Java 11以上、Spring Boot 3系の基本文法
  • spring.factoriesServiceLoader の存在を聞いたことがある
  • JARをmvn installしてローカルリポジトリに登録できる

なぜ「依存なしJAR」の具象クラスをDIしたいのか

「依存を入れたくない」という設計思想のもとに作られたライブラリは、インターフェースだけを公開して実装クラスをinternalパッケージに隠すことが多いのですが、中には「一応実装も同梱しておくよ」というスタンスで、publicな具象クラスが露出しているケースがあります。
これをそのまま使ってしまうと、ビジネスコード側がnewしてしまい、テスト時に Mockito で置き換えるために@Spyや PowerMock を使わざるを得なくなります。
DIコンテナに登録しておけば、本番は本物、テストはスタブという切り替えが@Primary@Profileだけで済み、テストが高速で安全になります。

SpringなしでもServiceLoaderで「軽量DI」を実現する

Springを使っていないプロジェクトでも、JDKに標準で搭載されているjava.util.ServiceLoaderを使えば、依存関係なしに「インターフェース⇔実装」のマッピングを外部化できます。
以下は、ライブラリ側にServiceLoader対応のプロバイダー設定を追加し、アプリケーション側で好きな実装を差し替える手順です。

ステップ1:ライブラリ側でプロバイダー定義ファイルを追加

  1. ライブラリプロジェクトのsrc/main/resources/META-INF/services/
    com.example.lib.FooService という名前のファイルを作成
  2. そのファイルに実装クラスのFQCNを記載
    com.example.lib.internal.FooServiceImpl
  3. FooServiceImplpublicコンストラクタ(引数なし)を用意しておく
  4. ライブラリをmvn installしてローカルリポジトリに登録

ステップ2:アプリケーション側でServiceLoader経由で取得

Java
public 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.javaprovides ... 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コンテナを作ってみます。

参考資料