REST-клієнти в Java-застосунках. Частина друга
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу продовжити знайомити вас з такою актуальною темою, як REST-клієнти. Я вже почав розповідати про історію появи HTTP-клієнтів у Java та перше покоління REST-клієнтів, якими багато хто користується досі. У другій частині я хочу розповісти про більш сучасні технології та показати, чим вони кращі й зручніші за попередників. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про те, як написати клієнтську частину для REST API.
WebClient
Розробники Spring Framework ще з 2013 року почали освоювати нішу реактивних застосунків, створивши свій проєкт Reactor, що став конкурентом добре відомому RxJava (ReactiveX). Щоправда конкурентами їх можна назвати з натяжкою, оскільки Reactor відсотків на 90 скопіював API від RxJava, та й до того ж підтримка RxJava була в багатьох Spring-проєктах.
Мета появи Reactor стала зрозумілою у
У чому переваги 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 підтримує такі декодери:
- Gson.
- Jackson.
- Moshi.
- 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, але також підтримує:
- OKHttp.
- Ribbon.
- JDK HTTP-клієнт.
- Google HTTP-клієнт.
- 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 була випущена ще в
HTTP Interface-клієнт
Дивна річ, але розробники Spring звернули увагу на декларативні REST-клієнти, коли вже існували й добре себе зарекомендували такі технології, як-от OpenFeign та Retrofit. У Spring 6.0 у
@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 тут є дві переваги:
- Метод може повертати Optional, Mono чи Flux.
- Метод може повертати об’єкт, 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-клієнта варто подумати над тим, чи підходить вам дефолтна конфігурація, або, можливо, варто перейти на більш ефективні або багатофункціональні варіанти.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів