REST-клієнти в Java-застосунках. Частина друга

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Усім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу продовжити знайомити вас з такою актуальною темою, як REST-клієнти. Я вже почав розповідати про історію появи HTTP-клієнтів у Java та перше покоління REST-клієнтів, якими багато хто користується досі. У другій частині я хочу розповісти про більш сучасні технології та показати, чим вони кращі й зручніші за попередників. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про те, як написати клієнтську частину для REST API.

WebClient

Розробники Spring Framework ще з 2013 року почали освоювати нішу реактивних застосунків, створивши свій проєкт Reactor, що став конкурентом добре відомому RxJava (ReactiveX). Щоправда конкурентами їх можна назвати з натяжкою, оскільки Reactor відсотків на 90 скопіював API від RxJava, та й до того ж підтримка RxJava була в багатьох Spring-проєктах.

Мета появи Reactor стала зрозумілою у 2017-му: тоді вийшов Spring 5.0, де з’явився новий проєкт Spring WebFlux, який надавав можливість для створення реактивних вебзастосунків якраз на базі Reactor. На додачу до нього йшов і новий REST-клієнт — WebClient. До цього часу в Spring уже був асинхронний REST-клієнт — AsyncRestTemplate, заснований навіть не на CompletableFuture, а на ListenableFuture з Spring Framework, і він відразу став deprecated. RestTemplate не став deprecated, але перейшов у режим підтримки, за якої виправлятимуться лише дрібні баги, що передбачало вибір WebClient як основного REST-клієнта для Spring-застосунків. За традицією для WebClient не знайшлося окремого легковажного модуля, тому його помістили в модуль spring-webflux, обмеживши його застосування знову ж таки Spring-проєктами. Водночас користувачам RestTemplate рекомендували перейти на WebClient (а отже й WebFlux).

У чому переваги WebClient порівняно з його старшим братом RestTemplate?

  • використовує реактивний неблокуючий підхід;
  • Fluent API (та принципи функціонального програмування);
  • гнучка обробка помилок;
  • активна розробка та впровадження нових фіч.

І наш приклад з отриманням книг можна переписати як:

                
  WebClient client = WebClient.create("http://localhost:8080");
 Mono<Book> mono = client.get()
               .uri("/books/1")
               .accept(MediaType.APPLICATION_JSON)
               .retrieve()
               .bodyToMono(Book.class);
 
mono.subscribe(data -> System.out.println("Received " + data));

Такий підхід ідеально пасує для потокових операцій, де ми просто підмикаємося до джерела та чекаємо на дані. Оскільки ви працюєте з реактивними типами (Mono/Flux) з Reactor, де вже вбудована обробка помилок, то набагато простіше таку обробку реалізувати:

Mono<String> mono = client.get()
                .uri("/books/1")
                .accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .bodyToMono(Book.class)
                .map(Book::getAuthor)
                .onErrorReturn("N/A");
 
mono.subscribe(data -> System.out.println("Author " + data));

Ще одна чудова фіча, якої немає у RestTemplate і яку ви отримуєте з коробки з WebClient, — повтори запитів у разі помилок:

Mono<Book> mono = client.get().uri("/books/1").accept(MediaType.APPLICATION_JSON)
              .retrieve()
              .bodyToMono(Book.class)
              .retry(3);

Щоправда деякі операції можуть виглядати дещо складно порівняно з тим же RestTemplate, наприклад, для зміни таймауту потрібно явно створити HttpClient і його підсунути в WebClient:

HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);
WebClient webClient = WebClient.builder()
         .baseUrl("http://localhost:8080")
         .clientConnector(new
                    ReactorClientHttpConnector(httpClient)).build();

Як і RestTemplate, WebClient є високорівневим API над такими HTTP-клієнтами:

  • Apache HTTP Components;
  • JDK-клієнт;
  • Jetty-клієнт;
  • Reactor / Netty;
  • Reactor / Netty 5.

Слід зазначити, що вже для RestTemplate було створено «тестову версію» — TestRestTemplate. Щоправда,головне, чим він відрізнявся, це підтримкою basic authentication. WebClient має зручнішу версію для тестування — WebTestClient, оскільки вона містить API для перевірки відповіді від сервера (expectations):

              WebTestClient client = WebTestClient.bindToServer().baseUrl("http://localhost:8080").build();
 
              client.get().uri("/books/1").accept(MediaType.APPLICATION_JSON).exchange().expectAll(
                           spec -> spec.expectStatus().isOk(), spec -> spec.expectHeader().contentType(MediaType.APPLICATION_JSON),
                           spec -> spec.expectBody(Book.class));

