Всем привет! Сегодня рассмотрим довольно интересную тему, которая является очевидной базой для веб-разработчиков, но для разработчиков десктопных приложений часто является чем-то непонятным. Так что давайте разбираться, что это за инъекция зависимостей (dependency injection, DI, внедрение зависимостей) и зачем она нужна.
Определение
В целом, инъекция зависимостей — это паттерн проектирования ПО, в котором зависимости, используемые объектом, предоставляются ему извне, а не создаются самим объектом. Простейший пример DI — конструктор с параметрами:
Класс WallCreatorWithDI знает, что у него внутри есть IParameterWriter, но не знает, какой именно — эта зависимость внедряется извне.
Класс WallCreatorWithoutDI знает, что у него внутри есть конкретный MarkParameterWriter — он сам управляет своими зависимостями.
В чём же преимущества подхода с DI? Отметим, что работать будут в любом случае оба подхода, само по себе отсутствие DI не вызовет багов и нарушений. Преимуществами DI является упрощение процессов поддержки кода, расширения функционала и замены реализации интерфейсов. Мы получаем более простой, понятный, удобный и контролируемый код, и тратим меньше времени на внесении изменений. Кроме того, это уменьшает уровень зависимости модулей нашего приложения друг от друга (они зависят от абстракций — IParameterWriter, но не от реализаций — MarkParameterWriter, поэтому изменения в реализациях с меньшей вероятностью приведут к багам там, где мы этого не ждём.
Ну и конечно, при выходе на рынок труда, программист со знанием и практическим опытом использования DI будет цениться выше, чем без него.
DI с помощью контейнеров
Что ж, это всё, конечно, здорово, но стоило ли писать статью ради мысли "передавайте параметры в конструктор, а не создавайте их в конструкторе"? Особенно если у читающего в его практических примерах реализация интерфейса всегда одна, в чём же в итоге выхлоп?
Расширим определение внедрения зависимостей (с помощью Википедии).
...объект отдаёт заботу о построении требуемых ему зависимостей внешнему, специально предназначенному для этого общему механизму.
Соответственно, механизм может быть разным. Это может быть передача параметров в конструктор, как в случае выше, а может быть специальный фреймворк для инъекций зависимостей — контейнер DI, о которых мы сейчас и поговорим.
Контейнер зависимостей позволяет нам создавать объекты не вручную, а брать их из контейнера, в котором мы их предварительно зарегистрировали. Зарегистрировать мы можем как класс, так и интерфейс и его реализацию. Контейнер сам пройдётся по всем необходимым в конструкторе класса зависимостям, найдёт их в контейнере, и создаст их.
Если зависимость дана в форме интерфейса, он вернёт объект того класса, который был реализован в качестве реализации этого интерфейса. Если зависимость дана в форме класса, то он вернёт объект зарегистрированного класса.
Если мы добавим в конструктор некоторый сервис, то нам нужно будет только добавить его при регистрации контейнера — а дальше он сделает всё за нас.
Порядок работы:
- Подключаем DI-фреймворк.
- Регистрируем нужные нам объекты.
- Берём объекты из контейнера, все их зависимости создадутся сами.
В программировании под Revit чаще всего непосредственно брать из контейнера мы будем классы окон, а их зависимости — ViewModel — создадутся сами. А внутри них — зависимости ViewModel, и так далее, при необходимости.
Пример реализации
Рассмотрим реализацию приложения с DI на примере Revit Lookup.
Как и любое приложение для Revit, он содержит точку входа в классе наследнике IExternalApplication — метод OnStartup в классе Application:
На строке 34 регистрируются хэндлеры для немодального режима, на строке 37 создаётся панель. Тут мы всё знаем. Но что за Host.Start()/Host.Stop().
Рядом с файлом Application.cs лежит файл Host.cs, давайте посмотрим его. Начнём с директив using:
Строка 4 — Microsoft.Extensions.Hosting — и есть тот самый фреймворк для Dependency Injection. Он подключён в виде nuget-пакета в csproj-файле:
Далее идёт регистрация сервисов. Рассмотрим метод Start():
//тут часть строк пропущена, там просто регистрация других сервисов
Мы создали объект ApplicationBuilder, зарегистрировали в нём все сервисы и вызвали метод Build, а затем запустили Host методом Start. И да, тут переменная _host внутри класса статическая, потому что её жизненный цикл равен жизненному циклу приложения.
Всё, все требуемые сервисы зарегистрированы. В какой же момент мы их берём?
Рассмотрим команду SnoopSelection:
Мы запрашиваем ILookupService. Смотрим, как он зарегистрирован в Host:
То есть при запросе ILookupService мы получим LookupService. Посмотрим его конструктор:
Тут мы используем IServiceScopeFactory (это интерфейс из Microsoft.Extensions.Hosting, и уже тема для второй части статьи, но суть как раз в этом — нам нужна эта фабрика для каких-то целей в нашем объекте, и DI контейнер даст нам её, а как именно — нам неважно). Пока кратко скажу, что это объект, который позволяет нам брать объекты из контейнера, не обращаясь непосредственно в Host, и у этих объектов будет время жизни Scoped — внутри скоупа, который в данном случае означает один клик на кнопку Snoop в интерфейсе.
Посмотрим, что в вызове LookupServiceImpl на строке 53:
Здесь мы создаём требуемые нам сервисы с помощью ScopeFactory, а так же говорим о том, что при закрытии окна будет вызван Dispose для скоупа.
Давайте пройдём по цепочке для ISnoopVisualService. Контейнер создаст нам такой класс:
При его создании будут созданы IWindow, ISnoopViewModel и NotificationService, и так далее.
То есть при всей сложной цепочке вызовов и зависимостей, нам не нужно создавать новые объекты, передавать их в параметры конструкторов и так далее — за нас всё делает DI-контейнер.
Время жизни сервисов
Помимо упрощения создания объектов, DI-контейнеры позволяют нам управлять их временем жизни. Управляем мы им при регистрации. У нас есть 3 опции:
Transient — при каждом обращении к контейнеру будет создаваться новый экземпляр объекта
Singleton — объект будет создан один раз при первом обращении, далее будет возвращаться тот же самый объект
Scoped — внутри скоупа будет возвращаться новый объект, в другом скоупе — новый.
Для веб-приложений скоупом является HTTP-запрос. В десктопных приложениях по умолчанию все объекты создаются внутри одного общего скоупа, поэтому, если не предпринимать других действий, Scoped-lifetime будет вести себя как Singleton-lifetime.
Но мы можем создать свой скоуп (методом CreateScope через IServiceScopeFactory), и далее этот скоуп ведёт себя как независимый контейнер — всё, что Transient будет создаваться заново, Singleton — будет одинаковым внутри всех скоупов, Scoped — одинаковым внутри скоупа.
Обычно скоуп регистрируется для создания окна, при этом все сервисы для нового окна будут уникальными (например, введённые пользователем значения не сохранятся, потому что будет создана новая ViewModel для окна). При создании скоупа нужно определить момент для вызова Dispose у него. Обычно это закрытие окна или событие Unloaded, если это страница (для DockablePane). Если вызвать Dispose раньше, мы не сможем получать объекты из скоупа, а если не вызвать — возможна утечка памяти, потому что скоуп продолжит существовать.
Заключение
Надеюсь, сегодня вы узнали что-то новое по теме DI. Для более глубокого изучения темы я советую изучать также другие источники (например) и примеры, а также применять DI на практике.
Учитывайте важный нюанс: не стоит применять DI, если пишите приложение из одного окна с одной ViewModel в течение пары часов (разве что для отработки навыков). В таком случае DI-контейнер усложнит приложение, а не упростит работу. А вот если у вас 5-10 команд, несколько окон, диалоги внутри них, ViewModel требует дополнительных сервисов — DI серьёзно упростит вам работу.
Не забывайте подписываться на мой телеграм-канал о Revit API и до новых встреч!