REST-клієнти в Java-застосунках
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу в цій серії статей поділитися з вами досвідом роботи з такою актуальною темою, як REST-клієнти. Я вже писав про REST API, як реалізовувати server-side pagination, як обробляти помилки, які конвенції є в REST API, а також про використання DTO. Але це все стосувалося серверної частини. Тут же я хочу, по-перше, узагальнити та систематизувати всю інформацію станом на 2023 рік, по-друге, порівняти всі найбільш популярні технології в сегменті і розповісти про останні тенденції в цій сфері. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про те, як написати клієнтську частину для REST API.
Vanilla Java
Що таке клієнт REST і чим він відрізняється від HTTP-клієнта? Я не повторюватиму тут принципи REST API та побудови RESTful вебсервісів, вони всім добре відомі. REST клієнт повинен дозволяти (і допомагати) розробнику простим зрозумілим способом писати код для надсилання REST-запитів на вебсервер. Так, REST-клієнт можна композиційно представити як об’єднання якогось зручного API, транспортного HTTP-клієнта (JDK або зовнішнього) і serializer / deserializer (теж зовнішнього), який перетворюватиме отриманий текст у потрібний формат даних і навпаки. А ще — можливі розширення функціональності, але у будь-якому випадку все починається з HTTP-клієнта.
Почнемо знайомство з найпростішими HTTP-клієнтами з часів Java 1.0. Java ніколи не страждала компактністю і наприклад, в ній не можна отримати вміст Інтернет-ресурсу одним рядком, як у Groovy:
String content = "http://www.google.com".toURL().getText();
Замість цього пропонувалося більш громіздке рішення на основі HttpURLConnection:
public static String getText(String url) throws Exception {URL website = URI.create(url).toURL();
URLConnection connection = website.openConnection();
try (BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {StringBuilder response = new StringBuilder();
String inputLine;
while ((inputLine = in.readLine()) != null) {response.append(inputLine);
}
return response.toString();
}
}
Крім його розміру, такий підхід має й інші недоліки:
- Мануальна обробка помилок та читання даних із сервера.
- Синхронний блокуючий характер взаємодії із сервером.
- Відсутність підтримки пулу потоків.
- Відсутність підтримки нових протоколів (HTTP/2+).
- Підтримка протоколів, які більше не використовуються (FTP, Gopher та інші).
Зрозуміло, мало хто писав такий код з нуля, а просто використав готову бібліотеку Commons IO або Google Guava. З іншого боку, додавати цілу бібліотеку у свій проєкт заради одного методу — не найоптимальніше рішення. Ще більш монстроїдальний код отримав для POST/PUT-запитів:
public static String sendText(String url, String data) throws Exception {URL website = URI.create(url).toURL();
URLConnection conn = website.openConnection();
conn.setDoOutput(true);
StringBuilder response = new StringBuilder();
try (OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream())) {writer.write(data);
writer.flush();
String line;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { while ((line = reader.readLine()) != null) {response.append(line);
}
}
return response.toString();
}
}
Фактично таке рішення — це низькорівневий HTTP-клієнт, який мало підходить для формування REST-запитів. Ситуація дуже довго лишалася на місці, поки в JDK 8/9 не з’явився новий API для роботи з I/O, і вище код можна було переписати як:
URL url = new URL("http://localhost:8080/books");try (InputStream in = url.openStream()) {return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
Однак він був придатним для найпростіших випадків, а в більш складних доводилося, знову ж таки, використовувати HttpURLConnection. Усе змінилося, коли в Java 11 з’явився довгоочікуваний і повноцінний HTTP-клієнт.
JDK HTTP-клієнт
Коли в 2015 році вийшла специфікація HTTP/2, яку почали жваво підхоплювати браузери, стало зрозуміло, що вдосконалювати та покращувати поточний HTTP-клієнт на базі HttpURLConnection, який створили ще в
- Підтримка протоколів HTTP/2 та WebSocket, а також server push.
- Використання і синхронного, і асинхронного підходу.
- Підтримка сучасних механізмів автентифікації.
- Ефективність на рівні конкурентів (Apache HTTP Components, Netty/Jetty-клієнт).
Зрештою після численних переробок він увійшов як стабільна фіча в JDK 11, і зразковий код для відправки GET-запиту виглядає так:
HttpRequest request = HttpRequest.newBuilder()
.GET()
.uri(URI.create("http://localhost:8080/books")).build();
HttpResponse<String> response = HttpClient
.newBuilder()
.build()
.send(request, HttpResponse.BodyHandlers.ofString());
Ще одна відмінність від попереднього клієнта — наявність абстракцій, як-от HttpClient, HttpRequest або HttpResponse, які дозволяють писати простіший і зрозуміліший код. У цьому прикладі використовується блокуюча відправка запиту, але всередині HTTP-клієнта запити завжди надсилаються асинхронно, а сама реалізація повністю заснована на CompletableFuture. Більше того, HttpClient містить справжнісінький connection cache, заснований на пулі потоків. Так, якщо ви надсилаєте запити на той самий сервер, немає необхідності щоразу відкривати нове з’єднання. Зрозуміло, ваше з’єднання не триматимуть вічно, воно закривається, якщо довго не використовується (за замовчуванням 20 хвилин).
Утім, усі ці плюшки недоступні користувачам ранніх версій Android, де підтримується лише Java 8, і вони традиційно використовували OkHttp та Apache HTTP Components. Усі інші цілком можуть використовувати JDK HTTP-клієнт, але оскільки в Java досі немає натяку на API для серіалізації даних, доводиться додатково використовувати бібліотеку для серіалізації, а швидше за все повноцінний REST-клієнт, де JDK HTTP-клієнт буде використовуватися для надсилання та отримання даних.
RestTemplate
RestTemplate з’явився у версії Spring 3.0 у 2009 році, коли туди додали підтримку REST-сервісів і відчайдушно потрібен був свій REST-клієнт на заміну не дуже зручних HTTP-клієнтів. Тому з’явився такий клас як RestTemplate, який міг не лише надіслати HTTP-запит, а й серіалізувати відповідь. По суті, він був першим REST-клієнтом у Java-світі.
Оскільки RestTempalte — це потоково-безпечний тип, то ви могли створити його на рівні класу і навіть глобально для всієї програми:
private final RestTemplate restTemplate = new RestTemplate();
а потім використовувати його API, наприклад, для GET запитів були два методи: getForObject та getForEntity:
public ResponseEntity<Book> findBook(int id) { return restTemplate.getForEntity("http://localhost:8080/book/{id}", Book.class, id);}
Різниця їхня в тому, що один повертав клас-ресурс, а інший — ResponseEntity. Для роботи з HTTP RestTemplate зараз підтримує:
- JDK-клієнт;
- Apache HTTP Components;
- Netty;
- OKHttp;
- Vanilla Java.
Останній варіант вибирається за замовчуванням. Але як він виробляє десеріалізацію? Усе завдяки такій інтегрованій абстракції як HttpMessageConverter. Під час ініціалізації RestTemplate перевіряє наявність у classpath відомих йому бібліотек серіалізації:
- JAXB/JSOB-B;
- Jackson;
- Gson.
І якщо вони є, то для десеріалізації використовує перший конвертор, який вміє працювати з цими форматом і типом даних. Аналогічно відбувається й із серіалізацією:
restTemplate.put("http://localhost:8080/book/" + book.getId(), request);Дуже важливо те, що RestTemplate автоматично відправляє заголовок Content-Type (за замовчуванням application/json), якщо є запит і це об’єкт. За початкового використання RestTemplate можна потрапити в пастку, коли ви хочете отримати від сервера список об’єктів і пишете приблизно такий код:
public List<Book> findBooks() { return restTemplate.getForObject("http://localhost:8080/books", List.class);Такий код скомпілюється і запуститься, але водночас цьому RestTemplate поверне вам не List<Book>, як можна було подумати, а List<Map>, і звичайно ви отримаєте ClassCastException. Тому правильніше буде використовувати не getForObject, а метод exchange, який дозволяє працювати з generic-колекціями:
return restTemplate.exchange("http://localhost:8080/books", HttpMethod.GET, null, new ParameterizedTypeReference<List<Book>>() { });Що з обробкою помилок? Тут за будь-якої помилки від сервера (connection refused, 4xx, 5xx) завжди викидається виняток — або ResourceAccessException чи одне зі спадкоємців RestClientResponseException, навіть якщо ви повертаєте ResponseEntity і за бажання могли отримати інформацію звідти. Тому вам доводиться вибирати один з трьох варіантів:
- Обертати всі виклики RestTemplate у
try-catch. - Створити об’єкт
ResponseErrorHandlerі підсунути в RestTemplate. - Додати глобальний
@ControllerAdvice.
RestTemplate підтримує Basic Authentication> з коробки, але завдяки такій абстракції як ClientHttpRequestInterceptor можна написати свій код, який буде реалізовувати інші типи аутентифікації.
Які ж загалом недоліки у RestTemplate:
- Використовує блокуючий (синхронний) підхід.
- Не підтримує streaming.
- Використовує перевантаження методів (overloading) для вказівки параметрів, а не більш гнучкий та сучасний варіант builder.
- Не пропонує вам якогось контракту, тобто ви можете надіслати запит на будь-який URL і спробувати отримати будь-який тип у відповіді, що збільшує ймовірність помилки.
- Перебуває в тому модулі, як і код підтримки серверного web.
Останній недолік є найбільш суттєвим. Якщо ви хочете використовувати RestTemplate у звичайному застосунку, то разом з ним отримаєте модуль spring-web і всі інші залежності з Spring Core. Тобто є сенс використовувати його для застосунків, де вже є Spring MVC. Була пропозиція розділити spring-web на клієнтську та серверну частину, але вона не була ухвалена. Усе це призвело до того, що в Spring 5.0 RestTemplate був переведений в режим підтримки (maintenance), і нових фітч очікувати в ньому не доводиться.
Unirest
У 2012 році компанія Kong, яка відома своїм однойменним API Gateway, вирішила випустити ще один продукт, цього разу REST-клієнт, який вона назвала Unitrest. У ньому вона запропонувала простий зрозумілий API та досить багато можливостей:
Book book = Unirest.get("http://localhost:8080/books/1").asObject(Book.class)
.getBody();
До плюсів можна віднести невеличкий розмір — всього 200 кілобайт і вбудовану підтримку таких серіалізаторів як-от Jackson і Gson. До відносних мінусів можна зарахувати використання тільки одного HTTP-клієнта від JDK, тому мінімальна версія для запуску Unitrest-JDK 11. Але в Unirest є унікальні можливості, яким він і залучив своїх шанувальників.
Насамперед це вбудований MockClient, який дуже легко налаштувати у тестах:
MockClient client = MockClient.register();
client.expect(HttpMethod.GET)
.thenReturn(MockResponse.of(500, "internal error"));
Потім підтримка server-side pagination, причому це єдина технологія з усіх розглянутих, яка підтримує це на рівні API:
PagedList<Book> list = Unirest.get("http://localhost:8080/books").asPaged(
r -> r.asObject(Book.class),
r -> r.getHeaders().getFirst("next"));
Підтримка кешування відповіді:
Unirest.config().cacheResponses(builder()
.maxAge(30, TimeUnit.MINUTES));
За промовчанням дані зберігаються в LinkedHashMap, але за бажанням можна адаптувати будь-який з наявних кеш-провайдерів (Caffeine, EhCAche).
Ну і нарешті підтримка різноманітних interceptors, які дозволяють отримати будь-яку інформацію, наприклад, нотифікацію про початок відправки запиту або помилки під час його виконання. Усі ці фічі роблять Unirest цілком привабливим для професійного використання.
JAX-RS клієнт
З Enterprise Java (Java EE/Jakarta EE) ситуація заплутаніша. Довгий час тут взагалі не було свого клієнта REST. І тільки коли в 2009 році в Spring MVC з’явився RestTemplate, справа зрушила з мертвої точки. На жаль, у JAX-RS 1.0, який з’явився того ж року і став частиною Java EE 6, не було додано свого клієнта REST. Тому вендори почали розробляти свої реалізації, наприклад, у Jersey з’явився свій тип Client, робота з яким виглядала так:
Client client = Client.create();
WebResource resource = client.resource("http://localhost:8080/books");ClientResponse response = resource.accept(MediaType.APPLICATION_JSON).get(ClientResponse.class);
if (response.getStatus() == 200) {Book book = response.getEntity(Book.class);
Вона використовувала роботу з HTTP з Vanilla Java і мала досить мізерні можливості серіалізації. Тим часом у 2013 році було випущено Java EE 7, до якого увійшов JAX-RS 2.0, куди вже додали нову специфікацію — клієнт для REST-сервісів. Якщо взяти того ж оновленого JerseyClient, він мав більш потужні можливості, наприклад, підтримував як транспортний протокол:
- Grizzly.
- Apache HTTP Components.
- Helidon HTTP-клієнт.
- Jetty HTTP-клієнт.
- Netty HTTP-клієнт.
- JDK NIO-клієнт.
Утім, за умовчанням використовується та сама Vanilla Java. Сама бібліотечка jersey-client досить невелика (300 КБ), але на додачу до неї йде jersey-common, яка займає 1,2 МБ, тож легковажним такий клієнт не назвеш. І це не враховуючи бібліотеки з серіалізації даних (Jackson, JAXB).
Сам API повністю змінився, якщо порівнювати його з першою версією:
try (Client client = ClientBuilder.newClient()) { WebTarget target = client.target("http://localhost:8080/books/1");Book book = target.request().accept(MediaType.APPLICATION_JSON).get(Book.class);
System.out.println(book.getId());
} catch (Exception e) {Утім його можна назвати кроком уперед порівняно з RestTemplate, оскільки він:
- Підтримує builder-style.
- Підтримує асинхронну роботу.
Тепер можна було асинхронно надсилати запити та отримувати вже Future, а не просто подання ресурсу:
Future<Book> book = target.request().accept(MediaType.APPLICATION_JSON)
.async().get(Book.class);
Щодо обробки помилок, тут використовується популярний підхід:
- у разі недоступності сервера викидається
IOException. - Якщо сервер повернув 4xx/5xx і клієнт запросив подання ресурсу, викидається вимкнення. А якщо клієнт повертає тип
Response, він містить усю необхідну інформацію (статус-код, заголовки і тіло запиту).
З відносних мінусів — відсутність такої концепції як ErrorHandler, куди можна було б перенести всю обробку помилок.
Висновки
У попередній статті я розповів про те, як з’явилися перше покоління REST-клієнтів і що передувало цьому. Java пройшла довгий шлях, перш ніж в HttpUrl-Connection.
Розробникам Spring Framework і тут вдалося виявити ініціативу та стати першими, хто створив повноцінний REST-клієнт — RestTemplate. Однак він був жорстко прив’язаний до Spring Framework, тому не став універсальним рішенням, таким як Unirest. Для Enterprise Java у специфікації JAX-RS 2.0 з’явився свій REST-клієнт, реалізований більшістю вендорів (Jersey, RestEasy, Apache CXF). У наступних статтях я продовжу свою розповідь про REST-клієнтів для Java-застосунків.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
18 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів