Имея некоторый опыт разработки, вы уже понимаете, как важно, чтобы ваше приложение было легко поддерживать и изменять — ведь главные сложности начинаются уже после создания минимально жизнеспособного продукта.
Но чтобы обеспечить простоту поддержки, нужно как следует подумать уже на этапе проектирования.
Есть один простой паттерн, который может вам пригодиться для этого — внедрение зависимостей (dependency injection, DI). Название не должно вас пугать, на самом деле все намного проще, чем кажется, нужно лишь разобраться — именно этим мы и займемся.
Начнем с того, что решим, где же мы должны внедрять эти зависимости. По большому счету — везде. Если функция использует что-то, что находится за ее пределами, мы должны использовать этот паттерн.
Звучит очень радикально, но давайте попробуем, чтобы разобраться в преимуществах и недостатках.
1. Единичная зависимость
/**
*
* @param {Pick<Math, 'random'>} Math
*/
const random = Math => Math.random()
// в продакшене используем стандартный Math
random(Math) // anything between 0 - 1
// для тестов подменяем его на собственную реализацию
random({ random: () => 4 }) // 4
Аннотация типа с помощью @param
или TypeScript необязательна, но она сразу дает понять, что именно находится в этом аргументе.
Таким образом, можно напрямую контролировать результат снаружи функции. Мы программируем только с известным интерфейсом и знаем, какие свойства нужны для работы.
Давайте взглянем на более реалистичный пример:
const fs = require('fs/promises')
const readFile = (fs, filename) => fs.readFile(filename)
readFile(fs, 'some-file.txt') // какой-то контент из файла
Функция readFile
использует внедренную зависимость fs
. Ей достаточно знать, что у этой зависимости есть метод readFile
, который она может использовать, все остальное не имеет значения.
DI позволяет легко расширить поведение программы, создав дополнительную обертку для функции чтения из файла. Например, мы можем добавить логирование, чтобы при каждом обращении к файлу выводилось сообщение.
const loggedFs = {
readFile: filename => {
console.log(`reading now file "${filename}"`)
return fs.readFile(filename)
}
}
readFile(loggedFs, 'some-file.txt')
// reading now file "some-file.txt"
// какой-то контент из файла
Если вы напрямую используете метод fs.readFile
везде, где нужно читать файлы, то для добавления логирования придется вручную внести изменения во все места использования, добавив console.log
.
Но если мы используем функцию readFile
, то достаточно просто передать ей другую зависимость (loggedFs
вместо fs
) с тем же интерфейсом — а поведение изменится везде.
Если же вы не хотите использовать логирование в каком-то месте кода, то просто передайте исходную зависимость — модуль fs
.
Этот подход прекрасно работает и для других задач:
- кэширование,
- валидация,
- преобразование аргумента,
- преобразование возвращаемого значения,
- публикация очереди сообщений,
- в целом перехват потока выполнения,
- тестирование,
- и др.
Например, в модульных тестах вам необходимо исключить побочные эффекты выполнения функции (чтение или запись в файл, обращение к базе данных, сетевые запросы, вывод в консоль), чтобы убедиться, что тест легко повторяется. DI позволяет легко это организовать, заменив «плохие» функции моками или шпионами.
2. Множественные зависимости в виде параметров
Тот же подход для нескольких зависимостей выглядит уже довольно громоздким:
const report = async (log, saveToDb, Date, message) => {
log(`${new Date()}: ${message}`)
await saveToDb(new Date(), message)
}
Тут уже не поймешь, какие параметры входят в исходную сигнатуру функции, а какие являются зависимостями. Это плохой вариант.
3. Множественные зависимости в виде объекта
Мы можем передавать все зависимости, которые используются в программе, вместе, в одном объекте. Внутри каждой функции вы можете взять только те зависимости, которые нужны, с помощью синтаксиса деструктуризации.
/**
* @param {object} context
* @param {object} context.db
* @param {function} context.log
* @param {number} id
*/
const readUserFromDbById = async ({ db, log }, id) => {
const user = await db.where({ id }).fetch()
log(`user fetched: ${user.name}`)
return user
}
Такой подход отлично подходит для функций, которым не нужно передавать зависимости дальше, другим функциям. В ином случае придется деструктурировать и зависимости для них.
Или можно сделать проще и принимать в функции целый объект с зависимостями, дав ему подходящее имя. Этот объект можно передавать вложенным вызовам функций целиком.
/**
* @param {object} context
* @param {object} context.db
* @param {function} context.log
* @param {number} id
*/
const readUserFromDbById = async (context, id) => {
const user = await context.db.where({ id }).fetch()
context.log(`user fetched: ${user.name}`)
return user
}
Вот примеры хороших имен:
- ctx
- deps
- dependencies
- options
- settings
- opts
4. Подготовка функций до исполнения
Можно ли сделать внедрение зависимостей менее болезненным?
Мы можем подготовить функции, передав им зависимости ДО того, как будем их использовать. Это дополнительный уровень абстракции, который позволит нам не беспокоиться о зависимостях при каждом вызове функции. Это удобно, если вы используете функцию много раз.
const readUserFromDbById = context => async id => { // ... }
// подготовка
const dependencies = {
db: {},
log: console.log
}
const readUser = readUserFromDbById(dependencies) // возвращает ФУНКЦИЮ
// вызов подготовленной функции
await readUser(1)
Будем называть такие функции «подготавливаемыми«.
В некоторых ситуациях нет возможности спроектировать функцию таким образом, то есть разделить передачу зависимостей и аргументов. Например, если вы используете библиотечные функции. В этом случае можно использовать каррирование (пошаговую передачу аргументов):
const R = require('ramda')
const readUserFromDbById = R.curry((context, id) => { ... })
// использовать можно двумя способами
// 1) последовательные вызовы
readUserFromDbById(ctx)(3)
// 2) классический вариант
readUserFromDbById(ctx, 3)
Или отложенное выполнение с поэтапной передачей параметров (почти то же самое):
const readUserFromDbById = async (context, id) => { // ... }
// использование
readUserFromDbByIdWithContext = R.partial(readUserFromDbById, context)
readUserFromDbByIdWithContext(3)
5. Зависимости для модуля: замыкания
Если у нас несколько семантических схожих функций, которые используют одни и те же зависимости (более или менее), мы можем объединить их в модуль. В этом случае удобно внедрять зависимости сразу для модуля, а не для отдельных функций, которые в него входят.
const appsFactory = ctx => {
const list = () => ctx.fs.readJSON('apps.json')
const get = async id => (await list())[id]
return {
list,
get,
}
}
По сути это то же самое, что мы делали в предыдущем шаге. Но здесь подготавливаются сразу несколько функций, а не одна.
Это немного удобнее, но у этой гибкости есть недостатки. Например, мы не можем предоставить функции get
альтернативу для переменной list
. То есть зависимости внедряются именно для модуля, но внутри него у нас нет влияния.
6. Зависимости для модуля: функциональный уровень
Давайте копать глубже.
У нас есть целый модуль, который использует примерно один и те же зависимости. Мы можем предоставить разные стратегии для его использования.
- Как в предыдущем примере, фабричную функцию, которая будет принимать нужные зависимости, внедрять их во все функции модуля и возвращать подготовленный модуль.
- Вариант попроще: сразу внедрять дефолтные зависимости и возвращать уже подготовленный модуль.
- Вариант посложнее: возвращать все функции модуля по отдельности в неподготовленном виде, чтобы пользователь мог внедрить в них все, что угодно.
Чтобы реализовать третий вариант, мы должны каждую функцию в модуле сделать «подготавливаемой» (см п. 4).
/***** Модуль *****/
const fs = require('fs-extra')
const R = require('ramda')
// "подготавливаемые" функции модуля,
// поддерживающие отдельную передачу зависимостей
const list = ctx => async () => ctx.fs.readJSON(ctx.filePath)
const get = ctx => async id => (await list(ctx)())[id]
const dependencyFns = { list, get }
// фабричная функция
// получает список зависимостей для всего модуля
// и внедряет зависимости во все функции
// (Ramda используется для удобной итерации по объекту)
const appsFactory = ctx => R.map(d => d(ctx), dependencyFns)
// экспортируем из модуля разные стратегии использования
module.exports = {
...dependencyFns,
default: appsFactory({ fs, filePath: './apps.json' }),
factory: appsFactory,
}
/***** Файл apps.json, который используется по умолчанию *****/
[1, 2, 3]
/***** Файл apps-2.json *****/
[4, 5, 6]
/***** Использование модуля *****/
const fs = require('fs-extra')
const apps = require('./apps')
// 1) использование подготовленного модуля с дефолтными зависимостями
const appsDefault = apps.default
appsDefault.list() // [1,2,3]
appsDefault.get(0) // 1
// 2) использование фабричной функции для внедрения собственных зависимостей в модуль
// (меняем путь к файлу)
const appsFactory = apps.factory({ fs, filePath: './apps-2.json' })
appsFactory.list() // [4,5,6]
appsFactory.get(0) // 4
// 3) использование отдельных функций модуля с внедрением зависимостей
apps.get({ fs, filePath: './apps-2.json' })(1) // 5
apps.get({ fs: { readJSON: () => [7,8,9] } })(0) // 7
Теперь вы можете свободно использовать модуль одним из трех способов на выбор:
- Взять только одну функцию, которая поддерживает отдельную передачу зависимостей (удобно для тестирования).
- Взять фабричную функцию и внедрить зависимости сразу во все функции модуля (удобно для полного контроля).
- Взять набор функций, уже заполненных рекомендуемыми зависимостями (библиотеки).
При этом вы все еще можете разделить настраиваемое поведение функции (настройки вроде ctx.filePath
) и необходимые функции (ctx.fs
).
Внутри модуля вполне можно смешивать разные стили DI, рассмотренные в предыдущих пунктах.
7. Зависимости для модуля: уровень зависимостей
Недостаток предыдущего метода заключается в том, что мы должны предоставить всю цепочку зависимостей. Мы не можем просто заменить функцию list
внутри функции get
на что-то другое, вместо этого нужно определить метод readJSON
для зависимости fs
. Хотя самой функции get
этот метод совершенно не нужен.
appsModule.get({ fs: { readJSON: () => [7,8,9] } })(0)
Чтобы решить эту проблему, нужно сами функции модулей сделать зависимостями. Это особенно полезно, если вы хотите, например, проигнорировать часть действий, выполняемых функцией, и получить от нее только адекватный результат.
const list = ctx => async () => ctx.fs.readJSON(ctx.filePath)
const get = ctx => async id => (await ctx.list(ctx)())[id]
Отличие от предыдущей реализации в том, что внутри функции get
мы берем ctx.list
, а не просто list
. То есть мы ожидаем, что функция list
будет передана в качестве зависимости.
Давайте попробуем объединить это с нашей фабричной функцией:
const appsFactory = ctx => R.map(d => d({ ...dependencyFns, ...ctx }), dependencyFns)
Тут немного усложнилась передача контекста. Помимо тех зависимостей, которые передаст в фабричную функцию пользователь, мы передаем также дефолтные зависимости — функции самого модуля. Они будут использоваться, если пользователь их не переопределит.
А вот так выглядит само внедрение зависимости:
appsFactory({list: () => () => [2, 3, 4]}).get(0) // 2
get({ list: () => () => [1, 2, 3] })(0) // 1
Сигнатура функции, которая заменяет list
, должна соответствовать исходной. То есть это должна быть функция, которая возвращает функцию. Для клиентского кода (который использует модуль) это не нужно, но наша реализация этого требует. Но громоздкий код, пожалуй, единственный недостаток этого решения.
Заключение
Мы рассмотрели 7 способов внедрения зависимостей в JavaScript и можем смело сказать, что это совсем не просто. Как минимум, это добавляет дополнительный слой кода.
Однако эти неудобства быстро окупятся, как только вы начнете извлекать пользу из легкого обмена модулями и расширения интерфейсов без опасений о том, что существующий код перестанет работать.
При использовании TypeScript вы даже можете получать предупреждения, если зависимости не соответствую ожидаемой сигнатуре функции.
Внедрение зависимостей — это не конечная цель, а только средство достижения цели. DI обеспечивает слабую связность вашего кода, из-за чего его становится удобнее поддерживать.
Mark Seeberg, книга Dependency Injection
Требуется определенная база знаний, чтобы оценить DI и его преимущества. Найдите время, чтобы узнать об этом немного больше.
4 комментария