🌐 Раздел 6 · Вопрос #14

Что такое HATEOAS

Простыми словами: сервер возвращает клиенту не только «сухие» данные, но и карту допустимых следующих действий в виде ссылок (URIs).


🟢 Junior Level

HATEOAS (Hypermedia As The Engine Of Application State) — это ограничение архитектурного стиля REST, согласно которому клиент взаимодействует с сетевым приложением исключительно через гипермедиа (ссылки), динамически предоставляемые сервером в ответах.

Простыми словами: сервер возвращает клиенту не только «сухие» данные, но и карту допустимых следующих действий в виде ссылок (URIs).

Аналогия с веб-браузером

Когда вы заходите в интернет-банк через браузер:

  • Вы не вводите в адресную строку URL /accounts/101/transfer-funds вручную.
  • Вы видите баланс и нажимаете кнопку со ссылкой «Перевести средства».
  • Если у вас нет прав на перевод или счет заморожен, кнопка (ссылка) просто отсутствует на странице.

HATEOAS переносит этот же принцип взаимодействия людей в веб-сайтах на уровень программного взаимодействия между сервером и API-клиентом.

Сравнение ответов API

1. Стандартный REST (Level 2):

{
  "orderId": 501,
  "status": "AWAITING_PAYMENT",
  "amount": 2500
}

Минус: Клиент обязан заранее жестко «захардкодить» в своем коде URL для оплаты (/api/v1/orders/501/payments) и самостоятельно проверять, в каких статусах оплата разрешена.

2. REST с поддержкой HATEOAS (Level 3 HAL):

{
  "orderId": 501,
  "status": "AWAITING_PAYMENT",
  "amount": 2500,
  "_links": {
    "self": { "href": "/api/v1/orders/501" },
    "payment": { "href": "/api/v1/orders/501/payments", "type": "POST" },
    "cancellation": { "href": "/api/v1/orders/501/cancellations", "type": "POST" }
  }
}

Плюс: Клиент смотрит на наличие ключа payment в объекте _links. Если заказ уже оплачен, сервер просто не вернет ссылку payment, и клиент автоматически скроет кнопку оплаты.


🟡 Middle Level

Концепция возможностей (Affordances) и State Machine

В HATEOAS ссылки представляют собой Affordances (доступные возможности):

  • Сервер выступает в роли конечного автомата (State Machine).
  • Переходы между состояниями бизнес-сущности определяются сервером, а не клиентом.
  • Управление доступом (RBAC/ABAC): Если у текущего пользователя нет роли администратора, ссылка "delete": { "href": "..." } просто не включается в ответ. Клиенту не нужно дублировать сложную матрицу прав доступа в коде мобильного или веб-приложения: достаточно проверить if (links.delete) renderDeleteButton().

Форматы гипермедиа: HAL и JSON:API

Для стандартизации структуры ссылок в REST API были разработаны специализированные спецификации:

1. HAL (Hypertext Application Language) — application/hal+json

Наиболее популярный и компактный формат в экосистеме Java и Spring:

  • Корневое поле _links содержит словарь отношений (rel) и ссылок (href).
  • Поле _embedded содержит вложенные коллекции ресурсов (чтобы избежать отдельных HTTP-запросов).

2. JSON:API — application/vnd.api+json

Более строгий стандарт, регламентирующий структуру объектов data, attributes, relationships и links.

Проблема работы за Reverse Proxy (Forwarded Headers)

В микросервисной архитектуре Spring Boot сервис обычно находится за балансировщиком или API Gateway (NGINX, Envoy, Kubernetes Ingress):

  • Сервер генерирует ссылки на основе своего локального контекста: http://10.244.1.15:8080/orders/501.
  • Если отправить такой URL клиенту, переход по ссылке завершится ошибкой сети.

Решение:

  1. Прокси-сервер обязан проставлять стандартные заголовки RFC 7239:
    • X-Forwarded-Host: api.example.com
    • X-Forwarded-Proto: https
    • X-Forwarded-Port: 443
    • X-Forwarded-Prefix: /api/v1
  2. В Spring Boot необходимо активировать фильтр:
    server.forward-headers-strategy=FRAMEWORK
    

    (регистрирует ForwardedHeaderFilter, автоматически подменяющий хост и схему в WebMvcLinkBuilder).


🔴 Senior Level

Почему индустрия практически отказалась от HATEOAS?

На собеседованиях уровня Senior важно понимать не только теорию Роя Филдинга, но и прагматичные причины, почему в 95% реальных проектов HATEOAS не используется:

  1. Несовместимость с современным Frontend/Mobile UI: Современные SPA (React, Angular) и мобильные приложения (iOS/Android) строятся на основе жестких макетов дизайна (Figma). Разработчик интерфейса не может произвольно перерисовывать экран только потому, что с сервера пришел неизвестный новый rel.
  2. Раздувание объема передаваемых данных (Payload Overhead): Для единичного объекта оверхед составляет 30–50%. Для списков из сотен элементов (коллекций) объем JSON увеличивается в 2–3 раза исключительно за счет дублирования URL-строк в каждом объекте.
  3. Нагрузка на CPU (Reflection Overhead): Динамическая генерация ссылок в RepresentationModelAssembler через WebMvcLinkBuilder использует рефлексию и резолвинг методов контроллеров, что создает ощутимую нагрузку при высоких RPS.
  4. Победа OpenAPI / TypeScript Generator: Клиентским разработчикам намного удобнее сгенерировать строго типизированный SDK по схеме OpenAPI 3.1, чем парсить динамический граф ссылок в runtime.

Практическая реализация на Spring HATEOAS (Spring Boot 3)

