Введение
В LEOCODE мы разрабатываем несколько проектов, используя Node.js с NestJS в качестве предпочтительной серверной среды. Подробнее о том, почему мы выбрали его, можно прочитать здесь. Как и с любым инструментом, во-первых, вам нужно научиться им пользоваться. Что еще более важно, так это научиться использовать его хорошие качества и уменьшать его плохие для достижения ваших конкретных целей.
Мы часто структурируем наши кодовые базы, используя шаблон гексагональной архитектуры, который подчеркивает изоляцию бизнес-логики, определяя четкие границы и тестируемость. В этой короткой статье я покажу вам, как мы используем NestJS (или, точнее, его функцию модулей), чтобы лучше выразить эти концепции в коде и помочь разработчикам лучше понять их и играть по их правилам.
Что такое модуль в NestJS?
Для тех из вас, кто не знает, что такое модуль, я кратко представлю его. Подробнее о них можно прочитать в официальной документации NestJS. С технической точки зрения единственной целью модуля является настройка механизма внедрения зависимостей NestJS. Я определил один ниже — в следующих примерах кода я иногда буду пропускать определения импорта, чтобы не загромождать примеры, но в реальном рабочем коде они должны быть там:
import { Module } from '@nestjs/common';
@Module({ imports: [OtherModule], providers: [MyService], controllers: [MyController], exports: [MyService], }) class MyModule {}
Давайте подробнее рассмотрим эти свойства, которые разделены на две группы: одна отвечает за внутреннюю конфигурацию DI, а другая за указание того, как наш модуль взаимодействует с миром.
В первой группе у нас есть:
- Поставщики — в этом массиве мы можем определить, что будет вставляться в места, как они обозначены токенами внедрения. Токеном инъекции может быть, например, определение класса, поэтому в большинстве случаев вы увидите здесь массив классов.
- Контроллеры. NestJS обрабатывает контроллеры немного иначе, чем любые другие поставщики, поэтому у них есть свои собственные определения. Тем не менее, они также могут быть внедрены, как и любые другие поставщики.
Во второй группе у нас есть:
- Импорт — мы определяем список модулей, которые мы хотим использовать в нашем модуле. Мы сможем внедрить провайдеров, которых они экспортировали, в наших собственных провайдеров.
- Экспорт — мы определяем список провайдеров, которые мы хотим сделать доступными для других модулей.
Вторая группа более интересна с нашей точки зрения. Используя эти механизмы импорта и экспорта, мы можем воспроизвести поведение, известное из таких языков, как Java уровень доступа к пакету. Кроме того, я хочу отметить, что я буду использовать слово «модуль» для описания набора классов или функций, которые вместе обеспечивают некоторую функциональность. Например, у нас может быть модуль, отвечающий за управление учетными записями пользователей, который состоит из таких классов, как LoginService, RegistrationService и так далее. Конечно, мы можем разделить управление учетными записями пользователей на несколько модулей… но я думаю, что вы поняли идею.
Зачем ограничивать доступ к коду?
В Java классы с уровнем доступа package-private могут использоваться только другими классами из того же пакета. Только явно опубликованные классы могут быть импортированы и использованы классами из других пакетов. Однако почему мы можем захотеть ограничить доступ к части кода? Есть несколько причин, но все они сводятся к следующему: управление изменениями. Изменения в программном обеспечении неизбежны из-за того, как работает компания и как это влияет на их бизнес-требования.
Технический долг должен быть оплачен, а инструменты и технологии, которые мы используем, меняются; так должен код, который мы пишем. В хорошо спроектированной системе большинство изменений являются локальными, что в основном означает, что изменение чего-то в одном «месте» не требует изменений в другом. «Место» может быть методом класса, самим классом, модулем, микросервисом, системой и так далее. Это зависит от уровня абстракции, который у нас есть. Локальные изменения намного проще сделать, чем глобальные. Это все хорошо, но как мы можем гарантировать, что изменение будет локальным? Нам нужно установить границу между компонентами нашей системы, чтобы изменение не пересекало ее. Опять же, эта граница может быть разной, в зависимости от контекста, но давайте сосредоточимся на модулях.
В реальных приложениях большинство модулей предназначены для предоставления каких-либо услуг другим модулям. Эти другие модули обычно называются клиентами. Чтобы сделать изменения локальными, нам нужно убедиться, что клиенты не зависят от деталей реализации этого модуля. Нам нужно только показать некоторые тщательно отобранные части нашего модуля внешнему миру. Те, которые меняются редко, называются стабильными. Затем, пока мы не изменим опубликованную часть, мы можем даже полностью переписать модуль, и наш клиент даже не заметит. Эта «опубликованная часть» определяет API модуля — его границу. Другие модули, которым нужны функции, предоставляемые нашим модулем, должны использовать его API и не могут зависеть от деталей его реализации.
Посмотрите на приведенный ниже пример, чтобы увидеть, как мы можем использовать модули NestJS и шаблон проектирования фасада для достижения этого на языке, который изначально не поддерживает какие-либо модификаторы уровня доступа к модулям:
---- file: A.module.ts
@Module({ providers: [ InternalServiceOfA, FacadeOfA, ], exports: [ FacadeOfA, ] }) export class ModuleA {}
---- file: B.module.ts
import { ModuleA } from './A.module';
@Module({ imports: [ ModuleA ], providers: [ InternalServiceOfB, ], }) export class ModuleB {}
Мы определили два модуля. ModuleB зависит от ModuleA. Теперь мы можем получить доступ к API ModuleA, внедрив его фасад в сервис ModuleB:
---- file: InternalServiceOfB.ts
@Injectable()
export class InternalServiceOfB {
constructor(
//this is ok, because we exported that from ModuleA
private facadeOfA: FacadeOfA,
){}
}
Однако попытка внедрить любой из внутренних провайдеров не увенчается успехом:
---- file: InternalServiceOfB.ts
@Injectable() export class InternalServiceOfB { constructor( // this will throw - that provider was not exported private internalServiceOfA: InternalServiceOfA, ) {} }
Что касается фасада, то это обычный сервис, который может выглядеть так:
---- file: FacadeOfA.ts
@Injectable() export class FacadeOfA { constructor( private internalServiceOfA: InternalServiceOfA, ) {}
public doSomething() { return this.internalServiceOfA.internalDoSomething(); } }
Это обычный внутренний провайдер, поэтому он имеет доступ ко всем другим внутренним провайдерам модулей. Таким образом, он может действовать как прокси для поведения модуля, проксируя только те, которые предназначены для более широкой аудитории. Некоторые из вас могут подумать, что создавать методы, которые служат только прокси, — это создавать бесполезный шаблон — я понимаю и в целом согласен с этим, но в данном конкретном случае построение фасада таким образом имеет некоторые преимущества:
- У нас есть весь API, описанный в одном месте. Внутри нашего модуля у нас может быть несколько разных сервисов, выполняющих реальную работу, но с точки зрения клиента есть только одна точка входа.
- Изменения внутри нашего модуля не влияют на клиентов — до тех пор, пока они не меняют API. Кроме того, когда они это делают, фасад может выступать в качестве переводчика, позволяя старым клиентам использовать новый API без изменения их реализации.
- Фасад может быть интерфейсом, имеющим множество реализаций. Это особенно полезно, когда мы структурируем нашу систему как модульный монолит и хотим выделить один из его модулей в автономный микросервис. Затем клиентам этого модуля нужно только реализовать фасад, чтобы использовать, например, HTTP вместо вызовов внутрипроцессных методов для общения с ним.
Что насчет тестов?
Ранее я писал, что любой клиент, который хочет использовать наш модуль, должен использовать его API, а как быть с тестами? Я отношусь к большинству тестов (в том числе модульных тестов) как к клиентскому коду, поэтому они также тестируют код в модуле, используя его фасад. Это контрастирует со многими другими подходами, когда вы пишете тесты для каждого класса отдельно (этот подход также предлагается в официальной документации NestJS). Некоторые из них даже советуют тестировать приватные методы класса. Через мгновение я объясню, почему я считаю эти подходы неверными в большинстве случаев, но я хочу начать с описания того, какую роль, по моему мнению, должны выполнять тесты.
Тесты должны давать мне, разработчику, чувство безопасности. Это их главная роль. Если вы чувствуете себя уверенно и безопасно, внося изменения в код и сразу запуская его в работу (при этом будучи уверенным, что он ничего не сломает), то, вероятно, вам не нужны тесты. Однако многие другие разработчики так не считают, особенно когда работают над сложной системой с большой пользовательской базой, включая меня. Нам нужны тесты, которые дадут нам некоторую уверенность в том, что изменение, которое мы должны сделать в какой-то момент, не нарушит ожидаемое поведение нашего модуля. Пока мы не меняем поведение, мы также не хотим менять тесты. При таком подходе единицей поведения является весь модуль, поэтому модульные тесты пишутся для модуля — его API, а не для конкретных классов и функций, которые его образуют. Если мы сделаем это правильно, мы сможем переписать весь модуль с нуля (например, для решения проблем с производительностью сервера или неудачных дизайнерских решений) и даже не коснуться тестового кода, который будет действовать как ремень безопасности, гарантирующий, что мы не введем критическое изменение во время рефакторинга. Этот стиль тестирования, естественно, склоняется к тому, чтобы почти никогда не использовать моки.
Как мы можем сделать это в реальном проекте, где модуль должен общаться с базой данных? Мы не хотим подключаться к реальной базе данных в модульных тестах, верно? Конечно, нет — мы хотим, чтобы модульные тесты были очень быстрыми, чтобы мы могли запускать их часто и получать мгновенную обратную связь независимо от того, нарушаем мы что-то или нет. Мы можем использовать принцип, известный как инверсия зависимостей, и программировать абстрактный интерфейс. Мы можем реализовать этот интерфейс по-разному для разных случаев использования. В модульных тестах мы можем использовать реализацию, которая использует хеш-таблицы для имитации поведения базы данных. Кроме того, в производстве мы используем реализацию с реальным драйвером базы данных. Вот как мы можем снова использовать модуль NestJS, чтобы помочь с этими трудностями.
Во-первых, мы можем определить два разных модуля:
/---- file: A.module.ts
@Module({ providers: [ InternalServiceOfA, { provide: DatabaseRespoitoryInterface, useClass: RealDatabaseRepositoryImplementation, } ], exports: [ FacadeOfA, ] }) export class ModuleA {}
@Module({ providers: [ InternalServiceOfA, { provide: DatabaseRespoitoryInterface, useClass: InMemoryRepositoryImplementation, } ], exports: [ FacadeOfA, ] }) export class TestModuleA {}
В производственном коде мы будем использовать класс ModuleA, но в тесте мы будем использовать TestModuleA, который настроен с реализацией в памяти интерфейса репозитория:
---- file: ModuleA.spec.ts
import { Test } from '@nestjs/testing';
const moduleRef = await Test.createTestingModule({ imports: [TestModuleA], }).compile();
facade = moduleRef.get(FacadeOfA);
Резюме
В этой короткой статье я показал вам, как вы можете использовать механизм модулей NestJS, чтобы лучше справляться с изоляцией изменений, таким образом создавая код, который легче поддерживать и развивать. Это ни в коем случае не исчерпывающее объяснение. Моей целью было кратко описать проблему и наше решение, но если вы хотите узнать больше, рекомендую посмотреть эту презентацию Якуба Набрдалика об этом подходе к модуляризации и тестированию. В Leocode мы используем методы, описанные выше, в нескольких проектах, и мы очень довольны. Попробуйте сами и убедитесь, что это работает так же хорошо для вас, как и для нас!
Первоначально опубликовано на https://leocode.com 20 ноября 2020 г.