RestTemplate がメンテナンスモードに入ってから、外部APIを叩くコードをどう書くか迷った人は多いですよね。かといって WebClient はReactorが前提で、同期処理のために毎回 block() を呼ぶのもなんだか不自然です。

そこでSpring Boot 3.2(Spring Framework 6.1)から登場したのが RestClient です。命令的で同期的、それでいてフルエントAPIを持つモダンなHTTPクライアントで、同期用途ならこれが第一候補になります。この記事では基本のリクエストからエラーハンドリング、タイムアウト、テスト、そしてRestTemplateからの移行までを一通り書けるところまで解説します。

なお、本記事のコードは Spring Boot 3.4 以降(Spring Framework 6.2)で動作確認しています。RestClient自体は3.2から使えますが、後述のタイムアウト設定だけAPIがバージョンで異なるので、そこは3.2向けの書き方も併記します。

RestClientはどういう位置づけなのか

ざっくり整理すると、こういう状況です。

  • RestTemplate はメンテナンスモードで、新機能の追加は止まっている
  • WebClient は同期利用もできるが、Reactor依存と block() の不自然さが残る
  • RestClient は命令的・同期のフルエントAPIとして両者の間を埋める

おもしろいのは、RestClient が内部でRestTemplateと同じインフラ(ClientHttpRequestFactory など)を再利用している点です。つまり実行モデルはRestTemplateと同じ同期ブロッキングのまま、APIだけがモダンになったと考えると分かりやすいです。リアクティブスタックを持ち込まずに書き換えられるので、既存の同期アプリとの相性がとても良いんですね。

RestClientを生成する

一番手軽なのは RestClient.create() です。設定を足したいときは builder() を使います。実務ではベースURLや共通ヘッダをまとめたいので、Beanとして定義して使い回すのがおすすめです。

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient userApiClient() {
        return RestClient.builder()
                .baseUrl("https://api.example.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

baseUrl() を一元管理しておくと、各リクエストではパスだけ書けば済みます。認証トークンのような共通ヘッダも defaultHeader() にまとめておきましょう。

フルエントAPIでGET/POST/PUT/DELETEを書く

GETとレスポンスの受け取り

基本の形は メソッド().uri().retrieve().body(型) です。GETでレスポンスをそのままオブジェクトへデシリアライズするならこう書けます。

// GET: URI変数を渡してオブジェクトで受け取る
User user = userApiClient.get()
        .uri("/users/{id}", 42)
        .retrieve()
        .body(User.class);

// クエリパラメータはUriBuilderで組み立てられる
List<User> users = userApiClient.get()
        .uri(uriBuilder -> uriBuilder.path("/users")
                .queryParam("role", "admin")
                .build())
        .retrieve()
        .body(new ParameterizedTypeReference<>() {});

リストのようなジェネリック型は ParameterizedTypeReference で受けるのがポイントです。

POST/PUT/DELETEとボディ送信

POSTやPUTでは body() にリクエストボディを渡します。

// POST: リクエストボディを送り、ステータスやヘッダも欲しいのでtoEntity()
ResponseEntity<User> response = userApiClient.post()
        .uri("/users")
        .body(new CreateUserRequest("mizu", "admin"))
        .retrieve()
        .toEntity(User.class);

URI location = response.getHeaders().getLocation();

// PUT: 更新
User updated = userApiClient.put()
        .uri("/users/{id}", 42)
        .body(new UpdateUserRequest("newName"))
        .retrieve()
        .body(User.class);

// DELETE: レスポンスボディが不要ならtoBodilessEntity()
userApiClient.delete()
        .uri("/users/{id}", 42)
        .retrieve()
        .toBodilessEntity();

ボディだけ欲しいなら body(型)、ステータスコードやヘッダも見たいなら toEntity(型)、レスポンスボディが不要なら toBodilessEntity() を使い分けます。

onStatusでエラーを例外に変換する

retrieve() はデフォルトで4xx/5xxを RestClientResponseException として投げます。ただ実務では、そのままだと業務ロジック側で扱いにくいので、独自の例外へ変換したいことが多いですよね。そこで onStatus() を挟みます。

User user = userApiClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
            if (response.getStatusCode().isSameCodeAs(HttpStatus.NOT_FOUND)) {
                throw new UserNotFoundException(id);
            }
            throw new ExternalApiException("クライアントエラー: " + response.getStatusCode());
        })
        .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
            throw new ExternalApiException("サーバエラー: " + response.getStatusCode());
        })
        .body(User.class);

ステータス判定は参照比較(==)ではなく isSameCodeAs() を使うのが安全です。getStatusCode() の戻り値型は HttpStatusCode なので、== だと将来カスタムコードが混ざったときに崩れます。ハンドラの第2引数からレスポンスボディも読めるので、エラーJSONをパースしてメッセージに含めることもできます。もっと低レベルに制御したいときは retrieve() の代わりに exchange() を使い、レスポンス全体を自分で処理する選択肢もあります。

connect/readタイムアウトを設定する

