Vert.x + Micronaut. Для чого нам Dependency Injection y світі мікросервісів
За більш ніж 6 років у розробці довелося мати справу з різними проектами, а також з різними реалізаціями Dependency Injection (DI). Якщо в Grails/Spring DI це практично основа, то, наприклад, для Android-проекту його потрібно було додавати вручну (Dagger 2), так само як і для геймдев-проекту на Unity (Zenject). Інші програми, як-от pure Servlet-сервіс на Apache Olingo чи мікросервіси на Vert.x, узагалі не використовували DI. Власне, спроба додати DI до проекту на Vert.x підштовхнула до досліджень та експериментів, які було проаналізовано й задокументовано.
Стаття буде корисна всім, кому близька тема чистого коду й, звісно, DI. У ній ми спробуємо розібратися, які проблеми може розв’язати DI, розглянемо приклади поганого/хорошого коду, виміряємо вплив на швидкодію програми й зробимо висновки.
Для чого нам DI
У світі Spring DI доступний за замовчуванням. Тому для більшості Java-розробників дискусія про те, чи потрібен він узагалі, може виглядати трохи дивною. Це питання стає обґрунтованішим, коли йдеться про мікросервіси. Вони повинні бути якомога меншими й швидко запускатися, тому логічно мінімізувати набір додаткових бібліотек. Більше того, деякі фреймворки не змушують розробника використовувати якісь конкретні бібліотеки і дозволяють обрати те, що необхідно.
Хорошим прикладом є Vert.x — неймовірно швидкий і компактний non-blocking фреймворк, який розбито на компоненти подібно до Spring, але без DI в основі. Виглядає як хороший кандидат для високонавантажених мікросервісів!
Враховуючи все вищеописане, додати DI у проект на базі Vert.x тільки тому, що це виглядає правильним, — не дуже хороша ідея. Dependency Injection — це інструмент, а кожен інструмент слід використовувати для вирішення певних задач. Давайте з’ясуємо типові проблеми проектів без DI і визначимо, чи можна їх позбутися, використавши DI.
Більшість прикладів і висновків можна застосувати до будь-яких інших мікросервісів з іншими фреймворками/мовами.
Переваги та недоліки DI
Перед тим як почати, варто виділити сильні й слабкі сторони інструмента, який ми збираємося використовувати.
Плюси
- Чистий код, який простіше читати / підтримувати / використовувати повторно.
- Код легше тестувати.
- Простіше змінювати реалізацію.
- Дотримання принципів хорошого дизайну (SRP, Loose Coupling і Dependency Inversion).
Мінуси
- Використання абстракцій може збільшити кількість класів.
- Швидкодія під час старту програми може погіршитися.
- DI може виявитися зайвим для невеликих проектів.
- Код прив’язано до
DI-фреймворку.
Демопроект
Цей Vert.x-проект зберігає користувачів у базі даних і повертає їх через HTTP. Перший планувальник періодично додає нового користувача в базу даних, а другий відправляє їм повідомлення.
Проект містить 3 вертікли (для тих, хто не знайомий із фреймворком, вертікл можна спрощено вважати модулем / стартовою точкою програми):
- HttpServerVerticle — слухає порт 8080 і повертає JSON з користувачами.
- CustomerProducerVerticle — додає нового користувача кожних 5 секунд.
- CustomerNotificationVerticle — відправляє повідомлення користувачам кожних 10 секунд.
CustomerService відповідає за повернення/додавання користувачів і використовує CustomerRepository, який працює з базою даних.
Повний код проекту доступний тут: без DI, з DI.
Які проблеми може вирішити DI
Коли розмір програми невеликий, DI може здаватися непотрібним. Але потім кількість коду росте, і в результаті, проблеми нелегко рефакторити. Тому краще подумати про це на початку.
Деякі типові code smells, які часто можна зустріти на проекті без DI:
- Зловживання синглтонами з глобальним станом.
- Самостійне створення залежностей, які не є логічною частиною класу, що їх створює й використовує.
- Потрібно багато рутинного коду, щоб передати «важкі» об’єкти під час створення залежностей; роздуті списки параметрів методів/конструкторів.
- Використання синглтонів, які зберігають стан (наприклад, connection pool), різними вертіклами (стосується лише Vert.x).
- Код важче тестувати й використовувати повторно.
Самостійне створення залежностей
Створення залежностей самим класом, який їх використовує, порушує перший принцип SOLID. Якщо в майбутньому залежність буде змінено, залежний клас також треба буде змінити.
public class CustomerProducerVerticle extends AbstractVerticle { CustomerService customerService = new CustomerService();
З таким підходом тестування стає важчим: створити мок для приватних полів не просто.
public class CustomerService { private CustomerRepository customerRepository = new CustomerRepository();
Приклад нижче показує, що CustomerNotifier використовує імплементацію EmailNotifier. Це означає, що ми не зможемо легко змінити реалізацію на SMSNotifier, якщо це буде потрібно.
public class CustomerNotificationVerticle extends AbstractVerticle { private CustomerNotifier notifier = new EmailNotifier();
Отримання синглтон-залежностей зі статичного методу
Код, що використовує статичні методи, важко протестувати unit-тестами без PowerMock. А з Vert.x його важко тестувати навіть з PowerMock через деякі конфлікти анотацій Vert.x і Junit5.
MySQLPool pool = PoolManager.getPool(); vertx.setPeriodic(5000, id -> pool.getConnection(conn -> addCustomer(conn.result())));
Більше того, вертікл повинен ізолювати свої стан і поведінку, щоб уникнути проблем з потоками. Правильний спосіб комунікації між вертіклами — Event Bus.
Замість того щоб використовувати єдиний Connection Pool, кожен вертікл повинен мати свій Connection Pool, визначений як синглтон у межах своєї області видимості.
Отримання залежностей ззовні вимагає багато рутинного коду
Видалення логіки, що створює залежності із самого класу, і передавання їх через конструктори або методи — хороша ідея, але вона вимагає багато рутинного коду, що створюватиме дерево залежностей. Крім того, це може збільшити кількість параметрів у методах, які приймать залежності, і вплинути на читабельність.
Наприклад, Connection-об’єкт створюється в HttpServletVerticle і передається в сервіс:
pool.getConnection(res -> { if (res.succeeded()) { customerService.getCustomers(0, 0, res.result())
Потім сервіс передає його в репозиторій:
public class CustomerService { public Future<List<Customer>> getCustomers(int offset, int limit, SqlConnection connection) { return customerRepository.getCustomers(offset, limit, connection);}
І нарешті, репозиторій використовує його:
public class CustomerRepository { public Future<List<Customer>> getCustomers(int offset, int limit, SqlConnection connection) {
DI-фреймворки
- Weld — орієнтований на програми Enterprise-рівня й надає багато можливостей, не потрібних мікросервісам.
- Spring — використовує рефлексію та впливає на час/пам’ять під час запуску.
- Google Guice — легкий
DI-фреймворк. Використовує рефлексію та впливає на час/пам’ять під час запуску. - Dagger 2 — легкий (<100 kb), швидкий compile-time DI. Базується на JSR-330. Створений, в першу чергу, для Android, але підійде для будь-якого Java-проекту. Спочатку може здаватися складним.
- Micronaut DI — швидкий compile-time DI, який створили під впливом Dagger колишні розробники зі Spring. Базується на JSR-330.
Micronaut
Перед тим як ми побачимо, чому Micronaut DI здається найкращим варіантом, ось короткий огляд самого фреймворку:
- Створений розробниками Grails.
- Базується на JVM — Java/Groovy/Kotlin.
- Підтримує реактивні й non-blocking аплікації.
- Швидкий час запуску й мінімальне споживання пам’яті.
- Найменший HelloWorld JAR на Micronaut займає 12 MB (14 MB на Groovy).
- Запускається на 10 MB max heap (20 MB для Groovy).
- Час запуску: кілька сотень мілісекунд (20 мілісекунд на GraalVM!).
- Головна особливість — DI, AOP і генерація проксі відбуваються під час компіляції.
Micronaut DI
Micronaut — доволі новий фреймворк, який створювали для мікросервісів з урахуванням усіх недоліків наявних рішень. Саме тому на тлі інших варіантів він найбільше підходить для нашого випадку. Розробники подбали про те, щоб Micronaut DI можна було використовуватися абсолютно незалежно від самого фреймворку.
Підсумуємо переваги цього інструмента:
- Дані, необхідні для Dependency Injection, генеруються під час компіляції. Це істотно зменшує час запуску й затрати пам’яті, а розмір коду практично не впливає на ці показники.
- JSR-330.
- Підтримка Java/Groovy/Kotlin.
- Легкий для старту.
Інтеграція з Micronaut DI
Отже, у нас є простий Vert.x-проект. Час додати до нього Micronaut! Для цього потрібно підключити додаткову залежність і плагін для обробки анотацій:
<dependency> <groupId>io.micronaut</groupId> <artifactId>micronaut-inject</artifactId> <version>${micronaut.version}</version> </dependency> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <source>11</source> <target>11</target> <annotationProcessorPaths> <path> <groupId>io.micronaut</groupId> <artifactId>micronaut-inject-java</artifactId> <version>${micronaut.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin>
Відрефакторений код
Micronaut підтримує JSR-300, тому можна легко перетворити залежності на біни, використовуючи анотації @Singleton або @Named.
@Singleton public class CustomerRepository {
Переважно існує один Composition Root, де збираються всі залежності. Але в проекті з Vert.x може бути корисно мати окремий контекст для кожного вертікла. Це зробить вертікли повністю ізольованими, і тоді не треба перейматися, що різні вертікли використають не thread-safe синглтони.
public CustomerNotificationVerticle() { BeanContext beanContext = BeanContext.run(); this.customerService = beanContext.getBean(CustomerService.class); this.notifier = beanContext.getBean(CustomerNotifier.class); }
Інші вертікли слід створити так само.
Залишилося тільки написати фабрику, що створить реалізацію інтерфейсу CustomerNotifier, і фабрику для створення Connection Pool.
@Factory public class NotifierFactory { @Singleton CustomerNotifier notifier() {return new EmailNotifier();} } @Factory public class PoolFactory { private static MySQLConnectOptions connectOptions = new MySQLConnectOptions() .setPort(InMemoryDBHandler.PORT) .setHost("localhost") .setDatabase(InMemoryDBHandler.DB) .setUser(InMemoryDBHandler.USER) .setPassword(InMemoryDBHandler.PASSWORD); private static PoolOptions poolOptions = new PoolOptions() .setMaxSize(5); @Singleton MySQLPool createPool() { return MySQLPool.pool(VertxSingletonHolder.vertx(), connectOptions, poolOptions);}
Тепер Connection Pool можна легко отримати там, де він потрібний:
@Singleton public class CustomerRepository { private MySQLPool pool; public CustomerRepository(MySQLPool pool) { this.pool = pool; }
Не потрібно передавати pool/connection у методи: список параметрів став меншим і читабельнішим.
@Singleton public class CustomerService { private CustomerRepository customerRepository; ... public Future<List<Customer>> getCustomers(int offset, int limit){ return customerRepository.getCustomers(offset, limit); }
Вплив на запуск програми
Середній час запуску без DI становив 500 мс. Після додавання Micronaut DI час збільшився до ~700 мс.
Вплив на запуск програми з ростом залежностей
Команда Micronaut заявляє, що збільшення кількості коду не вплине на запуск програми. Щоб це перевірити, було згенеровано нові біни. Кожен бін створюється як прототип і передається через конструктор.
Як видно на графіку, використовуючи навіть ~7000 залежностей, час запуску збільшився на ~1100 мс — і це з трьома окремими контекстами бінів.
Що з недоліками
- «Використання абстракцій може збільшити кількість класів». Вирішення цієї проблеми залежить від програміста, і необов’язково вносити додаткову абстракцію без необхідності.
- «Швидкодія під час старту програми може погіршитися». Завдяки генеруванню необхідних класів під час компіляції вплив на запуск програми є незначним і він практично не збільшуватиметься з розміром коду.
- «DI може виявитися зайвим для невеликих проектів». Як було з’ясовано, DI дуже корисний навіть для мікросервісів, тому його використання цілком виправдано.
- «Код прив’язано до
DI-фреймворку». Оскільки Micronaut підтримує JSR-330, у майбутньому його можна замінити будь-яким іншим JSR-330-фреймворком. Єдиною прив’язкою до фреймворку є логіка створення бінів (@Factory).
Які переваги
Чистий код
Як було з’ясовано, DI робить написання коду набагато простішим. Поганий дизайн коду дає невелике прискорення на початку, але з часом стає щораз важче додавати новий функціонал. Навіть невеликі зміни вимагають більше часу і можуть спричинити регресію.
«За якихось рік-два команди, що дуже швидко рухалися вперед на самому початку проекту, починають повзти зі швидкістю равлика. Кожна зміна, яку вносять у код, порушує його роботу у двох-трьох місцях. Жодна зміна не проходить тривіально. Для кожного доповнення чи модифікації системи потрібно „розуміти“ всі хитросплетіння коду, щоб у програмі їх ще побільшало». Роберт Мартін «Чистий код»
Maintenance
DI робить код читабельнішим. Написання читабельного коду є значно ресурсно-ефективнішою стратегією, ніж альтернативна стратегія написання коду «якнайшвидше».
Створюючи код, який легко підтримувати, можна оптимізувати до 70% часу й коштів, потрібних на maintenance, на відміну від 30% часу та коштів для написання коду.
Продуктивність
Побічним ефектом, який може покращити продуктивність, є те, що працювати із чистим кодом набагато приємніше, ніж з невпорядкованим.
Тестування
Оскільки DI заохочує програміста писати код, який не створює залежності самостійно, його набагато простіше покривати unit-тестами. Це скорочує час на написання тестів і дає можливість тестувати більше сценаріїв, що також зменшує кількість дефектів.
Як ми показали на прикладах, деякі класи дуже важко тестувати, навіть використовуючи PowerMock (через проблеми сумісності Vert.x). Пошук обхідних шляхів може істотно збільшити час тестування такого коду.
Ізоляція вертіклів
Як було зазначено раніше, комунікація між вертіклами має бути реалізована через Event Bus. Використання того самого екземпляра різними вертіклами порушує це правило й може призвести до несподіваної поведінки. Використовуючи DI, можна не тільки вирішити цю проблему, а й зробити можливим написання безпечного коду, навіть не думаючи про це.
Висновки
Як ми з’ясували, додавання DI до мікросервісу є цілком виправданим. Це збільшує продуктивність, скорочує час тестування, зменшує кількість дефектів, покращує maintainability й полегшує процес упровадження нового функціонала в майбутньому.
Інвестування часу в написання хорошого коду насправді зменшує вартість розробки програмного забезпечення.