HttpClient в Spring 7: замена FeignClient или нет?

от автора

За последние несколько лет для вызова внешних API в каждом втором (если не первом) проекте я видел одну и ту же картину:

  • RestTemplate

  • или FeignClient

Причём Feign почти всегда шёл в связке с OpenAPI: сгенерировали клиент, получили интерфейсы и не думаем о реализации. Удобно, красиво, привычно.

Но потом в Spring появился нативный декларативный HttpClient, который работает поверх RestClient / WebClient

И у меня возник вопрос: а можно ли им заменить Feign, не потеряв удобство?

Спойлер: да, можно и будет даже удобнее.

Откуда вообще взялся HttpClient

Идея, на самом деле, очень простая.

public interface UserClient {    @GetExchange("/api/users/{id}")    UserResponse getUser(        @PathVariable("id") Long id,        @RequestParam(name = "includeDetails", defaultValue = "false") boolean includeDetails,        @RequestHeader("Authorization") String authToken,        @RequestHeader("X-Request-Id") String requestId    );    @PostExchange("/api/users")    UserResponse createUser(        @RequestBody @Valid CreateUserRequest request,        @RequestHeader("Authorization") String authToken    );    @PatchExchange("/api/users/{id}")    void updateUserEmail(        @PathVariable("id") Long id,        @RequestParam("email") String email,        @RequestHeader("Authorization") String authToken    );}

В Spring уже есть:

  • @RestController – принимаем HTTP запросы

  • аннотации вроде @GetMapping, @RequestParam, @PathVariable

Так почему бы не использовать тот же подход, но для исходящих запросов? Так появился HttpClient. Те же аннотации, тот же стиль – только теперь это клиент.

Если вы раньше работали с Feign – здесь может возникнуть логичный вопрос: разве это не то же самое? По ощущениям – очень похоже:

  • интерфейс

  • аннотации

  • декларативный вызов

Но есть важное отличие: Feign – это часть Spring Cloud и отдельная экосистема со своей инфраструктурой.
А HttpExchange – это нативный механизм Spring Framework, который работает поверх стандартного HTTP-клиента (RestClient или WebClient) и не требует дополнительного стека. Плюс более лёгкая настройка и интеграция с Observability из коробки.

То есть внешне они выглядят почти одинаково, но HttpExchange – это «тот же подход», только встроенный прямо в Spring.

И вот в Spring 7 это уже полноценный инструмент, на который явно делают ставку.

А что с Feign?

Начиная с 2022 года, FeignClient официально перешёл в стадию поддержки. То есть он никуда не исчез, но активного развития уже нет.

И логично, что я начал смотреть в сторону нового HttpClient как замены.

Что насчёт интеграции с openapi-generator

Feign хорош не только декларативностью.

Один из его главных плюсов – работа в паре с openapi-generator:

  • берём openapi.yaml

  • прогоняем через openapi-generator

  • получаем готовый клиент

И просто используем готовый бин:

