1. Что такое контроллеры и зачем они нужны
Давайте представим Express-приложение как ресторан.
Маршруты (routes/) — это официанты, которые принимают заказы (HTTP-запросы) и решают, на какую кухню (контроллер) отправить заказ.
Контроллеры (controllers/) — это повара, которые знают, как готовить блюда (обрабатывать бизнес-логику запроса).
Сервисы (services/) — это кладовая и рецепты: тут хранится логика работы с данными, база знаний, интеграция с внешними сервисами.
Если официантов (routes) заставить готовить еду (писать бизнес-логику прямо в маршрутах), ресторан быстро превратится в хаос. Поэтому мы выносим обработку "готовки" в отдельные контроллеры.
Контроллер — это функция или набор функций, которые принимают запрос, делают нужные вычисления, работают с сервисами/моделями и отправляют ответ клиенту.
Зачем выносить логику в контроллеры?
- Чистота и читаемость кода: маршруты становятся короткими и понятными.
- Повторное использование: одну и ту же бизнес-логику можно использовать из разных маршрутов.
- Тестируемость: контроллеры проще тестировать отдельно.
- Лёгкость поддержки: если поменялись бизнес-правила, меняем только контроллер.
2. Пример структуры проекта с контроллерами
Рассмотрим структуру простого ToDo-приложения:
project-root/
├── app.js
├── routes/
│ └── tasks.js
├── controllers/
│ └── tasksController.js
├── services/
│ └── tasksService.js
└── data/
└── tasks.json
- routes/tasks.js — определяет маршруты (GET /tasks, POST /tasks и т.д.) и вызывает соответствующие контроллеры.
- controllers/tasksController.js — содержит функции, которые реализуют бизнес-логику: например, получение списка задач, добавление новой задачи и т.д.
- services/tasksService.js — отдельный слой для работы с данными (например, с файлами или базой данных). Это не обязательно, но для крупных проектов — очень удобно.
3. Как выглядит контроллер на практике
Пример: контроллер для задач (tasksController.js)
Начнём с простого варианта, когда данные хранятся просто в памяти (или в файле JSON).
// controllers/tasksController.js
const tasks = require('../data/tasks.json'); // Для простоты — массив задач
// Получить все задачи
function getAllTasks(req, res) {
res.json(tasks);
}
// Получить задачу по id
function getTaskById(req, res) {
const id = Number(req.params.id);
const task = tasks.find(t => t.id === id);
if (!task) {
return res.status(404).json({ error: 'Задача не найдена' });
}
res.json(task);
}
// Добавить новую задачу
function createTask(req, res) {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: 'Поле title обязательно' });
}
const newTask = {
id: tasks.length ? tasks[tasks.length - 1].id + 1 : 1,
title,
completed: false
};
tasks.push(newTask);
res.status(201).json(newTask);
}
// Экспортируем функции контроллера
module.exports = {
getAllTasks,
getTaskById,
createTask
};
Комментарии:
— Каждый контроллер — обычная функция, принимающая (req, res).
— Контроллер отвечает только за "кухню": что делать с запросом, как обработать данные, какой ответ отправить.
— Все детали работы с данными можно выносить в сервисы (services/), чтобы контроллер был ещё чище.
Как подключить контроллеры к маршрутам
// routes/tasks.js
const express = require('express');
const router = express.Router();
const tasksController = require('../controllers/tasksController');
// GET /tasks — получить все задачи
router.get('/', tasksController.getAllTasks);
// GET /tasks/:id — получить задачу по id
router.get('/:id', tasksController.getTaskById);
// POST /tasks — создать новую задачу
router.post('/', tasksController.createTask);
module.exports = router;
В результате, файл маршрутов — это просто список правил: какой маршрут вызывает какую функцию.
4. Разделение ответственности: контроллеры vs сервисы
В маленьких проектах контроллер может сам работать с данными. Но если проект растёт, лучше добавить слой сервисов:
- Контроллер: получает запрос, валидирует данные, вызывает нужный метод сервиса, отправляет ответ.
- Сервис: вся логика работы с данными (поиск, фильтрация, сохранение, интеграция с внешними API).
Пример контроллера с сервисом:
// controllers/tasksController.js
const tasksService = require('../services/tasksService');
async function getAllTasks(req, res) {
const tasks = await tasksService.getAll();
res.json(tasks);
}
async function getTaskById(req, res) {
const id = Number(req.params.id);
const task = await tasksService.getById(id);
if (!task) {
return res.status(404).json({ error: 'Задача не найдена' });
}
res.json(task);
}
async function createTask(req, res) {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: 'Поле title обязательно' });
}
const newTask = await tasksService.create({ title });
res.status(201).json(newTask);
}
module.exports = {
getAllTasks,
getTaskById,
createTask
};
tasksService.js может выглядеть так:
// services/tasksService.js
const fs = require('fs').promises;
const path = require('path');
const filePath = path.join(__dirname, '../data/tasks.json');
// Чтение всех задач
async function getAll() {
const raw = await fs.readFile(filePath, 'utf-8');
return JSON.parse(raw);
}
// Поиск задачи по id
async function getById(id) {
const tasks = await getAll();
return tasks.find(t => t.id === id);
}
// Создание новой задачи
async function create({ title }) {
const tasks = await getAll();
const newTask = {
id: tasks.length ? tasks[tasks.length - 1].id + 1 : 1,
title,
completed: false
};
tasks.push(newTask);
await fs.writeFile(filePath, JSON.stringify(tasks, null, 2));
return newTask;
}
module.exports = {
getAll,
getById,
create
};
В результате:
— Контроллеры не зависят от того, где и как хранятся данные.
— Если вы решите перейти с файлов на базу данных, меняете только сервис, контроллеры не трогаете.
5. Пример: обработка ошибок и валидация в контроллере
Контроллеры — отличное место для базовой валидации входных данных и обработки ошибок.
async function createTask(req, res) {
try {
const { title } = req.body;
if (!title || typeof title !== 'string') {
// Проверяем, что поле title есть и это строка
return res.status(400).json({ error: 'Поле title обязательно и должно быть строкой' });
}
const newTask = await tasksService.create({ title });
res.status(201).json(newTask);
} catch (err) {
// Неожиданная ошибка — отправляем 500
res.status(500).json({ error: 'Ошибка сервера', details: err.message });
}
}
Зачем так делать?
— Если что-то пошло не так, клиент получит понятное сообщение.
— Валидация данных на уровне контроллера — это "первая линия обороны" от мусорных запросов.
6. Полезные нюансы
Как связать всё вместе в основном приложении
В файле app.js (или index.js) подключаем маршруты:
const express = require('express');
const app = express();
app.use(express.json()); // Для работы с JSON-телом запроса
const tasksRouter = require('./routes/tasks');
app.use('/tasks', tasksRouter);
app.listen(3000, () => {
console.log('Сервер запущен на http://localhost:3000');
});
Советы по организации контроллеров
- Один контроллер — одна сущность (например, tasksController для задач, usersController для пользователей).
- Если контроллер становится слишком большим — разбивайте на отдельные файлы или группы функций.
- Не пишите в контроллере SQL-запросы, работу с файлами или сторонними API — для этого есть сервисы.
- Если логика совсем простая (например, вернуть "Hello, world!") — можно обойтись без контроллера, но для реальных приложений всегда выделяйте этот слой.
7. Практика: расширяем приложение
Добавим ещё один контроллер — удаление задачи:
// controllers/tasksController.js
async function deleteTask(req, res) {
const id = Number(req.params.id);
const deleted = await tasksService.deleteById(id);
if (!deleted) {
return res.status(404).json({ error: 'Задача не найдена' });
}
res.json({ message: 'Задача удалена' });
}
module.exports = {
// ...другие контроллеры,
deleteTask
};
В сервисе:
// services/tasksService.js
async function deleteById(id) {
const tasks = await getAll();
const index = tasks.findIndex(t => t.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
await fs.writeFile(filePath, JSON.stringify(tasks, null, 2));
return true;
}
module.exports = {
// ...другие методы,
deleteById
};
В маршрутах:
// routes/tasks.js
router.delete('/:id', tasksController.deleteTask);
8. Типичные ошибки при работе с контроллерами
Ошибка №1: Вся логика пишется прямо в маршрутах.
В результате, файл маршрутов превращается в монстра на 300 строк, где смешаны проверки, работа с данными, отправка ответов. Такой код тяжело читать, тестировать и поддерживать.
Ошибка №2: Контроллер занимается всем подряд.
Если контроллер сам валидирует, сам лезет в базу, сам отправляет почту и ещё и считает скидку — это сигнал, что пора вынести части логики в сервисы или утилиты.
Ошибка №3: Нет обработки ошибок.
Если контроллер не ловит исключения, приложение может "упасть" при любой ошибке в сервисе, а клиент получит неинформативный ответ или вообще ничего.
Ошибка №4: Не проверяются входные данные.
Если не валидировать данные в контроллере, можно получить неожиданные ошибки в сервисе или даже "дырки" в безопасности.
Ошибка №5: Нарушение принципа единой ответственности.
Контроллер должен только "рулить" бизнес-логикой обработки запроса, а не заниматься всем подряд.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