Введение
Повидав десятки разных приложений на NestJS, да и на других фреймворках, я выяснил, что одна из главных сильных и слабых сторон JavaScript — свобода выбора путей решения задач.
Именно свобода и максимальная гибкость, которые данный язык предлагает разработчикам, больше всего влияет на качество проектов на нём. Язык позволяет решать задачи и строить приложения практически как угодно. И у большинства приложений бекэнда я замечаю одно и то же: спустя год, расширять и изменять их становится крайне неприятной задачей, за которую никто не захочет браться.
Да, я принимаю, что на других языках ситуации могут быть схожими, но я буду говорить только про «своё болото», и об его улучшении.
Туториал является сугубо субъективным, отталкиваясь от опыта увиденных приложений на NestJS, и решать, что со всем этим делать только вам.
О чем будем говорить
Сначала выведем несколько проблем и дополним каждую из них, а затем узнаем, какие решения предлагаются автором, а затем сделаем выводы.
Краткая справка и мысли про NestJS
Когда-то, во времена, когда люди передвигались на саблезубых тиграх, а концепции асинхронности не было, и программы еще не умели ничего обещать, разработчики писали серверные приложения на нативном модуле http, фреймворке koa, или же, скорее всего, на express — довольно удобном и прорывным на то время фреймворком.
Некоторые же последовали примеру представленных выше фреймворков и начали создавать свои, те самые, фреймворки — про мемичность этого явления знают все. Время шло, деревья росли, и в 2017 вышел, как на данный момент можно судить, прорывной фреймворк на Node.js, который был встречен очень неоднозначно, но продолжал уверенно развиваться и набирать популярность.
Взяв устоявшиеся практики из Angular, добавив свой стандартный механизм внедрения зависимостей, фреймворк начал унифицировать бекэнд разработку, предоставляя мощность и гибкость, а также, упрощение расширение кодовой базы проектов.
По собственному мнению и мнениям из круга товарищей-разработчиков, NestJS является очень удобным, понятным и простым в разработке фреймворком. Создание и интеграция модулей, использование декораторов, родная поддержка TypeScript, возможность выбора фреймворка под NestJS — всё это повлияло на мой выбор, и в основном я стал работать именно на нём, оставив express для простых приложений, где модули были бы лишним нагромождением.
В чём же проблема?
Разобрав немало коммерческих и внутренних проектов на NestJS, я могу выделить следующие проблемы, а затем мы перейдем к их решениям.
Проблема — Отсутствие абстрактного и(ли) графического (доменного) представления
Некоторые разработчики просто создают папки с файлами, не понимая, что они должны делать на уровне решения проблем бизнеса. Нет разграничения контекстов, зон и слоёв ответственности, что рано или чуть позже превращает приложение в аберрацию и множества ручек, ножек и голов в местах, непредназначенных для них.
Проблема — Игнорирование контрактов и высокая связность
Очень мало видел, чтобы хоть для модулей API создавались контракты (интерфейсы), а это очень важный пункт, но разберём важность этого пункта в решcениях.
Также увидим мощный пример с уменьшением связности.
Проблема — Создание функций, ответственных за 999 бизнес кейсов одной тематики
Нередки ситуации, когда разработчики обрабатывают очень много бизнес кейсов в одной функции, что в определенный момент сделает внесение изменений в логику без тотального рефакторинга невозможным или крайне трудозатратным (а еще разработчика будут вспоминать хорошими словами).
Проблема — Отсутствие комментариев
Практически каждый встречался при подключении на новый проект с ситуацией, когда смотришь на код, а он на тебя, и вы друг друга не понимаете неопределенное время.
Обычно, без человека, который не сядет с новым разработчиком и расскажет про этот код, можно долго «засесть» на одном месте и, что хуже, самому начать создавать баги, неверно интерпретируя имеющийся код.
Проблема — Отказ и(ли) отсутствие выделения времени на создание и поддержку тестирований
Учитывая, что NestJS позволяет удобно и из коробки, вместе с Jest, создавать моки и тестирования, как юниты, так и сквозные с интеграционными, много где я этих самых заветных тестов не видел.
Возможно, на задачу не выделили времени с написанием/поддержкой тестирований, опираясь на какие-то другие приоритеты. Возможно, связано с же нежеланием разработчиков этим заниматься, но проблема серьезная, которая, скорее всего, повлечет гораздо больше проблем и трудозатрат, чем кажется.
Проблема — Чрезмерное использование ORM
ORM — довольно удобная и приятная вещь, которая сильно упрощает разработку, но повальное использование таких инструментов во всех случаях приложения, как правило, вызывает проблемы с работой логики частей приложения.
Проблема — Неправильное управление исключениями
Исключения в NestJS — очень гибкая и удобная вещь, которую, к тому же, можно улучшить фильтрами. Игнорирование или неправильное использование исключение может неплохо усложнить работу с кодом.
Решаем проблемы и рефлексируем
В данном блоке мы разберем каждую проблему детальнее и посмотрим на предлагаемые мной решения оных, а также, автор поделится своим опытом.
Решение — Отсутствие абстрактного и(ли) графического (доменного) представления
Для решения данной проблемы мы можем обратиться к абстрагированию от кода и к любимому многими DDD.
В одно время я очень увлекался темой DDD, и понял, что главное — не то, куда и как расставлять файлы с папками, не агрегаты, а как решать проблемы на уровне доменов и понимать в разделении ответственностей по слоям, научиться распознавать контексты в элементах доменов.
Но вернемся к графическому и абстрактному представлению приложения. Я советую всем рисовать и представлять приложение схематически, абстрагируясь от кода.
Для примера представим, что мы делаем бекэнд для университета, и как мы его можем представить, чтобы архитектура была понятной, и разработчики могли следовать ей?
Вот простая схема, рассчитанная на минимальное количество места для компактности, но вам советую не экономить холст и тогда будет красиво и понятно.
![Простейшая схема, нарисованная на draw.io, в реальности она будет на порядок больше. Простейшая схема, нарисованная на draw.io, в реальности она будет на порядок больше.](https://habrastorage.org/getpro/habr/upload_files/ef1/78c/48a/ef178c48a2b2f192a1e016952eb559d0.webp)
Про слои ответственности. Лучшим примером будет использование кода поиска не из репозитория. Рассматривать будем на крохотных примерах, на которых можно не увидеть очень больших проблем, но нужные ассоциации они у вас вызовут, уверен, ведь обычно логика использования репозитория бывает намного сложнее.
Пример неправильного кода
// Сервис + репозиторий @Injectable() export class UserService { constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, ) {} async updateUserByIdAnd(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> { const formatedObject = MyUtils.formatObject(someUpdateObj); /* * Еще какая-то логика... */ return this.userRepository.update({where: {id: userId}}, someUpdateObj); } }
К сожалению, мы намешали разные слои и зоны ответственностей: сервис пользователей и что-то считает-вычисляет, выполняя часть бизнес задачи, да еще и выполняет функции инфраструктуры, сразу же засорив код сервиса, который вообще не должен общаться с базой данных напрямую, кроме редких случаев.
Для исправления этого нам стоит вынести всю логику инфраструктуры в методы UserRepository, и уже вызывать их, полностью вынося из сервиса инфраструктурную логику.
Пример правильного разделения по слоям
// Репозиторий @Injectable() export class UserRepository { constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, ) {} async partialUpdateUserById(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> { //Можно использовать форматирование или выброс исключений, если мы определили репозиторий как умный (см. ниже) const formatedObject = MyUtils.formatObject(someUpdateObj); return this.userRepository.update({where: {id: userId}}, formatedObject); } } // Сервис @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, ) {} async updateUserByIdAnd(userId: string, someUpdateObj: UpdateUserDTO): Promise<User> { /* * Какая-то логика... */ return this.userRepository.partialUpdateUserById(userId, someUpdateObj); } }
На выбор предлагается два вида репозиториев:
-
Умные (smart) репозитории полезны тогда, когда требуется выполнение операций типа форматирования и прочего, которые не могут быть выполнены просто с использованием CRUD операций.
-
Глупые (dumb) репозитории подходят для простых частей системы, когда от него требуется просто выполнить операции CRUD.
Теперь мы разделили ответственности слоёв и упростили код и читаемости приложения.
Контекст же нужно намечать, чтобы разработчики понимали, что и где, возможно зачем, связанны модули приложения, что упростит понимание последнего.
Еще довольно полезным умением, которым стоит научиться — уметь думать доменно и посредством кода, например: Отчислить студента = Добавить в журнал отчислений запись о данном студенте в таблице журнала отчислений базы данных, удалить/изменить запись студента в таблице студентов базы данных.
Решение — Игнорирование контрактов и высокая связность
Для начала приведу абзац теории:
Связность (coupling) в программной инженерии относится к степени зависимости между различными модулями или компонентами системы. Высокая связность означает, что компоненты сильно зависят друг от друга, что усложняет их изменение, тестирование и повторное использование. Низкая связность, напротив, предполагает, что компоненты имеют минимальные зависимости друг от друга, что делает систему более гибкой и легкой для поддержки. Контракты — описание интерфейсов взаимодействия компонентов, то есть, интерфейсы или классы DTO в TypeScript.
Если же сделать выводы в контексте NestJS, то для низкой связности части приложения не должны быть привязанным к интерфейсам определенных классов, и классы не должны быть источниками контрактов, а должны их имплементировать.
Простой пример интерфейсов в NestJS:
Пример
// Интерфейс export interface IUserService { findAll(): Promise<User[]>; findOne(id: string): Promise<User>; create(createUserDto: CreateUserDto): Promise<User>; } // Сервис, его имплементирущий @Injectable() export class UserService implements IUserService { async findAll(): Promise<User[]> { // Логика получения всех пользователей } async findOne(id: string): Promise<User> { // Логика получения пользователя по ID } async create(createUserDto: CreateUserDto): Promise<User> { // Логика создания нового пользователя } }
То есть, у нас не интерфейс(описание функциональности) зависит от имеющейся функциональности, а имеющаяся функциональность зависит от требуемой функциональности.
А как же это связанно со связностью?
Низкая связность достигается тем, что КлассА не зависит от конкретной реализации КлассаБ, а использует контракт, описанный для КлассаБ, и ему не важно, как КлассБ выполняет логику функциональности.
Со временем я нашел самый полезный вариант снижения связности для NestJS, и я его вам покажу:
Тот самый вариант
// Интерфейс сервиса export interface IUserService { findAll(): Promise<User[]>; findOne(id: string): Promise<User>; create(createUserDto: CreateUserDto): Promise<User>; } // Интерфейс репозитория export interface IUserRepository { findAll(): Promise<User[]>; findOne(id: string): Promise<User>; create(createUserDto: CreateUserDto): Promise<User>; } // Сервис @Injectable() export class UserService implements IUserService { constructor( @Inject('IUserRepository') private readonly userRepository: IUserRepository, ) {} async findAll(): Promise<User[]> { return this.userRepository.findAll(); } async findOne(id: string): Promise<User> { return this.userRepository.findOne(id); } async create(createUserDto: CreateUserDto): Promise<User> { return this.userRepository.create(createUserDto); } } // Контроллер @Controller('users') export class UserController { constructor(@Inject('IUserService') private readonly userService: IUserService) {} @Get() async findAll() { return this.userService.findAll(); } @Get(':id') async findOne(@Param('id') id: string) { return this.userService.findOne(id); } @Post() async create(@Body() createUserDto: CreateUserDto) { return this.userService.create(createUserDto); } } // Репозиторий @Injectable() export class UserRepository implements IUserRepository { constructor( @InjectRepository(User) private readonly userRepository: Repository<User>, ) {} async findAll(): Promise<User[]> { // Логика для получения всех пользователей } async findOne(id: string): Promise<User> { // Логика для получения пользователя по ID } async create(createUserDto: CreateUserDto): Promise<User> { // Логика для создания нового пользователя } } // Модуль @Module({ controllers: [UserController], providers: [ { provide: 'IUserService', useClass: UserService, }, { provide: 'IUserRepository', useClass: UserRepository, }, ], }) export class UserModule { }
В данном примере мы получили максимально низкую связность, у каждого элемента есть свой контракт, который другие элементы получают и общаются посредством оного, а реализацию можно менять как и сколько угодно, главное, чтобы не нарушался контракт.
Компоненты взаимодействуют через интерфейсы, а не напрямую с конкретными реализациями, что уменьшает зависимость между ними.
При возможном изменении сервисов и/ли реализаций их логики, остальные элементы будут не затронуты без изменения контракта. Также, упрощает создание моков при тестировании, так как мы чётко знаем, что кому нужно.
Можно обойтись и без инжектов через токены, но в больших приложениях данная опция будет цениться для удобства больше.
Проблема — Создание функций, ответственных за 999 бизнес кейсов одной тематики
Допустим, есть метод:
Тот самый метод
async fetchData(userId: string): Promise<{user: User, shops?: Shops, shopsMoney?: ShopsMoney, myMoney: UserMoney}> { const user = this.userRepository.fetchUser(userId); const result = {}; if (user.role === UserRoles.ADMIN) { result.shops = this.shopsRepository.findAll(); result.shopsMoney = await Promise.all(ashops.map(async (shop) => { const money = await this.moneyRepository.findByShopId(shop.id); return {shop, money}; })); return result; } else { result.user = user; result.money = await this.moneyRepository.findByUserId(user.id); return result; } // ... }
Вот такой код я видел не один раз, и это еще я указал мало ролей и параметров, иногда вообще дремучий лес возникает в коде.
Что делать?
-
Не допускать создания таких методов, разбивать их на единственные ответственности.
-
Не допускать создания методов, использующих таких методов, например, разбивать API на несколько роутов с обособленными методами.
Решение — Отсутствие комментариев
Здесь решение довольно простое — начать писать комментарии, и я вас научу даже как.Есть такая прекрасная вещь, как JSDoc, и вот как можно писать комментарии на нем к функциям, методам и т.д.:
Пример
/** * @description Функция, которая добававляет пользователя в базу данных и отсылает ему на почту сообщение с приветствием * @param email - имейл пользователя * @param phone - (опционально) - телефон пользователя * @param name - ФИО пользователя * @returns {string} UUID пользователя * @link https://mysuperwiki.com/registerUser */ function registerUser(email: string, name: string, phone?: number): Promise<string> { ... }
Следуя данному примеру, мы можем получить описание функций, ссылку на вики, что возвращает функция. Еще неплохая функция JSDoc с ESLint, проверка на существование параметров, если нет соответствия, то последний будет сыпать варнингами.
![А еще получаем при наведении на функцию краткое описание А еще получаем при наведении на функцию краткое описание](https://habrastorage.org/getpro/habr/upload_files/9d7/6de/e54/9d76dee541f1d88e8b1a6b95a88e56ed.webp)
Еще рекомендую, но не настаиваю на описание шагов в важных и больших функциях, например:
Еще пример
function complicatedFunction(...) { // Шаг 1: Создаем x и вызываем функцию просчета траектории ... // Шаг 2: Передаем x в RMQ и ждем ответа ... // Шаг 3: Записываем результаты ответа от микросервиса просчета y ... }
Поначалу, когда разработчик пишет еще горячий код, то потребность и желание в написании комментариев не так остра, а спустя некоторое время он смотрит на код со словами «Что же это такое?» — обыденная ситуация.
Решение — Отказ и(ли) отсутствие выделения времени на создание и поддержку тестирований
Наверное, самая важная и распространённая проблема, на самом деле. Про мотивы размышлять не будем, но тестирования, хотя бы юниты, обязаны быть. И лучше в юнитах добиваться хороших показателей покрытия, то есть, все возможные ветвления кода и т.д., но не усердствовать с неверным вводом, так как такие данные обсекутся пайпами и валидаторами.
В противном случае при малейших изменениях в больших приложениях, оно может потрескаться, и возможно даже совершенно в другом месте приложения, а узнают об этом только конечные пользователи, и начнется очередной виток из саппорта, тикета, правок, тестирования и прочего…
Лучше всего сделать обязательные тестирования хотя бы перед merge-реквестом.Тестирования — важнейшая часть приложения, на которую стоит потратить время, чтобы потом быть намного увереннее, что проблем будет намного меньше. А если еще добавить интеграционные и сквозные (е2е), то будет еще лучше.
Решение — Неправильное управление исключениями
И, наконец, хочу рассказать о надобностях исключений при создании логики приложения. Возможно, это кажется очевидным, но, как показывает практика, немало людей о них не знают или не хотят использовать.
Исключения, особенно в NestJS, очень удобные, но позволяют уменьшить количество кода, например:
Плохой пример
// Сервис @Injectable() export class UserService { private users: User[] = []; findUserById(id: number): User | null { const user = this.users.find(user => user.id === id); return user || null; } } // Контроллер @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Get(':id') findUserById(@Param('id') id: number) { const user = this.userService.findUserById(id); if (!user) { return { message: 'Пользователь не найден!' }; // Лишний здесь код, но ответить пользователю нужно } return user; } }
Это самый простой пример, когда мы можем избавиться от части кода и убрать все негативные результаты при выполнении.
Пример
// Сервис @Injectable() export class UserService { private users: User[] = []; findUserById(id: number): User | null { const user = this.users.find(user => user.id === id); if (!user) { throw new NotFountException({ message: 'Пользователь не найден!' }); } return user; } } // Контроллер @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Get(':id') findUserById(@Param('id') id: number) { return user = this.userService.findUserById(id); } }
Как можно увидеть, мы уменьшили количество кода, не стали заниматься резолвом логики и даже получили возможность указывать, какой код NestJS отдаст пользователю.
Также можно и не обрабатывать результаты каких-то функций, а выбрасывать исключения в случаях, не являющимися правильными, прямо в самих функциях.
В таком случае у нас получаются либо только правильные ответы, либо ответы с ошибкой с кодом 4хх-5хх, что уменьшит количество ветвлений логики. Еще рекомендую добавить фильтры, которые добавляют дополнительную обработку исключений.
Какие выводы мы можем сделать
Вывод довольно прост — правильно выделять время на задачи, попросить ведущих разработчиков создать правила для написания кода, а что самое важное — внедрять людям культуру кода.
Культура кода важна для создания качественного, поддерживаемого и надежного программного обеспечения.
Она способствует улучшению взаимодействия с кодом внутри команды, повышает производительность разработчиков и обеспечивает хотя бы некоторые стандарты разработки.
Но тут, как и в стандартной культуре, нужно постепенно прививать и пропагандировать, что это нужно, и, главное, почему это важно и нужно.
На этом всё, спасибо за прочтение данной статьи, если есть вопросы или что-то другое, то обязательно пишите в комментариях. Всем успехов в ваших делах!
ссылка на оригинал статьи https://habr.com/ru/articles/825848/
Добавить комментарий