Практическое введение во внедрение зависимостей (Dependency Injection)

Перевод статьи «A Practical Introduction To Dependency Injection», опубликованный сайтом webdevblog.ru.

Концепция внедрения зависимостей — это, по сути, простое понятие. Но, оно обычно представлено вместе с более сложными теоретическими концепциями инверсии управления, инверсии зависимостей, принципов SOLID и т. д. Я написал эту статью, чтобы вам было как можно проще приступить к использованию Dependency Injection и начать пожинать плоды ее использования в реальном коде. Мы потратим совсем немного времени на обсуждение академических концепций, связанных с внедрением зависимостей. На эту тему были написаны целые книги, обеспечивающие более глубокое и строгое рассмотрение концепций.

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

Простое объяснение

«Внедрение зависимостей — Dependency Injection» — это слишком сложный термин, обозначающий чрезвычайно простую концепцию. На этом этапе можно сразу задать несколько сложных, но очевидных вопросов: «Как вы определяете зависимость?», «Что значит внедрять зависимость?», «Можете ли внедрять зависимость по-разному?» и главный вопрос «почему это полезно?»

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

Самый простой способ объяснить концепцию — рассмотреть следующий пример.

Пример кода, без внедрения зависимостей:

import { Engine } from './Engine';

class Car {
    private engine: Engine;

    public constructor () {
        this.engine = new Engine();
    }

    public startEngine(): void {
        this.engine.fireCylinders();
    }
}

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

import { Engine } from './Engine';

class Car {
    private engine: Engine;

    public constructor (engine: Engine) {
        this.engine = engine;
    }
    
    public startEngine(): void {
        this.engine.fireCylinders();
    }
}

Готово. Круто. Конец объяснения!

Что изменилось? Вместо того, чтобы позволить классу Car создавать экземпляр Engine (как это было в первом примере), во втором примере Car получил или внедрил экземпляр Engine из более высокого уровня управления в его конструкторе. По сути, это все, что является инъекцией зависимостей — акт внедрения (передачи) зависимости в другой класс или функцию. Все остальное, что связано с понятием внедрения зависимостей, является просто вариацией этой фундаментальной и простой концепции. Проще говоря, внедрение зависимостей — это метод, с помощью которого объект получает другие объекты, от которых он зависит, называемые зависимостями, а не создает их сам.

В общем случае, чтобы определить, что такое «зависимость», скажем так, если некоторый класс A использует функциональные возможности класса B, тогда B является зависимостью для A, или, другими словами, A имеет зависимость от B. Конечно, это не не ограничивается классами и относится также к функциям. В этом случае функция Car зависит от функции Engine, или Engine является зависимостью для Car. Зависимости — это просто переменные, как и большинство вещей в программировании.

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

Например, если мы хотим увидеть, что делает Car.startEngine (), если engine.fireCylinders () выдает ошибку, мы могли бы просто создать класс FakeEngine, расширить его от класса Engine, а затем переопределить fireCylinders, чтобы он выдал ошибку. В тесте мы можем внедрить этот объект FakeEngine в конструктор Car.

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

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

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

Пример из реального мира

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

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

Для начала предположим, что вам было поручено создать два класса — поставщика электронной почты email provider и класс для уровня доступа к данным, который должен использоваться некоторым UserService. Мы начнем с доступа к данным, для него создам еще один класс UserRepository:

// UserRepository.ts

import { dbDriver } from 'pg-driver';

export class UserRepository {
    public async addUser(user: User): Promise<void> {
        // ... dbDriver.save(...)
    }

    public async findUserById(id: string): Promise<User> {
        // ... dbDriver.query(...)
    }
    
    public async existsByEmail(email: string): Promise<boolean> {
        // ... dbDriver.save(...)
    }
}

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

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

В UserService вы должны импортировать класс UserRepository, создать его экземпляр и начать использовать:

import { UserRepository } from './UserRepository.ts';

class UserService {
    private readonly userRepository: UserRepository;
    
    public constructor () {
        // Not dependency injection.
        this.userRepository = new UserRepository();
    }

    public async registerUser(dto: IRegisterUserDto): Promise<void> {
        // User object & validation
        const user = User.fromDto(dto);

        if (await this.userRepository.existsByEmail(dto.email))
            return Promise.reject(new DuplicateEmailError());
            
        // Database persistence
        await this.userRepository.addUser(user);
        
        // Send a welcome email
        // ...
    }

    public async findUserById(id: string): Promise<User> {
        // No need for await here, the promise will be unwrapped by the caller.
        return this.userRepository.findUserById(id);
    }
}

ВкратцеDTO — это объект передачи данных (Data Transfer Object) — объект, который действует как пакет свойств для определения стандартизированной формы данных при перемещении между двумя внешними системами или двумя уровнями приложения. Вы можете узнать больше о DTO из статьи Мартина Фаулера по этой теме здесь. В этом случае IRegisterUserDto определяет контракт о том, какой должна быть форма данных по мере их поступления от клиента. У меня есть только два свойства — id и email. Вам может показаться странным, что DTO, которое мы ожидаем от клиента для создания нового пользователя, содержит идентификатор пользователя, даже если мы еще не создали пользователя. Идентификатор — это UUID, и я разрешаю клиенту сгенерировать его по ряду причин, которые выходят за рамки этой статьи. Кроме того, функция findUserById должна сопоставлять объект User с DTO ответа, но я пренебрегаю этим для краткости. Наконец, в реальном мире у меня не было бы модели домена пользователя, содержащей метод fromDto. Это плохо для чистоты домена. И снова здесь основная цель — краткость.

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

// SendGridEmailProvider.ts

import { sendMail } from 'sendgrid';

export class SendGridEmailProvider {
    public async sendWelcomeEmail(to: string): Promise<void> {
        // ... await sendMail(...);
    }
}

В UserService:

import { UserRepository }  from  './UserRepository.ts';
import { SendGridEmailProvider } from './SendGridEmailProvider.ts';

class UserService {
    private readonly userRepository: UserRepository;
    private readonly sendGridEmailProvider: SendGridEmailProvider;

    public constructor () {
        // Still not doing dependency injection.
        this.userRepository = new UserRepository();
        this.sendGridEmailProvider = new SendGridEmailProvider();
    }

    public async registerUser(dto: IRegisterUserDto): Promise<void> {
        // User object & validation
        const user = User.fromDto(dto);
        
        if (await this.userRepository.existsByEmail(dto.email))
            return Promise.reject(new DuplicateEmailError());
        
        // Database persistence
        await this.userRepository.addUser(user);
        
        // Send welcome email
        await this.sendGridEmailProvider.sendWelcomeEmail(user.email);
    }

    public async findUserById(id: string): Promise<User> {
        return this.userRepository.findUserById(id);
    }
}

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

Что произойдет, если мы решим, что нам нужно отказаться от SendGrid для электронной почты и использовать вместо этого MailChimp? Аналогично, что происходит, если мы захотим провести модульное тестирование наших методов — будем ли мы использовать в тестах реальную базу данных? Хуже того, собираемся ли мы отправлять настоящие электронные письма на потенциально реальные адреса электронной почты и платить за это?

В традиционной экосистеме JavaScript методы модульного тестирования классов в этой конфигурации будут очень сложными. Люди вынуждены подключать целые библиотеки просто для обеспечения функциональности заглушек, которые добавляют всевозможные уровни абстракции и, что еще хуже, могут напрямую связывать тесты с реализацией тестируемой системы, хотя на самом деле тесты никогда не должны знать о том как реальная система работает (это известно как тестирование черного ящика). Далее мы будем работать над смягчением этих проблем, обсуждая фактическую ответственность UserService и применяя новый метод «внедрения зависимостей».

Рассмотрим на мгновение, что делает UserService. Весь смысл существования UserService заключается в выполнении конкретных сценариев использования пользователей — их регистрации, чтения, обновления и т. д. Лучше всего, чтобы классы и функции имели только одну ответственность (SRP — принцип единой ответственности), а UserService отвечает за множество операций, связанных с пользователем. Почему же тогда UserService отвечает за управление временем жизни (то есть создает экземпляры этих классов) UserRepository и SendGridEmailProvider в этом примере?

Представьте себе, что у нас есть какой-то другой класс, использующий UserService, который открывает длительное соединение. Должен ли UserService быть ответственным за удаление этого соединения? Конечно, нет. У всех этих зависимостей есть связанный с ними срок жизни — они могут быть одиночными, они могут быть временными и ограничиваться определенным HTTP-запросом и т. д. Управление этими сроками жизни находится далеко за пределами компетенции UserService. Итак, чтобы решить эти проблемы, мы внедрим все зависимости, как мы рассмотрели раньше.

import { UserRepository }  from  './UserRepository.ts';
import { SendGridEmailProvider } from './SendGridEmailProvider.ts';

class UserService {
    private readonly userRepository: UserRepository;
    private readonly sendGridEmailProvider: SendGridEmailProvider;

    public constructor (
        userRepository: UserRepository,
        sendGridEmailProvider: SendGridEmailProvider
    ) {
        // Yay! Dependencies are injected.
        this.userRepository = userRepository;
        this.sendGridEmailProvider = sendGridEmailProvider;
    }

    public async registerUser(dto: IRegisterUserDto): Promise<void> {
        // User object & validation
        const user = User.fromDto(dto);

        if (await this.userRepository.existsByEmail(dto.email))
            return Promise.reject(new DuplicateEmailError());
        
        // Database persistence
        await this.userRepository.addUser(user);
        
        // Send welcome email
        await this.sendGridEmailProvider.sendWelcomeEmail(user.email);
    }

    public async findUserById(id: string): Promise<User> {
        return this.userRepository.findUserById(id);
    }
}

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

Зададимся парой вопросов. Во-первых, почему UserService знает, что мы используем SendGrid для электронной почты? Во-вторых, обе зависимости относятся к конкретным классам — конкретному UserRepository и конкретному SendGridEmailProvider. Эти отношения слишком жесткие — мы застряли в необходимости передавать некоторые объекты, которыми является UserRepository и SendGridEmailProvider.

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

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

Начните с определения интерфейса для UserRepository и его реализации:

// UserRepository.ts

import { dbDriver } from 'pg-driver';

export interface IUserRepository {
    addUser(user: User): Promise<void>;
    findUserById(id: string): Promise<User>;
    existsByEmail(email: string): Promise<boolean>;
}

export class UserRepository implements IUserRepository {
    public async addUser(user: User): Promise<void> {
        // ... dbDriver.save(...)
    }

    public async findUserById(id: string): Promise<User> {
        // ... dbDriver.query(...)
    }

    public async existsByEmail(email: string): Promise<boolean> {
        // ... dbDriver.save(...)
    }
}

Далее определим интерфейс для провайдера электронной почты:

// IEmailProvider.ts
export interface IEmailProvider {
    sendWelcomeEmail(to: string): Promise<void>;
}

// SendGridEmailProvider.ts
import { sendMail } from 'sendgrid';
import { IEmailProvider } from './IEmailProvider';

export class SendGridEmailProvider implements IEmailProvider {
    public async sendWelcomeEmail(to: string): Promise<void> {
        // ... await sendMail(...);
    }
}

Примечание. Тут я использовал шаблон Adapter Pattern.

Теперь наш UserService будет зависеть от интерфейсов, а не от конкретных реализаций зависимостей:

import { IUserRepository }  from  './UserRepository.ts';
import { IEmailProvider } from './SendGridEmailProvider.ts';

class UserService {
    private readonly userRepository: IUserRepository;
    private readonly emailProvider: IEmailProvider;

    public constructor (
        userRepository: IUserRepository,
        emailProvider: IEmailProvider
    ) {
        // Double yay! Injecting dependencies and coding against interfaces.
        this.userRepository = userRepository;
        this.emailProvider = emailProvider;
    }

    public async registerUser(dto: IRegisterUserDto): Promise<void> {
        // User object & validation
        const user = User.fromDto(dto);

        if (await this.userRepository.existsByEmail(dto.email))
            return Promise.reject(new DuplicateEmailError());
        
        // Database persistence
        await this.userRepository.addUser(user);
        
        // Send welcome email
        await this.emailProvider.sendWelcomeEmail(user.email);
    }

