注目イベント!
春の新人向け連載2025開催中!
今年も春の新人向け連載が始動しました!!
現場で役立つ考え方やTipsを丁寧に解説、今日から学びのペースを整えよう。
詳細はこちらから!
event banner

Springの小話 - ServiceConnectionのオレオレ対応

| 8 min read
Author: toshio-ogiwara toshio-ogiwaraの画像

Spring Boot 3.1からTestcontainersとの連携がしやすくなるServiceConnection機能が導入されました。使ってみると「これは便利だ!」と感じたのですが、対応しているのはPostgreSQLなど一部のミドルウェアに限られています。PostgreSQLなどのミドルウェアをコンテナで利用することは確かに多いですが、それと同じくらい対向のRESTアプリをコンテナ化し、スタブとして利用するケースも多いですが、独自コンテナに対してServiceConnectionはそのままでは使えません。

そこで今回はコンテナ化したRESTアプリを独自に(=オレオレで)ServiceConnection対応させ@ServiceConnectionで接続できるようにする方法を紹介します。

Testcontainersについては説明はしませんので、そこから理解したいという方は下のブログも参考にしてもらえればと思います。

Information

この記事は Spring Boot 3.5.3 で動作を確認しています。また記事で説明したコードはGitHubの こちら にすべてアップしています。

ServiceConnection対応前

#

まずはオレオレServiceConnection対応前のアプリですが、これは次のようになっています。記事ではこの例をもとにServiceConnection対応の方法を説明してきます。

