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 стала зрозумілою у
У чому переваги 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 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів