Русский
Русский
English
Статистика
Реклама

Di

Что можно положить в механизм Dependency Injection в Angular?

26.08.2020 16:11:24 | Автор: admin
Почти каждый разработчик на Angular может найти в Dependency Injection решение своей проблемы. Это хорошо было видно в комментариях к моей прошлой статье. Люди рассматривали различные варианты работы с данными из DI, сравнивали их удобство для той или иной ситуации. Это здорово, потому что такой простой инструмент дает нам столько возможностей.

Но несколько человек отписались мне, что им тяжело понять DI и его возможности в Angular. В интернете не так уж много материалов о том, как использовать DI эффективно, и для многих разработчиков он сводится к работе с глобальными сервисами или передачей глобальных данных из корня приложения в компоненты.

Давайте посмотрим на этот механизм в Angular чуть глубже.




Знаете ли вы свои зависимости?


Иногда нелегко понять, сколько зависимостей имеет ваш код.

Например, взгляните на этот псевдокласс и посчитайте, сколько зависимостей он имеет:

import { API_URL } from '../../../env/api-url';import { Logger } from '../../services/logger'; class PseudoClass {   request() {       fetch(API_URL).then(...);   }    onError(error) {       const logger = new Logger();        logger.log(document.location, error);   }}

Ответ
fetch браузерное API, опираемся на глобальную переменную, ожидая, что она объявлена.

API_URL импортированные данные из другого файла тоже можно считать зависимостью вашего класса (зависимость от расположения файла).

new Logger() также импортированные данные из другого файла и пересоздания множества экземпляров класса, когда нам достаточно лишь одного.

document также браузерное API и завязка на глобальную переменную.


Ну и что же не так?


Например, такой класс тяжело протестировать, так как он зависит от импортированных данных из других файлов и конкретных сущностей в них.

Другая ситуация: document и fetch будут без проблем работать в вашем браузере. Но если однажды вам потребуется перенести приложение в Server Side Rendering, то в nodejs окружении необходимых глобальных переменных может не быть.

Так и что же за DI и зачем он нужен?


Механизм внедрения зависимостей управляет зависимостями внутри приложения. В принципе, для нас, как для Angular-разработчиков, эта система довольно простая. Есть две основные операции: положить что-то в дерево зависимостей или получить что-то из него.

Механизм внедрения зависимостей управляет зависимостями внутри приложения. В принципе, для нас, как для Angular-разработчиков, эта система довольно простая. Есть две основные операции: положить что-то в дерево зависимостей или получить что-то из него.

Если хотите рассмотреть DI с более теоретической стороны, почитайте о принципе инверсии управления. Также можете посмотреть интересные видеоматериалы по теме: серия видео про IoC и DI у Ильи Климова на русском или небольшое видео про IoC на английском.

Вся магия возникает от порядка, в котором мы поставляем и берем зависимости.

Схема работы областей видимости в DI:


Что мы можем положить в DI?


Первая из операций с DI что-то положить в него. Собственно, для этого Angular позволяет нам прописывать providers-массив в декораторах наших модулей, компонентов или директив. Давайте посмотрим, из чего этот массив может состоять.

Providing класса


Обычно это знает каждый разработчик на Angular. Это тот случай, когда вы добавляете в приложение сервис.

Angular создает экземпляр класса, когда вы запрашиваете его в первый раз. А с Angular 6 мы можем и вовсе не прописывать классы в массив providers, а указать самому классу, в какое место в DI ему встать с providedIn:

providers: [   {       provide: SomeService,       useClass: SomeService   },   // Angular позволяет сократить эту запись как самый частый кейс:   SomeService]


Providing значения


Также через DI можно поставлять константные значения. Это может быть как простая строка с URL вашего API, так и сложный Observable с данными.

Providing значения обычно реализуется в связке с InjectionToken. Этот объект ключ для DI-механизма. Сначала вы говорите: Я хочу получить вот эти данные по такому ключу. А позже приходите к DI и спрашиваете: Есть ли что-то по этому ключу?

Ну и частый кейс проброс глобальных данных из корня приложения.

Лучше посмотреть это сразу в действии, поэтому давайте взглянем на stackblitz с примером:



Итак, в примере мы получили зависимость из DI вместо того, чтобы импортировать ее как константу из другого файла напрямую. И почему нам от этого лучше?

  • Мы можем переопределить значение токена на любом уровне дерева DI, никак не меняя компоненты, которые его используют.
  • Мы можем мокировать значение токена подходящими данными при тестировании.
  • Компонент полностью изолирован и всегда будет работать одинаково, независимо от контекста.


Providing фабрики


На мой взгляд, это самый мощный инструмент в механизме внедрения зависимостей Angular.

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

Вот еще один stackbitz с подробным примером создания фабрики со стримом.



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

Providing существующего экземпляра


Не самый частый случай, но этот вариант бывает очень полезным инструментом.

Вы можете положить в токен сущность, которая уже была создана. Хорошо работает в связке с forwardRef.

Посмотрите еще один пример со stackblitz с директивой, которая имплементирует интерфейс и подменяет собой другой токен через useExisting. В этом примере мы хотим переопределить значение токена только в области видимости DI для дочерних компонентов элемента, на котором висит директива. Причем директива может быть любой главное, что она реализует необходимый интерфейс.



Хитрости с DI-декораторами


DI-декораторы позволяют сделать запросы к DI более гибкими.

Если вы не знаете все четыре декоратора, советую почитать вот эту статью на Медиуме. Статья на английском, но там очень классные и понятные визуализации по теме.

Не многие также знают, что DI-декораторы можно использовать в массиве deps, который готовит аргументы для фабрики в providers.

providers: [   {     provide: SOME_TOKEN,     /**      * Чтобы фабрика получила декорированное значение, используйте такой      * [new Decorator(), new Decorator(),..., TOKEN]      * синтаксис.      *      * В этом случае вы получите null, если не будет значения для      * OPTIONAL_TOKEN      */     deps: [[new Optional(), OPTIONAL_TOKEN]],     useFactory: someTokenFactory   } ]


Фабрика токена


Конструктор InjectionToken принимает два аргумента.

Второй аргумент объект с конфигурацией токена.

Фабрика токена это функция, которая вызывается в момент, когда кто-то запрашивает этот токен в первый раз. В ней можно посчитать некое стандартное значение для токена или даже обращаться к другим DI-сущностям через функцию inject.

Посмотрите пример реализации функционала стрима нажатия кнопок, но уже на фабрике токена.



Заключение


DI в Angular удивительная тема: на первый взгляд, в нем не так много различных рычагов и инструментов для изучения, но можно писать и говорить часами о тех возможностях и способах применения, которые они нам дают.

Надеюсь, этой статьей мне удалось дать вам фундамент, на основе которого вы сможете придумать собственные решения для упрощения работы с данными в ваших приложениях и библиотеках.
Подробнее..

Возможности Angular DI, о которых почти ничего не сказано в документации

13.10.2020 14:23:08 | Автор: admin

Angular это достаточно большой фреймворк. Задокументировать и написать примеры использования для каждого кейса просто невозможно. И механизм внедрения зависимостей не исключение. В этой статье я расскажу о возможностях Angular DI, о которых вы почти ничего не найдете в официальной документации.

Что вы знаете о функции inject?

Документация говорит нам следующее:

Injects a token from the currently active injector. Must be used in the context of a factory function such as one defined for an InjectionToken. Throws an error if not called from such a context.

И дальше мы видим использование функции inject в примере с tree shakable токеном:

class MyService {  constructor(readonly myDep: MyDep) {}}const MY_SERVICE_TOKEN = new InjectionToken<MyService>('Manually constructed MyService', {  providedIn: 'root',  factory: () => new MyService(inject(MyDep)),});

Это все, что говорит нам документация.

Описание функции немного размыто, и не до конца понятно, в каком именно контексте можно использовать inject. Поэтому я провел нехитрый эксперимент и выяснил, что функция прекрасно работает:

  1. В фабриках tree shakable провайдеров.

  2. Вфабриках провайдеров.

  3. В конструкторах сервисов.

  4. В конструкторах модулей.

import { Injectable, inject } from "@angular/core";import { HelloService } from "./hello.service";@Injectable({ providedIn: "root" })export class AppService {  private helloService = inject(HelloService);  constructor(){    this.helloService.say("Meow");  }}

Так как фабричная функция InjectionToken не может иметь аргументы, inject единственный способ получить данные из инжектора. Но зачем нам эта функция в сервисах, если можно просто указать необходимые зависимости прямо в параметрах конструктора?

Рассмотрим небольшой пример.

Допустим, мы имеем абстрактный класс Storage, который зависит от класса Logger:

@Injectable()abstract class Storage {  constructor(private logger: Logger) { }}

Любой другой класс, реализующий класс Storage, должен предоставить ему его зависимость в ручном режиме. Если зависимостей много, это может оказаться очень неприятным делом:

@Injectable()class LocalStorage extends Storage {  constructor(logger: Logger,              private selfDependency: SelfDepService){    super(logger);  }}

Есть два выхода из ситуации передавать в родительский класс инжектор, из которого будут извлекаться все необходимые зависимости, либо просто использовать функцию inject! Так мы избавим дочерние классы от проксирования лишних зависимостей:

@Injectable()abstract class Storage {  private logger = inject(Logger);}@Injectable()class LocalStorage extends Storage {  constructor(private selfDependency: SelfDepService){    super();  }}

Профит!

Ручная установка контекста для функции inject

Давайте обратимся к исходному коду. Нас интересует приватная переменная _currentInjector, функция setCurrentInjector и сама функция inject.

Если внимательно посмотреть, то работа функции inject становится совершенно очевидной:

  • вызов функции setCurrentInjector присваивает в приватную переменную _currentInjector переданный инжектор, возвращая предыдущий;

  • функция inject достает из _currentInjector значение по переданному токену.

Это настолько просто, что мы совершенно спокойно можем заставить работать функцию inject даже в компонентах и директивах:

import { Component, Injector, Injectable, Directive, INJECTOR, Inject } from "@angular/core";import {  inject,  setCurrentInjector as setCurrentInjector} from "@angular/core";import { HelloService } from "./hello.service";@Component({  selector: "my-app",  template: ''})export class AppComponent {  constructor(injector: Injector) {    try {      const former = setCurrentInjector(injector);      const service = inject(HelloService);      setCurrentInjector(former);      service.say("AppComponent");    } catch (e) {      console.error("Error from AppComponent: ", e);    }  }}

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

Injection flags

InjectFlags это аналог модификаторов Optional, Self, SkipSelf и Host. Используются в функциях inject и Injector.get. Документация и здесь не подвела ее почти нет:

enum InjectFlags {  Default = 0,  Host = 1,  Self = 2,  SkipSelf = 4,  Optional = 8}

Человек знающий сразу увидит здесь битовые маски. Этот же enum можно представить немного в другом виде:

enum InjectFlags {  Default = 0b0000,  Host = 0b0001,  Self = 0b0010,  SkipSelf = 0b0100,  Optional = 0b1000}

Использование одного флага

Вот так можно получить поток событий роутера, не беспокоясь о том, что модуль роутинга не подключен:

export const ROUTER_EVENTS = new InjectionToken('Router events', {providedIn: "root",factory() {const router = inject(Router, InjectFlags.Optional);return router?.events ?? EMPTY;}});

Выглядит просто. А на деле еще и безопасно, без неожиданных падений и лишних событий.

Комбинация флагов

Комбинацию флагов можно использовать при проверке, что модуль импортировался один раз. А комбинируются они при помощи побитового ИЛИ:

@NgModule()class SomeModule {  constructor(){    const parent = inject(SomeModule, InjectFlags.Optional | InjectFlags.SkipSelf);    if (parent) {      throw new Error('SomeModule is already exist!');    }  }}

Значение нужного бита получается с помощью побитового И:

const flags = InjectFlags.Optional | InjectFlags.SkipSelf;const isOptional = !!(flags & InjectFlags.Optional);

Tree shakable сервисы и *SansProviders

*SansProviders сокращение для базовых интерфейсов обычных провайдеров ValueSansProvider, ExistingSansProvider, StaticClassSansProvider, ConstructorSansProvider, FactorySansProvider, ClassSansProvider.

Tree shakable сервисы это специальный способ сказать компилятору, что сервис не нужно включать в сборку, если он нигде не используется. При этом сервис не указывается в модуле, скорее наоборот модуль указывается в сервисе:

import {Injectable} from '@angular/core';@Injectable({providedIn: SomeModule})export class SomeService {}

Как мы видим из примера, в проперти providedIn указан модуль. Это работает точно так же, как и следующий пример:

@NgModule({  providers: [SomeService]})export class SomeModule {}

providedIn также может содержать специальные значения: 'root', 'platform' и 'any'. Это достаточно хорошо описано в документации, но там вообще ничего не сказано о возможности использования фабрик (я нашел небольшое упоминание в одном из гайдов).

Но если мы посетим исходники, то увидим, что мы можем использовать не только фабрики, но и все существующие способы провайдинга useFactory, useValue, useExisting и т. д.

Самый полезный, по моему мнению, способ использования фабрики в tree shakable сервисах выглядит так:

import { Injectable, Optional } from "@angular/core";import { SharedModule } from "./shared.module";@Injectable({  providedIn: SharedModule,  useFactory: (instance: SingletonService) => instance ?? new SingletonService(),  deps: [[new Optional(), SingletonService]]})export class SingletonService {  constructor() {    console.count("SingletonService constructed");  }}

Плюсы такого определения сервиса:

  1. Исключено случайное использование сервиса. Для работы с ним необходимо импортировать модуль сервиса.

  2. Самому модулю не нужно выделять статические методы forRoot и forChild.

  3. Гарантировано создание одного экземпляра сервиса.

С остальными способами определения не все так очевидно. Я могу предположить, что это поможет при использовании внешних библиотек, в которых еще и отсутствует типизация.

Например:

@Injectable({  providedIn: 'root',  useValue: jQuery})abstract class JqueryInstance {}

В этом случае по токену JqueryInstance мы будем получать инстанс jQuery.

Для остальных типов провайдеров я предлагаю придумать use-кейсы вам самим. Буду рад, если вы поделитесь ими в комментариях.

Взаимодействие компонент

В документации перечислены все существующие способы взаимодействия компонент и директив между собой:

  1. Input binding with a setter, ngOnChanges.

  2. Child events (outputs).

  3. Template variables.

  4. View/parent queries.

  5. Общий сервис.

Но ни слова не сказано о том, что дочерняя директива/компонент совершенно спокойно может получить инстанс родителя через DI.

import { Directive, HostListener } from "@angular/core";import { CountComponent } from "./count.component";@Directive({  selector: "[increment]"})export class IncrementDirective {  constructor(private countComponent: CountComponent) {}  @HostListener("click")  increment() {    this.countComponent.count += 1;  }}

На примере видно, что дочерняя директива получает инстанс компонента родителя, упрощая их взаимодействие. Обрабатывая клик по хостовому элементу, директива может напрямую поменять значение родительского компонента. Использование любого другого способа было бы как минимум более многословным.

Но почему это возможно? Ответ может быть очень большим, и он явно выходит за рамки этой статьи. Когда-нибудь я напишу об этом более подробно. А пока предлагаю вам прочитать об иерархии инжекторов и ElementInjector (именно в таком порядке).

Вместо вывода

Как я уже говорил, Angular очень большой фреймворк. И я уверен, что в его исходниках можно найти намного больше интересных возможностей.

Своими находками я всегда делюсь в своем Твиттере. Например, советы по Angular вы можете найти по хэштегу #AngularTip. А перевод самых интересных твитов по хэштегу #AngularTipRus! Буду рад, если вы поделитесь своими наблюдениями и советами со мной и сообществом. Спасибо за внимание!

Список хороших статей об Angular DI

Я вспомнил еще 2 статьи об Angular DI от @MarsiBarsi на русском языке. Пишите где угодно, предлагайте и давайте его пополнять!

Подробнее..

Внедрение зависимостей (dependency injection) через свойства-функции в JavaScript

19.11.2020 14:19:12 | Автор: admin


Известный, но не очень популярный способ внедрения зависимостей. Попытка реализовать этот способ в популярных DI npm пакетах. Еще один свой DI.



Несколько слов об OOP и DI


Тему противопоставления ООП другим парадигмам хотел бы оставить в стороне. На мой взгляд в одном приложении вполне могут сочетаться разные парадигмы. Считаю ES классы большим шагом в сторону привлекательности js для использования ООП.


Небольшая история из личного опыта. В 2006 году был гораздо более популярен, чем сейчас язык PERL. Он гибкий. Я в том году написал свою OO реализацию, и небольшое приложение, язык PERL это позволяет, пара мануалов 1, 2, и безграничные возможности.

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


Программируя на JavaScript, я чувствовал, что будет такая же проблема. Вроде ООП хочется, но посмотришь вокруг сколько вариантов как это сделать с прототипной моделью, и все не стандарты.


TypeScript тогда не было, но и когда появился с первого раза у меня ничего не получилось, все на каком-то ровном месте пляски с бубном были (это конечно субъективно). Тогда не срослось.


У меня был внутренний настрой, чем меньше JS в проекте, тем лучше. Я использовал JQuery UI Widget Factory. Не идеально, но можно расширять и какой-никакой стандарт, и в целом достаточно быстро получалось. Сейчас ES6 classes после множества локальных реализаций классов на ES5 просто прорыв и возможность использовать ООП. И по появлению ES6 классов можно подумать и о новых реализациях DI.


Внедрение зависимостей (dependency injection) считаю важным инструментом парадигмы ООП. Все легко, когда мы хотим отнаследоваться от одного класса, и немножко изменить поведение под свой проект. Но если мы добавляем сложную библиотеку из нескольких классов, и в ней есть DI, то получаем гибкое приложение.


DI может избавить библиотеку от монструозности. Например, библиотека календарик. Вариаций, как может быть нарисован календарь бесконечное количество (один/несколько месяцев, формат даты, времени, язык, стандарты...). Предусмотреть все возможные варианты как аргументы/параметры автору библиотеки просто невозможно. А если и захочет, то простенький календарик может превратиться в монстрокалендарь, который будут бояться использовать из-за его размеров. Но если будет возможность конечному клиенту легко чуточек допилить под себя или подключить плагин календарик становится прекрасным! Вполне себе аргументы для использования DI.


В написании тестов DI может быть полностью самодостаточным инструментом помощником.



По теме статьи


Один из вариантов реализации внедрения зависимостей через свойство, причем в свойстве и передается зависимость. Javascript позволяет определять функции как переменные. А прототипная модель позволяет легко менять контексты у этих функций. В итоге можно реализовать внедрение зависимостей через функции, которые возвращают необходимые зависимости.


Проще всего пояснить примером.


Допустим есть класс App приложения,


класс Storage какое то хранилище (один экземпляр на все приложение singleton/service),


и класс Date, для работы с датой (под каждую дату понадобится отдельный экземпляр).


Функции-свойству которая каждый след. вызов будет создавать новый объект (transient) добавим префикс new.


Функции-свойству всегда отдающую один и тот же объект (singleton) добавим префикс one.



class App {    /** @type { function( int ):IDate } */    newDate;    /** @type { function(): IStorage } */    oneStorage;    construct( newDate, oneStorage ) {        this.newDate = newDate;        this.oneStorage = oneStorage;    }    main() {        const oDate1 = this.newDate( 0 );        const oDate2 = this.newDate( 1000 );        const oStorage = this.oneStorage();        oStorage.insert( oDate1.format() + ' - ' + oDate2.format() );    }}


Мне нравится такой подход, тем что он максимально универсален. Так можно внедрять все что угодно и по умолчанию отложено (lazy). Когда добавляется много вариантов из конкретных реализаций, как внедрять (например в inversify: to, toSelf, toConstantValue, toDynamicValue, toConstructor, toFactory, toFunction, toAutoFactory, toProvider, toService), вся концепция DI становится сложной на ровном месте. Поэтому если внедрять везде одинаково, то можно писать быстрее.


Часто конкретная реализация DI накладывает определенные требования на код самих компонент или сами компоненты становятся зависимы от системы внедрения зависимостей. Я попробовал этот пример оформить в популярных DI реализациях, найти максимально универсальный формат компоненты, заодно оформить их в некую сравнительную табличку. Ниже опишу мои впечатления от различных DI реализаций.



Разные трактовки назначения dependency injection



Прежде, чем привести табличку, хочу обратить внимание на то, что все библиотеки очень разные. И дополнительная разница появляется от разных трактовок назначения dependency injection. Я условно их разделил по своему видению:


  1. Дать возможность писать тесты, не изменяя исходный код. Тестирование.
  2. Уменьшить связность кода, оставляя в реализации компонента ключи/токены для обращения к другим компонентам. Удобство поддержки, повторное использование, тестирование.
  3. Уменьшить связность кода, добавляя интерфейсы доступа к другим компонентам. Удобство поддержки, повторное использование, тестирование, безопасность, автокомплит/навигация IDE.


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


Во многих DI реализациях используются декораторы. Можно в библиотеке код компоненты оставить чистым, а декорировать в отдельном файле. Для клиента появляется вариант, либо импортировать чистый компонент, либо вместе с конкретным DI декоратором.


В целом, чем выше цифра, тем больше абстракций, больше гибкость, больше времени на разработку, ниже скорость исполнения кода. Поэтому говорить что, что-то лучше, или что-то хуже, неправильно. Есть разные инструменты для разных нужд. Выбор инструмента соответствующего задаче настоящее кунг-фу )



Популярные dependency injection вспомогательные библиотеки javascript/typescript


Сделал небольшой парсер, разбирающий попадание сочетания di в npm. Пакетов по этой теме ~1400. Все рассмотреть невозможно. Рассмотрел в порядке уменьшения количества npm dependents.



repo npm dependents npm weekly downloads github stars возраст, лет последняя правка, мес назад lang ES classes interfaces inject property bundle size, KB open github issues github forks
inversify/Inversifyjs
1798
408k 6.6k 6 1 TS + + + 63.3 204 458
typestack/typedi 353 62k 1.9k 5 3 TS + + + 30.3 17 98
thlorenz/proxyquire 344 426k 2.6k 8 8 ES5 ? ? ? ? 9 116
jeffijoe/awilix 244 42k 1.7k 5 1 TS + - - 31.7 2 92
aurelia/dependency-injection
153
13k 156 6 2 TS + - ? ? 2 68
stampit-org/stampit 170
22k 3k 8 1 ES5 ? ? ? ? 6 107
microsoft/tsyringe
149
80k 1.5k 3 1 TS + + - 30.4 27 69
boblauer/mock-require
136 160k 441 6 1 ES5 ? ? ? ? 4 29
mgechev/injection-js 105 236k 928 4 1 TS + -? ? 41.7 0 48
young-steveo/bottlejs
101
16k 1.2k 6 1 ES5 + D.TS -? - - 13.3 2 63
jaredhanson/electrolyte
33 1k 569 7 1 ES5 - - - ? 25 65
zhang740/power-di
10
0.2k 65 4 1 TS + + + 45.0 2 69
jpex-js/vue-inject
9 0.8k 174 4 12 ES5 - - ? ? 3 14
zazoomauro/node-dependency-injection
5
1k 123 4 2 ES6 + D.TS + -? + 291.0 3 17
justmoon/constitute 4 8k 132 5 60 ES6 + -? - 56.2 4 6
owja/ioc 1
2k 158 1 3 TS + + + 11.3 4 5
kraut-dps/di-box
1 0k 0 0 1 ES6 + D.TS + + + 11.1 0 0


Gitcompare ссылка



Codesandbox код реализации моего примера



https://github.com/inversify/InversifyJS


Наверное самый сложный, но и мощный пакет, возможно немного субъективно, потому что пример с ним делал самым первым. После него многие другие казались упрощенными версиями )).Наверное сложно придумать кейс, который бы не рассматривался авторами библиотеки. Монстр)



https://github.com/typestack/typedi


Чувствуется, что библиотека мощная, много разных возможностей. К сожалению, пока не смог разобраться, как я могу в App создать два разных экземпляра Date, с разными аргументами конструктора. Быть может здесь есть опытные его пользователи, которые подскажут?



https://github.com/thlorenz/proxyquire


Позволяет оставить код таким какой он есть, подменять содержимое файлов. В большей степени только для тестов. Сложно назвать DI, но для определенных задач может быть очень подходящим.



https://github.com/jeffijoe/awilix


Не получилось реализовать, возникает ошибка Symbol(Symbol.toPrimitive), как я понял, из-за того что в основе библиотеки Proxy, а у меня один из сервисов наследник от нативного Date класса. Не увидел в примерах использования интерфейсов.



https://github.com/aurelia/dependency-injection


Судя по документации и примерам создана именно с основной целью целью иметь возможность разбивать классы на более мелкие. Является частью фреймворка Aurelia.



https://github.com/stampit-org/stampit


Необычная ОО реализация. Множественное наследование. Не пытался что-то делать.



https://github.com/microsoft/tsyringe


Я не фанат Microsoft, но объективно написать реализацию в их библиотеке у меня получилось быстрее всех остальных. Все умеет, специально выделили что инъекция свойства не реализована и никогда не будет реализована.



https://github.com/boblauer/mock-require


По задумке очень похожа на proxyquire.



https://github.com/mgechev/injection-js


Использовалась в Angular 4. Обширные возможности, конкретно мой пример реализовать не получилось, непонятно как в useFactory передать аргумент.



https://github.com/young-steveo/bottlejs


Мой пример сделать не получилось. Вроде подходит метод .instanceFactory, но как туда передать аргумент не понятно.



https://github.com/jaredhanson/electrolyte


Не пытался реализовать. Варианты с ES6 классами пока не реализованы автором.



https://github.com/zhang740/power-di


Много возможностей. Есть специальный код для использования вместе с React. Чрезвычайно маленькая документация. Чтобы разобраться как что-либо сделать приходится смотреть тесты пакета. Не без костылей, но реализовал свой пример.



https://github.com/jpex-js/vue-inject


Специфичный для vue без ES6 классов инструмент. Не рассматривал. В этом фреймворке есть и возможность ипспользовать ES6 classes, и есть функционал provide inject через который можно использовать DI. Библиотека кажется устаревшей.



https://github.com/zazoomauro/node-dependency-injection


Конфигурация зависимостей определяется отдельным YAML/JS/JSON файлом. Для сервера. Основана на концепции фреймворка на php symfony Мой пример сделать не получилось, думал через костыли и передачу класса в setParameter, но и там ограничение, невозможно использовать конструктор класса как параметр.



https://github.com/justmoon/constitute


Реализовал, но костылями, которые аннулируют все DI преимущества.



https://github.com/owja/ioc


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



https://github.com/kraut-dps/di-box


Мой велосипед, подробнее ниже.



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



Свой велосипед


Основан на прототипной магии, пример совсем без каких либо библиотек:



class Service {    work () {        console.log('work');    }}class App {    oneService;    main () {        this.oneService().work();    }}// специальный es6 класс, выполняющий функции DIclass AppBox {    Service;    App;    _oService;    newApp () {        const oApp = new this.App();        // тут прототипная магия        oApp.oneService = this.oneService.bind(this);        return oApp;    }    oneService () {        if (!this._oService) {            this._oService = new this.Service();        }        return this._oService;    }}const oBox = new AppBox();oBox.Service = Service;oBox.App = App;const oApp = oBox.newApp();oApp.main();


Класс Box можно представить как набор декораторов конструкторов со своим состоянием, хранящим конструкторы и синглтоны, .

Непосредственно в библиотеке несколько инструментов чтобы создавать синглтоны (.one()), не писать bind(this), контролировать заполненность обязательных свойств. С библиотекой этот же пример выглядит так:



import {Box} from "di-box";class Service {    work() {        console.log( 'work' );    }}class App {    oneService;    main() {        this.oneService().work();    }}class AppBox extends Box {    App;    Service;    newService() {        return new this.Service();    }    oneService() {        return this.one( this.newService );    }    newApp() {        const oApp = new this.App();        oApp.oneService = this.oneService;        return oApp;    }}const oBox = new AppBox();oBox.Service = Service;oBox.App = App;const oApp = oBox.newApp();oApp.main();


Пример в codesandbox



Контроль обязательных свойств такой:


const oBox = new AppBox();// пропущено oBox.Service = Service;oBox.App = App;const oApp = oBox.newApp(); // то будет ошибка: свойство Service is undefinedoApp.main();


Конструкторы...



При написании компонентов для DI реализаций частенько приходится писать много аргументов в конструктор. И через какое то время, приходит мысль, что передача одного объекта со всеми зависимостями удобнее. Передача по ключу, удобнее чем по порядковому номеру.

Сравните:



constructor( arg1, arg2, arg3 ) {}// иconstructor( { arg1key: arg1, arg2key: arg2, arg3key: arg3 } ) {}


Но можно пойти еще дальше и попробовать отказаться от конструкторов, не во вред функциональности. Какие задачи у конструктора?


  1. Выполнить какие-то операции инициализации.
  2. Определить обязательные для работы компонента входные аргументы.

Первый пункт в ES таки подразумевает создание отдельного метода инициализации. Если этого не сделать, то достаточно сложно переопределить конструктор в наследнике из-за этой особенности. А DI изначально задуман для того чтобы сделать компонент более гибким.


Второй пункт можно решить организационным соглашениям. Например все публичные свойства не должны содержать undefined. Можно провести аналогию с абстрактными свойствами и методами из других языков. Как будто все публичные свойства абстрактны.


Сравните:



class A {    _arg1;    _arg2;    constructor( arg1, arg2 = null ) {        this._arg1 = arg1;        this._arg2 = arg2;    }}const instance = new A( 1, 2 );// иclass A {    arg1; // будет ошибка, если не установлено    arg2 = null; // ошибки не будет null !== undefined}const instance = new A();instance.arg1 = 1;instance.arg2 = 2;


Если компонент создается в dependency injection реализации, то можно дополнительной проверкой это реализовать. Это поведение по умолчанию библиотеки внедрения зависимостей di-box.

Но для классического подхода или для typescript с удобным синтаксисом типа constructor( public arg1: type, public arg2: type ) это поведение можно убрать опциями при создании Box:



new AppBox( { bNeedSelfCheck: false, sNeedCheckPrefix: null } );

В примере на codesandbox.



Итого с di-box получаем возможность писать в ООП стиле, с минимальным, но достаточным дополнительным кодом, реализующим DI. С одной стороны в реализации присутствует прототипная магия, но с другой она только на мета уровне, и сами компоненты могут быть чистыми, и ничего не знать об окружении.



Буду рад обсуждению, возможно упустил из виду какие-то другие библиотеки. Или может вы знаете как лучше реализовать мой пример в какой-то из реализаций DI. Напишите в комментариях.

Подробнее..

Архитектурный паттерн Dependency Injection в React-приложении

03.02.2021 12:13:25 | Автор: admin

Расшифровка доклада Сергея Нестерова с конференции FrontendLive 2020.

Привет! Меня зовут Сергей, уже больше двух лет я работаю в группе компаний Тинькофф. Моя команда занимается разработкой системы для анализа качества обслуживания клиентов в Тинькофф, и, как вы, наверное, догадались, мы используем React в своем приложении. Не так давно мы внедрили в свой проект архитектурный паттерн Dependency Injection совместно с IoC-контейнерами. Сделали мы это не просто так: это позволило нам решить ряд проблем, которые тормозили разработку нового функционала.

Непосредственно переход на новую архитектуру длился три-четыре месяца. Здесь нужно учитывать, что такого рода задачи являются техническим долгом, которые постоянно отодвигаются на задний план из-за разработки новых бизнес-фич.

Сегодня я расскажу про Dependency Injection в React-приложении. Рассмотрим, что из себя представляет этот архитектурный паттерн, как мы к нему пришли и какую проблему он решает. На примерах покажу, как внедрить Dependency Injection в ваш проект, какие есть плюсы и минусы.

Начну вот с такой формулы:

Frontend + DI

Идея этого доклада родилась из-за того, что архитектурный паттерн Dependency Injection, который, хоть и появился очень давно, к сожалению, до сих пор не очень широко используется в мире фронтенда и не встречается в реальных приложениях. Хотя в последние годы Dependency Injection набирает обороты и если еще не стал трендом фронтенд-разработки, то, как мне кажется, точно им станет в ближайшее время. Кстати, о трендах и технологиях, которые будут лидировать в следующем году, рассказал в своем докладе мой коллега, Филипп Нехаев.

Давайте посмотрим, где на сегодняшний день есть Dependency Injection. Он присутствует в таких современных и часто используемых фреймворках, как Angular и Nest.js (используется для написания бэкенда на NodeJS). И если в Angular Dependency Injection идет из коробки, то в React-приложениях и в самом React ничего подобного нет.

Цель моего доклада прийти к такому уравнению:

Frontend + DI =

и показать, как можно подружить ваше React-приложение с Dependency Injection. Но перед тем как начать, давайте познакомимся с нашим проектом.

Наш технологический стек

Погружу вас в наш технологический стек. Мы используем в своем проекте React и MobX. У нас по классической схеме есть какое-то одно глобальное хранилище, которое инициализируется в корне проекта и при помощи React-контекста передается вниз по дереву компонентов. В этом хранилище мы регистрируем все необходимые модели и сервисы, передаем зависимости и потихоньку начинаем строить наше приложение.

Мы разрабатываем систему для анализа качества обслуживания клиентов, и у нас основными сущностями являются звонки, чаты и прочие подобные коммуникации, существующие внутри компании. У нас есть таблицы с коммуникациями, в которых можно, например, посмотреть список звонков оператора и, допустим, оставить комментарий к звонку или оценить работу оператора в конкретном разговоре с клиентом. Конечно же, в зависимости от прав доступа к конкретному звонку, могут быть доступны и другие действия. Это выглядит так:

У нас есть карточка звонка с основной информацией и есть различные действия: например, оценить, прослушать эту коммуникацию или оставить комментарий. Это должно выглядеть как таблица с пагинацией, в которой происходит загрузка и отображение 30 коммуникаций на страницу. У нас есть чаты, письма, есть встречи, а можно взять и оценить оператора без какой-либо коммуникации. Само собой, такая таблица это переиспользуемый компонент, в котором отличаются только отображение карточек и их функционал.

Каждый элемент этой таблицы выполняет различные действия, что так или иначе приводит к большому количеству сущностей, и у этих сущностей большое количество связей. Мы это реализовали и получили решение с существенным количеством недостатков.

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

Большое количество пропсов по дереву компонентов. Нынешний контекст React появился в версии 16.2 или 16.3. Раньше, если и пользовались старым API, то все же склонялись к прокидыванию пропсов внутрь компонентов. Из-за того, что у нас вся логика отличалась на нижнем уровне (на уровне карточек), по дереву компонентов прокидывалось большое количество пропсов и при этом дерево было с глубокой вложенностью так называемый props hell.

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

Столкнувшись с этими проблемами, мы начали искать пути решения и пришли к архитектурному паттерну Dependency Injection. Тут стоит начать немного издалека с пяти основных принципов проектирования в объектно-ориентированном программировании, обозначаемых аббревиатурой SOLID.

SOLID

Что это за принципы?

  • Принцип единственной ответственности.

  • Принцип открытости/закрытости.

  • Принцип подстановки Барбары Лисков.

  • Принцип разделения интерфейса.

  • Принцип инверсии зависимостей.

В рамках моего доклада нас интересует только последний принцип принцип инверсии зависимостей. О чем он говорит?

  • Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

  • Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Чтобы разобраться, что все это значит, давайте взглянем на пример с кодом:

Я буду пользоваться двумя сущностями: сущностью соковыжималки (класс Juicer) и яблока (класс Apple). У класса Juicer есть внешняя зависимость от класса Apple. Что это значит? Это значит, что сейчас у нас очень сильно связаны два класса: Juicer и Apple. У этого решения есть ряд минусов:

  • Внешняя зависимость от класса Apple.

  • Сложность тестирования. Чтобы протестировать класс Juicer, нам нужно залезть внутрь него и посмотреть, как на самом деле устроен класс Apple.

  • Нет возможности для повторного использования. Сейчас наша соковыжималка может работать только с яблоками, и это, наверное, плохо. Хотелось бы получить более универсальную соковыжималку.

Соблюдая принцип инверсии зависимостей, мы эту связанность между классами убираем, добавляем абстракцию в виде интерфейса IFruit и разворачиваем нашу зависимость так, что теперь соковыжималка не полагается на конкретный класс, а зависит от какой-то абстракции.

В то же время наш класс Apple в этом месте как раз произошла инверсия зависимостей теперь полагается только на интерфейс, то есть на абстракцию. Снова взглянем на пример:

Теперь класс Juicer, во-первых, не инициализирует внутри конструктора необходимую зависимость, а ждет на вход какой-то фрукт с интерфейсом IFruit. Класс Apple имплементирует этот интерфейс и передается извне в конструктор класса Juicer. Мы делаем это для того, чтобы можно было повторно использовать этот класс.

Например, теперь мы можем взять апельсин и снова воспользоваться нашей соковыжималкой! А еще это легче тестировать, потому что теперь мы можем передать нашей соковыжималке не конкретный класс, а просто реализовать какой-то объект, который будет имплементацией нашего интерфейса.

Таким образом, мы добились низкой связанности классов друг с другом, то есть теперь мы можем работать с любыми фруктами.

Мы воспользовались архитектурным паттерном Dependency Injection. Он позволяет создавать зависимые объекты за пределами класса, которые его будут использовать и передавать его при помощи трех различных методов:

  • Constructor injection.

  • Property Injection.

  • Setter Injection.

Constructor injection. По большому счету, мы передаем внешнюю зависимость через конструктор класса. Это решение считается самым правильным, поскольку позволяет заключить явный контракт: мы видим, что для работы соковыжималки нужен какой-то фрукт. Это легко тестировать, передав объект, имплементированный от абстракции:

Property Injection. Здесь уже нет передачи зависимости через конструктор класса. Мы добавляем в property класса необходимую нам зависимость. Этот метод лучше не использовать, потому что, во-первых, это сокрытие зависимостей чтобы понять, с чем работает соковыжималка, нужно залезть внутрь нее:

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

Setter Injection. В сущности, он похож на Property Injection, но вместо предоставления зависимости напрямую в property класса, у нас есть сеттер, в котором мы передаем необходимую нам зависимость. Этот метод стоит использовать только для опциональных зависимостей. То есть наша соковыжималка должна уметь работать без предоставленной зависимости. Здесь, как и в случае с Property Injection, присутствует сокрытие зависимостей (неявный контракт), и нам нужно смотреть на конкретную реализацию:

Подведем итог:

  • Constructor Injection круто. Берем, используем.

  • Property Injection не используем.

  • Setter Injection используем только для опциональных зависимостей. Inversion of Control-контейнеры.

Выше я упоминал IoC-контейнеры, давайте немного остановимся на них.

Что это. IoC-контейнер это, как правило, библиотека или фреймворк, берущие на себя часть логики вашего приложения и отвечающие за создание инстансов классов: в каком порядке поднимать, какому классу нужна какая зависимость, поднимать ли на каждый запрос необходимой зависимости из контейнера новый инстанс класса или, допустим, брать уже поднятый.

Как это выглядит. IoC-контейнер это своего рода коробка, в которую мы складываем классы классы яблока и соковыжималки из нашего примера. На выходе мы можем из этого контейнера получить уже готовый инстанс класса, в который будут переданы зависимости, необходимые ему для работы.

Готовые решения для работы с React

Уже есть react-simple-di, react-ioc, typescript-ioc, inversifyJS.

Для нашего проекта мы выбрали inversifyJS, потому что он не зависит от конкретного фреймворка или библиотеки. Его можно использовать не только с React. Допустим, можно даже не пользоваться Dependency Injection Angular, а воспользоваться inversifyJS.

Он хорошо документирован и предоставляет мощные devtools. Это значит, что у него есть много методов для реализации крутых фич и он позволяет удобно дебажить код у него очень понятные сообщения об ошибках. То есть, когда у вас что-то упало, inversifyJS подскажет, что и где пошло не так:

Рассмотрим наш пример. У нас есть класс Juicer, и у него в конструкторе инициализируется зависимость от класса Apple. При использовании inversifyJS, чтобы сложить в контейнер, мы добавляем injectable-декоратор, который добавляет метаданные о представлении класса.

Далее мы добавляем inject-декоратор в конструктор класса и инжектим класс Apple, который нам нужен в качестве зависимости. Далее инициализируем наш контейнер из inversifyJS, закладываем туда необходимые нам объекты и биндим их по ключам этих классов, ну а потом можем доставать готовые инстансы из этого контейнера.

Вы можете сказать: Это не круто, у нас здесь до сих пор конкретные классы, а хотелось бы работать именно с интерфейсами для уменьшения зависимости! Ну что ж, давайте переделаем примеры. Вернемся к нашим интерфейсам:

Что мы теперь делаем? Мы имплементируем класс Apple от интерфейса IFruit. В конструктор класса мы передаем @inject по этому интерфейсу и затем регистрируем в контейнере по ключу необходимый нам класс Apple.

Что мы получим? Мы получим IFruit is not defined ошибку ReferenceError.

Почему так произошло? Думаю, вы знаете, что в runtime TypeScript обычный JavaScript и интерфейсов там нет. В момент, когда у нас запускается приложение, InversifyJS попытается инициализировать зависимость по интерфейсу, который на самом деле не определен.

Что мы можем сделать с этой ошибкой, чтобы пользоваться нашими интерфейсами? На самом деле, здесь только одно решение использовать строковые ключи или токены:

Мы добавляем строковую константу и говорим, что мы хотим получить в конструктор класса зависимость под ключом FruitKey. Далее в контейнере указываем, что класс Apple теперь будет относиться к этому ключу. Таким образом мы можем использовать интерфейсы, придерживаться архитектурного паттерна Dependency Injection и применять инверсию зависимостей.

Reflect-metadata

Reflect-metadata это библиотека, которая добавляет метаданные (данные о данных) о классах непосредственно в сам класс. Давайте посмотрим на примере, как это работает:

У нас есть класс Juicer, у него injectable- и inject-декораторы. Мы хотим понять, как же все-таки inversify-контейнер понимает, что внутрь класса Juicer нужно передать зависимость в виде фрукта. Давайте посмотрим, какие метаданные добавляет reflect-metadata к классу Juice.

Воспользуемся командой console.log(Reflect.getMetadataKeys) от нашего класса. Она выведет три ключа:

  • design:paramtypes;

  • inversify:tagged;

  • inversify:paramtypes.

Итак, мы хотим разобраться, как же inversifyJS понимает, что нужно предоставить в конструктор класса зависимость фрукта. Давайте посмотрим значение ключа inversify:tagged:

Снова выполняем console.log(Reflect.getMetadata) по ключу inversify:tagged и видим, что в метаданных класса Juicer присутствует запись о том, что первым параметром в конструктор класса нужно передать зависимость с ключом FruitKey. Именно так inversifyJS и работает: на основе метаданных понимает, какую зависимость и куда передать.

Dependency Injection+React

Перейдем к самому интересному к внедрению Dependency Injection в React-приложение. Стоит отметить, что в React внедрение в конструктор класса невозможно, потому что React использует конструктор класса по своему назначению. Здесь приходится добавлять обертки, чтобы связать наши компоненты с контейнером. Разобраться, как это работает, вам поможет демо. Вы можете его использовать, оно готово к работе. Просто добавляйте свои страницы и состояния.

import React from 'react';import { interfaces } from 'inversify'; const context = React.createContext<interfaces.Container | null>(null); export default context;

Давайте рассмотрим пример. Чтобы хранить контейнеры, конечно же, мы воспользуемся контекстом React. Здесь все достаточно просто: как обычно, мы вызываем функцию React.createContext и передаем ему первоначальное значение null. У inversifyJS есть типы, с помощью которых можно легко и понятно типизировать и при этом получать минимальное количество ошибок.

Что нужно для того, чтобы передать контекст в компонент? Нужно реализовать DiProvider контекст-провайдер, который позволит передавать вниз по дереву созданный нами контекст. Мы реализуем функцию, которая на вход будет принимать два параметра: наш контейнер и дочерние элементы (children) из родительского React-компонента, у которых будет доступ к зависимостям из контейнера:

type Props = {   container: interfaces.Container;   children: ReactNode;}; export function DiProvider({ container, children }: Props) {   return <Context.Provider value={container}>{children}</Context.Provider>;}

Дальше нам нужно реализовать High-Order-компонент, который будет помогать передавать контекст вниз. Для этого мы реализуем High-Order-компонент, который назовем, допустим, withProvider, и у него будут два параметра на вход компонент и контейнер, который мы инициализируем:

export function withProvider<P, C>(   component: JSXElementConstructor<P> & C,   container: interfaces.Container) {    class ProviderWrap extends Component<Props> {       public static contextType = Context;       public static displayName = `diProvider(${getDisplayName(component)})`;        public constructor(props: Props, context?: interfaces.Container) {           super(props);            this.context = context;            if (this.context) {               container.parent = this.context;           }       }        public render() {           const WrappedComponent = component;            return (               <DiProvider container={container}>                   <WrappedComponent {...(this.props as any)} />               </DiProvider>           );       }   }    return ProviderWrap as ComponentClass<Props>;}

В моем примере довольно много кода. Но большая его часть предназначена для корректной работы Typescript, который будет подсказывать, какие параметры можно передать в получившиеся High-Order-компоненты и отсеивать пропсы, получаемые из нашего контейнера. Мы реализовали функцию, которая оборачивает переданный компонент DiProvider функцией и передает в контекст контейнер, оставляя пропсы этого компонента без изменений.

В конструкторе класса мы ищем родительский контекст тех же самых контейнеров, чтобы реализовать иерархическую структуру DI-контейнеров. Если у нас уже есть контекст с контейнером по дереву компонент выше, то мы записываем в parent-поле дочернего контейнера ссылку на него. Как я упоминал ранее, для иерархических контейнеров это дает крутую фичу, к которой вернусь подробнее чуть позже.

Теперь перейдем к компоненту, который будет получать из нашего контейнера необходимые данные в виде пропсов. Для этого мы реализуем еще один High-Order-компонент, который будет принимать на вход сам компонент и зависимости, которые он хочет получить в качестве пропсов. Эти зависимости передаются в виде объекта, названия свойств которого будут соответствовать названиям пропсов компонента, а значения специальным Dependence-классам.

В конструктор Dependence-класса передается ключ необходимой зависимости или класс, у которого есть статическое поле с этим ключом. Вторым параметром можно передать объект с опциями. Это нужно для того, чтобы воспользоваться такими фишками inversify, как именованный binding (то есть по имени), tagged binding и функцией трансформации чтобы из класса достать уже конкретное свойство.

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

Все эти зависимости мы получаем при помощи функции inject. В ней мы проверяем, что у нас есть контекст с DI-контейнером, а затем достаем необходимые зависимости из него при помощи метода resolve, предварительно собрав ключи этих зависимостей. Получившийся результат складываем в новый объект, свойства которого мы вернем в компонент в виде пропсов.

Все, что нужно, мы сделали и теперь можем воспользоваться нашими High-Order-компонентами для стандартного компонента React, у которого мы хотим получить зависимость.

Мой пример написан на Next.js, чтобы был серверный рендеринг. Да и вообще Next.js легко собрать: то есть npm install, npm run dev все запустится. Сначала мы оборачиваем pages-компонент в HOC withProvider и передаем туда контейнер, который хотим использовать на уровне нашей страницы.

Чтобы получить необходимую зависимость из нашего контейнера, мы должны воспользоваться HOC diInject, передать туда компонент и указать внутри объекта, что мы хотим получить зависимость по такому-то строковому ключу.

Например: мы зарегистрировали в контейнере по строковому ключу зависимость ListModel и говорим, что она будет inSingletonScope, потому что мы хотим, чтобы эта зависимость закэшировалась и на каждый get-метод из контейнера мы получали тот же самый инстанс нашей зависимости. Дальше для типизации указываем в Props компонента, что у нас должна быть передана зависимость booksListModel из контейнера, и указываем ее тип. А inversifyJS в React-приложении даст нам поддержку иерархических контейнеров, повторное использование кода, низкую связанность и простоту тестирования.

Если последние два пункта исходят из того, что мы придерживаемся архитектурного паттерна Dependency Injection, то первые два это про плюшки, которые дает inversifyJS.

Давайте рассмотрим пример, иллюстрирующий иерархическую структуру наших контейнеров:

У нас SPA-приложение и есть входная точка. При помощи React Router мы перекидываем пользователя на конкретную страницу. В корне нашего проекта добавляем дефолтный контейнер, в который складываем зависимости для работы приложения: это сервисы типа fetch-сервиса, юзер-сервиса для работы с авторизованными данными пользователя и fetch для работы с бэкендом то есть все те вещи, которые использует каждая страница.

Далее, уже на уровне конкретной страницы, регистрируем дочерние контейнеры, в которых мы будем хранить состояние и сервисы конкретной страницы и они не будут переплетаться между собой. Теперь, когда мы загрузим страницу 1, она не будет ничего знать о странице 2.

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

В этом есть много плюсов, потому что теперь у нас страницы это такие модули, которые, во-первых, не могут управлять состоянием друг друга, а во-вторых, мы отдаем пользователю только тот js, который ему нужен на этой странице.

Поговорим о повторном использовании кода и для этого вернемся к моему примеру:

У нас есть карточка звонка, которую мы хотим переиспользовать. У нее есть различные внешние зависимости: CommentService, CommentModel и так далее.

Чтобы все время не регистрировать эти сервисы и не передавать их в контейнер, мы можем воспользоваться фишкой inversifyJS и взять Container Module, в который мы складываем все необходимые зависимости, а затем на конкретной странице просто подгружаем в этот контейнер необходимый нам модуль. Собственно, на этом все. Теперь мы можем всю нашу страницу разбить на независимые модули и подгружать конкретный модуль в контейнер, только когда он нам нужен.

Теперь хочу рассказать про две ключевые фишки inversifyJS, которыми мы пользуемся у себя в проекте. Первая из них tagged bindings. Для чего они нужны? Давайте вернемся к примеру с соковыжималкой, апельсином и яблоком:

Теперь мы скажем, что соковыжималка должна работать только с цитрусовыми фруктами. Для этого импортируем из inversifyJS декоратор tagged и внутри конструктора класса говорим, что по ключу FruitKey (то есть по ключу фруктов) хотим получить цитрусовый фрукт. Далее в контейнере регистрируем, что теперь по одному ключу FruitKey у нас два класса яблоко и апельсин, которым говорим, что яблоко у нас не цитрусовое, а апельсин цитрусовый. Чтобы различать эти два вида зависимостей, используем whenTargetTagged.

Вторая фишка, о которой я расскажу, named bindings:

Итак, у нас есть соковыжималка и мы хотим добавить еще один класс с внешней зависимостью в виде соковыжималки, в которой используются яблоки, класс Store, магазин. Чтобы получить соковыжималку с яблоками, в конструкторе класса мы указываем, что ее необходимо получить по ключу JuicerKey c дополнительным параметром AppleJuicer. Для этого воспользуемся декоратором named из inversifyJS.

В контейнере регистрируем наши фрукты и при помощи метода whenAnyAncestorNamed указываем, что у яблок будет дополнительный ключ AppleJuicer, а у апельсинов OrangeJuicer.

Обратите внимание, что в классе Juicer мы берем зависимость по ключу FruitKey и, по большому счету, здесь мы не знаем, что нам придет на вход апельсин или яблоко. Но при этом в родительской зависимости Store мы можем это определить.

Минусы Dependency Injection+React

Какие есть минусы DI в связке с React? Самый большой и, пожалуй, единственный минус это использование строковых ключей, которые не сопоставляются с типами. То есть для того, чтобы мы могли работать с абстракциями в виде интерфейсов, нам приходится добавлять строковые ключи, так как в runtime у нас обычный JavaScript, в котором нет интерфейсов:

Если посмотрите на предпоследнюю строчку с кодом, то увидите, что мы биндим Store по ключу StoreKey. Если в моем примере поменять местами Store и, допустим, ту же самую соковыжималку, то в приложении получим ошибку и TypeScript не скажет, что здесь что-то пошло не так.

Когда мы берем из контейнера какую-то зависимость или когда биндим какую-то зависимость в контейнер, лучше указывать, какой класс или интерфейс мы хотим получить или зарегистрировать в контейнере.

container.bind<Store>("StoreKey").to(Store);

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

Вывод

Мы получили новую модульную и гибкую архитектуру, которая легко поддается изменениям и легко расширяется. Придерживаясь архитектурного паттерна Dependency Injection, мы уменьшили связанность между компонентами системы и в целом пишем меньше кода. У нас больше нет одного глобального хранилища. Страницы стали полностью независимыми друг от друга и загружают только необходимые для конкретной страницы данные.

На этом все. Вы можете перейти по двум ссылкам: первая это playground для того, чтобы побаловаться с inversifyJS на NodeJS, вторая пример внедрения в React-приложение. Вы можете забрать себе эти High-Order-компоненты и контейнеры и начать строить свое приложение уже с React и inversifyJS.

Подробнее..

Глобальные объекты в Angular

29.03.2021 16:17:23 | Автор: admin

В JavaScript мы часто используем сущности вроде window, navigator, requestAnimationFrame или location. Некоторые из этих объектов существуют испокон веков, некоторые часть вечно растущего набора Web API. Возможно, вы встречали класс Location или токен DOCUMENT в Angular. Давайте обсудим, для чего они нужны и чему мы можем у них научиться, чтобы сделать наш код чище и более гибким.

DOCUMENT

DOCUMENT это токен из общего пакета Angular. Вот как можно им воспользоваться. Вместо этого:

constructor(private readonly elementRef: ElementRef) {}get isFocused(): boolean {return document.activeElement === this.elementRef.nativeElement;}

Можно написать так:

constructor(@Inject(ElementRef) private readonly elementRef: ElementRef,@Inject(DOCUMENT) private readonly documentRef: Document,) {}get isFocused(): boolean {return this.documentRef.activeElement === this.elementRef.nativeElement;}

В первом примере мы обратились к глобальной переменной напрямую. Во втором внедрили ее как зависимость в конструктор.

Я не собираюсь бросаться в вас модными акронимами или утверждать, что первый пример нарушает тот или иной принцип программирования. По правде сказать, я в этой теории не сильно подкован.

Вместо этого я покажу, почему подход с токеном лучше. Начнем с того, что посмотрим, откуда берется этот токен. В его объявлении в @angular/common нет ничего особенного:

export const DOCUMENT = new InjectionToken<Document>('DocumentToken');

Внутри @angular/platform-browser, однако, мы видим, как он получает свое значение (пример упрощенный):

{provide: DOCUMENT, useValue: document}

Когда вы добавляете BrowserModule в app.browser.module.ts, вы регистрируете целую кучу имплементаций для токенов, таких как RendererFactory2, Sanitizer, EventManager и наш DOCUMENT. Почему так? Потому что Angular это кросс-платформенный фреймворк. Он оперирует абстракциями и активно использует механизм внедрения зависимостей для того, чтобы работать в браузере, на сервере и на мобильных устройствах. Чтобы разобраться, давайте заглянем в ServerModule ещё одну платформу, доступную из коробки (пример упрощенный):

{provide: DOCUMENT, useFactory: _document, deps: [Injector]},// ...function _document(injector: Injector) {const config = injector.get(INITIAL_CONFIG);const window = domino.createWindow(config.document, config.url);return window.document;}

Мы видим, что там используется domino для создания имитации документа на основе конфига, взятого из DI. Именно это вы получите, если запустите Angular Universal для рендеринга приложения на сервере. Мы уже видим первый и самый важный плюс данного подхода. Работа с DOCUMENT возможна в SSR-окружении, в то время как глобальный document в нем отсутствует.

Другие глобальные сущности

Что ж, команда Angular позаботилась о document для нас, это здорово. Но что, если мы хотим, например, проверить браузер через строку userAgent? Для этого мы обычно обращаемся к navigator.userAgent. На самом же деле это означает, что сначала мы запрашиваем глобальный объект, window в случае браузера, и потом берем его поле navigator. Так что давайте начнем с токена WINDOW. Его довольно просто реализовать благодаря фабрике, которую можно добавить к созданию токена:

export const WINDOW = new InjectionToken<Window>('An abstraction over global window object',{factory: () => inject(DOCUMENT).defaultView!},);

Этого достаточно, чтобы начать использовать WINDOW по аналогии с DOCUMENT. Теперь используем тот же подход для создания NAVIGATOR:

export const NAVIGATOR = new InjectionToken<Navigator>('An abstraction over window.navigator object',{factory: () => inject(WINDOW).navigator,},);

Мы шагнем дальше и сделаем отдельный токен под USER_AGENT тем же способом. Зачем? Увидим позже!

Иногда одного токена недостаточно. Location из Angular это, по сути, обертка над location для упрощения работы с ним. Поскольку мы привыкли к RxJS, давайте заменим requestAnimationFrame на реализацию в виде Observable:

export const ANIMATION_FRAME = new InjectionToken<Observable<DOMHighResTimeStamp>>('Shared Observable based on `window.requestAnimationFrame`',{factory: () => {const performanceRef = inject(PERFORMANCE);      return interval(0, animationFrameScheduler).pipe(map(() => performanceRef.now()),share(),);},},);

Мы пропустили создание PERFORMANCE, потому что оно следует той же модели. Теперь у нас есть один общий стрим, основанный на requestAnimationFrame, который можно использовать по всему приложению. После того как мы заменили всё на токены, наши компоненты больше не полагаются на волшебным образом доступные сущности и получают всё, от чего они зависят, из DI. Классно!

Server Side Rendering

Теперь, допустим, мы хотим написать window.matchMedia('(prefers-color-scheme: dark)').

Конечно, на сервере в нашем WINDOW что-то да есть, но оно, безусловно, не поддерживает весь API объекта Window. Если мы попробуем сделать данный вызов в SSR, скорее всего, получим ошибку undefined is not a function. Один способ решить проблему обернуть все подобные вызовы в проверку isPlatformBrowser, но это скукота. Преимущество DI в том, что значения можно переопределять. Так что вместо особой обработки таких случаев мы можем предоставить безопасный муляж WINDOW в app.server.module.ts, который защитит нас от несуществующих свойств.

Это демонстрирует еще одно достоинство данного подхода: значение токена можно менять. Благодаря этому тестировать код, зависящий от браузерного API, становится очень просто. Особенно если вы используете Jest, в котором нативный API по большей части отсутствует. Но муляж это просто заглушка. Иногда мы можем подложить что-то осмысленное. В SSR-окружении у нас есть объект запроса, который содержит данные о user agent. Для этого мы и вынесли его в отдельный токен иногда его можно заполучить отдельно. Вот как мы можем превратить запрос в провайдер:

function provideUserAgent(req: Request): ValueProvider {return {provide: USER_AGENT,useValue: req.headers['user-agent'],};}

А затем добавим его в server.ts, когда будем настраивать Angular Universal:

server.get('*', (req, res) => {res.render(indexHtml, {req,providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl},provideUserAgent(req),],});});

Node.js так же имеет собственную имплементацию Performance, которую можно использовать на сервере:

{provide: PERFORMANCE,useFactory: performanceFactory,}// ...export function performanceFactory(): Performance {return require('perf_hooks').performance;}

Однако в случае requestAnimationFrame он нам не понадобится. Скорее всего, мы не хотим гонять наши Observable-цепочки впустую на сервере, так что просто подложим в DI EMPTY:

{provide: ANIMATION_FRAME,useValue: EMPTY,}

Следуя данной схеме, мы можем токенизировать все глобальные объекты в нашем приложении и предоставить замены для различных платформ, на которых мы его запускаем.

Подытожим

С таким подходом ваш код становится хорошо абстрагированным. Даже если вы не используете серверный рендеринг сейчас, возможно, он понадобится в будущем и вы будете к этому готовы. Кроме того, тестировать код гораздо легче, если все зависимости можно подменить. Мы создали крошечную библиотеку с токенами, которые используем сами:

Если вам требуется что-то, чего в ней еще нет, не стесняйтесь заводить issue на Гитхабе. Также у пакета есть напарник с версиями этих токенов под SSR:

Вы можете изучить этот проект по вселенной Rick and Morty от моего коллеги Игоря, чтобы увидеть это все в действии. Если вам в особенности интересен Angular Universal, прочитайте его статью о проблемах, с которыми он столкнулся, и как их решить.

Благодаря данному подходу наша библиотека Angular-компонентов Taiga UI без труда запустилась и под Angular Universal, и под Ionic. Надеюсь, что эти знания помогут и вам!

Подробнее..

Погружение во внедрение зависимостей (DI), или как взломать Матрицу

03.06.2021 16:16:28 | Автор: admin

Давным-давно в далекой Галактике, когда сестры Вачовски еще были братьями, искусственный разум в лице Архитектора поработил человечество и создал Матрицу Всем привет, это снова Максим Кравец из Holyweb, и сегодня я хочу поговорить про Dependency Injection, то есть про внедрение зависимостей, или просто DI. Зачем? Возможно, просто хочется почувствовать себя Морфеусом, произнеся сакраментальное: Я не могу объяснить тебе, что такое DI, я могу лишь показать тебе правду.

Постановка задачи

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

Пифия

Фабула, надеюсь, всем известна есть Матрица, к ней подключены люди. Люди пытаются освободиться, им мешают Агенты. Главный вопрос кто победит? Но это будет в конце фильма, а мы с вами пока в самом начале. Так что давайте поставим себя на место Архитектора и подумаем, как нам создать Матрицу?

Что есть программы? Те самые, которые управляют птицами, деревьями, ветром.

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

Что нам нужно обеспечить для функционирования Матрицы? Механизм внедрения, или (внимание, рояль в кустах), инжекции (Injection) функционала классов, отвечающих за всю вышеперечисленную флору, фауну и прочие природные явления, внутрь Матрицы.

Подождем, пока грузчики установят в кустах очередной музыкальный инструмент, и зададимся вопросом: а что произойдет с Матрицей после того, как мы в нее инжектируем нужный нам функционал? Все правильно у нее появятся зависимости (Dependency) от внешних по отношению к ней классов.

Пазл сложился? С одной стороны да. Dependency Injection это всего лишь механизм внедрения в класс зависимости от другого класса. С другой что это за механизм, для чего он нужен и когда его стоит использовать?

Первым делом, посмотрим на цитату в начале текста и обратим внимание на предложение: Программы совершенствуются. То есть переписываются. Изменяются. Что это означает для нас? Работа нашей Матрицы не должна зависеть от конкретной реализации класса зависимости.

Кажется, ерунда какая-то зависимость на то и зависимость, чтобы от нее зависеть!

А теперь следите за руками. Мы внедряем в Матрицу не конкретную реализацию зависимости, а абстрактный контракт, и реализуем механизм предоставления конкретной реализации, соответствующей этому контракту! Остались сущие пустяки понять, как же это все реализовать.

Внедрение зависимости в чистом виде

Оставим романтикам рассветы и закаты, птичек и цветочки. Мы, человеки, должны вырваться из под гнета ИИ вообще и Архитектора в частности. Так что будем разбираться с реализацией DI и параллельно освобождаться из Матрицы. Первая итерация. Создадим класс matrix, непосредственно в нем создадим агента по имени Смит, определим его силу. Там же, внутри Матрицы, создадим и претендента, задав его силу, после чего посмотрим, кто победит, вызвав метод whoWin():

class Matrix {  agent = {    name: 'Smith',    damage: 10000,  };  human = {    name: 'Cypher',    damage: 100,  };  whoWin(): string {    const result = this.agent.damage > this.human.damage      ? this.agent.name      : this.human.name;    return result;  }}const matrixV1 = new Matrix();console.log(Побеждает , matrixV1.whoWin());

Да, Сайфер не самый приятный персонаж, да еще и хотел вернуться в Матрицу, так что на роль первого проигравшего подходит идеально.

Побеждает  Smith

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

class Human {  name;  damage;  constructor(name, damage) {    this.name = name;    this.damage = damage;  }  get name(): string {    return this.name;  }  get damage(): number {    return this.damage;  }}class Matrix {  agent = {    name: 'Smith',    damage: 10000,  }; human;  constructor(challenger) {    this.human = challenger;  }  whoWin(): string {    const result = this.agent.damage > this.human.damage      ? this.agent.name      : this.human.name;    return result;  }

Мы добавили класс Human, принимающий на вход конструктора имя и силу человека, и возвращающий их в соответствующих методах. Также мы внесли изменения в наш класс Матрицы теперь информацию о претенденте на победу он получает через конструктор. Давайте проверим, сможет ли Тринити победить Агента Смита?

const Trinity = new Human('Trinity', 500);const matrixV1 = new Matrix(Trinity);console.log('Побеждает ', matrixV1.whoWin());

Увы, Тринити всего лишь человек (с), и ее сила по определению не может быть больше, чем у агента, так что итог закономерен.

Побеждает  Smith

Но стоп! Давайте посмотрим, что случилось с Матрицей? А случилось то, что класс Matrix и результаты его работы стал зависеть от класса Human! И нашему оператору, отправляющему Тринити в Матрицу, достаточно немного изменить код, чтобы обеспечить победу человечества!

class Human {   get damage(): number {    return this.damage * 1000;  }}

...

Пьем шампанское и расходимся по домам?

Чем плох подход выше? Тем, что класс Matrix ждет от зависимости challenger, передаваемой в конструктор, наличие метода damage, поскольку именно к нему мы обращаемся в коде. Но об этом знает Архитектор, создавший Матрицу, а не наш оператор! В примере мы можем угадать. А если не знать заранее название метода? Может быть, надо было написать не damage, а power? Или strength?

Инверсия зависимостей

Знакомьтесь! Dependency inversion principle, принцип инверсии зависимостей (DIP). Название, кстати, нередко сокращают, убирая слово принцип , и тогда остается только Dependency inversion (DI), что вносит путаницу в мысли новичков.

Принцип инверсии зависимостей имеет несколько трактовок, мы приведем лишь две:

  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Зачем он нам понадобился? Для того, чтобы обеспечить механизм соблюдения контракта для зависимости, о необходимости которого мы говорили при постановке задачи, и отсутствие которого в нашей реализации не дало нам пока наполнить бокалы шампанским.

Давайте внедрим в наш класс Matrix некий абстрактный класс AbstractHuman, а конкретную реализацию в виде класса Human попросим имплементировать эту абстракцию:

abstract class AbstractHuman {  abstract get name(): string;  abstract get damage(): number;}class Human implements AbstractHuman{  name;  damage;  constructor(name, damage) {    this.name = name;    this.damage = damage;  }  get name(): string {    return this.name;  }  get damage(): number {    return this.damage;  }}class Matrix {  agent = {    name: 'Smith',    damage: 10000,  }; human;  constructor(challenger: AbstractHuman) {    this.human = challenger;  }  whoWin(): string {    const result = this.agent.damage > this.human.damage      ? this.agent.name      : this.human.name;    return result;  }}const Morpheus = new Human('Morpheus', 900);const matrixV2 = new Matrix(Morpheus);console.log('Побеждает ', matrixV2.whoWin());

Морфеуса жалко, но все же он не избранный.

Побеждает  Smith

Вторая версия Матрицы пока что выигрывает, но что получилось на текущий момент? Класс Matrix больше не зависит от конкретной реализации класса Human задачу номер один мы выполнили. Класс Human отныне точно знает, какие методы с какими именами в нем должны присутствовать пока контракт в виде абстрактного класса AbstractHuman не будет полностью реализован (имплементирован) в конкретной реализации, мы будем получать ошибку. Задача номер два также выполнена.

Порадуемся за архитектора, кстати! В новой реализации он может создать отдельный класс для мужчин, отдельный для женщин, если понадобится сделать отдельный класс для брюнеток и отдельный для блондинок Согласитесь, таким модульным кодом легче управлять.

В бою с Морфеусом побеждает  SmithВ бою с Тринити побеждает  Smith

Думаю, вы уже догадались, что должен сделать наш оператор, чтобы Нео все же победил. Напишем еще один класс для Избранного, слегка отредактировав его силу:

...class TheOne implements AbstractHuman{  name;  damage;  constructor(name, damage) {    this.name = name;    this.damage = damage;  }  get name(): string {    return this.name;  }  get damage(): number {    return this.damage * 1000;  }}const Neo = new TheOne('Neo, 500);const matrixV5 = new Matrix(Neo);

Свершилось!

В бою с Нео побеждает  Нео

Инверсия управления

Давайте посмотрим, кто управляет кодом? В нашем примере мы сами пишем и класс Matrix, и класс Human, сами создаем инстансы и задаем все параметры. Мы управляем нашим кодом. Захотели внесли изменения и обеспечили победу Тринити.

Увы, по условиям мира, придуманного Вачовски, мы можем лишь вклиниваться в работу Матрицы, добавляя свои кусочки программного кода. Матрица управляет не только фантомами внутри себя, но и тем, как с ней работать извне!

Возможно, авторы трилогии увлекались программированием, потому что ситуация целиком и полностью списана с реальности и даже имеет свое название Inversion of Control (IoC).

Когда программист работает в фреймворке, он тоже пишет только часть кода, отдельные модули (классы, в которые внедряются зависимости) или сервисы (классы, которые внедряются в модули как зависимости). Причем какие именно (порой вплоть до правил именования файлов) решает фреймворк.

Кстати, уже использованный нами выше DIP (принцип инверсии зависимостей) одно из проявлений механизма IoC.

К-контейнер н-нада?

Последний шаг передача управления разрешением зависимостей. Кому и какой инстанс предоставить, использовать singleton или multiton также решается не программистом (оператором), а фреймворком (Матрицей).

Вариантов решения задачи множество, но все они сводятся к одной идее.

  • на верхнем уровне приложения создается глобальный объект,

  • в этом объекте регистрируется абстрактный интерфейс и класс, который его имплементирует,

  • модуль запрашивает необходимый ему интерфейс (абстрактный класс),

  • глобальный объект находит класс, имплементирующий данный интерфейс, при необходимости создает инстанс и передает его в модуль.

Конкретные реализации у каждого фреймворка свои: где-то используется Локатор сервисов/служб (Service Locator), где-то Контейнер DI, чаще называемый IoC Container. Но на уровне базовой функциональности отличия между подходами стираются до неразличимости.

У нас есть класс, который мы планируем внедрить (сервис). Мы сообщаем фреймворку о том, что этот класс нужно отправить в контейнер. Наиболее наглядно это происходит в Angular мы просто вешаем декоратор Injectable.

@Injectable()export class SomeService {}

Декоратор добавит к классу набор метаданных и зарегистрирует его в IoC контейнере.

Когда нам понадобится инстанс SomeService, фреймворк обратится к контейнеру, найдет уже существующий или создаст новый инстанс сервиса и вернет его нам.

Крестики-нолики, а точнее плюсы и минусы

Плюсы очевидны вместо монолитного приложения мы работаем с набором отдельных сервисов и модулей, не связанных друг с другом напрямую. Мы не завязаны на конкретную реализацию класса, более того, мы можем подменять их при необходимости просто через конфигурацию. За счет того, что большая часть технического кода отныне спрятана в недрах фреймворка, наш код становится компактнее, чище. Его легче рефакторить, тестировать, проводить отладку.

Минусы написание рабочего кода требует понимания логики работы фреймворка, иначе проект превращается в набор черных ящиков с наклейками я реализую такой-то интерфейс. Кроме того, за любое удобство в программировании приходится платить производительностью. В конечном итоге все всё равно сводится к обычному инстанцированию с помощью new, а дополнительные обертки, реализующие за нас эту логику, требуют и дополнительных ресурсов.

Вместо заключения, или как это использовать практически?

Окей, если необходимость добавления промежуточного слоя в виде контракта более-менее очевидна, то где на практике нам может пригодиться IoC?

Кейс 1 тестирование.

  • У вас есть модуль, который отвечает за оформление покупки в интернет-магазине.

  • Функционал списания средств мы вынесем в отдельный сервис и внедрим его через DI. Этот сервис будет обращаться к реальному эквайрингу банка Х.

  • Нам нужно протестировать работу модуля в целом, но мы не готовы совершать реальную покупку при каждом тесте.

  • Решение напишем моковый сервис, имплементирующий тот же контракт, что и боевой, и для теста через IoC будем вызывать моковую реализацию.

Кейс 2 расширение функционала.

  • Модуль прежний, оформление покупки в интернет-магазине.

  • Поступает задача добавить возможность оплаты не только в банке Х, но и в банке Y.

  • Мы пишем еще один платежный сервис, реализующий взаимодействие с банком Y и имплементирующий тот же контракт, что и сервис банка X.

  • Плюсы мы можем подменять банк, мы можем давать пользователю выбор, в какой банк платить. При этом мы никак не меняем наш основной модуль.

Кейс 3 управление на уровне инфраструктуры.

  • Модуль прежний.

  • Для production работаем с боевым сервисом платежей.

  • Для разработки подгружаем моковый вариант, симулирующий списание средств.

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

Надеюсь, этот краткий список примеров вас убедил в том, что вопроса, использовать или не использовать DI, в современной разработке не стоит. Однозначно использовать. А значит надо понимать, как это работает. Надеюсь, мне удалось не только помочь Нео в его битве со Смитом, но и вам в понимании, как устроен и работает DI.

Если есть вопросы или дополнения по теме, буду рад продолжить общение в комментариях. Напишите, о чем рассказать в следующей статье? А если хотите познакомиться с нами ближе, я всегда на связи в Телеграме @maximkravec.

Подробнее..

Teqfwdi

09.06.2021 18:07:37 | Автор: admin

Некоторые любят ездить велосипедах, а некоторые любят их изобретать. Я отношусь к тем, кто изобретает велосипеды, чтобы на них ездить. Пару лет назад я уже писал на Хабр про этот свой "велосипед" - контейнер внедрения зависимостей (DI container) для JavaScript. Последующее обсуждение принципов работы DI-контейнеров и их отличие от "Локатора Сервисов" достаточно сильно продвинуло меня в понимании работы моего собственного "велосипеда" и вылилось не только в ряд статей на Хабре (раз, два, три, четыре), но и в значительной доработке самого "велосипеда".

Под катом - описание работы DI-контейнера (@teqfw/di) по состоянию на текущий момент. Ограничения: контейнер написан на чистом JavaScript (ES2015+), работает только с ES2015+ кодом, оформленным в ES-модули с расширением *.mjs . Преимущества: позволяет загружать и использовать одни и те же модули как в браузере, так и в nodejs-приложениях без дополнительной транспиляции.

Основы работы DI-контейнеров

Типовое обращение к абстрактному контейнеру объектов выглядит примерно так:

const obj = container.get(id);

Последовательность действий контейнера:

  1. Определить по id , что за объект хочет получить вызывающая сторона.

  2. Проверить контейнер на предмет наличия в нём запрашиваемого объекта, если объект есть в контейнере вернуть его.

  3. Если же объект нужно создавать, то по id определить, где находится файл с исходным кодом объекта и загрузить исходники.

  4. Разобрать спецификацию входных зависимостей конструктора объекта (набор идентификаторов).

  5. Найти в контейнере зависимости согласно спецификации или создать заново.

  6. Создать запрашиваемый объект с использованием собранных зависимостей.

  7. Сохранить созданный объект в контейнере для последующего использования (при необходимости).

  8. Вернуть созданный объект вызывающей стороне.

Последовательность действий рекурсивная повторяется в пункте 5 для каждой зависимости, до тех пор, пока всё дерево зависимостей запрашиваемого объекта не будет создано.

ES-модуль

Прежде всего нужно понимать, что может экспортировать ES-модуль т.е., что именно может использовать DI-контейнер для создания объектов после загрузки исходного кода ES-модуля.

const obj = {name: 'Simple Object'};class Clazz {    constructor(spec) {        this.name = 'instance from class constructor';    }}function Factory(spec) {    return {name: 'instance from factory'};}export {    obj as ObjTmpl,    Clazz as default,    Factory,}

А это пример того, как вызывающая сторона могла бы создавать объекты при помощи кода из этого ES-модуля (вручную, без использования контейнера):

import Def from './es6.mjs';import {Factory} from './es6.mjs';import {ObjTmpl} from './es6.mjs';const spec = {}; // empty specificationconst instClass = new Def(spec);const instFact = Factory(spec);const instTmpl = Object.assign(ObjTmpl, {});

Итого, DI-контейнер, после загрузки ES-модуля, может создавать новые объекты на основе экспорта модуля:

  • используя классы;

  • используя фабричные функции;

Идентификаторы зависимостей

Идентификатор зависимости это строка, идентифицирующая объект, который ожидает получить конструктор объекта (фабричная функция) в качестве зависимости, или который DI-контейнер должен вернуть вызывающей стороне:

constructor(spec) {    const dep = spec['depId'];}...await container.get('dep1');

Именованные и импортируемые

В самом простом случае разработчик может создавать объекты вручную и помещать их прямо в контейнер под произвольными идентификаторами:

import Container from '@teqfw/di';const container = new Container();container.set('dep1', {name: 'first'});container.set('dep2', {name: 'second'});const obj = await container.get('dep1');

Но в большинстве случаев нас интересует способ автоматического нахождения контейнером ES-модуля, подгрузка исходников и определение способа создания зависимости (класс, фабричная функция или шаблон-объект).

В @teqfw/di все идентификаторы делятся на две большие группы:

  • именованные зависимости: название начинается со строчной буквы (connection, conf, i18n, );

  • импортируемые зависимости: названия начинаются с прописной буквы (EsModuleId);

Именованные зависимости добавляются в контейнер вручную через container.set(id, obj), для импортируемых зависимостей есть правила сопоставления идентификаторов путям к исходникам (рассмотрим позднее).

Модуль и экспорт модуля

В @teqfw/di для загрузки ES-модуля используется динамический импорт, результатом которого является специальный объект Module (см. пункт Модули в Javascript: исходный код и его отображение при отладке).

Необходимо различать, хотим ли мы использовать в качестве зависимости модуль целиком или какой-то определённый экспорт из данного модуля. В @teqfw/di для этого используется знак #:

  • EsModuleId: идентификатор для модуля целиком;

  • EsModuleId#ExportName: идентификатор для экспорта с именем ExportName в модуле EsModuleId;

  • EsModuleId#default и EsModuleId#: оба идентификатора равнозначны и указывают на экспорт default в модуле EsModuleId;

Функция и результат функции

Зависимости объекта в @teqfw/di передаются в спецификации в конструктор или фабричную функцию:

class Clazz {    constructor(spec) {}}function Factory(spec) {}

Как различить случай, когда мы хотим получить от контейнера сам класс (функцию), а когда экземпляр объекта данного класса (результат работы функции)? В идентификаторе зависимости для @teqfw/di это отражается при помощи символа $:

  • EsModuleId#ExportName: получить объект (класс, функцию) с именем ExportName из модуля EsModuleId.

  • EsModuleId#ExportName$: получить объект, созданный при помощи конструктора (фабричной функции), являющегося экспортом ExportName модуля EsModuleId.

Для создания объекта из default-экспорта ES-модуля нижеприведенные идентификаторы равнозначны:

  • EsModuleId#default$

  • EsModuleId#$

  • EsModuleId$

Singleton и новый экземпляр

Иногда контейнер должен использовать один и тот же экземпляр в качестве зависимости для всех объектов приложения (или некоторых), а иногда каждый раз должен создаваться новый экземпляр объекта. В идентификаторах зависимости это отражается через удвоение символа "доллар" -$$:

  • EsModuleId$ и EsModuleId#ExportName$: объект создается один раз (при первом запросе) и сохраняется в контейнере, для всех последующих запросов используется ранее сохраненный объект.

  • EsModuleId$$ и EsModuleId#ExportName$$: каждый раз создается новый экземпляр объекта.

При этом неважно, какой из объектов первым запросил создание singletonа все остальные получат этот же экземпляр. В некотором смысле любой DI-контейнер является global-объектом. Кто-то может сказать, что это антипаттерн, и отказаться от использования DI его полное право.

Сводная таблица идентификаторов

Итого в @teqfw/di используются следующие идентификаторы зависимостей:

let id1 = 'named'; // named singleton been added manuallylet id2 = 'EsModId'; // ES modulelet id3 = 'EsModId#'; // default export of ES modulelet id4 = 'EsModId#name'; // named export of ES modulelet id5 = 'EsModId$'; // singleton from default exportlet id6 = 'EsModId$$'; // new instance from default exportlet id7 = 'EsModId#name$'; // singleton from named exportlet id8 = 'EsModId#name$$'; // new instance from named export

Декларация зависимостей

Вся мощь контейнера раскрывается тогда, когда мы описываем зависимости, необходимые для создания объекта, в конструкторе (фабричной функции). В @teqfw/di это делается так:

constructor(spec) {    const named = spec['namedSingleton'];    const inst = spec['EsModId#name$$'];    const single = spec['EsModId$'];}

Особенностью @teqfw/di является то, что контейнер прерывает процесс создания запрошенного объекта, если обнаруживает неизвестную зависимость, подгружает исходники зависимости и создает зависимость, после чего вновь пытается создать запрошенный объект. Таким образом, первые строки конструктора запрошенного объекта могут выполняться несколько раз, если в процессе приходилось несколько раз прерывать процесс и подгружать нужные исходники.

Загрузка исходников

Чтобы контейнер по идентификатору зависимости мог обнаружить файл с исходным кодом соответствующего ES-модуля, нужна карта сопоставления идентификаторов зависимостей файлам с исходниками. В @teqfw/di добавлением позиций в карту делается так:

container.addSourceMapping('EsModId', './relative/path');container.addSourceMapping('EsModId', '/absolute/path', true);

Первый способ (с относительной адресацией) в основном применяется, если контейнер используется в браузере, второй в nodejs-приложениях. Тем не менее, оба способа могут применяться в обеих средах.

В карте сопоставления, используемой контейнером, прописывается корневой каталог с исходниками, дальнейшее сопоставление идентификаторов исходникам идет через использование namespaceов, где разделителем имен каталогов является _:

EsModId_PathTo_Mod => /absolute/path/PathTo/Mod.mjs

Резюме

DI-контейнер @teqfw/di позволяет использовать в качестве зависимостей как сами ES-модули, так и отдельные элементы из экспорта ES-модулей, а также создавать новые экземпляры объектов или использовать один единственный объект для всего приложения. Причем один и тот же код может использоваться как в браузерах, так и в nodejs-приложениях.

Типовой код для ES-модуля, используемого в @teqfw/di:

export default class Mod {    constructor(spec) {        const Clazz = spec['Lib_Dep#'];        const single = spec['Lib_Dep$'];        const inst = spec['Lib_Dep$$'];        // ...    }}

Обычный import также можно использовать в коде, но в таком случае код теряет возможность быть использованным одновременно и в браузерах, и в nodejs-приложениях, т.к. браузерный формат import'а не совместим с nodejs-форматом.

Подробнее..
Категории: Javascript , Dependency injection , Di , Teqfw

Инверсия контроля на голом TypeScript без боли

10.02.2021 18:20:17 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и (сколько себя помню) я борюсь со своим окружением. Ведь оно такое костное, дубовое, и никогда не понимает, что я от него хочу. Но в какой-то момент я понял, что хватит это терпеть и надо что-то менять. Поэтому теперь не окружение диктует мне, что я могу и не могу делать, а я диктую окружению каким ему быть.

Как вы уже поняли, далее речь пойдёт про инверсию контроля через "контекст окружения". Многим этот подход уже знаком по "переменным окружения" - они задаются при запуске программы и обычно наследуются для всех программ, которые та запускает. Мы же применим эту концепцию для организации нашего кода на TypeScript.

Итак, что мы хотим получить:

  • Функции при вызове наследуют контекст у вызвавшей их функции

  • Объекты наследуют контекст у их объекта-владельца

  • В системе может существовать одновременно множество вариантов контекста

  • Изменения в производных контекстах не влияют на исходный

  • Изменения в исходном контексте отражаются на производных

  • Тесты могут запускаться в изолированном и не изолированном контексте

  • Минимум бойлерплейта

  • Максимум перфоманса

  • Тайпчек всего этого

Давайте, объявим какую-нибудь глобальную константу в глобальном контексте окружения:

namespace $ {    export let $user_name: string = 'Anonymous'}

Теперь добавим в глобальный контекст какую-нибудь функцию. Например, функцию записи в лог:

namespace $ {    export function $log( this: $, ... params: unknown[] ) {        console.log( ... params )    }}

Обратите внимание на типизированный this. Он гарантирует, что данную функцию нельзя будет вызвать напрямую так:

$log( 123 ) // Error

Вызвать её можно исключительно из какого-либо контекста окружения. Например, из глобального контекста:

$.$log( 123 ) // OK

Однако, пока что $ у нас - это неймспейс, а не тип. Давайте для простоты создадим и одноимённый тип:

namespace $ {    export type $ = typeof $}

А раз мы получаем контекст окружения в this, то можем его использовать и для вызова других функций. Например, напишем функцию, которая приветствует пользователя по имени:

namespace $ {    export function $hello( this: $ ) {        this.$log( 'Hello ' + this.$user_name )    }}

Таким образом мы естественным образом можем передавать контекст окружения по стеку вызовов функций на любую глубину. Но в этом мало смысла, пока контекст всего один. Поэтому добавим фабрику контекстов, которая берёт текущий контекст, наследует от него производный, патчит его переданными ей переопределениями и возвращает:

namespace $ {    export function $ambient(        this: $,        over: Partial&lt; $ >,    ): $ {        const context = Object.create( this )        for( const field of Object.getOwnPropertyNames( over ) ) {            const descr = Object.getOwnPropertyDescriptor( over, field )!            Object.defineProperty( context, field, descr )        }        return context    }}

Object.create мы используем, чтобы создание производного контекста было быстрым, даже если он разрастётся. А вот Object.assign не используется, чтобы в переопределениях можно было задавать не только значения, но и геттеры, и сеттеры. Эта фабрика нам ещё пригодится, а пока давайте напишем наш первый тест:

namespace $.test {    export function $hello_greets_anon_by_default( this: $ ) {        const logs = [] as unknown[]        this.$log = logs.push.bind( logs )        this.$hello()        this.$assert( logs, [ 'Hello Anonymous' ] )    }}

Тест принимает на вход контекст кружения, и первым делом он настраивает контекст под себя - патчит функцию $log, чтобы та сохраняла все логи в локальную переменную. Потом мы запускаем тестируемую функцию в нашем контексте, и наконец, проверяем, что в логи вывелось именно то, что мы ожидаем. Напишем простейшую функцию для асертов:

namespace $ {    export function $assert&lt; Value >( a: Value, b: Value ) {        const sa = JSON.stringify( a, null, '\t' )        const sb = JSON.stringify( b, null, '\t' )        if( sa === sb ) return        throw new Error( `Not equal\n${sa}\n${sb}`)    }}

Обратите внимание, что мы поместили тест в отдельный неймспейс $.$test. Это нужно для того, чтобы взять и запустить все тесты скопом:

namespace $ {    export async function $test_run( this: $ ) {        for( const test of Object.values( this.$test ) ) {            await test.call( this.$isolated() )        }        this.$log( 'All tests passed' )    }}

Каждый тест запускается не в оригинальном контексте, а в изолированном. Это такой производный контекст, где замоканы все сущности, что общаются со внешним миром (сетевые запросы, время, консоль, файлы, рандом и т.д.). Исходно, она просто создаёт новый производный контекст:

namespace $ {    export function $isolated( this: $ ) {        return this.$ambient({})    }}

Но наша функция $log пишет в реальную консоль, что не очень-то похоже на изоляцию. Поэтому, рядом с ней мы положим переопределение $isolated, которое переопределяет в контексте $log на реализацию без сайд эффектов:

namespace $ {    const base = $isolated    $.$isolated = function( this: $ ) {        return base.call( this ).$ambient({            $log: ()=> {}        })    }}

Теперь мы уверены, что любые тесты по умолчанию не будут ничего писать в реальную консоль даже если мы не переопределим в них функцию $log.

Давайте так же напишем и тест, что наши переопределения контекстов работают исправно:

namespace $.test {    export function $hello_greets_overrided_name( this: $ ) {        const logs = [] as unknown[]        this.$log = logs.push.bind( logs )        const context = this.$ambient({ $user_name: 'Jin' })        context.$hello()        this.$hello()        this.$assert( logs, [ 'Hello Jin', 'Hello Anonymous' ] )    }}

Теперь перейдём к объектам. Для простоты работы с контекстами введём простой базовый класс для всех наших классов:

namespace $ {    export class $thing {        constructor( private _$: $ ) {}        get $() { return this._$ }    }}

Тут мы инъектируем контекст окружения через конструктор. И добавляем геттер, позволяющий получать зависимости через контекст минимальным объёмом кода. Геттер нам нужен для того, чтобы можно было переопределять контекст в потомках не потеряв переопределения предков. Для примера, создадим карточку, которая приветствует пользователя, добавляя к имени восклицательный знак:

namespace $ {    export class $hello_card extends $thing {        get $() {            return super.$.$ambient({                $user_name: super.$.$user_name + '!'            })        }        get user_name() {            return this.$.$user_name        }        set user_name( next: string ) {            this.$.$user_name = next        }        run() {            this.$.$hello()        }    }}

Напишем тест, чтобы удостовериться, что это действительно работает:

namespace $.test {    export function $hello_card_greets_anon_with_suffix( this: $ ) {        const logs = [] as unknown[]        this.$log = logs.push.bind( logs )        const card = new $hello_card( this )        card.run()        this.$assert( logs, [ 'Hello Anonymous!' ] )    }}

Супер, теперь посмотрим, как выстраивать дерево объектов. Тут основная идея в том, что у каждого объекта есть владелец, который контролирует его время жизни и контекст окружения. Давайте создадим страничку, которая владеет нашей карточкой:

namespace $ {    export class $hello_page extends $thing {        get $() {            return super.$.$ambient({                $user_name: 'Jin'            })        }        @ $mem        get Card() {            return new this.$.$hello_card( this.$ )        }        get user_name() {            return this.Card.user_name        }        set user_name( next: string ) {            this.Card.user_name = next        }        run() {            this.Card.run()        }    }}

Выносим создание владеимого объекта в отдельное свойство. Инъектим в него текущий контекст. И мемоизируем результат с помощью $mem. Возьмём самую простую его реализацию без реактивности:

namespace $ {    export function $mem(        host: object,        field: string,        descr: PropertyDescriptor,    ) {        const store = new WeakMap&lt; object, any >()        return {            ... descr,            get() {                let val = store.get( this )                if( val !== undefined ) return val                val = descr.get!.call( this )                store.set( this, val )                return val            }        }    }}

WeakMap нужен чтобы такое свойство можно было безопасно переопределять в подклассах, не ломая мемоизацию. Что ж, проверим, что имя пользователя действительно поменялось, а восклицательный знак не потерялся:

namespace $.test {    export function $hello_page_greets_overrided_name_with_suffix( this: $ ) {        const logs = [] as unknown[]        this.$log = logs.push.bind( logs )        const page = new $hello_page( this )        page.run()        this.$assert( logs, [ 'Hello Jin!' ] )    }}

Отлично, работает. Теперь усложняем задачу - переопределяем класс для поддерева объектов. Создадим новый класс для карточки, который позволяет переопределять имя пользователя, сохраняя его в локальное хранилище.

namespace $ {    export class $app_card extends $.$hello_card {        get $() {            const form = this            return super.$.$ambient({                get $user_name() { return form.user_name },                set $user_name( next: string ) { form.user_name = next }            })        }        get user_name() {            return super.$.$storage_local.getItem( 'user_name' ) ?? super.$.$user_name        }        set user_name( next: string ) {            super.$.$storage_local.setItem( 'user_name', next )        }    }}

Само локальное хранилище - это просто алиас для нативного объекта:

namespace $ {    export const $storage_local: Storage = window.localStorage}

А раз оно у нас персистится, то нужно нужно рядом положить и мок, который сохраняет данные не в нативное хранилище, а во временный объект:

namespace $ {    const base = $isolated    $.$isolated = function( this: $ ) {        const state = new Map&lt; string, string >()        return base.call( this ).$ambient({            $storage_local: {                getItem( key: string ){ return state.get( key ) ?? null },                setItem( key: string, val: string ) { state.set( key, val ) },                removeItem( key: string ) { state.delete( key ) },                key( index: number ) { return [ ... state.keys() ][ index ] ?? null },                get length() { return state.size },                clear() { state.clear() },            }        })    }}

Теперь мы, наконец, можем реализовать наше приложение, которое подменяет в контексте исходный класс $hello_card на свой $app_card, и всё поддерево объектов будет инстанцировать именно его.

namespace $ {    export class $app extends $thing {        get $() {            return super.$.$ambient({                $hello_card: $app_card,            })        }        @ $mem        get Hello() {            return new this.$.$hello_page( this.$ )        }        get user_name() {            return this.Hello.user_name        }        rename() {            this.Hello.user_name = 'John'        }    }}

Напишем пользовательский сценарий, где мы сначала сохраняем состояние локального хранилища и стираем его, потом проверяем, что имя соответствует дефолтному поведению, при переименовывании оно меняется и сохраняется даже при перезапуске приложения, а в конце подчищаем за собой, убеждаемся, что очистка сработала, и восстанавливаем состояние локального хранилища:

namespace $.$test {    export function $changable_user_name_in_object_tree( this: $ ) {        const name_old = this.$storage_local.getItem( 'user_name' )        this.$storage_local.removeItem( 'user_name' )        const app1 = new $app( this )        this.$assert( app1.user_name, 'Jin!' )        app1.rename()        this.$assert( app1.user_name, 'John' )        const app2 = new $app( this )        this.$assert( app2.user_name, 'John' )        this.$storage_local.removeItem( 'user_name' )        this.$assert( app2.user_name, 'Jin!' )        if( name_old !== null ) {            this.$storage_local.setItem( 'user_name', name_old )        }    }}

Тесты неспроста написаны в таком стиле, чтобы их можно было запускать не только на чистом состоянии, но и на грязном. Это позволит нам гонять одни и те же тесты с разными уровнями изоляции.

Запустим тесты в полностью изолированном контексте, чтобы проверить, что реализовали всю нашу логику правильно:

namespace $ {    await $.$test_run()}

А теперь запустим их же, но без изоляции, чтобы проверить, что наша логика корректно работает со внешними системами. Для этого просто создадим контекст, где $isolated возвращает производный контекст, но без каких-либо переопределений:

    $.$ambient({        $isolated: function(){ return $.$ambient({}) }    }).$test_run()}

Этот второй вариант, если запустить в Сафари в порно режиме, выдаст исключение, так как в нём нельзя обращаться к localStorage, а этот кейс в нашей нативной реализации $storage_local не предусмотрен.

Аналогично, запуская тесты в разных контекстах, можно проверять работу вашего кода с публичным сервером, с тестовым сервером, и вообще без сервера.

Подробнее об этом подходе к тестированию можно ознакомиться в моём выступлении на TechLeadConf: Фрактальное Тестирование.

Разобранный же тут подход к инверсии контроля активно применяется во фреймворке $mol, что даёт ему потрясающую гибкость и простоту кода. Но это уже совсем другая история

Если вас смущает общий неймспейс и отcутствие import/export, то можете ознакомиться с этим анализом: Fully Qualified Names vs Imports. А если смущает именование через подчёркивание, то с этим: PascalCase vs camelCase vs kebab case vs snake_case.

TypeScript песочница со всем кодом из статьи.

Подробнее..

Внедрение зависимостей проще, чем кажется?

27.06.2020 14:19:52 | Автор: admin
Привет, Хабр!

У нас готовится к выходу второе издание легендарной книги Марка Симана, Внедрение зависимостей на платформе .NET



Поэтому сегодня мы решили кратко освежить тему внедрения зависимостей для специалистов по .NET и C# и предлагаем перевод статьи Грэма Даунса, где эта парадигма рассматривается в контексте инверсии управления (IoC) и использования контейнеров

Большинству программистов, пожалуй, известен феномен Внедрение зависимостей (Dependency Injection), но не всем понятно, какой смысл в него вкладывается. Вероятно, вам приходилось иметь дело с интерфейсами и контейнерами, и иногда работа с ними приводила вас в тупик. С другой стороны, вы, возможно, только что-то слышали о внедрении зависимостей, и открыли эту статью, так как хотите лучше разобраться в их сути. В этой статье я покажу, насколько проста концепция внедрения зависимостей, и какой мощной она может быть на самом деле.

Внедрение зависимостей это самодостаточный подход, который можно использовать сам по себе. С другой стороны, этот подход можно применять и вместе с интерфейсами, и с контейнерами для внедрения зависимостей/инверсии управления (DI/IoC). Применяя внедрение зависимостей в таком контексте, можно столкнуться с некоторой путаницей, которую поначалу испытывал и я.

На протяжении всей карьеры (я специализируюсь на разработке в Net/C#), я привык использовать внедрение зависимостей в его чистейшей форме. При этом я реализовывал DI, вообще не прибегая ни к контейнерам, ни к инверсии управления. Все изменилось совсем недавно, когда мне поставили задачу, в которой без использования контейнеров было не обойтись. Тогда я крепко усомнился во всем, что знал ранее.

Поработав в таком стиле несколько недель, я осознал, что контейнеры и интерфейсы не осложняют внедрение зависимостей, а, наоборот, расширяют возможности этой парадигмы.

(Здесь важно отметить: интерфейсы и контейнеры используются только в контексте внедрения зависимостей. Внедрение зависимостей можно реализовать и без интерфейсов/контейнеров, но, в сущности, единственное назначение интерфейсов или контейнеров облегчить внедрение зависимостей).

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

Подготовка


Чтобы лучше понять внедрение зависимостей в их чистейшей форме, давайте разберем пример приложения, написанного на C#.

Для начала отметим, что такое приложение можно было бы написать без какого-либо внедрения зависимостей. Затем мы введем в него внедрение зависимостей, добавив простую возможность логирования.

По ходу работы вы увидите, как требования, предъявляемые к логированию, постепенно усложняются, и мы удовлетворяем эти требования, используя внедрение зависимостей; при этом зона ответственности класса Calculator сводится к минимуму. Внедрение зависимостей также избавит нас от необходимости видоизменять класс Calculator всякий раз, когда мы захотим поменять устройство логирования.

Приложение


Рассмотрим следующий код. Он написан для простого приложения-калькулятора, принимающего два числа, оператор и выводящего результат. (Это простое рабочее приложение для командной строки, поэтому вам не составит труда воспроизвести его как C# Console Application в Visual Studio и вставить туда код, если вы хотите следить за развитием примера. Все должно работать без проблем.)

У нас есть класс Calculator и основной класс Program, использующий его.

Program.cs:

using System;using System.Linq;namespace OfferZenDiTutorial{    class Program    {        static void Main(string[] args)        {            var number1 = GetNumber("Enter the first number: > ");            var number2 = GetNumber("Enter the second number: > ");            var operation = GetOperator();            var calc = new Calculator();            var result = GetResult(calc, number1, number2, operation);            Console.WriteLine($"{number1} {operation} {number2} = {result}");            Console.Write("Press any key to continue...");            Console.ReadKey();        }        private static float GetNumber(string message)        {            var isValid = false;            while (!isValid)            {                Console.Write(message);                var input = Console.ReadLine();                isValid = float.TryParse(input, out var number);                if (isValid)                    return number;                Console.WriteLine("Please enter a valid number. Press ^C to quit.");            }            return -1;        }        private static char GetOperator()        {            var isValid = false;            while (!isValid)            {                Console.Write("Please type the operator (/*+-) > ");                var input = Console.ReadKey();                Console.WriteLine();                var operation = input.KeyChar;                if ("/*+-".Contains(operation))                {                    isValid = true;                    return operation;                }                Console.WriteLine("Please enter a valid operator (/, *, +, or -). " +                                  "Press ^C to quit.");            }            return ' ';        }        private static float GetResult(Calculator calc, float number1, float number2,             char operation)        {            switch (operation)            {                case '/': return calc.Divide(number1, number2);                case '*': return calc.Multiply(number1, number2);                case '+': return calc.Add(number1, number2);                case '-': return calc.Subtract(number1, number2);                default:                    // Такого произойти не должно, если с предыдущими валидациями все было нормально                     throw new InvalidOperationException("Invalid operation passed: " +                                                         operation);            }        }    }}

Главная программа запускается, запрашивает у пользователя два числа и оператор, а затем вызывает класс Calculator для выполнения простой арифметической операции над этими числами. Затем выводит результат операции. Вот класс Calculator.

Calculator.cs:

namespace OfferZenDiTutorial{    public class Calculator    {        public float Divide(float number1, float number2)        {            return number1 / number2;        }        public float Multiply(float number1, float number2)        {            return number1 * number2;        }        public float Add(float number1, float number2)        {            return number1 + number2;        }        public float Subtract(float number1, float number2)        {            return number1 - number2;        }    }}

Логирование


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

Кажется, что не так это и сложно, верно? Берете и добавляете инструкции, в соответствии с которыми все операции, производимые в Calculator, должны заноситься в текстовый файл. Вот как теперь выглядит ваш Calculator:

Calculator.cs:

using System.IO;namespace OfferZenDiTutorial{    public class Calculator    {        private const string FileName = "Calculator.log";        public float Divide(float number1, float number2)        {            File.WriteAllText(FileName, $"Running {number1} / {number2}");            return number1 / number2;        }        public float Multiply(float number1, float number2)        {            File.WriteAllText(FileName, $"Running {number1} * {number2}");            return number1 * number2;        }        public float Add(float number1, float number2)        {            File.WriteAllText(FileName, $"Running {number1} + {number2}");            return number1 + number2;        }        public float Subtract(float number1, float number2)        {            File.WriteAllText(FileName, $"Running {number1} - {number2}");            return number1 - number2;        }    }}

Прекрасно работает. Всякий раз, когда в Calculator что-либо происходит, он записывает это в файл Calculator.log, расположенный в той же директории, откуда он запускается.

Но, возможен вопрос: а в самом ли деле уместно, чтобы класс Calculator отвечал за запись в текстовый файл?

Класс FileLogger


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

Первым делом создаем совершенно новый класс, назовем его FileLogger. Вот как он будет выглядеть.

FileLogger.csh:

using System;using System.IO;namespace OfferZenDiTutorial{    public class FileLogger    {        private const string FileName = "Calculator.log";        private readonly string _newLine = Environment.NewLine;        public void WriteLine(string message)        {            File.AppendAllText(FileName, $"{message}{_newLine}");        }    }}

Теперь все, что касается создания файла логов и записи информации в него обрабатывается в этом классе. Дополнительно получаем и одну приятную мелочь: что бы ни потреблял этот класс, не требуется ставить между отдельными записями пустые строки. Записи должны просто вызывать наш метод WriteLine, а все остальное мы берем на себя. Разве не круто?
Чтобы использовать класс, нам нужен объект, который его инстанцирует. Давайте решим эту проблему внутри класса Calculator. Заменим содержимое класса Calculator.cs следующим:

Calculator.cs:

namespace OfferZenDiTutorial{    public class Calculator    {        private readonly FileLogger _logger;        public Calculator()        {            _logger = new FileLogger();        }        public float Divide(float number1, float number2)        {            _logger.WriteLine($"Running {number1} / {number2}");            return number1 / number2;        }        public float Multiply(float number1, float number2)        {            _logger.WriteLine($"Running {number1} * {number2}");            return number1 * number2;        }        public float Add(float number1, float number2)        {            _logger.WriteLine($"Running {number1} + {number2}");            return number1 + number2;        }        public float Subtract(float number1, float number2)        {            _logger.WriteLine($"Running {number1} - {number2}");            return number1 - number2;        }    }}

Итак, теперь нашему калькулятору не важно, как именно новый логгер записывает информацию в файл, или где находится этот файл, и происходит ли вообще запись в файл. Однако, все равно существует одна проблема: можем ли мы вообще рассчитывать на то, что класс Calculator будет знать, как создается логгер?

Внедрение зависимости


Очевидно, ответ на последний вопрос отрицательный!

Именно здесь, уважаемый читатель, в дело вступает внедрение зависимости. Давайте изменим конструктор нашего класса Calculator:

Calculator.cs:

        public Calculator(FileLogger logger)        {            _logger = logger;        }

Вот и все. Больше в классе ничего не меняется.

Внедрение зависимостей это элемент более крупной темы под названием Мнверсия управления, но ее подробное рассмотрение выходит за рамки этой статьи.

В данном случае вам всего лишь требуется знать, что мы инвертируем управление классом логгера, либо, выражаясь метафорически, делегируем кому-то проблему создания файла FileLogger, внедряя экземпляр FileLogger в наш калькулятор, а не рассчитывая, что класс Calculator сам будет знать, как его создать.

Итак, чья же это ответственность?

Как раз того, кто инстанцирует класс Calculator. В нашем случае это основная программа.

Чтобы это продемонстрировать, изменим метод Main в нашем классе Program.cs следующим образом:

Program.cs

  static void Main(string[] args)        {            var number1 = GetNumber("Enter the first number: > ");            var number2 = GetNumber("Enter the second number: > ");            var operation = GetOperator();            // Следующие две строки изменены            var logger = new FileLogger();            var calc = new Calculator(logger);            var result = GetResult(calc, number1, number2, operation);            Console.WriteLine($"{number1} {operation} {number2} = {result}");            Console.Write("Press any key to continue...");            Console.ReadKey();        }

Таким образом, требуется изменить всего две строки. Мы не рассчитываем, что класс Calculator инстанцирует FileLogger, это за него сделает Main, а затем передаст ему результат.

В сущности, это и есть внедрение зависимостей. Не нужны ни интерфейсы, ни контейнеры для инверсии управления, ни что-либо подобное. В принципе, если вам доводилось выполнять что-либо подобное, то вы имели дело с внедрением зависимостей. Круто, правда?

Расширение возможностей: сделаем другой логгер


Несмотря на вышесказанное, у интерфейсов есть свое место, и по-настоящему они раскрываются именно в связке с Внедрением Зависимостей.

Допустим, у вас есть клиент, с точки зрения которого логирование каждого вызова к Calculator пустая трата времени и дискового пространства, и лучше вообще ничего не логировать.

Как вы считаете, придется ли ради этого делать изменения внутри Calculator, что потенциально потребует перекомпилировать и перераспределить ту сборку, в которой он находится?

Вот здесь нам и пригодятся интерфейсы.

Давайте напишем интерфейс. Назовем его ILogger, поскольку его реализацией будет заниматься наш класс FileLogger.

ILogger.cs

namespace OfferZenDiTutorial{    public interface ILogger    {        void WriteLine(string message);    }}

Как видите, он определяет единственный метод: WriteLine, реализованный FileLogger. Сделаем еще шаг и формализуем эти отношения, сделав так, чтобы этот класс официально реализовывал наш новый интерфейс:

FileLogger.cs

public class FileLogger : ILogger

Это единственное изменение, которое мы внесем в этот файл. Все остальное будет как прежде.
Итак, отношение мы определили что нам теперь с ним делать?

Для начала изменим класс Calculator таким образом, чтобы он использовал интерфейс ILogger, а не конкретную реализацию FileLogger:

Calculator.cs

private readonly ILogger _logger;        public Calculator(ILogger logger)        {            _logger = logger;        }

На данном этапе код по-прежнему компилируется и выполняется без всяких проблем. Мы передаем в него FileLogger из главного метода программы, того, который реализует ILogger. Единственное отличие заключается в том, что Calculator не просто не требуется знать, как создавать FileLogger, но и даже логгер какого рода ему выдается.

Поскольку все, что бы вы ни получили, реализует интерфейс ILogger (и, следовательно, имеет метод WriteLine), с практическим использованием проблем не возникает.

Теперь давайте добавим еще одну реализацию интерфейса ILogger. Это будет класс, который ничего не делает при вызове метода WriteLine. Мы назовем его NullLogger, и вот как он выглядит:

NullLogger.cs

namespace OfferZenDiTutorial{    public class NullLogger : ILogger    {        public void WriteLine(string message)        {            // Ничего не делаем в этой реализации        }    }}

На этот раз нам вообще ничего не потребуется менять в классе Calculator, если мы соберемся использовать новый NullLogger, поскольку тот уже принимает что угодно, реализующее интерфейс ILogger.

Нам потребуется изменить только лишь метод Main в нашем файле Program.cs, чтобы передать в него иную реализацию. Давайте этим и займемся, чтобы метод Main принял следующий вид:

Program.cs

 static void Main(string[] args)        {            var number1 = GetNumber("Enter the first number: > ");            var number2 = GetNumber("Enter the second number: > ");            var operation = GetOperator();            var logger = new NullLogger(); // Эту строку нужно изменить            var calc = new Calculator(logger);            var result = GetResult(calc, number1, number2, operation);            Console.WriteLine($"{number1} {operation} {number2} = {result}");            Console.Write("Press any key to continue...");            Console.ReadKey();        }

Опять же, изменить нужно только ту строку, которую я откомментировал. Если мы хотим использовать иной механизм логирования (например, такой, при котором информация записывается в журнал событий Windows, либо с применением SMS-уведомлений или уведомлений по электронной почте), то нам потребуется всего лишь передать иную реализацию
интерфейса ILogger.

Небольшая оговорка об интерфейсах


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

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

Контейнеры для внедрения зависимостей


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

Знакомьтесь с контейнером для внедрения зависимостей. Он упрощает вам жизнь, но принцип работы такого контейнера может показаться весьма запутанным, особенно, когда вы только начинаете его осваивать. На первый взгляд эта возможность может отдавать некоторой магией.
В данном примере мы воспользуемся контейнером от Unity, но на выбор есть и много других, назову лишь наиболее популярные: Castle Windsor, Ninject. С функциональной точки зрения эти контейнеры практически не отличаются. Разница может быть заметна на уровне синтаксиса и стиля, но, в конечном итоге, все сводится к вашим персональным предпочтениям и опыту разработки (а также к тому, что предписывается в вашей компании!).

Давайте подробно разберем пример с использованием Unity: я постараюсь объяснить, что здесь происходит.

Первым делом вам потребуется добавить ссылку на Unity. К счастью, для этого существует пакет Nuget, поэтому щелкните правой кнопкой мыши по вашему проекту в Visual Studio и выберите Manage Nuget Packages:



Найдите и установите пакет Unity, ориентируйтесь на проект Unity Container:



Итак, мы готовы. Измените метод Main файла Program.cs вот так:

Program.cs

 static void Main(string[] args)        {            var number1 = GetNumber("Enter the first number: > ");            var number2 = GetNumber("Enter the second number: > ");            var operation = GetOperator();            // Следующие три строки необходимо изменить            var container = new UnityContainer();            container.RegisterType<ILogger, NullLogger>();            var calc = container.Resolve<Calculator>();            var result = GetResult(calc, number1, number2, operation);            Console.WriteLine($"{number1} {operation} {number2} = {result}");            Console.Write("Press any key to continue...");            Console.ReadKey();        }

Опять же, менять требуется только те строки, которые отмечены. При этом, нам не придется ничего менять ни в классе Calculator, ни в одном из логгеров, ни в их интерфейсе: теперь они все внедряются во время исполнения, поэтому, если нам понадобится иной логгер, то мы должны будем всего лишь изменить конкретный класс, регистрируемый для ILogger.

При первом запуске этого кода вы можете столкнуться с такой ошибкой:



Вероятно, это одна из причуд с той версией пакета Unity, которая была актуальна на момент написания этой статьи. Надеюсь, что у вас все пройдет гладко.
Все дело в том, что при установке Unity также устанавливается неверная версия другого пакета, System.Runtime.CompilerServices.Unsafe. Если вы получаете такую ошибку, то должны вернуться к менеджеру пакетов Nuget, найти этот пакет под вкладкой Installed и обновить его до новейшей стабильной версии:



Теперь, когда этот проект работает, что именно он делает? Когда вы впервые пишете код таким образом, тянет предположить, что здесь замешана какая-то магия. Сейчас я расскажу, что мы здесь видим на самом деле и, надеюсь, это будет сеанс магии с разоблачением, поэтому далее вы не будете остерегаться работы с контейнерами.

Все начинается со строки var calc = container.Resolve<Calculator>();, поэтому именно отсюда я изложу смысл этого кода в форме диалога контейнера с самим собой: о чем он думает, когда видит эту инструкцию.

  1. Мне задано разрешить что-то под названием Calculator. Я знаю, что это такое?
  2. Вижу, в актуальном дереве процессов есть класс под названием Calculator. Это конкретный тип, значит, у него всего лишь одна реализация. Просто создам экземпляр этого класса. Как выглядят конструкторы?
  3. Хм, а конструктор всего один, и принимает он что-то под названием ILogger. Я знаю, что это такое?
  4. Нашел, но это же интерфейс. Мне вообще сообщалось, как его разрешать?
  5. Да, сообщалось! В предыдущей строке сказано, что, всякий раз, когда мне требуется разрешить ILogger, я должен передать экземпляр класса NullLogger.
  6. Окей, значит тут есть NullLogger. У него непараметризованный конструктор. Просто создам экземпляр.
  7. Передам этот экземпляр конструктору класса Calculator, а затем верну этот экземпляр к var calc.

Обратите внимание: если бы у NullLogger был конструктор, который запрашивал бы дополнительные типы, то конструктор просто повторил бы для них все шаги, начиная с 3. В принципе, он просматривает все типы и пытается автоматически разрешить все найденные типы в конкретные экземпляры.

Также обратите внимание, что в пункте 2 разработчикам не приходится явно регистрировать конкретные типы. При желании это можно сделать, например, чтобы изменить жизненный цикл либо передать производные классы и т.д. Но это еще одна причина избегать создания интерфейсов, пока в них нет очевидной нужды.

Вот и все. Ничего таинственного и особо мистического.

Другие возможности


Стоит отметить, что контейнеры позволяют делать еще некоторые вещи, которые было бы довольно сложно (но не невозможно) реализовать самостоятельно. Среди таких вещей управление жизненным циклом и внедрение методов и свойств. Обсуждение этих тем выходит за рамки данной статьи, поскольку, маловероятно, что такие техники понадобятся начинающим. Но я рекомендую вам, освоившись с темой, внимательно почитать документацию, чтобы понимать, какие еще возможности существуют.

Если вы хотите сами поэкспериментировать с примерами, приведенными в этой статье: смело клонируйте с Гитхаба репозиторий, в котором они выложены github.com/GrahamDo/OfferZenDiTutorial.git. Там семь веток, по одной на каждую рассмотренную нами итерацию.
Подробнее..

Методы организации DI и жизненного цикла приложения в GO

08.12.2020 00:17:38 | Автор: admin

Есть несколько вещей, которыми можно заниматься вечно: смотреть на огонь, фиксить баги в легаси-коде и, конечно, говорить о DI и всё равно нет-нет, да и будешь сталкиваться со странными зависимостями в очередном приложении.
В контексте языка GO, впрочем, ситуация чуть сложнее, поскольку явно выраженного и всеми поддерживаемого стандарта работы с зависимостями нет и каждый крутит педали своего собственного маленького самоката а, значит, есть что обсудить и сравнить.


В данной статье я рассмотрю самые популярные инструменты и подходы для организации иерархии зависимостей в go, с их преимуществами и недостатками. В случае, если вы знаете теорию и аббревиатура DI не вызывает у вас вопросов (в том числе и необходимость применения этого подхода), то можете начинать читать статью с середины, в первую половине я объясню, что такое DI, зачем это нужно вообще и в частности в го.


Зачем нам всё это нужно


Стоит начать с того, что главный враг всех программистов и главная причина появления практически всех инструментов проектирования это сложность. Тривиальный случай всегда понятен, легко ложится в голову, очевидно и изящно решается одной строчкой кода и с ним никогда не бывает проблем. Иное дело, когда в системе десятки и сотни тысяч (а иногда и больше) строк кода, и великое множество движущихся частей, которые переплетаются, взаимодействуют, да и просто существуют в одном тесном мирке, где кажется невозможным развернуться, не задев кого-то локтями.
Для решения проблемы сложности человечество пока не нашло пути лучше, чем разбивать сложные вещи на простые, изолируя их и рассматривая по отдельности.
Ключевая вещь здесь это изоляция, пока один компонент не влияет на соседние, можно не опасаться неожиданных эффектов и неявного воздействия одним на результат работы второго. Для обеспечения такой изоляции мы решаем контролировать связи каждого компонента, явно описав, от чего и как он зависит.
На этом моменте мы приходим к инъекции (или внедрению) зависимостей, которая на самом деле является просто способом организовать код так, чтобы каждому компоненту (класс, структура, модуль, etc.) были доступны только необходимые ему части приложения, скрывая от него всё излишнее для его работы или, цитируя википедию: DI это процесс предоставления внешней зависимости программному компоненту.


Такой подход решает сразу несколько задач:


  • Скрывает излишнее, уменьшая когнитивную нагрузку на разработчика;
  • Исключает неожиданные побочные эффекты (то есть, неявное влияние одних компонентов на работу других);
  • Абстрагирует одни компоненты от других, позволяя легко их заменять, тестировать и изменять;

Про жизненный цикл или при чём тут DI


Каждое приложение или его компонент для осуществления своей полезной работы требует решения дополнительных инфраструктурных задач. Эти действия можно объединить в несколько фаз:


  • Запуск приложение или компонент должно запустится и провести приготовления к работе: считать и применить конфигурацию, проверить доступ до внешних систем, от которых зависит непосредственно (например, база данных), начать слушать порт и так далее;
  • Работа наше приложение или компонент осуществляет свою полезную деятельность;
  • Завершение работы приложение или компонент прекращают принимать новые сигналы, заканчивают обрабатывать накопившиеся задачи, останавливают свою деятельность, закрывают соединения и так далее.

Эти три фазы упрощенно и есть основной жизненный цикл приложения и упоминаются они вместе с DI по той причине, что жизненный цикл приложения не может не основываться на зависимостях между компонентами приложения.
Безусловно, управление компонентами и решение вопросов их инициализации (собственно, DI) это всё же две разные задачи, но они часто решаются одними и теми же инструментами, а также связаны между собой, так что я позволю себе вольность рассматривать их как одно целое в рамках данной статьи.
Тут также следует заметить, что существуют различные подходы как реализации самого DI (внедрение через конструктор, сеттер или свойство), так и реализации системы резолвинга зависимостей (DI-контейнер, сервис локатор, тп), которые тоже обладают своими плюсами и минусами, но это уже вопросы, который необходимо рассматривать отдельно, в какой-нибудь другой статье.


Пример:

Представим, что мы пишем простой и типичный сервер, который принимает JSONы из сети, кладёт их в базу и обратно.
Это означает, что у нас есть:


  • Конфигурация, в которой описано, какой порт слушать и к какой базе присоединяться;
  • Сервер, который слушает порт;
  • Некий коннектор (соединение или пул соединений) к базе данных;

Захотим ли мы поднять сервер или соединение к бд, если у нас не получилось считать конфигурацию?
Устроит ли нас случай, когда сервер уже поднялся, прежде чем выяснилось, что на самом деле база недоступна и часть запросов уже оказалась получена и упала с закономерными internal server error? (или наоборот, мы успели обратиться в базу, создать соединение и тп, прежде чем обнаружили, что указанный порт недоступен?)
Нравится ли нам такой вариант, что при отключении/перезапуске конкретного сервиса пользователи успевают добежать до него и получить ошибку, потому приложение просто моментально завершило работу (возможно даже и в середине обработки чьего-то запроса)?


Эти проблемы решаются иерархией обработки компонентов приложения: сначала мы считываем конфигурацию, потом инициализируем соединение с бд, только потом поднимаем сервер и так далее.
При получении же заветного SIGINT, вместо моментального падения приложение сначала ждёт окончания обработки всех текущих запросов, потом выключает сервер, потом аккуратно закрывает соединение с базой данных и только потом окончательно завершает свою работу. Такое "аккуратное" безопасное завершение работы компонентов, кстати, называется Graceful shutdown.


Реализуя такое поведение в коде, мы бы, конечно, могли написать прямо в самих компонентах вызовы других компонентов с требованием завершить работу, но это бы нарушило принцип единой ответственности и закономерно превратило бы весь наш код в огромную кучу спагетти, без возможности точно понять что и когда вызовется и как вызов одной функции наложится на вызов другой.
Таким образом, существует две разные задачи, которые мы бы хотели отделить друг от друга и которые можно решить, применяя DI:


  • Управление иерархией компонентов приложения, то есть, определить, что от чего зависит, в каком порядке их всех создать и запустить или завершить, чтобы никто из них вдруг не обнаружил, что работает с ещё не созданным или уже завершенным компонентом;
  • Работу самих компонентов приложения: они просто работают, вызывая нужные им другие компоненты и не отвечая за вопросы инициализации, прогрева или остановки работы приложения;

DI не нужен или нужен только в Java


Если ваш код не состоит из одной длинной функции, то он безусловно будет состоять из набора компонентов, почти каждый из которых будет требовать для своей работы каких-то других компонентов. Соответственно, вы всё равно будете решать всё те же задачи управления зависимостями, только более или менее явным способом.
В совсем маленьких сервисах зависимостей, как правило, не очень много и любые вопросы архитектуры являются чем-то вроде порядка на вашем столе: скорее вопросы личной гигиены и чувства прекрасного, чем жизненная необходимость. Но тут нужно учесть пару нюансов: во-первых, наносервисы вполне заслуженно считаются антипаттерном (а, это значит, что у вас в микросервисе всё-таки будет достаточно много кода), во-вторых, программы всегда стремятся к усложнению и никогда к упрощению (что означает, что любой наносервис сейчас всё же имеет хорошие шансы стать больше в будущем), а в третьих существует так называемая "теория разбитых окон", согласно которой бардак, начавшись в маленьких и (казалось бы) неважных частях системы стремится к распространению в другие и морально облегчает заведение бардака в более важных частях приложения.
Поэтому лично я бы сказал, что существует ряд архитектурных практик, которые ничего не стоят, если начать их придерживаться на старте проекта, а их отсутствие вызовет у вас большую боль в будущем, когда очередной сервис немного подрастёт. Например, когда вы захотите написать тесты и замокать пару компонентов, или заменить реализацию одного компонента другим.


Теперь перейдём к практике.


Замечание


Данная статья не преследует цель предоставить исчерпывающую документацию по представленным библиотекам и утилитам, поэтому мной были выбраны максимально упрощенные примеры кода, просто чтобы продемонстрировать концептуальную разницу в рассматриваемых подходах. Естественно, все упомянутые инструменты умеют обрабатывать ошибки, возвращаемые конструкторами и обладают множеством дополнительных возможностей, соответствующие примеры можно найти в их документации.
Используемые примеры кода доступны на https://github.com/vivid-money/article-golang-di.


Ещё замечание


Для всех примеров я буду использовать простенькую иерархию, состоящую из трех компонентов, Logger это интерфейс, написанный под логгер из стандартной библиотеки, DBConn будет изображать соединение с базой данных, а HTTPServer, логично, сервер, слушающий определённый порт и производящий некий (фейковый) запрос к базе данных. Соответственно, инициализироваться и запускаться они должны в порядке Logger->DBConn->HTTPServer, а завершаться в обратном порядке.
Для демонстрации работы с блокирующимися и неблокирубщимися компонентами, DBConn не требует постоянной работы (просто необходимо один раз вызвать DBConn.Connect()), а httpServer.Serve, напротив, блокирует текущий поток исполнения.


Reflection based container


Начнём с распространенного в других языках варианта, который в мире го в основном представлен пакетами https://github.com/uber-go/dig и расширяющим его https://github.com/uber-go/fx.
Идея проста, граф зависимостей можно легко динамически описать в рантайме, там же к каждому из компонентов можно привязать хуки на старт и завершение работы. Посмотрим, как это выглядит на простом примере:


// Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до инициализации графа зависимостей.logger := log.New(os.Stderr, "", 0)logger.Print("Started")container := dig.New() // создаём контейнер// Регистрируем конструкторы.// Dig во время запуска программы будет использовать рефлексию, чтобы по сигнатуре каждой функции понять, что она создаёт и что для этого требует._ = container.Provide(func() components.Logger {    logger.Print("Provided logger")    return logger // Прокинули уже созданный логгер.})_ = container.Provide(components.NewDBConn)_ = container.Provide(components.NewHTTPServer)_ = container.Invoke(func(_ *components.HTTPServer) {    // Вызвали HTTPServer, как "корень" графа зависимостей, чтобы прогрузилось всё необходимое.    logger.Print("Can work with HTTPServer")    // Никаких средств для управления жизненным циклом нет, пришлось бы всё писать вручную.})/*    Output:    ---    Started    Provided logger    New DBConn    New HTTPServer    Can work with HTTPServer*/

Также fx предоставляет возможность работать непосредственно с жизненным циклом приложения:


ctx, cancel := context.WithCancel(context.Background())defer cancel()// Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до// инициализации графа зависимостей.logger := log.New(os.Stderr, "", 0)logger.Print("Started")// На этот раз используем fx, здесь уже у нас появляется объект "приложения".app := fx.New(    fx.Provide(func() components.Logger {        return logger // Добавляем логгер как внешний компонент.    }),    fx.Provide(        func(logger components.Logger, lc fx.Lifecycle) *components.DBConn { // можем получить ещё и lc - жизненный цикл.            conn := components.NewDBConn(logger)            // Можно навесить хуки.            lc.Append(fx.Hook{                OnStart: func(ctx context.Context) error {                    if err := conn.Connect(ctx); err != nil {                        return fmt.Errorf("can't connect to db: %w", err)                    }                    return nil                },                OnStop: func(ctx context.Context) error {                    return conn.Stop(ctx)                },            })            return conn        },        func(logger components.Logger, dbConn *components.DBConn, lc fx.Lifecycle) *components.HTTPServer {            s := components.NewHTTPServer(logger, dbConn)            lc.Append(fx.Hook{                OnStart: func(_ context.Context) error {                    go func() {                        defer cancel()                        // Ассинхронно запускаем сервер, т.к. Serve - блокирующая операция.                        if err := s.Serve(context.Background()); err != nil && !errors.Is(err, http.ErrServerClosed) {                            logger.Print("Error: ", err)                        }                    }()                    return nil                },                OnStop: func(ctx context.Context) error {                    return s.Stop(ctx)                },            })            return s        },    ),    fx.Invoke(        // Конструкторы - "ленивые", так что нужно будет вызвать корень графа зависимостей, чтобы прогрузилось всё необходимое.        func(*components.HTTPServer) {            go func() {                components.AwaitSignal(ctx) // ожидаем сигнала, чтобы после этого завершить приложение.                cancel()            }()        },    ),    fx.NopLogger,)_ = app.Start(ctx)<-ctx.Done() // ожидаем завершения контекста в случае ошибки или получения сигнала_ = app.Stop(context.Background())/*    Output:    ---    Started    New DBConn    New HTTPServer    Connecting DBConn    Connected DBConn    Serving HTTPServer    ^CStop HTTPServer    Stopped HTTPServer    Stop DBConn    Stopped DBConn*/

Может возникнуть вопрос, должен ли метод Serve быть блокирующим (по аналогии с ListenAndServe) или нет? Моя точка зрения на это проста: сделать блокирующий метод неблокирующим очень просто (go blockingFunc()), а вот обратное очень сложно. Так как любой код должен в том числе и облегчать работу с собой тем, кто его использует, логичнее всего предоставлять синхронный код, а ассинхронным его пусть сделает вызывающий, если ему это понадобится.


Возвращаясь к fx, в особенно сложных ситуациях можно использовать разнообразные специальные типы (fx.In, fx.Out и тд) и аннотации (optional, name и тд), позволяющие компонентам, зависящим от одинаковых интерфейсов, получать различные зависимости или просто связывать что-то по кастомным именам.
Также доступны хелперы, дающие дополнительные возможности, например, fx.Supply позволяет добавить в контейнер уже инициализированный объект в случае, если вы по какой-то причине не хотите его инициализировать используя сам контейнер, но хотите использовать его для других компонентов.


Такой "динамический" подход имеет свои плюсы:


  • Нет нужды поддерживать порядок, мы просто регистрируем конструкторы, а потом обращаемся к нужным интерфейсам и всё происходит самостоятельно, "волшебным образом". Соответственно, проще добавлять новый код;
  • За счёт динамического построения графа зависимостей, легко как подменять какие-то части на моки, так и вовсе тестировать отдельные части приложения;
  • Можно запросто использовать любые внешние библиотеки, просто добавив их конструкторы в контейнер;
  • Позволяет писать меньше кода;
  • Не требует xml или yaml;

Минусы:


  • Больше магии, сложнее разбираться с проблемами;
  • Поскольку контейнер собирается динамически, в рантайме, то мы теряем compile-time гарантии узнать о многих проблемах с зависимостями (например, забыли что-то зарегистрировать) можно только запустив приложение, иногда в особой конфигурации. Отчасти надёжность можно было бы повысить тестами, но именно гарантий такой подход всё равно не даст.
  • Конкретно для fx:
    • Нет возможностей обрабатывать ошибки работы компонентов (когда Serve внезапно прекращает работу и возвращает ошибку), придётся писать свои велосипеды, благо, это дело не самое сложное;


Кодогенерация


Остальные способы основываются на статическом коде и первым из них на ум приходит кодогенерация, которая в go представлена преимущественно https://github.com/google/wire за авторством всем известной компании.
Из самого названия этого подхода логично следует, что вместо того, чтобы резолвить зависимости динамически, мы сгенерируем явный статический и типизированный код. Таким образом, в случае ошибки на уровне графа зависимостей он или не сгенерируется, или не скомпилируется, соответственно, мы получаем compile-time гарантии решения зависимостей.
При таком подходе весь вопрос заключается в том, как именно мы будем описывать наш граф зависимостей, чтобы потом сгенерировать для него код. В разных языках для описания связей в коде используются различные средства, от аннотаций до конфигурационных файлов, но, поскольку в мире го аннотаций не существует, а магические комментарии это вещь очень спорная и обладает известными недостатками, разработчики в итоге остановились на конфигурировании кодом. Выглядит это следующим образом:


В начале необходимо описать компоненты и конструкторы для них, стандартным способом.
Затем в отдельном файле мы регистрируем конструкторы под специальным билд-тегом (чтобы код не попал в компиляцию уже "боевого" приложения и не возникало ошибок, связанных с одинаковыми именами функций):


// +build wireinjectpackage mainimport (    "context"    "github.com/google/wire"    "github.com/vivid-money/article-golang-di/pkg/components")func initializeHTTPServer(    _ context.Context,    _ components.Logger,    closer func(), // функция, которая вызовет остановку всего приложения) (    res *components.HTTPServer,    cleanup func(), // функция, которая остановит приложение    err error,) {    wire.Build(        NewDBConn,        NewHTTPServer,    )    return &components.HTTPServer{}, nil, nil}

В итоге, после вызова одноименной утилиты wire (можно делать это через go generate), wire просканирует ваш код, найдёт все вызовы wire и сгенерирует файл с кодом, который проводит все инжекты:


func initializeHTTPServer(contextContext context.Context, logger components.Logger, closer func()) (*components.HTTPServer, func(), error) {    dbConn, cleanup, err := NewDBConn(contextContext, logger)    if err != nil {        return nil, nil, err    }    httpServer, cleanup2 := NewHTTPServer(contextContext, logger, dbConn, closer)    return httpServer, func() {        cleanup2()        cleanup()    }, nil}

Соответственно мы можем сразу же вызывать initializeHTTPServer при старте нашего приложения и использовать сгенерированный код, который создаст и "прокинет" куда надо все зависимости:


package main//go:generate wireimport (    "context"    "fmt"    "log"    "os"    "errors"    "net/http"    "github.com/vivid-money/article-golang-di/pkg/components")// Поскольку wire не поддерживает lifecycle (точнее, поддерживает только Cleanup-функции), а мы не хотим// делать вызовы компонентов в нужном порядке руками, то придётся написать специальные врапперы для конструкторов,// которые при этом будут при создании компонента начинать работу и возвращать cleanup-функцию для его остановки.func NewDBConn(ctx context.Context, logger components.Logger) (*components.DBConn, func(), error) {    conn := components.NewDBConn(logger)    if err := conn.Connect(ctx); err != nil {        return nil, nil, fmt.Errorf("can't connect to db: %w", err)    }    return conn, func() {        if err := conn.Stop(context.Background()); err != nil {            logger.Print("Error trying to stop dbconn", err)        }    }, nil}func NewHTTPServer(    ctx context.Context,    logger components.Logger,    conn *components.DBConn,    closer func(),) (*components.HTTPServer, func()) {    srv := components.NewHTTPServer(logger, conn)    go func() {        if err := srv.Serve(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {            logger.Print("Error serving http: ", err)        }        closer()    }()    return srv, func() {        if err := srv.Stop(context.Background()); err != nil {            logger.Print("Error trying to stop http server", err)        }    }}func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    // Логгер в качестве исключения создадим заранее, потому что как правило что-то нужно писать в логи сразу, ещё до инициализации графа зависимостей.    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    // Нужен способ остановить приложение по команде или в случае ошибки. Не хочется отменять "главный" кониекси, так    // как он прекратит все Server'ы одновременно, что лишит смысла использование cleanup-функций. Поэтому мы будем    // делать это на другом контексте.    lifecycleCtx, cancelLifecycle := context.WithCancel(context.Background())    defer cancelLifecycle()    // Ничего не делаем с сервером, потому что вызываем Serve в конструкторах.    _, cleanup, _ := initializeHTTPServer(ctx, logger, func() {        cancelLifecycle()    })    defer cleanup()    go func() {        components.AwaitSignal(ctx) // ждём ошибки или сигнала        cancelLifecycle()    }()    <-lifecycleCtx.Done()    /*        Output:        ---        New DBConn        Connecting DBConn        Connected DBConn        New HTTPServer        Serving HTTPServer        ^CStop HTTPServer        Stopped HTTPServer        Stop DBConn        Stopped DBConn    */}

Плюсы такого подхода:


  • Очень явный и предсказуемый код;
  • Гарании на уровне компиляции;
  • Всё ещё не нужно ничего собирать руками;
  • Конфигурация выглядит достаточно минималистично, мы просто обозначаем интерфейсы и вызываем магическую функцию wire.Build;
  • Всё ещё никаких xml;
  • Wire предоставляет возможность возвращать кроме каждого из компонентов ещё и cleanup-функции, что удобно.

Однако есть и минусы:


  • Приходится делать лишние телодвижения, даже описание графа через инжекторы всё-таки занимает место;
  • Тяжелее использовать для тестов и моков, из-за отстутствия явных инструментов работы с абстрактными зависимостями; Это конечно решаемо, например, инжектом конструкторов, но всё равно тянет "лишние" сложности;
  • Конкретно для wire (нужно учитывать, что он ещё в бете):
    • Не умеет соотносить конструктор, возвращающий конкретный объект с зависимостью от интерфейса, если он этот объект реализует;

    • Нет нормальной поддержки жизненного цикла, это заставляет писать свои конструкторы, которые ещё и запускают/останавливают его, что неудобно и в общем смысле, и для использования конструкторов из внешних библиотек;

    • По той же причине приходится изобретать свой велосипед для остановки приложения в случае "падения" одного из компонентов;

    • Cleanup'функции вызываются просто по порядку, если в процессе одной из них произойдёт паника, то остальные не вызовутся.


Собираем граф руками


Для пришедших из других языков это могло бы звучать дико, но на самом деле вам не нужны серьёзные и сложные инструменты для того, чтобы управлять небольшим (или большим, но стабильным) графом зависимостей. Если это вызывает проблемы, то, конечно, лучше взять wire или dig/fx, но я могу вас уверить, что проблем с таким подходом у вас будет значительно меньше, чем вам кажется (или не будет вообще).
Одной из причин этому будет отсутствие у гошников манеры создавать избыточное количество компонентов (вместо отдельных классов-фабрик или даже фабрик-для-фабрик обычно создаётся простая функция-конструктор), другой некоторые специфические возможности го.


Так вот, давайте представим простой код, который сделает все необходимые инжекты:


logger := log.New(os.Stderr, "", 0)dbConn := components.NewDBConn(logger)httpServer := components.NewHTTPServer(logger, dbConn)doSomething(httpServer)

Это будет работать, это вполне минималистично, насколько это вообще можно без рантаймовой магии в данном языке, и вам не будет особенно дорого по необходимости (добавился новый аргумент или вообще новый компонент) добавить пару строк в этот код.
Вся сложность здесь будет в том, как реализовать жизненный цикл, потому что вариантов существует несколько.
Первым рассмотрим способ, про который Avito рассказывали вот в этом докладе:


Используем errgroup.


Выглядит оно вот так:


func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    g, gCtx := errgroup.WithContext(ctx)    dbConn := components.NewDBConn(logger)    g.Go(func() error {        // dbConn умеет останавливаться по отмене контекста.        if err := dbConn.Connect(gCtx); err != nil {            return fmt.Errorf("can't connect to db: %w", err)        }        return nil    })    httpServer := components.NewHTTPServer(logger, dbConn)    g.Go(func() error {        go func() {            // предположим, что httpServer (как и http.ListenAndServe, кстати) не умеет останавливаться по отмене            // контекста, тогда придётся добавить обработку отмены вручную.            <-gCtx.Done()            if err := httpServer.Stop(context.Background()); err != nil {                logger.Print("Stopped http server with error:", err)            }        }()        if err := httpServer.Serve(gCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {            return fmt.Errorf("can't serve http: %w", err)        }        return nil    })    go func() {        components.AwaitSignal(gCtx)        cancel()    }()    _ = g.Wait()    /*        Output:        ---        Started        New DBConn        New HTTPServer        Connecting DBConn        Connected DBConn        Serving HTTPServer        ^CStop HTTPServer        Stop DBConn        Stopped DBConn        Stopped HTTPServer        Finished serving HTTPServer    */}

Как это работает?
Мы запускаем все компоненты нашего приложения в отдельных горутинах, но при этом запускаем не вручную, а через специальную структуру g, которая:


  1. Будет считать запущенные через неё функции (чтобы потом дождаться всех);
  2. Предоставляет собственный контекст с возможностью отмены (получаем иерархию ctx.cancel->gCtx.cancel для каждой конечной функции);
  3. Будет внимательно смотреть на результаты функций, если хоть одна из них завершится ошибкой то отменит свой контекст, в результате чего все функции смогут получить сигнал отмены через переданные им gCtx и завершить свою работу.

Такая схема в целом неплоха, но я нахожу в ней определённый фатальный недостаток: errgroup заставляет положиться на событие отмены контекста. Такой подход не гарантирует порядка отмены каждой из функций, каждая из них может проверить переданный ей gCtx на .Done() в любой удобный для неё момент и в итоге мы теоретически можем получить ситуацию, когда у вас соединение с базой получило cancel и завершилось до того, как какой-то более высокоуровневый компонент (например, обрабатывающий важный сетевой запрос) завершил свою работу.
Кроме того:


  • errgroup возвращает только первую ошибку, остальные игнорирует;
  • errgroup отменяет контекст только в том случае, если какой-то из компонентов вернул ошибку. Если же по какой-то причине некий компонент завершится без ошибки, то система не отреагирует, продолжив работать, как ни в чём не бывало. Да, это можно исправить каким-нибудь велосипедом, но в таком случае зачем мы вообще что-то брали, если потом всё равно придётся дописывать?

Следующий способ это самописный lifecycle.


Идея, кажется, лежит на поверхности: если errgroup не даёт нам нужных гарантий, можно написать свой велосипед, который их даёт.
Таких идей в своё время не избежал и я и лично у меня получилось что-то такое:


ctx, cancel := context.WithCancel(context.Background())defer cancel()logger := log.New(os.Stderr, "", 0)logger.Print("Started")lc := lifecycle.NewLifecycle()dbConn := components.NewDBConn(logger)lc.AddServer(func(ctx context.Context) error { // просто регистриуем в правильном порядке серверы и шатдаунеры    return dbConn.Connect(ctx)}).AddShutdowner(func(ctx context.Context) error {    return dbConn.Stop(ctx)})httpSrv := components.NewHTTPServer(logger, dbConn)lc.Add(httpSrv) // потому что httpSrv реализует интерфейсы Server и Shutdownergo func() {    components.AwaitSignal(ctx)    lc.Stop(context.Background())}()_ = lc.Serve(ctx)

И такая идея хороша всем, кроме того, что делает сложным образом то, что можно сделать намного проще, используя нативные средства самого языка.
(именно поэтому реализации моего пакета lifecycle я не стал нигде выкладывать, это не имеет смысла)


Способ финальный


Существуй мы в мире Java или где-то ещё, то остановились бы на предыдущем варианте, поскольку отслеживать порядок инициализации, запуска и остановки сервисов "руками" звучит, как очень неблагодарная работа без права на ошибку.
Но в го есть три удобных инструмента, которые значительно облегчают это дело.
Про горутины в курсе, вероятно, все, кто хоть чуть-чуть этим интересовался, и если вы не в их числе, то вряд ли вы поняли предыдущие примеры кода, так что я не стану добавлять пояснения, тем более, что это вопрос буквально одного абзаца из первой же ссылки в гугле.
Второй такой удобный инструмент, это контекст, некий "волшебный" интерфейс, который принимает, наверное, уже почти любая функция в го и который кроме всего прочего предоставляет функциям возможность узнать, был ли данный контекст отменён (или отменить его самостоятельно для нижележащих функций). В результате такой механизм даёт нам контроль, позволяя каскадно завершать работу функции или группы функций в том числе и из main-функции.
Третий удобный и чуть менее очевидный инструмет, defer, является просто ключевым словом, добавляющим в некий стек текушей функции другую функцию, которая должна быть выполнена после завершения текущей.
А это означает, что во-первых, после defer'а можно делать сколько угодно return'ов не боясь, что где-то забудешь разблокировать мьютекс или закрыть файл (кстати, очень способствует сокращению ветвлений в коде), а во-вторых, они вызываются в обратном порядке. Можно вызывать конструкторы и каждый раз при вызове регистрировать деструктор и они вызовутся сами, по очереди, в правильном порядке с точки зрения графа зависимостей, не требуя никаких дополнительных инструментов:


a, err := NewA()if err != nil {    panic("cant create a: " + err.Error())}go a.Serve()defer a.Stop()b, err := NewB(a)if err != nil {    panic("cant create b: " + err.Error())}go b.Serve()defer b.Stop()/*    Порядок старта: A, B    Порядок остановки: B, A*/

Правда, остаётся ещё вопрос обработки ошибок, а также возврата первоначальной ошибки (что необязательно, но мне нравится делать именно так). Дело не обойдётся без трех маленьких хелперов:


  • ErrSet хранилище ошибок для их использования на уровне старта/остановки приложения;
  • Serve получает контекст и функцию-server, стартует этот server в отдельной горутине и при этом возвращает новый контекст, обернутый в WithCancel, вызываемый при завершении функции-server'а (что позволяет прекратить запуск приложения на середине, если один из предыдущих server'ов завершился);
  • Shutdown просто вызывает функцию и пишет возможную ошибку в ErrSet, потому что когда приложение уже завершается, нет необходимости как-либо отдельно обрабатывать ошибки завершения компонентов;

В итоге, код будет выглядеть так:


package mainimport (    "context"    "fmt"    "log"    "os"    "errors"    "net/http"    "github.com/vivid-money/article-golang-di/pkg/components")func main() {    ctx, cancel := context.WithCancel(context.Background())    defer cancel()    logger := log.New(os.Stderr, "", 0)    logger.Print("Started")    go func() {        components.AwaitSignal(ctx)        cancel()    }()    errset := &ErrSet{}    errset.Add(runApp(ctx, logger, errset))    _ = errset.Error() // можно обработать ошибку    /*        Output:        ---        Started        New DBConn        Connecting DBConn        Connected DBConn        New HTTPServer        Serving HTTPServer        ^CStop HTTPServer        Stop DBConn        Stopped DBConn        Stopped HTTPServer        Finished serving HTTPServer    */}func runApp(ctx context.Context, logger components.Logger, errSet *ErrSet) error {    var err error    dbConn := components.NewDBConn(logger)    if err := dbConn.Connect(ctx); err != nil {        return fmt.Errorf("cant connect dbConn: %w", err)    }    defer Shutdown("dbConn", errSet, dbConn.Stop)    httpServer := components.NewHTTPServer(logger, dbConn)    if ctx, err = Serve(ctx, "httpServer", errSet, httpServer.Serve); err != nil && !errors.Is(err, http.ErrServerClosed) {        return fmt.Errorf("cant serve httpServer: %w", err)    }    defer Shutdown("httpServer", errSet, httpServer.Stop)    components.AwaitSignal(ctx)    return ctx.Err()}

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


Что нам даёт такой подход?


  • Добавление компонентов происходит как и раньше, копипастом магических четырех слов New-Serve-defer-Shutdown (будь у нас дженерики, кстати, можно было бы ещё набросать простенький хелпер, чтобы было ещё меньше кода и совсем симпатично);
  • Поскольку при таком подходе вы можете инициализировать компоненты только в том порядке, в каком они зависят друг от друга, то ошибка, при которой вы начнёте или завершите работу компонентов в неправильном порядке сведена к нулю;
  • Ошибка в середине инициализации сервиса приводит к досрочному завершению приложения;
  • Завершение работы компонентов происходит в правильной (с точки зрения порядка зависимостей) последовательности;
  • Ошибка работы случайного компонента приведет к завершению приложения, но последовательность завершения всё равно останется правильной, от конца к началу;
  • Мы 100% дождёмся окончания всех компонентов, прежде, чем завершить приложение;
  • Весь код, осуществляющий работу жизненного цикла, описан очень явно и не содержит никакий магии;

Недостатки


  • Пишется руками, а значит при сотнях зависимостей может потребоваться переходить к кодогенерации;

Выводы


Самой лучшей практикой всегда остаётся выбор подходящего инструмента под определённую задачу.
Все рассмотренные мной решения имеют свои достоинства и недостатки, как сами по себе, так и применительно к специфике разработки на golang.
Описанный первым fx несмотря на свою некоторую неидиоматичность (в контексте go), выглядит хорошо проработанными и решает практически все необходимые задачи, а что не решает несложно дописать руками.
Wire несмотря на громкое имя создателей выглядит сыроватым и несколько недоработанным, но при этом безусловно идиоматичен и в состоянии продемонстрировать преимущества кодогенерации.
При этом инжекты руками не выглядят (да и не являются, по моему опыту) особенно болезненными, а все необходимые задачи можно решить с помощью стандартных go, context, defer и пары хелперов минимального размера.
Важнейшим делом всегда является архитектура, правильное моделирование предметной области и правильное разделение логики приложения на части с правильными зонами ответственности, а вопрос автоматизации инжектов зависимостей не является критичным, до определённого размера или определённой сложности. Лично я бы до действительно сотен компонентов без проблем использовал подход сбора графа зависимостей руками, а уже потом присмотрелся к wire (может, к тому времени он научиться решать вообще все задачи, решения которых хотелось бы от него ожидать).

Подробнее..

Pure DI для .NET

16.04.2021 22:16:20 | Автор: admin

Для того чтобы следовать принципам ООП и SOLID часто используют библиотеки внедрения зависимостей. Этих библиотек много, и всех их объединяет набор общих функции:

  • API для определения графа зависимостей

  • композиция объектов

  • управление жизненным циклом объектов

Мне было интересно разобраться как это работает, а лучший способ сделать это - написать свою библиотеку внедрения зависимостей IoC.Container. Она позволяет делать сложные вещи простыми способами: неплохо работает с общими типами - другие так не могут, позволяет создавать код, без зависимостей на инфраструктуру и обеспечивает хорошую производительность, по сравнению с другими похожими решениям, но НЕ по сравнению с чистым DI подходом.

Используя классические библиотеки внедрения зависимостей, мы получаем простоту определения графа зависимостей и теряем в производительности. Это заставляет нас искать компромиссы. Так, в случае, когда нужно работать с большим количеством объектов, использование библиотеки внедрения зависимостей может замедлить выполнение приложения. Одним из компромиссов здесь будет отказ от использования библиотек в этой части кода, и создание объектов по старинке. На этом закончится заранее определенный и предсказуемый граф, а каждый такой особый случай увеличит общую сложность кода. Помимо влияния на производительность, классические библиотеки могут быть источником проблем времени выполнения из-за их некорректной настройки.

В чистом DI композиция объектов выполняется вручную: обычно это большое количество конструкторов, аргументами которых являются другие конструкторы и так далее. Дополнительных накладных расходов нет. Проверка корректности композиции выполняется компилятором. Управление временем жизни объектов или любые другие вопросы решаются по мере их возникновения способами, эффективными для конкретной ситуации или более предпочтительными для автора кода. По мере увеличения количества новых классов или с каждой новой зависимостью сложность кода для композиции объектов возрастает все быстрее и быстрее. В какой-то момент можно потерять контроль над этой сложностью, что в последствии сильно замедлит дальнейшую разработку и приведет к ошибкам. Поэтому, по моему опыту, чистый DI применим пока объем кода не велик.

Что если оставить только лучшее от этих подходов:

  • определять граф зависимостей используя простой API

  • эффективная композиция объектов как при чистом DI

  • решение проблем и "нестыковок" на этапе компиляции

На мой взгляд, эти задачи мог бы на себя взять язык программирования. Но пока большинство языков программирования заняты копированием синтаксического сахара друг у друга, предлагается следующее не идеальное решение - использование генератора кода. Входными данными для него будут .NET типы, ориентированные на внедрение зависимостей и метаданные/API описания графа зависимостей. А результатом работы будет автоматически созданный код для композиции объектов, который проверяется и оптимизируется компилятором и JIT.

Итак, представляю вам бета-версию библиотеки Pure.DI! Цель этой статьи - собрать фидбек, идеи как сделать ее лучше. На данный момент библиотека состоит из двух NuGet пакетов beta версии, с которыми уже можно поиграться:

  • первый пакет Pure.DI.Contracts это API чтобы дать инструкции генератору кода как строить граф зависимостей

  • и генератор кода Pure.DI

Пакет Pure.DI.Contracts не содержит выполняемого кода, его можно использовать в проектах .NET Framework начиная с версии 3.5, для всех версий .NET Standard и .NET Core и, кончено, же для проектов .NET 5 и 6, в будущем можно добавить поддержку и .NET Framework 2, если это будет актуально. Все типы и методы этого пакета это API, и нужны исключительно для того, чтобы описать граф зависимостей, используя обычный синтаксис языка C#. Основная часть этого API был позаимствована у IoC.Container.

.NET 5 source code generator и Roslyn стали основой для генератора кода из пакета Pure.DI. Анализ метаданных и генерация происходит на лету каждый раз когда вы редактируете свой код в IDE или автоматически, когда запускаете компиляцию своих проектов или решений. Генерируемый код не отсвечивается рядом с обычным кодом и не попадает в системы контроля версий. Пример ниже показывает, как это работает.

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

interface IBox<out T> { T Content { get; } }interface ICat { State State { get; } }enum State { Alive, Dead }

Реализация этой абстракции для кота Шрёдингера может быть такой:

class CardboardBox<T> : IBox<T>{    public CardboardBox(T content) => Content = content;    public T Content { get; }}class ShroedingersCat : ICat{  // Суперпозиция состояний  private readonly Lazy<State> _superposition;  public ShroedingersCat(Lazy<State> superposition) =>    _superposition = superposition;  // Наблюдатель проверяет кота   // и в этот момент у кота появляется состояние  public State State => _superposition.Value;  public override string ToString() => $"{State} cat";}

Это код предметной области, который не зависит от инфраструктурного кода. Он написан по технологии DI, а в идеале SOLID.

Композиция объектов создается в модуле с инфраструктурным кодом, который отвечает за хостинг приложения, поближе к точке входа. В этот модуль и необходимо добавить ссылки на пакеты Pure.DI.Contracts и Pure.DI. И в нём же следует оставить генератору кода подсказки как строить граф зависимостей:

static partial class Glue{  // Моделирует случайное субатомное событие,  // которое может произойти, а может и не произойти  private static readonly Random Indeterminacy = new();  static Glue()  {    DI.Setup()      // Квантовая суперпозиция двух состояний      .Bind<State>().To(_ => (State)Indeterminacy.Next(2))      // Абстрактный кот будет котом Шрёдингера      .Bind<ICat>().To<ShroedingersCat>()      // Коробкой будет обычная картонная коробка      .Bind<IBox<TT>>().To<CardboardBox<TT>>()      // А корнем композиции тип в котором находится точка входа      // в консольное приложение      .Bind<Program>().As(Singleton).To<Program>();  }}

Первый вызов статического метода Setup() класса DI начинает цепочку подсказок. Если он находится в static partial классе, то весь сгенерированный код станет частью этого класса, иначе будет создан новый статический класс с постфиксом DI. Метод Setup() имеет необязательный параметр типа string чтобы переопределить имя класса для генерации на свое. В примере в настройке использована переменная Indeterminacy, поэтому класс Glue является static partial, чтобы сгенерированный код мог ссылаться на эту переменную из сгенерированной части класса.

Следующие за Setup() пары методов Bind<>() и To<>() определяют привязки типов зависимостей к их реализациям, например в:

.Bind().To()

ICat - это тип зависимости, им может быть интерфейс, абстрактный класс и любой другой не статический .NET тип. ShroedingersCat - это реализация, ей может быть любой не абстрактный не статический .NET тип. По моему мнению, типами зависимостей лучше делать интерфейсы, так как они имеют ряд преимуществ по сравнению с абстрактными классами. Одна из них - это возможность реализовать одним классом сразу несколько интерфейсов, а потом использовать его для нескольких типов зависимостей. И так, в Bind<>() мы определяем тип зависимости, а в To<>() его реализацию. Между этими методами могут быть и другие методы для управления привязкой:

  • дополнительные методы Bind<>(), если нужно привязать более одного типа зависимости к одной и той же реализации

  • метод As(Lifetime) для определения времени жизни объекта, их может быть много в цепочке, но учитывается последний

  • и метод Tag(object), который принимает тег привязки, их может быть несколько на одну привязку, и учитываются все

На данный момент существует несколько времен жизни, а их список будет пополняться:

  • Transient - значение по умолчанию, когда объект типа будет создаваться каждый раз

  • Singleton - будет создан единственный объект типа, в отличие от классических контейнеров здесь это будет статическое поле статического класса

  • PerThread - будет создано по одному объекту типа на поток

  • PerResolve - в процессе одной композиции объект типа будет переиспользован

  • Binding - позволяет использовать свою реализацию интерфейса ILifetime в качестве времени жизни

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

.Bind().Tag(Fat).Tag(Fluffy).To()

Обратите внимание, что методы Bind<>() и To<>() - это универсальные методы. Каждый содержит один параметр типа для определения типа зависимости, и ее реализации соответственно. Вместо неудобных открытых типов, как например, typeof(IBox<>) в API применяются маркеры универсальных типов, как TT. В нашем случае абстрактная коробка - это IBox, а ее картонная реализация это CardboardBox. Почему открытые типы менее удобны? Потому что по открытым типа невозможно однозначно определить требуемую для внедрения реализацию, в случае когда параметры универсальных типов это другие универсальные типы. Помимо обычных маркеров TT, TT1, TT2 и т.д. в API можно использовать маркеры типов с ограничениями. Их достаточно много в наборе готовых маркеров. Если нужен свой маркер c ограничениями, создайте свой тип и добавьте атрибут [GenericTypeArgument] и он станет маркером универсального типа, например:

[GenericTypeArgument]public class TTMy: IMyInterface { }

Метод To<>() завершает определение привязки. В общем случае реализация будет создаваться автоматически. Будет найден конструктор, пригодный для внедрения через конструктор с максимальным количеством параметров. При его выборе предпочтения будут отданы конструкторам без атрибута [Obsolete]. Иногда нужно переопределить то, как будет создаваться объект или, предположим, вызвать дополнительно еще какой-то метод инициализации. Для этого можно использовать другую перегрузку метода To<>(factory). Например, чтобы создать картонную коробку самостоятельно, привязка

.Bind<IBox>().To<CardboardBox>()

может быть заменена на

.Bind<IBox>().To(ctx => new CardboardBox(ctx.Resolve()))

To<>(factory) принимает аргументом lambda функцию, которая создает объект. А единственный аргумент lambda функции, здесь это - ctx, помогает внедрить зависимости самостоятельно. Генератор в последствии заменит вызов ctx.Resolve() на создание объекта типа TT на месте для лучшей производительности. Помимо метода Resolve() возможно использовать другой метод с таким же названием, но с одним дополнительным параметром - тегом типа object.

Время открывать коробки!

class Program{  // Создаем граф объектов в точке входа  public static void Main() => Glue.Resolve<Program>().Run();  private readonly IBox<ICat> _box;  internal Program(IBox<ICat> box) => _box = box;  private void Run() => Console.WriteLine(_box);}

В точке входа void Main() вызывается метод Glue.Resolve<Program>() для создания всей композиции объектов. Этот пример соответствует шаблону Composition Root, когда есть единственное такое место, очень близкое к точке входа в приложение, где выполняется композиция объектов, граф объектов четко определен, а типы предметной области не связаны с инфраструктурой. В идеальном случае метод Resolve<>() должен использоваться один раз в приложении для создания сразу всей композиции объектов:

static class ProgramSingleton{  static readonly Program Shared =     new Program(      new CardboardBox<ICat>(        new ShroedingersCat(          new Lazy<State>(            new Func<State>(              (State)Indeterminacy.Next(2))))));}

Вследствии того, что привязка для Program определена со временем жизни Singleton то метод Resolve<>() для типа Program каждый раз будет возвращать единственный объект этого типа. О многопоточности и ленивой инициализации не стоит беспокоится, так как этот объект будет создан гарантированно один раз и только при первой загрузке типа в момент первого обращения к статическому полю Shared класса статического приватного класса ProgramSingleton, вложенного в класс Glue.

Есть еще несколько интересных вопросов, которые хотелось бы обсудить. Обратите внимание, что конструктор кота Шрёдингера

ShroedingersCat(Lazy<State> superposition)

требует внедрения зависимости типа Lazy<> из стандартной библиотеки классов .NET. Как же это работает, когда в примере не определена привязка для Lazy<>? Дело в том, что пакет Pure.DI из коробки содержит набор привязок BCL типов. Конечно же, любую привязку можно переопределить при необходимости. Метод DependsOn(), с именем набора в качестве аргумента, позволяет использовать свои наборы привязок.

Другой важный вопрос, какую зависимость нужно внедрить чтобы иметь возможность создавать множество объектов определенного типа и делать это в некоторый момент времени? Все просто - Func<>, как и другие BCL типы поддерживается из коробки. Например, если вместо ICat, тип-потребитель запросит зависимость Func<ICat>, то станет возможным получить столько котов сколько и когда нужно.

Еще одна задача. Есть несколько реализаций с разными тегами, требуется получить все их. Чтобы получить всех котов, требуемую для внедрения зависимость можно определить как IEnumerable<ICat>, ICat[] или другой интерфейс коллекций из библиотеки классов .NET, например IReadOnlyCollection<T>. Конечно же, для IEnumerable<ICat> коты будут создаваться лениво.

Для простого примера, как кот Шрёдингера, такого API будет вполне достаточно. Для других случаев помогут привязки To<>(factory) c lambda функцией в аргументе, где объект можно создать вручную, хотя они не всегда удобны.

Рассмотрим ситуацию, когда есть множество реализаций для внедряемой зависимости, тип-потребитель имеет предпочтения по реализациям и мы хотим использовать автоматические привязки. В API есть все необходимое. Каждая реализация отмечается уникальным тегом в привязке, а внедряемая зависимость, например в параметре конструктора, отмечена атрибутом TagAttribute:

  • привязка: .Bind<ICat>().Tag(Fat).Tag(Fluffy).To<FatCat>()

  • и конструктор потребителя: BigBox([Tag(Fat)] T content) { }

Помимо TagAttribute есть другие предопределенные атрибуты:

  • TypeAttribute - когда нужно обозначить тип внедряемой зависимости вручную, не полагаясь, на тип параметра конструктора, метода, свойства или поля

  • OrderAttribute - для методов, свойств и полей указывается порядок вызова/инициализации

  • OrderAttribute - для конструкторов указывается на сколько один предпочтительнее других

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

  • TypeAttribute<>()

  • TagAttribute<>()

  • OrderAttribute<>()

В качестве параметра типа они принимают тип атрибута, а в качестве единственного необязательного параметра - позицию аргумента в конструкторе атрибута откуда брать значение: типа, тега или порядка, соответственно. По умолчанию позиция аргумента равна 0, следовательно, значения будут взяты из первого аргумента конструктора. Позицию следует переопределять, например, когда вы добавили свой атрибут, такой как InjectAttribute, и его конструктор содержит и тег, и тип одновременно.

Теперь немного о генераторе и генерируемом коде. Генератор кода, используя синтаксические деревья и семантические модели Roslyn API, отслеживает изменения исходного кода в IDE на лету, строит граф зависимостей и генерирует эффективный код для композиции объектов. При анализе выявляются ошибки или недочеты и компилятор получает соответствующие уведомления. Например, при обнаружении циклической зависимости появится ошибка в IDE или в терминале командной строки при сборке проекта, которая не позволит компилировать код, пока она не будет устранена. Будет указано предположительное место проблемы. В другой ситуации, например, когда генератор не сможет найти требуемую зависимость, он выдаст ошибку компиляции с описанием проблемы. В этом случае необходимо либо добавить привязку для требуемой зависимости, либо переопределить fallback стратегию: привязать интерфейс IFallback к своей реализации. Её метод Resolve<>() вызывается каждый раз когда не удается найти зависимость и: возвращает созданный объект для внедрения, бросает исключение или возвращает значение null для того чтобы оставить поведение по умолчанию. Когда fallback стратегия будет привязана генератор изменит ошибку на предупреждение, полагая что ситуация под вашим контролем, и код станет компилируемым.

Надеюсь, это библиотека будет полезной. Любые ваши замечания и идеи очень приветствуются.

Подробнее..
Категории: C , Net , Csharp , Di , Inversion

Pure.DI следующий шаг

25.04.2021 16:15:44 | Автор: admin

Недавно в этом посте вы познакомились с библиотекой Pure.DI. Этот пакет с анализатором/генератором кода .NET 5 задумывался как помощник, который пишет простой код для композиции объектов в стиле чистого DI, используя подсказки для построения графа зависимостей. Он следит за изменениями, анализирует типы и зависимости между ними, подсвечивает проблемы и предлагает пути решения. Важно отметить, что библиотека Pure.DI - это не контейнер внедрения зависимостей, в её задачи входит:

  • анализ графа зависимостей

  • определение в нем проблем и путей их решения

  • создание эффективного кода для композиции объектов

По обсуждениям в предыдущем посте у меня сложилось впечатление, что необходимо решить следующие вопросы:

  • добавить возможность использовать Pure.DI в инфраструктуре ASP.NET

  • убрать бинарную зависимость на API из пакета Pure.DI.Contracts

  • увеличить производительность для случаев, когда операция Resolve() выполняется многократно

Сейчас, после небольших доработок анализатор кода автоматически определяет, является ли проект ASP.NET проектом, и генерирует код специального метода расширения, который обеспечивает интеграцию с ASP.NET. Для демонстрации возможности выбрано серверное Blazor приложение:

Описание графа зависимостей находится в этом классе:

DI.Setup()  .Bind<IDispatcher>().As(Singleton).To<Dispatcher>()  .Bind<IClockViewModel>().To<ClockViewModel>()  .Bind<ITimer>().As(Scoped).To(_ => new Timer(TimeSpan.FromSeconds(1)))  .Bind<IClock>().As(ContainerSingleton).To<SystemClock>();

Для того чтобы связать эти DI типы с инфраструктурой ASP.NET нужно добавить всего лишь одну строку вызова метода расширения:

services.AddClockDomain();

Как видно из примера, его название зависит от имени класса-владельца, чтобы не путаться если их несколько. Помимо метода расширения, добавлены два времени жизни:

  • ContainerSingleton - чтобы использовать один объект типа на ASP.NET контейнер

  • Scoped - чтобы использовать по одному объекту типа на каждый ASP.NET scope

Сейчас их нельзя использовать вне контекста ASP.NET, иначе появится ошибка компиляции с информацией об этом.

Для решения вопроса о нежелательной бинарной зависимости на API я удалил пакет Pure.DI.Contracts. Теперь весь API для описания графа зависимостей генерируются на месте и является частью инфраструктурного кода проекта, где этот API и используется. Как итог, в проекты не добавляется ни одной бинарной зависимости, а единственная зависимость типа analyzers на пакет Pure.DI будет использована только во время компиляции и забыта сразу после. И, конечно, ее можно использовать без ограничения в любых проектах, не опасаясь зависеть от чего-то лишнего.

ASP.NET инфраструктура вызывает метод Resolve() для каждого запроса. Чтобы уменьшить накладные расходы на этот вызов, был оптимизирован код, ответственный за сопоставление типа корневого элемента композиции объектов к методу создания этой композиции. С результатами сравнительных тестов можно ознакомиться здесь. Хотелось бы подчеркнуть, что в этом сравнении используется спорный способ получения показателей производительности. Поэтому, эти результаты, дают приблизительную оценку накладных расходов на многократный вызов метода Resolve().

Как обычно, любые конструктивные замечания и идеи очень приветствуются.

Подробнее..
Категории: C , Net , Csharp , Dependency injection , Di

DI в iOS Complete guide

11.03.2021 20:12:02 | Автор: admin

Всем привет, меня зовут Виталий, я iOS-разработчик в юните мессенджера в Тинькофф. Сегодня поговорим о том, что такое DI, зачем он нужен, рассмотрим известные библиотеки для DI и сравним их между собой. Поехали!

Часть 1. Введение в DI

Dependency Injection, или внедрение зависимостей, это паттерн настройки объекта, при котором зависимости объекта задаются извне, а не создаются самим объектом. Другими словами, объекты настраиваются внешними объектами.

Существует несколько способов внедрения зависимостей.

  1. Interface injection (через интерфейс)

final class TestViewModel {    private lazy var userService = getUserService()    func getData() -> Data {        return userService.getData()    }}protocol TestViewModelResolving {    func getUserService() -> UserServiceProtocol}extension TestViewModel: TestViewModelResolving {    func getUserService() -> UserServiceProtocol { return UserService() }}

2. Property injection (через публичное свойство)

final class TestViewModel {    var userService: UserServiceProtocol?}final class UserService:  UserServiceProtocol {  }let viewModel = TestViewModel()viewModel.userService =  UserService(...)

3. Method injection (через публичный метод)

final class TestView: UIView {private var style: ViewStyleProtocol?func apply(style: ViewStyleProtocol) {self.style = style// applying style}}struct TestViewStyle: ViewStyleProtocol {  }let style = TestViewStyle(...)testView.apply(style: style)

4. Constructor injection (через инициализатор/конструктор)

final class TestViewModel {private let userService: UserServiceProtocolprivate let modelId: Stringinit(userService: UserServiceProtocol, modelId: String) {self.userService = userServiceself.modelId = modelId}...}final class UserService: UserServiceProtocol {  }let userService = UserService(...)let viewModel = TestViewModel(userService: userService, modelId: id)

Предпочтительным, как правило, является Constructor injection, так как он лишен недостатков трех предыдущих способов:

  1. Interface Injection требует создания дополнительного протокола для инъекции в каждый объект, что кажется избыточным.

  2. Property Injection позволяет создать объект и начать его использовать еще до того, как заинжектили все нужные зависимости. Это может приводить к ошибкам из-за неконсистентного состояния объекта. Кроме того, нам приходится делать внедряемые поля публичными, что дает возможность менять их извне в любой момент, нарушая инкапсуляцию.

  3. Method injection очень похож на Property Injection, но внедрение происходит через метод. Используется реже, чем Property Injection, но имеет те же недостатки.

Но даже Constructor injection можно использовать не всегда например, когда есть цикл зависимостей (пример будет чуть ниже). Еще бывает так, что использовать Constructor injection не очень удобно. Например, когда зависимости создаются уже после создания основного объекта. Конечно, можно использовать Constructor injection, если сделать зависимость опциональной и передавать в конструктор как nil по умолчанию. Но в таких кейсах обычно применяется Property injection.

Некоторые разработчики считают, что Constructor injection плох тем, что приводит к появлению boilerplate кода в разрастающемся от количества зависимостей инициализаторе, но, думаю, это не плохо. Такие иниты выступают отличным индикатором того, что вы помещаете в объект слишком много зависимостей и, возможно, стоит вспомнить про Single Responsibility Principle и подумать о лучшей декомпозиции объекта.

В чем преимущества от использования DI?

  1. DI делает зависимости объекта явными (четко прописанными в интерфейсе объекта) это позволяет лучше контролировать его сложность.

  2. DI делает зависимости объекта внешними (передаются в объект извне, а не создаются внутри) это позволяет отделять код создания объектов от бизнес-логики, улучшая разделение ответственности.

  3. DI дает возможность сделать зависимости гибкими. Mожно подменить объект другим например, если закрыть его протоколом, скрыв реализацию. Код классов теперь зависит только от интерфейсов, а не от конкретных классов, скрывая детали реализации и уменьшая связанность кода. Как следствие объекты становятся легко тестируемыми.

  4. DI уменьшает связанность(coupling) объектов.

  5. DI упрощает переиспользование объектов.

  6. DI улучшает декомпозицию за счет выноса порождающей логики наружу.

Какие минусы у этого подхода?

  1. Увеличивается количество сущностей в проекте. Добавляются дополнительные классы с порождающей логикой и протоколы, скрывающие детали реализации зависимостей.

  2. Возрастает время написания кода.

Часть 2. Service Locator

Когда говорят про DI в iOS-проектах, часто приводят в пример Service Locator (SL) паттерн, суть которого в наличии объекта-реестра, к которому обращаются объекты для получения зависимостей. Объект-реестр знает, как получить все зависимости, которые могут потребоваться.

В статьях и iOS-коммьюнити в целом есть некая неоднозначность трактовки понятий ServiceLocator, DI, DI-контейнер, IoC и IoC-контейнер. Не буду на этом подробно останавливаться, просто оставлю ссылку, где, как мне кажется, хорошо разобран этот вопрос. Отмечу только, что ключевым отличием паттернов SL и DI является то, что к локатору обращаются явно внутри класса и достают из него необходимые зависимости. При DI же, наоборот, зависимости внедряются извне.

Давайте рассмотрим простой пример сервис-локатора:

protocol ServiceLocating {func resolve<T>() -> T?}final class ServiceLocator: ServiceLocating {static let shared = ServiceLocator() // Singleton// MARK: Private propertiesprivate lazy var services = [String: Any]()// MARK: Init  private init() {}// MARK: Private methodsprivate func typeName(_ some: Any) -> String {return (some is Any.Type) ? "\(some)" : "\(type(of: some))"}// MARK: Internal methodsfunc register<T>(service: T) {let key = typeName(T.self)services[key] = service}func resolve<T>() -> T? {let key = typeName(T.self)return services[key] as? T}}

Как видим, реализация довольно проста. Внутри находится словарь, где ключом является строка, содержащая имя типа, а значением объект, который мы регистрируем в локаторе. Чтобы получить какую-то зависимость, нужно ее сначала зарегистрировать. Зависимости, как правило, регистрируются в одном месте на старте приложения. Данная реализация не является потокобезопасной, но для демонстрации сути паттерна этого вполне достаточно. Кроме того, мы реализовали SL как singleton, что, строго говоря, не обязательно.

Давайте рассмотрим пример использования SL. Допустим, раньше вы создавали зависимости прямо по месту использования:

func someMethod() {let service = TestService()// используем service}

С SL вы будете регистрировать зависимости где-то в одной точке при старте приложения и позже получать их через локатор:

ServiceLocator.shared.register(service: service)func someMethod() {guard let service: TestServiceProtocol = ServiceLocator.shared.resolve() else {         return     }// используем service}

В чем профит? Давайте разберемся.

Плюсы и минусы сервис-локатора

Плюсы:

  1. Можно получить любую необходимую зависимость, скрывая от пользователя детали создания объекта-зависимости.

  2. Избавляет от необходимости использовать сервисы-синглтоны.

  3. Удобно тестировать можно подменить зависимости при регистрации на моки.

Минусы:

  1. Часто является синглтоном. Синглтоны сами по себе имеют много недостатков и часто позиционируются как антипаттерны.
    Проблемы синглтонов:
    - глобальное состояние и его неявное изменение;
    - неявная зависимость от синглтона, что приводит к неявному нарушению SRP;
    - жесткая зависимость от синглтона, что мешает тестированию.

  2. Является god-объектом, который знает слишком много и имеет доступ к любому объекту. А значит, все, кто имеет к нему доступ, получают те же возможности. В сочетании со свойством синглтона проблема усугубляется.

  3. Способствует созданию внутренних неявных зависимостей, что приводит к неявной связанности (coupling), которая приводит к неявной сложности.

  4. Ошибки в рантайме: если вы резолвите зависимость, которую не регистрировали, то узнаете об ошибке только в рантайме.

Как было сказано выше, SL не обязательно должен быть синглтоном. Мы можем, например, инжектить его в объекты через Constructor Injection:

final class SomeClass {private let userService: UserServiceProtocolinit(serviceLocator: ServiceLocating) {userService = serviceLocator.resolve()!}}

Но тогда зависимости нашего класса все еще остаются неявными. В этом примере можно заметить еще один недостаток SL необходимость использовать implicitly unwrapped optional либо при каждом резолве проверять опционал на nil. Дело в том, что мы никогда не знаем, регистрировали ли мы нужный нам сервис в локаторе или нет, пока не попробуем зарезолвить его. Мы не получим никаких ворнингов или ошибок при сборке, если какой-то сервис не был зарегистрирован, просто поймаем краш в рантайме.

Чтобы улучшить последний пример, можно вынести SL в фабрику:

final class UserProfileFactory {static func createUserProfile(serviceLocator: ServiceLocating) -> UIViewController {let viewController = UserProfileViewController()let userService: UserServiceProtocol = serviceLocator.resolve()!let viewModel = UserProfileViewModel(userService: userService)viewController.viewModel = viewModelreturn viewController}}

Так мы реализуем принцип наименьших привилегий и избавляем объекты от необходимости знать про SL (то есть знать слишком много), оставляя его только в области видимости фабрик. Но это не избавляет нас от необходимости передавать объект SL между фабриками. Кто-то должен будет хранить этот объект, ведь он будет нужен в каждой фабрике каждого модуля приложения. Неплохим вариантом такого хранителя является координатор. Использование DI(CI) + Factory + SL + Coordinator позволяет сгладить описанные выше недостатки сервис-локатора.

Но тогда возникает резонный вопрос: а нужен ли здесь тогда вообще сервис-локатор? Почему бы не отказаться от него совсем и создавать зависимости внутри фабрик в конце концов, фабрики и задуманы для инкапсуляции порождающей логики. Оставляю этот вопрос вам на подумать.

Часть 3. DI-библиотеки

Итак, время познакомиться с популярными библиотеками для DI. Начнем с классификации. DI-библиотеки, которые мы рассмотрим, будут основаны либо на рефлексии, либо на кодогенерации.

Начнем с библиотек, основанных на рефлексии. Под рефлексией подразумевается отображение множества ключей на множество объектов. Например, в нашем сервис-локаторе мы использовали словарь (Dictionary), который, по сути, отображает ключ (имя типа объекта) на сам сервис-объект.

Библиотеки, основанные на рефлексии

Swinject

https://github.com/Swinject/Swinject

Более 4.5k звезд на Гитхабе. Наверное, самая популярная DI-библиотека для iOS. Довольно проста в использовании:

protocol Animal {var name: String { get }}final class Cat: Animal {let name: Stringinit(name: String) {self.name = name}}...let container = Container()container.register(Animal.self) { _ in Cat(name: "Mimi") }...let animal = container.resolve(Animal.self)!print(animal.name) //prints mimi

Но погодите! register...resolve вам это ничего не напоминает? Это же очень похоже на уже хорошо знакомый нам ServiceLocator. Если мы заглянем внутрь, то увидим аналогичный словарь, но ключом уже является структура ServiceKey, а значением некий объект за протоколом ServiceEntryProtocol.

public final class Container {internal var services = [ServiceKey: ServiceEntryProtocol]()...}internal struct ServiceKey {internal let serviceType: Any.Typeinternal let argumentsType: Any.Typeinternal let name: String?internal let option: ServiceKeyOption? // Used for SwinjectStoryboard or other extensions....}

В нашем примере простого SL мы использовали в качестве ключа строку с именем типа. Здесь все немного сложнее. Тоже используется тип сервиса (serviceType), но также учитываются типы аргументов, передаваемые при резолве. Имя (name) нужно, чтобы добавлять зависимости одного типа под разными именами, опции (option) для использования контейнера со сторибордами. По сути, все эти параметры нужны, чтобы получать уникальное значение хэша для разных зависимостей, которые мы регистрируем.

internal protocol ServiceEntryProtocol: AnyObject {func describeWithKey(_ serviceKey: ServiceKey) -> Stringvar objectScope: ObjectScopeProtocol { get }var storage: InstanceStorage { get }var factory: FunctionType { get }var initCompleted: (FunctionType)? { get }var serviceType: Any.Type { get }}

ServiceEntryProtocol - это уже не объект-сервис, как в нашем простеньком сервис-локаторе. За этим протоколом стоит класс ServiceEntry, который знает, как создавать объект, где и как его хранить (зависит от скоупа), а также содержит необходимые данные для разруливания циклических зависимостей (через initCompleted).

Что такое циклические зависимости? Чтобы разобраться, сначала нужно понять, как контейнер создает зависимости. Когда вы запрашиваете у контейнера зависимость некоторого типа, она тоже может иметь зависимости, которые тоже могут иметь зависимости и так далее. При создании зависимости контейнер строит ориентированный граф зависимостей, основываясь не только на связях между ними, но и на типе их скоупа.

Скоуп (scope) это тип, описывающий время жизни регистрируемой зависимости.

Swinject предоставляет четыре типа скоупа (но можно добавить и свой собственный скоуп): Graph, Transient, Container, Weak.

Graph

Дефолтный скоуп в Swinject. Зависимость разруливается в рамках построенного графа зависимостей. Например:

final class A {private let b: Binit(b: B) {    self.b = b}}final class B {private let c: Cinit(c: C) {    self.c = c}}final class C {private let a: Ainit(a: A) {    self.a = a}}let container = Container()container.register(A.self) { r inreturn A(b: r.resolve(B.self)!)}container.register(B.self) { r inreturn B(c: r.resolve(C.self)!)}container.register(C.self){ r inreturn C(a: r.resolve(A.self)!)}...let a1 = container.resolve(A.self)!let a2 = container.resolve(A.self)!

Для каждого резолва a1 и a2 строятся отдельные графы:

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

Transient

Он же prototype или unique в других библиотеках. Зависимость создается заново при каждом резолве.

Зарегистрируем A со скоупом transient:

container.register(A.self) { r inreturn A(b: r.resolve(B.self)!)}.inObjectScope(.transient)

Получим следующий граф при резолве:

Container

Он же singleton в других библиотеках. Думаю, суть понятна: один экземпляр на контейнер и дочерние контейнеры. Создается при первом резолве.

Зарегистрируем B со скоупом container:

container.register(B.self) { r inreturn B(c: r.resolve(C.self)!)}.inObjectScope(.container)

Получим следующий граф при резолве:

Weak

Он же WeakSingleton. Зависимость будет существовать, пока на нее есть сильная ссылка снаружи. С графом, думаю, понятно аналогично container.

Обработка циклических зависимостей

Разруливание циклических зависимостей мастхэв-фича для DI-библиотеки. В Swinject она реализуется через отложенную property injection внутри метода initCompleted для одной или нескольких зависимости в цикле.

То есть не получится использовать только constructor injection, чтобы разрулить цикл.

Например, для цикла, который мы рассматривали выше, можно переделать класс С, чтобы он поддерживал property injection, и добавить его реализацию в initCompleted:

final class C {var a: A?}container.register(C.self) { _ in return C() }.initCompleted { r, c inc.a = r.resolve(A.self)}

При резолве циклических зависимостей в Swinject последние могут создаваться несколько раз, что аффектит время резолва, и на больших циклах могут прилично аффектить время запуска приложения. Кроме того, нельзя точно сказать, какой именно из создавшихся объектов будет использован. В таких случаях лучше вынести все резолвы в цикле в initCompleted, что поможет избежать этой проблемы (подробнее здесь).

Swinject Autoregistration

Swinject также предоставляет упрощенный синтаксис для регистрации Autoregistration. То есть вместо того, чтобы писать так:

container.register(MyServiceProtocol.self) { r in    MyService(dependencyA: r.resolve(DependencyA.self)!,          dependencyB: r.resolve(DependencyB.self)!,          dependencyC: r.resolve(DependencyC.self)!,          dependencyD: r.resolve(DependencyD.self)!)}

Можно писать намного короче:

container.autoregister(MyServiceProtocol.self, initializer: MyService.init)

Удобная фича, но за удобство приходится платить скоростью. Использование авторегистрации замедляет время резолва, а значит, и время запуска приложения. На больших проектах при очень большом количестве зависимостей это будет критично. Насколько именно критично узнаем чуть позже, а пока предложу альтернативу, которая чуть короче обычного синтаксиса, но быстрее авторегистрации:

prefix operator <?prefix func <? <Service>(_ resolver: Resolver) -> Service? {return resolver.resolve(Service.self)}prefix operator <~prefix func <~ <Service>(_ resolver: Resolver) -> Service {return (<?resolver)!}

И это все! Теперь можно писать чуть короче:

container.register(MyServiceProtocol.self) { r in    MyService(dependencyA: <~r,          dependencyB: <~r,          dependencyC: <~r,          dependencyD: <~r)}

Плюс ко всем вышеописанным плюшкам Swinject также дает возможность:

  1. Строить иерархии контейнеров.

  2. Разбивать регистрацию на модули (Assembly).

  3. Работать со сторибордами.

  4. Обеспечить потокобезопасность. Container не потокобезопасен по умолчанию, но метод synchronize дает нам потокобезопасный резолвер:

let threadSafeContainer: Resolver = container.synchronize()

Минусы Swinject

Главный недостаток Swinject в том, что его очень легко можно использовать как сервис-локатор. В таком случае он наследует все проблемы, описанные в разделе о SL.

Swinject тоже имеет runtime-природу со всеми вытекающими. Если вы резолвите зависимость, которую не регистрировали, то узнаете об этом только при краше в рантайме. Вы можете сказать, что с вами этого не случится, так как вы никогда не забываете регистрировать зависимости. Но реально в больших проектах такое вполне может происходить. Например, может потребоваться добавить аргумент при резолве уже существующей зависимости, которая используется в разных модулях. С этого момента при резолве без аргумента вы получите краш в рантайме. Забыли добавить аргумент при резолве в одном из модулей? Что вам об этом скажет Swinject? К сожалению, ничего.

DIP

https://github.com/AliSoftware/Dip

888 звезд на Гитхабе. Тоже довольно популярная библиотека, очень похожая на Swinject в плане синтаксиса:

let container = DependencyContainer()container.register { ServiceImp() as Service }...let service = try! container.resolve() as Service

Обладает практически таким же набором фич, как Swinject, с несколькими отличиями:

  1. Нет поддержки иерархии контейнеров и разбиения регистрации на модули.

  2. Потокобезопасность по умолчанию (можно отключить через аргумент в DependencyContainer.init).

  3. Есть фича валидации. Можно провалидировать контейнер, что все зависимости, которые были зарегистрированы, будут зарезолвлены без ошибок.

  4. Кроме уже описанных скоупов, которые есть у Swinject, добавляет также EagerSingleton нетерпеливый синглтон. Суть в том, что этот синглтон создается при вызове container.bootstrap(), а не при первом резолве синглтона. Метод bootstrap фиксирует зависимости в контейнере, не давая добавить новые зависимости или удалить старые после его вызова.

Внутри устроен похожим на Swinject образом:

public final class DependencyContainer {...var definitions = [DefinitionKey: _Definition]()...}

Ключ также хранит информацию о типе и аргументах. Тэг аналог поля name в Swinject используется для регистрации одного типа под разными тэгами.

public struct DefinitionKey: Hashable, CustomStringConvertible {public let type: Any.Typepublic let typeOfArguments: Any.Typepublic private(set) var tag: DependencyContainer.Tag?...}

Definition обертка для зависимости, которая знает ее тип, скоуп и как создавать зависимость.

Как и Swinject, DIP может быть легко использован как сервис-локатор со всеми вытекающими. Некоторые разработчики предпочитают использовать DIP на старте проекта или на небольших проектах, объясняя это тем, что он проще, чем Swinject (спорное утверждение). Но когда небольшой проект разрастается, они с ужасом замечают, что время запуска приложения стало чертовски долгим. Дело в том, что DIP значительно медленнее, чем Swinject. Хотите узнать насколько? Не переключайтесь.

DITranquillity

https://github.com/ivlevAstef/DITranquillity

316 звезд на Гитхабе. Синтаксис немного отличается от Swinject и DIP. Во-первых, авторегистрация из коробки, что удобно, но за это приходится платить увеличением времени резолва. Во-вторых, resolve без необходимости использовать ! и try! тоже хорошо.

let container = DIContainer()container.register(MyService.init).as(MyServiceProtocol.self)...let router: MyServiceProtocol = container.resolve()

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

Несколько новых скоупов: perRun (один на каждый запуск приложения), perContainer (один на каждый контейнер) и single (типа синглтон, но создается сразу после вызова .initializeSingletonObjects() у контейнера).

Доступна валидация графа, то есть библиотека может проверить граф зависимостей еще до извлечения зависимостей. Когда регистрируем все зависимости, получаем граф, вызываем функцию проверки checkIsValid, и она проверяет, что все зарегистрировано верно, иначе бросает ошибку со ссылкой на файл и строку, где ошибка возникает, также очень удобно.

#if DEBUGif !container.makeGraph().checkIsValid(checkGraphCycles: true) {fatalError("invalid graph")}#endif

Под капотом в целом суть та же, хотя на первый взгляд кажется, что все сложнее, чем в Swinject и DIP.

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

public protocol DIStorage {/// Return all storaged object if there is.var any: [DIComponentInfo: Any] { get }...}/// Short information about component. Needed for good logpublic struct DIComponentInfo: Hashable, CustomStringConvertible {/// Any type announced at registration the componentpublic let type: DIAType/// File where the component is registrationpublic let file: String/// Line where the component is registrationpublic let line: Int  ...}

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

Автор библиотеки даже сравнил скорость своей библиотеки со Swinject на большом количестве регистраций(>1000) и простых графах зависимостей. По результатам этих тестов, DITranquility быстрее, чем Swinject (можно глянуть здесь).

EasyDI

https://github.com/TinkoffCreditSystems/EasyDi

88 звезд на Гитхабе. Отличается от предыдущих в плане синтаксиса. В отличие от предыдущих библиотек, уже не располагает к использованию себя в качестве сервис-локатора:

final class ServiceAssembly: Assembly {var apiClient: APIClient {return define(init: APIClient())}}

То есть мы не регистрируем и не резолвим зависимости явно, а создаем специальный объект Assembly, который выступает провайдером зависимостей и сам строит граф зависимостей под капотом. Все Assembly существуют в рамках некоторого контекста DIContext.

public final class DIContext {public static var defaultInstance = DIContext()fileprivate var assemblies: [String: Assembly] = [:]var objectGraphStorage: [String: InjectableObject] = [:]...}

Как видим, дефолтный DIContext статический и создается по умолчанию. Он контролирует граф зависимостей всех подконтрольных Assembly, то есть граф общий для всех ассембли внутри одного контекста. Сам граф зависимостей реализован с помощью словаря. Ключом в данном случае выступает имя типа Assembly + имя переменной нашей зависимости. А объект это просто Any.

public typealias InjectableObject = Any

Предоставляет четыре уже знакомые нам скоупа: prototype, objectGraph, lazySingleton и совсем недавно добавленный weak singleton. Кроме того, можно строить иерархии Assembly. Есть фича подмены объектов для тестирования.

Библиотека довольно проста в использовании и реализации и занимает всего чуть более 200 строк кода.

Кроме того, получаем потокобезопасность из коробки. Плюс нет явной фазы регистрации зависимостей, а значит, нельзя забыть зарегистрировать то, что нам понадобится.

Плюсы и минусы библиотек на рефлексии

Плюсы:

  1. Просты в использовании, невысокий порог входа.

  2. В реализации относительно несложно разобраться.

Минусы:

  1. Довольно легко использовать как сервис-локаторы и получить связанные с этим проблемы.

  2. Вся магия происходит в рантайме значит, и ошибки получаем в рантайме и не у всех библиотек есть возможность валидации графа.

  3. Аффектят время запуска приложения (опять-таки из-за своей рантайм-природы).

Несмотря на достаточное количество минусов, остаются самыми популярными решениями для DI в iOS.

Библиотеки, основанные на кодогенерации

Сначала давайте разберемся, как работают подобные библиотеки. Если вкратце, вы помечаете проперти своего класса неким атрибутом или наследуете свои компоненты от определенного протокола, а библиотека (или в данном случае уже скорее фреймворк), генерирует код, который эти проперти/зависимости внедряет. И это все. Вам больше ничего делать не нужно только использовать свои зависимости.

Если пока не стало понятно ничего, разберемся на примерах.

Needle

https://github.com/uber/needle

822 звезды на Гитхабе. Набирающая популярность библиотека от разработчиков Uber. Работает на базе SourceKit.

Главным преимуществом, по словам разработчиков, является обеспечение compile time safety кода работы для внедрения зависимостей. Контейнеры зависимостей должны наследоваться от класса Component. Зависимости DI-контейнера описываются в протоколе, который наследуется от базового протокола зависимостей Dependency и указывается в качестве generic типа самого контейнера.

protocol MyDependency: Dependency {var chocolate: Food { get }var milk: Food { get }}final class MyComponent: Component<MyDependency> {var hotChocolate: Drink {return HotChocolate(dependency.chocolate, dependency.milk)}var myChildComponent: MyChildComponent {return MyChildComponent(parent: self)}}

В примере также видим, как дочерние компоненты создаются в родительских. Очень напоминает Assembly в EasyDI, правда? Хотя под капотом они работают абсолютно по-разному. Каждый раз при сборке проекта генератор кода Needle анализирует исходный код проекта и ищет всех наследников класса Component - базового классов для DI-контейнеров.

У каждого DI-контейнера есть протокол описания зависимостей. Для каждого такого протокола генератор анализирует доступность каждой зависимости путем поиска ее среди родителей контейнера. Поиск идет снизу вверх от дочернего компонента к родительскому. Зависимость считается найденной, только если совпадают имя и тип зависимости.

Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это дает заявленную выше compile-time safety.

Плюсы и минусы Needle

Плюсы:

  1. Compile-time safety все проверки и ошибки во время компиляции.

  2. Время запуска приложения не аффектится с ростом числа зависимостей.

  3. Потокобезопасность из коробки.

  4. Поддержка иерархии компонентов.

Минусы:

  1. Пока что всего два варианта скоупа: shared (по сути container) и prototype, но в теории можно ими обойтись.

  2. Порог входа выше, чем у сервис-локаторов, хоть и не намного.

Мне самому Needle зашел. Потенциально хорошая альтернатива Swinject. Несмотря на номер последней версии v0.17.1, разработчики говорят, что библиотека production ready и уже используется во всех приложениях Uber.

Более подробно о библиотеке можно почитать здесь.

Weaver

https://github.com/scribd/Weaver

543 звезды на Гитхабе. Также основана на кодогенерации, использует SourceKitten, основанный на SourceKit. Но работает несколько иначе, чем Needle, а именно через аннотации к зависимостям. Тот, кто немного знаком с миром Android и Dagger, сразу поймет, о чем речь. Аннотации в нашем случае комментарии определенного формата или специальные property wrapperы.

Например:

final class MyClass {    // weaver: movieManager = MovieManager <- MovieManaging   // weaver: movieManager.scope = .container    ...}

или

final class MyClass {@Weaver(.registration, type: MovieManager.self, scope: .container)private var movieManager: MovieManaging...}

Первый вариант мне лично не нравится из-за хрупкости комментариев, но этот вариант был единственным до появления проперти врапперов в Swift 5.1. Второй вариант был вполне ожидаемым после их появления.

Weaver сканирует код проекта в поисках аннотаций, строит граф зависимостей и генерирует все, что нужно для DI, а именно контейнер MainDependencyContainer, у которого можно получить DependencyResolver для каждой зависимости.

Аннотации в Weaver бывают трех видов register, reference и parameter:

  1. Register

// weaver: dependencyName = DependencyConcreteType <- DependencyProtocol@Weaver(.registration, type: DependencyConcreteType.self)var dependencyName: DependencyProtocol

Добавляет зависимость в контейнер и предоставляет доступ к зависимости.

2. Reference

// weaver: dependencyName <- DependencyType@Weaver(.reference)var dependencyName: DependencyType 

Только предоставляет доступ (ссылку) к зависимости.

3. Parameter

// weaver: parameterName <= ParameterType@Weaver(.parameter)var parameterName: ParameterType

Параметр, который будет передаваться в объект при инициализации.

Все объекты, где мы добавляем аннотации, должны предоставлять стандартный init c аргументом резолвером соответствующего генерируемого типа, что выглядит как костыль:

required init(injecting _: MyDependencyResolver) {// no-op}

Библиотека предоставляет уже знакомые нам четыре скоупа: transient, container, weak, lazy.

Плюсы и минусы Weaver

Плюсы:

  1. Относительно проста в использовании.

  2. Compile time safety.

  3. Умеет генерировать стабы для тестов.

Минусы:

  1. Фактически библиотека принуждает нас использовать PropertyInjection.

  2. Костыльный конструктор у всех зависимостей.

  3. Порог вхождения выше, чем у needle. Неинтуитивные для понимания register/reference/parameter.

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

Плюсы и минусы библиотек на кодогенерации

Плюсы:

  1. Compile time safety, избавление от крашей в рантайме.

  2. Не аффектят скорость запуска приложения.

Минусы:

  1. Немного сложнее для понимания, чем register/resolve-контейнеры.

  2. Объекты зависят от нюансов библиотеки (наследуют специальные протоколы или используют специальные атрибуты у полей).

Часть 4. Битва контейнеров

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

Пусть каждая регистрируемая зависимость имеет от 0 до 3 зависимостей, часть которых также зависит друг от друга. Замеры будем делать на iPad 2017 (по железу аналог iPhone 6s) iOS 13,5 (время в секундах).

Итак, что мы имеем?

  1. DIP аутсайдер в этой битве.

  2. Swinject самый быстрый на 100 регистрациях, но прилично деградирует на росте регистраций до 1000.

  3. SwinjectAutoregistration делает свинжект еще немного медленнее.

  4. EasyDI и DITranquillity хорошо оптимизированы, и рост регистраций слабо аффектит время их работы.

Казалось бы, вот оно! Мы нашли фаворитов и можно идти внедрять их в свои проекты! Расходимся! Но нет, не все так просто. Мы ведь рассматривали простой граф зависимостей. У каждой регистрируемой зависимости было от 0 до 3 зависимостей. Это довольно мало, и в крупных и сложных проектах ситуация явно хуже.

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

Как видим, ситуация в корне меняется. Swinject все еще деградирует на росте регистраций, но его время заметно меньше, чем у других библиотек. Видимо, работа со сложным графом в Swinject оптимизирована лучше.

Отмечу, что время отдельно взятой библиотеки само по себе малоинформативно, важны лишь их значения относительно друг друга, потому что:

  1. При тестах считалось среднее время по замерам 100 тестов подряд. И первая итерация обычно выполнялась медленнее, чем последующие.

  2. Объекты, регистрируемые в тестах, по сути пустышки, не содержащие бизнес-логики, а значит, время их создания меньше, чем у объектов в реальных проектах.

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

Но и с библиотеками на кодогенерации тоже не все так просто :)

Давайте проверим, как сказывается рост числа зависимостей на времени компиляции. Рассмотрим уже упомянутые Needle и Weaver.

Даже несмотря на то, что обе библиотеки работают на базе SourceKit, время компиляции отличается на порядок при большом количестве зависимостей. На этом примере можно понять, как важна оптимизация в таких библиотеках.

Заключение

Вот какие выводы можно сделать из вышесказанного:

  1. Многие из популярных в iOS DI-библиотек позволяют использовать их как сервис-локаторы, то есть в некотором смысле они ближе к сервис-локаторам, чем к DI-контейнерам.

  2. Работа многих DI-библиотек основана на общих принципах и инструментах, но общие принципы можно реализовать по-разному. Например, время работы контейнеров, основанных на рефлексии, очень разнится и зависит не только от количества зависимостей, но и от сложности самого графа зависимостей (ну и от реализации самого контейнера, конечно). Или, например, время компиляции для библиотек, основанных на кодогенерации, тоже очень разнится.

  3. Оптимизация работы DI-библиотек очень важна, так как аффектит запуск приложения или его время сборки.

Какую библиотеку выбрать для своего проекта?

Зависит от ситуации. На небольших проектах Swinject может быть хорошим вариантом. Если вы уже используете Swinject (или любую другую register/resolve библиотеку) и вас критически не устраивает время запуска приложения, присмотритесь к Needle.

А еще всегда можно сделать свой DI c блэкджеком и оптимизацией. Можно это сделать довольно просто, например через фабрики, но это уже совсем другая история...

Ссылки на тестовые проекты

  1. https://github.com/vitalybatrakov/iOS_DI_Libs_Performance_Tests

  2. https://github.com/vitalybatrakov/iOS_DI_Libs_Compilation_Time_Tests

Что почитать/посмотреть

  1. https://martinfowler.com/articles/injection.html

  2. https://www.youtube.com/watch?v=-JGGw4SN6NA&ab_channel=AvitoTech

  3. https://jonfir.github.io/posts/ioc-ios/

  4. http://sergeyteplyakov.blogspot.com/2013/03/di-service-locator.html

  5. http://sergeyteplyakov.blogspot.com/2014/11/di-vs-dip-vs-ioc.html

  6. https://tech.badoo.com/ru/article/411/singlton-lokator-servisov-i-testy/

  7. http://personeltest.ru/aways/habr.com/ru/company/joom/blog/514784/

Подробнее..

Как я на собеседование готовился 2

15.12.2020 04:09:34 | Автор: admin
Во второй части, я хотел обновить понимание архитектур Onion и n-Tier, а так же DI-frameworks (Autofac и встроенный в net core). Но посмотрев на объем текста, я понял, что n-Tier будет описан очень кратко, за что сразу извиняюсь.
Так же, я постараюсь учесть замечания первой части,



Архитектура Onion


Преположим, что мы проектируем приложение для того, чтобы фиксировать, какие книги мы прочитали, а для точности, то хотим фиксировать даже то, сколько страниц было прочитано. Мы знаем, что это личная программа, которая нам нужна на нашем смартфоне, как бот для телеграмм и, возможно, для декстопа, так что смело выбираем такой вариант архитектуры:

(Tg Bot, Phone App, Desktop) => Asp.net Web Api => Database

Создаем проект в Visual studio типа Asp.net Core, где далее выбираем тип проекта Web Api.
Чем он отличается от обычного?
Во-первых, класс контроллера наследуется от класса ControllerBase, который разработан, как базовый для MVC без поддержки возврата представлений(html-кода).
Во-вторых, он предназначен для реализации REST сервисов с охватом всех видов HTTP запросов, а ответом на запросы Вы получаете json с явным указанием статуса ответа. Так же, Вы увидите, что контроллер, который создастся по-умолчанию, будет помечен атрибутом [ApiController], который имеет полезные опции именно для API.

Теперь нужно решить, как же хранить данные. Так как я знаю, что я читаю не более 12 книг в год, то мне будет достаточно csv-файла, который и будет представлять БД.

Поэтому я создаю класс, который описывает книгу:
Book.cs
using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;namespace WebApiTest{    public class Book    {        public int id { get; set; }        public string name { get; set; }        public string author { get; set; }        public int pages { get; set; }        public int readedPages { get; set; }    }}



А затем описываю класс работы с БД:
CsvDB.cs
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using System.Threading.Tasks;namespace WebApiTest{    public class CsvDB    {        const string dbPath = @"C:\\csv\books.csv";        private List<Book> books;        private void Init()        {            if (books != null)                return;            string[] lines = File.ReadAllLines(dbPath);            books = new List<Book>();            foreach(var line in lines)            {                string[] cells = line.Split(';');                Book newBook = new Book()                {                    id = int.Parse(cells[0]),                    name = cells[1],                    author = cells[2],                    pages = int.Parse(cells[3]),                    readedPages = int.Parse(cells[4])                };                books.Add(newBook);            }        }        public int Add(Book item)        {            Init();            int nextId = books.Max(x => x.id) + 1;            item.id = nextId;            books.Add(item);            return nextId;        }        public void Delete(int id)        {            Init();            Book selectedToDelete = books.Where(x => x.id == id).FirstOrDefault();            if(selectedToDelete != null)            {                books.Remove(selectedToDelete);            }        }        public Book Get(int id)        {            Init();            Book book = books.Where(x => x.id == id).FirstOrDefault();            return book;        }        public IEnumerable<Book> GetList()        {            Init();            return books;        }        public void Save()        {            StringBuilder sb = new StringBuilder();            foreach(var book in books)                sb.Append($"{book.id};{book.name};{book.author};{book.pages};{book.readedPages}");            File.WriteAllText(dbPath, sb.ToString());        }        public bool Update(Book item)        {            var selectedBook = books.Where(x => x.id == item.id).FirstOrDefault();            if(selectedBook != null)            {                selectedBook.name = item.name;                selectedBook.author = item.author;                selectedBook.pages = item.pages;                selectedBook.readedPages = item.readedPages;                return true;            }            return false;        }    }}


Дальше дело за малым, дописать API, чтобы была возможность взаимодействовать с ним:
BookController.cs
using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using Microsoft.AspNetCore.Mvc;using Microsoft.Extensions.Logging;namespace WebApiTest.Controllers{    [ApiController]    [Route("[controller]")]    public class BookController : ControllerBase    {        private CsvDB db;        public BookController()        {            db = new CsvDB();        }        [HttpGet]        public IEnumerable<Book> GetList() => db.GetList();        [HttpGet("{id}")]        public Book Get(int id) => db.Get(id);        [HttpDelete("{id}")]        public void Delete(int id) => db.Delete(id);        [HttpPut]        public bool Put(Book book) => db.Update(book);    }}


А дальше осталось лишь дописать UI, который был бы удобен. И все работает!
Круто же! Но нет, жена попросила, чтобы у неё тоже был доступ к такой удобной штуке.
Какие же нас ждут трудности? Во-первых, теперь нужно для всех книг добавить столбец, который обозначит айди пользователя. Поверьте, это не будет комфортно в случае с csv-файлом. Так же, теперь нужно и самих юзеров добавить! Да и теперь нужна какая-либо логика, чтобы жена не видела, что я дочитываю третий сборник Донцовой, вместо обещанного Толстого.

Давайте попробуем расширить этот проект к нужным требованиям:
Возможность создать аккаунт юзера, у который сможет хранить список своих книг и дополнять, сколько в какой он прочитал.
Честно, я хотел написать пример, но количество вещей, которые не хотелось бы делать, резко убили желание:
Создание контроллера, который отвечал бы за авторизацию и отдачу данных именно юзера;
Создание новой сущности Пользователь, а так же обработчика под неё;
Впихивание логики или в сам контроллер, из-за чего тот бы раздуло, или же в отдельный класс;
Переписывания логики работы с базой данных, ведь теперь или два csv-файла, или же переходить на БД

В итоге, мы получили большой монолит, который очень больно расширять. В нём большой набор тесных связей в приложении. Сильно связанный объект зависит от другого объекта; это означает, что изменение одного объекта в тесно связанном приложении часто требует изменения ряда других объектов. Это несложно, когда приложение небольшое, но в приложении корпоративного уровня слишком сложно внести изменения.

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

Историческая справка
Для решения проблем этих проблем была предложена луковичная архитектура.
Термин луковая архитектура был введен Джеффри Палермо в 2008 году. Эта архитектура обеспечивает лучший способ создания приложений, обеспечивающих лучшую тестируемость, ремонтопригодность и надежность таких инфраструктур, как базы данных и службы. Основная цель этой архитектуры решить проблемы, с которыми сталкивается трехуровневая архитектура или многоуровневая архитектура, и предоставить решение общих проблем, таких как объединение и разделение задач.
Вольный перевод отсюда


Поэтому мы попробуем реализовать наше приложение в Onion-стиле, чтобы показать преимущества данного способа.

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

Onion-архитектура во многом опирается на принцип инверсии зависимостей. Пользовательский интерфейс взаимодействует с бизнес-логикой через интерфейсы.

Принцип инверсии зависимостей
Принцип инверсии зависимостей (Dependency Inversion Principle) служит для создания слабосвязанных сущностей, которые легко тестировать, модифицировать и обновлять. Этот принцип можно сформулировать следующим образом:
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те и другие должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.



Классический проект в таком стиле состоит из четырех слоев:
  • Уровень объектов домена(Core)
  • Уровень репозитория(Repo)
  • Уровень обслуживания(Service)
  • Уровень внешнего интерфейса (веб / модульный тест)(Api)


Все слои направлены к центру(Core). Центр независим.

Уровень объектов домена


Это центральная часть приложения в которой описываются объекты, которые работают с базой данных.

Создадим новый проект в решении, который будет иметь выходной тип Библиотека классов. Я назвал его WebApiTest.Core

Создадим класс BaseEntity, который будет будет иметь общие свойства объектов.
BaseEntity.cs
    public class BaseEntity    {        public int id { get; set; }    }


Офф-топ
В данном случае, я буду использовать только свойство id, но вполне удобно будет сюда вынести всякие служебные вещи, вроде dateAdded, dateModifed и т.п.

Далее, создадим класс Book, который наследуется от BaseEntity
Book.cs
public class Book: BaseEntity
{
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}


Для нашего приложения этого будет пока-что достаточно, так что переходим к следующему уровню

Уровень репозитория


Теперь перейдем к реализации уровня репозитория. Создаем проект с типом Библиотека классов с названием WebApiTest.Repo
Мы будем использовать внедрение зависимостей, поэтому мы будем передавать параметры через конструктор, чтобы сделать их более гибкими. Таким образом, мы создаем общий интерфейс репозитория для операций с сущностями, чтобы мы могли разработать слабосвязанное приложение. Приведенный ниже фрагмент кода предназначен для интерфейса IRepository.
IRepository.cs
    public interface IRepository <T> where T : BaseEntity    {        IEnumerable<T> GetAll();        int Add(T item);        T Get(int id);        void Update(T item);        void Delete(T item);        void SaveChanges();    }




Теперь давайте реализуем класс репозитория для выполнения операций с базой данных над сущностью, которая реализует IRepository. Этот репозиторий содержит конструктор с параметром pathToBase, поэтому, когда мы создаем экземпляр репозитория, мы передаем путь к файлу, чтобы класс понимал, откуда забирать данные.
CsvRepository.cs
public class CsvRepository<T> : IRepository<T> where T : BaseEntity    {        private List<T> list;        private string dbPath;        private CsvConfiguration cfg = new CsvConfiguration(CultureInfo.InvariantCulture)        {            HasHeaderRecord = false,            Delimiter = ";"        };        public CsvRepository(string pathToBase)        {            dbPath = pathToBase;            using (var reader = new StreamReader(pathToBase)) {                using (var csv = new CsvReader(reader, cfg)) {                    list = csv.GetRecords<T>().ToList(); }            }        }        public int Add(T item)        {            if (item == null)                throw new Exception("Item is null");            var maxId = list.Max(x => x.id);            item.id = maxId + 1;            list.Add(item);            return item.id;        }        public void Delete(T item)        {            if (item == null)                throw new Exception("Item is null");            list.Remove(item);        }        public T Get(int id)        {            return list.SingleOrDefault(x => x.id == id);        }        public IEnumerable<T> GetAll()        {            return list;        }        public void SaveChanges()        {            using (TextWriter writer = new StreamWriter(dbPath, false, System.Text.Encoding.UTF8))            {                using (var csv = new CsvWriter(writer, cfg))                {                    csv.WriteRecords(list);                }            }        }        public void Update(T item)        {            if(item == null)                throw new Exception("Item is null");            var dbItem = list.SingleOrDefault(x => x.id == item.id);            if (dbItem == null)                throw new Exception("Cant find same item");            dbItem = item;        }



Мы разработали сущность и контекст, которые необходимы для работы с базы данных.

Уровень обслуживания


Теперь мы создаем третий уровень луковой архитектуры, который является уровнем обслуживания. Я назвал его WebApiText.Service. Этот уровень взаимодействует как с веб-приложениями, так и с проектами репозиториев.

Мы создаем интерфейс с именем IBookService. Этот интерфейс содержит сигнатуру всех методов, к которым обращается внешний уровень для объекта Book.
IBookService.cs
public interface IBookService    {        IEnumerable<Book> GetBooks();        Book GetBook(int id);        void DeleteBook(Book book);        void UpdateBook(Book book);        void DeleteBook(int id);        int AddBook(Book book);    }


Теперь реализуем его в классе BookService
BookService.cs
public class BookService : IBookService    {        private IRepository<Book> bookRepository;        public BookService(IRepository<Book> bookRepository)        {            this.bookRepository = bookRepository;        }        public int  AddBook(Book book)        {            return bookRepository.Add(book);        }        public void DeleteBook(Book book)        {            bookRepository.Delete(book);        }        public void DeleteBook(int id)        {            var book = bookRepository.Get(id);            bookRepository.Delete(book);        }        public Book GetBook(int id)        {            return bookRepository.Get(id);        }        public IEnumerable<Book> GetBooks()        {            return bookRepository.GetAll();        }        public void UpdateBook(Book book)        {            bookRepository.Update(book);        }    }



Уровень внешнего интерфейса



Теперь мы создаем последний слой луковой архитектуры, который, в нашем случае, внешним интерфейсом, с которым и буду взаимодействовать внешние приложения(бот, десктоп и т.п.). Чтобы создать этот уровень, мы вычищаем наш проект WebApiTest.Api, удаляя класс Book и вычищая BooksController. Этот проект дает возможность для операций с базой данных сущностей, а также контроллер для выполнения этих операций.

Поскольку концепция внедрения зависимостей является центральной для приложения ASP.NET Core, то теперь нам нужно зарегистрировать всё, что мы создали, для использования в приложении.

Внедрение зависимостей


В небольших приложениях на ASP.NET MVC мы относительно легко можем заменить одни классы на другие, вместо одного контекста данных использовать другой. Однако в крупных приложениях это уже будет проблематично сделать, особенно если у нас десятки контроллеров с сотней методов. В этой ситуации нам на помощь может прийти такой механизм как внедрение зависимостей.

И если раньше в ASP.NET 4 и других предыдущих версиях надо было использовать различные внешние IoC-контейнеры для установки зависимостей, такие как Ninject, Autofac, Unity, Windsor Castle, StructureMap, то ASP.NET Core уже имеет встроенный контейнер внедрения зависимостей, который представлен интерфейсом IServiceProvider. А сами зависимости еще называются сервисами, собственно поэтому контейнер можно назвать провайдером сервисов. Этот контейнер отвечает за сопоставление зависимостей с конкретными типами и за внедрение зависимостей в различные объекты.

В самом начале, мы использовали жесткую связь, чтобы использовать CsvDB в контроллере.
private CsvDB db;        public BookController()        {            db = new CsvDB();        }

На первый взгляд ничего плохого здесь нет, но, например, изменилась схема подключения к базе данных: вместо Csv я решил использовать MongoDB или MySql. Кроме того, может потребоваться динамически менять один класс на другой.

В данном случае жесткая связь привязывает контроллер к конкретной реализации репозитория. Такой код по мере расширения приложения сложнее поддерживать и сложнее тестировать. Поэтому рекомендуется уходить от использования жесткосвязанных компонентов к слабосвязанным.

Используя различные методы внедрения зависимостей, можно управлять жизненным циклом создаваемых сервисов. Сервисы, которые создаются механизмом Depedency Injection, могут представлять один из следующих типов:

  • Transient: при каждом обращении к сервису создается новый объект сервиса. В течение одного запроса может быть несколько обращений к сервису, соответственно при каждом обращении будет создаваться новый объект. Подобная модель жизненного цикла наиболее подходит для легковесных сервисов, которые не хранят данных о состоянии
  • Scoped: для каждого запроса создается свой объект сервиса. То есть если в течение одного запроса есть несколько обращений к одному сервису, то при всех этих обращениях будет использоваться один и тот же объект сервиса.
  • Singleton: объект сервиса создается при первом обращении к нему, все последующие запросы используют один и тот же ранее созданный объект сервиса

Для создания каждого типа сервиса во встроенном контейнере .net core предназначен соответствующий метод AddTransient(), AddScoped() и AddSingleton().

Мы могли бы использовать стандартный контейнер(провайдер сервисов), но он не поддерживает передачу параметров, поэтому мне придётся использовать библиотеку Autofac.

Для этого добавим в проект через NuGet два пакета: Autofac и Autofac.Extensions.DependencyInjection.
Теперь изменяем в файле Startup.cs метод ConfigureServices на:
ConfigureServices
 public IServiceProvider ConfigureServices(IServiceCollection services)        {            services.AddMvc();            var builder = new ContainerBuilder();//Создаем контейнер            builder.RegisterType<CsvRepository<Book>>()//Регистрируем CsvRepository                .As<IRepository<Book>>() //Как реализацию IRepository                .WithParameter("pathToBase", @"C:\csv\books.csv")//С параметром pathToBase                .InstancePerLifetimeScope(); //Scope            builder.RegisterType<BookService>()                .As<IBookService>()                .InstancePerDependency(); //Transient             builder.Populate(services); //             var container = builder.Build();            return new AutofacServiceProvider(container);        }



Таким образом, мы связали все реализации с их интерфейсами.

Вернемся к нашему проекту WebApiTest.Api.
Осталось только изменить BooksController.cs
BooksController.cs
[Route("[controller]")]    [ApiController]    public class BooksController : ControllerBase    {        private IBookService service;        public BooksController(IBookService service)        {            this.service = service;        }        [HttpGet]        public ActionResult<IEnumerable<Book>> Get()        {            return new JsonResult(service.GetBooks());        }        [HttpGet("{id}")]        public ActionResult<Book> Get(int id)        {            return new JsonResult(service.GetBook(id));        }        [HttpPost]        public void Post([FromBody] Book item)        {            service.AddBook(item);        }        [HttpPut("{id}")]        public void Put([FromBody] Book item)        {            service.UpdateBook(item);        }        [HttpDelete("{id}")]        public void Delete(int id)        {            service.DeleteBook(id);        }    }



Жмём F5, ждем открытия браузера, переходим на /books и
[{"name":"Test","author":"Test","pages":100,"readedPages":0,"id":1}]


Итог:


В данном тексте, я хотел обновить все свои знания по архитектурному паттерну Onion, а так же по внедрению зависимостей, обязательно с использованием Autofac.
Цель я считаю выполненной, спасибо, что прочитали ;)

n-Tier
В n-уровневой архитектуре приложение разделяется на логические слои и физические уровни.
Слои это способ распределения ответственности и управления зависимостями. Каждый слой несет определенную ответственность. В более высоком слое могут использоваться службы из более низкого слоя, но не наоборот.
Уровни разделяются физически путем запуска на разных компьютерах. С одного уровня можно отправлять вызовы непосредственно на другой уровень или использовать асинхронный обмен сообщениями (очередь сообщений). Каждый слой можно разместить на отдельном уровне, но это не обязательно. Вы можете разместить несколько слоев на одном уровне. Физическое разделение уровней улучшает масштабируемость и устойчивость, но также приводит к увеличению задержки из-за дополнительных операций сетевого взаимодействия.
Подробнее..
Категории: Net , Di , Onion

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru