Как сделать приложение на NestJS, которое можно будет поддерживать спустя годы

от автора

Введение

Повидав десятки разных приложений на 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, в реальности она будет на порядок больше.

Про слои ответственности. Лучшим примером будет использование кода поиска не из репозитория. Рассматривать будем на крохотных примерах, на которых можно не увидеть очень больших проблем, но нужные ассоциации они у вас вызовут, уверен, ведь обычно логика использования репозитория бывает намного сложнее.

Пример неправильного кода
// Сервис + репозиторий @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;     }     // ... }

Вот такой код я видел не один раз, и это еще я указал мало ролей и параметров, иногда вообще дремучий лес возникает в коде.

Что делать?

  1. Не допускать создания таких методов, разбивать их на единственные ответственности.

  2. Не допускать создания методов, использующих таких методов, например, разбивать 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, проверка на существование параметров, если нет соответствия, то последний будет сыпать варнингами.

 А еще получаем при наведении на функцию краткое описание

А еще получаем при наведении на функцию краткое описание

Еще рекомендую, но не настаиваю на описание шагов в важных и больших функциях, например:

Еще пример
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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *