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

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

Организации и услуги

Бизнес-логика может быть разделена между сущностями и службами.

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

class House {
    address: string
    floorArea: number
}

class Car {
    brand: string
    color: string
}

class User {
    name: string
    email: string
}

class Email {
    id: int
    subject: string
    message: string
}

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

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

interface EmailSenderInterface {
    send(user: User, email: Email)
}

Интерфейс может быть реализован различными классами для предоставления различных вариантов службы. Например, EmailSenderInterface можно реализовать с помощью SynchronousEmailSender, с помощью AsynchronousEmailSender, а для тестов с помощью DummyEmailSender.

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

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

Службы и зависимости

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

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

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

EmailSender будет зависеть от трех других служб: SmtpClient для отправки электронной почты, EmailRepository для взаимодействия с базой данных и Logger для регистрации ошибок.

interface SmtpClientInterface {
    send(toName: string, toEmail: string, subject: string, message: string)
}

interface EmailRepositoryInterface {
    insertEmail(address: string, email: Email, status: string)
    updateEmailStatus(id: number, status: string)
}

interface LoggerInterface {
    error(message: string)
}

class EmailSender {
    client: SmtpClientInterface
    repo: EmailRepositoryInterface
    logger: LoggerInterface
    
    send(user: User, email: Email) {
        try {
            this.repo.insertEmail(user.email, email, "sending")
            this.client.send(user.email, user.name, email.subject, email.message)
            this.repo.updateEmailStatus(email.id, "sent")
        } catch(e) {
            this.logger.error(e.toString())
        }
    }
}

EmailSender имеет 3 зависимости. Чтобы внедрить эти зависимости, вам нужно использовать сеттер. Вы можете добавить методы setSmtpClient(), setEmailRepository() и setLogger() в файл EmailSender.

// inject dependencies with setters
sender = new EmailSender()
sender.setSmtpClient(client)
sender.setEmailRepository(repo)
sender.setLogger(logger)

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

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

class EmailSender {
    // ... dependencies and other attributes ...
    
    constructor(client: SmtpClientInterface, repo: EmailRepositoryInterface, logger: LoggerInterface) {
        this.client = client
        this.repo = repo
        this.logger = logger
    }
    
    // ... methods ...
}

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

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

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

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

Этот конструктор является плохим примером:

constructor() {
    this.client = new SmtpClient()
}

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

Что следует сделать вместо этого:

constructor(client: SmtpClientInterface) {
    this.client = client
}

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

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

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

constructor(client: SmtpClient) {
    this.smtp = smtp
}

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

Создание службы

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

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

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

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

  • step 1 Фреймворк реагирует на событие, поступающее от пользователя (команда cli, http-запрос, щелчок по графическому интерфейсу и т. д.)
  • step 2 Событие анализируется для соответствия вводу службы
  • step 3 Сервис для этого запроса создается среди его зависимостей
  • step 4 Сервисный метод вызывается
  • step 5 Ответ отправляется обратно в фреймворк, чтобы его можно было передать пользователю (сообщение в терминале, http-ответ, …)

Таким образом, бизнес-логика откладывается внутри шага 4. Шаг 3 создает службы, поэтому контроллер — единственное место, где службы привязаны к реализации.

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

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

Контекст приложения

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

Давайте проиллюстрируем это, создав службу с именем NewsletterSender. Этот сервис должен иметь возможность отправлять информационный бюллетень некоторым пользователям. Требуется EmailSender для отправки электронных писем и UserManager, чтобы узнать, кто подписался на информационный бюллетень.

Дерево зависимостей будет выглядеть так:

NewsletterSender
    Logger
    EmailSender
        Logger
        EmailRepository
            DatabaseConnection
    UserManager
        Logger
        UserRepository
            DatabaseConnection

Зависимости не могут быть созданы в любом порядке. Некоторые зависимости должны быть созданы раньше других. Как DatabaseConnection и Logger. Затем можно создать репозитории и файлы EmailSender и UserManager. Как только все это создано, можно создать NewsletterSender.

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

function buildNewsletterSender(): NewsletterSender {
    let logger = new Logger()
    let conn = new DatabaseConnection()
    let userRepo = new UserRepository(conn)
    let emailRepo = new EmailRepository(conn)
    let emailSender = new EmailSender(logger, emailRepo)
    let userManager = new UserManager(logger, userRepo)

    return new NewsletterSender(logger, emailSender, userManager)
}

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

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

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

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

