За последние несколько лет для вызова внешних 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
@RequiredArgsConstructor
public 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
//...
@Override
public 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 не стоит свеч, но мне кажется, что можно хотя бы попробовать это реализовать.

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


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

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