本番運用ではタイムアウト設定が必須です。相手のAPIが応答しないときに無限に待ってしまうと、スレッドが枯渇してアプリ全体が巻き込まれます。Spring Boot 3.4以降なら ClientHttpRequestFactorySettingsClientHttpRequestFactoryBuilder でこう書けます。

@Bean
public RestClient userApiClient() {
    // org.springframework.boot.http.client パッケージ(3.4以降)
    ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
            .withConnectTimeout(Duration.ofSeconds(2))
            .withReadTimeout(Duration.ofSeconds(5));

    ClientHttpRequestFactory requestFactory =
            ClientHttpRequestFactoryBuilder.detect().build(settings);

    return RestClient.builder()
            .baseUrl("https://api.example.com")
            .requestFactory(requestFactory)
            .build();
}

Spring Boot 3.2/3.3ではこのビルダーがまだ無いので、ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS.withConnectTimeout(...).withReadTimeout(...)) を使ってください(DEFAULTS は定数、パッケージは org.springframework.boot.web.client)。

ファクトリはクラスパスから自動検出され、Apache HttpClientやJettyのクライアントを入れておけばそれが使われます。コネクションプールを細かく制御したいならApache HttpClientを明示的に選ぶとよいでしょう。タイムアウト超過時はリトライやサーキットブレーカーへ繋ぎたくなりますが、その辺りは Resilience4jでサーキットブレーカーを実装する記事 に委ねます。

RestTemplateからの移行手順

既存のRestTemplateコードは、ほぼ機械的にRestClientへ置き換えられます。よく使うメソッドの対応はこんな感じです。

RestTemplateRestClient
getForObject(url, T.class)get().uri(url).retrieve().body(T.class)
getForEntity(url, T.class)get().uri(url).retrieve().toEntity(T.class)
postForObject(url, body, T.class)post().uri(url).body(body).retrieve().body(T.class)
exchange(...)method().uri().body().retrieve().toEntity()

exchange(...) の行は汎化した形なので、GETやDELETEのようにボディを送らないリクエストでは body() を挟みません。実際のコードで見比べるとイメージしやすいです。

// Before: RestTemplate
ResponseEntity<User> res = restTemplate.exchange(
        "/users/{id}", HttpMethod.GET, null, User.class, id);
User user = res.getBody();

// After: RestClient(GETなのでbody()は無い)
User user = restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .body(User.class);

移行はいっぺんにやらなくても大丈夫です。既存の RestTemplate Beanと RestClient Beanを共存させ、触る箇所から順に置き換えていけます。既存のインターセプタ資産は requestInterceptor() で引き継げます。

一点だけ注意したいのは、エラーハンドリングの挙動差です。RestTemplateは DefaultResponseErrorHandler の設定次第でしたが、RestClientは retrieve() 時点で4xx/5xxを例外にします。そのため移行後は onStatus() を明示的に書いて、以前と同じ振る舞いになるか確認しておきましょう。

MockRestServiceServerでテストする

RestClientを使うコードのユニットテストには、RestTemplate時代からお馴染みの MockRestServiceServer がそのまま使えます。ポイントは、テスト対象サービスへ渡す RestClient と、モックをバインドする builder を同じものにすること。ここでは本番と同じ baseUrl を設定し、検証も同じ絶対URLで書きます。

RestClient.Builder builder = RestClient.builder()
        .baseUrl("https://api.example.com");
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();

// サービスにはbaseUrlを設定済みのbuilderから作ったRestClientを渡す
UserService service = new UserService(builder.build());

server.expect(requestTo("https://api.example.com/users/42"))
        .andExpect(method(HttpMethod.GET))
        .andRespond(withSuccess("{\"id\":42,\"name\":\"mizu\"}",
                MediaType.APPLICATION_JSON));

User user = service.findById(42);
assertThat(user.name()).isEqualTo("mizu");
server.verify();

baseUrl を揃えておけば、サービス側が /users/{id} と相対パスで呼んでも requestTo("https://api.example.com/users/42") の絶対URLと一致します。andRespond(withStatus(HttpStatus.NOT_FOUND)) のようにエラーレスポンスを返せば、onStatus() の分岐が意図どおり例外を投げるかも検証できます。外部APIとの結合をまるごと確認したいときは、WireMockで外部APIをテストする記事 のアプローチが向いています。

結局どれを選べばいいのか

最後に使い分けの目安をまとめておきます。

  • 同期・命令的でよいなら RestClient が第一候補
  • 非同期やストリーミングが必要なら WebClient
  • インターフェース宣言だけでクライアントを作りたいなら @HttpExchange
  • Feign互換やそのエコシステムを重視するなら OpenFeign

RestTemplateとWebClientの比較そのものは RestTemplateとWebClientの使い分け記事 に、宣言的クライアントは @HttpExchangeの記事OpenFeignの記事 にまとめてあるので、深掘りしたい方はそちらもどうぞ。

まとめ

RestClientは、RestTemplateの同期実行モデルはそのままに、APIだけをモダンにした素直な後継です。フルエントAPIで読みやすく、onStatus() でエラー変換もきれいに書け、テストは既存の MockRestServiceServer がそのまま使えます。同期用途でHTTPクライアントの移行先を探しているなら、まずRestClientから試してみるのがおすすめです。