Введение
Повидав десятки разных приложений на 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, и понял, что главное - не то, куда и как расставлять файлы с папками, не агрегаты, а как решать проблемы на уровне доменов и понимать в разделении ответственностей по слоям, научиться распознавать контексты в элементах доменов.
Но вернемся к графическому и абстрактному представлению приложения. Я советую всем рисовать и представлять приложение схематически, абстрагируясь от кода.
Для примера представим, что мы делаем бекэнд для университета, и как мы его можем представить, чтобы архитектура была понятной, и разработчики могли следовать ей?
Вот простая схема, рассчитанная на минимальное количество места для компактности, но вам советую не экономить холст и тогда будет красиво и понятно.
Про слои ответственности. Лучшим примером будет использование кода поиска не из репозитория. Рассматривать будем на крохотных примерах, на которых можно не увидеть очень больших проблем, но нужные ассоциации они у вас вызовут, уверен, ведь обычно логика использования репозитория бывает намного сложнее.
Пример неправильного кода
// Сервис + репозиторий
@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, проверка на существование параметров, если нет соответствия, то последний будет сыпать варнингами.
Еще рекомендую, но не настаиваю на описание шагов в важных и больших функциях, например:
Еще пример
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хх, что уменьшит количество ветвлений логики. Еще рекомендую добавить фильтры, которые добавляют дополнительную обработку исключений.
Какие выводы мы можем сделать
Вывод довольно прост - правильно выделять время на задачи, попросить ведущих разработчиков создать правила для написания кода, а что самое важное - внедрять людям культуру кода.
Культура кода важна для создания качественного, поддерживаемого и надежного программного обеспечения.
Она способствует улучшению взаимодействия с кодом внутри команды, повышает производительность разработчиков и обеспечивает хотя бы некоторые стандарты разработки.
Но тут, как и в стандартной культуре, нужно постепенно прививать и пропагандировать, что это нужно, и, главное, почему это важно и нужно.
На этом всё, спасибо за прочтение данной статьи, если есть вопросы или что-то другое, то обязательно пишите в комментариях. Всем успехов в ваших делах!
Комментарии (11)
kellas
01.07.2024 15:48+2Сама постановка задачи некорректная.
Нет проектов которые легко поддерживать спустя годы, потому что с годами меняются люди и подходы в разработке. Об этом косвенно свидетельствует опыт многолетней работы программистов на каком-нибудь одном предприятии, где они как специалисты "консервируются" и у них нет проблем с поддержкой проекта который работает уже 10 лет, но есть проблемы с трудоустройством в другие компании работающие уже на современном стеке. Если же они параллельно с работой развиваются , то у них регулярно чешутся руки всё переписать.
И наоборот, специалист с современными стеком вынужден ломать свои представления о разработке чтобы понять ту старую логику и те старые инструменты, которые в общем-то были хуже, труднее в использовании и т п
То есть поддержка старого кода всегда будет тяжёлой задачей, смиритесь с этим, тут либо стагнация, либо боль от встречи даже с самим собой из прошлого выраженного в том "бывшем" коде.
Avangardio Автор
01.07.2024 15:48Привет, у меня следующее мнение: если мы сидим на стеке того же неста, то придется поддерживать только модули ручного кода, привязывать/отвязывать реализации на либерах, но именно с самим нестом я не видел серьезных изменений к подходу к работе в несте годами, так что некоторые проекты можно понять и что-то изменить с меньшей болью.
Froncast
01.07.2024 15:48Не пишу комментарии к какой-либо статье на хабре, потому что обычно в комментариях уже более опытные люди высказали свое мнение. Но тут у меня есть с чем не согласиться. Возможно какую-то информацию я не верно интерпретировал, додумал сам, принял всё близко к сердцу и т.д., потому и "родился" данный комментарий.
1) Отсутствие абстрактного и(ли) графического (доменного) представления:
Простая представленная схема. Возможно она действительно нарисованна просто для наглядности связи между модулями и я не разлядел в ней того как они на самом деле между собой связаны. Ибо глядя на эту схему я вижу "класические" модули из документации NestJS и когда модули вот так вот полностью импортируются один в другой это со временем начинает вызывать ряд проблем: связанность, в модули тянется то что им совершенно ненужно, из-за чего приложение стартует дольше.
Набираясь опыта пришел к решению (а в последующем увидел в разных статьях и официальном курсе NestJS), что каждый модуль также делится на слои, где каждый слой это модуль. И если модулю Feature1 нужен модуль Feature2, то в него будет импортироваться только бизнес слой(модуль) из модуля Feature2.
2) Игнорирование контрактов и высокая связность
Про подключение провайдеров через интефейсы соглашусь, но в целом про модуль написано выше. Обойтись и без инжектов через токены можно используя абстрактные классы.
Не знаю какую Вы закладывали логику для получения/создания пользователей, но так как Вы не написали пример то приходится додумывать самому что там написано. А потому могу сказалить лишь то, что максимум какая логика тут может быть описана, так это обработка входящих параметров (условия выборки, пагинация и т.д.) в зависимости от выбранного хранилища и клиента для него. При этом в контракте эти параметры должны быть описаны так, чтобы в любой момент можно было подменить как хранилище, так и клиента (сомневаюсь, что это частая практика и тем не менее, вдруг с TypeOrm решили перейти например на Kysely).
3) Отсутствие комментариев
С решением согласен, а вот пример функции тригернул.Функция, которая добававляет пользователя в базу данных и отсылает ему на почту сообщение с приветствием
Ну не должна функция заниматься и созданием пользователя и отправкой уведомления. После создания пользователя лучше либо кинуть событие, на которое отрегарует соответствующий подписчик, либо в бизнес слое после создания пользователя отправляется уведомление.
4) Неправильное управление исключениями.
Вот тут крайне спорная ситуация. Если же с Вашим бекэндом общаются только через TCP, то решение кинуть HttpException в методе SomeService имеет место быть, иначе управление исключениями явно должно быть иное.
SanoLitch
Пример про связанность высосан из пальца…я раньше тоже городил интерфейсы на все подряд и кроме как боли это ничего не приносило. В целом ничего плохого не вижу для сервисов и репозиториев использовать конкретный класс как источник контракта между слоями за небольшим исключением - обычно это какие-нибудь классы-сервисы для обращения к третьим системам, отсылалки оповещений/сообщений и тп; у них может быть несколько однотипных реализаций, которые уже и инжектятся в целевой бизнесовый класс-сервис.
Но я бы в целом не сказал, что пихание повсюду интерфейсов и создание бесконечного количества абстракций оказывает решающее влияние на связанность. Можно так завязать бизнес-логику на эти самые контракты, что будь то абстракция или реализация суть одна - каша (как обычно и бывает). Важнее грамотный дизайн модулей и четкое понимание как они должны взаимодействовать.
Avangardio Автор
Согласен, кашу можно наварить как угодно, но, в случаях использования контрактов можно получить ситуацию, когда человек, захотев прямо сразу менять контракт, может задуматься, что может отсеять плохие решения.
А так, использовать или нет контракты - зависит от команды, как привыкли, или из-за чего проблемы возникают.
Avangardio Автор
Плюс, если есть БА(прямо как йети) в команде - при некоторых случаях изучения работы кода, контракты помогут.
SanoLitch
Это навряд ли
А вообще мой посыл в том, что не надо лепить интерфейсы и абстракции просто потому, что в умных книжках так написано.
Абстракции должны изолировать потенциальные точки отказа приложения и обобщать взаимодействие с множеством реализаций. Кстати про умные книжки, давече читал "Эволюционная архитектура" и там как раз было про соотношение абстракций к реализациям - у авторов мнение (там есть какая-то формула для подсчета этого добра), что ок это примерно 1:3-1:4 соответственно, с чем я вообщем-то согласен.
А пилить интерфесы вроде IUserService и IUserRepository ну максимально бессмысленно - других реализаций для них (почти 100%) не будет, поэтому смысла скрывать реализацию нет; смене контракта интерфейс не помешает; это даже не абстракция, просто интерфейс ради интерфейса
Avangardio Автор
Интересно узнать про соотношение, спасибо за рекомендацию)