    public async findUserById(id: string): Promise<User> {
        return this.userRepository.findUserById(id);
    }
}

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

Интерфейс — это розетка, обеспечивающая функциональность «plug-and-play». В этом примере проводка в стене и источник электричества похожи на зависимости, а ваш тостер похож на UserService (он зависит от электричества) — источник электричества может измениться, а тостер по-прежнему будет работать нормально и не требует быть замененным, потому что розетка, действуя как интерфейс, определяет стандартные средства для связи. Фактически, можно сказать, что розетка действует как «абстракция» от настенной проводки, автоматических выключателей, источника электроэнергии и т. д.

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

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

Если завтра мы решим, что хотим полагаться на Mailchimp для электронной почты, мы просто создадим новый класс Mailchimp, который учитывает интерфейс IEmailProvider, и вставим его вместо SendGrid. Фактический класс UserService никогда не должен меняться, даже если мы только что внесли огромные изменения в нашу систему, переключившись на нового провайдера электронной почты. Прелесть этих шаблонов в том, что UserService остается в блаженном неведении о том, как зависимости, которые он использует, работают за кулисами. Интерфейс служит архитектурной границей между обоими компонентами, сохраняя их должным образом разделенными.

Кроме того, когда дело доходит до тестирования, мы можем создавать подделки (классы для имитации функционала), которые подчиняются интерфейсам, и вместо этого внедрять их. Здесь вы можете увидеть поддельный UserRepository и поддельного EmailProvider.

// Both fakes:
class FakeUserRepository implements IUserRepository {
    private readonly users: User[] = [];

    public async addUser(user: User): Promise<void> {
        this.users.push(user);
    }

    public async findUserById(id: string): Promise<User> {
        const userOrNone = this.users.find(u => u.id === id);

        return userOrNone
            ? Promise.resolve(userOrNone)
            : Promise.reject(new NotFoundError());
    }

    public async existsByEmail(email: string): Promise<boolean> {
        return Boolean(this.users.find(u => u.email === email));
    }

    public getPersistedUserCount = () => this.users.length;
}

class FakeEmailProvider implements IEmailProvider {
    private readonly emailRecipients: string[] = [];

    public async sendWelcomeEmail(to: string): Promise<void> {
        this.emailRecipients.push(to);
    }

    public wasEmailSentToRecipient = (recipient: string) =>
        Boolean(this.emailRecipients.find(r => r === recipient));
}

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

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

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

// Fakes
let fakeUserRepository: FakeUserRepository;
let fakeEmailProvider: FakeEmailProvider;

// SUT
let userService: UserService;

// We want to clean out the internal arrays of both fakes 
// before each test.
beforeEach(() => {
    fakeUserRepository = new FakeUserRepository();
    fakeEmailProvider = new FakeEmailProvider();
    
    userService = new UserService(fakeUserRepository, fakeEmailProvider);
});

// A factory to easily create DTOs.
// Here, we have the optional choice of overriding the defaults
// thanks to the built in `Partial` utility type of TypeScript.
function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto {
    return {
        id: 'someId',
        email: 'example@domain.com',
        ...opts
    };
}

test('should correctly persist a user and send an email', async () => {
    // Arrange
    const dto = createSeedRegisterUserDto();

    // Act
    await userService.registerUser(dto);

    // Assert
    const expectedUser = User.fromDto(dto);
    const persistedUser = await fakeUserRepository.findUserById(dto.id);
    
    const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email);

    expect(persistedUser).toEqual(expectedUser);
    expect(wasEmailSent).toBe(true);
});

test('should reject with a DuplicateEmailError if an email already exists', async () => {
    // Arrange
    const existingEmail = 'john.doe@live.com';
    const dto = createSeedRegisterUserDto({ email: existingEmail });
    const existingUser = User.fromDto(dto);
    
    await fakeUserRepository.addUser(existingUser);

    // Act, Assert
    await expect(userService.registerUser(dto))
        .rejects.toBeInstanceOf(DuplicateEmailError);

    expect(fakeUserRepository.getPersistedUserCount()).toBe(1);
});

