Міграція на Spring Data REST
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник. Хочу в цій статті поділитися з вами досвідом роботи з такою популярною технологією, як Spring Data REST та її застосування у Java-застосунках.
Я вже писав про REST API, як реалізовувати server-side pagination, як обробляти помилки, які конвенції є в REST API, про використання DTO, REST-клієнти (частина 1 та частина 2) та застосування Hypermedia/HAL.
Тепер докладно розповім про проєкт Spring Data REST та про те, як ми мігрували на нього один із наших сервісів. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про те, як поєднати REST API та Spring Data репозиторії.
Причини використання
Якщо ви розробляєте Spring Boot застосунок, де є і доступ до даних, і REST API для цих даних, дуже швидко можете помітити, що постійно виконуєте той самий процес:
- Створюєте сутності.
- Пишете репозиторії (якщо у вас Spring Data).
- Реалізуєте сервіс для бізнес-функціональності або інфраструктурних/інтеграційних завдань (опційно).
- Закінчуйте контролером та DTO (опційно).
Якщо ви робите такі кроки постійно, рано чи пізно починаєте замислюватися над тим, чи не можна автоматизувати/оптимізувати цей процес? Адже в більшості випадків наші репозиторії напрочуд схожі на наші REST-контролери, тому що реалізують одні й ті ж CRUD-операції. А одна з причин використання Spring Data — це готові CRUD-операції.
Якби ми змогли застосувати технологію, яка замінила б собою кроки (3) і (4) і сама генерувала REST-сервіси та контролери на базі репозиторіїв, то скоротили б обсяг нашої роботи. Єдине, що може стати на заваді такій міграції — це, як завжди, деталі:
- можливість конвертації сутність -> DTO (якщо ви це використовуєте);
- можливість писати нестандартні REST-сервіси чи вказувати власну реалізацію CRUD-операцій;
- різноманітні налаштування (URI-ресурсів, вимкнення певних CRUD операцій);
- пряма підтримка HATEOAS, якщо, наприклад, ви вже використовуєте Spring HATEOAS;
- підтримка Spring Security (якщо ви використовуєте);
- Підтримка Etag-функціональності (якщо це необхідно).
І, на щастя, така технологія давно існує — це Spring Data REST. Вона з’явилася у 2012 році для того, щоб об’єднати ті проєкти, які на той момент уже існували та добре себе зарекомендували:
- Spring MVC;
- Spring Data;
- Spring Security.
Про її інноваційність говорить той факт, що зараз, через 12 років, немає якогось аналога, на який можна було б перейти чи використати. Навіть у Enterprise Java, Jakarta EE або Eclipse Microprofile, не з’явилося якоїсь схожої функціональності.
У нашому квитковому сервісі зараз є така вертикаль зон відповідальності:
- OrderController — вебшар;
- OrderServiceImpl — бізнес-логіка;
- OrderRepository — доступ до даних (на базі JPA).
OrderRepository виглядає досить компактно:
public interface OrderRepository extends JpaRepository<Order, Integer> {List<Order> findByCreatedBy(String userId);
}
Якщо подивитися на методи OrderServiceImpl, то багато з них, по суті, не містять бізнес-логіки, а просто делегують управління OrderRepository (тонка прокладка):
@Override
public List<Order> findOrders(String userId) { if (StringUtils.isBlank(userId)) {return orderRepository.findAll();
}
return orderRepository.findByCreatedBy(userId);
}
@Override
public Order findOrder(int orderId) {return orderRepository.findById(orderId)
.orElseThrow(() -> new InvalidParameterException("Order with id " + orderId + " doesn't exist"));}
І було б привабливо в цьому разі позбутися і цих методів, і від REST-сервісів, які їх викликають, а залишити тільки OrderRepository з деякою конфігурацією. Навіщо потрібна конфігурація? Якщо поглянути на деякі з REST-сервісів, вони повертають не доменні об’єкти (як репозиторії), а DTO на базі HATEOAS:
@GetMapping(path = "{id}") public OrderEntityModel findById(@PathVariable int id) {return OrderEntityModel.of(orderService.findOrder(id), transformer);
}
І нам би хотілося й надалі використовувати такий підхід. Вийде у нас чи ні — з’ясуємо у наступному розділі.
Застосовуємо Spring Data REST
Додамо новий стартер для квиткового сервісу для Maven-конфігурації:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
<version>${spring.boot.version}</version></dependency>
Що тепер зміниться після цієї магічної дії? Перезберемо Docker images, запустимо контейнери. Найпростіше перевірити зміни, оскільки у нас є Spring Boot Actuator та endpoint /actuator/mappings, який показує всі наявні вебобробники (handler methods).
Але якби навіть у нас його не було б, ми могли б відкрити головну сторінку нашого сервісу (localhost:8060) і нам повернеться JSON у форматі HAL, який покаже, що у нас є REST API для замовлень, причому одночасно з підтримкою сортування та посторінкового виведення:
{ "_links" : { "orders" : { "href" : "http://localhost:8060/orders{?page,size,sort}","templated" : true
},
"profile" : {"href" : "http://localhost:8060/profile"
}
}
}
Spring Data REST за умовчанням використовує назву ресурсу таку ж, як назва класу-сутності (Order), але у множині. Цікавим є останній endpoint — /profile. Якщо його відкрити, повернеться новий JSON:
{ "_links" : { "self" : {"href" : "http://localhost:8060/profile"
},
"orders" : {"href" : "http://localhost:8060/profile/orders"
}
}
}
І якщо відкрити вже посилання /profile/orders, нам повернеться JSON з детальною інформацією про всі наші нові REST-сервіси для такого ресурсу, як замовлення, причому там буде навіть властивості ресурсу замовлення (у вигляді схеми) і все нові REST-сервіси на базі CRUD:
id: create-orders;id: get-orders;id: delete-order;id: get-order;id: update-order;id: patch-order;name: findByCreatedBy;
Таким чином, Spring Data REST автоматично генерує REST-сервіс і для всіх query methods у Spring Data репозиторіях. Як він виглядатиме? Якщо все-таки відкрити endpoint /actuator/mappings, можна помітити цікавий обробник:
"handler": "org.springframework.data.rest.webmvc.RepositoryEntityController#getCollectionResourceCompact(RootResourceInformation, DefaultedPageable, Sort, RepresentationModelAssemblers)",
"predicate": "{GET [/{repository}], produces [application/x-spring-data-compact+json || text/uri-list]}",
Тут вказується такий клас, як RepositoryEntityController, який обробляє запити для всіх ресурсів, налаштованих на використання у Spring Data REST:
@RepositoryRestController
class RepositoryEntityController implements ApplicationEventPublisherAware { private static final String BASE_MAPPING = "/{repository}";
Це не традиційний REST-контролер, тому що тут не можна зробити статичну конфігурацію, а просто обробник запитів. На цьому вивчення Spring Data REST можна було закінчити, якщо все, що вам потрібно, — REST-сервіси на базі CRUD операцій. Але перевага будь-якої технології полягає не так у базових фітчах, як у можливості налаштувати її під ваші потреби, про що ми зараз і поговоримо.
Налаштування та налаштування
Якщо зараз запустити наявні інтеграційні тести для OrderController, виявиться, що хоча Spring Data REST підключений, але не використовується, тому що всі запити, як і раніше, обробляються наявними OrderController:
@RestController
@RequestMapping("/orders")@RequiredArgsConstructor
public class OrderController {@GetMapping
public RepresentationModel<?> search(@RequestParam(name = "user_id", required = false) String userId) {
Чому так? Справа в тому, що і REST-контролери, і Spring Data REST обробники — всі вони handler methods з точки зору Spring MVC. І якщо вони мають однаковий URI (/orders), то тут вже важливий пріоритет, який вищий у REST-контролерів.
Щоб вирішити цей конфлікт, закоментуємо наші GET та DELETE REST-сервіси в OrderController. Тепер тести починають падати, причому з досить дивної причини — наші нові контролери не повертають ідентифікатор замовлення у відповіді:
Caused by: com.jayway.jsonpath.PathNotFoundException: No results for path: $['id']
Причина не зовсім очевидна. Справа в тому, що автори Spring Data REST вважали, що внутрішній ідентифікатор сутності, який береться з БД, — не дуже гарна кандидатура на роль зовнішнього ідентифікатора ресурсу. Більше того, вони вважають, що не потрібні і зовнішнім споживачам. Тому він просто не повертається сервером. Якщо вам це не підходить, для того щоб це змінити, є спеціальний інтерфейс RepositoryRestConfigurer:
@Configuration
public class SpringDataRestConfiguration implements RepositoryRestConfigurer {@Override
public void configureRepositoryRestConfiguration(
RepositoryRestConfiguration config, CorsRegistry cors) {config.exposeIdsFor(Order.class);
}
}
Щоправда в нашому випадку це не допоможе, тому що ми повертаємо в REST-сервісах не сутність (Order), а DTO (OrderDTO). Spring Data REST підтримує використання DTO як проєкцій. Для цього є два підходи. У першому потрібно створити інтерфейс OrderDTO, де вказуємо всі необхідні властивості:
public interface OrderDTO {int getId();
String getState();
LocalDateTime getDueDate();
}
І повернути його у будь-якому з методів у вашому репозиторії:
public interface OrderRepository extends JpaRepository<Order, Integer> {List<OrderDTO> findOrders();
Такий варіант підходить тільки якщо вам потрібно повернути DTO для якогось конкретного випадку. Але за дефолтоv для решти CRUD-операцій однаково повертатиметься не OrderDTO, а Order. У другому варіанті ви додаєте інструкцію @Projection:
@Projection(name = "orders", types = Order.class)
interface OrderDTO {int getId();
String getState();
LocalDateTime getDueDate();
}
І можете застосувати цю проєкцію, якщо вкажете в запиті її як параметр: localhost:8060/orders?projection=orders
Щоправда є обмеження, наприклад, Spring Data REST не генерує посилання для таких проєкцій. Однак навіть якби ці посилання генерувалися, це б нас не врятувало, оскільки там були лише дефолтні посилання на операції (наприклад, self).
У будь-якому випадку і цей варіант нам не підходить, тому що тут Spring Data REST просто копіюватиме поля з таблиці замовлень в OrderDTO, у нас же використовується спеціальна конверсія, та ще й на базі HATEOAS:
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);
}
}
На щастя, Spring Data REST підтримує такий підхід. Якщо вам потрібно конвертувати ваші сутності в HATEOS/HAL відповідь, є спеціальний інтерфейс RepresentationModelProcessor, який дозволить це зробити:
@RequiredArgsConstructor
public class OrderModelProcessor implements RepresentationModelProcessor<EntityModel<?>> {private final Transformer transformer;
@Override
public EntityModel<?> process(EntityModel<?> model) {return EntityModel.of(transformer.transform(model.getContent(), OrderDTO.class), model.getLinks());
}
}
Тут нам дається згенерована EntityModel, ми витягаємо з неї тіло (Order), конвертуємо його в наш DTO і додаємо посилання з цієї EntityModel. Тобто плюс цього підходу в тому, що нам не потрібно самим генерувати посилання (relations), за нас це робить Spring Data REST. Якщо тепер запустити тест і виконати запит GET /orders, отримаємо дивну помилку:
java.lang.AssertionError: Status expected:<200> but was:<405>
У чому ж справа? Адже ми бачили, що endpoints /orders був створений і має підтримуватись Spring Data REST. Проблема полягає в тому, що зараз використовується комбінований підхід. Для одного й того ж базового URI (/orders) є і REST-контролер (для запиту POST) і Spring Data REST обробники (для всіх інших HTTP-методів):
@RestController
@RequestMapping("/orders")@RequiredArgsConstructor
public class OrderController {@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public void create(@RequestBody @Valid CreateOrderDTO createOrderDTO, HttpServletRequest req) { Spring Data REST по дефолту не підтримує такий підхід, тому тут потрібно змінити @RequestMapping на @BasePathAwareController:
@RestController
@RequiredArgsConstructor
@BasePathAwareController("/orders")public class OrderController {
Тепер частина тестів проходить, але виникла нова складність. За замовчуванням, коли Spring Data REST повертає ресурс, вона генерує лише посилання на отримання такого ресурсу:
"_links" : { "self" : {"href" : "http://localhost/orders/1"
},
"order" : {"href" : "http://localhost/custom/orders/1"
}
}
Нам потрібно додавати й інші посилання на операції з ресурсом, залежно від контексту, наприклад, посилання на видалення (скасування) замовлення. На жаль, якоїсь опції у Spring Data REST для цього немає (а було б зручно, щоб автоматично генерувалися посилання на видалення/зміну ресурсів), тому доводиться явно додавати таке посилання самим:
@RequiredArgsConstructor
public class OrderModelProcessor implements RepresentationModelProcessor<EntityModel<?>> {private final Transformer transformer;
@Override
public EntityModel<?> process(EntityModel<?> model) {Links links = model.getLinks();
Order order = (Order) model.getContent();
if (!order.isPayed() && !order.isCancelled()) { links = links.and(Link.of(links.getRequiredLink("self").getHref(), "delete"));}
return EntityModel.of(transformer.transform(order, OrderDTO.class), links);
}
Тепер посилання delete з’явиться тільки для замовлень, які можна скасувати. У нашому OrderRepository є query method findByCreatedBy:
public interface OrderRepository extends JpaRepository<Order, Integer> {List<Order> findByCreatedBy(String userId);
}
Як його можна викликати через REST API? Spring REST Docs використовує таку схему: до базового URI /search додається назва Java-методу та назва аргументу як параметр запиту:
GET /orders/search/findByCreatedBy?userId=123
Однак не дуже добре вказувати в URI внутрішню назву методу, яка може змінитися. На щастя, це можна змінити за допомогою анотації @RestResource та атрибуту path:
public interface OrderRepository extends JpaRepository<Order, Integer> {@RestResource(path = "byUser")
List<Order> findByCreatedBy(String userId);
}
Тепер наш URI зміниться на більш читабельний:
GET /orders/search/byUser?userId=123
Але процес конфігурації Spring Data REST не закінчено. Якщо надіслати запит GET /orders, нам повернеться відповідь, де назва поля замовлень буде не orders (як ми хочемо), а orderDTOes, тому що ми повертаємо список об’єктів OrderDTO до OrderModelProcessor:
"_embedded" : { "orderDTOes" : [ {"id" : 1,
"state" : "CO
Це дрібниця, але вона вплине на наших клієнтів, тому хотілося б повернути звичнішу назву властивості — orders. Чи можна це зробити? У документації явної відповіді на це немає, тому доводиться витратити кілька днів на дослідження вихідних записів Spring HATEOAS і Spring Data REST, щоб розібратися в тому, як це все разом працює.
Зрештою можна натрапити на такий цікавий інтерфейс:
public interface LinkRelationProvider extends Plugin<LookupContext> {
Саме він та його реалізації відповідають за генерацію відповіді у тому, що стосується колекцій. Якби ми повертали List<Order> (список сутностей), то за його перетворення на JSON відповідав клас RepositoryRelProvider, але якщо повертається список довільних об’єктів, використовується інший клас зі складною назвою — EvoInflectorLinkRelationProvider.
Його єдине завдання — перетворити назву класу на іменник у множині. Тому нам лише залишається успадкувати його і, якщо до нас приходить OrderDTO, замінити на льоту на Order:
public class CustomLinkProvider extends EvoInflectorLinkRelationProvider {private final Map<Class<?>, Class<?>> aliases = Map.of(OrderDTO.class, Order.class);
@Override
public LinkRelation getCollectionResourceRelFor(Class<?> type) {return super.getCollectionResourceRelFor(aliases.getOrDefault(type, type));
}
@Override
public boolean supports(LookupContext context) {return aliases.containsKey(context.getType());
}
}
Як потрібно застосувати новий клас? Тут головне не потрапити в пастку, тому що у вже згаданому раніше інтерфейсі RepositoryRestConfigurer є можливість перевизначити метод configureRepositoryRestConfiguration:
static class HateoasConfig implements RepositoryRestConfigurer {@Override
public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) {config.setLinkRelationProvider(new CustomLinkProvider());
}
І вказати тут наш LinkProvider. Але він буде викликатись тільки для класів-сутностей. А якщо ми хочемо, щоб він викликався для будь-яких типів, слід його оголосити як звичайний Spring bean, але з найвищим пріоритетом:
static class HateoasConfig {@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
CustomLinkProvider customLinkProvider() {return new CustomLinkProvider();
}
Й ось тільки тепер усі наші тести проходять.
Висновки
Таким чином ми змогли перекласти наш квитковий сервіс Spring Data REST, отримавши такі переваги:
- позбулися необхідності описувати рутинні CRUD-операції в контролерах та сервісах;
- отримали out-of-the-box посторінковий висновок та підтримку query methods;
- відразу отримали відповідь у форматі HAL;
- під час інтеграції з Query DSL мали можливість пошуку за будь-якою властивістю сутностей;
- мали можливість одночасного використання репозиторіїв та REST-контролерів.
Водночас Spring Data REST виявився досить гнучким, щоб ми могли за бажання змінити/налаштувати будь-який етап конвертації наших сутностей у відповідь HTTP (ResponseEnity).
Це може знадобитися, оскільки тепер наші репозиторії, по суті, і є наш публічний REST API. Це можна віднести до відносних мінусів нового підходу, але він нівелюється завдяки гнучким параметрам конфігурації.
Читайте також: Рейтинг мов програмування 2024. TypeScript в трійці лідерів, Python зʼявляється у всіх нішах, а Rust — улюблена мова.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів