Репутація українського ІТ. Пройти опитування Асоціації IT Ukraine
×Закрыть

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 вертікли (для тих, хто не знайомий із фреймворком, вертікл можна спрощено вважати модулем / стартовою точкою програми):

  1. HttpServerVerticle — слухає порт 8080 і повертає JSON з користувачами.
  2. CustomerProducerVerticle — додає нового користувача кожних 5 секунд.
  3. 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 робить написання коду набагато простішим. Поганий дизайн коду дає невелике прискорення на початку, але з часом стає щораз важче додавати новий функціонал. Навіть невеликі зміни вимагають більше часу і можуть спричинити регресію.

«За якихось рік-два команди, що дуже швидко рухалися вперед на самому початку проекту, починають повзти зі швидкістю равлика. Кожна зміна, яку вносять у код, порушує його роботу у двох-трьох місцях. Жодна зміна не проходить тривіально. Для кожного доповнення чи модифікації системи потрібно „розуміти“ всі хитросплетіння коду, щоб у програмі їх ще побільшало». Роберт Мартін «Чистий код»

Image Source

Maintenance

DI робить код читабельнішим. Написання читабельного коду є значно ресурсно-ефективнішою стратегією, ніж альтернативна стратегія написання коду «якнайшвидше».

Створюючи код, який легко підтримувати, можна оптимізувати до 70% часу й коштів, потрібних на maintenance, на відміну від 30% часу та коштів для написання коду.

Продуктивність

Побічним ефектом, який може покращити продуктивність, є те, що працювати із чистим кодом набагато приємніше, ніж з невпорядкованим.

Тестування

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

Як ми показали на прикладах, деякі класи дуже важко тестувати, навіть використовуючи PowerMock (через проблеми сумісності Vert.x). Пошук обхідних шляхів може істотно збільшити час тестування такого коду.

Ізоляція вертіклів

Як було зазначено раніше, комунікація між вертіклами має бути реалізована через Event Bus. Використання того самого екземпляра різними вертіклами порушує це правило й може призвести до несподіваної поведінки. Використовуючи DI, можна не тільки вирішити цю проблему, а й зробити можливим написання безпечного коду, навіть не думаючи про це.

Висновки

Як ми з’ясували, додавання DI до мікросервісу є цілком виправданим. Це збільшує продуктивність, скорочує час тестування, зменшує кількість дефектів, покращує maintainability й полегшує процес упровадження нового функціонала в майбутньому.

Інвестування часу в написання хорошого коду насправді зменшує вартість розробки програмного забезпечення.

LinkedIn

21 комментарий

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Интересная статья, спасибо.
А возможно ли как-то избавиться от неявной зависимости

BeanContext beanContext = BeanContext.run();

в конструкторе класса вертикла? Здесь сразу возникает вопрос, а как это мокать в тестах?
У нас на проекте связка Vert.x + Spring DI. Для того что бы инжектить зависимости в вертиклы используем интерфейс VerticleFactory. Это дает возможность инжектить все через конструктор.

Можна передати BeanContext в конструктор вертікла. Або написати простеньку BeanContextFactory, і передавати її.

Чудово, що стає все більше технічних статей українською :)

ИТ-шникам надобно бы английский, как латина средневековому ученому.

а то ничего что большАя часть АТО-шников русскоязычные?

Ну це наслідки багаторічної русифікації, але то все пройде

в Причерноморье у нас не говорили ни по старосрусски ни по российски/украински, там жили кочевники и перед тем, как Хаджибей стал Одессой там говорили по-Татарски, как и в нашем Крыму. Потом стали говорить по Российски и кто кого русифицировал?

у вас — це коли і де?

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

у нас это В Украине :)

там окрім татарів, яких було загалом не так багато, була ще купа греків, болгарів, німців і т.п.

это уже после того как татар «попросили» оттуда, а до того украинский и российский языки там только у рабов можно было услышать да на невольничьих рынках, как и в Крыму. В Российской Империи кого туда толкьо не селили: немцев, болгар, русских, украинцев, так что там был полный интернационал :).

тоді ще татар звідти не попросили, але регіон вже активно заселявся сусідами, що перемогли їх на полі бою

По поводу «попросили» я не про Крим, я про Причорномор’я. (до питання)

Останні роки роблю так:

public static void main(String[] args) {
    Holder holder = new Holder();
    ...
    SomeHandler handler = new SomeHandler(holder);
    ...
    server.run(handler);
}

public class SomeHandler {
   
    private final SomeSingleton someSingleton;
   
    public SomeHandler(Holder holder) {
        this.someSingleton = holder.someSingleton;
    }
   
}

Плюси:

— Код простий і легко читається
— Максимально швидкий старт
— Ніяких залежностей, анотацій, контекстів
— Максимально просто мокати, тестувати
— Fail fast. Якщо ініціалізація біна фейлиться — це відбувається зразу, до підняття всього іншого

З мінусів хіба що відсутність лейзі лоадингу, але для нас це швидше антипатерн. Оскільки в нас зразу велике навантаження йде.