<ServiceConnection対応前>

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class ContainerClientStep5Test {
    @Autowired
    private ContainerClient client;

    @Configuration(proxyBeanMethods = false)
    @Import(ClientApplication.class)
    static class TestConfig {
        @Bean
        GenericContainer<?> appContainer() {
            return new GenericContainer<>("container-app:latest")
                    .withExposedPorts(8080);
        }
        @Bean
        DynamicPropertyRegistrar targetUrlRegistrar(GenericContainer<?> appContainer, Environment env) {
            String destination = "http://" + appContainer.getHost() + ":" + appContainer.getFirstMappedPort();
            return registry -> registry.add("client.connect-url", () -> destination);
        }
    }

このアプリはRESTアプリをcontainer-app:latestとしてイメージ化し、それをTestcontainerのGenericContainerでインスタンス化しています。GenericContainerはクラス名のとおり、汎用のコンテナクラスでコンストラクタで指定されたイメージを実体化するものとなります。

このコンテナを使うクライアントアプリはclient.connect-urlプロパティからコンテナの接続先を取得し、その値をもとに RestClient を使ってコンテナのREST APIを呼び出しています。

GenericContainerはServiceConnectionに対応していないため、接続先情報の取得と設定はDynamicPropertyRegistrarを使って自分で行っています。

では、これをServiceConnectionに対応させるために必要な内容を順を追って説明していきます。

ServiceConnectionの仕組み

#

ServiceConnectionに対応するための細かい話をする前に、そもそもServiceConnectionとはどのような仕組みかを説明します。

ServiceConnectionの仕組みはその対応前と対応後の設定情報の流れを比較するのがわかりやすいため、下の図を使って説明します。ただし、この部分に限っては先ほどの例ではなくPostgreSQLContainerを例に説明します。

service-connection

図がわかるようにServiceConnection対応前は、接続情報はあくまでも設定ファイル(プロパティ値)を経由して取得するものでした。これが対応後はコンテナインスタンスから取得した接続情報が直接使われるようになるのが一番大きな違いとなります。

接続情報が使われるまでのそれぞれの動き(1.~5.)を少し説明すると次のようになります(厳密には実装とは少し異なる部分がありますが、理解しやすいようにある程度丸めて説明をしています)。

  1. @Beanが指定されているため、生成されたコンテナインスタンスがSpringのBeanとして登録されます。
  2. Beanに@ServiceConnectionが付けられている場合、Springはspring.factoriesから接続情報の取得を行う接続詳細ファクトリを取得します。Springはspring.factoriesに複数登録されているファクトリの中から、Bean登録するコンテナインスタンスの型に合致するファクトリを取得します。これは原則コンテナクラスごとにファクトリクラスが必要なことを意味します。
  3. ファクトリは接続に必要な情報をコンテナインスタンスから取得します。
  4. ファクトリは接続詳細のインスタンスを生成し、取得した接続情報をバインドします。また、この接続詳細インスタンスはSpringによりBeanとして登録されます。
  5. 接続情報が欲しいBeanは接続詳細インスタンスをインジェクションで取得し必要な値を参照します。

この仕組みからオレオレでServiceConnection対応するために以下4点が必要なことがわかります。

  • Testcontainersの独自コンテナクラス
    • 2.の手順からわかるようにSpringはコンテナインスタンスの型をもとに対応するファクトリクラスをマッチングさせるため、個別のクラスを作成する必要があります
  • 接続詳細ファクトリ実装
    • 作成した独自コンテナクラスに対応するファクトリクラスの実装が必要となります
  • 接続詳細
    • 取得した接続情報をバインドするためのインタフェースとその実装が必要となります
  • spring.factoriesへの登録
    • Springが作成したファクトリクラスを取得できるようにspring.factoriesにファクトリクラスを登録します

ServiceConnection対応の実装

#

ここまでの内容をもとに対応前の例をServiceConnectionに対応させるために必要なものをあてはめると次のようになります。

service-connection-classes

RESTアプリを扱うコンテナクラスはRestAppContainerとして作成し、そのネーミングに合わせて他のクラスを作成しています。それではそれぞれの実装をみていきます。

<RestAppContainer>

public class RestAppContainer extends GenericContainer<RestAppContainer> {
    public RestAppContainer(@NonNull String dockerImageName) {
        super(dockerImageName);
    }
}

RestAppContainerの実体はGenericContainerと同じですが、ファクトリを検索するためのマーカーとしてGenericContainerを継承した独自クラスを作成しています。

<RestAppConnectionDetails>

public interface RestAppConnectionDetails extends ConnectionDetails {
    String getConnectUrl();
}

RestAppContainerからの接続詳細を表すインターフェースとなります。接続詳細インターフェースはSpringのConnectionDetailsを継承する必要があります。

<RestAppContainerConnectionDetailsFactory>

class RestAppContainerConnectionDetailsFactory
    extends ContainerConnectionDetailsFactory<RestAppContainer, RestAppConnectionDetails> {

    @Override
    protected RestAppContainerConnectionDetails getContainerConnectionDetails(
            ContainerConnectionSource<RestAppContainer> source) {

        return new RestAppContainerConnectionDetails(source);
    }
    
    private static final class RestAppContainerConnectionDetails
    ... // ここの部分は後ほど出てきます
}

接続詳細ファクトリの実装はそれが接続詳細ファクトリであることを表すSpringのConnectionDetailsFactoryインターフェースを実装する必要があります。このインタフェースに対するスケルトン実装としてContainerConnectionDetailsFactoryがSpringから提供されているため、今回はそれを継承するようにしています。

ConnectionDetailsFactoryインターフェースはコンテナクラス(RestAppContainer)と接続詳細(RestAppConnectionDetails)の2つの型パラメータを必要とします。コンテナクラスはそのファクトリがどのコンテナクラスに対するファクトリなのかを意味し、接続詳細はそのファクトリが生成する接続詳細の型を意味します。つまり、ファクトリクラスのマッチングは基本的にファクトリクラスに定義されたコンテナクラスの型パラメータよって決定されます。

ConnectionDetailsFactoryインターフェースに必要な実装はContainerConnectionSourceでされているため、必要な実装は接続詳細インターフェースに対するRestAppContainerConnectionDetailsインスタンスを返すだけです。

<RestAppContainerConnectionDetails>

private static final class RestAppContainerConnectionDetails
    extends ContainerConnectionDetails<RestAppContainer>
    implements RestAppConnectionDetails {

    protected RestAppContainerConnectionDetails(ContainerConnectionSource<RestAppContainer> source) {
        super(source);
    }
    @Override
    public String getConnectUrl() {
        String host = getContainer().getHost();
        int port = getContainer().getFirstMappedPort();
        return "http://%s:%s".formatted(host, port);
    }
}

このクラスはRestAppConnectionDetailsインターフェースの実装で、クラスの責務は接続先を返すだけですが、Springから接続詳細向けのスケルトン実装としてContainerConnectionDetailsが提供されているため、せっかくなので今回はこれを継承しています。

このクラスの中心となる部分はgetConnectUrlメソッドですが、実装はみてわかる通り、コンテナインスタンスからコンテナが稼働しているホスト(通常はlocalhost)とホスト側の公開ポートを取得して接続先として返していて、これがまさに「接続情報はコンテナが知っている」を表している部分となります。

<spring.factories>

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
package.name.RestAppContainerConnectionDetailsFactory

最後に作成したファクトリ実装をspring.factoriesにFQCNで登録したら完成です。spring.factoriesがない場合は自分のプロジェクトのMETA-INF配下に普通のテキストファイルとして作成するだけでOKです。

これで自分が作ったRestAppContainer@ServiceConnectionが使えるようになります。
ServiceConnection対応前のコードを次のように修正すると対応前と全く同じように動きます。

<ServiceConnection対応後>

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class ContainerClientStep6Test {

    @Autowired
    private ContainerClient client;

    @Configuration(proxyBeanMethods = false)
    @Import(ClientApplication.class)
    static class TestConfig {
        @Bean
        @ServiceConnection // ← つける
        RestAppContainer appContainer() {
            return new RestAppContainer("container-app:latest")
                    .withExposedPorts(8080);
        }
    }
    // DynamicPropertyRegistrarは不要

さいごに

#

ServiceConnectionのオレオレ対応はいかがでしたでしょうか。仕組みが分かる前は黒魔術的な感じが若干あり、難しそうに思えましたが、理解できればそれほど難しいものではないかと思います。ServiceConnectionに対応することでDynamicPropertyRegistrar@DynamicPropertySourceの操作が不要となる直接的なメリットがありますが、それ以外にも提供側/利用側ともに接続に関するプロパティの知識が不要となる認知負荷の軽減もあります。是非、オレオレを試してみていただければと思います。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。