В современном JavaScript каждый файл исходного кода является модулем. Модуль может импортировать другие модули проекта или внешние библиотеки, которые также являются модулями. Через импорт устанавливается прямая связь с зависимыми модулями — жёсткая связь с конкретной реализацией модулей. Это не создаёт проблем, если приложение небольшое или если импортируемые модули являются архитектурно определяющими, например, модули библиотеки React.
import React, { useEffect } from 'react';
import some from 'some-library';
import http, { Request, Response } from 'some-http-library';
Однако прямой импорт усложняет адаптацию приложения к новым или альтернативным версиям модулей. Также он затрудняет повторное использование собственных модулей в другом окружении из-за жёсткой привязки к зависимым модулям.
Например, для выполнения HTTP-запросов часто используется встроенный программный интерфейс браузера — Fetch API. Его даже не надо импортировать. Это удобно, пока проект работает в одном окружении. Проблемы возникают при исполнении проекта на сервере или в старых браузерах, где встроенного Fetch API может не быть, и модули, использующие его, перестают работать. Многие сторонние модули разработаны для конкретного окружения, например, модули для Node.js часто не работают в браузере, и наоборот.
Проблему жесткой зависимости можно решить через реализацию "полифилла" для недостающего модуля (например, Fetch API), то есть добавление отсутствующей функциональности в глобальную область видимости. Однако использование полифиллов — дорогостоящее решение, так как требует полного воссоздания недостающего модуля в новом окружении даже если не нужна вся его функционалность.
Технический приём с полифиллом подсказывает решение: в самом полифилле реализуется адаптация к другому окружению. Вопрос только в том, как сделать адаптацию не избыточной и архитектурно корректной, а также применять её не только к отсутствующим программным интерфейсам, но и ко всем ключевым зависимостям, включая внутренние модули проекта.
Решить проблему помогает паттерн проектирования "Адаптер". Адаптер — это объект (или функция), предоставляющий минимально необходимый и стабильный программный интерфейс. Все обращения к адаптеру переадресуются целевому модулю. Адаптер преобразует вызовы и данные в формат, ожидаемый целевым модулем, обеспечивая постоянную совместимость. В результате замена или обновление целевого модуля затрагивает только адаптер, а не весь код проекта.
Решить проблему поможет паттерн проектирования "Адаптер". Адаптером является объект (или функция), который предоставляет минимально необходимый и стабильный программ ный интерфейс. Все обращения к объекту-адаптеру будут переадресованы в целевой модуль. Адаптер будет преобразовывать вызовы и данные в формат, ожидаемый целевым модулем, обеспечивая таким образом постоянную совместимость. В результате замена или обновление целевого модуля будет касаться только адаптера, а не всего кода проекта.
В адаптере определяется минимально необходимый программный интерфейс. Если для выполнения запросов к API в рамках проекта достаточно одного метода request() с указанием адреса и данных запроса, то только этот метод и следует определить в адаптере. Не нужно повторять в адаптере весь интерфейс целевого модуля, например, Fetch API или Axios. Адаптер, в зависимости от окружения или других условий, будет переадресовывать вызовы к Fetch API или Axios. Чем меньше интерфейс адаптера, тем проще выполнять адаптацию. Кроме того, адаптер с минимальным интерфейсом даёт чёткое представление о том, насколько сильно тот или иной модуль интегрирован в проект.
/**
* Адаптер для HTTP-запросов в функциональном стиле.
* Возвращаем только метод request с минимальным набором параметров, хотя fetch может горяздо больше.
*/
const createHttpClient = (config: { baseUrl: string }) => {
const request = async (
url: string,
options: {
method?: string;
headers?: Record<string, string>;
data?: unknown;
} = {},
): Promise<Response> => {
const response = fetch(config.baseUrl + url, {
method: options.method || 'GET',
headers: options.headers || {},
body: options.data ? JSON.stringify(options.data) : null,
});
};
return { request };
};
Адаптеры зачастую выглядят как обычные модули и именуются в соответствии с предоставляемым функционалом.
// Пример использования адаптера вместо прямого вызова fetch()
const httpClient = createHttpClient({ baseUrl: 'https://api.example.com' });
const response = await httpClient.request('/api/v1/data');
Промежуточный слой (адаптер) не нужно применять для всей логики. Он необходим для внешних и вспомогательных модулей (библиотек), таких как модули для выполнения HTTP-запросов, логирования, мультиязычности или различных утилит. Их интеграцию целесообразно выносить в отдельные слои, предоставляя доступ через адаптер, а не импортируя их напрямую. Кроме переадресации вызова в целевой модуль в адаптере можно реализовывать дополнительную логику, упрощающую применение модуля в проекте.
Паттерн "Адаптер" помогает в определённой степени соблюдать принцип открытости/закрытости, поскольку позволяет интегрировать в проект изначально несовместимые модули без изменения кода всего проекта. Однако паттерн "Адаптер" не решает всех проблем. Остаётся сложность с повторным использованием собственных модулей, так как теперь они жёстко привязаны к конкретным адаптерам. Паттерн "Адаптер" — это только один из приёмов для ослабления связей, и его стоит дополнять другими подходами, которые будут рассмотрены в следующей теме.