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

Dependency injection

Что можно положить в механизм 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 удивительная тема: на первый взгляд, в нем не так много различных рычагов и инструментов для изучения, но можно писать и говорить часами о тех возможностях и способах применения, которые они нам дают.

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

Namespaces в JavaScript (часть II, заключительная)

17.11.2020 22:21:17 | Автор: admin

В своей прошлой статье я прикидывал, какие namespace'ы мне нужны для упорядочивания кода в ES6-модулях. В этой статье я описываю, какие namespace'ы у меня получились и как их использовать при порождении объектов и разрешении зависимостей (dependency injection).

Для чего нужны namespace'ы

Для тех, кто не видит важность namespace'ов, могу привести в пример почтовые адреса:

  • страна / область / город / улица / дом / квартира

или адресацию в файловой системе:

  • /var/lib/dpkg/status

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

Дуальный характер JavaScript'а

Отличие JavaScript'а от остальных языков программирования в том, что JS-программы исполняются в двух очень различных средах:

  • браузер: с ориентацией преимущественно на сетевые ресурсы;

  • сервер: с ориентацией преимущественно на файловые ресурсы;

Один и тот же ES6-модуль может быть адресован в абсолютной нотации таким образом (браузер и сервер):

http://demo.com/node_modules/@vendor/package/src/module.mjs/home/alex/demo/node_modules/@vendor/package/src/module.mjs

Текущая проблема адресации ES6-модулей

В nodejs-приложениях относительная адресация элементов кода идёт относительно каталога ./node_modules/:

import ModuleDefault from '@vendor/module/src/Module.mjs';

а в браузерных приложениях такая адресация приводит к ошибке:

Uncaught (in promise) TypeError: Failed to resolve module specifier "@vendor/module/src/Module.mjs". Relative references must start with either "/", "./", or "../".

Если же изменить адрес на ./@vendor/module/src/Module.mjs, как того требует браузерная среда, то в nodejs-приложениях получим ошибку:

internal/process/esm_loader.js:74    internalBinding('errors').triggerUncaughtException(                              ^Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/.../@vendor/module/src/Module.mjs' imported from /.../test.mjs    at finalizeResolution (internal/modules/esm/resolve.js:276:11)    at moduleResolve (internal/modules/esm/resolve.js:699:10)

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

Подробнее

Т.е., если у нас есть модуль ClassA.mjs в пакете packageA:

export default class ClassA {}

И есть модуль ClassB.mjs в пакете packageB, в котором мы импортируем содержимое из модуля packageA/ClassA.mjs:

import ClassA from 'packageA/ClassA.mjs'export default class ClassB {}

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

import ClassA from './packageA/ClassA.mjs'export default class ClassB {}

в nodejs-приложении.

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

import ClassA from '/packageA/ClassA.mjs'export default class ClassB {}

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

Адресация ES6-модуля на основе путей

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

@vendor/package1/src/module1.js

к виду:

@vendor/package1/module1

У этого преобразования есть существенный недостаток - адрес содержит разделитель "/", который не может присутствовать (без экранирования) в идентификаторах JS-кода (именах переменных, функций, классов):

const obj = {name, version, Path/Like/Id}

Независимая адресация ES6-модуля

В PHP namespace'ы появились с версии 5.3, а до этого программисты использовали подход, применяемый, например, в Zend1 (old style):

class My_Long_NestedComponent_ClassName {}

Этот подход неплохо зарекомендовал себя в PHP (до появления встроенных в язык namespace'ов), поэтому его вполне можно перенести на JS, используя при именовании пространств символы, разрешённые для идентификаторов JS-кода:

const obj = {name, version, Zend_Like_Id}

К тому же у него есть дополнительный плюс по отношению к адресации на основе путей.

Имя простого npm-пакета (package) не может содержать разделителей (/), максимум, что можно - поместить пакет в scope (@vendor/package). Если в процессе разработки наш пакет разросся и мы захотим разделить его на несколько пакетов, то нам придётся переделывать адреса для всех элементов кода:

package/module1/region/area/path/to/module // old 'package'package_module1/region/area/path/to/module // new 'package_module1'package_module1_region/area/path/to/module // new 'package_module1_region'

Адресация в стиле Zend1 лишена этого недостатка. Нам нужно подставить правила маппинга для соответствующих узлов идентификатора:

Package => ./node_modules/packagePackage_Module1 => ./node_modules/package_mod1Package_Module1_Region => ./node_modules/package_mod1_reg

после чего путь к искомому модулю определяется по наибольшей длине имеющегося маппинга:

Package_Module2_Mod => ./node_modules/package/Module2/Mod.jsPackage_Module1_Sub_Mod => ./node_modules/package_mod1/Sub/Mod.jsPackage_Module1_Region_Area => ./node_modules/package_mod1_reg/Area.js

В общем, решение, проверенное временем, и гораздо лучше адресации на основе путей.

Адресация кода внутри ES6-модуля

ES6-модуль предоставляет доступ к своему содержимому через инструкцию export. Допустим, что у нас есть пространство Ns_App_Plugin c маппингом на путь ./node_modules/@vendor/app_plugin/src, в котором определён ES6-модуль ./Shared/Util.mjs с таким экспортом:

export function fn() {};export class Clazz {};export default {fn, Clazz};

Используя namespace с независимой от пути адресацией, можно адресовать сам модуль как:

Ns_App_Plugin_Shared_Util

а используя символ "#" (по аналогии с подобным символом в URL), можно адресовать соответствующий экспорт внутри модуля:

Ns_App_Plugin_Shared_Util#fnNs_App_Plugin_Shared_Util#ClazzNs_App_Plugin_Shared_Util#default

Символ "#" так же, как и символ "/", не может использоваться в идентификаторах JS-кода без экранирования. Вместо него можно было бы использовать символ "$", но я его приберёг для другого (об этом чуть ниже).

Default export

В Java каждый класс располагается в отдельном java-файле. В PHP можно в одном php-файле можно разместить несколько классов, но, как правило, придерживаются аналогичного подхода: один класс - один файл.

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

export default function Ns_App_Plugin_Shared_Util() {}

Если придерживаться этого правила - "один файл - один экспорт", то у нас пропадает необходимость в использовании символа "#". В зависимости от контекста адрес Ns_App_Plugin_Shared_Util может означать как ES6-модуль, так и default экспорт из этого модуля.

Порождение объектов

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

Допустим, у нас в приложении есть контейнер, в который мы можем помещать объекты двух типов. Пусть одиночки в контейнере у нас адресуются строкой, начинающейся с прописной буквы (dbConfig), а фабрики - такой же строкой, только с двумя знаками доллара на конце - dbTransaction$$. Тогда запросы к контейнеру могли бы выглядеть так:

const cfg1 = container.get('dbConfig');const cfg2 = container.get('dbConfig');const trn1 = container.get('dbTransaction$$');const trn2 = container.get('dbTransaction$$');cfg === cfg2;   // truetrn1 === trn2;   // false

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

const singleton = container.get('dbConfig');const newInstance = container.get('dbTransaction$$');const module = container.get('Ns_App_Plugin_Shared_Util');const defExport = container.get('Ns_App_Plugin_Shared_Util#');const defExportSingleton = container.get('Ns_App_Plugin_Shared_Util$');const defExportNewInstance = container.get('Ns_App_Plugin_Shared_Util$$');

Dependency Injection

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

Чуть больше года назад я уже делал реализацию DI-контейнера и даже опубликовал его описание на Хабре. Но на тот момент я не понимал значения ES6-модуля как элемента JS-кода. Такое понимание пришло только при написании статьи "Javascript: исходный код и его отображение при отладке" (это понимание и есть практическая ценность статьи, о которой интересовался коллега @Zenitchik). Предыдущая реализация моего контейнера позволяла вставлять только одиночные зависимости и зависимости на основе фабрик (вновь порождённые объекты). В текущей реализации контейнер позволяет получать доступ к следующими элементам:

  • singleton, добавленный в контейнер вручную: dbConfig;

  • новый объект, созданный при помощи фабрики, добавленной в контейнер вручную: dbTransaction$$;

  • ES6-модуль: Ns_App_Module;

  • отдельный экспорт: Ns_App_Module#name (именованный) и Ns_App_Module# (default);

  • singleton, созданный из default-экспорта модуля: Ns_App_Module$;

  • singleton, созданный из именованного экспорта модуля: Ns_App_Module#name$;

  • новый объект, созданный при помощи фабрики, полученной из default-экспорта модуля: Ns_App_Module$$;

  • новый объект, созданный при помощи фабрики, полученной из именованного экспорта модуля: Ns_App_Module#name$$;

Таким образом, на данным момент я могу использовать в своих JS-приложениях namespace'ы, аналогичные тем, которые есть в PHP и Java, а типовой модуль моего приложения выглядит примерно так:

export default function Ns_App_Plugin_Fn(spec) {    const singleton = spec.dbConfig;    const newInstance = spec.dbTransaction$$;    const module = spec.Ns_App_Plugin_Shared_Util;    const defExport = spec['Ns_App_Plugin_Shared_Util#'];    const defExportSingleton = spec.Ns_App_Plugin_Shared_Util$;    const defExportNewInstance = spec.Ns_App_Plugin_Shared_Util$$;    // ...}

или так:

export default class Ns_App_Plugin_Class {    constructor(spec) {        const singleton = spec.dbConfig;        // ...    }}

И этот код может использоваться без изменений как в nodejs, так и в браузере.

Недостатки

Наследование

При данном подходе не получается использовать наследование:

export default class Ns_App_Plugin_Mod extends Ns_App_Plugin_Base {}

вместо этого приходится использовать композицию, что для моих задач считаю приемлемым.

Расширение

Не всегда получается загрузить ES6-модули в виде файлов с расширением *.js. Появляется ошибка:

(node:506150) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension./home/alex/.../src/Server.js:2import $path from 'path';^^^^^^

Для стабильности лучше размещать все ES6-модули в файлах с расширением *.mjs. В этом случае загрузчик принудительно использует схему ES6.

Демо

Я подготовил небольшой демо-проект, в котором есть два модуля:

@flancer64/demo_teqfw_di_mod_main@flancer64/demo_teqfw_di_mod_plugin

В каждом из которых по три скрипта:

./src/Front.mjs./src/Server.mjs./src/Shared.mjs

Front-скрипты и server-скрипты используют shared-скрипты, а скрипты main-модуля используют скрипты plugin-модуля.

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

Загрузка контейнера, его настройка, получение из контейнера объекта для запуска на фронте:

<script type="module">    const baseUrl = location.href;    // load DI container as ES6 module (w/o namespaces)    import(baseUrl + 'node_modules/@teqfw/di/src/Container.mjs').then(async (modContainer) => {        // init container and setup namespaces mapping        /** @type {TeqFw_Di_Container} */        const container = new modContainer.default();        const pathMain = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_main/src';        const pathPlugin = baseUrl + 'node_modules/@flancer64/demo_teqfw_di_mod_plugin/src';        container.addSourceMapping('Demo_Main', pathMain, true, 'mjs');        container.addSourceMapping('Demo_Main_Plugin', pathPlugin, true, 'mjs');        // get main front as singleton        /** @type {Demo_Main_Front} */        const frontMain = await container.get('Demo_Main_Front$');        frontMain.out('#main', '#plugin');    });</script>

Код основного фронт-объекта с подтягиванием зависимостей в конструкторе через spec объект:

// frontend code cannot use traditional npm packages but can use browser APIexport default class Demo_Main_Front {    /** @type {Demo_Main_Shared} */    singleMainShared    /** @type {Demo_Main_Plugin_Front} */    instPluginFront    constructor(spec) {        // get default export as singleton (class)        this.singleMainShared = spec.Demo_Main_Shared$;        // get default export as new instance (class)        this.instPluginFront = spec.Demo_Main_Plugin_Front$$;    }    //...}

Код shared-объекта из plugin-пакета:

// shared code cannot use traditional npm packages and cannot use browser APIexport default function Demo_Main_Plugin_Shared(param) {    return {pluginShared: param};}

Резюме

Вот он - @teqfw/di

Подробнее..

Внедрение зависимостей (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. Напишите в комментариях.

Подробнее..

Глобальные объекты в 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. Надеюсь, что эти знания помогут и вам!

Подробнее..

Создание приложений на Angular с использованием продвинутых возможностей DI

01.06.2021 10:11:39 | Автор: admin

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

Слоеный пирог приложения

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

  1. Хранение данных и осуществление операций с данными (слой данных).

  2. Преобразование информации к виду, требуемому для отображения, обработка действий пользователя (слой управления или контроллер).

  3. Визуализация данных и делегация событий (слой представления).

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

  • элементы слоя представления компоненты;

  • зависимости слоя управления находятся в элементных инжекторах, а слоя данных в модульных;

  • связь между слоями осуществляется средствами системы DI;

  • элементы каждого уровня могут иметь дополнительные зависимости, которые непосредственно к слою не относятся;

  • слои связаны в строгом порядке: сервисы одного уровня не могут зависеть друг от друга, компоненты слоя представления могут внедрять только контроллеры, а контроллеры только сервисы слоя данных.

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

Вообще говоря, под данными, передаваемыми между слоями, имеются в виду произвольные объекты. Однако, в большинстве случаев ими будут Observable, которые идеально подходят к описываемому подходу. Как правило, слой данных отдает Observable с частью состояния приложения. Затем в слое управления с помощью операторов rxjs данные преобразовываются к нужному формату, и в шаблоне компонента осуществляется подписка через async pipe. События на странице связываются с обработчиком в контроллере. Он может иметь сложную логику управления запросами к слою данных и подписывается на Observable, которые возвращают асинхронные команды. Подписка позволяет гибко реагировать на результат выполнения отдельных команд и обрабатывать ошибки, например, открывая всплывающие сообщения. Элементы слоя управления я буду дальше называть контроллерами, хотя они отличаются от таковых в MVC паттерне.

Слой данных

Сервисы слоя данных хранят состояние приложения (бизнес-данные, состояние интерфейса) в удобном для работы с ним виде. В качестве дополнительных зависимостей используются сервисы для работы с данными (например: http клиент и менеджеры состояния). Для непосредственного хранения данных удобно использовать BehaviourSubject в простых случаях, и такие библиотеки как akita, Rxjs или ngxs для более сложных. Однако, на мой взгляд, последние две избыточны при данном подходе. Лучше всего для предлагаемой архитектуры подходит akita. Ее преимуществами являются отсутствие бойлерплейта и возможность переиспользовать стейты обычным наследованием. При этом обновлять стейт можно непосредственно в операторах rxjs запросов, что гораздо удобнее, чем создание экшенов.

@Injectable({providedIn: 'root'})export class HeroState {  private hero = new BehaviorSubject(null);  constructor(private heroService: HeroService) {}  load(id: string) {    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));  }  save(hero: Hero) {    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));  }  get hero$(): Observable<Hero> {    return this.hero.asObservable();  }}

Слой управления

Так как каждый сервис слоя относится к конкретному компоненту с его поддеревом, логично назвать сервис контроллером компонента. Благодаря тому, что контроллер компонента находится в элементном инжекторе, в нем можно использовать OnDestroy hook и внедрять те же зависимости, что и в компоненте, например ActivatedRoute. Безусловно, можно не создавать отдельный сервис для контроллера в тех случаях, где это равноценно вынесению кода из компонента.

Помимо зависимостей из слоя данных, в контроллере могут быть внедрены зависимости управляющие визуализацией (например: открытие диалогов, роутер) и помогающие с преобразованием данных (например: FormBuilder).

@Injectable()export class HeroController implements OnDestroy {  private heroSubscription: Subscription;    heroForm = this.fb.group({    id: [],    name: ['', Validators.required],    power: ['', Validators.required]  });  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }  save() {    this.heroState.save(this.heroForm.value).subscribe();  }  initialize() {    this.route.paramMap.pipe(      map(params => params.get('id')),      switchMap(id => this.heroState.load(id)),    ).subscribe();    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));  }    ngOnDestroy() {    this.heroSubscription.unsubscribe();  }}

Слой представления

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

@Component({  selector: 'hero',  template: `    <hero-form [form]="heroController.heroForm"></hero-form>    <button (click)="heroController.save()">Save</button>  `,  providers: [HeroController]})export class HeroComponent {  constructor(public heroController: HeroController) {    this.heroController.initialize();  }}

Повторное использование кода

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

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

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

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

export abstract class EntityState<T> {    abstract get entities$(): Observable<T[]>; // список сущностей    abstract get selectedId$(): Observable<string>; // id выбранного элемента    abstract get selected$(): Observable<T>; // выбранный элемент    abstract select(id: string); // выбрать элемент с указанным id    abstract load(): Observable<T[]> // загрузить список    abstract save(entity: T): Observable<T>; // сохранить сущность}

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

@Injectable()export class EntityCardController {    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {    }    save(form: FormGroup) {        this.entityState.save(form.value).subscribe({            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })        })    }}

В самом компоненте используем еще один способ внедрения зависимости через директиву @ContentChild.

@Component({    selector: 'entity-card',    template: `        <mat-card>            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">                <mat-card-title>                    <ng-content select=".header"></ng-content>                </mat-card-title>                <mat-card-content>                    <ng-content></ng-content>                </mat-card-content>                <mat-card-actions>                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>                </mat-card-actions>            </ng-container>            <ng-template #notSelected>Select Item</ng-template>        </mat-card>    `,    providers: [EntityCardController]})export class EntityCardComponent {    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;    constructor(public entityCardController: EntityCardController) {        this.entityCardController.initialize();    }}

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

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

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

<entity-card><hero-form></hero-form></entity-card>

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

export interface Entity {    value: string;    label: string;}@Injectable()export abstract class EntityListController<T> {    constructor(protected entityState: EntityState<T>) {}    select(value: string) {        this.entityState.select(value);    }    selected$ = this.entityState.selectedId$;    abstract get entityList$(): Observable<Entity[]>;}

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

@Injectable()export class FilmsListController extends EntityListController<Film> {    entityList$ = this.entityState.entities$.pipe(        map(films => films.map(f => ({ value: f.id, label: f.title })))    )}

Компонент списка использует этот сервис, однако его реализация будет предоставлена внешним компонентом.

@Component({    selector: 'entity-list',    template: `        <mat-selection-list [multiple]="false"                             (selectionChange)="entityListController.select($event.options[0].value)">            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"                             [selected]="item.value === (entityListController.selected$ | async)"                             [value]="item.value">                {{ item.label }}            </mat-list-option>        </mat-selection-list>    `})export class EntityListComponent {    constructor(public entityListController: EntityListController<any>) {}}

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

@Component({    selector: 'entity-page',    template: `        <mat-sidenav-container>            <mat-sidenav opened mode="side">                <entity-list></entity-list>            </mat-sidenav>            <ng-content></ng-content>        </mat-sidenav-container>    `,})export class EntityPageComponent {}

Использование компонента entity-page:

@Component({    selector: 'film-page',    template: `        <entity-page>            <entity-card>                <span class="header">Film</span>                <film-form></film-form>            </entity-card>        </entity-page>    `,    providers: [        { provide: EntityState, useExisting: FilmsState },        { provide: EntityListController, useClass: FilmsListController }    ]})export class FilmPageComponent {}

Компонент entity-card передается через проекцию содержимого для возможности использования ContentChild.

Послесловие

Описанный подход позволил мне значительно упростить процесс проектирования и ускорить разработку без ущерба качеству и читаемости кода. Он отлично масштабируется к реальным задачам. В примерах были продемонстрированы лишь базовые техники переиспользования. Их комбинация с такими фичами как multi-провайдеры и модификаторы доступа (Optional, Self, SkipSelf, Host) позволяет гибко выделять абстракции в сложных случаях, используя меньше кода, чем обычное переиспользование компонентов.

Подробнее..

Погружение во внедрение зависимостей (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

Принцип слоеного теста

23.12.2020 16:22:50 | Автор: admin
Всем неустрашимым на пути от отрицания до убеждения посвящается


image

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

Не судьба...


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

у нас есть метод, который подсчитывает сумму чисел

public Integer sum(Integer a, Integer b) {
return a+b
}

на данный метод можно написать тест

Test
public void testGoodOne() {
assertThat(sum(2,2), is(4));
}


Это не шутка, это упрощенный пример из типичной статьи про технологию unit тестирования, где в начале и конце общие фразы про пользу и необходимость, а в середине такое
Увидев такое, и перечитав для верности дважды, соискатель восклицает: Что за лютый бред?.. Ведь, у него в коде практически нет методов, которые все необходимое получают через аргументы, а затем отдают однозначный результат по ним. Это типичные утилитарные методы, и они практически не меняются. А как быть со сложными процедурами, с внедренными зависимостями, с методами без возврата значений? Там это подход не применим от слова совсем.

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

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

Ключевая миссия.



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

Ключевая функция unit тестов зафиксировать ожидаемое поведение системы.

и этот:

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

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

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

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

Но, черт возьми, как?



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

Рассмотрим слои типичного веб- приложения: контроллеры, сервисы, репозитории и т.п. Кроме того используются слои утилит, фасадов, моделей и DTO. Два последних не должны содержать функционала, т.е. методов кроме аксессоров(геттеры/сеттеры), поэтому покрывать их тестами не нужно. Остальные слои мы рассмотрим как цели для покрытия.
Как не напрашивается это вкусное сравнение, приложение нельзя сравнить со слоеным тортом по той причине, что слои эти внедряются друг в друга, как зависимости:
  • контроллер внедряет в себя сервис/ы, к которым обращается за результатом
  • сервис внедряет в себя репозитории (DAO), может внедрять утилитарные компоненты
  • фасад предназначен для комбинирования работы множества сервисов или компоненты, соответственно внедряет в себя их


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

Принцип слоеного теста.



Перейдем к примерам, простое приложение на Java Spring Boot, код будет элементарный, так что суть легко понятна и аналогично применима для других современных языков/фреймворков. Задача у приложения будет простая умножить число на 3, т.е. утроить (англ. triple), но при этом мы создадим многослойное приложение с внедрением зависимостей (dependency injection) и послойным покрытием с головы до пят.

image

В структуре созданы пакеты для трех слоев: controller, service, repo. Структура тестов аналогична.
Работать приложение будет так:
  1. с фронт-энда на контроллер приходит GET запрос с идентификатором числа, которое требуется утроить.
  2. контроллер запрашивает результат у своей зависимости сервиса
  3. сервис запрашивает данные у своей зависимости репозитория, умножает и возвращает результат контроллеру
  4. контроллер дополняет результат и возвращает на фронт-энд


Начнем с контроллера:

@RestController@RequiredArgsConstructorpublic class SomeController {   private final SomeService someService; // dependency injection   static final String RESP_PREFIX = "Результат: ";   static final String PATH_GET_TRIPLE = "/triple/{numberId}";   @GetMapping(path = PATH_GET_TRIPLE) // mapping method to GET with url=path   public ResponseEntity<String> triple(@PathVariable(name = "numberId") int numberId) {       int res = someService.tripleMethod(numberId);   // dependency call       String resp = RESP_PREFIX + res;                // own logic       return ResponseEntity.ok().body(resp);   }}


Типичный рест контроллер, имеет внедрение зависимости someService. Метод triple настроен на GET запрос по URL "/triple/{numberId}", где в переменной пути передается идентификатор числа. Сам метод можно разделить на две основные составляющие:

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


Рассмотрим сервис:

@Service@RequiredArgsConstructorpublic class SomeService {   private final SomeRepository someRepository; // dependency injection   public int tripleMethod(int numberId) {       Integer fromDB = someRepository.findOne(numberId);  // dependency call       int res = fromDB * 3;                               // own logic       return res;   }}


Тут подобная ситуация: внедрение зависимости someRepository, а метод состоит из обращения к зависимости и собственной логики.

Наконец репозиторий, для простоты выполнен без базы данных:

@Repositorypublic class SomeRepository {   public Integer findOne(Integer id){       return id;   }}


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

Если запустить наше приложение, то по настроенному url можно увидеть:



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

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

@Test    void someMethod_test() {        // prepare...        int res = someService.someMethod();                 // check...    }


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

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

int numberId = 42; // input path variable


Этот же numberId транзитом передается на вход методу сервиса, и тут самое время обеспечить сервис-мок:

@MockBeanprivate SomeService someService;


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

int serviceRes = numberId*3; // result from mock someService// prepare someService.tripleMethod behaviorwhen(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);


Эта запись означает: когда будет вызван someService.tripleMethod с аргументом равным numberId, вернуть значение serviceRes.
Кроме того, эта запись фиксирует факт, что данный метод сервиса должен быть вызван, что важный момент. Бывает что требуется зафиксировать вызов процедуры без результата, тогда используется иная запись, условно такая не делать ничего когда...:

Mockito.doNothing().when(someService).someMethod(eq(someParam));


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

int serviceRes = numberId*5; 


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

Итак мы определили поведение мока в нашем сценарии, следовательно при выполнении теста, когда внутри вызова целевого метода дело дойдет до мока, он вернет что попросили serviceRes, и дальше с этим значением будет работать собственный код контроллера.
Далее помещаем в сценарий вызов целевого метода. Метод контроллера имеет особенность он не вызывается в коде явно, а привязан через HTTP метод GET и URL, поэтому в тестах вызывается через специальный тестовый клиент. В Spring это MockMvc, в других фреймворках есть аналоги, например WebTestCase.createClient в Symfony. Итак, далее просто выполнение метода контроллера через маппинг по GET и URL.

       //// mockMvc.perform       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);       MvcResult mvcResult = mockMvc.perform(requestConfig)           .andExpect(status().isOk())           //.andDo(MockMvcResultHandlers.print())           .andReturn()       ;//// mockMvc.perform


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

// check of callingMockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));


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

А теперь главное мы проверяем поведение собственного кода контроллера:

// check of resultassertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());


Тут мы зафиксировали то, за что отвечает сам метод что результат полученный от someService конкатенируется с префиксом контроллера, и именно эта строка уходит в тело response. Кстати, воочию в содержимом Body можно убедиться, если раскомментировать строку

//.andDo(MockMvcResultHandlers.print())


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

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

@WebMvcTest(SomeController.class)class SomeControllerTest {   @MockBean   private SomeService someService;   @Autowired   private MockMvc mockMvc;   @Test   void triple() throws Exception {       int numberId = 42; // input path variable       int serviceRes = numberId*3; // result from mock someService       // prepare someService.tripleMethod behavior       when(someService.tripleMethod(eq(numberId))).thenReturn(serviceRes);       //// mockMvc.perform       MockHttpServletRequestBuilder requestConfig = MockMvcRequestBuilders.get(SomeController.PATH_GET_TRIPLE, numberId);       MvcResult mvcResult = mockMvc.perform(requestConfig)           .andExpect(status().isOk())           //.andDo(MockMvcResultHandlers.print())           .andReturn()       ;//// mockMvc.perform       // check of calling       Mockito.verify(someService, Mockito.atLeastOnce()).tripleMethod(eq(numberId));       // check of result       assertEquals(SomeController.RESP_PREFIX+serviceRes, mvcResult.getResponse().getContentAsString());   }}


Теперь настало время честного теста метода someService.tripleMethod, где аналогично есть вызов зависимости и собственный код. Готовим произвольный входящий аргумент и имитируем поведение зависимости someRepository:
int numberId = 42;when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());


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

assertEquals(numberId*3, res);


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

@ExtendWith(MockitoExtension.class)class SomeServiceTest {   @Mock   private SomeRepository someRepository; // то, что мокируем   @InjectMocks   private SomeService someService; // куда внедряем то, что мокируем   @Test   void tripleMethod() {       int numberId = 42;       when(someRepository.findOne(eq(numberId))).then(AdditionalAnswers.returnsFirstArg());       int res = someService.tripleMethod(numberId);       assertEquals(numberId*3, res);   }}


Поскольку репозиторий у нас условно-игрушечный, то и тест получился соответствующий:

class SomeRepositoryTest {   // no dependency injection   private final SomeRepository someRepository = new SomeRepository();   @Test   void findOne() {       int id = 777;       Integer fromDB = someRepository.findOne(id);       assertEquals(id, fromDB);   }}


Однако и тут весь скелет на месте: подготовка, вызов и проверка. Таким образом корректная работа someRepository.findOne зафиксирована.

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

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

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

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

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

На дорожку...



Вопрос для собеседования: сколько раз в рамках тикета разработчик должен запускать тесты? Сколько угодно, но как минимум дважды:
  • перед началом работы, чтобы убедиться что все OK, а не выяснять потом, что уже было сломано, а не ты сломал
  • по окончании работы

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

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


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

Код примера доступен по ссылке на github.com: https://github.com/denisorlov/examples/tree/main/unittestidea
Подробнее..

Методы организации 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 (может, к тому времени он научиться решать вообще все задачи, решения которых хотелось бы от него ожидать).

Подробнее..

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

10.02.2021 20:14:57 | Автор: admin

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

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

func NewGreeter(name string) (*Greeter, error) {  sender, err := NewSender()  if err != nil {    return nil, err  }  return Greeter{name, sender}, nil}func (g Greeter) Greet() error {  return g.sender.Send("Hello, " + g.name + "!")}func (g *Greeter) Close() error {  return o.sender.Close()}g, err := NewGreeter("Go")if err != nil {  panic(err)}defer g.Close()g.Greet()

А здесь он делегирует эту задачу - это и есть Dependency Injection:

func NewGreeter(name string, sender *Sender) *Greeter {  return Greeter{name, sender}}func (g Greeter) Greet() error {  return g.sender.Send("Hello, " + g.name + "!")}s, err := NewSender()if err != nil {  panic(err)}defer s.Close()g := NewGreeter("Go", s)g.Greet()

Такой подход, несмотря на свою простоту, даёт существенные преимущества:

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

Решить эту проблему призваны DI-контейнеры. В общих чертах контейнер работает следующим образом:

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

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

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

Здесь я хочу упомянуть и после навсегда забыть Service Locator. По большому счёту это то, с чего мы начинали: объект зависит от локатора, из которого сам извлекает (pull) свои зависимости. Хотя такой подход несколько снижает связность кода и повышает его тестируемость за счёт отсутствия взаимодействия конструктора зависимого объекта с конструкторами зависимостей, но сами зависимости при этом скрыты: нет никакого иного пути узнать их, кроме как "подсмотреть" в документации (если она есть) или непосредственно в коде объекта. На мой взгляд, SL заслуженно считается многими анти-паттерном.

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

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

Что там с этим в GO?

Многие фреймворки в других языках поддерживают внедрение зависимостей из коробки - некоторые даже основаны на нём. В качестве примеров можно привести Spring, .NET Framework, Symfony, Laravel, Angular, Dagger. Даже для C++ и Rust можно что-то найти, но глядя на список невольно обращаешь внимание, что темой DI в основном интересуется кровавый энтерпрайз :)

В сообществе Go эта тема не очень популярна, но тем не менее представлена Wire от Google и Fx от Uber (там внутри используется dig). Их можно считать основными, хотя есть ещё ряд проектов (аттеншн! в списке по ссылке не тот wire).

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

Uber Fx

github.com/uber-go/fx

github.com/uber-go/dig

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

Для начала работы с dig потребуется явно создать контейнер:

c := dig.New()

Затем нужно сконфигурировать контейнер:

if err := c.Provide(NewLogger); err != nil {  ...}if err := c.Provide(NewDB); err != nil {  ...}

После этого можно вызвать точку входа программы:

if err := c.Invoke(func (logger *log.Logger, db *sql.DB) error { ... }); err != nil {  ...}

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

missing dependencies for function ... (path/to/file.go:42): missing type: *Config

Кроме того, *sql.DB реализует интерфейс io.Closer, но метод db.Close нигде не вызывается. Хотя Go в состоянии самостоятельно освободить системные ресурсы при завершении программы, это всё же не очень хорошо.

Тем не менее, давайте посмотрим, что можно со всем этим сделать.

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

package objectimport (  ".../container"  ".../other")func init() {  if err := container.Provide(New); err != nil {    panic(err)  }}type Object struct { ... }func New(o *other.Other) (*Object, error) { ... }

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

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

func run(logger *log.Logger, db *sql.DB) error { ... }// +build !validatefunc main() {  c, err := NewContainer()  if err != nil {    panic(err)  }  if err := c.Invoke(run); err != nil {    panic(err)  }}// +build validatefunc main() {  c, err := NewContainer(dig.DryRun(true)) // DryRun указывает dig не выполнять                                           // функции, а просто анализировать                                           // их сигнатуры.  if err != nil {    panic(err)  }  if err := c.Invoke(run); err != nil {    panic(err)  }}

Теперь можно просто запустить команду

go run -tags validate

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

Но можно и не усложнять и вместо тэгов просто использовать тесты :)

А вот заставить dig вызвать db.Close не получится - он просто этого не умеет. Чтобы справиться с этой проблемой, нужно использовать Fx.

Fx вводит понятие приложения, в котором контейнер - лишь одна из составных частей. Другой его составной частью является fx.Lifecycle, который и позволит зарегистрировать хук для этапа остановки приложения:

func NewDB(lc fx.Lifecycle, logger *log.Logger) (*sql.DB, error) {  db, err := sql.Open("...")  if err != nil {    return nil, err  }  logger.Print("database initialized")  lc.Append(fx.Hook{    OnStop: func(context.Context) error {      logger.Print("database finalized")      return db.Close()    },  })  return db, nil}app := fx.New(  fx.Provide(    NewLogger,    NewDB,  ),  fx.Invoke(run),)app.Run() // Блокируется в ожидании SIGINT, можно использовать app.Start/app.Stop.

Мы теряем возможность использовать DryRun, но зато можем наконец закрыть базу данных (но не в случае паники).

Зависимость конструктора от fx.Lifecycle выглядит неприятно - она автоматически делает Fx несовместимым со стандартными (и нормально тестируемыми) конструкторами, которые придётся оборачивать специально для фреймворка.

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

Больше информации про dig и Fx можно найти в документации к ним. Я же предлагаю рассмотреть следующий проект.

Google Wire

github.com/google/wire

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

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

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

Создаём файл wire.go (имя не принципиально):

// +build wireinjectpackage mainimport (  "database/sql""log""github.com/google/wire")type Container struct {  Logger *log.Logger  DB     *sql.DB}func NewContainer() (*Container, func(), error) {  panic(wire.Build(    NewLogger,    NewDB,    wire.Struct(new(Container), "*"),  ))}

Запускаем Wire:

> wire github.com/user/module< wire: github.com/user/module/wire: wrote path/to/module/wire_gen.go

Получаем сгенерированный код в файле wire_gen.go (исходное имя с постфиксом _gen):

// Code generated by Wire. DO NOT EDIT.//go:generate go run github.com/google/wire/cmd/wire//+build !wireinjectpackage mainimport (  "database/sql""log")// Injectors from wire.go:func NewContainer() (*Container, func(), error) {logger, cleanup, err := NewLogger()  if err != nil {    return nil, nil, err}  db, cleanup2, err := NewDB(logger)  if err != nil {    cleanup()    return nil, nil, err}container := &Container{Logger: logger,    DB:     db,}return container, func() {    cleanup2()cleanup()}, nil}// wire.go:type Container struct {Logger *log.LoggerDB     *sql.DB}

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

func NewDB(logger *log.Logger) (*sql.DB, func(), error) {  db, err := sql.Open("...")  if err != nil {    return nil, nil, err  }  logger.Print("database initialized")  return db, func() {    _ = db.Close()    logger.Print("database finalized")  }, nil}

Wire принимает и стандартные конструкторы вида func(...) T и func(...) (T, error), но для них никакой финализации не выполняется, даже если T имплементирует io.Closer.

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

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

А что с распределением конфигурации по пакетам, которую для dig получилось реализовать с помощью глобального контейнера? В какой-то мере это можно реализовать с помощью Provider Sets, но необходимо помнить, что для одного типа в wire.Build может существовать только один провайдер. Это может оказаться проблемой для разделяемых транзитивных зависимостей: если, скажем, клиент базы данных и консьюмер сообщений оба зависят от логгера, который кроме них больше никому не нужен, то оба объекта не могут включить его провайдер в свой Provider Set - в этом случае возникнет конфликт между двумя провайдерами для одного типа. Использовать же какие-то динамические структуры типа массива провайдеров мешает тот факт, что код Wire - это не код Go, а значит, допустим, оператор распаковки массива генератору ни о чём не скажет. Так что по большому счёту конфигурировать контейнер можно только в одном месте - в описании инжектора.

Итак, Wire выглядит довольно хорошо в качестве реализации DI в Go, даже несмотря на то, что пока ещё остаётся в бета-версии (на момент написания статьи последней версией была 0.5.0). Я думаю, что часть его недостатков, если не они все, вполне могут быть устранены в будущих версиях.

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

KInit

github.com/go-kata/kinit

github.com/go-kata/examples

Я исходил из следующих требований, разрабатывая эту библиотеку:

  • Гарантия очистки ресурсов даже в случае паники. Мне кажется весьма странным то, что ни одна из реализаций DI в Go не уделила этому должного внимания.

  • Совместимость со стандартными конструкторами. Опять же - весьма странно, что во всех реализациях с этим возникают проблемы.

  • Возможность распределения конфигурации по пакетам.

  • Возможность валидации графа зависимостей без (реального) запуска программы. Опционально - возможность визуализации графа.

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

В результате получилось следующее.

Библиотека по умолчанию предлагает глобальный основанный на рефлексии DI/IoC-контейнер, который пакеты могут конфигурировать аналогично тому, как это было сделано для dig. Контейнер может быть проверен на отсутствие циклических и неудовлетворённых зависимостей опять же аналогично способу, описанному для dig.

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

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

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

KInit рассматривает и конструкторы, и процессоры как интерфейсы. Реализует эти интерфейсы (причём в нескольких вариантах) набор расширений KInitX. Их может реализовать и пользователь, если у него возникнет потребность в специфичном механизме. Например, конструкторов существует два вида - один основан на функциях, в то время как другой похож на wire.Struct и инициализирует структуры на месте (актуально для структур с большим количеством полей). Если потребуется сделать специфический конструктор, использующий что-то типа dig.In или именованных типов - его можно реализовать и использовать в KInit наряду с библиотечными.

Сконфигурировать глобальный контейнер можно как-то так:

// Конструктор рассматривает экспортируемые поля структуры// как её зависимости.kinitx.MustProvide((*Config)(nil))// Процессор выполняет загрузку уже созданной структуры// со значениями по умолчанию из файла.kinitx.MustAttach((*Config).Load)// Конструктор создаёт клиент базы данных, метод Close которого// будет гарантированно вызван даже в случае паники.kinitx.MustProvide(NewDB) // func NewDB(...) (*sql.DB, error)// Псевдо-конструктор связывает интерфейс с реализацией.kinitx.MustBind((*StorageInterface)(nil), (*PostgresStrorage)(nil))

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

Работать с функторами можно примерно так:

func run(logger *log.Logger, db *sql.DB) (further kinit.Functor, err error) { ... }// Функтор регистрируется для учёта// его зависимостей при валидации контейнера.kinitx.MustConsider(run)// Последовательность событий:// 1. Создаются зависимости функтора run.// 2. Функтор run запускается.// 3. Создаются недостающие зависимости функтора further.// 4. Функтор further запускается.// 5. Зависимости, созданные на шагах 1 и 3, уничтожаются.kinitx.MustRun(run)

Ещё есть недокументированные функции, позволяющие запустить в контейнере функцию, пока вы запускаете функцию - но на то они и недокументированные :) Если они покажутся вам интересными, то отправная точка исследования здесь.

Спасибо за внимание! Я надеюсь, что эта статья была для вас полезна. Давайте обсудим DI в Go в комментариях :)

Подробнее..

Domain-driven design, Hexagonal architecture of ports and adapters, Dependency injection и Python

31.05.2021 12:16:05 | Автор: admin

Prologue

- Глянь, статью на Хабр подготовил.
- Эм... а почему заголовок на английском?
- "Предметно-ориентированное проектирование, Гексагональная архитектура портов и адаптеров, Внедрение зависимостей и Пайто..."

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

Intro

Как же летит время! Два года назад я расстался с миром Django и очутился в мире Kotlin, Java и Spring Boot. Я испытал самый настоящий культурный шок. Голова гудела от объёма новых знаний. Хотелось бежать обратно в тёплую, ламповую, знакомую до байтов экосистему Питона. Особенно тяжело на первых порах давалась концепция инверсии управления (Inversion of Control, IoC) при связывании компонентов. После прямолинейного подхода Django, автоматическое внедрение зависимостей (Dependency Injection, DI) казалось чёрной магией. Но именно эта особенность фреймворка Spring Boot позволила проектировать приложения следуя заветам Чистой Архитектуры. Самым же большим вызовом стал отказ от философии "пилим фичи из трекера" в пользу Предметно-ориентированного проектирования (Domain-Driven Design, DDD).

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

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

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

Dependency Injection

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

Допустим нам понадобилась функция, отправляющая сообщения с пометкой "ТРЕВОГА!" в шину сообщений. После недолгих размышлений напишем:

from my_cool_messaging_library import get_message_bus()def send_alert(message: str):    message_bus = get_message_bus()    message_bus.send(topic='alert', message=message)

В чём главная проблема функции send_alert()? Она зависит от объекта message_bus, но для вызывающего эта зависимость совершенно не очевидна! А если вы хотите отправить сообщение по другой шине? А как насчёт уровня магии, необходимой для тестирования этой функции? Что, что? mock.patch(...) говорите? Коллеги, атака в лоб провалилась, давайте зайдём с флангов.

from my_cool_messaging_library import MessageBusdef send_alert(message_bus: MessageBus, message: str):    message_bus.send(topic='alert', message=message)

Казалось, небольшое изменение, добавили аргумент в функцию. Но одним лишь этим изменением мы убиваем нескольких зайцев: Вызывающему очевидно, что функция send_alert() зависит от объекта message_bus типа MessageBus (да здравствуют аннотации!). А тестирование, из обезьяньих патчей с бубном, превращается в написание краткого и ясного кода. Не верите?

def test_send_alert_sends_message_to_alert_topic()    message_bus_mock = MessageBusMock()    send_alert(message_bus_mock, "A week of astrology at Habrahabr!")    assert message_bus_mock.sent_to_topic == 'alert'    assert message_bus_mock.sent_message == "A week of astrology at Habrahabr!"class MessageBusMock(MessageBus):    def send(self, topic, message):        self.sent_to_topic = topic        self.sent_message = message

Тут искушённый читатель задастся вопросом: неужели придётся передавать экземпляр message_bus в функцию send_alert() при каждом вызове? Но ведь это неудобно! В чём смысл каждый раз писать

send_alert(get_message_bus(), "Stackoverflow is down")

Попытаемся решить эту проблему посредством ООП:

class AlertDispatcher:    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def send(message: str):        self._message_bus.send(topic='alert', message=message)alert_dispatcher = AlertDispatcher(get_message_bus())alert_dispatcher.send("Oh no, yet another dependency!")

Теперь уже класс AlertDispatcher зависит от объекта типа MessageBus. Мы внедряем эту зависимость в момент создания объекта AlertDispatcher посредством передачи зависимости в конструктор. Мы связали (we have wired, не путать с coupling!) объект и его зависимость.

Но теперь акцент смещается с message_bus на alert_dispatcher! Этот компонент может понадобиться в различных местах приложения. Мало ли откуда нужно оправить сигнал тревоги! Значит, необходим некий глобальный контекст из которого можно будет этот объект достать. И прежде чем перейти к построению такого контекста, давайте немного порассуждаем о природе компонентов и их связывании.

Componential architecture

Говоря о внедрении зависимостей мы не сильно заостряли внимание на типах. Но вы наверняка догадались, что MessageBus - это всего лишь абстракция, интерфейс, или как бы сказал PEP-544 - протокол. Где-то в нашем приложении объявленo:

class MessageBus(typing.Protocol):    def send(topic: str, message: str):        pass

В проекте также есть простейшая реализация MessageBus-a, записывающая сообщения в список:

class MemoryMessageBus(MessageBus):    sent_messages = []    def send(topic: str, messagge: str):        self.sent_messages.append((str, message))

Таким же образом можно абстрагировать бизнес-логику, разделив абстрактный сценарий пользования (use case) и его имплементацию:

class DispatchAlertUseCase(typing.Protocol):    def dispatch_alert(message: str):        pass
class AlertDispatcherService(DispatchAlertUseCase):    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def dispatch_alert(message: str):        self._message_bus.send(topic='alert', message=message)

Давайте для наглядности добавим HTTP-контроллер, который принимает сообщения по HTTP-каналу и вызывает DispatchAlertUseCase:

class ChatOpsController:    ...    def __init__(self, dispatch_alert_use_case: DispatchAlertUseCase):        self._dispatch_alert_use_case = dispatch_alert_use_case    @post('/alert)    def alert(self, message: Message):        self._dispatch_alert_use_case.dispatch_alert(message)        return HTTP_ACCEPTED

Наконец, всё это необходимо связать воедино:

from my_favourite_http_framework import http_serverdef main():    message_bus = MemoryMessageBus()    alert_dispatcher_service = AlertDispatcherService(message_bus)    chat_opts_controller = ChatOpsController(alert_dispatcher_service)    http_server.start()

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

@post('/alert)def alert(message: Message):    bus = MemoryMessageBus()    bus.send(topic='alert', message=message)    return HTTP_ACCEPTED

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

Вернёмся к нашей компонентной архитектуре. В чём её преимущества?

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

  • Каждый компонент работает в чётких рамках и решает лишь одну задачу.

  • Это значит, что компоненты могут быть протестированы как в полной изоляции, так и в любой произвольной комбинации включающей тестовых двойников (test double). Думаю не стоит объяснять, насколько проще тестировать изолированные части программы. Подход к TDD меняется с невнятного "нуууу, у нас есть тесты" на бодрое "тесты утром, вечером код".

  • С учётом того, что зависимости описываются абстракциями, можно безболезненно заменить один компонент другим. В нашем примере - вместо MemoryMessageBus можно бухнуть DbMessageBus, да хоть в файл на диске писать - тому кто вызывает message_bus.send(...) нет до этого никакого дела.

"Да это же SOLID!" - скажите вы. И будете абсолютно правы. Не удивлюсь, если у вас возникло чувство дежавю, ведь благородный дон @zueve год назад детально описал связь SOLID и Чистой архитектуры в статье "Clean Architecture глазами Python-разработчика". И наша компонентная архитектура находится лишь в шаге от чистой "гексагональной" архитектуры. Кстати, причём тут гексагон?

Architecture is about intent

Одно из замечательных высказываний дядюшки Боба на тему архитектуры приложений - Architecture is about intent (Намерения - в архитектуре).

Что вы видите на этом скриншоте?

Не удивлюсь, если многие ответили "Типичное приложение на Django". Отлично! А что же делает это приложение? Вы вероятно телепат 80го уровня, если смогли ответить на этот вопрос правильно. Лично я не именю ни малейшего понятия - это скриншот первого попавшегося Django-приложения с Гитхаба.

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

Разгадка

Это один из этажей библиотеки Oodi в Хельсинки.

Надеюсь вам было несложно отгадать эту маленькую загадку и вы вынесли из неё главное: архитектура должна встречать нас с порога, буквально с момента окончания git clone.... Как здорово, когда код приложения организован таким образом, что предназначение того или иного файла или директории лежит на поверхности!

В "Гексагональной архитектуре", гексагон в частности призван упростить восприятие архитектуры. Мудрено? Пардон, сейчас всё будет продемонстрировано наглядно.

Hexagonal architecture of Ports and Adapters

"У нас Гексагональная архитектура портов и адаптеров" - с этой фразы начинается рассказ об архитектуре приложения новым членам команды. Далее мы показываем нечто Ктулхуподобное:

Изобретатель термина "Гексагональная архитектура" Алистар Кокбёрн (Alistair Cockburn) объясняя выбор названия акцентировал внимание на его графическом представлении:

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

Итак, на изображении мы видим:

Домен (предметная область) - это сердце приложения. Классы, методы, функции, константы и другие объекты домена повторяют язык предметной области. Например, правило Хабра

"Пользователь может голосовать за публикации, комментарии и карму других пользователей если его карма 5"

будет отображено именно здесь. И как вы наверняка поняли, в домене нет места HTTP, SQL, RabbitMQ, AWS и т.д. и т.п.

Зато всему этому празднику технологий есть место в адаптерах подсоединяемых к портам. Команды и запросы поступают в приложение через ведущие (driver) или API порты. Команды и запросы которые отдаёт приложение поступают в ведомые порты (driven port). Их также называют портами интерфейса поставщика услуг (Service Provider Interface, SPI).

Между портами и доменом сидят дирижёры - сервисы приложения (Application services). Они являются связующим звеном между сценариями пользования, доменом и ведомыми портами необходимыми для выполнения сценария. Также стоит упомянуть, что именно сервис приложения определяет, будет ли сценарий выполняться в рамках общей транзакции, или нет.

Всё это - и порты, и адаптеры и сервисы приложения и даже домен - слои архитектуры, состоящие из индивидуальных компонентов. Главной заповедью взаимодействия между слоями является "Зависимости всегда направлены от внешних слоёв к центру приложения". Например, адаптер может ссылаться на домен или другой адаптер, а домен ссылаться на адаптер - не может.

И... ВСЁ. Это - вся суть Гексагональной архитектуры портов и адаптеров. Она замечательно подходит для задач с обширной предметной областью. Для голого CRUDа а-ля HTTP интерфейс для базы данных, такая архитектура избыточна - Active Record вам в руки.

Давайте же засучим рукава и разберём на примере, как спроектировать Django-приложение по канонам гексагональной архитектуры.

Interlude

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

Во второй части вас ждёт реализация гексагональной архитектуры на знакомом нам всем примере. В первой части мы старались абстрагироваться от конкретных решений, будь то фреймворки или библиотеки. Последующий пример построен на основе Django и DRF с целью продемонстрировать, как можно вплести гексагональную архитектуру в фреймворк с устоявшимися традициями и архитектурными решениями. В приведённых примерах вырезаны некоторые необязательные участки и имеются допущения. Это сделано для того, чтобы мы могли сфокусироваться на важном и не отвлекались на второстепенные детали. Полностью исходный код примера доступен в репозитории https://github.com/basicWolf/hexagonal-architecture-django.

Upvote a post at Hubruhubr

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

Рейтинг публикации меняется путём голосования пользователей.

  1. Пользователь может проголосовать "ЗА" или "ПРОТИВ" публикации.

  2. Пользователь может голосовать если его карма 5.

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

С чего же начать работу? Конечно же с построения модели предметной области!

Domain model

Давайте ещё раз внимательно прочтём требования и подумаем, как описать "пользователя голосующего за публикацию"? Например (source):

# src/myapp/application/domain/model/voting_user.pyclass VotingUser:    id: UUID    voting_for_article_id: UUID    voted: bool    karma: int    def cast_vote(self, vote: Vote) -> CastArticleVoteResult:        ...

На первый взгляд - сомнительного вида творение. Но обратившись к деталям сценария мы убедимся, что этот набор данных - необходим и достаточен для голосования. Vote и CastArticleVoteResult - это также модели домена (source):

# src/myapp/application/domain/model/vote.py# Обозначает голос "За" или "Против"class Vote(Enum):    UP = 'up'    DOWN = 'down'

В свою очередь CastArticleVoteResult - это тип объединяющий оговорённые исходы сценария: ГолосПользователя, НедостаточноКармы, ПользовательУжеПроголосовалЗаПубликацию (source):

# src/myapp/application/domain/model/cast_article_vote_result.py...CastArticleVoteResult = Union[ArticleVote, InsufficientKarma, VoteAlreadyCast]

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

Ответ

(source)

# src/myapp/application/domain/model/article_vote.py@dataclassclass ArticleVote:    user_id: UUID    article_id: UUID    vote: Vote    id: UUID = field(default_factory=uuid4)

Но самое интересное будет происходить в теле метода cast_article_vote(). И начнём мы конечно же с тестов. Первый же тест нацелен на проверку успешно выполненного сценария (source):

def test_cast_vote_returns_article_vote(user_id: UUID, article_id: UUID):    voting_user = VotingUser(        user_id=user_id,        voting_for_article_id=article_id,        karma=10    )    result = voting_user.cast_vote(Vote.UP)    assert isinstance(result, ArticleVote)    assert result.vote == Vote.UP    assert result.article_id == article_id    assert result.user_id == user_id

Запускаем тест и... ожидаемый фейл. В лучших традициях ТДД мы начнём игру в пинг-понг с тестами и кодом, с каждым тестом дописывая сценарий до полной готовности (source):

MINIMUM_KARMA_REQUIRED_FOR_VOTING = 5...def cast_vote(self, vote: Vote) -> CastArticleVoteResult1:    if self.voted:        return VoteAlreadyCast(            user_id=self.id,            article_id=self.voting_for_article_id        )    if self.karma < MINIMUM_KARMA_REQUIRED_FOR_VOTING:        return InsufficientKarma(user_id=self.id)    self.voted = True    return ArticleVote(        user_id=self.id,        article_id=self.voting_for_article_id,        vote=vote    )

На этом мы закончим моделирование предметной области и приступим к написанию API приложения.

Driver port: Cast article vote use case

Как было сказано ранее, в гексагональной архитектуре, приложение управляется через API-порты.

Чтобы как-то дотянуться до доменной модели, в наше приложение нужно добавить ведущий порт CastArticleVotingtUseCase, который принимает ID пользователя, ID публикации, значение голоса: за или против и возвращает результат выполненного сценария (source):

# src/myapp/application/ports/api/cast_article_vote/cast_aticle_vote_use_case.pyclass CastArticleVoteUseCase(Protocol):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        raise NotImplementedError()

Все входные параметры сценария обёрнуты в единую структуру-команду CastArticleVoteCommand (source), а все возможные результаты объединены - это уже знакомая модель домена CastArticleVoteResult (source):

# src/myapp/application/ports/api/cast_article_vote/cast_article_vote_command.py@dataclassclass CastArticleVoteCommand:    user_id: UUID    article_id: UUID    vote: Vote

Работа с гексагональной архитектурой чем-то напоминает прищурившегося Леонардо ди Каприо с фразой "We need to go deeper". Набросав каркас сценария пользования, можно примкнуть к нему с двух сторон. Можно имплементировать сервис, который свяжет доменную модель и ведомые порты для выполнения сценария. Или заняться API адаптерами, которые вызывают этот сценарий. Давайте зайдём со стороны API и напишем HTTP адаптер с помощью Django Rest Framework.

HTTP API Adapter

Наш HTTP адаптер, или на языке Django и DRF - View, до безобразия прост. За исключением преобразований запроса и ответа, он умещается в несколько строк (source):

# src/myapp/application/adapter/api/http/article_vote_view.pyclass ArticleVoteView(APIView):    ...    def __init__(self, cast_article_vote_use_case: CastArticleVoteUseCase):        self.cast_article_vote_use_case = cast_article_vote_use_case        super().__init__()    def post(self, request: Request) -> Response:        cast_article_vote_command = self._read_command(request)        result = self.cast_article_vote_use_case.cast_article_vote(            cast_article_vote_command        )        return self._build_response(result)    ...

И как вы поняли, смысл всего этого сводится к

  1. Принять HTTP запрос, десериализировать и валидировать входные данные.

  2. Запустить сценарий пользования.

  3. Сериализовать и возвратить результат выполненного сценария.

Этот адаптер конечно же строился по кирпичику с применением практик TDD и использованием инструментов Django и DRF для тестирования view-шек. Ведь для теста достаточно построить запрос (request), скормить его адаптеру и проверить ответ (response). При этом мы полностью контролируем основную зависимость cast_article_vote_use_case: CastArticleVoteUseCase и можем внедрить на её место тестового двойника.

Например, давайте напишем тест для сценария, в котором пользователь пытается проголосовать повторно. Ожидаемо, что статус в ответе будет 409 CONFLICT (source):

# tests/test_myapp/application/adapter/api/http/test_article_vote_view.pydef test_post_article_vote_with_same_user_and_article_id_twice_returns_conflict(    arf: APIRequestFactory,    user_id: UUID,    article_id: UUID):    # В роли объекта реализующего сценарий выступает    # специализированный двойник, возвращающий при вызове    # .cast_article_vote() контролируемый результат.    # Можно и MagicMock, но нужно ли?    cast_article_use_case_mock = CastArticleVoteUseCaseMock(        returned_result=VoteAlreadyCast(            user_id=user_id,            article_id=article_id        )    )    article_vote_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_use_case_mock    )    response: Response = article_vote_view(        arf.post(            f'/article_vote',            {                'user_id': user_id,                'article_id': article_id,                'vote': Vote.UP.value            },            format='json'        )    )    assert response.status_code == HTTPStatus.CONFLICT    assert response.data == {        'status': 409,        'detail': f"User \"{user_id}\" has already cast a vote for article \"{article_id}\"",        'title': "Cannot cast a vote"    }

Адаптер получает на вход валидные данные, собирает из них команду и вызывает сценарий. Oднако, вместо продакшн-кода, этот вызов получает двойник, который тут же возвращает VoteAlreadyCast. Адаптеру же нужно правильно обработать этот результат и сформировать HTTP Response. Остаётся протестировать, соответствует ли сформированный ответ и его статус ожидаемым значениям.

Ещё раз попрошу заметить, насколько облегчённее становится тестирование, когда не нужно загружать всё приложение целиком. Адепты Django вспомнят о легковесном тестировании вьюшек посредством RequestFactory. Но гексагональная архитектура позволяет шагнуть дальше. Мы избавились от обезьяньих патчей и mock-обёрток конкретных классов. Мы легко управляем поведением зависимостей нашего View, ведь взаимодействие с ними происходит через абстрактный интерфейс. Всё это легко модифицировать и отлаживать.

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

Application services

Как дирижёр управляет оркестром исполняющим произведение, так и сервис приложения управляет доменом и ведомыми портами при выполнении сценария.

PostRatingService

С места в карьер погрузимся в имплементацию нашего сценария. В первом приближении сервис реализующий сценарий выглядит так (source):

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase  # имплементируем протокол явным образом):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        ...

Отлично, но откуда возьмётся голосующий пользователь? Тут и появляется первая SPI-зависимость GetVotingUserPort задача которой найти голосующего пользователя по его ID. Но как мы помним, доменная модель не занимается записью голоса в какое-либо долговременное хранилище вроде БД. Для этого понадобится ещё одна SPI-зависимость SaveArticleVotePort:

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase):    _get_voting_user_port: GetVotingUserPort    _save_article_vote_port: SaveArticleVotePort    # def __init__(...) # внедрение зависимостей oпустим, чтобы не раздувать листинг    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        voting_user = self._get_voting_user_port.get_voting_user(            user_id=command.user_id,            article_id=command.article_id        )        cast_vote_result = voting_user.cast_vote(command.vote)        if isinstance(cast_vote_result, ArticleVote):            self._save_article_vote_port.save_article_vote(cast_vote_result)        return cast_vote_result

Вы наверняка представили как выглядят интерфейсы этих SPI-зависимостей. Приведём один из интерфейсов здесь (source):

# src/myapp/application/ports/spi/save_article_vote_port.pyclass SaveArticleVotePort(Protocol):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        raise NotImplementedError()

За кадром мы конечно же сначала напишем тесты, а уже потом код :) При написании юнит-тестов роль SPI-адаптеров в тестах сервиса, как и в предыдущих примерах, играют дублёры. Но чтобы удержать сей опус в рамках статьи, позвольте оставить тесты в виде ссылки на исходник (source) и двинуться дальше.

SPI Ports and Adapters

Продолжим рассматривать SPI-порты и адаптеры на примере SaveArticleVotePort. К этому моменту можно было и забыть, что мы всё ещё находимся в рамках Django. Ведь до сих пор не было написано того, с чего обычно начинается любое Django-приложение - модель данных! Начнём с адаптера, который можно подключить в вышеуказанный порт (source):

# src/myapp/application/adapter/spi/persistence/repository/article_vote_repository.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import (    ArticleVoteEntity)from myapp.application.domain.model.article_vote import ArticleVotefrom myapp.application.ports.spi.save_article_vote_port import SaveArticleVotePortclass ArticleVoteRepository(    SaveArticleVotePort,):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        article_vote_entity = ArticleVoteEntity.from_domain_model(article_vote)        article_vote_entity.save()        return article_vote_entity.to_domain_model()

Вспомним, что паттерн "Репозиторий" подразумевает скрытие деталей и тонкостей работы с источником данных. "Но позвольте! - скажете Вы, - a где здесь Django?". Чтобы избежать путаницы со словом "Model", модель данных носит гордое название ArticleVoteEntity. Entity также подразумевает, что у неё имеется уникальный идентификатор (source):

# src/myapp/application/adapter/spi/persistence/entity/article_vote_entity.pyclass ArticleVoteEntity(models.Model):    ... # здесь объявлены константы VOTE_UP, VOTE_DOWN и VOTE_CHOICES    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)    user_id = models.UUIDField()    article_id = models.UUIDField()    vote = models.IntegerField(choices=VOTES_CHOICES)    ...    def from_domain_model(cls, article_vote: ArticleVote) -> ArticleVoteEntity:        ...    def to_domain_model(self) -> ArticleVote:        ...

Таким образом, всё что происходит в save_article_vote() - это создание Django-модели из доменной модели, сохранение её в БД, обратная конвертация и возврат доменной модели. Это поведение легко протестировать. Например, юнит тест удачного исхода выглядит так (source):

# tests/test_myapp/application/adapter/spi/persistence/repository/test_article_vote_repository.py@pytest.mark.django_dbdef test_save_article_vote_persists_to_database(    article_vote_id: UUID,    user_id: UUID,    article_id: UUID):    article_vote_repository = ArticleVoteRepository()    article_vote_repository.save_article_vote(        ArticleVote(            id=article_vote_id,            user_id=user_id,            article_id=article_id,            vote=Vote.UP        )    )    assert ArticleVoteEntity.objects.filter(        id=article_vote_id,        user_id=user_id,        article_id=article_id,        vote=ArticleVoteEntity.VOTE_UP    ).exists()

Одним из требований Django является декларация моделей в models.py. Это решается простым импортированием:

# src/myapp/models.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import ArticleVoteEntityfrom myapp.application.adapter.spi.persistence.entity.voting_user_entity import VotingUserEntity

Exceptions

Приложение почти готово!. Но вам не кажется, что мы кое-что упустили? Подсказка: Что произойдёт при голосовании, если ID пользователя или публикации будет указан неверно? Где-то в недрах Django вылетит исключение VotingUserEntity.DoesNotExist, что на поверхности выльется в неприятный HTTP 500 - Internal Server Error, хотя правильнее было бы вернуть HTTP 400 - Bad Request с телом, содержащим причину ошибки.

Ответ на вопрос, "В какой момент должно быть обработано это исключение?", вовсе не очевиден. С архитектурной точки зрения, ни API, ни домен не волнуют проблемы SPI-адаптеров. Максимум, что может сделать API с таким исключением - обработать его в общем порядке, а-ля except Exception:. С другой стороны SPI-порт может предоставить исключение-обёртку, в которую SPI-адаптер завернёт внутреннюю ошибку. А API может её поймать.

О, я слышу вас, дорогие адепты функционального программирования! "Какие исключения? В топку! Даёшь Either!". В ваших словах много правды и эта тема заслуживает отдельной статьи. В одном я же, я полностью соглашусь с вами - в домене не должно быть исключений!.

Например, в данной ситуации уместным будет исключение VotingUserNotFound (source) в которое оборачивается VotingUserEntity.DoesNotExist (source):

# src/myapp/application/adapter/spi/persistence/exceptions/voting_user_not_found.pyclass VotingUserNotFound(Exception):    def __init__(self, user_id: UUID):        super().__init__(user_id, f"User '{user_id}' not found")# ---# myapp/application/adapter/spi/persistence/repository/voting_user_repository.pyclass VotingUserRepository(GetVotingUserPort):    ...    def get_voting_user(self, user_id: UUID, article_id: UUID) -> VotingUser:        try:            # Код немного упрощён, в оригинале здесь происходит            # аннотация флагом "голосовал ли пользователь за статью".            # см. исходник            entity = VotingUserEntity.objects.get(id=user_id)        except VotingUserEntity.DoesNotExist as e:            raise VotingUserNotFound(user_id) from e        return self._to_domain_model(entity)

А вот теперь действительно, приложение почти готово! Осталось соединить все компоненты и точки входа.

Dependencies and application entry point

Традиционно точки входа и маршрутизация HTTP-запросов в Django-приложениях декларируется в urls.py. Всё что нам нужно сделать - это добавить запись в urlpatterns (source):

urlpatterns = [    path('article_vote', ArticleVoteView(...).as_view())]

Но погодите! Ведь ArticleVoteView требует зависимость имплементирующую CastArticleVoteUseCase. Это конечно же PostRatingService... которому в свою очередь требуются GetVotingUserPort и SaveArticleVotePort. Всю эту цепочку зависимостей удобно хранить и управлять из одного места - контейнера зависимостей (source):

# src/myapp/dependencies_container.py...def build_production_dependencies_container() -> Dict[str, Any]:    save_article_vote_adapter = ArticleVoteRepository()    get_vote_casting_user_adapter = VotingUserRepository()    cast_article_vote_use_case = PostRatingService(        get_vote_casting_user_adapter,        save_article_vote_adapter    )    article_vote_django_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_vote_use_case    )    return {        'article_vote_django_view': article_vote_django_view    }

Этот контейнер инициализируется на старте приложения в AppConfig.ready() (source):

# myapp/apps.pyclass MyAppConfig(AppConfig):    name = 'myapp'    container: Dict[str, Any]    def ready(self) -> None:        from myapp.dependencies_container import build_production_dependencies_container        self.container = build_production_dependencies_container()

И наконец urls.py:

app_config = django_apps.get_containing_app_config('myapp')article_vote_django_view = app_config.container['article_vote_django_view']urlpatterns = [    path('article_vote', article_vote_django_view)]

Inversion of Control Containers

Для реализации одного небольшого сценария нам понадобилось создать и связать четыре компонента. С каждым новым сценарием, число компонентов будет расти и количество связей будет увеличиваться в арифметической прогрессии. Как управлять этим зоопарком, когда приложение начнёт разрастаться до неприличных размеров? Тут на помощь приходят Контейнеры Инверсии Управления.

IoC-container - это фреймворк управляющий объектами и их зависимостями во время исполнения программы.

Spring был первым универсальным IoC-контейнером / фреймворком с которым я столкнулся на практике (для зануд: Micronaut - да!). Чего уж таить, я не сразу проникся заложенными в него идеями. По-настоящему оценить всю мощь автоматического связывания (autowiring) и сопутствующего функционала я смог лишь выстраивая приложение следуя практикам гексагональной архитектуры.

Представьте, насколько удобнее будет использование условного декоратора @Component, который при загрузке программы внесёт класс в реестр зависимостей и выстроит дерево зависимостей автоматически?

T.e. если зарегистрировать компоненты:

@Componentclass ArticleVoteRepository(    SaveArticleVotePort,):    ...@Componentclass VotingUserRepository(GetVotingUserPort):    ...

То IoC-container сможет инициализировать и внедрить их через конструктор в другой компонент:

```@Componentclass PostRatingService(    CastArticleVoteUseCase):    def __init__(        self,        get_voting_user_port: GetVotingUserPort,        save_article_vote_port: SaveArticleVotePort    ):        ...

К сожалению мне не приходилось иметь дела с подобным инструментарием в экосистеме Питона. Буду благодарен, если вы поделитесь опытом в комментариях!

Directory structure

Помните скриншот "типичного Django-приложения"? Сравните его с тем что получилось у нас:

Чувствуете разницу? Нам больше не нужно лезть в файлы в надежде разобраться, что же там лежит и для чего они предназначены. Более того, теперь даже структура тестов и кода приложения идентичны! Архитектура приложения видна невооружённым глазом и существует "на бумаге", а не только в голове у разработчиков приложения.

Interlude

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

Domain-Driven Design

Эрик Эванс (Eric Evans) популяризировал термин "Domain-Driven Design" в "большой синей книге" написанной в 2003м году. И всё заверте... Предметно-ориентированное проектирование - это методология разработки сложных систем, в которой во главу угла ставится понимание разработчиками предметной области путем общение с представителями (экспертами) предметной области и её моделирование в коде.

Мартин Фаулер (Martin Folwer) в своей статье рассуждая о заслугах Эванса подчёркивает, что в этой книге Эванс закрепил терминологию DDD, которой мы пользуемся и по сей день.

В частности, Эванс ввёл понятие об Универсальном Языке (Ubiquitous Language) - языке который разработчики с одной стороны и эксперты предметной области с другой, вырабатывают в процессе общения в течении всей жизни продукта. Невероятно сложно создать систему (а ведь смысл DDD - помочь нам проектировать именно сложные системы!) не понимая, для чего она предназначена и как ею пользуются.

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

- Майкл Крайтон, "Парк Юрского периода"

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

Другой важный термин - Ограниченный Контекст (Bounded Context) - автономные части предметной области с устоявшимися правилами, терминами и определениями. Простой пример: в онлайн магазине, модель "товар" несёт в себе совершенно разный смысл для отделов маркетинга, бухгалтерии, склада и логистики. Для связи моделей товара в этих контекстах достаточно наличие одинакового идентификатора (например UUID).

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

О DDD можно рассуждать и рассуждать. Эту тему не то что в одну статью, её и в толстенную книгу-то нелегко уместить. Приведу лишь несколько цитат, которые помогут перекинуть мостик между DDD и гексагональной архитектурой:

Предметная область - это сфера знаний или деятельности.

Модель - это система абстракций, представляющих определённый аспект предметной области.

Модель извлекает знания и предположения о предметной области и не является способом отобразить реальность.

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

Эти цитаты взяты из выступления Эрика Эванса на конференции DDD Europe 2019 года. Приглашаю вас насладиться этим выступлением, прежде чем вы введёте "DDD" в поиск Хабра и начнёте увлекательное падение в бездонную кроличью нору. По пути вас ждёт много открытий и куча набитых шишек. Помню один восхитительный момент: внезапно в голове сложилась мозаика и пришло озарение, что фундаментальные идеи DDD и Agile Manifesto имеют общие корни.

Hexagonal Architecture

Так причём же здесь Гексагональная архитектура? Я очень надеюсь, что внимательный читатель уже ответил на этот вопрос.

На заре Гексагональной архитектуры в 2005м году, Алистар Кокбёрн писал:

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

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

Становится просто связать модель предметной области в коде и "на бумаге" используя универсальный язык общения с экспертами. Универсальный язык обогащается с обеих сторон. При написании кода находятся и изменяются объекты, связи между ними и всё это перетекает обратно в модель предметной области.

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

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

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

Microservices

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

На десерт - короткое видео на тему от Дейва Фарли: The problem with microservices.

Outro

Спасибо вам уважаемый читатель. Спасибо, что не бросили меня в середине статьи и прошли со мной до конца. Надеюсь тема беседы вас заинтриговала и вы дерзнёте внедрить принципы гексагональной архитектуры и DDD в ваши проекты. Успехов и до новых встреч!

P.S.

Хотите проверить, насколько вы прониклись вышеизложенным? Тогда подумайте и ответьте, является ли поле VotingUser.voted оптимальным решением с точки зрения моделирования предметной области? А если нет, что бы вы предложили взамен?

Подробнее..

У Вас проблемы с legacy значит, Вам повезло! Распил монолита на PHP

06.01.2021 06:14:44 | Автор: admin

Вступление

Меня часто просят рассказать о работе с legacy-монолитами. Про микросервисную архитектуру и переход на нее говорят много, но редко упоминают о том, что проекты приходят ней после многих лет роста с монолитным приложением. Учебники по решению проблем не пишут. Чтобы поменять архитектуру живого решения, надо пройти через несколько этапов. Автор работал с разными проектами - и с полноценным multitenancy service-oriented REST architecture в Oracle, и с огромным монолитом, в репозитории которого были коммиты за десять лет. Эта статья - о темной стороне, о legacy-коде, и практических решениях проблем с монолитными приложениями на PHP.

Причины появления legacy

Есть две основные причины появления legacy-кода.

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

Вторая - технический долг, который создается специально. Руководство сокращает срок разработки ПО за счет отказа от проектирования, автоматического тестирования или code review, одобряет сторонние библиотеки, которые не поддерживаются, а разработчики не документируют сложную логику. Это встречается повсеместно и не зависит от количества денег на счету компании. Не стоит ругать плохих начальников. У них есть весомые причины поступать именно так.

У продуктов есть жизненный цикл, период большого спроса на популярные товары длится три-четыре месяца. Все лучшее конкуренты скопируют и сделают еще лучше, поэтому компании вынуждены регулярно выпускать новинки. Чтобы поддерживать объем выручки, новые продукты и новые версии выпускают каждые несколько месяцев, так продажи нового цикла компенсируют снижение продаж по товарам в конце цикла. По три-четыре крупных релиза в год делают и Apple, и Marvel, и в Oracle на рынке enterprise SAAS тоже квартальный релизный цикл. При этом, рецепта успеха не существует. 97% стартапов выкидывают наработки по своему продукту, и пробуют делать что-то новое, прежде чем найдут такой продукт, который у них покупают. Поэтому затраты на разработку MVP в стартапах максимально сокращают.

У вас проблемы с легаси - значит, вам повезло!

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

Проблемы?

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

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

Что делать тем, кому повезло?

Начинать надо с тестирования

Серьезные изменения кода всегда приводят к неожиданным проблемам. Без надежного тестирования сбои приложения приведут к потере выручки и снижению продаж.

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

Обновление версии языка

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

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

Составить список проблемы совместимости с новой версией PHP помогут утилиты статического анализа.

Rector поможет решить простые случаи несовместимости с новой версией, автоматически обновив часть кода.

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

Phan показывает использование в коде лексических конструкций, которые убраны из новых версий PHP.

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

Обновление версии платформы или языка в таком случае выполняется достаточно быстро. Автор был инициатором обновления PHP с 5-ой версии на 7-ую для приложения с очень большим объемом кода, и эта задача была успешно выполнена командой за три недели.

Переход от монолита к сервисной архитектуре

Иногда проекты вырастают. Продукты стали успешными на рынке, и регулярно выпускаются. По законам Лемана сложность ПО растёт, функциональное содержание расширяется, вместе с ними штат разработчиков и объем кода постоянно увеличиваются. Замена устаревшего ПО в бюджет разработки не закладывается, чтобы улучшить финансовые результаты, поэтому качество программ ухудшается. Размер git-репозитория может исчисляться гигабайтами. Постепенно скорость разработки уменьшается, и когда разработчики перестают успевать выпускать ПО для новых продуктов, монолит решают разделять.

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

К счастью, слона можно съесть по кусочкам - отделять от монолита модули, не переписывая код заново, зафиксировать API, а затем превращать их в сервисы. Сначала части кода приложения надо выделить в отдельные пакеты, а затем из пакетов можно будет создавать сервисы.

Перенос кода в пакеты открывает ряд возможностей:

  • можно сократить размер репозитория приложения,

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

  • можно описать зависимости между своими модулями и использовать composer для управления зависимостями и версиями своих пакетов,

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

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

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

Разделение приложения на пакеты

Допустим, есть приложение на PHP, которое предоставляет клиентский API. Начинать любые изменения надо с процедур тестирования и релиза, которые включают план отката. Эти процедуры называют release, control, validation и DevOps. В активно развивающихся проектах тестирование и выкладка отработаны. В этом случае надо начинать разделять приложение с определения таких ограниченных контекстов, которые логично выделить в отдельные модули и сервисы.

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

Создание отдельного модуля - это цикл из пяти подзадач:

1. Выбрать небольшой функционал для переноса в модуль - например, изменение размера изображений;

2. Определить API модуля - написать интерфейс, доступный приложению;

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

4. Скопировать в модуль старый код и инвертировать в коде модуля зависимости через границу модуля, без рефакторинга или переписывания всего кода;

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

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

Начать создавать пакеты можно в локальном каталоге, а для полноценной сборки и развертывания стоит создать собственный репозиторий пакетов, такой, как Packeton, и перенести код модулей в собственные git-репозитории. Так же, можно использовать платный репозиторий Private Packagist.

Как создать composer-пакет в приложении и зарегистрировать его как сервис в IoC-контейнере, можно посмотреть здесь: до изменений, после изменений, diff.

В примерах используется composer для управления зависимостями пакетов и Symfony Dependency Injection как IoC-контейнер для сервисов. У Вас может быть другой контейнер. Если в приложении нет IoC-контейнера, придется делать рефакторинг и реализовать внедрение зависимостей. Простейший пример добавления IoC-контейнера в приложение.

Решение проблем со связанностью кода

Есть два типа связанности:

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

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

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

1. Расширение классов, реализация интерфейсов, использование трейтов, когда декларация структур используется через границу будущего модуля.

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

Основные алгоритмы расцепления связанности:

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

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

  • Наследование от внешних классов с зависимостями надо превратить в композицию с помощью адаптеров, которые внедряются как сервисы.

  • Для защищенных свойств, которые используются в дочернем классе, надо сделать getter-методы, а для защищенных методов надо создать прокси-методы.

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

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

2. Статические вызовы.

Синтаксис PHP допускает вызов статических методов у объектов как методов класса (пример). Если Вы выносите в пакет или обычную функцию или класс, у которого есть статический метод, эти функции/методы нужно добавить в публичное API пакета (пример, diff).

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

Ссылки: пример прямого статического вызова, пример инверсии зависимости статического вызова через внедрение сервиса, diff коммита.

Если несколько методов из разных классов используются вместе, для них можно создать сервис-фасад.

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

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

5. Использование глобальных констант и констант классов.

Возьмем пример: в приложении есть класс, который нарушает Single Responsibility Principle, и содержит обращения к константе другого класса. Наша задача - вынести первый класс в пакет без рефакторинга второго класса, потому что рефакторинг потребует изменения всего остального кода, в котором используется константа. Надо избавиться от прямого обращения к константе.

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

Ссылки: до изменений, после изменений, diff, декларация инъекции константы в контейнере.

6. Динамическое разрешение имен через строковые операции.

Пример: $model = new ($modelName . Class);

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

Эту конструкцию можно попробовать переписать в switch-структуру со статическим списком классов. К счастью, в приложениях подобный код встречается редко.

Оптимизация

В больших приложениях количество сервисов в IoC-контейнере бывает очень большим. Если в пакет выносится большой объем кода, у него могут быть десятки зависимостей. При обработке клиентских вызовов обычно создается только небольшая часть сервисов. Однако, при передаче зависимостей в конструктор класса контейнер будет создавать все перечисленные сервисы.

Есть несколько способов решения этой задачи:

  1. Сервисы, которые передаются в пакет, можно объявить как lazy.

  2. Объект API пакета можно объявить как Service Subscriber.

  3. Разделить API пакета на несколько сервисов.

Самый гибкий способ - это реализация Service Subscriber. Когда сервис объявляется подписчиком, можно реализовать в пакете вызов внешних сервисов по мере обращения к ним в пакете. Примеры: код до изменений, где используется один из нескольких классов, и код после переноса в пакет c инверсией зависимостей, где нужный сервис создается по требованию. Diff.

Service-Oriented Architecture

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

У каждого пакета зафиксирован публичный API. На основе этого API можно создать сервис с restful-протоколом. Код нового сервиса - это код пакета, вокруг которого написан достаточно стандартный роутинг, запись логов, и прочий инфраструктурный код. А в старом коде вместо кода пакета появляется адаптер для http-вызовов через curl.

При создании отдельных внутренних приложений-сервисов надо решить две задачи:

  1. Детальное протоколирование вызовов всех сервисов. Каждому клиентскому запросу надо присваивать уникальный ID вызова, который передается во все сервисы при вызовах внутренних API, и каждый вызов сервиса надо протоколировать. Надо иметь возможность отследить вызовы сервисов по цепочке.

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

Заключение

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

Подробнее..

Aiohttp Dependency Injector руководство по применению dependency injection

02.08.2020 22:16:32 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Продолжаю серию руководств по применению Dependency Injector для построения приложений.

В этом руководстве хочу показать как применять Dependency Injector для разработки aiohttp приложений.

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовка окружения
  3. Структура проекта
  4. Установка зависимостей
  5. Минимальное приложение
  6. Giphy API клиент
  7. Сервис поиска
  8. Подключаем поиск
  9. Немного рефакторинга
  10. Добавляем тесты
  11. Заключение

Завершенный проект можно найти на Github.

Для старта необходимо иметь:

  • Python 3.5+
  • Virtual environment

И желательно иметь:

  • Начальные навыки разработки с помощью aiohttp
  • Общее представление о принципе dependency injection


Что мы будем строить?





Мы будем строить REST API приложение, которое ищет забавные гифки на Giphy. Назовем его Giphy Navigator.

Как работает Giphy Navigator?

  • Клиент отправляет запрос указывая что искать и сколько результатов вернуть.
  • Giphy Navigator возвращает ответ в формате json.
  • Ответ включает:
    • поисковый запрос
    • количество результатов
    • список url гифок

Пример ответа:

{    "query": "Dependency Injector",    "limit": 10,    "gifs": [        {            "url": "https://giphy.com/gifs/boxes-dependent-swbf2-6Eo7KzABxgJMY"        },        {            "url": "https://giphy.com/gifs/depends-J56qCcOhk6hKE"        },        {            "url": "https://giphy.com/gifs/web-series-ccstudios-bro-dependent-1lhU8KAVwmVVu"        },        {            "url": "https://giphy.com/gifs/TheBoysTV-friends-friend-weneedeachother-XxR9qcIwcf5Jq404Sx"        },        {            "url": "https://giphy.com/gifs/netflix-a-series-of-unfortunate-events-asoue-9rgeQXbwoK53pcxn7f"        },        {            "url": "https://giphy.com/gifs/black-and-white-sad-skins-Hs4YzLs2zJuLu"        },        {            "url": "https://giphy.com/gifs/always-there-for-you-i-am-here-PlayjhCco9jHBYrd9w"        },        {            "url": "https://giphy.com/gifs/stream-famous-dollar-YT2dvOByEwXCdoYiA1"        },        {            "url": "https://giphy.com/gifs/i-love-you-there-for-am-1BhGzgpZXYWwWMAGB1"        },        {            "url": "https://giphy.com/gifs/life-like-twerk-9hlnWxjHqmH28"        }    ]}

Подготовим окружение


Начнём с подготовки окружения.

В первую очередь нам нужно создать папку проекта и virtual environment:

mkdir giphynav-aiohttp-tutorialcd giphynav-aiohttp-tutorialpython3 -m venv venv

Теперь давайте активируем virtual environment:

. venv/bin/activate

Окружение готово, теперь займемся структурой проекта.

Структура проекта


В этом разделе организуем структуру проекта.

Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми.

Начальная структура:

./ giphynavigator/    __init__.py    application.py    containers.py    views.py venv/ requirements.txt

Установка зависимостей


Пришло время установить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • aiohttp веб фреймворк
  • aiohttp-devtools библиотека-помогатор, которая предоставляет сервер для разработки с live-перезагрузкой
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest-aiohttp библиотека-помогатор для тестирования aiohttp приложений
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectoraiohttpaiohttp-devtoolspyyamlpytest-aiohttppytest-cov

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

pip install -r requirements.txt

Дополнительно установим httpie. Это HTTP клиент для командной строки. Мы будем
использовать его для ручного тестирования API.

Выполним в терминале:

pip install httpie

Зависимости установлены. Теперь построим минимальное приложение.

Минимальное приложение


В этом разделе построим минимальное приложение. У него будет эндпоинт, который будет возвращать пустой ответ.

Отредактируем views.py:

"""Views module."""from aiohttp import webasync def index(request: web.Request) -> web.Response:    query = request.query.get('query', 'Dependency Injector')    limit = int(request.query.get('limit', 10))    gifs = []    return web.json_response(        {            'query': query,            'limit': limit,            'gifs': gifs,        },    )

Теперь добавим контейнер зависимостей (дальше просто контейнер). Контейнер будет содержать все компоненты приложения. Добавим первые два компонента. Это aiohttp приложение и представление index.

Отредактируем containers.py:

"""Application containers module."""from dependency_injector import containersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    index_view = aiohttp.View(views.index)

Теперь нам нужно создать фабрику aiohttp приложения. Ее обычно называют
create_app(). Она будет создавать контейнер. Контейнер будет использован для создания aiohttp приложения. Последним шагом настроим маршрутизацию мы назначим представление index_view из контейнера обрабатывать запросы к корню "/" нашего приложения.

Отредактируем application.py:

"""Application module."""from aiohttp import webfrom .containers import ApplicationContainerdef create_app():    """Create and return aiohttp application."""    container = ApplicationContainer()    app: web.Application = container.app()    app.container = container    app.add_routes([        web.get('/', container.index_view.as_view()),    ])    return app

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Теперь мы готовы запустить наше приложение:

Выполните команду в терминале:

adev runserver giphynavigator/application.py --livereload

Вывод должен выглядеть так:

[18:52:59] Starting aux server at http://localhost:8001 [18:52:59] Starting dev server at http://localhost:8000 

Используем httpie чтобы проверить работу сервера:

http http://127.0.0.1:8000/

Вы увидите:

HTTP/1.1 200 OKContent-Length: 844Content-Type: application/json; charset=utf-8Date: Wed, 29 Jul 2020 21:01:50 GMTServer: Python/3.8 aiohttp/3.6.2{    "gifs": [],    "limit": 10,    "query": "Dependency Injector"}

Минимальное приложение готово. Давайте подключим Giphy API.

Giphy API клиент


В этом разделе мы интегрируем наше приложение с Giphy API. Мы создадим собственный API клиент используя клиентскую часть aiohttp.

Создайте пустой файл giphy.py в пакете giphynavigator:

./ giphynavigator/    __init__.py    application.py    containers.py    giphy.py    views.py venv/ requirements.txt

и добавьте в него следующие строки:

"""Giphy client module."""from aiohttp import ClientSession, ClientTimeoutclass GiphyClient:    API_URL = 'http://api.giphy.com/v1'    def __init__(self, api_key, timeout):        self._api_key = api_key        self._timeout = ClientTimeout(timeout)    async def search(self, query, limit):        """Make search API call and return result."""        if not query:            return []        url = f'{self.API_URL}/gifs/search'        params = {            'q': query,            'api_key': self._api_key,            'limit': limit,        }        async with ClientSession(timeout=self._timeout) as session:            async with session.get(url, params=params) as response:                if response.status != 200:                    response.raise_for_status()                return await response.json()

Теперь нам нужно добавить GiphyClient в контейнер. У GiphyClient есть две зависимости, которые нужно передать при его создании: API ключ и таймаут запроса. Для этого нам нужно будет воспользоваться двумя новыми провайдерами из модуля dependency_injector.providers:

  • Провайдер Factory будет создавать GiphyClient.
  • Провайдер Configuration будет передавать API ключ и таймаут GiphyClient.

Отредактируем containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    config = providers.Configuration()    giphy_client = providers.Factory(        giphy.GiphyClient,        api_key=config.giphy.api_key,        timeout=config.giphy.request_timeout,    )    index_view = aiohttp.View(views.index)

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

Сначала используем, потом задаем значения.

Теперь давайте добавим файл конфигурации.
Будем использовать YAML.

Создайте пустой файл config.yml в корне проекта:

./ giphynavigator/    __init__.py    application.py    containers.py    giphy.py    views.py venv/ config.yml requirements.txt

И заполните его следующими строками:

giphy:  request_timeout: 10

Для передачи API ключа мы будем использовать переменную окружения GIPHY_API_KEY .

Теперь нам нужно отредактировать create_app() чтобы сделать 2 действие при старте приложения:

  • Загрузить конфигурацию из config.yml
  • Загрузить API ключ из переменной окружения GIPHY_API_KEY

Отредактируйте application.py:

"""Application module."""from aiohttp import webfrom .containers import ApplicationContainerdef create_app():    """Create and return aiohttp application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.config.giphy.api_key.from_env('GIPHY_API_KEY')    app: web.Application = container.app()    app.container = container    app.add_routes([        web.get('/', container.index_view.as_view()),    ])    return app

Теперь нам нужно создать API ключ и установить его в переменную окружения.

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

export GIPHY_API_KEY=wBJ2wZG7SRqfrU9nPgPiWvORmloDyuL0

Для создания собственного ключа Giphy API следуйте этому руководству.

Создание Giphy API клиента и установка конфигурации завершена. Давайте перейдем к сервису поиска.

Сервис поиска


Пришло время добавить сервис поиска SearchService. Он будет:

  • Выполнять поиск
  • Форматировать полученный ответ

SearchService будет использовать GiphyClient.

Создайте пустой файл services.py в пакете giphynavigator:

./ giphynavigator/    __init__.py    application.py    containers.py    giphy.py    services.py    views.py venv/ requirements.txt

и добавьте в него следующие строки:

"""Services module."""from .giphy import GiphyClientclass SearchService:    def __init__(self, giphy_client: GiphyClient):        self._giphy_client = giphy_client    async def search(self, query, limit):        """Search for gifs and return formatted data."""        if not query:            return []        result = await self._giphy_client.search(query, limit)        return [{'url': gif['url']} for gif in result['data']]

При создании SearchService нужно передавать GiphyClient. Мы укажем это при добавлении SearchService в контейнер.

Отредактируем containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    config = providers.Configuration()    giphy_client = providers.Factory(        giphy.GiphyClient,        api_key=config.giphy.api_key,        timeout=config.giphy.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        giphy_client=giphy_client,    )    index_view = aiohttp.View(views.index)

Создание сервиса поиска SearchService завершено. В следующем разделе мы подключим его к нашему представлению.

Подключаем поиск


Теперь мы готовы чтобы поиск заработал. Давайте используем SearchService в index представлении.

Отредактируйте views.py:

"""Views module."""from aiohttp import webfrom .services import SearchServiceasync def index(        request: web.Request,        search_service: SearchService,) -> web.Response:    query = request.query.get('query', 'Dependency Injector')    limit = int(request.query.get('limit', 10))    gifs = await search_service.search(query, limit)    return web.json_response(        {            'query': query,            'limit': limit,            'gifs': gifs,        },    )

Теперь изменим контейнер чтобы передавать зависимость SearchService в представление index при его вызове.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    config = providers.Configuration()    giphy_client = providers.Factory(        giphy.GiphyClient,        api_key=config.giphy.api_key,        timeout=config.giphy.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        giphy_client=giphy_client,    )    index_view = aiohttp.View(        views.index,        search_service=search_service,    )

Убедитесь что приложение работает или выполните:

adev runserver giphynavigator/application.py --livereload

и сделайте запрос к API в терминале:

http http://localhost:8000/ query=="wow,it works" limit==5

Вы увидите:

HTTP/1.1 200 OKContent-Length: 850Content-Type: application/json; charset=utf-8Date: Wed, 29 Jul 2020 22:22:55 GMTServer: Python/3.8 aiohttp/3.6.2{    "gifs": [        {            "url": "https://giphy.com/gifs/discoverychannel-nugget-gold-rush-rick-ness-KGGPIlnC4hr4u2s3pY"        },        {            "url": "https://giphy.com/gifs/primevideoin-ll1hyBS2IrUPLE0E71"        },        {            "url": "https://giphy.com/gifs/jackman-works-jackmanworks-l4pTgQoCrmXq8Txlu"        },        {            "url": "https://giphy.com/gifs/cat-massage-at-work-l46CzMaOlJXAFuO3u"        },        {            "url": "https://giphy.com/gifs/everwhatproductions-fun-christmas-3oxHQCI8tKXoeW4IBq"        },    ],    "limit": 10,    "query": "wow,it works"}



Поиск работает.

Немного рефакторинга


Наше представление index содержит два hardcoded значения:

  • Поисковый запрос по умолчанию
  • Лимит количества результатов

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

Отредактируйте views.py:

"""Views module."""from aiohttp import webfrom .services import SearchServiceasync def index(        request: web.Request,        search_service: SearchService,        default_query: str,        default_limit: int,) -> web.Response:    query = request.query.get('query', default_query)    limit = int(request.query.get('limit', default_limit))    gifs = await search_service.search(query, limit)    return web.json_response(        {            'query': query,            'limit': limit,            'gifs': gifs,        },    )

Теперь нам нужно чтобы эти значения передавались при вызове. Давайте обновим контейнер.

Отредактируйте containers.py:

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    config = providers.Configuration()    giphy_client = providers.Factory(        giphy.GiphyClient,        api_key=config.giphy.api_key,        timeout=config.giphy.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        giphy_client=giphy_client,    )    index_view = aiohttp.View(        views.index,        search_service=search_service,        default_query=config.search.default_query,        default_limit=config.search.default_limit,    )

Теперь давайте обновим конфигурационный файл.

Отредактируйте config.yml:

giphy:  request_timeout: 10search:  default_query: "Dependency Injector"  default_limit: 10

Рефакторинг закончен. Мы сделали наше приложение чище перенесли hardcoded значения в конфигурацию.

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

Добавляем тесты


Было бы неплохо добавить несколько тестов. Давай сделаем это. Мы будем использовать pytest и coverage.

Создайте пустой файл tests.py в пакете giphynavigator:

./ giphynavigator/    __init__.py    application.py    containers.py    giphy.py    services.py    tests.py    views.py venv/ requirements.txt

и добавьте в него следующие строки:

"""Tests module."""from unittest import mockimport pytestfrom giphynavigator.application import create_appfrom giphynavigator.giphy import GiphyClient@pytest.fixturedef app():    return create_app()@pytest.fixturedef client(app, aiohttp_client, loop):    return loop.run_until_complete(aiohttp_client(app))async def test_index(client, app):    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)    giphy_client_mock.search.return_value = {        'data': [            {'url': 'https://giphy.com/gif1.gif'},            {'url': 'https://giphy.com/gif2.gif'},        ],    }    with app.container.giphy_client.override(giphy_client_mock):        response = await client.get(            '/',            params={                'query': 'test',                'limit': 10,            },        )    assert response.status == 200    data = await response.json()    assert data == {        'query': 'test',        'limit': 10,        'gifs': [            {'url': 'https://giphy.com/gif1.gif'},            {'url': 'https://giphy.com/gif2.gif'},        ],    }async def test_index_no_data(client, app):    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)    giphy_client_mock.search.return_value = {        'data': [],    }    with app.container.giphy_client.override(giphy_client_mock):        response = await client.get('/')    assert response.status == 200    data = await response.json()    assert data['gifs'] == []async def test_index_default_params(client, app):    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)    giphy_client_mock.search.return_value = {        'data': [],    }    with app.container.giphy_client.override(giphy_client_mock):        response = await client.get('/')    assert response.status == 200    data = await response.json()    assert data['query'] == app.container.config.search.default_query()    assert data['limit'] == app.container.config.search.default_limit()

Теперь давайте запустим тестирование и проверим покрытие:

py.test giphynavigator/tests.py --cov=giphynavigator

Вы увидите:

platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1plugins: cov-2.10.0, aiohttp-0.3.0, asyncio-0.14.0collected 3 itemsgiphynavigator/tests.py ...                                     [100%]---------- coverage: platform darwin, python 3.8.3-final-0 -----------Name                            Stmts   Miss  Cover---------------------------------------------------giphynavigator/__init__.py          0      0   100%giphynavigator/__main__.py          5      5     0%giphynavigator/application.py      10      0   100%giphynavigator/containers.py       10      0   100%giphynavigator/giphy.py            16     11    31%giphynavigator/services.py          9      1    89%giphynavigator/tests.py            35      0   100%giphynavigator/views.py             7      0   100%---------------------------------------------------TOTAL                              92     17    82%

Обратите внимание как мы заменяем giphy_client моком с помощью метода .override(). Таким образом можно переопределить возвращаемое значения любого провайдера.

Работа закончена. Теперь давайте подведем итоги.

Заключение


Мы построили aiohttp REST API приложение применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

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

"""Application containers module."""from dependency_injector import containers, providersfrom dependency_injector.ext import aiohttpfrom aiohttp import webfrom . import giphy, services, viewsclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    app = aiohttp.Application(web.Application)    config = providers.Configuration()    giphy_client = providers.Factory(        giphy.GiphyClient,        api_key=config.giphy.api_key,        timeout=config.giphy.request_timeout,    )    search_service = providers.Factory(        services.SearchService,        giphy_client=giphy_client,    )    index_view = aiohttp.View(        views.index,        search_service=search_service,        default_query=config.search.default_query,        default_limit=config.search.default_limit,    )

Что дальше?


Подробнее..

Мониторинг демон на Asyncio Dependency Injector руководство по применению dependency injection

09.08.2020 08:06:15 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это еще одно руководство по построению приложений с помощью Dependency Injector.

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

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Проверка инструментов
  3. Структура проекта
  4. Подготовка окружения
  5. Логирование и конфигурация
  6. Диспетчер
  7. Мониторинг example.com
  8. Мониторинг httpbin.org
  9. Тесты
  10. Заключение

Завершенный проект можно найти на Github.

Для старта желательно иметь:

  • Начальные знания по asyncio
  • Общее представление о принципе dependency injection

Что мы будем строить?


Мы будем строить мониторинг демон, который будет следить за доступом к веб-сервисам.

Демон будет посылать запросы к example.com и httpbin.org каждые несколько секунд. При получении ответа он будет записывать в лог такие данные:

  • Код ответа
  • Количество байт в ответе
  • Время, затраченное на выполнение запроса



Проверка инструментов


Мы будем использовать Docker и docker-compose. Давайте проверим, что они установлены:

docker --versiondocker-compose --version

Вывод должен выглядеть приблизительно так:

Docker version 19.03.12, build 48a66213fedocker-compose version 1.26.2, build eefe0d31

Если Docker или docker-compose не установлены, их нужно установить перед тем как продолжить. Следуйте этим руководствам:


Инструменты готовы. Переходим к структуре проекта.

Структура проекта


Создаем папку проекта и переходим в нее:

mkdir monitoring-daemon-tutorialcd monitoring-daemon-tutorial

Теперь нам нужно создать начальную структуру проекта. Создаем файлы и папки следуя структуре ниже. Все файлы пока будут пустыми. Мы наполним их позже.

Начальная структура проекта:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py config.yml docker-compose.yml Dockerfile requirements.txt

Начальная структура проекта готова. Мы расширим ее с следующих секциях.

Дальше нас ждет подготовка окружения.

Подготовка окружения


В этом разделе мы подготовим окружение для запуска нашего демона.

Для начала нужно определить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • aiohttp веб фреймворк (нам нужен только http клиент)
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-asyncio библиотека-помогатор для тестирования asyncio приложений
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectoraiohttppyyamlpytestpytest-asynciopytest-cov

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

pip install -r requirements.txt

Далее создаем Dockerfile. Он будет описывать процесс сборки и запуска нашего демона. Мы будем использовать python:3.8-buster в качестве базового образа.

Добавим следующие строки в файл Dockerfile:

FROM python:3.8-busterENV PYTHONUNBUFFERED=1WORKDIR /codeCOPY . /code/RUN apt-get install openssl \ && pip install --upgrade pip \ && pip install -r requirements.txt \ && rm -rf ~/.cacheCMD ["python", "-m", "monitoringdaemon"]

Последним шагом определим настройки docker-compose.

Добавим следующие строки в файл docker-compose.yml:

version: "3.7"services:  monitor:    build: ./    image: monitoring-daemon    volumes:      - "./:/code"

Все готово. Давайте запустим сборку образа и проверим что окружение настроено верно.

Выполним в терминале:

docker-compose build

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

Successfully built 5b4ee5e76e35Successfully tagged monitoring-daemon:latest

После того как процесс сборки завершен запустим контейнер:

docker-compose up

Вы увидите:

Creating network "monitoring-daemon-tutorial_default" with the default driverCreating monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitoring-daemon-tutorial_monitor_1 exited with code 0

Окружение готово. Контейнер запускается и завершает работу с кодом 0.

Следующим шагом мы настроим логирование и чтение файла конфигурации.

Логирование и конфигурация


В этом разделе мы настроим логирование и чтение файла конфигурации.

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

Добавим первые два компонента. Это объект конфигурации и функция настройки логирования.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )

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

Сначала используем, потом задаем значения.

Настройки логирования будут содержаться в конфигурационном файле.

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"

Теперь определим функцию, которая будет запускать наш демон. Её обычно называют main(). Она будет создавать контейнер. Контейнер будет использован для чтения конфигурационного файла и вызова функции настройки логирования.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Логирование и чтение конфигурации настроено. В следующем разделе мы создадим диспетчер мониторинговых задач.

Диспетчер


Пришло время добавить диспетчер мониторинговых задач.

Диспетчер будет содержать список мониторинговых задач и контролировать их выполнение. Он будет выполнять каждую задачу в соответствии с расписанием. Класс Monitor базовый класс для мониторинговых задач. Для создания конкретных задач нужно добавлять дочерние классы и реализовывать метод check().


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

Создадим dispatcher.py и monitors.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

Добавим следующие строки в файл monitors.py:

"""Monitors module."""import loggingclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()

и в файл dispatcher.py:

""""Dispatcher module."""import asyncioimport loggingimport signalimport timefrom typing import Listfrom .monitors import Monitorclass Dispatcher:    def __init__(self, monitors: List[Monitor]) -> None:        self._monitors = monitors        self._monitor_tasks: List[asyncio.Task] = []        self._logger = logging.getLogger(self.__class__.__name__)        self._stopping = False    def run(self) -> None:        asyncio.run(self.start())    async def start(self) -> None:        self._logger.info('Starting up')        for monitor in self._monitors:            self._monitor_tasks.append(                asyncio.create_task(self._run_monitor(monitor)),            )        asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.stop)        asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.stop)        await asyncio.gather(*self._monitor_tasks, return_exceptions=True)        self.stop()    def stop(self) -> None:        if self._stopping:            return        self._stopping = True        self._logger.info('Shutting down')        for task, monitor in zip(self._monitor_tasks, self._monitors):            task.cancel()        self._logger.info('Shutdown finished successfully')    @staticmethod    async def _run_monitor(monitor: Monitor) -> None:        def _until_next(last: float) -> float:            time_took = time.time() - last            return monitor.check_every - time_took        while True:            time_start = time.time()            try:                await monitor.check()            except asyncio.CancelledError:                break            except Exception:                monitor.logger.exception('Error executing monitor check')            await asyncio.sleep(_until_next(last=time_start))

Диспетчер нужно добавить в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Каждый компонент добавляется в контейнер.

В завершении нам нужно обновить функцию main(). Мы получим диспетчер из контейнера и вызовем его метод run().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main() -> None:    """Run the application."""    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.configure_logging()    dispatcher = container.dispatcher()    dispatcher.run()if __name__ == '__main__':    main()

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

Выполним в терминале:

docker-compose up

Вывод должен выглядеть так:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 16:12:35,772] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutting downmonitor_1  | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutdown finished successfullymonitoring-daemon-tutorial_monitor_1 exited with code 0

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

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

Мониторинг example.com


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Мы начнем с расширения нашей модели классов новым типом мониторинговой задачи HttpMonitor.

HttpMonitor это дочерний класс Monitor. Мы реализуем метод check(). Он будет отправлять HTTP запрос и логировать полученный ответ. Детали выполнения HTTP запроса будут делегированы классу HttpClient.


Сперва добавим HttpClient.

Создадим файл http.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py config.yml docker-compose.yml Dockerfile requirements.txt

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

"""Http client module."""from aiohttp import ClientSession, ClientTimeout, ClientResponseclass HttpClient:    async def request(self, method: str, url: str, timeout: int) -> ClientResponse:        async with ClientSession(timeout=ClientTimeout(timeout)) as session:            async with session.request(method, url) as response:                return response

Далее нужно добавить HttpClient в контейнер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            # TODO: add monitors        ),    )

Теперь мы готовы добавить HttpMonitor. Добавим его в модуль monitors.

Отредактируем monitors.py:

"""Monitors module."""import loggingimport timefrom typing import Dict, Anyfrom .http import HttpClientclass Monitor:    def __init__(self, check_every: int) -> None:        self.check_every = check_every        self.logger = logging.getLogger(self.__class__.__name__)    async def check(self) -> None:        raise NotImplementedError()class HttpMonitor(Monitor):    def __init__(            self,            http_client: HttpClient,            options: Dict[str, Any],    ) -> None:        self._client = http_client        self._method = options.pop('method')        self._url = options.pop('url')        self._timeout = options.pop('timeout')        super().__init__(check_every=options.pop('check_every'))    @property    def full_name(self) -> str:        return '{0}.{1}(url="{2}")'.format(__name__, self.__class__.__name__, self._url)    async def check(self) -> None:        time_start = time.time()        response = await self._client.request(            method=self._method,            url=self._url,            timeout=self._timeout,        )        time_end = time.time()        time_took = time_end - time_start        self.logger.info(            'Response code: %s, content length: %s, request took: %s seconds',            response.status,            response.content_length,            round(time_took, 3)        )

У нас все готово для добавления проверки http://example.com. Нам нужно сделать два изменения в контейнере:

  • Добавить фабрику example_monitor.
  • Передать example_monitor в диспетчер.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,        ),    )

Провайдер example_monitor имеет зависимость от значений конфигурации. Давайте добавим эти значения:

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5

Все готово. Запускаем демон и проверяем работу.

Выполняем в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 17:06:41,965] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 17:06:42,033] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.067 secondsmonitor_1  |monitor_1  | [2020-08-08 17:06:47,040] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.073 seconds

Наш демон может следить за наличием доступа к http://example.com.

Давайте добавим мониторинг https://httpbin.org.

Мониторинг httpbin.org


В этом разделе мы добавим мониторинговую задачу, которая будет следить за доступом к http://example.com.

Добавление мониторинговой задачи для https://httpbin.org будет сделать легче, так как все компоненты уже готовы. Нам просто нужно добавить новый провайдер в контейнер и обновить конфигурацию.

Отредактируем containers.py:

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )

Отредактируем config.yml:

log:  level: "INFO"  format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"monitors:  example:    method: "GET"    url: "http://example.com"    timeout: 5    check_every: 5  httpbin:    method: "GET"    url: "https://httpbin.org/get"    timeout: 5    check_every: 5

Запустим демон и проверим логи.

Выполним в терминале:

docker-compose up

И видим подобный вывод:

Starting monitoring-daemon-tutorial_monitor_1 ... doneAttaching to monitoring-daemon-tutorial_monitor_1monitor_1  | [2020-08-08 18:09:08,540] [INFO] [Dispatcher]: Starting upmonitor_1  | [2020-08-08 18:09:08,618] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.077 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:08,722] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.18 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,619] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET http://example.commonitor_1  |     response code: 200monitor_1  |     content length: 648monitor_1  |     request took: 0.066 secondsmonitor_1  |monitor_1  | [2020-08-08 18:09:13,681] [INFO] [HttpMonitor]: Checkmonitor_1  |     GET https://httpbin.org/getmonitor_1  |     response code: 200monitor_1  |     content length: 310monitor_1  |     request took: 0.126 seconds

Функциональная часть завершена. Демон следит за наличием доступа к http://example.com и https://httpbin.org.

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

Тесты


Было бы неплохо добавить несколько тестов. Давайте сделаем это.

Создаем файл tests.py в пакете monitoringdaemon:

./ monitoringdaemon/    __init__.py    __main__.py    containers.py    dispatcher.py    http.py    monitors.py    tests.py config.yml docker-compose.yml Dockerfile requirements.txt

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

"""Tests module."""import asyncioimport dataclassesfrom unittest import mockimport pytestfrom .containers import ApplicationContainer@dataclasses.dataclassclass RequestStub:    status: int    content_length: int@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'log': {            'level': 'INFO',            'formant': '[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s',        },        'monitors': {            'example': {                'method': 'GET',                'url': 'http://fake-example.com',                'timeout': 1,                'check_every': 1,            },            'httpbin': {                'method': 'GET',                'url': 'https://fake-httpbin.org/get',                'timeout': 1,                'check_every': 1,            },        },    })    return container@pytest.mark.asyncioasync def test_example_monitor(container, caplog):    caplog.set_level('INFO')    http_client_mock = mock.AsyncMock()    http_client_mock.request.return_value = RequestStub(        status=200,        content_length=635,    )    with container.http_client.override(http_client_mock):        example_monitor = container.example_monitor()        await example_monitor.check()    assert 'http://fake-example.com' in caplog.text    assert 'response code: 200' in caplog.text    assert 'content length: 635' in caplog.text@pytest.mark.asyncioasync def test_dispatcher(container, caplog, event_loop):    caplog.set_level('INFO')    example_monitor_mock = mock.AsyncMock()    httpbin_monitor_mock = mock.AsyncMock()    with container.example_monitor.override(example_monitor_mock), \            container.httpbin_monitor.override(httpbin_monitor_mock):        dispatcher = container.dispatcher()        event_loop.create_task(dispatcher.start())        await asyncio.sleep(0.1)        dispatcher.stop()    assert example_monitor_mock.check.called    assert httpbin_monitor_mock.check.called

Для запуска тестов выполним в терминале:

docker-compose run --rm monitor py.test monitoringdaemon/tests.py --cov=monitoringdaemon

Должен получиться подобный результат:

platform linux -- Python 3.8.3, pytest-6.0.1, py-1.9.0, pluggy-0.13.1rootdir: /codeplugins: asyncio-0.14.0, cov-2.10.0collected 2 itemsmonitoringdaemon/tests.py ..                                    [100%]----------- coverage: platform linux, python 3.8.3-final-0 -----------Name                             Stmts   Miss  Cover----------------------------------------------------monitoringdaemon/__init__.py         0      0   100%monitoringdaemon/__main__.py         9      9     0%monitoringdaemon/containers.py      11      0   100%monitoringdaemon/dispatcher.py      43      5    88%monitoringdaemon/http.py             6      3    50%monitoringdaemon/monitors.py        23      1    96%monitoringdaemon/tests.py           37      0   100%----------------------------------------------------TOTAL                              129     18    86%

Обратите внимание как в тесте test_example_monitor мы подменяем HttpClient моком с помощью метода .override(). Таким образом можно переопределить возвращаемое значения любого провайдера.

Такие же действия выполняются в тесте test_dispatcher для подмены моками мониторинговых задач.


Заключение


Мы построили мониторинг демон на базе asyncio применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

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

"""Application containers module."""import loggingimport sysfrom dependency_injector import containers, providersfrom . import http, monitors, dispatcherclass ApplicationContainer(containers.DeclarativeContainer):    """Application container."""    config = providers.Configuration()    configure_logging = providers.Callable(        logging.basicConfig,        stream=sys.stdout,        level=config.log.level,        format=config.log.format,    )    http_client = providers.Factory(http.HttpClient)    example_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.example,    )    httpbin_monitor = providers.Factory(        monitors.HttpMonitor,        http_client=http_client,        options=config.monitors.httpbin,    )    dispatcher = providers.Factory(        dispatcher.Dispatcher,        monitors=providers.List(            example_monitor,            httpbin_monitor,        ),    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

Что дальше?


Подробнее..

CLI приложение Dependency Injector руководство по применению dependency injection Вопросы ответы

14.08.2020 02:18:06 | Автор: admin
Привет,

Я создатель Dependency Injector. Это dependency injection фреймворк для Python.

Это завершающее руководство по построению приложений с помощью Dependency Injector. Прошлые руководства рассказывают как построить веб-приложение на Flask, REST API на Aiohttp и мониторинг демона на Asyncio применяя принцип dependency injection.

Сегодня хочу показать как можно построить консольное (CLI) приложение.

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

Руководство состоит из таких частей:

  1. Что мы будем строить?
  2. Подготовка окружения
  3. Структура проекта
  4. Установка зависимостей
  5. Фикстуры
  6. Контейнер
  7. Работа с csv
  8. Работа с sqlite
  9. Провайдер Selector
  10. Тесты
  11. Заключение
  12. PS: вопросы и ответы

Завершенный проект можно найти на Github.

Для старта необходимо иметь:

  • Python 3.5+
  • Virtual environment

И желательно иметь общее представление о принципе dependency injection.

Что мы будем строить?


Мы будем строить CLI (консольное) приложение, которое ищет фильмы. Назовем его Movie Lister.

Как работает Movie Lister?

  • У нас есть база данных фильмов
  • О каждом фильме известна такая информация:
    • Название
    • Год выпуска
    • Имя режиссёра
  • База данных распространяется в двух форматах:
    • Csv файл
    • Sqlite база данных
  • Приложение выполняет поиск по базе данных по таким критериям:
    • Имя режиссёра
    • Год выпуска
  • Другие форматы баз данных могут быть добавлены в будущем

Movie Lister это приложение-пример, которое используется в статье Мартина Фаулера о dependency injection и inversion of control.

Вот как выглядит диаграмма классов приложения Movie Lister:


Обязанности между классами распределены так:

  • MovieLister отвечает за поиск
  • MovieFinder отвечает за извлечение данных из базы
  • Movie класс сущности фильм

Подготовка окружения


Начнём с подготовки окружения.

В первую очередь нам нужно создать папку проекта и virtual environment:

mkdir movie-lister-tutorialcd movie-lister-tutorialpython3 -m venv venv

Теперь давайте активируем virtual environment:

. venv/bin/activate

Окружение готово. Теперь займемся структурой проекта.

Структура проекта


В этом разделе организуем структуру проекта.

Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми.

Начальная структура:

./ movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Установка зависимостей


Пришло время установить зависимости. Мы будем использовать такие пакеты:

  • dependency-injector dependency injection фреймворк
  • pyyaml библиотека для парсинга YAML файлов, используется для чтения конфига
  • pytest фреймворк для тестирования
  • pytest-cov библиотека-помогатор для измерения покрытия кода тестами

Добавим следующие строки в файл requirements.txt:

dependency-injectorpyyamlpytestpytest-cov

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

pip install -r requirements.txt

Установка зависимостей завершена. Переходим к фикстурам.

Фикстуры


В это разделе мы добавим фикстуры. Фикстурами называют тестовые данные.

Мы создадим скрипт, который создаст тестовые базы данных.

Добавляем директорию data/ в корень проекта и внутрь добавляем файл fixtures.py:

./ data/    fixtures.py movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Далее редактируем fixtures.py:

"""Fixtures module."""import csvimport sqlite3import pathlibSAMPLE_DATA = [    ('The Hunger Games: Mockingjay - Part 2', 2015, 'Francis Lawrence'),    ('Rogue One: A Star Wars Story', 2016, 'Gareth Edwards'),    ('The Jungle Book', 2016, 'Jon Favreau'),]FILE = pathlib.Path(__file__)DIR = FILE.parentCSV_FILE = DIR / 'movies.csv'SQLITE_FILE = DIR / 'movies.db'def create_csv(movies_data, path):    with open(path, 'w') as opened_file:        writer = csv.writer(opened_file)        for row in movies_data:            writer.writerow(row)def create_sqlite(movies_data, path):    with sqlite3.connect(path) as db:        db.execute(            'CREATE TABLE IF NOT EXISTS movies '            '(title text, year int, director text)'        )        db.execute('DELETE FROM movies')        db.executemany('INSERT INTO movies VALUES (?,?,?)', movies_data)def main():    create_csv(SAMPLE_DATA, CSV_FILE)    create_sqlite(SAMPLE_DATA, SQLITE_FILE)    print('OK')if __name__ == '__main__':    main()

Теперь выполним в терминале:

python data/fixtures.py

Скрипт должен вывести OK при успешном завершении.

Проверим, что файлы movies.csv и movies.db появились в директории data/:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py venv/ config.yml requirements.txt

Фикстуры созданы. Продолжаем.

Контейнер


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

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

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containersclass ApplicationContainer(containers.DeclarativeContainer):    ...

Контейнер пока пуст. Мы добавим провайдеры в следующих секциях.

Давайте еще добавим функцию main(). Её обязанность запускать приложение. Пока она будет только создавать контейнер.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()if __name__ == '__main__':    main()

Контейнер первый объект в приложении. Он используется для получения всех остальных объектов.

Работа с csv


Теперь добавим все что нужно для работы с csv файлами.

Нам понадобится:

  • Сущность Movie
  • Базовый класс MovieFinder
  • Его реализация CsvMovieFinder
  • Класс MovieLister

После добавления каждого компонента будем добавлять его в контейнер.



Создаем файл entities.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie entities module."""class Movie:    def __init__(self, title: str, year: int, director: str):        self.title = str(title)        self.year = int(year)        self.director = str(director)    def __repr__(self):        return '{0}(title={1}, year={2}, director={3})'.format(            self.__class__.__name__,            repr(self.title),            repr(self.year),            repr(self.director),        )

Теперь нам нужно добавить фабрику Movie в контейнер. Для этого нам понадобиться модуль providers из dependency_injector.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import entitiesclass ApplicationContainer(containers.DeclarativeContainer):    movie = providers.Factory(entities.Movie)

Не забудьте убрать эллипсис (...). В контейнере уже есть провайдеры и он больше не нужен.

Переходим к созданию finders.

Создаем файл finders.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie finders module."""import csvfrom typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]

Теперь добавим CsvMovieFinder в контейнер.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )

У CsvMovieFinder есть зависимость от фабрики Movie. CsvMovieFinder нуждается в фабрике так как будет создавать объекты Movie по мере того как будет читать данные из файла. Для того чтобы передать фабрику мы используем атрибут .provider. Это называется делегирование провайдеров. Если мы укажем фабрику movie как зависимость, она будет вызвана когда csv_finder будет создавать CsvMovieFinder и в качестве инъекции будет передан объект Movie. Используя атрибут .provider в качестве инъекции будет передам сам провайдер.

У csv_finder еще есть зависимость от нескольких опций конфигурации. Мы добавили провайдер Сonfiguration чтобы передать эти зависимости.

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

Сначала используем, потом задаем значения.

Теперь давайте добавим значения конфигурации.

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","

Значения установлены в конфигурационный файл. Обновим функцию main() чтобы указать его расположение.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')if __name__ == '__main__':    main()

Переходим к listers.

Создаем файл listers.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py venv/ config.yml requirements.txt

и добавляем внутрь следующие строки:

"""Movie listers module."""from .finders import MovieFinderclass MovieLister:    def __init__(self, movie_finder: MovieFinder):        self._movie_finder = movie_finder    def movies_directed_by(self, director):        return [            movie for movie in self._movie_finder.find_all()            if movie.director == director        ]    def movies_released_in(self, year):        return [            movie for movie in self._movie_finder.find_all()            if movie.year == year        ]

Обновляем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=csv_finder,    )

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

В завершение обновляем функцию main().

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Все готово. Теперь запустим приложение.

Выполним в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

Работа с sqlite


В это разделе мы добавим другой тип MovieFinder SqliteMovieFinder.

Отредактируем finders.py:

"""Movie finders module."""import csvimport sqlite3from typing import Callable, Listfrom .entities import Movieclass MovieFinder:    def __init__(self, movie_factory: Callable[..., Movie]) -> None:        self._movie_factory = movie_factory    def find_all(self) -> List[Movie]:        raise NotImplementedError()class CsvMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,            delimiter: str,    ) -> None:        self._csv_file_path = path        self._delimiter = delimiter        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with open(self._csv_file_path) as csv_file:            csv_reader = csv.reader(csv_file, delimiter=self._delimiter)            return [self._movie_factory(*row) for row in csv_reader]class SqliteMovieFinder(MovieFinder):    def __init__(            self,            movie_factory: Callable[..., Movie],            path: str,    ) -> None:        self._database = sqlite3.connect(path)        super().__init__(movie_factory)    def find_all(self) -> List[Movie]:        with self._database as db:            rows = db.execute('SELECT title, year, director FROM movies')            return [self._movie_factory(*row) for row in rows]

Добавляем провайдер sqlite_finder в контейнер и указываем его в качестве зависимости для провайдера lister.

Отредактируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=sqlite_finder,    )

У провайдера sqlite_finder есть зависимость от опций конфигурации, которые мы еще не определили. Обновим файл конфигурации:

Отредактируем config.yml:

finder:  csv:    path: "data/movies.csv"    delimiter: ","  sqlite:    path: "data/movies.db"

Готово. Давайте проверим.

Выполняем в терминале:

python -m movies

Вы увидите:

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

Провайдер Selector


В этом разделе мы сделаем наше приложение более гибким.

Больше не нужно будет делать изменения в коде для переключения между csv и sqlite форматами. Мы реализуем переключатель на базе переменной окружения MOVIE_FINDER_TYPE:

  • Когда MOVIE_FINDER_TYPE=csv приложения использует формат csv.
  • Когда MOVIE_FINDER_TYPE=sqlite приложения использует формат sqlite.

В этом нам поможет провайдер Selector. Он выбирает провайдер на основе опции конфигурации (документация).

Отредактрируем containers.py:

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )

Мы создали провайдер finder и указали его в качестве зависимости для провайдера lister. Провайдер finder выбирает между провайдерами csv_finder и sqlite_finder во время выполнения. Выбор зависит от значения переключателя.

Переключателем является опция конфигурации config.finder.type. Когда ее значение csv используется провайдер из ключа csv. Аналогично для sqlite.

Теперь нам нужно считать значение config.finder.type из переменной окружения MOVIE_FINDER_TYPE.

Отредактируем __main__.py:

"""Main module."""from .containers import ApplicationContainerdef main():    container = ApplicationContainer()    container.config.from_yaml('config.yml')    container.config.finder.type.from_env('MOVIE_FINDER_TYPE')    lister = container.lister()    print(        'Francis Lawrence movies:',        lister.movies_directed_by('Francis Lawrence'),    )    print(        '2016 movies:',        lister.movies_released_in(2016),    )if __name__ == '__main__':    main()

Готово.

Выполним в терминале следующие команды:

MOVIE_FINDER_TYPE=csv python -m moviesMOVIE_FINDER_TYPE=sqlite python -m movies

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

Francis Lawrence movies: [Movie(title='The Hunger Games: Mockingjay - Part 2', year=2015, director='Francis Lawrence')]2016 movies: [Movie(title='Rogue One: A Star Wars Story', year=2016, director='Gareth Edwards'), Movie(title='The Jungle Book', year=2016, director='Jon Favreau')]

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

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

В следующем разделе добавим несколько тестов.

Тесты


В завершение добавим несколько тестов.

Создаём файл tests.py в пакете movies:

./ data/    fixtures.py    movies.csv    movies.db movies/    __init__.py    __main__.py    containers.py    entities.py    finders.py    listers.py    tests.py venv/ config.yml requirements.txt

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

"""Tests module."""from unittest import mockimport pytestfrom .containers import ApplicationContainer@pytest.fixturedef container():    container = ApplicationContainer()    container.config.from_dict({        'finder': {            'type': 'csv',            'csv': {                'path': '/fake-movies.csv',                'delimiter': ',',            },            'sqlite': {                'path': '/fake-movies.db',            },        },    })    return containerdef test_movies_directed_by(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_directed_by('Jon Favreau')    assert len(movies) == 1    assert movies[0].title == 'The Jungle Book'def test_movies_released_in(container):    finder_mock = mock.Mock()    finder_mock.find_all.return_value = [        container.movie('The 33', 2015, 'Patricia Riggen'),        container.movie('The Jungle Book', 2016, 'Jon Favreau'),    ]    with container.finder.override(finder_mock):        lister = container.lister()        movies = lister.movies_released_in(2015)    assert len(movies) == 1    assert movies[0].title == 'The 33'

Теперь запустим тестирование и проверим покрытие:

pytest movies/tests.py --cov=movies

Вы увидите:

platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1plugins: cov-2.10.0collected 2 itemsmovies/tests.py ..                                              [100%]---------- coverage: platform darwin, python 3.8.3-final-0 -----------Name                   Stmts   Miss  Cover------------------------------------------movies/__init__.py         0      0   100%movies/__main__.py        10     10     0%movies/containers.py       9      0   100%movies/entities.py         7      1    86%movies/finders.py         26     13    50%movies/listers.py          8      0   100%movies/tests.py           24      0   100%------------------------------------------TOTAL                     84     24    71%

Мы использовали метод .override() провайдера finder. Провайдер переопределяется моком. При обращении к провайдеру finder теперь будет возвращен переопределяющий мок.

Работа закончена. Теперь давайте подведем итоги.

Заключение


Мы построили консольное (CLI) приложение применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.

Преимущество, которое вы получаете с Dependency Injector это контейнер.

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

"""Containers module."""from dependency_injector import containers, providersfrom . import finders, listers, entitiesclass ApplicationContainer(containers.DeclarativeContainer):    config = providers.Configuration()    movie = providers.Factory(entities.Movie)    csv_finder = providers.Singleton(        finders.CsvMovieFinder,        movie_factory=movie.provider,        path=config.finder.csv.path,        delimiter=config.finder.csv.delimiter,    )    sqlite_finder = providers.Singleton(        finders.SqliteMovieFinder,        movie_factory=movie.provider,        path=config.finder.sqlite.path,    )    finder = providers.Selector(        config.finder.type,        csv=csv_finder,        sqlite=sqlite_finder,    )    lister = providers.Factory(        listers.MovieLister,        movie_finder=finder,    )


Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.

PS: вопросы и ответы


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

Я подготовил ответы:

Что такое dependency injection?

  • это принцип который уменьшает связывание (coupling) и увеличивает сцепление (cohesion)

Зачем мне применять dependency injection?

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

Как мне начать применять dependency injection?

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

Зачем мне для этого фреймворк?

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

Какаю цену я плачу?

  • тебе нужно явно указывать зависимости в контейнере
  • это дополнительная работа
  • это начнет приносить дивиденды когда проект начнет расти
  • или через 2 недели после его завершения (когда ты забудешь какие решения принимал и какова структура проекта)

Концепция Dependency Injector


В дополнение опишу концепцию Dependency Injector как фреймворка.

Dependency Injector основан на двух принципах:

  • Явное лучше неявного (PEP20).
  • Не делать никакой магии с вашим кодом.

Чем Dependency Injector отличается от другим фреймворков?

  • Нет автоматического связывания. Фреймворк не делает автоматического связывания зависимостей. Не используется интроспекция, связывание по именам аргументов и / или типам. Потому что явное лучше неявного (PEP20).
  • Не загрязняет код вашего приложения. Ваше приложение не знает о наличии Dependency Injector и не зависит от него. Никаких @inject декораторов, аннотаций, патчинга или других волшебных трюков.

Dependency Injector предлагает простой контракт:

  • Вы показываете фреймворку как собирать объекты
  • Фреймворк их собирает

Сила Dependency Injector в его простоте и прямолинейности. Это простой инструмент для реализации мощного принципа.

Что дальше?


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

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


Буду рад фидбеку и отвечу на вопросы в комментариях.
Подробнее..

Dependency Injector 4.0 упрощенная интеграция с другими Python фреймворками

12.10.2020 02:06:31 | Автор: admin


Привет,

Я выпустил новую мажорную версию Dependency Injector.

Основная фича этой версии связывание (wiring). Она позволяет делать инъекции в функции и методы без затягивания их в контейнер.

from dependency_injector import containers, providersfrom dependency_injector.wiring import Provideclass Container(containers.DeclarativeContainer):    config = providers.Configuration()    api_client = providers.Singleton(        ApiClient,        api_key=config.api_key,        timeout=config.timeout.as_int(),    )    service = providers.Factory(        Service,        api_client=api_client,    )def main(service: Service = Provide[Container.service]):    ...if __name__ == '__main__':    container = Container()    container.config.api_key.from_env('API_KEY')    container.config.timeout.from_env('TIMEOUT')    container.wire(modules=[sys.modules[__name__]])    main()  # <-- зависимость внедряется автоматически    with container.api_client.override(mock.Mock()):        main()  # <-- переопределенная зависимость внедряется автоматически

Когда вызывается функция main() зависимость Service собирается и передается автоматически.

При тестировании вызывается container.api_client.override() чтобы заменить API клиент на мок. При вызове main() зависимость Service будет собираться с моком.

Новая фича упрощает использование Dependency Injectorа с другими Python фреймворками.

Как связывание помогает интеграции с другими фреймворками?


Связывание дает возможность делать точные инъекции независимо от структуры приложения. В отличии от 3-ей версии для внедрения зависимости не нужно затягивать функцию или класс в контейнер.

Пример с Flask:

import sysfrom dependency_injector import containers, providersfrom dependency_injector.wiring import Providefrom flask import Flask, jsonclass Service:    ...class Container(containers.DeclarativeContainer):    service = providers.Factory(Service)def index_view(service: Service = Provide[Container.service]) -> str:    return json.dumps({'service_id': id(service)})if __name__ == '__main__':    container = Container()    container.wire(modules=[sys.modules[__name__]])    app = Flask(__name__)    app.add_url_rule('/', 'index', index_view)    app.run()

Другие примеры:


Как работает связывание?


Для того чтобы применять связывание нужно:

  • Разместить маркеры в коде. Маркер вида Provide[Container.bar] указывается как дефолтное значение аргумента функции или метода. Маркеры нужны чтобы указать что и куда внедрять.
  • Связать контейнер с маркерами в коде. Для этого нужно вызвать метод container.wire(modules=[...], packages=[...]) и указать модули или пакеты, в которых есть маркеры.
  • Использовать функции и методы как обычно. Фреймворк подготовит и внедрит нужные зависимости автоматически.

Связывание работает на базе интроспекции. При вызове container.wire(modules=[...], packages=[...]) фреймворк пройдется по всем функциям и методам в этих пакетах и модулях и изучит их дефолтные параметры. Если дефолтным параметром будет маркер, то такая функция или метод будут пропатчены декоратором внедрения зависимостей. Этот декоратор при вызове подготавливает и внедряет зависимости вместо маркеров в оригинальную функцию.

def foo(bar: Bar = Provide[Container.bar]):    ...container = Container()container.wire(modules=[sys.modules[__name__]])foo()  # <--- Аргумент "bar" будет внедрен# То же что и:foo(bar=container.bar())

Больше про связывание можно узнать тут.

Совместимость?


Версия 4.0 совместима с версиями 3.х.

Интеграционные модули ext.flask и ext.aiohttp задеприкечены в пользу связывания.
При использовании фреймворк будет выводить предупреждение и рекомендовать перейти на связывание.

Полный список изменений можно найти тут.

Что дальше?


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

FastAPI Dependency Injector

18.11.2020 10:21:27 | Автор: admin


Привет,

Я выпустил новую версию Dependency Injector 4.4. Она позволяет использовать Dependency Injector вместе с FastAPI. В этом посте покажу как это работает.

Основная задача интеграции: подружить директиву Depends FastAPI c маркерами Provide и Provider Dependency Injector'a.

Из коробки до версии DI 4.4 это не работало. FastAPI использует типизацию и Pydantic для валидации входных параметров и ответа. Маркеры Dependency Injector'а приводили его в недоумение.

Решение пришло после изучения внутренностей FastAPI. Пришлось сделать нескольких изменений в модуле связывания (wiring) Dependency Injector'а. Директива Depends теперь работает вместе с маркерами Provide и Provider.

Пример


Создайте файл fastapi_di_example.py и поместите в него следующие строки:

import sysfrom fastapi import FastAPI, Dependsfrom dependency_injector import containers, providersfrom dependency_injector.wiring import inject, Provideclass Service:    async def process(self) -> str:        return 'Ok'class Container(containers.DeclarativeContainer):    service = providers.Factory(Service)app = FastAPI()@app.api_route('/')@injectasync def index(service: Service = Depends(Provide[Container.service])):    result = await service.process()    return {'result': result}container = Container()container.wire(modules=[sys.modules[__name__]])

Для того чтобы запустить пример установите зависимости:

pip install fastapi dependency-injector uvicorn

и запустите uvicorn:

uvicorn fastapi_di_example:app --reload

В терминале должно будет появится что-то вроде:

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)INFO:     Started reloader process [11910] using watchgodINFO:     Started server process [11912]INFO:     Waiting for application startup.INFO:     Application startup complete.

а http://127.0.0.1:8000 должен возвращать:

{    "result": "Ok"}


Как тестировать?


Создайте рядом файл tests.py и поместите в него следующие строки:

from unittest import mockimport pytestfrom httpx import AsyncClientfrom fastapi_di_example import app, container, Service@pytest.fixturedef client(event_loop):    client = AsyncClient(app=app, base_url='http://test')    yield client    event_loop.run_until_complete(client.aclose())@pytest.mark.asyncioasync def test_index(client):    service_mock = mock.AsyncMock(spec=Service)    service_mock.process.return_value = 'Foo'    with container.service.override(service_mock):        response = await client.get('/')    assert response.status_code == 200    assert response.json() == {'result': 'Foo'}

Для того чтобы запустить тесты установите зависимости:

pip install pytest pytest-asyncio httpx

и запустите pytest:

pytest tests.py

В терминале должно будет появится:

======= test session starts =======platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1rootdir: ...plugins: asyncio-0.14.0collected 1 itemtests.py .                                                      [100%]======= 1 passed in 0.17s =======

Что дает интеграция?


FastAPI классный фреймворк для построения API. В него встроен базовый механизм dependency injection.

Эта интеграция улучшает работу dependency injection в FastAPI. Она позволяет использовать в нём провайдеры, переопредение, конфиг и ресурсы Dependency Injector'а.

Что дальше?


Подробнее..
Категории: Python , Dependency injection , Fastapi

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

Из песочницы Hilt еще один DI?

11.08.2020 20:13:10 | Автор: admin

Встречайте Hilt Dependency Injection (DI) в JetPack, но это не правда, так как Hilt это просто обертка для Dagger2. Для небольших проектов сможет встать более удобным инструментом и хорошо интегрируется с остальными продуктами в JetPack.


Не буду описывать как добавить в проект, все хорошо описано в статье


Зачем?


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


И если хочется использовать Dagger2, но с минимальными усилиями, то для этого как раз и был придуман Hilt.


Что упростили для нас:


  • Готовые компоненты (из названий понятно к чему относятся)
    • ApplicationComponent
    • ActivityRetainedComponent
    • ActivityComponent
    • FragmentComponent
    • ViewComponent
    • ViewWithFragmentComponent
    • ServiceComponent
  • В модуле указываешь в какой компонент добавить
  • Через @AndroidEntryPoint Hilt компилятор генерирует весь bolierplate для создания компонента и хранения (например, ActivityRetainedComponent сохранит сущность после поворота экрана, ActivityComponent пересоздаст заново).

Такой код выглядит довольно элегантно (весь boilerplate за нас сгенерируется)


@AndroidEntryPointclass ExampleActivity : AppCompatActivity() {     @Inject lateinit var testService: TestService}

Особенности


Application обязателен


Необходимо объявить Application и пометить @HiltAndroidApp, без него Hilt не заработает.


@HiltAndroidAppclass App : Application() { }

Иерархическая зависимость


Если хотите использовать Hilt в фрагментах, то Activity которая содержит эти фрагменты обязательно помечать аннотацией @AndroidEntryPoint


Если View пометить @WithFragmentBindings то Fragment должен быть с аннотацией @AndroidEntryPoint, а без этой аннотации зависит от того куда инжектимся Activity или Fragment


Объявление модулей


Все как в Dagger2, но нет необходимости добавлять модуль в компонент, а достаточно использовать аннотацию @InstallIn. Это и понятно, так как компоненты не доступны для редактирования.


@InstallIn(ApplicationComponent::class)@Moduleclass NetworkModule {    @Singleton    @Provides    fun provideHttpService(): HttpService {        return object : HttpService {            init {                Log.e("Tester", "HttpService initialized")            }            override fun request() {                Log.e("Tester", "HttpService::request")            }        }    }}

При добавления Hilt, все модули должны быть с @InstallIn, либо компилятор ругнется, что аннотация отсутствует.


Кастомные Component и Subcomponent


Создать их конечно можно, так как там Dagger2, но теряется тогда весь смысл Hilt и все придется писать вручную. Вот что предлагается в документации:


DaggerLoginComponent.builder()        .context(this)        .appDependencies(          EntryPointsAccessors.fromApplication(            applicationContext,            LoginModuleDependencies::class.java          )        )        .build()        .inject(this)

Поддержка многомодульности


Все это есть, но только когда используем только готовые компоненты. Но если добавить для каждого модуля свои компоненты (как советуют тут), то лучше использовать Dagger2.


Ограничения для @AndroidEntryPoint


  • Поддерживаются Activity наследуемые от ComponentActivity и AppCompatActivity
  • Поддерживаются Fragment наследуемые от androidx.Fragment
  • Не поддерживаются Retain фрагменты

Что внутри


Hilt работает следующим образом:


  • Генерируются Dagger Component-ы
  • Генерируются базовые классы для Application, Activity, Fragment, View и т.д, которые помечены аннотацией @AndroidEntryPoint
  • Dagger компилятор генерирует статический коды

Как устроено хранение ActivityRetainedComponent


Не стали усложнять и просто поместили компонент в ViewModel из arch библиотеки:


this.viewModelProvider =        new ViewModelProvider(            activity,            new ViewModelProvider.Factory() {              @NonNull              @Override              @SuppressWarnings("unchecked")              public <T extends ViewModel> T create(@NonNull Class<T> aClass) {                ActivityRetainedComponent component =                    ((GeneratedComponentManager<LifecycleComponentBuilderEntryPoint>)                            activity.getApplication())                        .generatedComponent()                        .retainedComponentBuilder()                        .build();                return (T) new ActivityRetainedComponentViewModel(component);              }            });

Итог


Плюсы:


  • Более простое использование чем Dagger2
  • Добавление модулей через аннотацию выглядит довольно удобно (в несколько уже не поместишь)
  • Код чище и много boilerpate спрятано.
  • Все плюсы от Dagger2 (генерация статического кода, валидация зависимостей и т.д.)
  • Удобен для небольших проектов

Минусы:


  • Тяжело избавится, не только захочется убрать или заменить, но и просто перейти на Dagger2
  • Тяжело добавить кастомные компоненты, что ограничивает использование с крупных проектах
  • Наследует минусы Dagger2 и еще больше увеличивает время сборки
  • Иерархическая зависимость, например, нельзя использовать в Fragment без Activity c @AndroidEntryPoint

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


Подробнее..

Наследование компонентов в Angular простой способ решить проблему с Dependency Injection

28.02.2021 10:04:21 | Автор: admin

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

Но есть один нюанс: эти злосчастные конструкторы. Нужно в каждом наследуемом компоненте передавать все DI в конструктор родителя.

constructor ( customService: CustomService, additionalService: AdditionalService) {super(customService, additionalService)}

выглядит не очень, но это полбеды. Беда в том, что если у нас в базовом классе добавляется DI, нам придется прыгать по всем компонентам-наследникам и добавлять эту зависимость в конструктор. Плакал наш DRY :-))

Попробуем сделать по другому: вместе с базовым компонентом создадим Injectable-класс, куда завернем все зависимости. И заинжектим его в базовый класс

@Injectable()export class MyBaseComponentDependences {constructor(    public customService: CustomService,      public additionalService: AdditionalService      ) {}    }@Directive()export abstract class MyBaseComponent<T> {//Пример использования сервиса в родительском классеshareEntity = this.deps.additionalService.getShare()    protected constructor(    public deps: MyBaseComponentDependences     ) {}}

Класс-наследник будет выглядеть так

@Component({providers: [MyBaseComponentDependences] })export class MyChildComponent extends MyBaseComponent<MyDto> {    //Пример использования сервиса в классе-наследнике    customEntity = this.deps.customService.getEntity()        constructor(deps: MyBaseComponentDependences) {    super(deps);     }}

Теперь, если у нас в базовый класс добавляется DI мы меняем только класс MyBaseComponentDependences, все остальное остается как есть. Проблема решена

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

Подробнее..
Категории: Angular , Ооп , Dependency injection

Категории

Последние комментарии

  • Имя: Макс
    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