Проектаванне API на Node.js з шарамі: ад занадто большых контралероў да чыстай архітектуры
Выучыце, як перафактараваць API на Node.js у шары кантролера, сэрвіса і адгурування дадзейнаў, каб усунуць заплутаную бізнес-логіку, нэўнацоўныя памылкі і проблемы з масштабаваннем.
Крыніцы: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Практычныя наставы пра правильнай структурацыі, масштабавання і адтрымкі backend-часткі на Node.js, апранутыя на прыкладзе стварэння API для практычнага выкарыстання.
Калі вы пачынаеце пісаць API на Node.js, робота з backendам можа здавацца прыгожа простай.
Задаеце маршрут.
Праўяее яго да базы дадзеных.
Адправляеце назад JSON-даанні.
І ўсё.
І які-ткі час такы падход справды працюе.
Потым аплікацыя пачынае растаць.
Раптам вы сталкватэцеся з:
- Бізнес-правіламі, якія ўжоўвешчаны безпосередна ў кантролерах
- Званнемі да базы дадзеных, распаўшыміся па кожным файлу маршрута
- Павтараючыся логікай верыфікацыі
- Логікай адрабаткі памылак, якая разніцяцца з адного канцэнтраўкі да іншага
- Занадта вялікімі файламі
- Кодам, які стварае труднасці для автаматызаванага тэставання
- Логікай аутентыкацыі, заплутанай з логікай домэны
- Маленькімі зменамі, якія ламаюць функцыі, якія здаваліся некаляванымі
З тэхнічной точкі зору API не зламваецца.
Адбываецца штось болей тонкае — ўсё йдзе не так:
Внесенне змян у яго стала справжня проблема.
Самэй тады вы розумеете, што існуе рэальная разліка межа API, якое проста працюе, і тым, якое можа вытрымаць сталягу роботу пад умовамі рэальнага развіцца.
1. Мая пачатковая архітектура Node.js
Тыпова API на пачатковай стадіўцы можа выглядаць так:
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
Ніякі з гэтых кодаў сам по сабе не ёсць некоректным.
Для швырага пратотыпу гэта абсалютна разумны пункт выйшага.
Проблемы пачынаюцца, калі проект начынае расшырвацца.
Уявіце сабе нараджэнне разных функцыяй, такіх як:
- Аутентыкацыя
- Автарызацыя
- Перакананне вхідных дадзеных
- Перакананне адресы электронной пошты
- Обработка платэжаў
- Керуванне купонамі
- Управлінне замовленнямі
- Следжыранне запасаў
Тепер уявіце, што вы прымушваеце всі этыя аспекты працаваць безпосередна ў вашых обробніках маршрутаў.
У такім случае у вас больш няма справжней архітектуры.
У вас є маса коду, якая проста выконваецца.
2. Проблема была не ў Node.js
Это памылка, якую лёгка здзейсніць на пачатку.
Інстынкт вельмі часта выводзіць нас да такага мыслення:
"Мая база коду становіцца хаотычной проста таму, што дапрацоўка становіцца большай."
Это толькі частка працэсы.
Асалодныя прычыны былі ў недастатку чыстай раздзеленасці абавясцей.
Одні выканавачы карацілі сабой занадта многа задач.
Ёны былі адпаведальныя за:
- Аналіз запыту, які надходзіць
- Перакананне правамоўнасці вхідных дадзеных
- Перакананне аутентыкаціі
- Выконанне бізнес-логіки
Это шасць чы раўна сем разных задач, якія знаходзяцца ў аднам месцы.
Калі які-небудзь патрабаванне змяняецца, даводзіцца зноў і зноў рэдагаваць той самы файл.
Пастаўленна.
3. Архітектура, яку я выкарыстоўваю зараз
Болей чыстая стратэгія — раздзеліць прыкладненне на адныя слоі.
Напрыклад:
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
Основны прынцып прост:
Кожны слой павінен мець адную чыстую адпаведальнасць.
4. Кантролеры не павінны мець логіку бізнесу
Задача кантролера павінна быць абмежаная толькі HTTP-спрабамі.
Штось на кшталт гэтага:
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
Кантролеру не трэба ведаць:
- Дзе чы як зберагаецца запис пользователя
- Якая тэхналогія базайнам дадзенняў выкарыстоўваецца
Нічога з гэтага не ў яго абавесці.
Яго едыная адпаведнасць — обрабоцваць прыходзячыя запиты і сформаваць выходныя адпаведзі.
5. Логіку бізнесу трэба разместіць у службах
Слой служб — гэта там, дзе знаходзіцца рэальная логіка прыемленае.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
Гэта дазволяе логіку викорыстоўваць знову.
Іншы часткі системы, напрыклад, могу проста вызваць:
userService.createUser(data);
без неабходнасці імітаваць HTTP-запит ужо.
Гэтая можлівасць повторнага викорыстоўвання мае вялікое значэнне як для тэставання, так і для дагоўнае падтрымкі.
6. Даступ да базы дадзеных трэба выдзеліць у окремы слой
Логіку бізнесу не трэба заплутваць сырымі SQL-запитамі чы вызывамі ORM, распаўсюджанымі па всій системе.
Самэ гэтае прыбліжна рашаюць репазітарыі.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
Калі гэта ўжо реалізавана, раздзеленне абавяска стае явным.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Кожны слой знае толькі тое, што яму абсалютна неабходна знать, і нічога больш.
7. Чаму DTO-ы маюць большое значэнне, чым можна было б спадзявацца
Паўзучая пачатковая памылка — перадача необработаных тэла запитоў безпосередна ў аплікацыю.
Што-та такое:
const user = await userService.createUser(req.body);
Гэта рызыкавана.
Што на самай працо знаходзіцца ў req.body?
Можнаце вы быць вялікімі ў паведамленні?
DTO стварае чыстае, адзначанае кантракты для таго, што ўжо очаквана.
class CreateUserDto {
name: string;
email: string;
password: string;
}
Пасля гэтага прыходзячыя данні пераверачаюцца, перш чым іх прабывае дазволіць нават падыходзіць да логіки аплікацыі.
Напрыклад, пэўны пакет дадзеных:
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
можна пераканацца па правілах такім, як:
- Обавяжлівыя полья
- Фармат электроннай пашты
- Дужнае значэнне пароля
- Тыпы дадзеных
Основны прынцып тут просты:
Ніколі не давайце веры даннэм, якія прыходзяць з кліента.
Фронтэнд не выступае як межа безпекі.
8. Адносць у обробцы адзінакоў мае значэнне
Аднойчы проблемай у API на ранніх стадіях є нэадносныя форматы адпаведзей.
Одна з маршрутаў можа адправіць:
{
"error": "User not found"
}
Іншы адпавядае:
{
"message": "Not found"
}
А ўсё іншае выглядае так:
{
"success": false,
"data": null
}
Для тых, хто стварае фронтэнд для работы з гэтым API, такая нэадносць є вельмі канфузуючай.
Вжыце адной цэнтрызаванай методыкі обробкі адзінакоў значна спрыяе лёгкай работе з API.
Напрыклад:
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
Конкрэтны выбраны формат не мае вельмі большага значэння.
Галоўнае — ён завжды аднаковы.
9. Аутантыкацыя — гэта не автарызацыя
Эта разліка часта прычына памылак у багатьох разрабоў на самым пачатку.
Аутантыка адпавядае на адна запитанне:
Кім вы ёсце?
Автарызацыя адпавядае на іншае запитанне:
Што вам дазволена робіць?
З пункту зору алгорытму, гэта выглядае працоўна так:
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
Тое, што корыстнік аутантыфікован, не значыць, што ёму дазволена выконваць будзь-якія дзеяння, якія ён хоча.
Самэ гэтая прычына выклікае патрэбу, каб перакантрольванне ролей і праваў было аддзельна ад базовай логікі заходжання і аутантыкацыі.
10. У канечнай лініі трэба думаць пра масштабаванне
Калі код стае досыць адорганізаваным, з’яўляецца нова запитанне:
Што будзе, калі обсяг трафіку зростае?
Самэ тут такія раштункі, як Redis, чергі задач і індэксы базы дадзеных, пачынаюць граціяваць сваё значэнне.
Уявіце канцэнтр пункт, які з кожным запитам выканае дорогі запыт да базы дадзеных.
Замест таго:
Request
↓
Database
↓
Response
сістэма можа з часам перайсці да чаго-то на кшталт:
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
А для всіх тых рэчаў, якія не павінны блакаваць адпаведзенне:
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
Это не дазволяе спакойным, некритычным задачам запаздваліць рэальны запыт.
Але ёсць застерэжэнне, якое варта падтвердзіць:
Не вярбуйтесь у Redis, кеі, Kafka, мікросервісы чы рэшытакі Kubernetes толькі таму, што яны выглядаюць вражаюча.
Архітэктура існуе для рашэння рэальных проблем.
Яна не павінна ствараць новыя.
11. Найважлівейшы урок
Рэальная змяна манталітэту сводзіцца да гэтага:
Прыпыніце оптымізаваць тое, насколькі быстро пішыцца код.
Уместа таго оптымізавайце тое, насколькі безпечна яго можна будзе зменіць пазней.
Калі пачатваеце першы проект, здаецца, што кашто ўтрымліваць у аднам файле — гэта эфектыўна.
І спачатку гэта справды так.
Але напісанне пачатковай версіі — толькі незначная частка абоўшчай роботы.
Большая частка таго, што выканаецца з кодавой базай, адбываецца пасля таго.
З часам проекту будзе патрэбна:
- Дадаць новыя функцыі
- Вылечыць багі
- Адлацца да змянюючыхся трэбаванняў
- Заменіць бібліятэкі
- Апдэйтаваць схемы базы дадзеных
- Даўжыць автаналіз
- Уведаць системы прав
- Праспеўніць медленныя запыты
- Праўяжыцца да сервісаў трэціх сторон
Якщо архітектура ператварае кожны з гэтых пунктаў у проблему, то раннія швальнасць не была справжнім прынтам.
12. Кращая пачаткова точка
Свежы backend на Node.js сёння могаць быць аргументабна практыкуемы прыблізна так:
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
Для большых прыграмаў арганізаванне коду па модулях аб функцыях зазвычай ўсё лепша, чым складванне всього ў вялікія спальнія папкі, заполненыя некалязанымі кантролерамі і службамі.
Точныя правілы структуры папак не ўсё такі важны.
Важна тое, каб адпаведальнасць кожнага элемента заставалася ясной.
13. Сэм правіла, якія варта дапэўніваць
У сукупнасці гэта сводзіцца да сэм правіла:
1. Храніце кантролеры простымі
Кантролеры існуюць для обработкі HTTP, нічога больш.
2. Размешчайце бізнес-логіку ў службах
Службы — гэта там, дзе павінна знаходзіцца фактычная працэздатнась прыграмы.
3. Ізолюйце операцыі з базай дадзеных
Утримвайцеся ад таго, каб запыты да базай дадзеных прасачваліся ў кожны канц кодавой базы.
4. Пераканаўце зовнішні вхідныя даны
Не прыпускайце, што req.body містіць тое, чаго чакаюць.
5. Стандартызуйце выключэння
Зробіце адміністрацыю рэакцый на нявезенасці прадбачальной і аднаковай.
6. Дадзіце інфраструктуру толькі калі ёй трэба
Redis, очакванні і мікросервісы — это інструменты, якія викорыстоўваюцца тады, калі ёсць парадокс, а не пазнакі вышэйшага рангу.
7. Проектавацыя з урахованнем змян
Хто бы ні адпрацоўваў гэты код, ён будзе яго мяняць набагато чащэ, чым пісаў занова.
Вывык
Запуск API на Node.js не ёсць складным.
Зберагчы яго зрозумелым працаваючы над функцыямі месяцамі — гэта значна большая задача.
Лёгка асоціяваць хорашую розробку бэкенду з ведамасцю, як під’ўязаць:
GET
POST
PUT
DELETE
Гэта насправды толькі пачаткова точка.
Праўяя майстэрнасць крыўціцца ў ведамасці таго, дзе павінна знаходзіцца кожная частка логіки, як разныя часткі системы взаімаюцца межу сабою і як ўдосконаляць яе, не ламаючы при гэму ўсё іншае.
Не кожны проект патрабуе складнай архітэктуры.
Яго патрабуе архітэктура, яка ўзмоўнае паследуе за сложнасцю рэальнай проблемы.
Таму што мета ніколі не была:
«Зробіць так, каб усё працавало.»
Рэальная мета —:
«Зробіць так, каб было лёгка адаптавацца, калі трэбованні нехта змянююцца.»