Практическое введение в внедрение зависимостей
Опубликовано: 2022-03-10Концепция внедрения зависимостей по своей сути является принципиально простым понятием. Тем не менее, его обычно представляют вместе с более теоретическими концепциями инверсии управления, инверсии зависимостей, принципами SOLID и т. д. Чтобы вам было как можно проще приступить к использованию внедрения зависимостей и начать пожинать его плоды, эта статья будет оставаться в значительной степени на практической стороне истории, изображая примеры, которые точно показывают преимущества его использования, главным образом оторван от связанной теории.
Мы потратим очень мало времени на обсуждение академических концепций, связанных с внедрением зависимостей, поскольку основная часть этого объяснения будет отведена для второй статьи этой серии. Действительно, могут быть и были написаны целые книги, которые обеспечивают более глубокое и строгое рассмотрение концепций.
Здесь мы начнем с простого объяснения, перейдем к еще нескольким примерам из реальной жизни, а затем обсудим некоторую справочную информацию. В другой статье (следующей за этой) будет обсуждаться, как внедрение зависимостей вписывается в общую экосистему применения передовых архитектурных шаблонов.
Простое объяснение
«Внедрение зависимостей» — слишком сложный термин для чрезвычайно простой концепции. На этом этапе некоторые мудрые и разумные вопросы будут такими: «Как вы определяете «зависимость»?», «Что означает «внедрение» зависимости?», «Можете ли вы внедрять зависимости по-разному?» и «почему это полезно?» Вы можете не поверить, что такой термин, как «Внедрение зависимостей», можно объяснить двумя фрагментами кода и парой слов, но, увы, это возможно.
Самый простой способ объяснить концепцию — это показать вам.
Это, например, не внедрение зависимостей:
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
, что означает, что в тесте мы можем создать подкласс Engine
и переопределить его методы.
Например, если мы хотим увидеть, что Car.startEngine()
, если engine.fireCylinders()
выдает ошибку, мы могли бы просто создать класс FakeEngine
, расширить класс Engine
, а затем переопределить fireCylinders
, чтобы он выдавал ошибку. . В тесте мы можем внедрить этот объект FakeEngine
в конструктор Car
. Поскольку FakeEngine
является Engine
в силу наследования, система типов TypeScript удовлетворяется. Использование наследования и переопределения методов не обязательно будет лучшим способом сделать это, как мы увидим позже, но это, безусловно, вариант.
Я хочу, чтобы было очень, очень ясно, что то, что вы видите выше, является основным понятием внедрения зависимостей. Car
сам по себе недостаточно умен, чтобы знать, какой двигатель ему нужен. Только инженеры, создающие автомобиль, понимают требования к его двигателям и колесам. Таким образом, имеет смысл, чтобы люди, которые строят автомобиль, предоставили конкретный требуемый двигатель, а не позволяли Car
самому выбирать, какой двигатель он хочет использовать.
Я использую слово «конструировать» именно потому, что вы конструируете машину, вызывая конструктор, в который вводятся зависимости. Если автомобиль также создал свои собственные шины в дополнение к двигателю, откуда мы знаем, что используемые шины безопасны для вращения на максимальных оборотах, которые может выдать двигатель? По всем этим и многим другим причинам должно иметь смысл, возможно, интуитивно, что Car
не должен иметь никакого отношения к решению, какой Engine
и какие Wheels
он использует. Они должны предоставляться с какого-то более высокого уровня управления.
В последнем примере, изображающем внедрение зависимостей в действии, если вы представляете Engine
как абстрактный класс, а не конкретный, это должно иметь еще больше смысла — автомобиль знает, что ему нужен движок, и он знает, что движок должен иметь некоторые базовые функции. , но то, как этот двигатель управляется и какая его конкретная реализация, зарезервировано для решения и предоставлено фрагментом кода, который создает (конструирует) автомобиль.
Реальный пример
Мы рассмотрим еще несколько практических примеров, которые, надеюсь, помогут объяснить, опять же интуитивно, почему внедрение зависимостей полезно. Надеемся, что, не зацикливаясь на теории, а вместо этого перейдя непосредственно к применимым концепциям, вы сможете более полно увидеть преимущества, которые предоставляет внедрение зависимостей, и трудности жизни без него. Позже мы вернемся к несколько более «академическому» рассмотрению темы.
Мы начнем с создания нашего приложения обычным способом, сильно связанным, без использования внедрения зависимостей или абстракций, чтобы мы увидели недостатки этого подхода и сложности, которые он добавляет к тестированию. Попутно мы будем постепенно проводить рефакторинг, пока не исправим все проблемы.
Для начала предположим, что вам поручили создать два класса — поставщика электронной почты и класса для уровня доступа к данным, который должен использоваться каким-либо UserService
. Мы начнем с доступа к данным, но оба они легко определяются:
// 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(...) } }
Примечание . Название «Репозиторий» здесь происходит от «Шаблона репозитория», метода отделения вашей базы данных от вашей бизнес-логики. Вы можете узнать больше о шаблоне репозитория, но для целей этой статьи вы можете просто рассматривать его как некоторый класс, который инкапсулирует вашу базу данных, чтобы с точки зрения бизнес-логики ваша система хранения данных рассматривалась просто как хранилище в памяти. коллекция. Полное объяснение шаблона репозитория выходит за рамки этой статьи.
Это то, как мы обычно ожидаем, что все будет работать, и dbDriver
жестко закодирован в файле.
В вашем UserService
вы должны импортировать класс, создать его экземпляр и начать его использовать:
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 — это объект передачи данных — это объект, который действует как пакет свойств для определения стандартизированной формы данных при их перемещении между двумя внешними системами или двумя уровнями приложения. Вы можете узнать больше о DTO из статьи Мартина Фаулера по этой теме здесь. В этом случае IRegisterUserDto
определяет контракт о том, какой должна быть форма данных, поступающих от клиента. У меня он содержит только два свойства — id
и email
. Вы можете подумать, что это странно, что DTO, который мы ожидаем от клиента для создания нового пользователя, содержит идентификатор пользователя, даже если мы еще не создали пользователя. Идентификатор — это UUID, и я позволяю клиенту генерировать его по разным причинам, которые выходят за рамки этой статьи. Кроме того, функция findUserById
должна сопоставлять объект User
с DTO ответа, но для краткости я проигнорировал это. Наконец, в реальном мире у меня не было бы модели предметной области User
, содержащей метод fromDto
. Это плохо для чистоты домена. Еще раз повторю, что его целью здесь является краткость.
Затем вы хотите обработать отправку электронных писем. Опять же, как обычно, вы можете просто создать класс провайдера электронной почты и импортировать его в свой 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); } }
Теперь у нас есть полностью рабочий класс, и в мире, где мы вообще не заботимся о тестируемости или написании чистого кода в любом смысле, и в мире, где технический долг не существует, а надоедливые менеджеры программ не t установить сроки, это совершенно нормально. К сожалению, это не тот мир, в котором нам выгодно жить.
Что произойдет, если мы решим, что нам нужно отказаться от 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
на более высокий уровень. Если бы я хотел только показать, как мы можем внедрять зависимости через конструктор, чтобы объяснить основной принцип внедрения зависимостей, я мог бы остановиться на этом. Однако с точки зрения дизайна все еще есть некоторые проблемы, которые, если их исправить, сделают наше использование внедрения зависимостей еще более эффективным.
Во-первых, почему 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(...); } }
Примечание. Это шаблон адаптера из группы четырех шаблонов проектирования.
Теперь наш 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); } }
Если интерфейсы для вас новы, это может показаться очень и очень сложным. Действительно, концепция создания слабосвязанного программного обеспечения может оказаться для вас новой. Подумайте о настенных емкостях. Вы можете подключить любое устройство к любой розетке, если вилка подходит к розетке. Это слабая связь в действии. Ваш тостер не встроен в стену, потому что если это так, и вы решили обновить свой тостер, вам не повезло. Вместо этого используются розетки, а розетка определяет интерфейс. Точно так же, когда вы подключаете электронное устройство к настенной розетке, вас не волнует потенциал напряжения, максимальное потребление тока, частота переменного тока и т. д., вас просто волнует, подходит ли вилка к розетке. Вы можете вызвать электрика и заменить все провода за этой розеткой, и у вас не будет проблем с подключением тостера, пока эта розетка не изменится. Кроме того, ваш источник электроэнергии может быть переключен на город или ваши собственные солнечные батареи, и, опять же, вам все равно, пока вы все еще можете подключиться к этой розетке.

Интерфейс является выходом, обеспечивающим функциональность «подключи и работай». В этом примере проводка в стене и источник электричества сродни зависимостям, а ваш тостер сродни UserService
(у него есть зависимость от электричества) — источник электричества может меняться, а тостер по-прежнему работает нормально и не требуется. быть тронутым, потому что розетка, действующая как интерфейс, определяет стандартные средства для общения обоих. Фактически можно сказать, что розетка действует как «абстракция» настенной проводки, автоматических выключателей, источника электроэнергии и т. д.
Это общепринятый и общепризнанный принцип разработки программного обеспечения, по причинам, изложенным выше, — кодировать в соответствии с интерфейсами (абстракциями), а не реализациями, что мы и сделали здесь. При этом нам предоставляется свобода менять реализации по своему усмотрению, поскольку эти реализации скрыты за интерфейсом (точно так же, как настенная проводка скрыта за розеткой), и поэтому бизнес-логика, использующая зависимость, никогда не должна меняться до тех пор, пока интерфейс никогда не меняется. Помните, UserService
нужно знать только, какие функции предлагаются его зависимостями , а не то, как эти функции поддерживаются за кулисами . Вот почему использование интерфейсов работает.
Эти два простых изменения использования интерфейсов и внедрения зависимостей имеют большое значение, когда речь идет о создании слабосвязанного программного обеспечения, и решают все проблемы, с которыми мы столкнулись выше.
Если завтра мы решим, что хотим полагаться на Mailchimp для электронной почты, мы просто создадим новый класс Mailchimp, который учитывает интерфейс IEmailProvider
, и внедрим его вместо SendGrid. Фактический класс UserService
никогда не должен меняться, хотя мы только что внесли огромные изменения в нашу систему, переключившись на нового провайдера электронной почты. Прелесть этих шаблонов в том, что UserService
остается в блаженном неведении о том, как используемые им зависимости работают за кулисами. Интерфейс служит архитектурной границей между обоими компонентами, обеспечивая их надлежащую развязку.
Кроме того, когда дело доходит до тестирования, мы можем создавать подделки, которые соблюдают интерфейсы, и вместо этого внедрять их. Здесь вы можете увидеть поддельный репозиторий и поддельный поставщик электронной почты.
// 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
не станет мудрее; он будет использовать их так же, как если бы они были реальной сделкой. Причина, по которой он может это сделать, заключается в том, что он знает, что все методы и свойства, которые он хочет использовать в своих зависимостях, действительно существуют и действительно доступны (поскольку они реализуют интерфейсы), и это все, что нужно знать UserService
(т. е. не как работают зависимости).
Мы добавим эти две во время тестов, и это сделает процесс тестирования намного проще и понятнее, чем то, к чему вы, возможно, привыкли, когда имеете дело с чрезмерными библиотеками имитации и заглушки, работая с собственной внутренней Jest. инструменты, или попытка обезьяньей заплаты.
Вот настоящие тесты с использованием подделок:
// 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: '[email protected]', ...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 = '[email protected]'; 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.
В первом тесте в разделе «упорядочить» мы просто создаем DTO. В разделе «действие» мы вызываем тестируемую систему и выполняем ее поведение. Все становится немного сложнее, когда делаются утверждения. Помните, что на этом этапе теста мы даже не знаем, правильно ли был сохранен пользователь. Итак, мы определяем, как должен выглядеть постоянный пользователь, а затем вызываем поддельный репозиторий и запрашиваем у него пользователя с ожидаемым идентификатором. Если UserService
не сохранил пользователя правильно, это вызовет NotFoundError
, и тест завершится неудачей, в противном случае он вернет нам пользователя. Затем мы звоним поставщику поддельной электронной почты и спрашиваем его, зафиксировал ли он отправку электронного письма этому пользователю. Наконец, мы делаем утверждения с Jest, и это завершает тест. Это выразительно и читается так же, как система на самом деле работает. Нет никакой косвенной связи с фиктивными библиотеками и нет связи с реализацией UserService
.
Во втором тесте мы создаем существующего пользователя и добавляем его в репозиторий, затем мы пытаемся снова вызвать службу, используя DTO, который уже использовался для создания и сохранения пользователя, и мы ожидаем, что это не удастся. Мы также утверждаем, что никаких новых данных в репозиторий не добавлялось.
Для третьего теста раздел «упорядочить» теперь состоит из создания пользователя и сохранения его в поддельном репозитории. Затем мы вызываем SUT и, наконец, проверяем, является ли возвращающийся пользователь тем, кого мы ранее сохранили в репозитории.
Эти примеры относительно просты, но когда все становится сложнее, возможность полагаться на внедрение зависимостей и интерфейсы таким образом сохраняет ваш код чистым и делает написание тестов удовольствием.
Небольшое отступление о тестировании: в общем случае вам не нужно имитировать каждую зависимость, которую использует код. Многие люди ошибочно утверждают, что «модуль» в «модульном тесте» — это одна функция или один класс. Это не может быть более неправильным. «Единица» определяется как «единица функциональности» или «единица поведения», а не одна функция или класс. Поэтому, если единица поведения использует 5 разных классов, вам не нужно имитировать все эти классы, если только они не выходят за границы модуля. В этом случае я издевался над базой данных и над провайдером электронной почты, потому что у меня нет выбора. Если я не хочу использовать настоящую базу данных и не хочу отправлять электронное письмо, я должен издеваться над ними. Но если бы у меня была еще куча классов, которые ничего не делают в сети, я бы не стал над ними издеваться, потому что они представляют собой детали реализации единицы поведения. Я также мог отказаться от имитации базы данных и электронной почты и запустить настоящую локальную базу данных и настоящий 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) => { ... } ); }
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
Заключение
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we'll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- И больше.
Следите за обновлениями!