使用 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。