Рефакторинг додатку за MVC архітектурою
У backend розробці прийнято не писати весь код в одному файлі, а розділяти його на блоки в залежності від функціонала. Реалізують це у вигляді MVC-архітектури (модель-вид-контроллер). Тобто розділяють бізнес-логіку і презентацію.
Модель - це блоки роботи з базами даних і різні серверні обробки
Вид - блоки, які комунікують із зовнішнім світом. (повернення json, route, html тощо). Тобто те, що віддаємо назовні.
Контролер - забезпечує взаємодію моделі та виду, які не комунікують напряму між собою.
Проведемо рефакторинг за визначеною архітектурою код розглянутий раніше.
Створимо проєкт такої структури:
project
├── index.js
├── package.json
├── usersdb.json
├── enviroments
│ ├── development.env
│ └── production.env
├── models
├── controllers
│ └──userController.js
├── middlewares
│ └──userMiddlewares.js
├──routes
│ └──userRoutes.js
├──views
├──utils
│ ├──appError.js
│ ├──catchAsync.js
│ └──index.js
└──servises
index.js - головний файл роботи сервера.
package.json - файл конфігурації проєкту.
usersdb.json - файл, який тимчасово поки виконує роль бази даних.
enviroments - каталог в якому зберігаються файли зі змінними оточення.
models - моделі даних для збереження в базу даних
controllers - каталог, який містить контролери обробки запитів.
middlewares - каталог, який містить проміжне пз.
routes - каталог, який містить роути запитів.
views -
utils - каталог із допоміжними функціями (ваділатори, обробники тощо).
servises - каталог зі стороннім функціоналом (бази даних, функції-конфігуратори, розсилки тощо.
Винесення функцій-обробників (Controllers)
З головного файлу index.js винесемо окремо в userController.js обробники запитів. розглянемо на прикладі створення користувача.
Переносимо в цей файл використовувані бібліотеки
const uuid = require("uuid").v4;
const fs = require("fs").promises;
А також саму функцію контроллера (назвемо її, наприклад createUser):
exports.createUser = async (req, res) => {
try {
const { name, year, email } = req.body;
// тут має бути валідація даних, які прийшли
//створюємо обʼєкт нового користувача, генеруємо для нього унікальний id
const newUser = { name, year, email, id: uuid() };
// зберігаємо користувача в базу даних (usersdb.json)
const usersDB = await fs.readFile("./usersdb.json");
const users = JSON.parse(usersDB);
users.push(newUser);
await fs.writeFile("./usersdb.json", JSON.stringify(users));
//надсилаємо відповідь на фронтенд
res.status(201).json({
msg: "User created",
user: newUser,
});
} catch (err) {
console.log(err);
res.sendStatus(500);
}
};
В index.js імпортуємо контролери й підключаємо до роуту
const userController = require("./controllers/userController");
// some code
app.post("/users", userController.createUser);
За аналогією виносимо всі інші контролери також у файл userController.js. Назви їм дамо, наприклад такі: getAllUsers, getOneUser, updateUser, deleteUser.
Нижче розгорни, щоб побачити весь код цього файлу.
А в нашому index.js лишаються гарні лаконічні роути:
app.post("/users", userController.createUser);
app.get("/users", userController.getAllUsers);
app.get("/users/:userId", userController.getOneUser);
app.patch("/users/:userId", userController.updateUser);
app.delete("/users/:userId", userController.deleteUser);
А також після них пропишемо обробку запиту за неіснуючим роутом і виникнення непередбачуваної помилки сервера.
Роутери (Router)
Як ми побачили вище, в index.js лишилися обробники роутів користувача. Але сервер може обробляти запити не тільки користувача, але й інші запити й згодом їх перелік стане довгим. Видно, що спільним для них є базовий запит users. У express передбачена можливість винесення однотипних роутів завдяки Router. Підключаємо і створюємо екземпляр роута:
const { Router } = require("express");
//some code
const router = Router();
Переносимо імпорт контроллерів з index.js до userRoutes.js
const userController = require("../controllers/userController");
Переносимо всі роути з index.js до userRoutes.js і замість app вказуємо router. Крім того прибираємо базовий урл users, бо його ми вкажемо в index.js. І експортуємо наш роутер:
router.post("/", userController.createUser);
router.get("/", userController.getAllUsers);
router.get("/:userId", userController.getOneUser);
router.patch("/:userId", userController.updateUser);
router.delete("/:userId", userController.deleteUser);
module.exports = router;
Після цього нам потрібно в index.js заімпортувати роути повʼязані з користувачами і вказати який роут оброблятиме запити за users (синтаксис як у middleware):
const userRoutes = require("./routes/userRoutes");
//some code
app.use("/users", userRoutes);
ПОКРАЩЕННЯ КОДУ: У файлі роутів можна деструктуризувати наші контроллери і відтак упростити код для читання.
const {
createUser,
getAllUsers,
getOneUser,
updateUser,
deleteUser,
} = require("../controllers/userController");
router.post("/", createUser);
router.get("/", getAllUsers);
router.get("/:userId", getOneUser);
router.patch("/:userId", updateUser);
router.delete("/:userId", deleteUser);
Альтернативний синтаксис роутів через метод route:
router
.route("/")
.post(createUser)
.get(getAllUsers);
router.route("/:userId")
.get(getOneUser)
.patch(updateUser)
.delete(deleteUser);
Винесення проміжних функцій (Middlewares)
Проміжні функції (middleware) також доречно виносити в окремі файли. Наприклад ми стоврювали в index.js кастомний middleware отримання обʼєкту користувача. Давайте його винесемо в окремий файл userMiddlewares.js. Порядок такий само як і при роботі з контроллерами. Підключаємо у файлі необхідні модулі і створюємо функцію та експортуємо її.
const fs = require("fs").promises;
exports.checkUserId = async (req, res, next) => {
try {
const { userId } = req.params;
const users = JSON.parse(await fs.readFile("./usersdb.json"));
const user = users.find((item) => item.id === userId);
if (!user) {
return res.status(404).json({
msg: "User does not exist",
});
}
req.user = user;
next();
} catch (err) {
console.log(err);
res.sendStatus(500);
}
};
Утім підключати цю функцію ми будемо не в index.js, а в userRoutes.js, адже саме на цьому маршруті використовується ця проміжна функція.
const { checkUserId } = require("../middlewares/userMiddlewares");
У роуті ми перераховуємо по порядку всі middleware, а останнім вказуємо контроллер обробки запиту. Примітно, що цю проміжну функцію треба використовувати тільки там де в маршруті присутній id:
router.post("/", createUser);
router.get("/", getAllUsers);
router.get("/:userId", checkUserId, getOneUser);
router.patch("/:userId", checkUserId, updateUser);
router.delete("/:userId", checkUserId, deleteUser);
Або можна прописати цей middleware перед тим як будуть опрацьовані роути, де він використовується.
router
.route("/")
.post(createUser)
.get(getAllUsers);
router.use("/:userId", checkUserId);
router
.route("/:userId")
.get(getOneUser)
.patch(updateUser)
.delete(deleteUser);
Кастомний обробник помилок
Можна написати клас специфічної обробки помилок.
Для цього створимо на базі класу Error наш клас AppError. Він прийматиме статус помилки і повідомлення:
class AppError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
module.exports = AppError;
Для полегшення підключення цих модулів створимо в папці utils файл index.js, який буде хабом для модулів в цій папці:
const AppError = require('./appError');
module.exports = {
AppError,
};
Для наочності використання цього класу застосуємо його в нашому кастомному middleware. Підключаємо:
const { AppError } = require("../utils");
Пропишемо всередині якусь перевірку. наприклад на довдину id, щоб вона була не короштою за 10 символів, інакше згенеруємо помилку:
if (userId.length < 10) {
throw new AppError(400, "Invalid Id...");
}
В такому разі помилку буде опрацьовувати глобальний хендлер помилки. Для цього потрібно просто замість повернення статусу помилки в секції case(err) {} передати естафету функції next і як параметр передати їй помилку.
catch (err) {
console.log(err);
next(err);
// замість цього: res.sendStatus(500);
}
Тоді в обробнику помилки в index.js потрібно прописати повернення нового коду помилки і повідомлення, або за замовчанням статус 500.
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
msg: err.message,
});
});
Перевіримо відпрацювання цієї помилки:

Рефакторинг повторюваних паттернів try{}catch(){}
Як бачимо у наших проміжних обробниках і контроллерах постійно повторюється конструкція try{}catch(){}. Підхід Don't repeate yourself каже, що цей код треба покращити.
Напишемо в utils в файлі catchAsync.js функцію-обгортку, яка прийматиме нашу функцію і повертатиме таку саму функцію, а у разі виникнення помилки передаватиме на обробник помилок нашу еррорку.
module.exports = (fn) => (req, res, next) => {
fn(req, res, next).catch((err) => next(err));
};
Експортуємо цю функцію через хаб index.js
const AppError = require("./appError");
const catchAsync = require("./catchAsync");
module.exports = {
AppError,
catchAsync,
};
Тепер випробуємо цей код на прикладі нашого кастомного middleware.
Імпортуємо:
const { AppError, catchAsync } = require("../utils");
Тепер ми огортаємо функцію в catchAsync і прибираємо try{}catch(){}
const { AppError, catchAsync } = require("../utils");
exports.checkUserId = catchAsync(async (req, res, next) => {
const { userId } = req.params;
if (userId.length < 10) {
throw new AppError(400, "Invalid Id...");
}
const users = JSON.parse(await fs.readFile("./usersdb.json"));
const user = users.find((item) => item.id === userId);
if (!user) {
return res.status(404).json({
msg: "User does not exist",
});
}
req.user = user;
next();
});
так само можна переписати всі асинхронні функції в контролерах.
Покликання:
Last updated