CQRS зустрічає сучасну Java

Розробка надійних і водночас легких у підтримці програмних рішень залишається ключовою метою сучасної розробки програмного забезпечення. Шаблон Command Query Responsibility Segregation (CQRS) пропонує ефективний підхід, створюючи чіткий поділ між виконанням команд і запитами даних, спрощуючи архітектуру системи та підвищуючи її продуктивність. У той же час підхід Data-Oriented Programming (DOP) зосереджується на ефективній роботі з даними. Ця стаття демонструє, як інтеграція CQRS та DOP веде до створення більш надійних, масштабованих і легких у підтримці систем.

Вступ до CQRS

Command Query Responsibility Segregation (CQRS) — це шаблон, вперше описаний Грегом Янгом [1]. Він базується на принципі розподілу відповідальностей. CQRS бере свій початок із принципу Command-Query Separation (CQS), який вперше представив Бертран Мейєр у своїй книзі “Object-Oriented Software Construction”. У той час як CQS стверджує, що методи мають бути або командами, які змінюють стан об’єкта, але не повертають значення, або запитами, які повертають значення, але не змінюють стан, CQRS розширює цей принцип до рівня архітектури програмних додатків.

pic

CQRS розділяє відповідальність за обробку команд, які змінюють стан системи, і відповідальність за виконання запитів інформації про цей стан. Такий поділ дозволяє оптимізувати дизайн обох сфер відповідальності, що може призвести до кращої структуризації. Моделюючи команди й запити окремо, розробники можуть більш чітко та легко реалізовувати складну бізнес-логіку.

Однак впровадження CQRS у систему може збільшити її складність, оскільки потрібно управляти двома окремими моделями. Проте, як ми побачимо в цій статті, цей очевидний недолік може стати великою перевагою в контексті запитів до даних і продуктивності. Крім того, це може полегшити підтримку системи, оскільки зміни в логіці запитів можуть здійснюватися незалежно від логіки команд і навпаки. Окрім цього, поділ дозволяє оптимізувати масштабування, оскільки операції читання та запису вимагають різних системних ресурсів і можуть масштабуватися окремо. Варто зазначити, що для багатьох бізнес-додатків це може бути неактуальним, оскільки кількість користувачів і їхня поведінка відомі заздалегідь.

Програмування, орієнтоване на дані (DOP)

Data-Oriented Programming (DOP) — це парадигма, яка пропонує альтернативний підхід до традиційного об’єктно-орієнтованого програмування (OOP), зосереджуючи увагу на даних та їхніх структурах, а не на об’єктах і їхній поведінці. Основні концепти DOP:

  1. Незмінність (Immutability)
    Структури даних є незмінними, тобто після створення їх не можна змінити.
  2. Розділення ідентичності та стану
    У DOP ідентичність даних відокремлюється від їхнього стану. Це означає, що стан об’єкта у певний момент часу є просто знімком його даних, що спрощує розуміння його історії та змін.
  3. Моделювання даних як центральний елемент дизайну
    На відміну від OOP, яке фокусується на поведінці об’єктів і методах, DOP акцентує увагу на проєктуванні моделей даних. Це сприяє чіткій структуризації даних і спрощує їхню маніпуляцію та запити.

У своїй статті [2] Браян Гетц розглядає реалізацію DOP у Java та описує, як поєднання нових функцій Java, таких як записи (records), запечатані класи (sealed classes) і шаблонне зіставлення (pattern matching), підтримує принципи DOP і веде до створення більш точних, зрозумілих і надійних програм. Зокрема, команди з CQRS моделюються відповідно до ідей Браяна Гетца.

Сучасні функції Java

Java впровадила кілька важливих нових функцій мови в останніх версіях, які суттєво впливають на те, як розробники пишуть і структурують код. Серед функцій, важливих для цієї статті, — записи (records), запечатані класи (sealed classes) і шаблонне зіставлення (pattern matching), описані нижче.

Записи (records) були представлені в Java 16 як попередня функція та стали невід'ємною частиною мови починаючи з Java 17.
Записи (Records) — це спеціальний тип класів, який використовується для моделювання простих структур даних, так званих носіїв даних (data carriers), з мінімальною кількістю коду. Запис автоматично створює всі поля як final, генерує геттери (getters), але без префікса get, а також реалізації методів equals(), hashCode() та toString(). Записи ідеально підходять для моделювання незмінних об’єктів даних (immutable data objects) і значно зменшують кількість коду, який потрібно написати.

Запечатані класи (Sealed classes) були офіційно введені в Java 17 і пропонують спосіб обмежити спадкування. Розробник може явно контролювати, які інші класи чи інтерфейси можуть успадковувати цей тип, запечатуючи клас або інтерфейс. Це досягається за допомогою ключових слів sealed та permitted, які вказують точні типи, яким дозволено успадковувати запечатаний клас. Запечатані класи сприяють більш точному контролю над спадкуванням і дозволяють розробникам більш чітко визначати та захищати ієрархічні системи типів, що особливо корисно в доменному моделюванні.

Шаблонне зіставлення (Pattern matching) для оператора instanceof було представлено в Java 16 як функція попереднього перегляду та було далі розвинено. Воно забезпечує більш компактний і читабельний спосіб виконання перевірок типів і наступних перетворень типів. За допомогою шаблонного зіставлення ви можете не тільки перевіряти, чи належить об’єкт до певного типу в операторі if, а й у виразі switch. Якщо об’єкт відповідає певному типу, його можна безпосередньо перетворити на локальну змінну відповідного типу. Це спрощує код, усуваючи необхідність виконувати явне перетворення типу окремо, і зменшує кількість помилок при роботі з перетвореннями типів. У Java 21 також додано шаблони записів (record patterns), які дозволяють отримувати прямий доступ до окремих компонентів запису.

Команди CQRS із сучасною Java

Щоб проілюструвати інтеграцію CQRS із сучасною Java та новими функціями, зазначеними вище, ми зосередимося на командах. Прикладом може бути веб-магазин, який пропонує стандартні функції, такі як «Створити замовлення», «Додати товар» і «Змінити кількість». У традиційному підході створюються REST-інтерфейси, що дозволяють створювати та змінювати замовлення. У списку 1 показано реалізацію, яку часто зустрічають на практиці. Весь об’єкт замовлення завжди передається, незалежно від того, які дані були змінені. PurchaseOrderDTO також є одноковим відображенням сутності PurchaseOrder, тому його легко можна зіставити за допомогою таких маперів (mapper), як ModelMapper або MapStruct.

@ResponseStatus(HttpStatus.CREATED)  
@PostMapping  
void post(@RequestBody PurchaseOrderDTO purchaseOrderDTO) {  
 var purchaseOrder = modelMapper.map(purchaseOrderDTO, PurchaseOrder.class);  
 customerRepository.findById(purchaseOrder.getCustomer().getId()).ifPresent(purchaseOrder::setCustomer);  
 purchaseOrderRepository.save(purchaseOrder);  
}  
@PutMapping("{id}")  
void put(@PathVariable Long id, @RequestBody PurchaseOrderDTO purchaseOrderDTO) {  
 if (id.equals(purchaseOrderDTO.getId())) {  
 throw new IllegalArgumentException();  
 }  
 var purchaseOrder = modelMapper.map(purchaseOrderDTO, PurchaseOrder.class);  
 purchaseOrderRepository.save(purchaseOrder);  
}

Цей дизайн має кілька проблем. У методі put() незрозуміло, які дані змінює інтерфейс. Крім того, передається занадто багато даних, оскільки весь об’єкт замовлення завжди надсилається з клієнта на сервер, що в більшості випадків є зайвим. На стороні клієнта незрозуміло, які дані в об’єкті інтерфейсу можна змінити.

CQRS допомагає вирішити ці проблеми, надсилаючи команди з клієнта на сервер замість об’єктів. Команди можна отримати з вимог, зазначених на початку розділу: «Створити замовлення», «Додати товар» і «Змінити кількість».
Зосередження на командах позитивно впливає на розуміння застосунку, оскільки додає семантику до коду.

sealed interface OrderCommand permits CreateOrder, AddOrderItem, UpdateQuantity {  
 record CreateOrder(long customerId) implements OrderCommand {  
 }  
 record AddOrderItem(long orderId, long productId, int quantity) implements OrderCommand {  
 }  

 record UpdateQuantity(long orderItemId, int quantity) implements OrderCommand {  
 }  
}

Оскільки команди є незмінними (immutable), доцільно моделювати їх як записи (records) у Java (Список 2). У прикладі використовується запечатаний інтерфейс (sealed interface), який реалізується всіма командами для їх групування та покращення зрозумілості. Завдяки запечатаному інтерфейсу ми можемо використовувати вичерпність (exhaustiveness) виразу switch для реалізації обробки команд. Вичерпність означає, що компілятор перевіряє, чи всі можливі значення були оброблені. Це є значною перевагою порівняно з конструкцією if/else if/else, і, крім того, якщо під час подальшої розробки додається нова команда, система повідомляє, якщо її не було оброблено.

switch (orderCommand) {  
 case OrderCommand.CreateOrder(long customerId) -> {  
 var purchaseOrder = orderService.createOrder(customerId);  
 return created(...).buildAndExpand(...).toUri()).build();  
 } case OrderCommand.AddOrderItem(long orderId, long productId, int quantity) -> {  
 var orderItemRecord = orderService.addItem(orderId, productId, quantity);  
 return created(...).buildAndExpand(...).toUri()).build();  
 } case OrderCommand.UpdateQuantity(long orderItemId, int quantity) -> {  
 orderService.updateQuantity(orderItemId, quantity);  
 return ok().build();  
 }  
}

У Списку 3, крім виразу switch, можна також побачити приклад використання шаблонного зіставлення (pattern matching) із шаблонами записів (record patterns). Команди CreateOrder, AddOrderItem і UpdateQuantity деконструюються, а їхні окремі поля передаються безпосередньо в OrderService. Цей приклад має перевагу, оскільки OrderService не знає про існування команд і залишається незалежним. Повний вихідний код прикладів можна знайти за посиланням [3].

Висновок

Java постійно еволюціонує, щоб іти в ногу з технологічними змінами. У останніх версіях Java впровадила кілька сучасних функцій мови, таких як записи (records), шаблонне зіставлення (pattern matching) і запечатані класи (sealed classes). Ці розширення не лише покращують читабельність і написання коду, але й дозволяють використовувати підходи функціонального програмування та вдосконалене моделювання даних, що допомагають розробникам Java створювати ефективніші та виразніші програми.
У статті показано, що використання CQRS із розподілом відповідальностей спрощує розуміння та підтримку системи, а також значно виграє від нових функцій мови Java, особливо в частині реалізації команд.

Посилання

[1] Greg Young (2010): CQRS Documents by Greg Young
https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf
[2] Brian Goetz (2022): Data Oriented Programming in Java, InfoQ https://www.infoq.com/articles/data-oriented-programming-java/
[3] https://github.com/simasch/cqrs-meets-modern-java

Перекладено з: CQRS Meets Modern Java

Leave a Reply

Your email address will not be published. Required fields are marked *