Pull to refresh

jugger – внедрение зависимостей как в Android

Reading time12 min
Views2.6K

Привет, меня зовут Иван и я Android разработчик. Но еще я занимаюсь Flutter разработкой. Я как разработчик, который начинает изучать новую технологию или фреймворк, начинаю сначала искать аналоги библиотек из своей основной сферы. Надеюсь я такой не один. Например Retrofit для http запросов, Dagger для di и т. д. В 2018 году, когда только познакомился с Flutter, был пакет который повторял функционал Dagger-а — это inject.dart. Но на самом деле его нельзя назвать полноценным пакетом, так как он был выложен командой гугла в открытый доступ для демонстрации того, что на dart можно написать инструмент который использует кодогенерацию. Сейчас inject.dart заброшен и не поддерживается. На GitHub у него 855 звезд, можно сказать что сообществу Flutter-а интересен такой пакет как Dagger из Java. Поэтому в 2019 году я решил написать собственный пакет, который был вдохновлен Dagger 2 и inject.dart. Целью было удовлетворить свои потребности в разработке, хотелось иметь такую же библиотеку для Di как и в Java(Android). Второстепенная цель это изучение кодогенерации в Dart.

Jugger — это пакет для внедрения зависимостей в Dart использующий кодогенерацию. Отличительная его черта от других Di контейнеров это то что все проверки на ошибки в графе зависимостей проводятся во время генерации кода. Это значит что не будет runtime ошибок например из-за забытой зависимости.

Подключение

Для начала работы с jugger нужно подключить следующие пакеты в pubspec.yaml:

dependencies:
  jugger: ^2.1.0

dev_dependencies:
  build_runner: ^2.1.7
  jugger_generator: ^2.2.1

Так как jugger использует кодогенерацию, нужно ее запускать с помощью команды:

Если используете в Flutter проекте:

flutter pub run build_runner build

Если используете в Dart проекте:

dart pub run build_runner build

Для лучшего понимая работы jugger буду объяснять на реальных примерах.

Пример 1. Аналитика в приложении.

В приложении используется аналитика Google Analytics и Flurry. Чтобы не использовать SDK этих библиотек напрямую, нужно использовать класс-обертку.

Объявим классы аналитики:

import 'package:jugger/jugger.dart';

class Firebase {
  @inject //1
  const Firebase();
  
  void trackEvent(String name) {
    //...
  }
}

class Flurry {
  @inject
  const Flurry();
  
  void trackEvent(String name, Map<String, Object> params) {
    //...
  }
}

class Tracker {
  @inject
  const Tracker({
    required this.firebase,
    required this.flurry,
  });

  final Firebase firebase;
  final Flurry flurry;

  void trackApplicationStartedEvent() {
    firebase.trackEvent('started');
    flurry.trackEvent('started', const <String, String>{});
    print('track started event');
  }
}
  1. Для того чтобы jugger использовал классы в графе зависимостей, нужно конструктор класса пометить аннотацией @inject. Делаем это со всеми тремя нашими классами.

Теперь нужно объявить компонент и перечислить в нем классы которые он должен предоставлять:

@Component() // 1
abstract class AppComponent { // 2
  Tracker getTracker(); // 3
}
  1. Класс компонента помечается аннотацией @Component. Компонент — это класс который содержит в себе граф зависимостей и по требованию возвращает экземпляр определенного типа.

  2. Компонент должен быть абстрактным.

  3. Так говорим jugger-у что он должен предоставлять экземпляр нашего Tracker-а. Важен только тип который возвращает метод, имя может быть любым.

Запускаем кодогенерацию. Результатом выполнения должно быть:

...

[INFO] Caching finalized dependency graph...
[INFO] Caching finalized dependency graph completed, took 35ms

[INFO] Succeeded after 1.2s with 1 outputs (1 actions)

Process finished with exit code 0

Jugger сгенерировал файл нашего компонента. В моей случае он называется example1.jugger.dart. Соответсвует названию файла в котором находится компонент + jugger.dart в конце.

Теперь попробуем создать наш компонент и вызвать событие трекера.

import 'package:jugger/jugger.dart';

import 'example1.jugger.dart'; // 1