Для качественной архитектуры разделения доменных моделей и HATEOAS-представлений используется RepresentationModelAssemblerSupport:

package com.example.dto;

import com.example.controller.OrderController;
import com.example.entity.Order;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<OrderDto>> {

    @Override
    public EntityModel<OrderDto> toModel(Order order) {
        OrderDto dto = new OrderDto(order.getId(), order.getStatus(), order.getTotalAmount());

        // Базовая ссылка на самого себя (self)
        EntityModel<OrderDto> orderModel = EntityModel.of(dto,
                linkTo(methodOn(OrderController.class).getOrderById(order.getId())).withSelfRel()
        );

        // Динамическое добавление ссылок на основе состояния конечного автомата
        if ("AWAITING_PAYMENT".equals(order.getStatus())) {
            orderModel.add(
                    linkTo(methodOn(OrderController.class).payOrder(order.getId())).withRel("payment"),
                    linkTo(methodOn(OrderController.class).cancelOrder(order.getId())).withRel("cancellation")
            );
        } else if ("PAID".equals(order.getStatus())) {
            orderModel.add(
                    linkTo(methodOn(OrderController.class).getDeliveryStatus(order.getId())).withRel("delivery")
            );
        }

        return orderModel;
    }
}
package com.example.controller;

import com.example.dto.OrderDto;
import com.example.dto.OrderModelAssembler;
import com.example.entity.Order;
import com.example.service.OrderService;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final OrderService orderService;
    private final OrderModelAssembler assembler;

    public OrderController(OrderService orderService, OrderModelAssembler assembler) {
        this.orderService = orderService;
        this.assembler = assembler;
    }

    @GetMapping(value = "/{id}", produces = "application/hal+json")
    public ResponseEntity<EntityModel<OrderDto>> getOrderById(@PathVariable Long id) {
        Order order = orderService.findById(id);
        return ResponseEntity.ok(assembler.toModel(order));
    }

    @PostMapping("/{id}/payments")
    public ResponseEntity<Void> payOrder(@PathVariable Long id) {
        orderService.processPayment(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{id}/cancellations")
    public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/{id}/delivery")
    public ResponseEntity<String> getDeliveryStatus(@PathVariable Long id) {
        return ResponseEntity.ok("IN_TRANSIT");
    }
}

Кэширование HATEOAS и заголовок Vary

Если сервер динамически генерирует ссылки в зависимости от прав авторизованного пользователя (администратор видит ссылку rel="delete", а обычный пользователь — нет), ответ нельзя кэшировать публично без указания заголовка:

Cache-Control: private, max-age=300
Vary: Authorization

Иначе промежуточный кэш (CDN / NGINX) отдаст ответ с правами администратора обычному пользователю.


🎯 Шпаргалка для интервью

30-секундный ответ

HATEOAS (Level 3 модели зрелости Ричардсона) — это принцип REST, при котором сервер динамически возвращает клиенту ссылки на доступные операции вместе с телом ресурса.

Позволяет клиенту перемещаться по API без жестко зашитых URL (decoupling) и реализует конечный автомат (State Machine): список доступных действий меняется в зависимости от состояния сущности и прав доступа пользователя. Стандартом де-факто для разметки ссылок является формат HAL (_links).

На практике HATEOAS применяется редко из-за раздувания сетевого трафика (в 2–3 раза для списков), нагрузки на CPU и того, что фронтенд-командам удобнее работать со статическими контрактами OpenAPI.

4 каверзных вопроса с ответами

  1. Что такое Affordance в контексте HATEOAS? Ответ: Это гипермедиа-подсказка сервера о действии, которое клиент может совершить прямо сейчас из текущего состояния ресурса. Если ссылка с определенным rel отсутствует в блоке _links, значит, операция в данный момент недоступна для ресурса или запрещена для прав текущего пользователя.

  2. Какая распространенная проблема возникает со ссылками HATEOAS при деплое за NGINX или Kubernetes Ingress и как она решается? Ответ: Проблема искажения URL: Spring HATEOAS видит внутренний IP пода (10.244.x.x:8080) и генерирует ссылки на внутренний адрес по протоколу http. Решается настройкой проксирования заголовков X-Forwarded-* и включением в Spring Boot настройки server.forward-headers-strategy=FRAMEWORK (ForwardedHeaderFilter).

  3. Почему HATEOAS считается неэффективным для высоконагруженных систем (Highload)? Ответ: Во-первых, ссылки раздувают размер сетевого ответа на 30–200%, перегружая полосу пропускания. Во-вторых, построение ссылочного графа на каждой ноде через рефлексию создает дополнительный оверхед на CPU и сборщик мусора (GC) при формировании ответов для больших коллекций.

  4. Как корректно кэшировать HATEOAS ответы, если набор ссылок зависит от роли пользователя? Ответ: Ответы должны помечаться директивой Cache-Control: private (запрет кэширования на публичных CDN/прокси) и обязательно содержать заголовок Vary: Authorization, чтобы кэш браузера не использовал ссылки одной учетной записи для другой.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «API нельзя называть REST, если в нем нет HATEOAS» (в индустрии Level 2 модели Ричардсона повсеместно считается полноценным и достаточным REST API).
  • ❌ «HATEOAS ускоряет работу приложения и экономит сетевой трафик» (наоборот, существенно увеличивает размер payload и нагрузку на CPU).
  • ❌ «HATEOAS нужен во всех мобильных приложениях» (в мобильной разработке он практически не используется из-за жесткой верстки UI под конкретные сценарии).
  • ❌ «Ссылки в HATEOAS пишутся вручную конкатенацией строк в контроллере» (в Spring используется WebMvcLinkBuilder и RepresentationModelAssembler).

Связанные темы