Как сделать приложение на NestJS, которое можно будет поддерживать спустя годы
- вторник, 2 июля 2024 г. в 00:00:07
Повидав десятки разных приложений на NestJS, да и на других фреймворках, я выяснил, что одна из главных сильных и слабых сторон JavaScript - свобода выбора путей решения задач.
Именно свобода и максимальная гибкость, которые данный язык предлагает разработчикам, больше всего влияет на качество проектов на нём. Язык позволяет решать задачи и строить приложения практически как угодно. И у большинства приложений бекэнда я замечаю одно и то же: спустя год, расширять и изменять их становится крайне неприятной задачей, за которую никто не захочет браться.
Да, я принимаю, что на других языках ситуации могут быть схожими, но я буду говорить только про "своё болото", и об его улучшении.
Туториал является сугубо субъективным, отталкиваясь от опыта увиденных приложений на NestJS, и решать, что со всем этим делать только вам.
Сначала выведем несколько проблем и дополним каждую из них, а затем узнаем, какие решения предлагаются автором, а затем сделаем выводы.
Когда-то, во времена, когда люди передвигались на саблезубых тиграх, а концепции асинхронности не было, и программы еще не умели ничего обещать, разработчики писали серверные приложения на нативном модуле http, фреймворке koa, или же, скорее всего, на express - довольно удобном и прорывным на то время фреймворком.
Некоторые же последовали примеру представленных выше фреймворков и начали создавать свои, те самые, фреймворки - про мемичность этого явления знают все. Время шло, деревья росли, и в 2017 вышел, как на данный момент можно судить, прорывной фреймворк на Node.js, который был встречен очень неоднозначно, но продолжал уверенно развиваться и набирать популярность.
Взяв устоявшиеся практики из Angular, добавив свой стандартный механизм внедрения зависимостей, фреймворк начал унифицировать бекэнд разработку, предоставляя мощность и гибкость, а также, упрощение расширение кодовой базы проектов.
По собственному мнению и мнениям из круга товарищей-разработчиков, NestJS является очень удобным, понятным и простым в разработке фреймворком. Создание и интеграция модулей, использование декораторов, родная поддержка TypeScript, возможность выбора фреймворка под NestJS - всё это повлияло на мой выбор, и в основном я стал работать именно на нём, оставив express для простых приложений, где модули были бы лишним нагромождением.
Разобрав немало коммерческих и внутренних проектов на NestJS, я могу выделить следующие проблемы, а затем мы перейдем к их решениям.
Некоторые разработчики просто создают папки с файлами, не понимая, что они должны делать на уровне решения проблем бизнеса. Нет разграничения контекстов, зон и слоёв ответственности, что рано или чуть позже превращает приложение в аберрацию и множества ручек, ножек и голов в местах, непредназначенных для них.
Очень мало видел, чтобы хоть для модулей API создавались контракты (интерфейсы), а это очень важный пункт, но разберём важность этого пункта в решcениях.
Также увидим мощный пример с уменьшением связности.
Нередки ситуации, когда разработчики обрабатывают очень много бизнес кейсов в одной функции, что в определенный момент сделает внесение изменений в логику без тотального рефакторинга невозможным или крайне трудозатратным (а еще разработчика будут вспоминать хорошими словами).
Практически каждый встречался при подключении на новый проект с ситуацией, когда смотришь на код, а он на тебя, и вы друг друга не понимаете неопределенное время.
Обычно, без человека, который не сядет с новым разработчиком и расскажет про этот код, можно долго "засесть" на одном месте и, что хуже, самому начать создавать баги, неверно интерпретируя имеющийся код.
Учитывая, что NestJS позволяет удобно и из коробки, вместе с Jest, создавать моки и тестирования, как юниты, так и сквозные с интеграционными, много где я этих самых заветных тестов не видел.
Возможно, на задачу не выделили времени с написанием/поддержкой тестирований, опираясь на какие-то другие приоритеты. Возможно, связано с же нежеланием разработчиков этим заниматься, но проблема серьезная, которая, скорее всего, повлечет гораздо больше проблем и трудозатрат, чем кажется.
ORM - довольно удобная и приятная вещь, которая сильно упрощает разработку, но повальное использование таких инструментов во всех случаях приложения, как правило, вызывает проблемы с работой логики частей приложения.
Исключения в NestJS - очень гибкая и удобная вещь, которую, к тому же, можно улучшить фильтрами. Игнорирование или неправильное использование исключение может неплохо усложнить работу с кодом.
В данном блоке мы разберем каждую проблему детальнее и посмотрим на предлагаемые мной решения оных, а также, автор поделится своим опытом.
Для решения данной проблемы мы можем обратиться к абстрагированию от кода и к любимому многими DDD.
В одно время я очень увлекался темой DDD, и понял, что главное - не то, куда и как расставлять файлы с папками, не агрегаты, а как решать проблемы на уровне доменов и понимать в разделении ответственностей по слоям, научиться распознавать контексты в элементах доменов.
Но вернемся к графическому и абстрактному представлению приложения. Я советую всем рисовать и представлять приложение схематически, абстрагируясь от кода.
Для примера представим, что мы делаем бекэнд для университета, и как мы его можем представить, чтобы архитектура была понятной, и разработчики могли следовать ей?
Вот простая схема, рассчитанная на минимальное количество места для компактности, но вам советую не экономить холст и тогда будет красиво и понятно.
Про слои ответственности. Лучшим примером будет использование кода поиска не из репозитория. Рассматривать будем на крохотных примерах, на которых можно не увидеть очень больших проблем, но нужные ассоциации они у вас вызовут, уверен, ведь обычно логика использования репозитория бывает намного сложнее.
// Сервис + репозиторий
@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 {
}
В данном примере мы получили максимально низкую связность, у каждого элемента есть свой контракт, который другие элементы получают и общаются посредством оного, а реализацию можно менять как и сколько угодно, главное, чтобы не нарушался контракт.
Компоненты взаимодействуют через интерфейсы, а не напрямую с конкретными реализациями, что уменьшает зависимость между ними.
При возможном изменении сервисов и/ли реализаций их логики, остальные элементы будут не затронуты без изменения контракта. Также, упрощает создание моков при тестировании, так как мы чётко знаем, что кому нужно.
Можно обойтись и без инжектов через токены, но в больших приложениях данная опция будет цениться для удобства больше.
Допустим, есть метод:
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, проверка на существование параметров, если нет соответствия, то последний будет сыпать варнингами.
Еще рекомендую, но не настаиваю на описание шагов в важных и больших функциях, например:
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хх, что уменьшит количество ветвлений логики. Еще рекомендую добавить фильтры, которые добавляют дополнительную обработку исключений.
Вывод довольно прост - правильно выделять время на задачи, попросить ведущих разработчиков создать правила для написания кода, а что самое важное - внедрять людям культуру кода.
Культура кода важна для создания качественного, поддерживаемого и надежного программного обеспечения.
Она способствует улучшению взаимодействия с кодом внутри команды, повышает производительность разработчиков и обеспечивает хотя бы некоторые стандарты разработки.
Но тут, как и в стандартной культуре, нужно постепенно прививать и пропагандировать, что это нужно, и, главное, почему это важно и нужно.
На этом всё, спасибо за прочтение данной статьи, если есть вопросы или что-то другое, то обязательно пишите в комментариях. Всем успехов в ваших делах!