void main() {
  final AppComponent appComponent = JuggerAppComponent.create(); // 2
  appComponent.getTracker().trackApplicationStartedEvent(); //3
}
  1. Импортируем сгенерированный файл.

  2. Название сгенерированного компонента будет совпадать с названием интерфейса с префиксом Jugger.

  3. Получаем на Tracker и логируем событие. Все хорошо, кроме того что многократный вызов getTracker будет создавать новый экземпляр Tracker-а что не есть хорошо. Нужно сделать так чтобы трекер был в единственном экземпляре. Это можно сделать следующим образом:

...
@singleton // 1
class Tracker {
...
  1. Добавили аннотацию @singleton к классу Tracker. Это значит что данный класс будет в единственном экземпляре в рамках компонента.

    Запустим еще раз наш код с assert:

...
  assert(identical(appComponent.getTracker(), appComponent.getTracker()));

Как и ожидалось assert не сработал, а это значит многократный вызов getTracker один и тот же экземпляр.

Идеальный пример, в котором все участвующие в графе зависимостей классы помечены аннотациями @inject и @singleton. Что если нужно использовать классы из других библиотек? Для этого существую модули. Модуль — это простой класс, который содержит логику для создания объектов. Модули содержат только методы, которые возвращают зависимость определенного типа.

Модифицируем наш код.

Удалим @inject у конструкторов:

class Firebase {
  const Firebase();

  void trackEvent(String name) {
    //...
  }
}

class Flurry {
  const Flurry();

  void trackEvent(String name, Map<String, Object> params) {
    //...
  }
}

Объявим модуль:

@module // 1
abstract class AppModule {
  @provides // 2
  static Flurry provideFlurry() => const Flurry(); // 3

  @provides
  static Firebase provideFirebase() => const Firebase();
}
  1. Класс модуля помечается аннотацией @module.

  2. Методы модуля помечаются аннотацией @provides, их jugger будет использовать для составления графа зависимостей если это ему будет нужно.

  3. Объявляем метод который возвращает экземпляр Flurry. Его конструктор не помечен аннотацией @inject, Поэтому jugger будет искать его в модулях.

  4. Метод должен быть статическим, имя может быть любым. В рамках всех модулей которые использует компонент, может быть только один метод который возвращает класс одного типа. Объявив несколько методов, которые возвращают один тип, jugger не поймет какой из них нужно использовать.

Подключим модуль к нашему компоненту:

@Component(modules: <Type>[AppModule]) // 1
abstract class AppComponent {
  Tracker get tracker; // 2
}
  1. Указываем какие модули может использовать jugger для составления графа зависимостей.

  2. Можно записать в виде геттера, так тоже допустимо.

Так как теперь используем геттер в компоненте, поправим и main метод:

void main() {
  final AppComponent appComponent = JuggerAppComponent.create(); // 2
  appComponent.tracker.trackApplicationStartedEvent(); //3
  assert(identical(appComponent.tracker, appComponent.tracker));
}

Запускам генерацию. Результат ожидаем, реализовали его с помощью модуля. Но Tracker мы оставили без изменений, давайте и его тоже добавим в модуль, удалив аннотации:

class Tracker {
  const Tracker({
    required this.firebase,
    required this.flurry,
  });
...
@module
abstract class AppModule {
  @provides
  @singleton // 1
  static Tracker provideTracker(
    Firebase firebase, // 2
    Flurry flurry,
  ) =>
      Tracker(firebase: firebase, flurry: flurry);
...
  1. Аннотацией @singleton теперь помечен метод модуля, который предоставляет экземпляр Tracker-а. Все так же как и было с конструктором, трекер будет в единственном экземпляре.

  2. provide метод принимает экземпляры аналитики, которые используются для создания Tracker.

Пример 2. Несколько конфигураций приложения.

Приложение может работать в нескольких режимах: dev и release. В зависимости от режима приложение имеет определенный конфиг.

Начнем с объявления класса конфига и компонента:

// 1
enum AppEnvironment {
  dev,
  release,
}

class AppConfig {
  const AppConfig(this.baseUrl);

  final String baseUrl;
}

@Component(modules: <Type>[AppModule])
abstract class AppComponent {
  AppConfig getConfig();
}

@module
abstract class AppModule {
  @provides
  @singleton
  static AppConfig provideAppConfig(AppEnvironment environment) {
    // 2
    switch(environment) {
      case AppEnvironment.dev:
        return const AppConfig('https://dev.com/');
      case AppEnvironment.release:
        return const AppConfig('https://release.com/');
    }
  }
}
  1. Окружение нашего приложения представленное в виде перечисления.

  2. В зависимости от окружения возвращаем нужный конфиг.

Запустим генерацию кода. Так, jugger не понял нас и с ошибкой завершил ее. Ошибка должна быть такой:

error: Provider for (AppEnvironment) not found

Все верно, jugger не нашел "провайдера" для AppEnvironment и сообщил нам об этом. Как же передать значения нашего окружения компоненту? Это можно сделать с помощью ComponentBuilder. ComponentBuilder — это класс который собтирает компонент, передавая ему наши аргументы. Для нашего кейса Builder будет выглядеть так:

@componentBuilder // 1
abstract class AppComponentBuilder { // 2
  AppComponentBuilder setAppEnvironment(AppEnvironment environment); // 3
  AppComponent build(); // 4
}
  1. Класс помечается аннотацией @componentBuilder.

  2. ComponentBuilder должен быть абстрактным.

  3. Передаем значение окружения в качестве аргумента компонента. Метод должен возвращать тип Builder-а и иметь один параметр. Имя не имеет значения.

  4. Обязательный метод build. Должен возвращать тип компонента.

Снова запустим генерацию кода. Генерация прошла успешно. С Builder-ом создание компонента выглядит так:

void main() {
  final AppComponent appComponent = JuggerAppComponentBuilder() // 1
      .setAppEnvironment(AppEnvironment.release) // 2
      .build(); // 3
  print(appComponent.getConfig().baseUrl);
}
  1. Вызываем сгенерированный JuggerAppComponentBuilder.

  2. Передаем значение окружения.

  3. Вызываем build чтобы создать наш компонент.

Хорошо бы не создавать AppConfig в методе provideAppConfig, а зависеть от двух вариантов и возвращать тот который нужно. Это можно сделать с помощью квалификатора @Named.

@module
abstract class AppModule {
  @provides
  @Named('dev') // 1
  static AppConfig provideDevAppConfig() {
    return const AppConfig('https://dev.com/');
  }

  @provides
  @Named('release')
  static AppConfig provideReleaseAppConfig() {
    return const AppConfig('https://dev.com/');
  }

  @provides
  @singleton
  static AppConfig provideAppConfig(
    AppEnvironment environment,
    @Named('dev') AppConfig dev, // 2
    @Named('release') AppConfig release,
  ) {
    switch (environment) {
      case AppEnvironment.dev:
        return dev;
      case AppEnvironment.release:
        return release;
    }
  }
}
  1. Jugger поддерживает Qualifier. Qualifier используется если нужно различить два разных экземпляра класса одного типа. Пометили метод аннотацией @Named с указанием тега. Теперь jugger будет учитывать не только тип при составлении графа, но и тег.

  2. Также пометили "зависимость" аннотацией @Named с нужным тегом. Когда jugger видит квалификатор, он пытается найти "провайдера" для такого типа с этим тегом.

Выделили для каждой версии конфига provide метод, и используем сразу два экземпляра конфига в provideAppConfig, только вот в результате будет использован только один, не зачем сразу же создавать два. Инициализацию можно отложить, используя интерфейс IProvider. IProvider<T> - интерфейс имеющий метод get который возвращает экземпляр класса определенного типа. Jugger имеет две его реализации: Provider<T> — каждый вызов get() возвращает новый экземпляр. SingletonProvider<T> — понятно по названию, возвращает всегда один экземпляр, кешируя значение.

Чтобы добавить отложенную реализацию конфига нужно сделать так:

@provides
@singleton
static AppConfig provideAppConfig(
  AppEnvironment environment,
  @Named('dev') IProvider<AppConfig> dev, // 1
  @Named('release') IProvider<AppConfig> release,
) {
  switch (environment) {
    case AppEnvironment.dev:
      return dev.get(); // 2
    case AppEnvironment.release:
      return release.get();
  }
}
  1. Вместо нужного типа используем IProvider чтобы не инициализировать конфиг сразу.

  2. Вызываем get(), чтобы получить экземпляр одного конфига, второй не будет инициализирован.

В этом примере используем квалификатор @Named чтобы компонент мог по тегу предоставлять разные значения для одного типа. В его использовании есть недостаток, нужно следить за тем, что тег был всегда актуальным и правильным, если меняем значение в одном месте, нужно это же сделать и в других местах. Запросто это можно забыть. Конечно jugger сообщим нам что он не может найти тип с таким тегом, но это можно избежать, если использовать кастомный квалификатор.

Кастомный квалификатор объявляется так:

@qualifier // 1
class Release {
  const Release();
}

const Release release = Release(); // 2

@qualifier
class Dev {
  const Dev();
}

const Dev dev = Dev();
  1. Помечаем класс @qualifier. Теперь это наш кастомный квалификатор.

  2. Для удобства можно объявить глобальную переменную и использовать ее

Теперь заменяет @Named аннотации нашим квалификатором:

@module
abstract class AppModule {
  @provides
  @dev
  static AppConfig provideDevAppConfig() {
    return const AppConfig('https://dev.com/');
  }

  @provides
  @release
  static AppConfig provideReleaseAppConfig() {
    return const AppConfig('https://release.com/');
  }

  @provides
  @singleton
  static AppConfig provideAppConfig(
    AppEnvironment environment,
    @dev IProvider<AppConfig> dev,
    @release IProvider<AppConfig> release,
  ) {
    switch (environment) {
      case AppEnvironment.dev:
        return dev.get();
      case AppEnvironment.release:
        return release.get();
    }
  }
}

Запускаем генерацию кода. Результат тот же самый что и с использование @Named.

Пример 3. Локальный компонент.

Любое приложение состоит из экранов. Экран это короткоживущая сущность, потому что его "закрыть"(уничтожить) и "открыть"(создать) снова и снова. Внутри себя он использует нужные ему классы, экземпляры которых "живут" пока существует экран. Все эти классы нужно как-то создавать, и для этого можно использовать jugger. При использовании jugger можно выделить долгоживущие и короткоживущие компоненты. К долгоживущим компонентам относится компонент приложения(в примерах выше это AppComponent). К короткоживущим — компонент экрана. В этом примере покажу как использовать классы которые предоставляет AppComponent в компоненте экрана.

В приложении есть AppComponent который предоставляет экземпляр Logger-а. Кроме этого есть главный экран, который использует Logger для отладки.

Объявим наш компонент и логгер:

@singleton
class Logger {
  @inject
  const Logger();

  void debug(String tag, String message) {
    print('$tag: $message');
  }
}

@Component()
abstract class AppComponent {
  Logger getLogger();
}

Теперь объявим компонент нашего экрана. Чтобы он не был пустым, добавим логическую сущность, назовем ее MainScreenBloc(BloC из Flutter).

@singleton // 1
class MainScreenBloc {
  @inject
  MainScreenBloc(this.logger) { // 2
    logger.debug('MainScreenBloc', 'init');
  }

  final Logger logger;
}

@Component(dependencies: <Type>[AppComponent]) // 3
abstract class MainScreenComponent {
  MainScreenBloc getMainScreenBloc();
}

@componentBuilder
abstract class MainScreenComponentBuilder {
  MainScreenComponentBuilder setAppComponent(AppComponent appComponent); // 4
  MainScreenComponent build();
}
  1. В рамках экрана может быть только один экземпляр bloc, поэтому помечаем аннотацией @singleton.

  2. bloc зависит от логгера, jugger возьмет его из AppComponent.

  3. Чтобы использовать классы которые предоставляет AppComponent, его нужно добавить в поле dependencies. Компонент может зависть сразу он нескольких других компонентов.

  4. Уже знакомый нам componentBuilder, AppComponent нужно передать как аргумент компонента.

main функция будет выглядеть так:

void main() {
  final AppComponent appComponent = JuggerAppComponent.create();
  appComponent.getLogger().debug('main', 'launch'); // 1

  final MainScreenComponent mainScreenComponent =
      JuggerMainScreenComponentBuilder()
          .setAppComponent(appComponent) // 2
          .build();

  mainScreenComponent.getMainScreenBloc(); // 3
}
  1. Логируем запуск приложения.

  2. Передаем экземпляр компонента приложения в компонент экрана.

  3. jugger инициализирует классы лениво, вызываем метод чтобы bloc залогировал свою инициализацию.

В логих видим следующее:

main: launch
MainScreenBloc: init

Process finished with exit code 0

На этих трех примерах я попытался рассказать об основных возможностях jugger-а, но это еще не все.

Типы внедрений

Jugger и как и другие di контейнеры поддерживает три основных типа внедрения зависимостей:

  1. Через конструктор

  2. Через поле

  3. Через метод

Первый тип самый предпочтительный и был показан в примерах выше. Два остальных не рекомендуется использовать, но jugger их поддерживает.

Внедрение через поле

class InjectableClass {
  @inject // 1
  late String injectableField;
}

@Component(modules: <Type>[AppModule])
abstract class AppComponent {
  void inject(InjectableClass target); // 2
}

@module
abstract class AppModule {
  @provides
  static String provideString() => 'hello';
}
  1. Поле которое нужно инжектировать нужно пометить аннотацией @inject.

  2. Инжектируемый класс нужно указать в методе с одним аргументом. Метод должен возвращать тип void, имя может быть любым.

Инжект класса выглядит следующим образом:

void main() {
  final AppComponent appComponent = JuggerAppComponent.create();

  final InjectableClass myClass = InjectableClass();

  appComponent.inject(myClass); // 1
  print(myClass.injectableField); // 2
}
  1. Вызываем метод и передаем экземпляр инжектируемого класса.

  2. Проверяем что инжект прошел успешно.

Внедрение через метод

Не сильно отличается от инжекта через поле, нужно лишь пометить @inject аннотацией метод:

class InjectableClass {
  @inject
  void init(String string) {
    print(string);
  }
}

Неленивая инициализация

Все классы которые предоставляет компонент иницилизируются лениво, то есть когда их запрашиваем. Но бывают случаи что определенный класс нужно инициализировать сразу же после создания компонента. Для этого существует аннотация @nonLazy.

@singleton
class Logger {
  Logger(this.tag) {
    print('$tag: init');
  }

  final String tag;
}

@Component(modules: <Type>[AppModule])
abstract class AppComponent {
  Logger getLogger();
}

@module
abstract class AppModule {
  @provides
  @singleton
  @nonLazy // 1
  static Logger provideLogger() => Logger('myLogger');
}

void main() {
  final AppComponent appComponent = JuggerAppComponent.create(); // 2
}
  1. Помечаем provide метод аннотацией @nonLazy. jugger инициализирует Logger сразу же после создания компонента.

  2. Создаем компонент и ничего не вызываем у него. В логах видим ожидаемое сообщение "myLogger: init".

Связка интерфейса и реализации

Jugger позволяет короткой записью связать интерфейс и его реализацию. Вместо того чтобы воспользоваться аннотацией @provide, при этом передать все зависимости через аргументы метода, можно воспользоваться аннотацией @binds.

@Component(modules: <Type>[AppModule])
abstract class AppComponent {
  Tracker getTracker();
}

abstract class Tracker {
  void trackApplicationStartedEvent();
}

class TrackerImpl implements Tracker {
  @inject // 1
  const TrackerImpl();

  @override
  void trackApplicationStartedEvent() {
    print('track started event');
  }
}

@module
abstract class AppModule {
  @binds // 2
  @singleton
  Tracker bindTracker(TrackerImpl impl); // 3
}

void main() {
  final AppComponent appComponent = JuggerAppComponent.create();
  assert(appComponent.getTracker().runtimeType == TrackerImpl);
  print(appComponent.getTracker().runtimeType);
}
  1. Важно как и предыдущих примерах пометить конструктор аннотацией @inject.

  2. этой аннотацией говорим jugger-у что нужно связать интерфейс трекера с его реализацией.

  3. Абстрактный метод, который возвращает интерфейс класса. Должен иметь один параметр, этим параметром должна быть реализация интерфейса.

Теперь при запросе Tracker-а компонент вернет TrackerImpl.

А что с Flutter?

На Github есть пример использования jugger во Flutter.

Полезные ссылки:

  1. jugger в pub.dev: https://pub.dev/packages/jugger

  2. jugger generator в pub.dev: https://pub.dev/packages/jugger_generator

  3. Репозиторий на GitHub: https://github.com/ivk1800/jugger.dart

  4. Телеграм канал для вопросов, предложений и критики: https://t.me/jugger_chat

Tags:
Hubs:
Total votes 1: ↑1 and ↓0+1
Comments3

Articles