Як використовувати Hypermedia у Java-застосунках
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу в цій статті поділитися з вами досвідом роботи з такою актуальною темою, як Hypermedia та її застосування в Java-застосунках. Я вже писав про REST API, як реалізовувати server-side pagination, як обробляти помилки, які конвенції є в REST API, про використання DTO, REST-клієнти (частина перша і частина друга).
Тепер хочу докладно розповісти про еволюційний розвиток REST API та застосування Hypermedia. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про те, як використовувати HATEOAS і HAL у проєктах.
HATEOAS і HAL
Уявімо, що ваша команда розробляє мікросервісний вебзастосунок (інтернет-магазин) на основі REST API, де зокрема є сервіс замовлень та клієнтська частина. Тут показується інформація про замовлення та можливі дії над ними. Можна помітити дві речі:
- Немає кнопки «Скасувати замовлення».
- Для завершених замовлень усе ще доступна кнопка оплати.
Очевидно, ці недоліки можна усунути в клієнтській частині, але водночас з’являються дві складності:
- Логіку можливості тієї чи іншої дії над замовленнями потрібно реалізувати як на клієнті, так і на сервері (а отже й синхронізувати їх).
- Клієнт повинен знати про те, як і коли який REST API використовувати. Це можна усунути шляхом додавання Open API (Swagger), створення специфікації і потім REST-клієнта. Але все це є значною роботою.
Є і простіше рішення, яке називається HATEOAS (Hypermedia as the engine of application state). Незважаючи на хитромудру назву, розшифровується вона досить просто. Клієнт отримує від REST-сервісу не тільки інформацію про ресурсі (подання), а й те, які події із цим ресурсом (і навіть дочірніми ресурсами) можливі та яким способом. Більше того, клієнт не має права виконувати будь-які інші операції. HATEOAS є обмеженням (constraints) у рамках REST API і було запропоновано Роєм Філдінгом у 2000 році. А в так званій REST API Maturity Model (моделі зрілості) саме останній, третій рівень передбачає застосування Hypermedia. Звучить привабливо і загалом технічно реалізовується, але відразу постає питання: а який буде формат такого повідомлення? Адже його мають розуміти і клієнт, і сервер. Питання залишалося відкритим до 2011 року, коли з’явилася специфікація для стандарту HAL — Hypermedia Application Language. Зараз доступна вже 11 версія цієї специфікації, але загальна концепція залишилася незмінною. HAL декларує структуру документа, який повертається сервером клієнту, причому це може бути і JSON, і XML, але найпопулярніший варіант це все-таки JSON. У цьому випадку документ отримує новий content-type application/hal+json для того, щоб клієнт зміг розпізнати його (HAL-документ). Тобто якщо сервер повертає документ формату application/json, клієнт працюватиме з ним як з набором даних. Якщо сервер повернув тип application/hal+json, клієнт візьме з нього і дані, і додаткову метаінформацію. Клієнт може в заголовку запиту Accept вказати, чи він підтримує HAL чи ні. А сервер згенерує свою відповідь, базуючись на значенні цього заголовка. Це називається Content negotiation і також входить у REST API Maturity Model Level 3. Відповідно до стандарту HAL-документ може містити три види даних:
- Resource object (тобто уявлення ресурсу з його властивостями).
- Link Objects (посилання на ті операції, які доступні клієнту для цього ресурсу).
- Дочірні (child або embedded) ресурси.
Водночас жорстко регламентується те, що посилання повинні зберігатися у спеціальному власності _links
, а вкладені ресурси (якщо вони є) — у спеціальній властивості _embedded
. Наприклад візьмемо відповідь сервера на запит GET /products/1
:
{
"_links": {
"self": { "href": "/products/1" },
"purchase": { "href": "/orders" }
},
"category": "Drinks",
"name": "Apple juice",
"price": 10.0
}
Відповідь містить інформацію про продукти й об’єкт _links, властивості якого називаються relations чи relation types (скорочено rel). Кожен relation ототожнюється з дозволеною операцією та обов’язково містить атрибут href
— той URL, на який потрібно надіслати запит, щоб цю операцію виконати. Як клієнт зрозуміє, що означає relation purchase та з якою операцією його асоціювати? Список можливих назв для relations стандартизований і, наприклад, для пошуку створений relation search, а для посилання на самого себе — relation self.
Тут може виникнути два питання:
- Як клієнт дізнається про те, який метод HTTP використовувати?
- Як клієнт дізнається про те, якою буде URL-адреса для цього запиту?
І справді, варто подивитися на документацію Paypal API, яка підтримує HATEOAS (але не HAL), тобто HTTP-метод:
{
"links": [
{
"href": "https://api-m.paypal.com/v1/payments/sale/36C38912MN9658832",
"rel": "self",
"method": "GET"
},
{
"href": "https://api-m.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
"rel": "refund",
"method": "POST"
}
]
}
У HAL такого немає, оскільки вважається, що метод HTTP залежить від семантики relation. Наприклад, для self — GET, для cancel — DELETE і т. д. Але як бути з relation activate, наприклад? І тут дуже допоможуть так звані curies. Це спеціальна властивість в _links
, яка містить посилання на документацію щодо цього relation. Наприклад, ми б для нашого relation purchase могли б переписати відповідь від сервера як:
{
"_links": {
"self": { "href": "/products/1" },
"curies": [{
"name": "doc",
"href": "https://sample.com/relations/{rel}",
"templated": true
}],
"doc:purchase": { "href": "/orders" }
},
"category": "Drinks",
"name": "Apple juice",
"price": 10.0
}
Зверніть увагу на властивість templated
і placeholder {rel}
, тобто посилання є темплейтом, куди можна підставити будь-який підтримуваний relations. У curies є інші корисні застосування. Уявимо, що у вас є кілька версій API, але деякі з них ви не хочете підтримувати. Як про це повідомити клієнта, щоб він скоріше перейшов на нову версію? Для цього в curies вказуються всі підтримувані версії:
{
"_links": {
"self": { "href": "/products/1" },
"curies": {
"name": "v1",
"href": "https://sample.com/relations/v1/{rel}",
"templated": true
},{
"name": "v2",
"href": "https://sample.com/relations/v2/{rel}",
"templated": true
}],
"v1:purchase": { "href": "/orders",
"deprecation": "https://sample.com/v1-deprecation"},
"v2:purchase": { "href": "/orders" }
},
"category": "Drinks",
"name": "Apple juice",
"price": 10.0
}
А в relation v1-purchase вказується властивість deprecation з посиланням на відповідну документацію. Тепер повернемось до другого питання. Як же клієнт дізнається адресу API для конкретного товару? У цьому то й суть HATEOAS, що вона, на відміну від стандартного API REST, ще й надає таку можливість як API discoverability. Тобто коли ви спочатку запитуєте товари GET /products, вам повертаються не тільки дані про товари, а й посилання на ті операції, які для них підтримуються:
{
"_links": {
"self": { "href": "/products" },
"next": { "href": "/products?page=2" }
},
"_embedded": {
"products": [{
"_links": {
"self": { "href": "/products/1" },
"purchase": { "href": "/orders" }
},
"category": "Drinks",
"name": "Apple juice",
"price": 10.0
}
}]
}
}
У цьому випадку можливі операції — це посторінковий висновок (за допомогою параметра запиту page). Зверніть увагу на два факти:
_links
вказано як для списку товарів, так і для конкретного товару.- Самі товари вказані у спеціальній властивості
_embedded
.
Найпростіша технологія, побудована на Hypermedia / HATEOAS — браузери, а ось у Java-світі підтримка HATEOAS закладена у JAX-RS та Spring HATEOAS.
Використовуємо Spring HATEOAS
Spring HATEOAS з’явився в 2012 році і згодом став досить популярним (як і HATEOAS). Наприклад, такі проєкти, як-от Spring Boot Actuator та Spring Data REST, засновані на HATEOAS у тому, що стосується взаємодії з клієнтом.
Додати новий стартер досить просто:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
<version>${spring.boot.version}</version>
</dependency>
Якщо ви давно використовуєте Spring / Spring Boot, то можете очікувати, що відповіді від сервера магічним чином відразу зміняться. В цьому випадку це не так, і необхідно явно вказати, що ви використовуєте Hypermedia / HATEOAS.
Зараз у нас OrderController
повертає список OrderDTO
:
@RestController
@RequestMapping
public class OrderController implements OrderFacade {
@GetMapping(path = "users/orders")
public List<OrderDTO> findForCurrentUser(HttpServletRequest req) {
Нам необхідно переробити його так, щоб він повертав також DTO, але вже не звичайний JSON, а спеціального типу application/hal+json з усіма посиланнями (relations). Для цього є одразу три варіанти. Перший варіант — поміняти наш OrderDTO
і успадкувати його від спеціального класу RepresentationModel
зі spring-hateoas
:
@Getter
@Setter
public class OrderDTO extends RepresentationModel<OrderDTO> {
private int id;
private String state;
private LocalDateTime dueDate;
private LocalDateTime createdAt;
private double amount;
}
RepresentationModel — дуже простий базовий клас, де фактично є лише список посилань:
public class RepresentationModel<T extends RepresentationModel<? extends T>> {
private final List<Link> links;
Такий підхід дуже зручний тим, що нам нічого не потрібно змінювати в сервісах-споживачах нашого API, вони, як і раніше, використовують OrderDTO, тільки в розширеному варіанті. Мінус цього підходу в тому, що наші DTO знаходяться в окремій клієнтській бібліотеці, і всі сервіси підтягнути як залежність не тільки spring-hateoas, але і весь Spring Framework.
Якщо ви не бажаєте змінювати ваші DTO, то вам може підійти клас EntityModel:
public class EntityModel<T> extends RepresentationModel<EntityModel<T>> {
private T content;
По суті це клас-обгортка для ваших DTO. І ваш сервіс повертає вже список EntityModel
:
@GetMapping(path = "users/orders")
public List<EntityModel<OrderDTO>> findForCurrentUser(HttpServletRequest req) {
Такий підхід зручніший тим, що до нього легко адаптувати фронтендські застосунки. Сервіси-споживачі працюватимуть з OrderDTO
, якщо їм не потрібні посилання (і навігація ними), а вони просто хочуть отримати подання ресурсу.
Тепер головне питання: як створити об’єкт EntityModel
? Якщо у вас немає посилань, це зробити елементарно просто:
EntityModel<OrderDTO> model = EntityModel.of(orderDTO);
Але без посилань такий об’єкт нікому не потрібний. Для наших замовлень є дві можливі операції:
- оплата;
- скасування.
Обидві операції є безумовними, а залежить стан замовлення. Ми не можемо оплатити замовлення двічі або скасувати вже оплачене та доставлене замовлення. Для того, щоб сформувати посилання на ці операції в Spring HATEOAS, є утилітний клас WebMvcLinkBuilder зі зручними методами linkTo і withRel. Вам не потрібно вручну генерувати URL, а необхідно лише вказати клас-контролер і метод (REST-сервіс) з аргументами, далі Spring HATEOAS вважає конфігурацію з анотацій:
List<Link> links = new ArrayList<>();
links.add(linkTo(methodOn(OrderController.class).findById(order.getId())).withSelfRel());
EntityModel<OrderDTO> model = EntityModel.of(orderDTO, links);
Relation self завжди доступний, тому ми обов’язково вказуємо його як посилання. Але з іншими посиланнями ситуація складніша. Наприклад, наш метод cancel повертає void, тому його не можна використовувати разом із WebMvcLinkBuilder
:
@DeleteMapping(path = "orders/{id}")
public void cancel(@PathVariable int id) {
ticketService.cancelReservation(id, "Order cancelled");
}
Доводиться штучно повертати ResponseEntity
:
@DeleteMapping(path = "orders/{id}")
public ResponseEntity<Void> cancel(@PathVariable int id) {
ticketService.cancelReservation(id, "Order cancelled");
return ResponseEntity.noContent().build();
}
Ще складніша ситуація з процесом оплати, оскільки оплата відбувається в іншому сервісі. І в нас виникає чотири різні варіанти вирішення цього завдання:
- Можна перенести керування оплатою на фронтенд, хоча це нівелює наше рішення використовувати HATEOAS.
- Можна спробувати згенерувати посилання вручну, але це не так просто. По-перше, знання API стороннього сервісу порушує ізольованість (автономність) нашого сервісу. По-друге, створення посилань на поточний сервіс реалізувати просто. А ось як дізнатись host:port іншого сервісу? Ця конфігурація відома хіба що на Gateway API сервері та залежить від environment.
- Можна спробувати перенести генерацію посилань на Gateway API, але ми не можемо перенести туди бізнес-логіку (сутності) квиткового сервісу, тому що це порушить принцип SRP. Щоправда ми можемо генерувати самі relations на квитковому сервісі, а ось URI генерувати на Gateway API сервері, але це також не так просто реалізувати.
- Можна створити новий REST-сервіс у нашому контролері, який прийматиме запит на оплату, а потім переадресовуватиме запит на платіжний сервіс. Такий варіант технічно можливий, але він подовжить обробку запиту і загалом вимагатиме часу на реалізацію.
Як ви бачите, Spring HATEOAS ідеально підходить для випадку, коли всі посилання ведуть на поточний сервер. Але складнощі починаються, коли ви намагаєтеся зробити щось нестандартне. До речі, тут ще одна проблема. Припустимо, що адреса квиткового сервісу order-service:8080. Для доступу до замовлення буде згенеровано посилання order-service:8080/orders/1. Але водночас ви використовуєте Gateway API, і ваші зовнішні споживачі (клієнти) не мають доступу до конкретних сервісів. Так, вам потрібно переробити посилання на gateway:8080/orders-service/orders/1. На щастя, у Spring MVC є спеціальний фільтр ForwardedHeaderFilter. Якщо у запиті є заголовок X-Forwarded-Host, Spring HATOEAS використовує саме його значення на засланні замість поточного сервера.
Так, якщо не брати до уваги операцію оплати, але генерація відповіді буде виглядати так:
List<Link> links = new ArrayList<>();
if(!order.isPayed() && !order.isCancelled()) {
links.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("delete"));
}
links.add(linkTo(methodOn(OrderController.class).findById(order.getId())).withSelfRel());
EntityModel<OrderDTO> model = EntityModel.of(orderDTO, links);
Буде не дуже добре, якщо ми будемо в кожному REST-сервісі використовувати один і той самий блок коду. Краще створити новий клас OrderEntityModel
та перенести туди цю функціональність:
public class OrderEntityModel extends EntityModel<OrderDTO> {
public OrderEntityModel(OrderDTO orderDTO, Iterable<Link> links) {
super(orderDTO, links);
}
public static OrderEntityModel of(Order order, Transformer transformer) {
List<Link> links = new ArrayList<>();
if (!order.isPayed() && !order.isCancelled()) {
links.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("delete"));
}
links.add(linkTo(methodOn(OrderController.class).findById(order.getId())).withSelfRel());
return new OrderEntityModel(transformer.transform(order, OrderDTO.class), links);
}
}
Тепер наш REST-сервіс виглядає так:
@GetMapping(path = "users/orders")
public List<OrderEntityModel> findForCurrentUser(HttpServletRequest req) {
String userId = SecurityUtil.getUserId(req::getHeader);
return ticketService.findOrders(userId).stream().map(order -> OrderEntityModel.of(order, transformer)).toList();
}
Перезапускаємо застосунок, тепер замовлення повертаються у такому форматі:
[
{
"id":1,
"state":"COMPLETED",
"dueDate":"2023-12-20T08:08:05.242819",
"clientName":"User",
"clientPhone":"11111",
"cancellationReason":null,
"createdAt":"2023-12-19T08:08:05.453858",
"amount":100.0,
"links":[
{
"rel":"delete",
"href":"http://ticket:8080/orders/1"
},
{
"rel":"self",
"href":"http://ticket:8080/orders/1"
}
]
},
{
"id":2,
"state":"CREATED",
"dueDate":"2023-12-28T18:12:26.984641",
"clientName":"User1",
"clientPhone":"11111",
"cancellationReason":null,
"createdAt":"2023-12-27T18:12:26.987654",
"amount":100.0,
"links":[
{
"rel":"delete",
"href":"http://ticket:8080/orders/3"
},
{
"rel":"self",
"href":"http://ticket:8080/orders/3"
}
]
}
]
Переходимо на HAL
Оскільки в нас немає оплачених замовлень, всі замовлення мають посилання на скасування. Цікавий момент. Хоча посилання вказані в JSON-документі, але назва властивості не є _links
, а links. І Content-Type, що повертається, не application/hal+json, а application/json. Можливо, на це вплинуло те, що клієнт вказав заголовок запиту Accept: application/json
? Ні, якщо змінити це значення на Accept: application/hal+json
, жодних змін у відповіді не буде. Спробуємо вказати явно формат спеціальної інструкцією @EnableHypermediaSupport:
@Configuration
@EnableHypermediaSupport(type = HypermediaType.HAL)
public static class HateoasConfig {
}
Пробуємо встановити в true спеціальну властивість use-hal-as-default-json-media-type (хоча вони і так за умовчанням true). Ні те, ні інше не допомагає, не допомагає і читання документації. Допоможе тільки пірнання глибоко у вихідники, а саме: в базовий клас RepresentationModel
, де, виявляється, у властивості links hard-coded ім’я.
@JsonProperty("links")
public Links getLinks() {
return Links.of(links);
}
І виявляється, що HATEOAS і HAL — це не те саме. Якщо ви використовуєте HATEOAS, ви можете використовувати будь-який формат (стандарт) для ваших DTO. До речі, анотація @EnableHypermediaSupport
підтримує одразу п`ять форматів:
- HAL.
- HAL Forms.
- HTTP Problem Detail.
- Collection JSON.
- Uber.
У нашому випадку потрібно поміняти тип List<OrderModel>
, що повертається, на Collec-tionModel<OrderModel>
, якщо ми хочемо отримати HAL-документ:
@GetMapping(path = "users/orders")
public CollectionModel<OrderEntityModel> findForCurrentUser(HttpServletRequest req) {
А в реалізації ми обернемо список DTO в CollectionModel і додамо посилання на себе (тобто на операцію отримання списку замовлень):
List<OrderEntityModel> orders = ticketService.findOrders(userId).stream()
.map(order -> OrderEntityModel.of(order, transformer)).toList();
return CollectionModel.of(orders,
linkTo(methodOn(OrderController.class).findForCurrentUser(req)).withSelfRel());
Зрозуміло, у нас відразу впадуть усі тести для цього REST-сервісу, оскільки змінився і вихідний формат, і Content-Type, тому там, де використовувався MediaType.APPLICATION_JSON
, потрібно використовувати MediaTypes.HAL_JSON. А сам тест виглядає так:
@Test
void findByCurrentUser_noOrdersExist_returnsEmptyList() throws Exception {
ResultActions result = mockMvc.perform(get("/users/orders").header("X-USER-ID", "12"));
result.andExpect(status().isOk()).andExpect(content().contentType(MediaTypes.HAL_JSON))
.andExpect(jsonPath("$._links.self.href", equalTo("http://localhost/users/orders")));
}
Зрозуміло, що потрібно переробляти фронт-енд після такого оновлення, бо зараз клієнт отримує ось такий HAL-документ:
{
"_embedded":{
"orderDTOList":[
{
"id":1,
"state":"COMPLETED",
"dueDate":"2023-12-20T08:08:05.242819",
"clientName":"User",
"clientPhone":"11111",
"cancellationReason":null,
"createdAt":"2023-12-19T08:08:05.453858",
"amount":100.0,
"_links":{
"delete":{
"href":"http://ticket:8080/orders/1"
},
"self":{
"href":"http://ticket:8080/orders/1"
}
}
},
{
"id":2,
"state":"COMPLETED",
"dueDate":"2023-12-20T20:31:57.251314",
"clientName":"Sam",
"clientPhone":"1111",
"cancellationReason":null,
"createdAt":"2023-12-19T20:31:57.463743",
"amount":100.0,
"_links":{
"delete":{
"href":"http://ticket:8080/orders/2"
},
"self":{
"href":"http://ticket:8080/orders/2"
}
}
}
]
},
"_links":{
"self":{
"href":"http://ticket:8080/users/orders"
}
}
}
Єдине, що нас може бентежити — це не зовсім вдала назва властивості, яка зберігає наші замовлення — orderDTOList. Воно суперечить і нашій доменній моделі, і конвенціям за назвою ресурсів. На жаль, під час використання CollectionModel формується така згенерована назва. У CollectionModel є й інші обмеження, наприклад, потрібно, щоб об’єкти були спадкоємцями RepresentationModel. Якщо у вас не виходить так зробити, або у вас одиночний об’єкт і вам взагалі не потрібна CollectionModel, але чи потрібен HAL-формат? У такому разі є зручний утилітний клас HalModelBuilder. Завдяки йому можна обернути будь-який об’єкт у спеціальний контейнер HalRepresentationModel, а наш код зі створення RepresentationModel виглядав би так:
List<Link> links = new ArrayList<>();
if (!order.isPayed() && !order.isCancelled()) {
links.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("delete"));
}
links.add(linkTo(methodOn(OrderController.class).findById(order.getId())).withSelfRel());
RepresentationModel<?> model = HalModelBuilder.halModelOf(orderDTO)
.links(links)
.build();
У нашому випадку ми можемо уникнути CollectionModel і використовувати HalModelBuilder:
@GetMapping(path = "users/orders")
public RepresentationModel<?> findForCurrentUser(HttpServletRequest req) {
String userId = SecurityUtil.getUserId(req::getHeader);
List<OrderEntityModel> orders = ticketService.findOrders(userId).stream()
.map(order -> OrderEntityModel.of(order, transformer)).toList();
return HalModelBuilder.emptyHalModel().embed(orders, LinkRelation.of("orders")).
link(linkTo(methodOn(OrderController.class).findForCurrentUser(req)).withSelfRel()).build();
}
Щоправда, доведеться явно вставляти посилання на операцію. Загалом HalModelBuilder можна назвати низькорівневим API, а CollectionModel — високорівневим.
Перезапускаємо застосунок. Тепер наші замовлення повертаються як orders.
Висновки
Загалом Spring HATEOAS надає багаті можливості для використання стратегії HATEOAS та підтримки створення HAL-документів. Ви можете використовувати як готові класи-контейнери (EntityModel, RepresentationModeL), так і спеціальний HalModelBuilder для більш точного формування HAL-документа. Тепер ваш клієнтський застосунок одержуватиме від сервера не тільки дані, але й метаінформацію щодо можливих операцій та посилання для їхнього виконання.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів