使用 Express 在 Node.js 中编写 REST API

1. 概述

本文将指导你使用 Express 构建一个管理 Order 的 REST API — 从项目初始化、路由定义、中间件、数据验证,到集中错误处理。读完本文后,你将能够独立构建一个完整的 CRUD API,使用 Jest 和 Supertest 编写自动化测试,并了解使用 Express 时的常见错误。

整篇文章的示例:一个管理 Customer 订单的 API (Order),支持创建、查看、编辑、删除等操作。

2. 项目准备

创建一个新的 Node.js 项目并安装 Express:

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

在 package.json 中添加 type: module 以使用 import/export 语法(ES Module),并声明测试脚本:

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

注意: Jest 使用 ES Module 需要 --experimental-vm-modules 标志。如果不想麻烦,你也可以移除 "type": "module" 并使用 require/module.exports(CommonJS) — 两者都是有效的,取决于团队偏好。

3. 创建基础 Express 服务器

// src/app.js
import express from 'express';

const app = express();
app.use(express.json()); // 将 JSON body 解析为 req.body

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

export default app;
// src/server.js
import app from './app.js';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API 正在端口 ${PORT} 上运行`);
});
  • express.json() 是内置中间件,帮助 Express 自动将 Content-Type: application/json 解析为对象 req.body。没有这一行,req.body 将是 undefined。
  • 将 app.js 与 server.js 分开,这样测试时不需要真正打开端口 — 这是 REST API Node.js 的重要习惯。

试试运行:

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. 定义 Order 资源

使用内存中的数组作为“数据库”来专注于 API 部分,不依赖真实 DB。

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

这个 Repository 模拟了你在任何 ORM(如 Sequelize、Prisma)中会看到的 CRUD 操作,只是数据存储在 RAM 而不是 DB。

5. 使用 Express Router 进行路由

与其把所有路由都放在 app 中,不如使用 express.Router() 按资源模块化:

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: '订单不存在' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: '订单不存在' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: '订单不存在' });
  res.status(204).end();
});

export default router;

将 router 挂载到 app,使用 /orders 前缀:

// src/app.js (更新)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id 是路由参数,通过 req.params.id 获取(始终是字符串,需要 Number() 进行比较)。
  • 调用 next({ status: 404, message: ... }) 将错误传递给错误处理中间件(第 7 节),而不是在每个路由中重复写 res.status(404)...。
  • 删除成功时返回 204 No Content — 没有响应体。

6. 验证输入数据

用 if/else 手动验证很容易遗漏情况。使用 express-validator 更简洁清晰:

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName 是必需的'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount 必须大于 0'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

将验证器附加到 POST 路由:

// src/orders/orderRouter.js (更新)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • 每个 body(...) 是检查一个字段的规则;如果失败,错误被收集在 validationResult(req) 中。
  • 数组中的最后一个中间件检查 errors.isEmpty() — 如果有错误,返回 400 并显示详情,不调用 next() 以阻止请求继续进行。

发送缺少 amount 的请求:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "张三"}'
{"errors":[{"type":"field","msg":"amount 必须大于 0","path":"amount",...}]}

7. 集中错误处理

Express 允许定义错误处理中间件 — 它有4 个参数 (err, req, res, next),放在所有路由之后:

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || '系统错误';

  if (status === 500) {
    console.error(err); // 记录真实错误用于调试,不向客户端返回堆栈跟踪
  }

  res.status(status).json({ message });
}
// src/app.js (更新,添加到文件末尾)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express 通过参数数量(4 个)识别这是一个错误处理器,而不是通过函数名。
  • 必须在所有 app.use(router) 之后放置 app.use(errorHandler) — Express 按声明顺序处理中间件。
  • 任何 next(err) 或路由中的错误抛出(如果使用 express-async-errors 或 try/catch)都会落入这里。

常见错误: 在异步代码中忘记调用 next(err)(例如 await db.query(...) 抛出错误)导致 Express 无法捕获错误,服务器崩溃或请求挂起。对于 Express 5(较新版本),异步处理器抛出的错误会自动被捕获 — 而 Express 4 需要手动用 try/catch 包装并调用 next(err)。

8. 添加 Service 层来分离业务逻辑

当逻辑变得复杂时(例如:如果订单 amount 超过客户限额则不允许创建),不要把一切都塞进路由器。提取出 orderService:

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('超出允许的限额');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (更新 POST 路由)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

路由器现在只负责 HTTP(状态码、解析参数),而 orderService 负责业务逻辑。这是与其他框架中的 Controller–Service 模式一样的做法,有助于独立测试业务逻辑而不依赖 HTTP。

9. 使用 Jest 和 Supertest 编写测试

Supertest 允许直接在 Express 实例 app 上调用 API,而不需要真正打开端口 — 这就是第 3 节为什么要分离 app.js 和 server.js 的原因。

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('成功创建订单返回 201 和 id', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: '张三', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: '张三',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('缺少 amount 返回 400', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: '张三' });

    expect(response.status).toBe(400);
  });

  it('获取不存在的订单返回 404', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('订单不存在');
  });
});

运行测试:

npm test
PASS  tests/order.test.js
  Order API
    ✓ 成功创建订单返回 201 和 id
    ✓ 缺少 amount 返回 400
    ✓ 获取不存在的订单返回 404
  • request(app) 初始化一个虚拟 HTTP 客户端,直接绑定到 Express 应用 — 比真实网络请求快得多。
  • toMatchObject 只检查指定的字段,忽略其他字段(如自动生成的 id) — 方便测试具有动态值的响应。

注意: 由于 orders 是在内存中的数组,在多个测试之间共享,按顺序运行的测试可能相互影响(例如第 1 个测试创建的订单在第 2 个测试运行时仍然存在)。对于真实 API 连接到数据库,应该在 beforeEach 中重置数据或使用事务回滚。

10. 何时不应使用纯粹的 Express

  • 大型 API,复杂的多领域逻辑: Express 对代码结构没有强制约定,容易形成“意大利面条式代码”,如果团队没有自己制定规则的话。如果团队熟悉分层架构,可以考虑使用 NestJS(有 DI、模块、装饰器类似 Spring)。
  • 需要强类型安全: 纯 JavaScript 的 Express 无法在编译时捕获数据类型错误。对于大型项目,应该从一开始就使用 TypeScript,而不是等到项目变大后再转换 — 后期转换的成本会高得多。
  • 高吞吐量,需要复杂的流/WebSocket: Express 仍然可以处理,但 Fastify 或其他性能优化的框架(基准测试中 JSON 序列化速度更快)可能更适合极高流量的系统。

对于小到中型 API,有清晰的 CRUD 操作(如上面的示例),Express 仍然是一个快速简洁的选择,拥有庞大的社区,易于找到现成的中间件(身份认证、速率限制、CORS 等)。

11. 总结

通过本文,你已经用 Express 构建了一个完整的 Order REST API:使用 Router 进行路由,使用 express-validator 验证输入,使用 4 参数中间件进行集中错误处理,并提取 service 层来分离业务逻辑以便于测试。使用 Jest + Supertest 编写的测试让你能够自信地重构,而不必担心破坏 API 的行为。在这个基础上,你可以添加真实数据库连接(Prisma、Mongoose)、身份认证(JWT),或在项目扩大时迁移到 TypeScript。

nodejsexpressrest-apijavascripttesting