Еще минусы:
— длинных граф зависимостей с множеством уровней абстракций потребует писать код или много копипасты
— если есть классы со стейтом, то их прийдется менеджить руками и писать код
— необходимость сделать инстанцирование контекст-зависимым вызовет необходимость писать код
— необходимость добавить интерсепторы что бы сделать трейс-логирование или какой-то circuit breaker потребует писать код
— в самом конце вы поймёте что сделали ещё один велосипедный di контейнер — вместо взять готовый.

Но в целом если проект простой(на Java такое бывает?!) — пару классов, мало уровней абстракций, анемичные сервисы и мало взаимодействия с источниками сайд эффектов — то подключать контейнер нет смысла и можно писать код с инвертированными зависимостями как вы. ;)

———

Мінуси

Самый главный минус di — он наполняет детерминированный код сайд эффектами, и все ваше тестирование, перформанс, да и понимание работы кода обрастает проблемами такими же как если вы инстанцируете зависимости внутри класса, если не ещё большими.

длинных граф зависимостей с множеством уровней абстракций потребует писать код или много копипасты

Яким чином? Можна приклад?

если есть классы со стейтом, то их прийдется менеджить руками и писать код

Не бачу як це пов’язано з DI.

необходимость сделать инстанцирование контекст-зависимым вызовет необходимость писать код

Просто ініціалізуєте сінглон під інший контекст. Не назвав би 2 строки кода копі пасти «писати код».

необходимость добавить интерсепторы что бы сделать трейс-логирование или какой-то circuit breaker потребует писать код

Так і робим. Не бачу тут проблеми. Цей код пишеться 1 раз і потім всюди перевикористовується. Я заглядав в код деяких circuit breaker. Космічні кораблі там, де можна обійтись звичайними LongAdder, AtomicInteger, etc. А потім хелло ворлд хаває 5 ГБ.

Яким чином? Можна приклад?

Например у вас есть апишка, которая из контролера вызывает какой-то бизнес-леер — а он там сделан по паттернам, а посему в нем там куча каких-то стратегий, фабрик — под ними еще репозиториев, которые работают с sql/redis/mongo и прочего solid java ужаса — как вы видите инстанцирование в имплементации такого метода?

Не бачу як це пов’язано з DI.

там где есть state — там есть lifetime management обьектов в графе зависимостей. А это то что за вас нынче делают DI контейнеры. Пример у вас какая-то джоба — вам надо сделать per job scope жизни этого контекста и шарить его как зависимость и у вас таких джоб 20 скажем, как будете менеджить?

Просто ініціалізуєте сінглон під інший контекст. Не назвав би 2 строки кода копі пасти «писати код»

у меня веб сервер — и мне скажем надо в каких-то апи методах юзать подключение к postgres read-only, а в других write только. с di контейнером мне это будет стоит максимум навесить какие-то атрибуты на метод контролера , написать fluent конфигурацию резолва для контейнера правильной коннекшин стринги и все.
Как вы это будет без контейнера делать — полагаю в бизнес код засандалите весь это boilerplate?

Так і робим. Не бачу тут проблеми. Цей код пишеться 1 раз і потім всюди перевикористовується. Я заглядав в код деяких circuit breaker. Космічні кораблі там, де можна обійтись звичайними LongAdder, AtomicInteger, etc. А потім хелло ворлд хаває 5 ГБ.

можем поспорить, что я возьму готовую либу и мой time-to-market будет быстрее чем пока вы напишите свой di контейнер и circuit breaker, а по поводу того что ваш код будет оптимальней работать чем у oss комньюнити очень смелое заявление.

там где есть state — там есть lifetime management обьектов в графе зависимостей. А это то что за вас нынче делают DI контейнеры. Пример у вас какая-то джоба — вам надо сделать per job scope жизни этого контекста и шарить его как зависимость и у вас таких джоб 20 скажем, как будете менеджить?

Это менеджит job scheduler, а не DI.

можем поспорить, что я возьму готовую либу и мой time-to-market будет быстрее чем пока вы напишите свой di контейнер и circuit breaker, а по поводу того что ваш код будет оптимальней работать чем у oss комньюнити очень смелое заявление.

Пока ты будешь настраивать Spring Boot и килотонны зависимостей в pom.xml и тп, Дмитрий уже напишет всю бизнес логику, и будет потихоньку выходить в продакшен.

не думал, что в Java мире все так плачевно, что быстрее разработать своё чем сконфигурировать готовое.

Это менеджит job scheduler, а не DI

Это кардинально меняет ситуацию, Job context заинжекченный в джобу не пример инверсии зависимости — так и запишем.

Пока ты будешь настраивать Spring Boot и килотонны зависимостей в pom.xml

start.spring.io ;-)

И вообще нефиг ежа голым задом пугать — Spring Boot для того и сделан чтоб из коробки подниматься с минимальным настраиванием (с чем он успешно справляется). А зависимости это вообще мимо кассы — они будут одинаковые в обоих случаях. У фронтенда вообще нет DI, а фолдер node_modules бесконечного размера.

Код простий і легко читається

целый абзац который не несёт смысловой нагрузки, джава такая джава.

Подписаться на комментарии