class Context {
    newsletterSender: NewsletterSender
    userUpdater: UserUpdater
    // other services that are not used directly by controllers can also be added here
    logger: Logger
    conn: DatabaseConnection
    userRepo: UserRepository
    emailRepo: EmailRepository
    emailSender: EmailSender
    userManager: UserManager
}

function buildContext(): Context {
    let c = new Context()

    c.logger = new Logger()
    c.conn = new DatabaseConnection()
    c.userRepo = new UserRepository(c.conn)
    c.emailRepo = new EmailRepository(c.conn)
    c.emailSender = new EmailSender(c.logger, c.emailRepo)
    c.userManager = new UserManager(c.logger, c.userRepo)

    c.newsletterSender = new NewsletterSender(c.logger, c.emailSender, c.userManager)
    c.userUpdater = new UserUpdater(c.logger, c.userRepo)

    return c
}

Этот контекст можно использовать во всех ваших контроллерах. И функция buildContext — единственное место, где вам нужно подумать об инициализации службы.

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

Улучшения

Контекст, который мы только что создали, имеет два основных недостатка:

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

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

class Context {
    services: Map<string, any>
    factories: Map<string, () => any>
}

class A {}
class B {a: A}

function buildContext(): Context {
    let context = new Context()
    context.services = new Map()
    context.factories = new Map()

    // register service A
    context.factories.set("A", function() {
        return new A()
    })

    // register service B
    context.factories.set("B", function() {
        let b = new B()
        // We need to retrieve A from the context.
        // We can not use context.services directly because it may not be there yet.
        // So we need to use a retrieveService function that use the factories.
        b.a = retrieveService(context, "A")
        return b
    })

    return context
}

// retrieveService is a function that retrieves a service a the context.
// It uses the factory to build the service.
// The created services are registered in context.services.
// The next times, the service will be retrieved from context.services
// without instantiating the service again.
function retrieveService(context: Context, service: string) {
    if (context.services.has(service)) {
        return context.services.get(service)
    }
    if (!context.factories.has(service)) {
        return null
    }
    let factory = context.factories.get(service)
    let s = factory()
    context.services.set(service, s)
    return s
}

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

Теперь у вас есть ленивая инициализация ваших сервисов. И это решает вторую проблему.

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

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

type Factory = (container: Container) => any;

class Container {
    services: Map<string, any>
    factories: Map<string, Factory>

    constructor() {
        this.services = new Map()
        this.factories = new Map()
    }
    
    set(name: string, factory: Factory) {
        this.factories.set(name, factory)
    }
    
    get(name: string): any {
        if (this.services.has(name)) {
            return this.services.get(name)
        }
        if (!this.factories.has(name)) {
            return null
        }
        let s = this.factories.get(name)(this)
        this.services.set(name, s)
        return s
    }
}

// And now we can use the Container !

let c = new Container()

class A {}
class B {a: A}

c.set("A", function(c: Container) {
    return new A()
})

c.set("B", function(c: Container) {
    let b = new B()
    b.a = c.get("A")
    return b
})

let b = c.get("B") // B has be created with its dependency A
let a = c.get("A") // A has already been created for B, the same instance is reused

console.log(b.a === a) // true

И все готово, вы создали контейнер для внедрения зависимостей!

Это всего около 25 строк кода. Конечно, это минималистично, но основные идеи здесь, и это работает. Код является допустимым машинописным текстом и может быть преобразован в javascript на игровой площадке машинописного текста. Затем javascript может быть выполнен в вашем браузере.

Вывод

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

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

Если вы используете go (golang), посмотрите мою библиотеку sarulabs/di. Он использует концепции, представленные в этой статье, для запуска контейнеров внедрения зависимостей.

Дополнительные примечания

Одиночки

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

class MyServiceBuilder {
    build(): MyService {
        return new MyService()
    }
}

container.set("my-service-builder", function(c: Container) {
    return new MyServiceBuilder()
})

myService = container.get("my-service-builder").build()

Типизированные языки

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

container.get("my-service-builder").(MyServiceBuilder).build()

Контейнер как зависимость

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

class MyService {
    container: Container
}

container.set("my-service", function(c: Container)) {
    let s = new MyService()
    s.container = c
    return s
})

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

Более того, реальные зависимости вашего сервиса больше не ясны, и сервис стало намного сложнее тестировать.

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

Первоначально опубликовано на www.sarulabs.com 12 июня 2018 г.