@Component@RequiredArgsConstructorpublic WeatherClient {    // Декларативный сгенерированный интерфейс от feign  private final WeatherApi api;    public Forecast getWeather(...) {    var weather = api.getWeather(...);    // ...  }}

И для HttpExchange эта опция тоже доступна.

Как настроить openapi-generator и новый клиент

Ключевая вещь – это опция в генераторе:

<library>spring-http-interface</library>

Полная конфигурация:

<plugin>      <groupId>org.openapitools</groupId>      <artifactId>openapi-generator-maven-plugin</artifactId>      <version>${openapi-generator.version}</version>      <executions>         <execution>      <id>generate-weather-client</id>      <goals>         <goal>generate</goal>      </goals>      <configuration>         <inputSpec>${project.basedir}/src/main/resources/openapi/external/weather-api.yaml</inputSpec>         <generatorName>spring</generatorName>         <library>spring-http-interface</library>         <output>${project.build.directory}/generated-sources/openapi-client</output>         <apiPackage>ulllie.exchange.openapi.gen.client.api</apiPackage>         <modelPackage>ulllie.exchange.openapi.gen.client.model</modelPackage>         <generateApis>true</generateApis>         <generateModels>true</generateModels>         <generateSupportingFiles>false</generateSupportingFiles>         <configOptions>            <useSpringBoot4>true</useSpringBoot4>            <useJackson3>true</useJackson3>            <interfaceOnly>true</interfaceOnly>            <skipDefaultInterface>true</skipDefaultInterface>            <useBeanValidation>true</useBeanValidation>            <annotationLibrary>none</annotationLibrary>            <serializationLibrary>jackson</serializationLibrary>            <useTags>true</useTags>         </configOptions>      </configuration>  </execution>    </executions></plugin>

На выходе получаем:

public interface OpenMeteoApi {     @HttpExchange(method = "GET", value = "/v1/forecast")     ResponseEntity<ForecastResponse> getForecast(     @RequestParam("latitude") Double latitude,     @RequestParam("longitude") Double longitude   );}

То есть это тот же подход, но на нативном Spring API.

Как это подключается

// Такой подход через имплементацию сразу добавляет Observability внутрь клиента@Configuration  @ImportHttpServices(group = "weather", types = OpenMeteoApi.class)  public class WeatherRestClientConfig implements RestClientHttpServiceGroupConfigurer {        @Override        public void configureGroups(Groups<RestClient.Builder> groups) {     groups.filterByName("weather").forEachClient(      ($, builder) -> builder.baseUrl("https://api.weather.com")     );      }  }

Настройка обработки ошибок

builder.baseUrl(properties.baseUrl())  .defaultStatusHandler(  status -> status.is4xxClientError() || status.is5xxServerError(),                 //errorHandler реализует RestClient.ResponseSpec.ErrorHandler   errorHandler         );
public class HttpErrorHandler implements RestClient.ResponseSpec.ErrorHandler//...@Overridepublic void handle(HttpRequest request, ClientHttpResponse response)

То есть мы можем обрабатывать 4хх и 5хх статусы сразу на месте, по любой логике, которая нам нужна. Либо можем обернуть это в нашу ошибку, например, ApiRequestException, и затем настроить @ControllerAdvice – всё зависит от вашего воображения.

Также в новом клиенте легче настраивается работа с Resilience4j, интерцепторами.

А как же WebClient?

Да, можно использовать и его.

Можно даже генерировать реактивные сигнатуры, нужно всего лишь добавить в генератор соответствующий флажок и настроить в конфиге не RestClient builder, а WebClient.Builder

Mono<ForecastResponse> // или Flux

Но тут появляется интересный момент — Project Loom и его работа из коробки в Spring.

spring.threads.virtual.enabled=true

И внезапно:

  • блокирующий код перестаёт быть проблемой

  • RestClient становится «достаточно хорошим»

  • код остаётся простым, те пишем код как раньше в блокирующем стиле, никакой реактивщины

Остаётся дождаться Structured Concurrency и тогда конкурентный код с помощью VT станет намного user-friendly.

Лично моё мнение:
WebClient приносит с собой реактивный стиль, который не всегда просто читать и дебажить.
В сценариях без реактивных пайплайнов RestClient + virtual threads даёт схожую масштабируемость для I/O, но с более понятным императивным кодом.

Самая неожиданная проблема — валидация

Сгенерированные Response выглядят примерно так:

public class ForecastResponse {  // ...      private CurrentWeather currentWeather; // без @Valid}

И тут важный момент: Bean Validation не идёт вглубь, так как генератор не ставит аннотации@Valid на вложенных сущностях. То есть валидируется только верхний уровень.

Что с этим делать?

Я нашёл три варианта:

1. Аннотации через OpenAPI

x-field-extra-annotation: "@jakarta.validation.Valid"

Работает, но требует менять спецификацию. А хотелось бы бездумно копировать спеку.

2. TraversableResolver

Можно заставить валидатор всегда обходить всё дерево.

public class AlwaysTraversableResolver implements TraversableResolver

Но:

  • риск циклов

  • возможный удар по производительности

  • влияет глобально

Звучит как «можно, но лучше не надо».

3. Кастомные шаблоны генерации

Самый адекватный вариант:

  • переопределяем mustache-шаблоны

  • добавляем @Valid автоматически

В этот момент я задался ещё одним вопросом

Как же правильно валидировать контракт быстро? И как сделать так, чтобы мы сразу падали, если внешний API промазал мимо контракта? Те есть в поле notNull пришло null.

В такие моменты кажется, что лучший способ валидировать REST контракт – это не использовать REST и перейти на gRPC

Но gRPC не панацея.

Я подумал:

а что, если базовую валидацию делать во время SerDe?

И тут появляется Kotlin.

data class ForecastResponse(  val temperature: Double,  val windSpeed: Double?)

Идея такая: чтобы вынести логику генерации HttpClient в отдельный Spring starter. То есть этот репозиторий будет хранить openapi спеки, и openapi-generator будет генерировать Kotlin классы.

Kotlin тут нужен как первая станция защиты – проверка на null safety. По идее, конечный докер образ не должен сильно распухнуть, так как нужно в стартер добавить две зависимости (помимо HttpClient и openapi-generator):

  • org.jetbrains.kotlin:kotlin-stdlib

  • com.fasterxml.jackson.module:jackson-module-kotlin Много они не весят – примерно 5MB.

Если API внезапно вернёт null в non-null поле – мы упадём сразу на десериализации.
И это удобно:

  • ошибка максимально ранняя

  • никаких неожиданных NPE дальше

При этом важно понимать, что такое поведение зависит от конфигурации Jackson и kotlin-module, а также от того, как именно сгенерированы модели.
То есть это не абсолютная гарантия, а скорее способ добиться fail-fast поведения для части ошибок контракта, в первую очередь связанных с nullability.

А @Min, @Max и прочее можно оставить как второй слой и проверять конкретно в каждом сервисе, который будет использовать этот стартер.


Финал

Если вы уже переходите на 6 или 7 Spring, можно спокойно использовать новый декларативный HttpClient.

Если у вас пару вызовов внешнего API – проще будет вручную написать новый декларативный интерфейс. Если же внешних API вызовов много, или у вашего сервиса много RestController’ов, и вы хотите поставить client коллегам внутри Java Spring микросервисов — это хорошая возможность воспользоваться openapi-generator в связке с новым HttpClient.

Подводя итог, стек получается такой:

  • HttpСlient → декларативный клиент + возможность работы в синхронном стиле через RestClient либо же реактивно через ProjectReactor — WebClient

  • OpenAPI → генерация интерфейсов и классов

  • Bean Validation → глубоко настраеваемая валидация

и возможно рассмотреть вариант генерации Kotlin классов как защита от null (fail fast).

P.S. Я не выдаю свои решения за чистую монету – это лишь сказ о том, как я перешёл на новый HttpClient и к каким пришёл умозаключениям. Может быть, идея с Kotlin не стоит свеч, но мне кажется, что можно хотя бы попробовать это реализовать.

Если у кого-то есть свои идеи как можно сделать лучше/красивее/стильнее – приглашаю обсудить их в комментариях.


Репозиторий с примером

Спасибо за прочтение!

Спасибо за прочтение!

ссылка на оригинал статьи https://habr.com/ru/articles/1022466/