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

Если говорить нестрого, то компонент - это некоторая сущность, имеющая некоторое назначение и решающая какую-то одну задачу. Если задач несколько - то скорее всего мы имеем дело с несколькими компонентами и надо подумать над декомпозицией. К компоненту, уже по определению, применяется принцип SRP.
Для того, чтобы обсуждение было более наглядным, возьмем пример. Будем рассматривать такой компонент, как бариста.
У баристы есть назначение - он готовит кофе.
Однако, чтобы выполнить свое предназначение, любому компоненту нужны связи с внешним миром (с другими компонентами). Можно выделить четыре вида связи:
- Входные данные - набор входных условий, на основании которых компонент делает свою работу. Для нашего примера с баристой - входными данными являются заказы клиентов.
- Выходные данные - набор выходных артефактов, которые являются результатом деятельности компонента. Для примера с баристой - это чашка кофе по конкретному заказу клиента.
- Зависимости - ресурсы, артефакты, данные, которые использует компонент, чтобы преобразовывать входные данные в выходные. Для примера с баристой - это зерна кофе, вода, электричество, молоко и т.д.
- Конфигурация - специальные управляющие воздействия на то, как компонент делает свою работу. Для примера с баристой - это, например, меню от руководства, задающее доступные для заказа и приготовления напитки (т.е. например, даже если у нас есть молоко для капучино, мы можем намеренно его не готовить).
Для большего понимания, рассмотрим пример кода с баристой:
struct ClientAsk {
enum CoffeeKinds {
case americano
case cappuccino
// the rest ...
}
let coffeeKind: CoffeeKinds
let withSugar: Bool
}
final class Barista {
private let dependencies: BaristaDependencies
private let menu: Menu
init(dependencies: BaristaDependencies, menu: Menu) {
self.dependencies = dependencies
self.menu = menu
}
func makeCoffee(ask: ClientAsk) -> Result<Cup, BaristaErrors> {
// use dependencies and menu to process client ask and make a cup of coffee
// or report and error
}
}
В данном случае
- Входные данные: ClientAsk, сущность, описывающая заказ клиента. Это то, что принимает компонент для одной итерации своей работы.
- Выходные данные: Result<Cup, Error>, тут стоит отметить важный факт, что результатом работы компонента может быть:
- успешное выполнение своих обязанностей, и тогда результатом этого будет некоторый артефакт (в нашем случае Cup) этой работы
- или сигнал о том, что работа в обычном виде выполнена быть не может (в нашем случае BaristaErrors), артефакт, описывающий причины того, почему работа не может быть сделана или почему не удалось ее выполнить (важно не забывать про эту часть)
- Зависимости: Dependencies, тут все что необходимо для приготовления чашечки кофе из заказа клиента
- Конфигурация: Menu, описывает виды кофе, которые можно сегодня приготовить
Зачем нам компонент?
С концепцией компонента разобрались, если будут вопросы - пишите, дополню статью. Теперь более насущный вопрос, а зачем нам компонент? Здесь надо сделать небольшое лирическое отступление.
Перед разработкой любой программной системы и при добавлении новой функциональности в уже имеющуюся, необходимо хорошо продумать что и как мы будем делать. Обычно я даже прошу написать что-то вроде RFC, в котором было бы явно описано что и как. Но чтобы разобраться что и как мы хотим делать (и даже написать RFC), необходимо к этому продумыванию как-то подойти. Декомпозировать задачу (которая, на самом деле, редко бывает сформулирована формально) на составляющие и обозначить между ними связи. А если система уже имеется, то еще и “прицепить” новую “запчасть” к тем что уже есть, ничего не забыть и сделать это правильно. И если мы будем мыслить систему в виде взаимодействующих друг с другом компонентов, то у нас будет хорошее подспорье к тому, чтобы задачу проектирования решить успешно.
К тому же, компоненты - фрактальны. Т.е. можно сделать несколько уровней не только в контексте кто кому предоставляет конфигурацию и зависимости, но и кто из кого состоит. Таким образом на одном уровне абстракции компонент может быть одним кубиком, а на другом - графом взаимодействующих друг с другом более низкоуровневых компонент.
Вернемся к нашему баристе, для пользователя это своего рода черных ящик - мы ему заказ, он нам чашечку кофе (или ошибку, не забываем!). Однако, если вы как клиент постоите и посмотрите на работу баристы, то заметите, что чашка кофе - результат последовательности действий. И таким образом компонент “Бариста” состоял бы из сети компонентов, в который входили бы: кофемолка, кофемашина, мойка для чашей, и т.д.
Поэтому компонент дает нам удобную абстракцию для анализа систем и синтеза решений.
Класс как компонент
Вооружившись абстракцией компонента, давайте посмотрим на привычные конструкции из программирования. Любой класс можно рассматривать как компонент. Вернемся к примеру с баристой выше
struct ClientAsk {
enum CoffeeKinds {
case americano
case capuccino
// the rest ...
}
let coffeeKind: CoffeeKinds
let withSugar: Bool
}
protocol BaristaDependencies {
var grinder: Grinder { get }
var coffeeMachine: CoffeeMachine { get }
var waterTap: WaterTap { get }
// ...
}
protocol Menu {
var isCappuccinoAllowed: Bool { get }
// ...
}
final class Barista {
private let dependencies: BaristaDependencies
private let menu: Menu
init(dependencies: BaristaDependencies, menu: Menu) {
self.dependencies = dependencies
self.menu = menu
}
func makeCoffee(ask: ClientAsk) -> Result<Cup, BaristaErrors> {
// use dependencies and menu to process client ask and make a cup of coffee
// or report and error
}
}
Как я уже выше писал - это типичный компонент. Но давайте посмотрим на другие входящие в пример вещи. Является ли ClientAsk компонентом? А Cup? А BaristaDependencies? А Menu?
Интуиция подсказывает что скорее нет, ведь это всего лишь DTO (Data Transfer Object). Но это впечатление обманчиво - это тоже компоненты.
- ClientAsk и Cup это действительно DTO, объекты, чье единственное предназначение - перенос данных. Таким образом их входные и выходные данные хранятся напрямую в их полях и идентичны. Хотя порой такое бывает не всегда, и даже простые DTO могут на вход получать данные в одном виде, а на выходе давать в другом.
- BaristaDependencies как мы видим лишь контракт, который может скрывать в себе как просто прокси доступ к нужным объектам (про DI как-нибудь поразмышляю отдельно), так и какую-нибудь замысловатую логику получения этих объектов. На этом уровне контракт компонентом не является, но. Во-первых, является на другом, понятийном уровне, задача такого компонента - скрывать от пользователя реализацию получения зависимостей. Во-вторых, компонентом однозначно является реализация этого контракта.
- Menu является с одной стороны вариацией DTO’шки, с чуть более закрытым контрактом чем у ClientAsk например, а с другой стороны контрактом, который скрывает логику получения настроек (а она может быть весьма замысловатой). Как и BaristaDependencies, Menu является компонентом на понятийном уровне (если бы мы рисовали схемку, то Menu был бы квадратиком над стрелочкой), и реализация этого контракта также будет компонентом.
Модуль как компонент
Как я уже писал выше, компоненты фрактальны, а поэтому применять мы их можем не только к классам, но и к модулям. Таким образом у нас может быть модуль (давайте например думать в SPM-модулях для iOS/Swift) Баристы BaristaModule, который имеет Package.swift и в этом модуле лежит собственно исходный код по тому, как готовить кофе. Однако, реализовывать например, подачу воды или определение сортов кофе внутри этого модуля было бы неправильно, потому что подача воды и сорта кофе - это вещи, которыми пользуется не только бариста.
Таким образом мы могли бы определить еще модули, которые мы могли бы рассмотреть с позиции, что модуль BaristaModule - это компонент
- Входные данные: нам нужен способ получать заказы клиентов, поэтому входными данными может быть модуль заказов (AskModule), в котором определяются способы сделать заказ и формализуются для передачи баристе
- Выходные данные: нам нужен способ передать чашку клиенту, и здесь может быть модуль, который непосредственно доставляет чашку, например OfficientModule. К слову на вход и выход может работать один и тот же модуль официанта.
- Зависимости: как уже писал выше - это все что необходимо для баристы для работы, поэтому сюда все модули, которые предоставляют функционал баристе (WaterTap, MilkProvider, CoffeeMachine Shop, …).
- Конфигурация: тут модуль управления, например дирекция кофе-буфетов.
18.02.2025
Сегодня поговорим о том, почему сущности вырастают. Вроде бы маленькие и понятные, через какое-то время они становятся большими и непонятными. Напоминает ситуацию, как будто вы приручили маленького котенка, а он потом вырос в саблезубого тигра. Почему так происходит и что делать? Постараюсь на простых примерах поразмышлять над этим.
Почему?
Но начнем чуть издалека, рассмотрим такой пример. Нам надо включать лампочку. Оставим за скобками вечный вопрос сколько программистов для этого надо, и просто напишем код.
class Lamp {
private(set) var isOn: Bool = false
func switchOn() {
isOn = true
// do the actual switch
}
func switchOff() {
isOn = false
// do the actual switch
}
}
Вроде бы все отлично и все понятно (я намеренно опустил логику переключения, она тут вторична). Эта лампочка уходит в релиз и все работает, все довольны.
Но это не конец истории, через, скажем, месяц, приходит продакт и говорит, а теперь нам нужны диммируемые лампочки (это такие у которых яркость можно измерять, очень полезно). И вроде бы отлично, диммируемые лампочки - это хороший продукт. Мы засучиваем рукава и смело в бой. Модифицируем наш класс
class Lamp {
let isDimmable: Bool
private(set) var isOn: Bool
private(set) var dimValue: Double
init(isDimmable: Bool, isOn: Bool = false, dimValue: Double = 0.0) {
self.isDimmable = isDimmable
self.isOn = isOn
self.dimValue = dimValue
}
func switchOn() {
isOn = true
// do the actual switch
}
func switchOff() {
isOn = false
// do the actual switch
}
func dim(value: Double) {
if (value > 0) {
switchOn()
}
dimValue = value
// do the actual dim
}
}
и такой код даже будет работать. И вы даже быстро его напишите, и даже релиз случится. Но как мы видим, уже тут есть проблемы:
- комбинаторика состояний isOn и dimValue. Я добавил некоторую проверку в функции dim(value:) но легко мог ее забыть. И тогда лампочка начала бы вести себя странно.
- любая простая лампочка начинает знать про dimValue даже если она не dimmable.
прежде чем это все начинать фиксить, давайте еще усложним проблему. Продакты добавляют линейку устройств реле. Реле позволяет управлять не только светом, а например, чайником. Или светом но в выключателе. Или любым другим устройством которое можно включить в розетку. Отлично! Давайте чуть перепишем наш класс.
class SomethingSwitchable {
let isDimmable: Bool
let isRelay: Bool
let switchCallback: ((_ isOn: Bool) -> Void)?
private(set) var isOn: Bool
private(set) var dimValue: Double
init(isDimmable: Bool, isRelay: Bool, isOn: Bool = false, dimValue: Double = 0.0, switchCallback: ((_ isOn: Bool) -> Void)?) {
self.isDimmable = isDimmable
self.isRelay = isRelay
self.switchCallback = switchCallback
self.isOn = isOn
self.dimValue = dimValue
}
func switchOn() {
isOn = true
// do the actual switch
switchCallback?(isOn)
}
func switchOff() {
isOn = false
// do the actual switch
switchCallback?(isOn)
}
func dim(value: Double) {
if (value > 0) {
switchOn()
}
dimValue = value
// do the actual dim
}
}
class Lamp: SomethingSwitchable {
init(isDimmable: Bool, isOn: Bool = false, dimValue: Double = 0.0) {
super.init(isDimmable: isDimmable, isRelay: false, isOn: isOn, dimValue: 0.0) { isOn in
switch isOn {
case false: // do the actual switch off
case true: // do the actual swift on
}
}
}
}
class Relay: SomethingSwitchable {
init(isOn: Bool = false, switchCallback: ((Bool) -> Void)?) {
super.init(isDimmable: false, isRelay: true, isOn: isOn, dimValue: 0.0, switchCallback: switchCallback)
}
}
Я даже показал, как можно вынести общую логику в “базовый класс”. Но по приведенному коду видно что мы пошли не туда. И наша маленькая приятная лампочка превратилась в страшное нечто.
Как поправить?
Для начала давайте разберем мелкие правки которые можно было сделать, чтобы чуть спасти ситуацию. Эти техники могут быть полезны в целом взять на вооружение.
Я упоминал комбинаторику состояний isOn и dimValue. Пока опустим то, что лампочка и диммируемая лампочка могут быть разными сущностями, просто попробуем исправить ситуацию.
Для этого, обозначим, что isOn и dimValue не являются полностью независимыми, в данном примере dimValue имеет смысл только тогда, когда isOn = true. И, как гласит принцип “явное лучше неявного”, сделаем это явно и перепишем код так:
class Lamp {
enum LampState {
case off
case on(value: Double)
}
let isDimmable: Bool
private(set) var state: LampState
init(isDimmable: Bool, state: LampState) {
self.isDimmable = isDimmable
self.state = state
}
func switchOn() {
state = .on(value: 1.0)
// do the actual switch
}
func switchOff() {
state = .off
// do the actual switch
}
func dim(value: Double) {
state = .on(value: value)
// do the actual dim
}
}
За счет явного введение LampState мы отразили любому читающему наш код, как работает состояние нашей лампочки. И даже код в dim(value:) стал более понятным - мы явно включим лампочку и включим ее в нужное состояние сразу (она не будем промаргивать через состояние 1.0).
Также можно явно заметить, что тип Double по области значений явно шире, чем нам надо. Явно диммирование управляется через диапазон 0-1. И это тоже можно явно отразить, опять же, явное лучше неявного.
struct DimValue {
private static let minimum = 0.0
private static let maximum = 1.0
private(set) var value: Double
init(value: Double) {
self.value = Self.clamp(value, min: Self.minimum, max: Self.maximum)
}
private static func clamp(_ value: Double, min: Double, max: Double) -> Double {
return Swift.max(min, Swift.min(value, max))
}
mutating func setValue(_ newValue: Double) {
value = Self.clamp(newValue, min: Self.minimum, max: Self.maximum)
}
public static let max = DimValue(value: Self.maximum)
}
class Lamp {
enum LampState {
case off
case on(value: DimValue)
}
let isDimmable: Bool
private(set) var state: LampState
init(isDimmable: Bool, state: LampState) {
self.isDimmable = isDimmable
self.state = state
}
func switchOn() {
state = .on(value: .max)
// do the actual switch
}
func switchOff() {
state = .off
// do the actual switch
}
func dim(value: DimValue) {
state = .on(value: value)
// do the actual dim
}
}
Таким образом мы увидим несоответствие кода сразу же, да еще и компилятором проверим.
Меня можно укорить в том, что я на самом деле увеличил объем кода по сравнению с моей же реализацией диммируемой лампочки. Да, кода стало чуть больше, но он стал более явно выражать то, что он делает, без предположений существующих только в головах.
Теперь давайте рассмотрим более фундаментальную проблему - у нас все лампочки диммируемые. Даже те, которые нет. И на самом деле поле isDimmable не сильно помогает решить проблему, потому что как вы можете заметить, оно устанавливается в конструкторе, но нигде не используется - про него забыли.
И попутно непонятно, а как теперь быть лампочкам недиммируемым? Игнорировать значение диммера?
Давайте начнем приводить код в чувство, попутно учитывая будущее когда у нас появится реле.
Формально - у нас есть три сущности:
- Что-то что может включаться
- Что-то что может диммироваться
- Что-то что может включать других (ака реле)
На самом деле наш код базовой реализации класса SomethingSwitchable дает еще и четвертый вариант, что-то вроде “диммируемого реле”, которое на самом деле может быть регулятором скорости/яркости.
Попутно вспомним Interface Segregation Principle и заведем такие сущности.
Для начала что-то, что может переключаться
protocol Switchable {
var isOn: Bool { get }
func switchOn()
func switchOff()
}
Это может быть лампочка, может быть реле. Далее, что-то что можно диммировать
protocol Dimmable {
var dimValue: DimValue { get }
func dim(value: DimValue)
}
И наконец, что-то, что может управлять другими
protocol Controling {
func setSwitchCallback(_ switchCallback: @escaping (_ isOn: Bool) -> Void)
}
Теперь у нас есть базовые кубики, из которых мы можем строить что-то более сложное.
Простую лампочку сделаем так
class YablochkovLamp: Switchable {
private(set) var isOn: Bool = false
func switchOn() {
isOn = true
// do the on logic
}
func switchOff() {
isOn = false
// do the off logic
}
}
Заметьте, она может включаться/выключаться, ее состояние простое. Оно не искажается состоянием диммируемой лампочки и может использовать простое булево значение (вопросы thread-safety пока опустим).
Диммируемая лампочка будет такой:
class DimmableLamp {
private enum State {
case off
case on(value: DimValue)
}
private var state: State = .off
}
extension DimmableLamp: Switchable {
var isOn: Bool {
switch state {
case .off: return false
case .on: return true
}
}
func switchOn() {
state = .on(value: .max)
// do the logic
}
func switchOff() {
state = .off
// do the logic
}
}
extension DimmableLamp: Dimmable {
var dimValue: DimValue {
switch state {
case .off: return .min
case let .on(value): return value
}
}
func dim(value: DimValue) {
state = .on(value: value)
// do the logic
}
}
Тут отметим, что на уровне типов можно явно отметить, что эта лампочка диммируемая, теперь сложно об этом забыть. Кроме того, я показал, что с помощью extension’ов можно расширить логику после создания типа (ретроактивно). И в довесок спрятал от внешнего наблюдателя внутреннее состояние лампочки.
Ну и теперь реле:
class Relay {
private(set) var isOn: Bool = false
private var switchCallback: ((_ isOn: Bool) -> Void)?
}
extension Relay: Switchable {
func switchOn() {
isOn = true
switchCallback?(isOn)
}
func switchOff() {
isOn = false
switchCallback?(isOn)
}
}
extension Relay: Controling {
func setSwitchCallback(_ switchCallback: @escaping (Bool) -> Void) {
self.switchCallback = switchCallback
}
}
Как видим состояние реле проще состояние диммируемой лампочки (не надо явно учитывать сайд эффект и понятно), но при этом функциональность реле реализована так, как надо.
Такая детализация позволяет:
- Строить сущности из кубиков (я не упоминул еще о дефолтных реализация в протоколах, что тоже улучшает ситуацию)
- Уменьшать комбинаторный взрыв за счет разделения и в целом упрощать реализацию сущностей
- Заменять конкретные классы в процессе развития кода на другие, удовлетворяющие контракту.
Про третий пункт отдельно. Например, лампочка у нас “уехала” на другой хост в ip сети, и чтобы скрыть от управляющего кода этот факт и не делать его сложнее чем надо, мы можем изобрести такую связку проксей:
class SwitchableProxyClient: Switchable {
// private let network: NetworkClient
var isOn: Bool {
// network.proxyIsOn
return false
}
func switchOn() {
// network.proxySwitchOn()
}
func switchOff() {
// network.proxySwitchOff()
}
}
class SwitchableProxyServer {
private let switchable: Switchable
// private let networkServer: NetworkServer
init(switchable: Switchable) {
self.switchable = switchable
// networkServer.switchOn = {
// switchable.switchOn()
// }
// networkServer.switchOff = {
// switchable.switchOff()
// }
// networkServer.isOn = {
// switchable.isOn
// }
}
}
и благодаря им, управляющий код не будет знать, что лампочка “уехала”, а в проксе можно реализовать гарантии надежности для всех, кто может быть Switchable.
А мораль?
А мораль в том, что, когда вы пишите или что еще более важно модифицируете какую-то сущность, хорошо бы подумать о том, как ваша модификация сказывается на том, чем сущность является. Не превращаете ли вы ее в франкенштейна и может быть ее стоит подробить на концепции и эти концепции использовать.
В конце концов, то как вы обозначили контракт вашей сущности влияет на то, как ее используют. И как понимают в целом, что это.
08.04.2024
Тема внедрение зависимостей одна из краеугольных в целом при любой разработке. И, благодаря этому, одна из наиболее дискуссионных, порой доходящаяя до уровня “священных войн” между апологетами разных подходов. В этой статье постараюсь изложить свое видение подходов к решению проблем.
Преамбула
Для того, чтобы не превращать эту статью в рассказ “от сотворения мира”, очень рекомендую посмотреть это видео (с указанного таймкода, лекция там в целом про архитектуру, но нас в рамках этой статьи будут интересовать выкладки про DI).
Постановка проблемы
Базовая задача
Итак, мы стараемся придумать решение следующей проблемы
-
protocol SomeBookService { /* ... */ }
class BookProcessor {
let service: SomeBookService = SomeBookServiceImpl()
}
-
interface SomeBookService { /* ... */ }
class BookProcessor(
private val service: SomeBookService = SomeBookServiceImpl()
)
Что мы хотим от хорошего решения?
- Через протокол/интерфейс. Использующий зависимость класс не должен знать о конкретной реализации и должен иметь работать с любой реализацией правильно реализующей контракт (объявленный в протоколе/интерфейсе).
- Узкий контракт. Реализация, которую мы получаем в виде зависимости должена иметь минимально необходимый контракт. Наша сущность не должна видеть лишнего
- Необходимость и достаточность. Поставляемые зафисимости поставляются в необходимом и достаточном объеме, не требуется каких-то специальных приседаний для того, чтобы получить что-то еще (это как правило относится к запрету использовать внутри синглтоны).
- Реализация DI для логического кода “невидима”. Мы не должны видеть фрагменты порождающего кода где-либо (кроме некоторых исключений).
Платформенные особенности
Мы рассматриваем мобильные платформы (в целом можно рассматривать и серверные, но там как правило все проще в этом плане).
iOS
Как правило, все сущности iOS позволяют нормально реализовать constructor injection паттерн без необходимости построения специальных решений. Иногда требуются специальные действия для создания циклических зависимостей, но они легко решаются способами, описанными ниже.
Единственный объект, который система создает сама - это UIApplication (и applicationDidFinishLaunchingWithOptions) или SwiftUI объект обозначенный как @main. Но эти объекты будем считать точкой входа и растить графы от них.
Android
У Android также как и в iOS есть главный объект создаваемый системой - Application, но есть и важное отличие - в Android есть объекты, которые фреймворком могут быть убиты и пересозданы через тривиальный конструктор, такие как Activity, Fragment, View и тд. И есть общепринятая практика использовать для иньекции зависимостей в такие объекты через паттерн Service Locator. Service Locator считаем антипаттерном (как минимум он противоречит принципам хорошего решения, упомянутым выше).
Поэтому для решение проблемы спец сущностей (которые могут быть пересозданы), в Android надо предусмотреть дополнительные средства.
O Service Locator’е
Service Locator это такой паттерн проектирования, который, упрощенно говоря, предоставляет единую точку, через которую можно запросить разные зависимости. В рамках создания библиотек для DI он обычно соседствует с паттерном Singleton предоставляя публично известный разделяемый объект, из которого можно запросить практически все что угодно.
Я считаю его антипаттерном в применении к задачам DI (у него есть другая применимость, которая норм).
Но чтобы не быть голословным, давайте рассмотрим поставленные требования к хорошему решению:
- Через протокол/интерфейс - эту задачу в целом можно решить, отдавая из публичного singleton’а сервис локатора интерфейсы
- Узкий контракт - так как точка эта общеизвестная, то надо отдавать зависимость целиком, что нарушает принцип сужения контракта. Можно из публичного singleton’а отдавать одну реализацию под набором интерфейсов, но тогда у нас будет очень сложно выглядеть общий контракт такого объекта, сложно будет что-то в нем найти.
- Необходимость и достаточность - мы показываем всевозмоюжные интерфейсы или даже реализации, что любой клиент может видеть все приложение, что явно нарушает это требование
- “Невидимость” - требование явно нарушается, так как из любой точки приложения можно публичный singleton локатора
Таким образом Service Locator использовать не стоит, поэтому будем строить решение на базе концепции контейнера.
О библиотечных решениях
В мире написано огромное количество библиотек, заявляющих что так или иначе решают проблему внедрения зависимостей. Однако большая часть из них основана на паттерне Service Locator. Справедливости ради стоит сказать, что наиболее популярная библиотека Dagger 2 для Android пытается использовать концепцию контейнера, и в Sevice Locator его чаще всего превращают при использовании.
Идея решения проблемы
Как уже было сказано выше - будем использовать концепцию контейнера.
Контейнер - это такая сущность, которыя
- Создает нужные объекты и управляет их временем жизни
- Настраивает связи между ними
- Невидима для самих объектов
Каждая конкретная логическая сущность (кроме самих контейнеров) заявляет необходимость зависимостей (через constructor или property injection) и все. Никоим образом логическая сущность не получает доступа не к типу контейнера, ни к его инстансу (кроме как через Factory интерфейсы там где нужна prototype зависимость).
Отступление про нейминг. Однажды я увидел, как в коде контейнеры называют графами (Graph) и мне эта идея так понравилась, что далее буду называть типы контейнеров графами, можно в рамках этой заметки считать эти термины эквивалентными для задач DI.
Таким образом простейший контейнер может выглядеть так
-
final class SomeGraph {
// 'singleton' entities
private let dep1: Dep1
private let dep2: Dep2
private let dep3: Dep3
// other graphs
private let subGraph: SomeSubGraph
init(
someGraphDependencies: SomeGraphDependencies,
someSpecificDependency: SomeSpecificDependency,
configuration: SomeGraphConfiguration,
...
) {
self.dep1 = Dep1(
/* ... */,
makeSome: {
return self.makeSomePrototypeDep(val: configuration.val)
}
)
self.dep2 = Dep2(/* ... */, useVal: configuration.useVal)
self.dep3 = Dep3(dep1: Dep1, dep2: Dep2)
// ...
self.subGraph = SomeSubGraph(
/* ... */
)
}
deinit {
// тут некоторая логика очистки сущностей, которые того требуют
}
// 'prototype' entities
private func makeSomePrototypeDep(val: Val1) -> Dep4 {
// ...
return Dep4(
val: val
)
}
}
-
class SomeGraph(
someGraphDependencies: SomeGraphDependencies,
someSpecificDependency: SomeSpecificDependency,
private val configuration: SomeGraphConfiguration,
...
) {
// 'singleton' entities
private val dep1: Dep1
private val dep2: Dep2
private val dep3: Dep3
// other graphs
private val subGraph: SomeSubGraph
init {
this.dep1 = Dep1(
/* ... */,
makeSome = {
return makeSomePrototypeDep(val = configuration.val)
}
)
this.dep2 = Dep2(/* ... */, useVal = configuration.useVal)
this.dep3 = Dep3(dep1 = Dep1, dep2 = Dep2)
// ...
this.subGraph = SomeSubGraph(
/* ... */
)
}
fun cleanup() {
// в Java/Kotlin нет полноценных деструкторов, поэтому cleanup методы
// необходимо будет вызывать руками
// тут некоторая логика очистки сущностей, которые того требуют
}
// 'prototype' entities
private fun makeSomePrototypeDep(val: Val1): Dep4 {
// ...
return Dep4(
val = val
)
}
}
И все - собирайте нужную конструкцию из иерархических графов. Когда какой-то набор сущностей станет ненужным - зануляете граф и все очищается (в Android не забываем звать cleanup).
PROFIT? не совсем, есть тонкости
iOS
В iOS как правило все проходит без проблем. Мы создаем корневой граф-контейнер в applicationWill/DidLaunchingWithOptions и передаем его целиком или частями в дальнейшие сущности, или создаем его в корневом объекте помеченном @main и также передаем целиком или частями дальше. Главное не забывать о сужении интерфейсов и принципе достаточной необходимости.
Android
Для бизнесовых сущностей достаточно также в рамках класса Application создать корневой граф и действовать аналогично iOS, но, как я уже выше упоминал, есть проблемы с андроидными сущностями. Поэтому для них нужно специальное решение.
Заведем пару контрактов
// любая сущность, в которую наш DI сможет что-то инжектить
interface Injectable
// контракт сущности контейнера, который сможет что-то куда-то инжектить
interface Injector {
fun inject(into: Injectable) // можно опционально return Result
}
Чуть выше в разделе про Service Locator я уже говорил, что основная проблема в нем в том, что
- его shared instance известен публично, и может быть использован скрыто. С этим увы ничего не поделать, андроидные компоненты устроены так, что нам придется пойти на открытие какого-то shared instance. Для activity/fragment есть лазейка, о ней чуть позже, но в общем итоге не поделать ничего.
- из него можно достать что угодно, эту проблему будем решать.
Также важно, что для Android компонентов инъекция будет асинхронной относительно вызова конструктора объектов, поэтому полноценную compile time проверку мы сделать не сможем. Ограничимся сокрытием лишней информации.
Теперь нам нужно предусмотреть сущность, которую мы будем видеть через shared instance, но не моч из нее ничего достать
class SomeGraph private constructor(
private val dependency: SomeDependnecy,
// other deps
): Injector {
override fun inject(target: Injectable) = when (target) {
is SomeKnownFragment -> {
target.dependency = dependency
}
else -> {
// report error in some kind
}
}
companion object {
var sharedInstance: SomeGraph? = null
private set
fun setup(
dependency: SomeDependency
) {
synchronized(this) {
if (sharedInstance != null) {
return
}
sharedInstance = SomeGraph(
dependency = dependency
)
}
}
}
}
И теперь в условном фрагменте мы можем написать так
class SomeKnownFragment: Fragment(), Injectable {
lateinit var dependency: Dependency? = null
override fun onAttach(context: Context) {
super.onAttach(context)
SomeGraph.sharedInstance?.inject(this)
}
Как я уже сказал, есть некоторая проблема в том, что связывание будет проверено только в runtime, а правильнее было бы сделать в compile time, но архитектура компонентов Android’а тут этому препятствует.
К слову такой подход хорошо подходит для библиотек, этот SomeGraph может быть объектов библиотеки, которую надо проинициализировать и потом сама библиотека будет ее использовать. Для Activity/Fragment и тд можем сделать лучше, для этого нам потребуется еще немножко сахара (покажу на примере Activity, для фрагментов можно сделать аналогично)
fun Activity.inject() {
val thisAsInjectable = this as? Injectable ?: throw InjectException("activity not injectable")
val application = application ?: throw InjectException("application isn't set to activity")
val injector = application as? Injector ?: throw InjectException("application is not an injector")
injector.inject(thisAsInjectable)
}
теперь надо класс приложения разметить соответствующим образом
class YourApp: Application(), Injector {
private lateinit var rootGraph: RootGraph
override fun onCreate() {
super.onCreate()
rootGraph = RootGraph(this)
}
override fun inject(target: Injectable) {
if (!this::graph.isInitialized) {
error("Application isn't properly initialized yet")
}
graph.inject(target)
}
}
корневой граф будет выглядеть примерно так
class RootGraph(
applicationContext: Context
): Injector {
// private val yourdeps = ...
init {
// тут инициализация нужных компонентов
}
override fun inject(target: Injectable) {
when (target) {
is MainActivity -> {
target.depepdency = ...
}
// можно делегировать что-то субграфам
}
}
}
И в самой активити будет просто
class MainActivity : Activity(), Injectable {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
inject()
// остальная инициализация
}
}
Никаких синглетонов и никакого раскрытия лишних зависимостей - PROFIT.
P.S. Если придумаете как решить эту проблему с аналогичными гарантиями да еще и в compile time буду благодарен
06.11.2019
Внедрение зависимостей очень горячая тема в любой области разработки, где мы пишем что-то более сложное чем Hello, World. Однако несмотря на казалось бы изученный вдоль и поперек вопрос, вариантов его решения вы можете на просторах интернета найти великое множество. И в каждом месте оно подается как единственно правильное. И как же выбрать? Предлагаю в этой статье немножко рассмотреть подходы, их плюсы и минусы, немножко поиграться со Swift’ом вообще и попробовать его новые фичи в виде @PropertyWrapper’s.
Итак, постановка задачи у нас будет такая - у нас есть два класса BooksRenderer, который просто каким-то образом рисует книжки, и BooksProvider, который ему их поставляет. На Swift это будет выглядеть примерно так:
final class BooksRenderer {
let provider: BooksProvider = ... /* за это троеточие и будет вестись основная борьба */
func draw() {
let books = provider.books
/* тут каким-то образом рисуются книги из массива books */
}
}
protocol BooksProvider {
var books: [Book] { get }
}
Будем также считать что есть некая реализация протокола BooksProvider, например такая наивная
final class NaiveBooksProvider: BooksProvider {
var books: [Book] {
return [
Book(title: "Dune", author: "Frank Herbert"),
Book(title: "Lord of the Rings", author: "John R.R. Tolkien")
]
}
}
Теперь наша задача каким-то образом доставить экземпляр класса NaiveBooksProvider в BooksRenderer. Самый наивный подход такой, создать экземлляр класса прямо на месте:
final class BooksRenderer {
let provider: BooksProvider = NaiveBooksProvider()
}
Несмотря на то, что этот подход, каким бы наивным он не был, много где применяется, у него есть очевидные недостатки:
- Мы можем захотеть как-то менять конкретный класс реализации, а он тут “прибит гвоздями”
- Мы можем захотеть unit-протестировать класс BooksRenderer, и тогда вместо провайдера захотим вставить какой-нибудь мок
и т.д.
Нам надо что-то лучше. И много где предлагают хорошо известный паттер ServiceLocator. Если его применить, то выглядеть это будет примерно так:
final class ServiceLocator {
static let booksProvider: BooksProvider = NaiveBooksProvider()
}
final class BooksRenderer {
let provider: BooksProvider = ServiceLocator.booksProvider
}
Уже лучше, ответственность за выбор конкретного класса мы достали из BooksRenderer и наделили этой почетной обязанностью класс ServiceLocator. И мы даже можем сделать разные ServiceLocator’ы для основного приложения и для тестов, которые будут создавать разные BooksProvider’ы, однако:
- Теперь 90% кода будет знать про класс ServiceLocator
- Класс ServiceLocator будет огромным (кто там что говорил про Massive View Controller?, у нас тут Massive Service Locator)
Прежде чем пойти дальше, давайте сделаем некоторое лирическое отступление, разберемся в терминологии зависимостей. Вообще внедряемых зависимостей может быть два типа: прости хоспади singleton (но это не то что вы подумали) и prototype. “singleton” зависимости - это такие зависимости, которые сколько бы вы не внедряли в рамках одного конкретного модуля, это всегда будет один экземпляр. “prototype” же - даст на каждую точку внедрения новый экземпляр.
Поэтому если говорить про наш пример с ServiceLocator’ом, то например booksProvider - это singleton зависимость, а bookUpdateOperation - prototype:
final class ServiceLocator {
static let booksProvider: BooksProvider = NaiveBooksProvider()
static func bookUpdateOperation() -> Operation & BookUpdate {
return NaiveBookUpdateOperation(...)
}
}
Теперь давайте сделаем еще одно лирическое отступление, подчерпнутое мной когда я еще занимался “кровавым” enterprise и работал с Srping Framework. Хороший DI контейнер это такой контейнер, который не видно. Тут можно еще пофилософствовать и вспомнить ТРИЗ с ее идеальным конечным результатом, который на наш DI’ный контекст перефразируется так: “хороший DI контейнер - это такой, которого нет, а зависимости внедряются”.
Таким образом, можно сделать такой DI на базе initializer injection (оно лучше property injection, потому что компилятор в этом случае не даст вам озорничать, а с property injection легко забыть что-нибудь присвоить и грохнуться в рантайме):
final class AppContainer {
let booksProvider: BooksProvider
init(with appDelegate: AppDelegate) {
booksProvider = NaiveBooksProvider(...)
appDelegate.booksRenderer = BooksRenderer(provider: booksProvider)
}
}
@UIApplicationMain
final class AppDelegate: UIResponder {
let container: AppContainer
var booksRenderer: BooksRenderer!
func applicationDidFinishLauncherWithOptions(...) {
container = AppContainer(with: self)
}
}
final class BooksRenderer {
let provider: BooksProvider
init(provider: BooksProvider) {
self.provider = provider
}
}
Причем такой подход будет гарантировать вам проверку компилятором. И при этом про AppContainer будет знать только AppDelegate. Да, корневые зависимости в самом AppDelegate’е будут force unwrapped (что исть не хорошо, но лучше я не придумал), но эта вольность доступна только тут.
prootype зависимости в таком подходе можно оформить либо в виде фабрики
final class SomeFactory {
let superDep: SuperDep
init(superDep: SuperDep) {
self.superDep = superDep
}
func makeSomeDep(...) -> SomeDep {
return SomeDep(superDep, ...)
}
}
и потом внедрять это фабрику как singleton зависимость туда где нужно генерить prototype’ные, или в виде замыкания
typealias SomeFactory = (_ superDep: SuperDep, ...) -> SomeDep { SomeDep(superDep, ...) }
и также ее внедрять как singleton зависимость.
Но я обещал немножко Swift 5.1 и @PropertyWrapper, их легко сделать так (пусть будет наш пример с ServiceLocator’ом, хотя его можно легко модифицировать):
@propertyWrapper
public class Inject<Dep> {
private let name: String?
private var kept: Dep?
public var wrappedValue: Dep {
kept ?? {
let dependency: Dep = ServiceLocator.resolve(for: name)
kept = dependency
return dependency
}()
}
public convenience init() {
self.init(nil)
}
public init(_ name: String?) {
self.name = name
}
}
final class ServiceLocator {
static func register<T>(for name: String, resolver: @escaping () -> T) {
// register
}
static func resolve<T>(for name: String?) -> T {
// do some magic
}
}
final class BooksRenderer {
@Inject private var provider: BooksProvider
}
И вуаля! Однако несмотря на всю прелесть такой магии, есть проблема в месте где творится магия (“do some magic”). Если вы вдруг забыли сделать register, то упс, вы получаете рантайм крэш. И сделать это красиво с compile time check непонятно как, так как регистрация динамическая.
Предлагаю подискутировать, оформляйте issues тут
P.S.
08.05.2019
Какое-то время назад я написал статью Разрабатываем и отлаживаем С++ в Docker с помощью VSCode в котором для сборки использовался gcc и для отладки gdbserver. В этом обновленном посте хочу затронуть такой же вопрос, но уже на базе clang 8 и lldb-server.
Также в отличии от предыдущей статьи мы не будем каждый раз пересоздавать контейнер, а запустим его один раз и будет с ним работать через docker exec.
Итак, начнем, Dockerfile
FROM ubuntu:18.04
RUN apt-get update
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential xz-utils curl cmake
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y lldb
RUN curl -SL http://releases.llvm.org/8.0.0/clang+llvm-8.0.0-x86_64-linux-gnu-ubuntu-18.04.tar.xz | tar -xJC . && \
mv clang+llvm-8.0.0-x86_64-linux-gnu-ubuntu-18.04 clang_8.0.0 && \
mv clang_8.0.0 /usr/local
ENV PATH="/usr/local/clang_8.0.0/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/clang_8.0.0/lib:${LD_LIBRARY_PATH}"
WORKDIR /opt/build
VOLUME ["/opt"]
lldb (для lldb-server) используем поставляемый в пакетах (на момент написания статьи для 18.04 это был LLDB 6). Также из пакетов ставим cmake (вроде ничего специфичного пока не надо). clang ставим руками, но уже собранный.
Дальше нам нужен скрипт сборки контейнера (/scripts/docker_build.sh)
#!/bin/bash
cwd=$(pwd)
docker build \
-t something/cmaker \
.
И таска для vscode
{
"label": "build docker container",
"command": "${workspaceFolder}/scripts/docker_build.sh"
}
после этого нам понадобится запустить контейнер для последующего использования, для этого нам нужен скрипт (/scripts/docker_run.sh). Тут попутно запускается lldb-server, о нем позже:
#!/bin/bash
cwd=$(pwd)
docker stop сmaker
docker rm сmaker
docker run \
-dt \
--name сmaker \
-p 7000:7000 \
-p 7001:7001 \
-p 7002:7002 \
-p 7003:7003 \
-v ${cwd}:/opt \
--privileged \
something/cmaker \
"${@}"
и таска для vscode
{
"label": "run container for future builds",
"command": "${workspaceFolder}/scripts/docker_run.sh",
"args": [
"lldb-server", "platform", "--server", "--listen=0.0.0.0:7000", "-m", "7001", "-M", "7003"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
После этого можем приступать к сборке проекта. Структуру проекта сразу предусмотрел для многомодульного проекта
include
scripts
docker_build.sh
docker_run.sh
hword
include
CMakeLists.txt
main.cpp
CMakeLists.txt
Dockerfile
Корневой CMakeLists.txt выглядит так
cmake_minimum_required(VERSION 3.0)
project(something)
set(CMAKE_BINARY_DIR ${CMAKE_SOURCE_DIR}/build)
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR})
set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR})
set(PROJECT_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/include)
include_directories("${PROJECT_INCLUDE_DIR}")
include_directories("${PROJECT_SOURCE_DIR}")
add_subdirectory(hword)
CMakeLists.txt для hword выглядит так
cmake_minimum_required(VERSION 3.0)
project(hword)
set(PROJECT_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/include)
set(HWORD_VERSION_MAJOR 1)
set(HWORD_VERSION_MINOR 0)
set(PROJECT_HWORD_SRCS
${PROJECT_SOURCE_DIR}/main.cpp
)
include_directories("${PROJECT_BINARY_DIR}")
add_executable(${PROJECT_NAME}_r ${PROJECT_HWORD_SRCS})
include_directories("${PROJECT_INCLUDE_DIR}")
Обратите внимание что к имени проекта добавлен постфикс _r
Для примера содержимое main.cpp
#include <unistd.h>
#include <limits.h>
#include <iostream>
using namespace std;
static const int HNAME_MAX = 512;
static const int LNAME_MAX = 512;
int main()
{
char hostname[HNAME_MAX];
char username[LNAME_MAX];
gethostname(hostname, HNAME_MAX);
getlogin_r(username, LNAME_MAX);
cout << "hostname: " << hostname << ", username: " << username << endl;
return 0;
}
Теперь соберем все это дело, запустим и отладим
Нам понадобится скрипт для docker exec
#!/bin/bash
cwd=$(pwd)
docker exec \
-it \
cmaker \
"${@}"
Таска для cmake
{
"label": "cmake initialize scripts (Makefile, Debug)",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"cmake", "-G", "Unix Makefiles",
"-DCMAKE_BUILD_TYPE=Debug",
"-DCMAKE_C_COMPILER=/usr/local/clang_8.0.0/bin/clang",
"-DCMAKE_CXX_COMPILER=/usr/local/clang_8.0.0/bin/clang++",
"/opt"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
Обратите внимание, что тут явным образом указаны пути до компиляторов, это очень важно.
Далее таска для сборки всего проекта и для сборки конкретного
{
"label": "make full project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"make", "-j", "8"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
{
"label": "make (hword) project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"make", "-j", "8", "hword_r"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
И для простого запуска
{
"label": "run (hword) project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"/opt/build/hword_r"
]
}
С отладкой есть несколько проблем. Во-первых, кроме порта на котором слушает lldb-server надо пробросить еще несколько, на которых будет работать собственно соединение (для этого при запуске контейнера несколько опций -p). Во-вторых, lldb-server’у этот диапазон портов надо явно задать. И в третьих, надо правильно написать launch.json, выглядит он так:
{
"name": "debug: hword",
"type": "lldb",
"request": "launch",
"program": "/opt/build/hword_r",
"initCommands": [
"platform select remote-linux",
"platform connect connect://127.0.0.1:7000"
],
"cwd": "/opt",
"sourceMap": {
"/opt" : "${workspaceFolder}"
}
}
Жмем F5 и выуля - мы в отладке. Надеюсь это поможет :)