test('should correctly return a user', async () => {
    // Arrange
    const user = User.fromDto(createSeedRegisterUserDto());
    await fakeUserRepository.addUser(user);

    // Act
    const receivedUser = await userService.findUserById(user.id);

    // Assert
    expect(receivedUser).toEqual(user);
});

Здесь вы заметите несколько вещей: подделки очень просты. Имитировать фреймворки, которые служат только для обфускации, не составляет никакого труда. Все создано вручную, а это значит, что в кодовой базе нет никакого волшебства. Асинхронное поведение имитируется, чтобы соответствовать интерфейсам. Я использую async/await в тестах, хотя в самом тесте все поведение является синхронным, но я считаю, что такое поведение более точно соответствует тому, что я ожидаю, как операции будут работать в реальном мире, и поэтому, добавив async/await, я могу запустить тот же набор тестов против реальных реализаций в дополнение к подделкам. Фактически, в реальной жизни я, скорее всего, даже не стал бы беспокоиться о имитации базы данных и вместо этого использовал бы локальную БД в контейнере Docker, пока не было создано так много тестов, что мне все равно бы пришлось бы имитировать ее для повышения производительности. Затем я мог запускать тесты БД в памяти после каждого отдельного изменения и резервировать настоящие локальные тесты БД прямо перед отправкой коммита сборки в конвейере CI / CD.

В первом тесте в разделе «Arrange» мы просто создаем DTO. В разделе «Act» мы вызываем тестируемую систему и выполняем ее поведение. В разделе «Assert», все становится немного сложнее. Помните, что на этом этапе тестирования мы даже не знаем, правильно ли был сохранен пользователь. Итак, мы определяем, как должен выглядеть пользователь, а затем вызываем фальшивый репозиторий и запрашиваем у него пользователя с ожидаемым идентификатором. Если UserService не сохранил пользователя правильно, это вызовет NotFoundError, и тест завершится неудачно, в противном случае он вернет нам пользователя. Затем мы вызываем провайдера поддельной электронной почты и спрашиваем, записал ли он отправку электронного письма этому пользователю. Наконец, мы используем утверждения (assert) с помощью Jest, и на этом тест завершается. Он выразительный и читается так же, как на самом деле работает система. Нет косвенного указания от имитирующих библиотек и нет связи с реализацией UserService.

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

Для третьего теста раздел «Arrange» теперь состоит из создания пользователя и сохранения его в поддельном репозитории. Затем мы вызываем SUT и, наконец, проверяем, является ли возвращенный пользователь тем, кого мы сохранили в репо ранее.

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

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

«Единица» определяется как «единица функциональности» или «единица поведения», а не как одна функция или класс. Итак, если единица поведения использует 5 разных классов, вам не нужно имитировать все эти классы, если они не выходят за пределы модуля.

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

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

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

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

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

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

function makeUserService(
    userRepository: IUserRepository,
    emailProvider: IEmailProvider
): IUserService {
    return {
        registerUser: async dto => {
            // ...
        },

        findUserById: id => userRepository.findUserById(id)
    }
}

Это фабрика, которая получает зависимости и создает объект службы. Мы также можем вводить зависимости в функции высшего порядка. Типичным примером может быть создание функции Express Middleware, в которую вставляются UserRepository и ILogger:

function authProvider(userRepository: IUserRepository, logger: ILogger) {
    return async (req: Request, res: Response, next: NextFunction) => {
        // ...
        // Has access to userRepository, logger, req, res, and next.
    }
}

В первом примере я не определял тип dto и id, потому что если мы определим интерфейс с именем IUserService, содержащий сигнатуры методов для службы, то компилятор TS автоматически определит типы. Точно так же, если бы я определил сигнатуру функции для Express Middleware как возвращаемый тип authProvider, мне бы не пришлось объявлять типы аргументов там.

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

import { sendMail } from 'sendgrid';