Загалом WebClient є кроком уперед порівняно з RestTemplate, але його популярності заважає необхідність у серверному фреймворку (Spring WebFlux), причому з дискусії з розробниками випливає, що якихось змін чекати не доводиться.

OpenFeign

OpenFeign спочатку розроблявся компанією Netflix з 2013 року і тому мав назву Netflix Feign, хоча Netflix ніколи не розглядала його як свій офіційний проєкт. Розробники говорили, що на створення цього продукту їх надихнули такі технології, як-от Retrofit і JAX-RS. У 2016 році ситуація змінилася, і проєкт перейшов на рейки open-source, тому став називатися OpenFeign. Він реалізовував один з популярних патернів мікросервісної архітектури — декларативний REST-клієнт. Головне завдання цього патерну — позбавити розробника необхідності писати код для доступу до REST-сервісів. Єдине, що потрібно, створити контракт (інтерфейс), решта OpenFeign створить сам за допомогою JDK-проксі. Тобто якщо ви використовуєте Feign, то просто не зможете надіслати некоректний запит (на відміну від того ж RestTemplate), хіба що якщо припуститеся помилки в конфігурації. Уявімо, що вам необхідно створити REST-клієнта для сайту, який повертав би пости та коментарі користувачів. Ви пишете інтерфейс PostClient:

 public interface PostClient {
 
       @RequestLine("GET /posts")
       List<Post> findPosts();
 
       @RequestLine("GET /posts/{id}")
       Post findPostById(@Param("id") int id);
 }

Може виникнути питання: навіщо писати @RequestLine("GET /posts/{id}"), якщо простіший і зрозуміліший інший варіант — @Post("/posts/{id}")? Адже можна забути вказати HTTP-метод або помилитись у його назві. Проте наявний підхід спочатку обраний авторами бібліотеки.

Далі ви створюєте об’єкт цього інтерфейсу за допомогою класу Feign:

              PostClient client = Feign.builder().target(PostClient.class, "http://jsonplaceholder.typicode.com");
              List<Post> posts = client.findPosts();

Але якщо просто виконати такий код без додаткового налаштування, можна отримати виняток:

Exception in thread "main" feign.codec.DecodeException: java.util.List<model.Post> is not a type supported by this decoder.

За замовчуванням Feign може працювати з тими класами-відповідями, які є рядками, а якщо ви хочете працювати з Java-об’єктами, потрібно вказати декодер (або десеріалізатор). Наразі Feign підтримує такі декодери:

  1. Gson.
  2. Jackson.
  3. Moshi.
  4. SAX, JAXB, SOAP (для XML-формату).

Водночас ви можете створити свій декодер (і кодер), для цього потрібно лише реалізувати інтерфейси Encoder і Decoder. Якщо вам для серіалізації підходить Jackson, потрібно додати залежність feign-jackson і вказати його під час створення PostClient:

       PostClient client = Feign.builder()
              .decoder(new JacksonDecoder())
              .target(PostClient.class, "http://jsonplaceholder.typicode.com");

Утім ще рано впадати в ейфорію. Якщо подивитися на вихідний код JacksonDecoder, можна переконатися, що він використовує лише стандартні модулі:

public class JacksonDecoder implements Decoder {
 
  private final ObjectMapper mapper;
 
  public JacksonDecoder() {
    this(Collections.<Module>emptyList());
  }
 
  public JacksonDecoder(Iterable<Module> modules) {
    this(new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        .registerModules(modules));
  }
 
  public JacksonDecoder(ObjectMapper mapper) {
    this.mapper = mapper;
  }

Тобто навіть якщо у вас є classpath-підтримка Jackson для Java 8, вона не буде залучена. Доведеться це робити вручну:

final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
      
PostClient client = Feign.builder()
              .decoder(new JacksonDecoder(objectMapper))
              .target(PostClient.class, "http://jsonplaceholder.typicode.com");

Але як Feign надсилає HTTP-запити? За замовчуванням він використовує знайомий HTTPUrlConnection, але також підтримує:

  1. OKHttp.
  2. Ribbon.
  3. JDK HTTP-клієнт.
  4. Google HTTP-клієнт.
  5. Apache HTTP Components.

А якщо ви використовуєте Ribbon, отримуєте не лише load balancer, а й безкоштовну інтеграцію з Eureka. І якщо вам не підходить дефолтний HTTP-клієнт, можна, наприклад, додати залежність feign-java11 і вказати JDK HTTP-клієнт під час створення PostClient:

              PostClient client = Feign.builder()
                           .decoder(new JacksonDecoder())
                           .client(new Http2Client())
                           .target(PostClient.class, "http://jsonplaceholder.typicode.com");

Так, Feign можна назвати медіатором, який спритно поєднує можливості HTTP-клієнта та бібліотеки серіалізації. Автори цієї бібліотеки ухвалили дуже вдале рішення, відокремивши API (контракт) від декодера та HTTP-клієнта, щоб ви могли в будь-який час змінити останні два. Щоправда в деяких випадках API та декодер все ж тісно пов’язані. Наприклад, ви можете захотіти, щоб ваш API повертав Optional:

       @RequestLine("GET /posts/{id}")
       Optional<Post> findPostById(@Param("id") int id);

Такий API підтримує Spring MVC та Spring Data. Тут же, якщо у вас декодер Gson, то ви отримаєте помилку:

Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.lang.Object java.util.Optional.value accessible:

Дивно, але через девʼять років після релізу Java 8 Gson усе ще її повністю не підтримує. Якщо ви спробуєте перейти на Jackson-декодер, отримаєте вже іншу помилку:

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.util.Optional` Додаткова зручність Feign: у його модульній структурі ядро містить лише базові можливості, роблячи Feign вкрай легким (216 кб), а решту – у модулях-розширеннях. Також Feign містить використання Basic-аутентифікації та такого патерну, як Retry. Якщо ваш сервер поверне помилку, яка транслюється в IOException, Feign зробить максимум пʼять повторних спроб надіслати запит з інтервалом 100 мс. Зрозуміло, ці значення можна за бажання змінити.

Використовуючи Feign, ви спрощує конфігурацію для ваших тестів. Якби у вашому проєкті використовувався RestTemplate, наприклад, потрібно було б знати, які його методи застосовуються в тому чи іншому місці. Тут же споживачі можуть використовувати тільки той API, який оголошений у REST-клієнта, і замінити його буде неважко:

public class PostClientTest {
      
       @Mock
       PostClient postClient;

Ще одна перевага Feign — створені клієнти (інтерфейси) є контрактом, і ви можете під час оголошення ваших REST-сервісів реалізувати в них ці інтерфейси:

@RequiredArgsConstructor
public class PostController implements PostClient {
      
       private final PostService postService;
 
       @Override
       public List<Post> findPosts() {
              return postService.findAll();
       }
 
       @Override
       public Post findPostById(int id) {
              return postService.findById(id);
       }

Таким чином і ваш сервіс, і ваш клієнт будуть одним цілим. Ви не можете змінити одне без іншого, а це означає, що не може бути такого, що ви забудете продати якусь операцію у вашому сервісі. Раніше доводилося описувати сервіси REST за допомогою Swagger (OpenAPI), а потім на базі OpenAPI специфікації генерувати REST-клієнта. Єдине що: не можна гарантувати, що mapping для шляхів у клієнті та сервері буде той самий.

Щоправда не завжди так можна діяти. Наприклад, вам потрібно отримати від сервера і тіло, і заголовки, тоді вам доведеться повертати як об’єкт Response з OpenFeign:

                Response create(CreateProductDTO productDTO);

Зрозуміло, що цей тип не може бути повернутий з вашого REST-сервісу на серверній частині. До речі, Response — це аналог ResponseEntity із Spring MVC, але, на жаль, не типізований, що не дозволяє вказати тип значення, що повертається. До речі, з Response пов’язана така цікава тема, як-от обробка помилок. Як відреагує Feign клієнт під час виконання цього методу:

                void create(CreateProductDTO productDTO);

Якщо сервер недоступний, буде викинуто RetryableException, якщо сервер повернув помилку зі статус-кодом >= 400, то буде викинуто відповідний виняток. Але якщо метод повертає Response:

                Response create(CreateProductDTO productDTO);

То вже ніяких винятків не викидається, тут клопіт — програміста перевірити статус-код та заголовки.

А якщо вам потрібно для всіх (або деяких) запитів надіслати один і той самий заголовок, наприклад, Accept? Саме для цього існує анотація @Headers:

@Headers("Accept: application/json")
public interface PostClient {

Але іноді заголовки будуть динамічними та відрізнятимуться від запиту до запиту, тоді їх можна вказувати анотацією @Param:

       @Headers("X-API-KEY: {apiKey}")
       @RequestLine("POST /posts")
       void save(@Param("apiKey") String apiKey, Post post);

Анотація @Headers може стати життєво важливою, коли ви надсилаєте дані на сервер:

@RequestLine("POST /products")
@Headers("Content-Type: application/json")
Response create(CreatePrductDTO productDTO);

На жаль, Feign-клієнт не надсилає заголовок Content-Type на сервер, якщо у вас є тіло запиту (як це роблять деякі інші клієнти REST). Вказати, що ми завжди відправляємо JSON, теж не можна. Точніше, можна додати @Headers для всього інтерфейсу, але тоді він надсилатиме цей заголовок і для GET/DELETE-запитів, що буде неправильним.

Дуже важливий момент: чи Feign є потоково-безпечним? Як пояснюють автори, точної відповіді на це питання немає, тому що це залежить від того, чи є потоково-безпечними HTTP-клієнти та декодери.

Retrofit

Retrofit — це декларативний REST від компанії Square, яка відома своїм HTTP-клієнтом OkHttp та JSON-серіалізатором Moshi. Створений у 2012 році, він став дуже популярним, особливо для Android-розробників:

public interface BookClient {
 
       @GET("books")
       Call<List<Book>> findAll();
      
       @GET("books/{id}")
       Call<Book> findById(@Path("id") int id);
}

Тут, на відміну від OpenFeign, використовуються спеціалізовані анотації @GET, @POST і т. д., а значить, шансів зробити помилку менше.

Retrofit підтримує одразу шість бібліотек для серіалізації:

  • Gson;
  • Jackson;
  • Moshi;
  • Protobuf;
  • Wire;
  • Simple XML;
  • JAXB.

Причому потрібно вказувати серіалізатор під час створення клієнта, сам його Retrofit знаходити не вміє:

       Retrofit retrofit = new Retrofit.Builder()
              .baseUrl("http://localhost:8080")
              .addConverterFactory(GsonConverterFactory.create())
              .build();
 
       BookClient bookClient = retrofit.create(BookClient.class);
       Call<Book> call = bookClient.findById(1);
       Book book = call.execute().body();

Усі типи, що повертаються, обертаються в спеціальний контейнер Call. Це досить зручно, тому що дозволяє один раз створити запит, а потім його багаторазово виконувати. Крім того, саме під час роботи з Call ви вирішуєте, чи виконуватимете ви запит синхронно (call.execute) або асинхронно (call.enqueue).

Об’єднує OpenFeign та Retrofit те, що декларативний клієнт виходить не параметризованим, і ви можете написати один такий клієнт для всього застосунку. Було б зручніше, якби кожен такий клієнт створювався для окремої сутності (як Spring Data репозиторії), тут жодних обмежень немає.

Загалом Retrofit виглядає досить зручним для використання, а його мінус — остання версія 2.9 була випущена ще в 2020-му, і хоч розробники обіцяють новий реліз, Changelog цього не поповнюється. Ще одна особливість, яка може вас відштовхнути, — Retrofit підтримує лише OkHttp як HTTP-клієнт та підтримка якихось інших технологій не планується.

HTTP Interface-клієнт

Дивна річ, але розробники Spring звернули увагу на декларативні REST-клієнти, коли вже існували й добре себе зарекомендували такі технології, як-от OpenFeign та Retrofit. У Spring 6.0 у 2022-му з’явився HTTP Interface-клієнт — надбудова над WebClient, яка дозволяє писати контракт для вашого серверного API за допомогою Spring MVC-анотацій:

@HttpExchange("/books")
public interface BookClient {
 
       @GetExchange
       List<Book> findAll();
 
       @GetExchange("{id}")
       Optional<Book> findById(@PathVariable int id);

Тут @HttpExchange — це аналог @RequestMapping для серверного Spring MVC, а @GetExchange — @GetMapping. А код для інтеграції HTTP Interface та WebClient виглядає так:

WebClient client = WebClient.builder().baseUrl("http://localhost:8080/").build();
WebClientAdapter adapter = WebClientAdapter.create(client);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
 
BookClient bookClient = factory.createClient(BookClient.class);
List<Book> books = bookClient.findAll();

Цей код зчитує всю конфігурацію з BookClient та застосованих анотацій та створює адаптер, який передає лічені дані у WebClient. Порівняно з OpenFeign тут є дві переваги:

  1. Метод може повертати Optional, Mono чи Flux.
  2. Метод може повертати об’єкт, ResponseEntity або тільки заголовки (HttpHeaders).

Відносний мінус: не підтримується декларативне оголошення заголовків запиту (Feign, Retrofit), їх необхідно вказувати як аргументи методу:

       @PostExchange
       ResponseEntity<Book> save(@RequestBody Book book,
                     @RequestHeader Map<String, String> headers);

Також чудово було б використовувати кешування для таких методів:

       @GetExchange
       @Cacheable
       List<Book> findAll();

Поява нового клієнта залишила байдужим лише тих розробників, які використовують Spring MVC, тому що в них був лише старий-добрий RestTemplate. Але в Spring 6.1 з’явилася можливість використовувати його разом з HTTP Interface-клієнтом:

              RestTemplate restTemplate = new RestTemplate();
              restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory("http://localhost:8080"));
              RestTemplateAdapter adapter = RestTemplateAdapter.create(restTemplate);
              HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
 
              BookClient bookClient = factory.createClient(BookClient.class);
              List<Book> books = bookClient.findAll();

Так, HTTP Interface-клієнт став доступним для всіх, хто розробляє вебзастосунки (мікросервіси) на Spring Framework.

RestClient

Іноді складається враження, що останніми роками Spring-розробники змагаються між собою, сперечаючись, хто більше створить різних REST-клієнтів. Не встигли ми звикнути до WebClient у Spring 5.0, як у Spring 6.0 додали HTTP Interface-клієнт, а у 6.1 — новий RestClient. Фактично це той самий WebClient, який зробили синхронним (таким, що блокує) і додали нові можливості (наприклад, підтримка Micrometer Observation):

              RestClient client = RestClient.builder()
                             .baseUrl("http://localhost:8080")
                             .build();
             
              Book result = client.get()
                             .uri("books/1")
                             .accept(MediaType.APPLICATION_JSON)
                             .retrieve()
                             .body(Book.class);

З новим fluent API набагато простіше та зрозуміліше стало обробляти відповіді від сервера та помилки:

              Book result = client.get()
                             .uri("books/1")
                             .accept(MediaType.APPLICATION_JSON)
                             .retrieve()
                             .onStatus(code -> true, (request, response) -> log.info(request.toString()))
                             .body(Book.class);

Ще одне покращення — за замовчуванням RestClient використовує не HttpURLConnection (як RestTemplate), а JDK HTTP-клієнт.

Загалом RestClient — адекватна заміна для RestTemplate, і ті, хто останніми роками переходили на WebClient, тепер можуть перейти на RestClient, якщо їм підходить синхронний API.

Висновки

У цій статті я розібрав як HTTP-клієнтів усередині JDK, так і найбільш популярні REST-клієнти для Java-застосунків. Тут і технології, прив’язані до конкретної платформи (Spring, Jakarta EE), легковагі (Unirest) та окремо декларативні клієнти (OpenFeign, Retrofit, HTTP Interface-клієнт).

Приємно, що останніми роками в цій ніші з’явилися нові продукти (WebClient, RestClient), які спрощують створення REST-запитів та дозволяють писати більш зрозумілий код. Водночас не варто забувати, що REST-клієнт — це лише API та pre-processing. За відправлення запитів відповідає окремий HTTP-клієнт, за серіалізацію даних — окремо бібліотека серіалізації. Тому після вибору REST-клієнта варто подумати над тим, чи підходить вам дефолтна конфігурація, або, можливо, варто перейти на більш ефективні або багатофункціональні варіанти.

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

прикольні ці дві статті. Відкрив для себе декілька нових клієнтів насправді :D Дякую автору :)

Може виникнути питання: навіщо писати @RequestLine("GET /posts/{id}"), якщо простіший і зрозуміліший інший варіант — @Post("/posts/{id}")?

Мабуть, тому що було б вже @Get("/posts/{id}") тоді?

Дякую. Хороша шпаргалка (хоч я і не дев :))

Підписатись на коментарі