Express.js

Express.js — минималистичный веб-фреймворк для Node.js. Он упрощает маршрутизацию, обработку HTTP-запросов и ответов, подключение middleware и создание API.

Установка:

npm init -y
npm install express

Для ES Modules добавьте в package.json:

{
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}

Минимальный сервер:

import express from "express";

const app = express();
const PORT = process.env.PORT ?? 3000;

app.get("/", (req, res) => {
  res.send("Hello from Express");
});

app.listen(PORT, () => {
  console.log(`Server started on http://localhost:${PORT}`);
});

В CommonJS импорт выглядит так:

const express = require("express");

Роутинг в Express

Маршрут связывает HTTP-метод и URL с обработчиком.

app.METHOD(PATH, HANDLER);

Основные методы:

Метод Назначение
app.get() Получение данных
app.post() Создание ресурса
app.put() Полная замена ресурса
app.patch() Частичное изменение
app.delete() Удаление ресурса

CRUD-пример:

app.get("/users", getUsers);
app.get("/users/:id", getUserById);
app.post("/users", createUser);
app.patch("/users/:id", updateUser);
app.delete("/users/:id", deleteUser);

Параметры маршрута

Динамические части пути доступны через req.params:

app.get("/users/:id", (req, res) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id) || id <= 0) {
    return res.status(400).json({ error: "Некорректный id" });
  }

  res.json({ id });
});

Для запроса GET /users/42 значение req.params.id равно строке "42".

Query-параметры

Параметры после ? находятся в req.query:

GET /products?category=books&page=2
app.get("/products", (req, res) => {
  const { category, page = "1" } = req.query;
  res.json({ category, page: Number(page) });
});

express.Router()

Роутер объединяет связанные маршруты:

import { Router } from "express";

const router = Router();

router.get("/", getUsers);
router.get("/:id", getUserById);
router.post("/", createUser);

export default router;

Подключение:

import usersRouter from "./routes/users.routes.js";

app.use("/api/users", usersRouter);

Получатся адреса /api/users и /api/users/:id.

Несколько методов одного пути можно объединить:

router
  .route("/:id")
  .get(getUserById)
  .patch(updateUser)
  .delete(deleteUser);

Обработчик неизвестного маршрута размещается после существующих маршрутов:

app.use((req, res) => {
  res.status(404).json({ error: "Маршрут не найден" });
});

Middleware

Middleware — функция, выполняемая между получением запроса и отправкой ответа.

function middleware(req, res, next) {
  // Обработка запроса
  next();
}

Middleware может:

Встроенные middleware

JSON-тело запроса:

app.use(express.json({ limit: "100kb" }));

Данные HTML-формы:

app.use(express.urlencoded({ extended: true }));

Статические файлы:

app.use(express.static("public"));

Кастомное middleware

Логирование запросов:

function requestLogger(req, res, next) {
  const startedAt = Date.now();

  res.on("finish", () => {
    const duration = Date.now() - startedAt;
    console.log(
      `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`
    );
  });

  next();
}

app.use(requestLogger);

Проверка авторизации для отдельного маршрута:

function requireAuth(req, res, next) {
  if (!req.get("Authorization")) {
    return res.status(401).json({ error: "Требуется авторизация" });
  }

  next();
}

app.get("/profile", requireAuth, getProfile);

Порядок выполнения

Express обходит middleware и маршруты сверху вниз:

app.use(firstMiddleware);
app.use(express.json());
app.use("/api", apiRouter);
app.use(notFound);
app.use(errorHandler);

Если middleware не отправило ответ и не вызвало next(), запрос останется незавершённым.

Код после next() выполняется при обратном выходе из цепочки:

app.use((req, res, next) => {
  console.log("До next");
  next();
  console.log("После next");
});

Обычное middleware имеет три параметра. Middleware ошибок — четыре:

function errorHandler(error, req, res, next) {}

Объекты req и res

req представляет входящий запрос, res — формируемый ответ.

Основные свойства req

Свойство Значение
req.method HTTP-метод
req.params Параметры пути
req.query Query-параметры
req.body Разобранное тело
req.headers Заголовки
req.path Путь без query-строки
req.originalUrl Исходный URL
req.ip IP-адрес клиента

Получение заголовка:

const token = req.get("Authorization");

Проверка формата тела:

if (!req.is("application/json")) {
  return res.status(415).json({ error: "Ожидается JSON" });
}

Данные в params, query, body и заголовках поступают от клиента. Им нельзя доверять без проверки.

Основные методы res

res.send("Hello");
res.json({ status: "ok" });
res.status(201).json({ id: 1 });
res.sendStatus(204);
res.redirect("/login");
res.set("Cache-Control", "no-store");

Частые HTTP-статусы:

Статус Значение
200 Успешный запрос
201 Ресурс создан
204 Успешно, без тела
400 Некорректный запрос
401 Требуется аутентификация
403 Доступ запрещён
404 Ресурс не найден
409 Конфликт состояния
422 Ошибка валидации
500 Ошибка сервера

После отправки ответа функцию обычно завершают через return:

if (!user) {
  return res.status(404).json({ error: "Пользователь не найден" });
}

res.json(user);

Иначе можно случайно отправить ответ дважды и получить ошибку Cannot set headers after they are sent.


Централизованная обработка ошибок

Пользовательский класс ошибки:

export class AppError extends Error {
  constructor(statusCode, message, details) {
    super(message);
    this.name = "AppError";
    this.statusCode = statusCode;
    this.details = details;
  }
}

Использование:

throw new AppError(404, "Пользователь не найден");

Единый обработчик:

export function errorHandler(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  const statusCode = error.statusCode ?? 500;
  const isServerError = statusCode >= 500;

  if (isServerError) {
    console.error({
      message: error.message,
      stack: error.stack,
      method: req.method,
      url: req.originalUrl
    });
  }

  res.status(statusCode).json({
    error: isServerError
      ? "Внутренняя ошибка сервера"
      : error.message,
    ...(error.details && { details: error.details })
  });
}

Подключение выполняется в конце:

app.use("/api/users", usersRouter);
app.use(notFound);
app.use(errorHandler);

Для Express 4 асинхронные контроллеры часто оборачивают:

export function asyncHandler(handler) {
  return (req, res, next) => {
    Promise.resolve(handler(req, res, next)).catch(next);
  };
}
router.get(
  "/:id",
  asyncHandler(async (req, res) => {
    const user = await usersService.getById(req.params.id);
    res.json(user);
  })
);

В Express 5 отклонённые Promise из async-обработчиков автоматически передаются обработчику ошибок.

В production не следует возвращать клиенту стек, пути файловой системы, запросы к базе, ключи и внутренние сообщения сервисов.


Валидация входных данных

Проверять нужно req.params, req.query, req.body, важные заголовки и файлы.

Ручное middleware:

function validateCreateUser(req, res, next) {
  const { name, email, age } = req.body;
  const errors = [];

  if (typeof name !== "string" || name.trim().length < 2) {
    errors.push({ field: "name", message: "Некорректное имя" });
  }

  if (typeof email !== "string" || !email.includes("@")) {
    errors.push({ field: "email", message: "Некорректный email" });
  }

  if (age !== undefined && (!Number.isInteger(age) || age < 0)) {
    errors.push({ field: "age", message: "Некорректный возраст" });
  }

  if (errors.length) {
    return res.status(422).json({
      error: "Ошибка валидации",
      details: errors
    });
  }

  req.body = {
    name: name.trim(),
    email: email.trim().toLowerCase(),
    ...(age !== undefined && { age })
  };

  next();
}

router.post("/", validateCreateUser, createUser);

Нужно различать отсутствие поля и ложное значение:

if (req.body.count === undefined) {
  // Поле отсутствует
}

Проверка if (!req.body.count) также отклонит допустимый 0.

Для сложных структур используют схемы Zod, Joi или express-validator. Общий принцип:

const result = schema.safeParse(req.body);

if (!result.success) {
  return res.status(422).json({
    error: "Ошибка валидации",
    details: result.error.issues
  });
}

req.body = result.data;
next();

Различия терминов:

Не передавайте req.body напрямую в модель базы данных. Явно выбирайте разрешённые поля:

const { name, email } = req.body;
const user = await repository.create({ name, email });

Работа со статикой

Структура проекта:

project/
├── public/
│   ├── index.html
│   ├── styles.css
│   └── images/logo.svg
└── src/app.js

Подключение:

app.use(express.static("public"));

Файл public/styles.css будет доступен как /styles.css.

Надёжнее использовать абсолютный путь:

import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const publicDirectory = path.join(__dirname, "../public");

app.use(express.static(publicDirectory));

URL-префикс:

app.use("/static", express.static(publicDirectory));

Теперь файл доступен как /static/styles.css.

Настройки кэширования:

app.use(
  "/static",
  express.static(publicDirectory, {
    maxAge: "1d",
    etag: true,
    index: false
  })
);

Долгое кэширование лучше использовать для файлов с хешем в имени, например app.a4f81c.js.


Структурирование приложения

По мере роста приложения HTTP-слой и бизнес-логику разделяют:

src/
├── app.js
├── server.js
├── routes/
│   └── users.routes.js
├── controllers/
│   └── users.controller.js
├── services/
│   └── users.service.js
├── repositories/
│   └── users.repository.js
├── middleware/
│   ├── validate-user.js
│   ├── not-found.js
│   └── error-handler.js
└── errors/
    └── app-error.js
Слой Ответственность
Роут Метод, путь и цепочка middleware
Контроллер Чтение req, вызов сервиса, ответ через res
Сервис Бизнес-правила
Репозиторий Доступ к данным
Middleware Сквозная обработка запросов

Роут:

router.get("/", getUsers);
router.get("/:id", getUserById);
router.post("/", validateCreateUser, createUser);

Контроллер:

export async function createUser(req, res) {
  const user = await usersService.create(req.body);
  res.status(201).json({ data: user });
}

Сервис:

export async function create(input) {
  const existing = await usersRepository.findByEmail(input.email);

  if (existing) {
    throw new AppError(409, "Email уже используется");
  }

  return usersRepository.create(input);
}

Сервису не нужны req и res. Благодаря этому бизнес-логику проще тестировать и переиспользовать.

Полезно отделять приложение от запуска сервера.

app.js:

export const app = express();

app.use(express.json());
app.use("/api/users", usersRouter);
app.use(notFound);
app.use(errorHandler);

server.js:

import { app } from "./app.js";

const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => console.log(`Port: ${PORT}`));

Тесты смогут импортировать app, не открывая сетевой порт.


CORS в Express

CORS определяет, разрешено ли браузерному JavaScript одной origin читать ответ другой origin. Origin включает протокол, домен и порт.

Установка:

npm install cors

Подключение:

import cors from "cors";

app.use(cors({
  origin: "https://app.example.com"
}));

Несколько разрешённых источников:

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com"
]);

app.use(cors({
  origin(origin, callback) {
    if (!origin || allowedOrigins.has(origin)) {
      return callback(null, true);
    }

    callback(new Error("Origin is not allowed"));
  }
}));

Cookie и другие credentials:

app.use(cors({
  origin: "https://app.example.com",
  credentials: true,
  methods: ["GET", "POST", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["X-Request-Id"],
  maxAge: 600
}));

Клиент:

fetch("https://api.example.com/profile", {
  credentials: "include"
});

При credentials: true нельзя возвращать Access-Control-Allow-Origin: *: нужен конкретный origin.

Для некоторых запросов браузер сначала отправляет OPTIONS — preflight. При глобальном подключении middleware cors обычно обрабатывает его автоматически.

CORS действует в браузере и не заменяет аутентификацию, авторизацию, проверку данных, защиту cookie и ограничение частоты запросов.


Общий пример

import crypto from "node:crypto";
import express from "express";
import cors from "cors";

const app = express();
const users = [];

app.disable("x-powered-by");
app.use(cors({ origin: process.env.CLIENT_ORIGIN ?? "http://localhost:5173" }));
app.use(express.json({ limit: "100kb" }));

app.use((req, res, next) => {
  req.requestId = crypto.randomUUID();
  res.set("X-Request-Id", req.requestId);
  next();
});

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.get("/api/users", (req, res) => {
  res.json({ data: users });
});

app.post("/api/users", (req, res, next) => {
  try {
    const { name, email } = req.body;

    if (typeof name !== "string" || typeof email !== "string") {
      throw new AppError(422, "Некорректные данные");
    }

    const user = {
      id: users.length + 1,
      name: name.trim(),
      email: email.trim().toLowerCase()
    };

    users.push(user);
    res.status(201).json({ data: user });
  } catch (error) {
    next(error);
  }
});

app.use((req, res, next) => {
  next(new AppError(404, "Маршрут не найден"));
});

app.use((error, req, res, next) => {
  if (res.headersSent) return next(error);

  const statusCode = error.statusCode ?? 500;
  res.status(statusCode).json({
    error: statusCode >= 500 ? "Ошибка сервера" : error.message
  });
});

class AppError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.statusCode = statusCode;
  }
}

app.listen(process.env.PORT ?? 3000);

Типичный порядок middleware

app.disable("x-powered-by");
app.use(cors(corsOptions));
app.use(express.json({ limit: "100kb" }));
app.use(express.urlencoded({ extended: true }));
app.use(requestId);
app.use(requestLogger);
app.use("/static", express.static(publicDirectory));
app.use("/api/users", usersRouter);
app.use(notFound);
app.use(errorHandler);

Маршруты должны находиться до notFound, а обработчик ошибок — после обычных middleware и маршрутов.


Частые ошибки

Парсер JSON подключён после маршрута

// Неправильно
app.post("/users", createUser);
app.use(express.json());
// Правильно
app.use(express.json());
app.post("/users", createUser);

Middleware не продолжает и не завершает запрос

function brokenMiddleware(req, res, next) {
  console.log(req.method);
  // Нет next() и нет ответа
}

Ответ отправляется дважды

После раннего ответа используйте return.

Error middleware имеет три параметра

Express распознаёт обработчик ошибок по четырём параметрам:

function errorHandler(error, req, res, next) {}

Прямое сохранение req.body

Клиент может передать лишние свойства. Валидируйте данные и создавайте новый объект только из разрешённых полей.

Слишком широкий CORS

Не используйте открытый origin без необходимости. При credentials указывайте конкретные разрешённые origin.

Бизнес-логика находится в роуте

Роуты должны описывать HTTP-маршрутизацию, контроллеры — HTTP-взаимодействие, сервисы — бизнес-правила.


Краткие рекомендации