async function main() {
    const app = express();
    
    const dbConnection = await connectToDatabase();
    
    // Change emailProvider to `makeMailChimpEmailProvider` whenever we want
    // with no changes made to dependent code.
    const userRepository = makeUserRepository(dbConnection);
    const emailProvider = makeSendGridEmailProvider(sendMail);
    
    const userService = makeUserService(userRepository, emailProvider);

    // Put this into another file. It’s a controller action.
    app.post('/login', (req, res) => {
        await userService.registerUser(req.body as IRegisterUserDto);
        return res.send();
    });

    // Put this into another file. It’s a controller action.
    app.delete(
        '/me', 
        authProvider(userRepository, emailProvider), 
        (req, res) => { ... }
    );
}

Обратите внимание, что мы извлекаем необходимые нам зависимости, такие как соединение с базой данных или функции сторонней библиотеки, а затем мы используем фабрики для создания собственных зависимостей с использованием сторонних. Затем мы передаем их в зависимый код. Поскольку все кодируется с помощью абстракций, я могу заменить либо userRepository, либо emailProvider на любую другую функцию или класс с любой реализацией, которую я хочу (которая по-прежнему правильно реализует интерфейс), и UserService можно будет просто использовать его без каких-либо изменений, потому что UserService заботится только о публичном интерфейсе зависимостей, а не о том, как эти зависимости работают.

В качестве отказа от ответственности я хочу указать на несколько моментов. Как указывалось ранее, эта демонстрация была оптимизирована для демонстрации того, как внедрение зависимостей упрощает жизнь, и поэтому она не была оптимизирована с точки зрения передовых методов проектирования системы, поскольку шаблоны, окружающие технически использование Repositories и DTO. В реальной жизни приходится иметь дело с управлением транзакциями в репозиториях, и DTO, поэтому как правило, не следует передавать в методы обслуживания, а скорее отображать в контроллере, чтобы уровень представления мог развиваться отдельно от уровня приложения. Здесь метод userSerivce.findById также игнорирует отображение объекта домена пользователя в DTO, что он и должен делать в реальной жизни. Ничто из этого не влияет на реализацию DI, я просто хотел сосредоточить внимание на преимуществах самого DI, а не на дизайне Repository, управлении единицами работы или DTO. Наконец, хотя это может немного походить на структуру NestJS с точки зрения способа выполнения действий, но это не так, и я активно отговариваю людей от использования NestJS по причинам, выходящим за рамки этой статьи.

Краткий теоретический обзор

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

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

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

Внедрение зависимостей может принимать разные формы. Внедрение конструктора (Constructor Injection) — это то, что мы здесь использовали, поскольку зависимости вводятся в конструктор. Также существует Setter Injection и Interface Injection. В первом случае зависимый компонент предоставит метод установки, который будет использоваться для внедрения зависимости, то есть он может предоставить такой метод, как setUserRepository (userRepository: UserRepository). Во втором случае мы можем определить интерфейсы, через которые будет выполняться инъекция, но я опущу здесь объяснение последнего метода для краткости.

Поскольку подключение зависимостей вручную может быть затруднено, существуют различные инфраструктуры и контейнеры IoC. Эти контейнеры хранят ваши зависимости и разрешают правильные во время выполнения, часто с помощью Reflection на таких языках, как C# или Java, предоставляя различные параметры конфигурации для времени жизни зависимости. Несмотря на преимущества, которые предоставляют контейнеры IoC, есть случаи, когда нужно отказаться от них и разрешать зависимости только вручную. Чтобы узнать больше об этом, прочитайте выступление Грега Янга  8 Lines of Code.

Кроме того, платформы DI и контейнеры IoC могут предоставлять слишком много параметров, и многие полагаются на декораторы или атрибуты для выполнения таких методов, как setter или field injection. Я не люблю такой такой подход, потому что, если вы подумаете об этом интуитивно, точка внедрения зависимостей состоит в достижении слабой связи, но если вы начнете разбрасывать декораторы, специфичные для контейнера IoC, по всей своей бизнес-логике, хоть вы, возможно, достигните разделение зависимости, вы можете случайно подключить себя к контейнеру IoC. Контейнеры IoC, такие как Awilix, решают эту проблему, поскольку они остаются отделенными от бизнес-логики вашего приложения.

Заключение

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

[customscript]techrocks_custom_after_post_html[/customscript]

[customscript]techrocks_custom_script[/customscript]

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх