Привет, Хабр. Меня зовут Денис Былинин, я архитектор в компании Сравни.
Сегодня хочу рассказать, как мы пришли к использованию NestJS и какие сделали выводы после года работы с ним. Чтобы не уходить в абстракции, которые легко гуглятся, я сосредоточусь на том, как использование этого фреймворка в реальности влияет на разработку, а также на его плюсах и минусах, с которыми мы лично столкнулись в работе.
Статья будет полезна руководителям разработки, системным архитекторам, тимлидам и всем, кто так или иначе заинтересован во внедрении новых фреймворков и инструментов в компании.
С чего всё начиналось
Несколько лет назад, когда наша команда разработки бэкенда мобильного приложения была совсем небольшой, мы решили с нуля пересобрать наш Node.js-стек. Взяли Babel и все вкусные фишки ESNext, поставили поверх Koa и Mongoose, сделали красивую документацию в JSDoc и склеили всё это набором кастомных библиотек. Так родился наш небольшой boilerplate для новых сервисов мобильной команды. На тот момент такая схема покрывала наши основные потребности, и мы спокойно занимались своим делом.
Постепенно мобильное приложение Сравни стало расти — вокруг образовывались новые вспомогательные сервисы и сайд-проекты, которые нам тоже приходилось поддерживать. А вместе с ним, команда интенсивно пополнялась новыми специалистами.
Вскоре стали поступать первые тревожные звоночки из мрачного будущего, которое нас ожидало, если ничего не предпринять. Стало сложнее поддерживать общую кодовую базу, появились проблемы с тестированием. Практики и рекомендации к построению бэкенда порой интерпретировались весьма специфическим образом, и новым сотрудникам приходилось долго разбираться в том, как всё устроено.
Всё это тормозило и усложняло процесс разработки и поддержки. К тому же, дальнейшее расширение фреймворка стало дорогим удовольствием, потому что библиотеки были далеко не идеально подогнаны друг к другу. Поэтому мы с тяжелым сердцем решили отказаться от дальнейшего развития своего DIY-фреймворка и начали искать альтернативы.
В итоге выбор пал на фреймворк NestJS на базе Node.js. Сразу скажу, что мы не искали «бескомпромиссно лучший со всех сторон фреймворк». Нам нужен был инструмент, который помог бы решить наметившиеся проблемы и одновременно дал бы достаточно свободы для развития.
Чем нас привлек Nest? Строгой модульной системой с инкапсуляцией логики, инверсией управления, богатым набором инструментов на все случаи жизни, хорошей типизацией, легкостью тестирования (во многом благодаря инъекции зависимостей), внятной документацией, независимостью от транспорта, возможностями кастомизации и расширения.
На данный момент мы используем NestJS для разработки и поддержки пяти сервисов:
профиль пользователя (10 микросервисов + 4 сервиса интеграций)
управление продуктовыми сценариями (5 микросервисов)
мобильные виджеты (2 микросервиса)
последние действия пользователя
проведение экспериментов
В большинстве случаев мы применяем стандартный инструментарий Nest, разворачивая API-гейтвеи, сервисы и микросервисы из собственных шаблонов с некоторыми доработками стандартных модулей. Но об этом чуть позже.
Структура и boilerplate
Одна из главных особенностей Nest, с которой сталкиваешься даже при развертывании стандартного приложения через nest new my-app
— в нем очень много шаблонного кода.
На скрине стандартный код приложения с одним контроллером и одним вложенным модулем. Если вы, к примеру, захотите добавить модель и репозиторий для реализации CRUD, то файлов получится еще больше. Конечно, если вы и так стараетесь декомпозировать логику приложения на различные уровни, выделять бизнес-логику, работу с БД и т. д., то из дополнительных файлов вас ожидает только сам модуль.
Постепенно с этим удается свыкнуться. Подобная шаблонизация помогает хорошо ориентироваться даже в незнакомом коде. А с помощью CLI или расширений на VSCode вы сможете быстро генерировать новые модули и сервисы.
При должном старании можно создавать довольно запутанные структуры, где директории модулей бесконечно вкладываются друг в друга, а импорты не помещаются в экран. У нас получается с этим бороться на уровне соглашений. К тому же, структура, используемая генераторами Nest является просто рекомендацией, вы можете придумать такую, которая будет устраивать вашу команду.
Метамагия и темная сторона Nest
Вся магия фреймворка построена на записи метаданных (роутинги, OpenAPI, инъекции), а также на извлечении данных о типах.
На скрине изображен довольно редкий пример с большим количеством декораторов, но в реальных задачах встречаются и более длинные цепочки.
Выглядит это хоть и читабельно, но не очень красиво. Для подобных случаев Nest предлагает встроенную функцию для комбинирования нескольких декораторов в один. Конечно, злоупотребление таким подходом тоже не будет красить код, просто мы можем выбрать меньшее из зол по ситуации.
Для кастомизации определенного поведения Nest приготовьтесь часто использовать reflect-metadata и всякие интересные поля типа «design:type», «design:paramtypes», «design:returntype», а также искать константы, которые Nest использует для записи своей служебной meta-информации.
Dependency Injection
Nest предлагает прекрасный инструментарий для управления созданием сущностей для DI. Есть несколько видов инъекций: по конструктору и по токену. В качестве токена можно взять любую строку или символ, пометить им соответствующее поле класса или параметр конструктора, для инъекции по конструктору токен будет сгенерирован из имени класса «под капотом» Nest. Также вы можете управлять процессом создания элементов модуля с помощью подмен, фабрик и даже просто подменять токен или конструктор каким-то объектом.
Но этот же инструментарий часто служит причиной критики NestJS.
К сожалению, при инъекции по конструктору не удастся полностью развязать код модулей, которые зависят друг от друга.
Так или иначе, в данном примере нам придется импортировать из другого модуля SomeService для описания его типа, чтобы Nest мог использовать инъекцию по конструктору. Описанные выше способы позволят вам этот класс подменить другим по ситуации, но от импортов не избавят.
С другой стороны, у нас есть инъекция по токену, которая позволяет нам определить интерфейсы и избавляет от импорта сервисов, провайдеров и т. д. из других модулей.
Но тут мы сразу же теряем проверку типов, потому что Nest во время сборки дерева зависимостей уже не сможет проверить, подойдет ли по сигнатуре элемент, который мы инжектим по токену. И проверка корректности ложится на наши плечи.
Теоретически, этот код можно доработать так, чтобы свести шанс ошибки к минимуму:
Такой подход позволит поймать ошибку на стадии написания кода, но если импортируемый модуль уже экспортирует нужный нам сервис по токену, то придется вспомнить свою жизнь до TypeScript и надеяться только на себя.
При всех этих недостатках системы инъекции зависимостей, в реальных проектах мы еще ни разу не испытывали каких-то непреодолимых проблем. Чаще всего мы используем банальную инъекцию по конструктору.
Ошибки DI
Отдельного упоминания стоят ошибки Nest, которые он возвращает в случае, когда не удалось разрешить дерево зависимостей. Иногда приходится всерьез поломать голову, какой модуль мы забыли импортировать или где использовали неверный токен.
Сделаем, например, циклическую зависимость, которую webpack нам соберет без проблем:
test.module.ts
test.service.ts
second.service.ts
Если бы пример был не столь вырожденный и очевидный, то пришлось бы очень внимательно изучить ошибку, чтобы понять, в чем здесь дело:
[Nest] 20992 - 01.12.2022, 07:47:40 ERROR [ExceptionHandler] Nest can't resolve dependencies of the TestService (?). Please make sure that the argument dependency at index [0] is available in the TestModule context.
И только отсутствие упоминания SecondService после argument позволит нам догадаться, что плохое произошло именно с ним. Иначе ошибка выглядела бы так:
ERROR [ExceptionHandler] Nest can't resolve dependencies of the TestService (?). Please make sure that the argument SecondService at index [0] is available in the TestModule context.
Стоит отметить, что такие ошибки приходится расследовать не так уж часто. Либо при рефакторинге большого объема кода с изменением иерархии модулей, либо при написании сразу десятка модулей без проверки. События эти настолько редки, что сходу я не смог вспомнить какого-то более возмутительного примера.
Transport agnostic framework
А теперь мы переходим к самому сладкому, о чем обычно не пишут в тредах на реддите, но что можно раскопать на гитхабе, либо столкнуться с этим на собственном опыте, как было у нас.
В документации говорится, что фреймворк не зависит от транспорта и в случае необходимости можно легко и просто реализовать собственный. Но в реальности все несколько сложнее. Так как транспорты NestJS зависят от одного общего родителя, то для соблюдения корректности придется глубоко погрузиться в код самого фреймворка.
Если вы, например, захотите добавить трассировку, то будьте готовы к тому, что вам придется перелопатить все транспорты и надеяться, что в будущих релизах код, на который вы опирались для построения трассировки, не изменится. Или полностью дублировать весь код нужных транспортов, а также код клиентов, серверов и прочих сущностей, где вы хотите видеть трассировку, но тогда получится, что вы уже разрабатываете свой фреймворк, параллельный Nest.
Предполагаемая универсальность Nest нередко становится его слабым местом. Так, например, до девятой версии сигнатуры полезной нагрузки Kafka и RabbitMQ не совпадали, что не позволяло бесшовно переключить транспорты. Мы решили написать кастомную десериализацию, которая приводила бы ответ от Kafka в тот же вид, что и ответ от RabbitMQ.
В девятой версии Nest транспорт через Kafka был унифицирован, но на этот раз в коде появилась ошибка, которая уже приводила к зависанию транспорта при попытке отправить ошибку. Для быстрого решения можно сделать небольшой хак-фикс для server-kafka, но спотыкаться о такие ошибки при переезде на новую версию фреймворка для достаточно большого проекта, согласитесь, не очень весело.
Паттерны сообщений, которые можно привязывать к обработчикам, тоже могут неприятно удивлять, потому что они не имеют никакой валидации на допустимые символы. Поэтому метод, который работает для одного транспорта, может неожиданно перестать работать для другого.
К примеру, код из документации, который прекрасно работает для RabbitMQ, не сработает для Kafka. Потому что, в конечном итоге, код server-kafka попытается подписаться на consumer.subscribe(Object.assign({ topic: ‘{"cmd":"sum"}’ }, consumerSubscribeOptions))
, где значение поля topic формируется этой замечательной функцией, a фигурные скобки не допустимы в имени топика. Конечно, можно придумать свой универсальный способ преобразования подходящий для Kafka, но придется создать свой собственный транспорт, наследуя его от server-kafka.ts.
Где не стоит использовать Nest
Во-первых, Nest скорее всего «не зайдет» для небольших проектов, где не нужно разнообразие транспортов, healthcheck-и и прочее. С другой стороны, развернуть простой HTTP-сервер с CLI при некотором опыте с Nest достаточно просто. Мы, например, разворачиваем новые сервисы уже из готовых шаблонов.
Во-вторых, непредсказуемое количество времени могут занять задачи, для решения которых нужно выходить за пределы основных концепций Nest — потому что придется глубоко погружаться в код фреймворка или использовать нетривиальные подходы, которые в дальнейшем будет сложно поддерживать и переносить на новые версии.
Например, в концепцию Nest будет непросто вписать динамические эндпоинты, которые формируются из описания в конфиге. Конечно, в крайнем случае можно динамически сконструировать класс и повесить на него нужные декораторы с помощью фабрик. Но в рамках концепции Nest это будет максимально спорное решение.
Наконец, Nest, возможно, не лучший выбор, если для вас критична максимальная производительность. За удобство, универсальность и гибкость Nest отвечают множество слоев абстракций и rxjs-магия — и всё это, увы, отрицательно сказывается на производительности.
Что в итоге?
Если перед вами стоят проблемы, связанные с масштабированием проекта, то Nest может стать неплохим подспорьем для их решения. Хотя нам не удалось полностью отказаться от собственных библиотек и доработок, но их стало гораздо меньше. Nest провоцирует лучше структурировать код, думать над декомпозицией и, в целом, использовать правильные подходы к программированию. Благодаря этому, погружение новых людей в рабочий процесс стало проходить на порядок проще и быстрее.
Мы прочувствовали на практике, что техническую сложность, возникающую при масштабировании, нельзя убрать полностью. Однако, всегда можно выбрать, какие инструменты использовать, чтобы ее локализовать. И хотя создавать свои велосипеды весело и познавательно, но в итоге они отнимают уйму сил на поддержание их в рабочем состоянии.
Спасибо за внимание, буду рад ответить на вопросы и комментарии.