外部APIを呼ぶたびに RestClientWebClient のコードを書いていると、URLの組み立てやパラメータの渡し方が散らかってきますよね。「OpenFeignみたいにインターフェースを書くだけで済ませたいけど、そのために spring-cloud-openfeign を入れるのはちょっと重いな」と感じたことはないでしょうか。

そんなときに使えるのが、Spring Framework 6 から標準で入っている HTTP Interface(@HttpExchange)です。追加の依存はゼロで、インターフェースを定義するだけで宣言的なHTTPクライアントが作れます。この記事では、インターフェース定義からProxyの生成、エラーハンドリングまで、実際に動く形で構成していきます。

HTTP Interfaceとは何か

HTTP Interface は、Javaのインターフェースにアノテーションを付けるだけでHTTPクライアントを宣言的に定義できる仕組みです。実装クラスは自分で書きません。Springが実行時にProxyを生成して、メソッド呼び出しを実際のHTTPリクエストへ変換してくれます。

ポイントは、これが RestClient / WebClient / RestTemplate上にアダプタとして載る 設計になっていることです。つまり下回りのHTTP通信はこれまで通りのクライアントが担い、その手前に宣言的なレイヤーを足すイメージです。

OpenFeign との最大の違いは依存関係です。OpenFeign は spring-cloud-openfeign というSpring Cloudの一部を必要としますが、HTTP Interface は Spring Framework 本体の機能なので、Webスターターさえ入っていればそのまま使えます。

前提条件と依存関係

必要なのは Spring Framework 6 以上、つまり Spring Boot 3 以上です。5系以前では使えません。同期で使うなら spring-boot-starter-web、リアクティブなら spring-boot-starter-webflux を入れます。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

この記事では同期版の RestClientAdapter を主軸にします。RestClientAdapterRestClient を使うため、Spring Boot 3.2 以降が前提になる点だけ注意してください。リアクティブな WebClientAdapter は後半で補足します。

クライアントインターフェースを定義する

まずは呼び出したいAPIをインターフェースとして表現します。ここでは仮にユーザー情報を扱うAPIを例にします。

import org.springframework.web.service.annotation.*;
import org.springframework.web.bind.annotation.*;

@HttpExchange("/api/users")
public interface UserApiClient {

    @GetExchange("/{id}")
    User getUser(@PathVariable Long id);

    @GetExchange
    List<User> searchUsers(@RequestParam String keyword);

    @PostExchange
    User createUser(@RequestBody UserRequest request,
                    @RequestHeader("X-Tenant-Id") String tenantId);
}

@HttpExchange でベースパスを宣言し、各メソッドに @GetExchange@PostExchange を付けます。パラメータのバインドは見覚えのあるアノテーションそのままで、@PathVariable がパス変数、@RequestParam がクエリパラメータ、@RequestBody がリクエストボディ、@RequestHeader が個別ヘッダに対応します。

戻り値の型も柔軟です。User のように直接オブジェクトを返せばボディがデシリアライズされ、List<User> ならコレクションになります。ステータスコードやヘッダも見たい場合は ResponseEntity<User> を返すようにします。

HttpServiceProxyFactoryでProxyを生成する

インターフェースができたら、そこから実体のProxyを作ります。ここで登場するのが HttpServiceProxyFactory です。RestClientRestClientAdapter でラップし、それを元にファクトリを組み立てます。

@Configuration
public class HttpClientConfig {

    @Bean
    public UserApiClient userApiClient() {
        RestClient restClient = RestClient.builder()
                .baseUrl("https://api.example.com")
                .build();

        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build();

        return factory.createClient(UserApiClient.class);
    }
}

factory.createClient(UserApiClient.class) がProxy実装を返してくれるので、それをBeanとして登録するだけです。あとはアプリのどこからでも普通にDIして使えます。

@Service
public class UserService {
    private final UserApiClient client;

    public UserService(UserApiClient client) {
        this.client = client;
    }

    public User find(Long id) {
        return client.getUser(id);
    }
}

ベースURLや共通設定は、アダプタの元になる RestClient 側に集約するのがコツです。インターフェースは「何を呼ぶか」に専念させ、「どこに・どう呼ぶか」はクライアント側に寄せると見通しが良くなります。

基盤クライアントを設定する

実運用ではベースURLだけでなく、共通ヘッダーやタイムアウトもまとめて設定したくなります。これらはすべて元の RestClient で指定できます。

@Bean
public RestClient apiRestClient(ApiProperties props) {
    var settings = ClientHttpRequestFactorySettings.defaults()
            .withConnectTimeout(Duration.ofSeconds(3))
            .withReadTimeout(Duration.ofSeconds(10));

    return RestClient.builder()
            .baseUrl(props.baseUrl())
            .defaultHeader("Authorization", "Bearer " + props.token())
            .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
            .build();
}

ベースURLを環境ごとに切り替えたいときは、@ConfigurationProperties で外出しすると扱いやすくなります。

@ConfigurationProperties(prefix = "api")
public record ApiProperties(String baseUrl, String token) {}

こうしておけば、複数のHTTP Interfaceで同じ RestClient を共有でき、認証ヘッダの付与漏れといったミスも防げます。

エラーハンドリング(4xx / 5xx)

デフォルトでは 4xx / 5xx のレスポンスで例外が投げられますが、独自の例外に変換したいことが多いですよね。RestClientdefaultStatusHandler を使うと、ステータスごとに横断的なハンドリングを差し込めます。

RestClient.builder()
        .baseUrl(props.baseUrl())
        .defaultStatusHandler(HttpStatusCode::is4xxClientError, (req, res) -> {
            ApiError error = readError(res);
            throw new ApiClientException(res.getStatusCode(), error);
        })
        .defaultStatusHandler(HttpStatusCode::is5xxServerError, (req, res) -> {
            throw new ApiServerException(res.getStatusCode());
        })
        .build();

エラーレスポンスのボディに詳細メッセージが入っている場合は、専用のDTOへデシリアライズしておくと、呼び出し側で原因を判別しやすくなります。

WebClient を使う場合は、同じ役割を onStatus が担います。書き味は変わりますが、ステータス条件で分岐して例外を投げるという考え方は共通です。

WebClientアダプタでリアクティブに使う

WebFluxスタックでノンブロッキングに呼びたい場合は、WebClientAdapter を使います。

@Bean
public UserApiClient reactiveUserApiClient(WebClient.Builder builder) {
    WebClient webClient = builder.baseUrl("https://api.example.com").build();

    HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builderFor(WebClientAdapter.create(webClient))
            .build();

    return factory.createClient(UserApiClient.class);
}

アダプタを差し替えるだけで、構成の流れはほとんど同じです。リアクティブにしたいメソッドは戻り値を Mono<User>Flux<User> にすると、ノンブロッキングで動きます。

選択基準はシンプルで、アプリ全体がWebFluxで組まれているなら WebClient、そうでなければ素直に RestClient を選べば十分です。同期スタックで無理にリアクティブを持ち込む必要はありません。

OpenFeignとの比較と使い分け

どちらも宣言的という点では似ていますが、立ち位置はかなり違います。

観点@HttpExchangeOpenFeign
依存Spring Framework標準(追加ゼロ)spring-cloud-openfeign が必要
下回りRestClient / WebClient独自(連携可)
サービスディスカバリ連携標準では非対応Eureka等と統合しやすい
ロードバランサ連携別途構成Spring Cloud LoadBalancer と統合

ざっくり言うと、Spring Cloud のエコシステム(サービスディスカバリやクライアントサイドのロードバランシング)を活用するなら OpenFeign が有利です。一方、単純な外部API連携や、依存を増やしたくないケースでは @HttpExchange がすっきりはまります。OpenFeign 側の詳しい設定は Spring BootでOpenFeignを使って宣言的HTTPクライアントを作る を参照してください。

なお、リトライやサーキットブレーカーといった耐障害性は、どちらの方式でも別の仕組みで補います。@HttpExchange と組み合わせるなら Spring BootでResilience4jのサーキットブレーカーを実装する が参考になります。アダプタの下回りそのものを掘り下げたい場合は Spring BootのRestClientで同期HTTPクライアントを実装する もあわせてどうぞ。

まとめ

@HttpExchangeHttpServiceProxyFactory を使えば、Spring Cloud依存を増やさずに宣言的なHTTPクライアントが作れます。流れとしては、インターフェースを定義して、RestClientAdapter を介してProxyを生成し、基盤クライアントにベースURLや共通設定を集約し、defaultStatusHandler でエラーを整える、という4ステップでした。

同期なら RestClient、リアクティブなら WebClient とアダプタを選ぶだけで切り替えられるのも気持ちいいところです。まずは小さな外部API連携から、インターフェース1枚で置き換えてみると効果を実感しやすいと思います。