Spring Boot4.0の目玉機能のHTTP Service Clientsを試してみた #PERSOL CAREER Advent Calendar2025

はじめに

アドベントカレンダー 6 日目担当の廣瀬と申します。 私は doda グロース開発部に所属しており、普段は doda サイトの開発に従事しております。 業務では Java、Spring Framework あたりがメイン言語となっています。

Spring Framework といえば、Spring Boot4.0 が 11 月 21 日にリリースされましたね! Java 25 のファーストクラスサポートに加え、API バージョニングや HTTP サービスクライアントのサポートが強化されていました。 本記事では HTTP サービスクライアントで追加された機能について試していきます。 github.com

作るもの

今回は Spring Boot 4 の新機能「HTTP Service Clients」を使って、

  • 任意の国の祝日取得 API

  • 日本 × ベトナム(× 任意の国)の祝日比較 API

を実装します。

背景

弊社では現在、ベトナムオフショアの活用を積極的に進めています。 日頃から BSE(Bridge SE)の方々と連携しながら開発を進めていますが、

「日本は祝日なので明日は連絡が取れません」 「こちらは平日ですが、そちらは祝日でしたか…?」

といった祝日のズレによるすれ違いが発生します。

そこで今回は日本、ベトナム(必要に応じてその他の国も)の祝日を比較できる API を作っていきます!

利用環境

ディレクトリ構成

spring-boot4-holidays/
├── Dockerfile
├── mvnw
├── .mvn/
├── pom.xml
└── src
    └── main
        ├── java
        │   └── com/example/demo
        │       ├── DemoApplication.java
        │       ├── client
        │       │   └── HolidayClient.java
        │       ├── service
        │       │   └── HolidayService.java
        │       └── web
        │           └── HolidayController.java
        └── resources
            └── application.properties

全体の処理の流れ

実装

1. Spring Initializr で雛形作成

まずは、Spring Initializr で Spring プロジェクトの雛形を作成します。

  • Spring Boot: 4.0.0
  • Java: 25
  • Dependencies: Spring Web, HTTP Client

spring initializer

2. application.properties の設定

spring.application.name=demo
spring.http.clients.connect-timeout=1s
spring.http.serviceclient.holiday.base-url=https://date.nager.at

3. HTTP Service Clientの登録

@ImportHttpServices で com.example.demo.client 配下の @HttpExchange interface を HTTP Service Client として登録。

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.service.registry.ImportHttpServices;

@SpringBootApplication
@ImportHttpServices(
        group = "holiday",
        basePackages = "com.example.demo.client"
)
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

4. HTTP Service Client の実装

Public Holidays API を叩く HTTP Service Interface を定義。

package com.example.demo.client;

import java.time.LocalDate;
import java.util.List;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;

@HttpExchange
public interface HolidayClient {

    @GetExchange("/api/v3/PublicHolidays/{year}/{countryCode}")
    List<PublicHoliday> getPublicHolidays(@PathVariable int year,
                                          @PathVariable String countryCode);

    // Nager.Date のレスポンスに合わせたレコード
    // https://date.nager.at/api/v3/PublicHolidays/2025/JP 参照
    record PublicHoliday(
            LocalDate date,
            String localName,
            String name,
            String countryCode,
            boolean global,
            List<String> types
    ) {}
}

5. HolidayService の実装

祝日を比較するロジックを作成。

package com.example.demo.service;

import java.util.*;
import java.util.stream.Collectors;

import com.example.demo.client.HolidayClient;
import com.example.demo.client.HolidayClient.PublicHoliday;
import org.springframework.stereotype.Service;

@Service
public class HolidayService {

    private final HolidayClient holidayClient;

    public HolidayService(HolidayClient holidayClient) {
        this.holidayClient = holidayClient;
    }

    public List<PublicHoliday> getHolidays(int year, String countryCode) {
        return holidayClient.getPublicHolidays(year, countryCode.toUpperCase());
    }

    public HolidayComparison compare(int year, String country1, String country2) {
        var c1 = country1.toUpperCase();
        var c2 = country2.toUpperCase();

        List<PublicHoliday> list1 = holidayClient.getPublicHolidays(year, c1);
        List<PublicHoliday> list2 = holidayClient.getPublicHolidays(year, c2);

        // date で比較する
        Map<String, PublicHoliday> byDate1 = list1.stream()
                .collect(Collectors.toMap(h -> h.date().toString(), h -> h));
        Map<String, PublicHoliday> byDate2 = list2.stream()
                .collect(Collectors.toMap(h -> h.date().toString(), h -> h));

        List<PublicHoliday> shared = byDate1.keySet().stream()
                .filter(byDate2::containsKey)
                .map(byDate1::get)
                .collect(Collectors.toList());

        List<PublicHoliday> only1 = byDate1.keySet().stream()
                .filter(k -> !byDate2.containsKey(k))
                .map(byDate1::get)
                .collect(Collectors.toList());

        List<PublicHoliday> only2 = byDate2.keySet().stream()
                .filter(k -> !byDate1.containsKey(k))
                .map(byDate2::get)
                .collect(Collectors.toList());
        return new HolidayComparison(c1, c2, only1, only2, shared);
    }

    public record HolidayComparison(
            String country1,
            String country2,
            List<PublicHoliday> onlyInCountry1,
            List<PublicHoliday> onlyInCountry2,
            List<PublicHoliday> sharedHolidays
    ) {}
}

6. HolidayController の実装

REST API

package com.example.demo.controller;

import java.util.List;

import com.example.demo.client.HolidayClient.PublicHoliday;
import com.example.demo.service.HolidayService;
import com.example.demo.service.HolidayService.HolidayComparison;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/holidays")
public class HolidayController {

    private final HolidayService holidayService;

    public HolidayController(HolidayService holidayService) {
        this.holidayService = holidayService;
    }

    // 汎用:どの国でも取れる
    // 例: /holidays/2025?country=JP
    //     /holidays/2025?country=VN
    @GetMapping("/{year}")
    public List<PublicHoliday> getHolidays(
            @PathVariable int year,
            @RequestParam(defaultValue = "JP") String country
    ) {
        return holidayService.getHolidays(year, country);
    }

    // 比較API:日本×ベトナムとか
    // 例: /holidays/compare/2025?country1=JP&country2=VN
    @GetMapping("/compare/{year}")
    public HolidayComparison compare(
            @PathVariable int year,
            @RequestParam(defaultValue = "JP") String country1,
            @RequestParam(defaultValue = "VN") String country2
    ) {
        return holidayService.compare(year, country1, country2);
    }
}

7. Dockerfile

Docker 上でビルドする構成(Java 25 + Maven)

# ============================
# Build Stage (Java 25 + Maven)
# ============================
FROM eclipse-temurin:25-jdk AS build

WORKDIR /workspace

# Maven wrapper を使えるようにコピー
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .

# 依存関係の事前ダウンロード
RUN ./mvnw -q dependency:go-offline

# ソースコードコピー
COPY src src

# Spring Boot アプリをビルド
RUN ./mvnw -q package -DskipTests


# ============================
# Run Stage (Java 25 JRE)
# ============================
FROM eclipse-temurin:25-jre

WORKDIR /app

# Build Stage で生成された jar をコピー
COPY --from=build /workspace/target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

起動・動作確認

Dockerビルド

# ビルド
docker build -t boot4-holidays .

# 起動
docker run --rm -p 8080:8080 boot4-holidays

API 動作確認(日本 × ベトナム比較)

ブラウザで下記の URL にリクエストします。

http://localhost:8080/holidays/compare/2025?country1=JP&country2=VN

下記のレスポンスが返ってきました。

気になる祝日の重複ですが… 元日しか重複していなかったです。(意外と重複していないことが分かりました。)

Spring Boot3.5との比較

HTTP Service Clients の最大の差分は

「クライアント生成の“初期設定コード”を自前で書く必要がなくなったこと」だと思います。

3.5 の場合の実装

HolidayClient.java

@Bean
WebClient holidayWebClient(WebClient.Builder builder) {
    return builder.baseUrl("https://date.nager.at").build();
}

@Bean
HolidayClient holidayClient(WebClient holidayWebClient) {
    var adapter = WebClientAdapter.forClient(holidayWebClient);
    var factory = HttpServiceProxyFactory.builder(adapter).build();
    return factory.createClient(HolidayClient.class);
}

4.0 の場合の実装

DemoApplication.java

@SpringBootApplication
@ImportHttpServices(group = "holiday", basePackages = "com.example.demo.client")
class DemoApplication {}

application.properties

spring.http.serviceclient.holiday.base-url: https://date.nager.at

HTTP Service Clientsの新機能によって何が便利になったのか(比較表)

項目 Spring Boot 3.5 Spring Boot 4.0
HTTP Interface 使えるが“クライアントを動かす準備コード”が必要 @ImportHttpServices だけで自動登録
base-url WebClient Builder に定義 application.properties に集約
設定方法 クラスごとにコードで設定 グループ単位で properties 化
拡張性 API が増えるほど Config ファイルも増加 interface を置くだけで追加可能

まとめ

  • Spring Boot 4 の HTTP Service Clients により 外部 API クライアントの実装が劇的にシンプルになりました

  • Bean 配線が不要・設定は集約され、実務での保守性が大幅向上しました

  • 日本 × ベトナムの祝日比較 API という題材で 新機能の実用例を確認できました

GitHub リポジトリ

github.com

*

廣瀬 達也 Tatsuya Hirose

プロダクト開発統括部 グロース開発部 dodaサイト開発2グループ リードエンジニア

前職では不動産系のWebアプリ・モバイルアプリの受託開発を5年ほど経験。2023年6月にパーソルキャリアへ中途入社。以降は求人検索や応募系の機能の開発業務に従事。