Навіщо використовують DTO. Приклади в Java-застосунках
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Я Сергій Моренець, Java-девелопер, викладач і тренер. На своїх тренінгах регулярно розповідаю про такий популярний патерн, як DTO. Згодом матеріалу з цієї теми набралося досить багато для окремої статті, де я хочу впорядкувати свої знання і поділитися прикладами з практики.
Якщо розробника запитати, з чого складається його проєкт, то він впевнено відповість, що з функцій, класів, компонентів і сервісів. Це можна назвати початковим рівнем розуміння структури проєкту. Але якщо абстрагуватися і піднятися на рівень вище, то виявиться, що в проєктi залучені десятки і сотні випадків застосування патернів, принципів дизайну і конвенцій. Вони впливають на те, наскільки просто зрозуміти і змінити наш проєкт, і на його якість.
Так історично склалося, що першими «офіційно визнаними» патернами в ІТ були патерни проєктування, і придумали їх четверо молодих девелоперів, яких згодом назвали «Банда чотирьох». Їхня книга «Прийоми об’єктно-орієнтованого проєктування. Патерни проєктування» досі вважається бестселером, хоча була випущена ще в 1994 році. Після цього розробники охоче взяли патерни на озброєння. 21 століття принесло нам еру Web 2.0 і розквіт enterprise-застосунків, де широко використовували вебсервіси, ORM-технології, багатопотокову обробку даних і розподілені системи. Відповідно автори почали випускати нові книги, де розповідали про свої дослідження і напрацювання. Так, відомий британський програміст Мартін Фаулер в 2003 році випустив ще один бестселер Patterns of Enterprise Application Architecture, а Кент Бек
У своїй книзі Фаулер розділив 51 патерн на 10 категорій, включаючи Distribution patterns, куди зарахував патерн Data-transfer object (DTO). У цій статті ми розглянемо його призначення і розберемо два найбільш цікаві приклади використання для Java-застосунків. Фаулер давав таке визначення DTO: «Об’єкт, який переносить дані між процесами для зменшення кількості викликів», і згадував альтернативну назву патерну — Value Object, хоча і не схвалював його. Розберімо невеликий приклад для того, щоб зрозуміти його задум.
Отже, уявімо проєкт найпростішого інтернет-магазину. У його доменний моделі є і товари (клас Product), і замовлення (клас Order):
@Getter
@Setter
public class Product {
private String id;
private String name;
private List<Order> orders;
}
Товари повинні відображатися на сайті (і в мобільному застосунку), тому існує REST API, який їх відправляє клієнту:
@RestController
@RequestMapping("products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public List<Product> findAll() {
return productService.findProducts();
}
}
І все б добре, але виявляється, що:
- замовлень для товару може бути непристойно багато, і їх завантаження та відправка уповільнює роботу REST API;
- замовлення взагалі не потрібні для відображення в розділі «Товари».
Оскільки Spring MVC використовує Jackson для серіалізації даних, то вихід з цієї ситуації досить простий — додати анотацію @JsonIgnore, яка видаляє поле з отриманого JSON:
@JsonIgnore
private List<Order> orders;
І все б добре, але виявляється, що є ще один REST-сервiс, в якому замовлення вже повинні повертатися:
@GetMapping("{id}")
public Product findById(@PathVariable String id) {
return productService.findById(id);
}
Але й тут є залізобетонний вихід — патерн Json View.Необхідно створити новий маркер-клас (або інтерфейс) ProductView і додати в нього вкладені типи для кожного подання нашого товару:
public interface ProductView {
interface Detailed {}
interface Summary {}
}
Тепер залишилося змінити @JsonIgnore на @JsonView з того ж Jackson:
@JsonView(ProductView.Detailed.class)
private List<Order> orders;
І відповідно наш REST API:
@GetMapping
@JsonView(ProductView.Summary.class)
public List<Product> findAll() {
return productService.findProducts();
}
@GetMapping("{id}")
@JsonView(ProductView.Detailed.class)
public Product findById(@PathVariable String id) {
return productService.findById(id);
}
Тепер Jackson буде серіалізувати поле orders тільки для REST-сервісу, на якому стоїть анотація @JsonView(ProductView.Detailed.class). І все б добре, але виявляється, що:
- Клієнтові потрібно разом з товарами відправити нові дані (наприклад, статистичні), які зберігаються в іншому місці (або навіть іншій базі). Тому клієнт повинен зробити кілька запитів до сервера, щоб отримати всю необхідну інформацію.
- Наш API має негативну звичку еволюціонувати, і потрібно відправляти клієнтам різні види товару, залежно від версії API.
- Ми не можемо видаляти і змінювати поля в доменній моделі, оскільки це зламає роботу клієнтського коду.
А найгірше — те, що наша доменна модель (частина middle-tier) залежить від рівня представлення (presentation-tier) і постійно під неї підлаштовується. А це порушує один з наріжних каменів layered architecture — кожен layer повинен залежати (і знати) тільки від нижчих layers. Тому єдиний вихід — відправляти клієнтам тільки ті дані, які вони запитують, а для цього потрібні нові класи, до яких пред’являється жорстка вимога. У них повинні бути тільки дані і жодної бізнес-логіки.
Ці класи і будуть нашими DTO. Для прикладу створимо новий клас DTO для відображення ключових полів товару:
@Getter
@Setter
public class ProductSummaryDTO {
private String id;
private String name;
}
Тепер наш REST API не залежить від доменної моделі, але нові класи ускладнюють процес обробки даних, тому що необхідно кожен раз копіювати дані з DTO в доменну модель і навпаки. Це можна робити і вручну, але є цілий набір бібліотек, які дозволяють спростити цей процес, наприклад Dozer, ModelMapper або MapStruct. Якщо ви використовуєте Dozer, то копіювати дані дуже просто:
private final Mapper mapper = DozerBeanMapperBuilder.buildDefault();
@GetMapping
public List<ProductSummaryDTO> findAll() {
return productService.findProducts().stream().map(product -> mapper.map(product, ProductSummaryDTO.class))
.collect(Collectors.toList());
}
Dozer дуже гнучкий в налаштуванні, але використовує Reflection API, тому працює повільно. Якщо вам критична швидкодія, то бібліотека MapStruct використовує annotation processor. Він під час компіляції генерує код, який буде копіювати дані object-to-object, тобто максимально швидко.
Зараз DTO — це один з необхідних патернів розподіленої архітектури, який дозволяє розділити доменну модель і модель представлення (ViewModel). У Domain-driven-design (DDD) є принцип anti-corruption layer, який говорить про те, що ми не повинні розкривати (відправляти) споживачам наших сервісів доменну модель, а замість цього створювати проміжний layer (фасад) і віддавати клієнтам. По суті, наші DTO якраз і виконують роль такого anti-corruption layer.
Чи повинен DTO бути immutable? В ідеалі так, тому що після свого створення він серіалізується і відправляється клієнту. І не дуже бажано, щоб якийсь інший код мав можливість його змінювати. Ми можемо оголосити клас immutable, зробивши всi поля final і додавши анотацію @Value з Lombok. Lombok при компіляції згенерує getters і конструктор для ініціалізації всіх полів:
@Value
public class ProductSummaryDTO {
private final String id;
private final String name;
}
І якщо додати новий REST-сервіс для створення товарів:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void save(@RequestBody ProductSummaryDTO dto) {
то наш об’єкт ProductSummaryDTO буде коректно десеріалізований, використовуючи згенерований all-args конструктор. Тут доречно згадати, що в Java 16 з’явилися immutable-типи — записи (records). Раз так, чому б їх не використовувати як DTO, тим більше, що Jackscon, починаючи з версії 2.12, їх підтримує.
public record ProductSummaryDTO(String id, String name) {
}
Є і ще один цікавий приклад використання DTO, пов’язаний не з web, а з ORM-технологіями. Уявімо, що у вас в проєкті використовується JPA (Hibernate) разом з Spring Data і є метод, який запитує і повертає назовні товари з бази:
@RequiredArgsConstructor
@Service
@Transactional
public class ProductService {
private final ProductRepository productRepository;
public Product findById(int id) {
return productRepository.findWithId(id);
}
Оскільки ми позначили наш сервіс як @Transactional, це означає, що після завершення методу findById Spring автоматично завершить транзакцію (і закриє сесію). Але що, як у нас в класі Product є lazy-loading поля?
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = «product»)
private Set<Order> orders;
При спробі до них звернутися поза сесією (наприклад, в контролері або при серіалізації) ми відразу отримаємо сумнозвісну LazyInitializationException. І це буде неприємним сюрпризом, особливо якщо у нас немає інтеграційних тестів. З цією проблемою можна боротися:
- Змінити fetch type на EAGER.
- Застосувати JPQL-запит з join fetch.
- Використовувати OpenSessionInView filter.
Але переважно це workarounds і навіть антипатерни. Крім того, щоб розпізнати цю потенційну проблему, потрібно переглянути весь код, який пов’язаний з тими ж товарами. Є спосіб більш безпечний, хоча і більш витратний — повертати з сервісу тільки DTO. Це гарантує, що всі запити до бази будуть зроблені при активній транзакції. Крім того, ми скопіюємо в DTO тільки ті дані, які нам потрібні. І якщо нам, наприклад, замовлення не потрібні для поточного запиту, то вони і не будуть завантажені з бази.
Отже, ми розглянули історію появи такого патерну, як DTO, і кілька прикладів його використання в Java-застосунках. Загалом застосування DTO дозволяє вирішити цілий пласт проблем:
- Розділити доменну модель від REST API і позбавити її від необхідності підлаштовуватися під потреби споживачів.
- Реалізувати версіонування вашого API.
- Реалізувати принципи Hypermedia (Spring HATEOAS).
- Створити клієнтську бібліотеку для вашого сервісу і помістити туди DTO. Ця бібліотека буде використовуватися тими сервісами, які викликають ваш REST API.
133 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів