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

Ioc

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

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

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

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

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

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

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

Frontend + DI

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

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

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

Frontend + DI =

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

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

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

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

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

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

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

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

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

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

SOLID

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Constructor injection.

  • Property Injection.

  • Setter Injection.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reflect-metadata

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

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

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

  • design:paramtypes;

  • inversify:tagged;

  • inversify:paramtypes.

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

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

Dependency Injection+React

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Минусы Dependency Injection+React

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

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

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

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

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

Вывод

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

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

Подробнее..

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$log( 123 ) // Error

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

$.$log( 123 ) // OK

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

namespace $ {    export type $ = typeof $}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

namespace $ {    await $.$test_run()}

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

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

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

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

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

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

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

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

Подробнее..

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

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

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



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

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

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

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

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

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

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

Подготовка


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

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

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

Приложение


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

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

Program.cs:

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

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

Calculator.cs:

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

Логирование


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

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

Calculator.cs:

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

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

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

Класс FileLogger


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

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

FileLogger.csh:

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

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

Calculator.cs:

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

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

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


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

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

Calculator.cs:

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

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

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

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

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

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

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

Program.cs

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

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

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

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


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

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

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

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

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

ILogger.cs

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

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

FileLogger.cs

public class FileLogger : ILogger

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

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

Calculator.cs

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

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

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

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

NullLogger.cs

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

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

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

Program.cs

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

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

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


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

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

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


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

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

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

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



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



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

Program.cs

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

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

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



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



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

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

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

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

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

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

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


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

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

Внедрение зависимостей в 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 в комментариях :)

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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