jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 03 — Express 5 và Request Pipeline

Thiết kế Express 5 như một request pipeline có thứ tự, validation boundary, error contract, proxy trust và graceful shutdown kiểm chứng được.

Một endpoint có thể validate đúng, authenticate đúng và vẫn rò dữ liệu nếu middleware authorization được đăng ký sau handler. Trong Express, kiến trúc không bắt đầu từ folder; nó bắt đầu từ thứ tự request đi qua pipeline và contract của từng middleware.

Bài này dùng Node.js 24 LTS, TypeScript và Express 5. Sau khi đọc, bạn có thể:

  • lần theo request qua app middleware, router, handler và error middleware;
  • đặt validation, authentication, authorization và rate limit đúng boundary;
  • tách HTTP translation khỏi business logic mà không tạo abstraction hình thức;
  • chuẩn hóa error response cho frontend và giữ nguyên cause cho observability;
  • cấu hình proxy trust, request id, body limit và graceful shutdown theo topology thật.

Kiến thức cần có: HTTP semantics, stream, timeout và error contract ở Phần 2. Express giảm boilerplate nhưng không thay các invariant đó.


Nền tảng Express

npm install express@5
npm install -D @types/express
import express from 'express';

const app = express();

app.use(express.json()); // parse JSON bodies → req.body (replaces Phase 2's readJson)

app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });
});

app.listen(3000, () => console.log('http://localhost:3000'));

So với Phase 2: không createServer, không parse URL, không tự set status/header. Express là tầng mỏng trên http server: một router, một pipeline middleware, và helper tiện dụng. Bên dưới app vẫn là callback (req, res) bạn có thể truyền cho http.createServer(app) — hữu ích khi cần server thô (cho WebSocket hay tắt êm).

Vòng đời request

incoming request

app-level middleware (helmet, cors, json, logger)   ── runs in order ──┐
   ▼                                                                    │
router match → route-level middleware → route handler                  │ any of these can
   ▼                                                                    │ end the response
res.json() / res.send() ── response sent ◄──────────────────────────── ┘
   ▼ (if next(err) called anywhere)
error-handling middleware (4 args), registered LAST

Mental model quan trọng: middleware là một chuỗi có thứ tự, nhưng mỗi phần tử có thể kết thúc response, chuyển tiếp hoặc chuyển sang error pipeline. Bug phổ biến xuất hiện khi contract đó bị phá: middleware đứng sai vị trí, không gọi next(), gọi next() sau khi đã gửi response, hoặc giữ công việc async ngoài Promise mà Express không quan sát được.


Đào sâu middleware

Một middleware là (req, res, next) => … — đúng mẫu compose/next bạn dựng ở Phase 2, giờ là hạng nhất.

app.use(express.json()); // JSON bodies
app.use(express.urlencoded({ extended: true })); // HTML form posts
app.use(express.static('public')); // serve files from ./public

// Custom middleware — runs on every request, in registration order
app.use((req, res, next) => {
  const start = performance.now();
  res.on('finish', () => {
    console.log(
      `${req.method} ${req.path} ${res.statusCode} ${(performance.now() - start).toFixed(1)}ms`
    );
  });
  res.on('close', () => {
    if (!res.writableFinished)
      console.warn(`${req.method} ${req.path} client disconnected`);
  });
  next(); // forget this → the request hangs forever
});

Năm điều cần thấm:

  • Thứ tự quan trọng — chạy trên xuống; đăng ký body parser và auth trước route cần chúng.
  • Phạm vi — toàn cục / theo tiền tố path / theo route.
  • Cắt mạch — có thể kết thúc request mà không gọi next().
  • next('route') bỏ phần còn lại của stack route; next(err) nhảy thẳng tới error handler.
  • Gắn dữ liệu suy ra vào req, không tạo global mới — nó theo request và chết cùng request.

Khi nhiều middleware cùng cần request context, mở rộng type một lần thay vì cast rải rác. Chỉ đặt dữ liệu đã được server xác minh; không sao chép nguyên header client vào req.user hay requestId.

// types/express.d.ts
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
      requestId?: string;
    }
  }
}
export {};

Mẫu routing

app.get('/users', listUsers); // collection
app.post('/users', createUser);
app.get('/users/:id', getUser); // req.params.id
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);

// Chain handlers for one path with app.route to avoid repeating it
app.route('/posts').get(listPosts).post(createPost);

req.params, req.queryreq.body đến từ ba nguồn khác nhau nhưng đều là input không tin cậy. Giá trị query có thể là chuỗi, mảng hoặc object tùy parser; schema phải parse và validate trước khi service sử dụng.

Router — tổ chức app đang lớn

express.Router() là một mini-app: gom route liên quan và middleware riêng của chúng vào module:

// routes/users.ts
import { Router } from 'express';
const router = Router();

router.use(requireAuth); // applies to every route in THIS router only
router.get('/', listUsers);
router.post('/', createUser);
router.get('/:id', getUser);

export default router;
// app.ts
import usersRouter from './routes/users.js';
app.use('/api/users', usersRouter); // every route is prefixed with /api/users

Thay đổi routing Express 5: đoạn tùy chọn dùng {}, wildcard phải có tên, chuỗi regex nội tuyến bị bỏ. Nếu route copy từ tutorial cũ chạy sai, đây là lý do.


Kiến trúc phân tầng — giữ HTTP ở biên

Một hình dạng hữu ích là route → controller → use case/service → repository. Controller dịch HTTP sang input của use case; service giữ policy nghiệp vụ; repository che chi tiết truy cập dữ liệu khi sự trừu tượng đó thực sự có giá trị.

// controller — thin: parse input, call service, shape the HTTP response
export const getUser: RequestHandler = async (req, res) => {
  const user = await userService.getById(req.params.id); // throws NotFoundError if missing
  res.json(user);
};

// service — business rules, framework-agnostic (no req/res in here)
export const userService = {
  async getById(id: string) {
    const user = await userRepo.findById(id);
    if (!user) throw new NotFoundError('User not found');
    return user;
  },
};

Service test được không cần HTTP và có thể được gọi từ CLI, queue worker hoặc transport khác. Tuy nhiên không cần tạo một class cho mỗi hàm CRUD: thêm layer khi nó tạo boundary, policy hoặc test seam rõ ràng; abstraction không có trách nhiệm chỉ làm tăng navigation cost.


Chiến lược xử lý lỗi và API contract

Tập trung lỗi dự kiến bằng type có machine-readable code; giữ lỗi bất ngờ cùng cause để log, nhưng không phơi implementation detail cho client:

export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly code: string,
    options?: ErrorOptions
  ) {
    super(message, options);
    this.name = this.constructor.name;
    Error.captureStackTrace?.(this, this.constructor); // clean stack, omits this ctor
  }
}
export class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404, 'RESOURCE_NOT_FOUND');
  }
}
export class ValidationError extends AppError {
  constructor(message = 'Invalid input', options?: ErrorOptions) {
    super(message, 422, 'VALIDATION_ERROR', options);
  }
}
export class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'AUTHENTICATION_REQUIRED');
  }
}

Error handler được nhận diện qua bốn tham số và đăng ký cuối cùng:

import type { Request, Response, NextFunction } from 'express';

// 404 for anything no route matched — registered AFTER all routes, BEFORE the error handler
app.use((req: Request, _res: Response, next: NextFunction) => {
  next(new NotFoundError(`Cannot ${req.method} ${req.path}`));
});

app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  // Header/body đã bắt đầu (ví dụ stream lỗi giữa chừng): giao cho default handler đóng connection.
  if (res.headersSent) return next(err);

  const isApp = err instanceof AppError;
  const status = isApp ? err.statusCode : 500;
  if (!isApp || status >= 500) console.error({ requestId: req.requestId, err });

  res
    .status(status)
    .type('application/problem+json')
    .json({
      type: `https://api.example.com/problems/${isApp ? isApp.code.toLowerCase() : 'internal-error'}`,
      title: isApp ? isApp.message : 'Internal Server Error',
      status,
      code: isApp ? isApp.code : 'INTERNAL_ERROR',
      requestId: req.requestId,
    });
});

Lỗi dự kiến cần status/code ổn định; lỗi bất ngờ cần log object lỗi thật, gồm stack và cause, rồi map thành 500 chung. Nếu response streaming đã gửi header, không thể đổi sang JSON error nữa; chuyển tiếp để Express đóng connection và ghi nhận failure.


Xử lý lỗi async

Express 5 tự chuyển rejected Promise từ handler/middleware sang next(error):

// Express 5: just throw or let it reject — the framework forwards it.
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User not found');
  res.json(user);
});

Vì vậy project thuần Express 5 không cần catchAsync cho handler trả Promise. Helper dưới chỉ hữu ích trong giai đoạn cùng codebase còn chạy Express 4; giữ nó sau migration tạo thêm một abstraction không cần thiết:

import type { Request, Response, NextFunction, RequestHandler } from 'express';

const catchAsync =
  (
    fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
  ): RequestHandler =>
  (req, res, next) => {
    fn(req, res, next).catch(next);
  };

Validation

Validation là boundary giữa representation không tin cậy và input đã có type của use case. Hai cách phổ biến:

Validator dạng chuỗi phù hợp với rule ngắn ngay trên route:

import { body, validationResult } from 'express-validator';

app.post(
  '/register',
  body('email').isEmail().normalizeEmail(),
  body('password')
    .isLength({ min: 8 })
    .matches(/^(?=.*[A-Za-z])(?=.*\d)/),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty())
      return res.status(422).json({ errors: errors.array() });
    next();
  },
  registerHandler
);

Schema-first phù hợp khi cùng schema phục vụ runtime validation, type inference và API documentation. Thay vì mutate req.body rồi tuyên bố nó đã có type, wrapper dưới chuyển giá trị đã parse trực tiếp vào handler:

import { z } from 'zod';
import type { Request, RequestHandler, Response } from 'express';

const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.coerce.number().int().positive().optional(), // coerce query strings → number
});

type ValidatedHandler<T> = (
  input: T,
  req: Request,
  res: Response
) => void | Promise<void>;

function withBody<S extends z.ZodTypeAny>(
  schema: S,
  handler: ValidatedHandler<z.infer<S>>
): RequestHandler {
  return async (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return next(
        new ValidationError('Request body is invalid', { cause: result.error })
      );
    }
    await handler(result.data, req, res);
  };
}

app.post(
  '/register',
  withBody(RegisterSchema, async (input, _req, res) => {
    const user = await userService.register(input); // input is z.infer<typeof RegisterSchema>
    res.status(201).json(user);
  })
);

Parse và validate representation ở biên transport. Domain vẫn phải bảo vệ invariant của chính nó, vì use case có thể được gọi từ queue, CLI hoặc code nội bộ không đi qua route Express.


Bộ middleware production

npm install morgan cors helmet compression express-rate-limit
import morgan from 'morgan';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import rateLimit from 'express-rate-limit';

app.disable('x-powered-by');
// Chỉ dùng 1 khi mọi đường vào production đi qua đúng một trusted proxy.
app.set('trust proxy', 1);
app.use(helmet());
app.use(
  cors({
    origin: process.env.FRONTEND_URL,
    credentials: true,
  })
);
app.use(compression()); // gzip/br responses
app.use(express.json({ limit: '1mb', strict: true }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

// Baseline abuse control; distributed deployment needs a shared/external store.
app.use(
  '/api/',
  rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-7' })
);

trust proxy là quyết định security dựa trên topology, không phải dòng cấu hình mặc định. Nếu client có đường kết nối trực tiếp hoặc số proxy thay đổi, cấu hình 1 có thể khiến Express tin header do client giả. Dùng subnet/function trust cụ thể và test req.ip, req.protocol, req.hostname qua mọi ingress path. Rate limiter trong memory cũng không tạo một quota chung khi chạy nhiều replica.


Context request & log có cấu trúc

Gắn request id ở biên và truyền context bằng AsyncLocalStorage để truy vết một request qua nhiều dòng log:

import { randomUUID } from 'node:crypto';

const REQUEST_ID = /^[A-Za-z0-9_-]{8,128}$/;

app.use((req, res, next) => {
  const incoming = req.get('x-request-id');
  req.requestId =
    incoming && REQUEST_ID.test(incoming) ? incoming : randomUUID();
  res.setHeader('X-Request-Id', req.requestId);
  next();
});

Header correlation id vẫn là input: giới hạn format/độ dài trước khi đưa vào log. Ở production, dùng AsyncLocalStorage và logger JSON để mọi log con tự mang requestId; không log token, cookie hoặc body nhạy cảm.


Tắt êm với Express

Giữ tham chiếu tới server bên dưới để xả khi deploy:

const server = app.listen(3000);

function shutdown(signal: string): void {
  console.log(`${signal} — draining`);
  server.close(() => process.exit(0));
  server.closeIdleConnections();
  setTimeout(() => {
    server.closeAllConnections();
    process.exit(1);
  }, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Failure modes và quyết định kiến trúc

Thứ tự pipeline đề xuất

Một API điển hình có thể dùng thứ tự sau; route cụ thể vẫn phải đặt authorization sát use case thay vì chỉ ở global middleware:

request id / context
  → access log
  → security headers + CORS
  → body parser có limit
  → router
      → authentication
      → validation
      → authorization theo resource
      → handler/use case
  → 404
  → error mapper
Failure modeTriệu chứngNguyên nhân thường gặpCách xác nhận
Request treoclient timeout, không có statusmiddleware quên next() và chưa gửi responseintegration test với deadline ngắn
ERR_HTTP_HEADERS_SENTlog 500 sau khi client đã nhận bodygọi next(err) hoặc gửi lần hai sau res.json()test từng branch và kiểm return
Mất error asyncunhandled rejection hoặc response treocallback async không được return/await, EventEmitter lỗi ngoài Promisefault injection đúng boundary
Rate limit sai usermọi user chung quota hoặc attacker bypasstrust proxy sai, store riêng từng replicatest qua ingress thật với nhiều IP/replica
Error contract lệchfrontend phải parse messageroute tự trả lỗi thay vì qua mappercontract test trên toàn bộ 4xx/5xx
Shutdown cắt requestdeploy sinh socket resetgrace period ngắn hơn request/dependency deadlinegửi SIGTERM khi request đang chạy

Express hay abstraction khác?

Lựa chọnPhù hợp khiChi phí cần chấp nhận
node:http trực tiếpprotocol server nhỏ, proxy/stream chuyên biệttự sở hữu routing, validation, error pipeline
Express 5cần ecosystem middleware và control trực tiếp trên pipelineít convention; team phải thống nhất architecture
Framework có module/DInhiều team cần convention, lifecycle và tooling thống nhấtabstraction, learning curve và migration cost cao hơn

Quyết định không nên dựa trên benchmark hello-world đơn lẻ. Đo workload thật, nhưng ưu tiên correctness, ecosystem, năng lực team và chi phí vận hành trước khi throughput framework trở thành bottleneck đã chứng minh.

Hợp đồng với frontend

  • Một error envelope thống nhất theo application/problem+json, có code, status, requestId và field errors có path ổn định.
  • 401 cho phép client bắt đầu login/refresh flow; 403 không được tự động refresh vô hạn.
  • Validation code không phụ thuộc câu tiếng Anh hiển thị. Frontend map code sang bản dịch và UI phù hợp.
  • CORS allowlist và cookie credentials phải được test cùng origin deploy thật, không chỉ Postman.
  • Request id được trả lại để UI/support cung cấp mã tra cứu, nhưng không lộ stack hay internal identifier nhạy cảm.

Dự án thực hành

  1. REST API đầy đủ (CRUD): dựng lại API tasks bằng Express 5, tách route → controller → service. Để ý nó ngắn và rõ hơn.

  2. Bộ middleware production: ráp helmet, cors, compression, morgan, giới hạn JSON, rate-limit, trust proxy, middleware request-id, và logger đo thời gian.

  3. Hệ thống lỗi có type: thêm cây lỗi, handler 404 cuối, error handler toàn cục, và catchAsync. Kích 404/422/500 và xác nhận hình dạng nhất quán.

  4. Tầng validation Zod: dựng factory validate(schema) dùng lại; validate body và query trên route tạo; chứng minh req.body có type khi đi tiếp.

Bài tập thêm: viết route /{*splat} theo cú pháp Express 5; bảo vệ router bằng requireAuth; thêm tắt êm và xác nhận request đang chạy hoàn tất khi SIGTERM.

Tiêu chí hoàn thành: Supertest hoặc client tương đương phải chứng minh middleware chạy đúng thứ tự, malformed body không vào use case, rejected Promise tới error mapper, stream lỗi sau khi gửi header không tạo response thứ hai, 401/403/422/500 cùng schema, và hai request đồng thời không dùng nhầm requestId.


Checklist trước khi ship

  • Controller mỏng; logic ở service.
  • Thứ tự middleware đúng; mỗi nhánh hoặc next() hoặc kết thúc response một lần.
  • Một cây lỗi + một handler; không lộ stack.
  • Mọi input validate ở biên.
  • Đủ bộ bảo mật/giới hạn.
  • App tắt êm.
  • trust proxy khớp mọi ingress path và rate-limit dùng shared store khi có nhiều replica.
  • Không có middleware async “fire-and-forget” trong request lifecycle.

Nếu chỉ nhớ năm điều

  • Express là pipeline có thứ tự; thứ tự middleware chính là một phần kiến trúc.
  • Express 5 chuyển rejected Promise được return từ handler sang error middleware.
  • Validation transport không thay invariant ở domain.
  • Error mapper phải giữ chi tiết trong log nhưng chỉ trả contract an toàn cho client.
  • Proxy trust, rate limit và graceful shutdown phải được test qua topology production thật.

Phần tiếp theo

Request pipeline đã có boundary rõ, nhưng dữ liệu vẫn nằm trong bộ nhớ và biến mất khi restart. Ở Phần 4, ta thiết kế data layer với transaction, pool, query shape và failure contract thay vì chỉ thay một object bằng ORM.

Đọc tiếp: Thiết kế Data Layer.

Tài liệu chính thức