Skip to content
准备工作:项目初始化与结构设计
从一个空目录开始。
bash
mkdir express-todo-api
cd express-todo-api
npm init -y安装运行时依赖和 TypeScript 相关开发依赖:
bash
npm install express
npm install -D typescript @types/node @types/express ts-nodets-node 用于直接执行 .ts 文件,省去开发阶段先编译到 .js 的步骤。如果更习惯编译后运行,也可以换成 tsc 搭配 node。
生成一份最小化的 tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}目录结构在本章结束后会演变成这样:
express-todo-api/
├── src/
│ ├── app.ts # 应用入口:挂载路由、错误处理
│ ├── routes/
│ │ └── todos.ts # /todos 下的所有端点
│ └── models/
│ └── todo.ts # 数据模型和内存操作
├── package.json
└── tsconfig.json先建好目录:
bash
mkdir -p src/routes src/models入口 src/app.ts 最开始是一条能跑通的 Express 应用骨架——此时还没有路由和错误处理,后续逐步填充:
typescript
import express from 'express';
const app = express();
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});在 package.json 的 "scripts" 里添加启动命令:
json
"dev": "ts-node src/app.ts"运行 npm run dev,终端打印出监听端口即表示环境就绪。
待办事项的数据模型
内存数组充当简易“数据库”。先定义类型,再围绕数组封装一套纯函数操作。
src/models/todo.ts:
typescript
export interface Todo {
id: number;
title: string;
completed: boolean;
}
let todos: Todo[] = [
{ id: 1, title: '学习 Express', completed: false },
{ id: 2, title: '写 REST API', completed: false },
];
let nextId = 3;
export function getAllTodos(): Todo[] {
return todos;
}
export function getTodoById(id: number): Todo | undefined {
return todos.find(t => t.id === id);
}
export function createTodo(title: string): Todo {
const todo: Todo = { id: nextId++, title, completed: false };
todos.push(todo);
return todo;
}
export function updateTodo(id: number, updates: Partial<Pick<Todo, 'title' | 'completed'>>): Todo | undefined {
const todo = todos.find(t => t.id === id);
if (!todo) return undefined;
if (updates.title !== undefined) todo.title = updates.title;
if (updates.completed !== undefined) todo.completed = updates.completed;
return todo;
}
export function deleteTodo(id: number): boolean {
const index = todos.findIndex(t => t.id === id);
if (index === -1) return false;
todos.splice(index, 1);
return true;
}密钥生成直接使用自增 nextId,没有引入复杂的 ID 生成逻辑——示例的重心在 HTTP 层。createTodo 仅要求 title,completed 默认为 false,与常规待办应用习惯一致。
路由模块只依赖这几个函数,完全不接触数组内部结构。
路由模块的创建与挂载
Express 项目规模稍大后,所有端点挤在一个文件里很难维护。express.Router() 的思路是:一个资源一个路由器模块,每个路由器相当于一个迷你应用,最终挂载到主应用的路径前缀上。
src/routes/todos.ts:
typescript
import { Router, Request, Response } from 'express';
const router = Router();
// 后续在这里挂载具体端点
export default router;回到 src/app.ts,将该路由器挂载到 /todos:
typescript
import todosRouter from './routes/todos';
const app = express();
app.use('/todos', todosRouter);app.use('/todos', todosRouter) 的匹配规则是“路径前缀匹配”:所有以 /todos 开头的请求都会进入这个路由器。因此路由器内部定义的 / 实际对应完整路径 /todos,/:id 对应 /todos/:id。
如果后续还需要用户、标签等资源,可以如法炮制 routes/users.ts、routes/tags.ts,再用 app.use('/users', usersRouter) 挂载。结构平铺直叙。
实现查询与详情端点(GET)
在 src/routes/todos.ts 中添加两个 GET 端点。
首先获取全部待办事项:
typescript
import { getAllTodos, getTodoById } from '../models/todo';
router.get('/', (req: Request, res: Response) => {
const todos = getAllTodos();
res.json(todos);
});res.json() 会将数组序列化为 JSON,并自动带上 Content-Type: application/json; charset=utf-8。
按 id 查询单条:
typescript
router.get('/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'id 必须是数字' });
}
const todo = getTodoById(id);
if (!todo) {
return res.status(404).json({ error: 'Todo not found' });
}
res.json(todo);
});这里做了两道防护:parseInt 遇到非数字返回 NaN 时,直接 400;资源不存在时返回 404 并附带 JSON 错误信息。每个分支都使用 return 确保后续逻辑不再执行——当前全是同步代码,不会出问题,但这个习惯可以避免将来引入中间件时的意外。
实现创建端点(POST)与请求体解析
要拿到客户端提交的 JSON 数据,需要 express.json() 中间件。它会解析 Content-Type: application/json 的请求体,并将结果放到 req.body。
在 src/app.ts 中,必须在路由挂载之前使用这个中间件,否则路由里的 req.body 仍然是 undefined:
typescript
app.use(express.json()); // 解析 JSON 请求体
app.use('/todos', todosRouter);顺序是 Express 中间件的核心规则——按挂载先后执行。如果 express.json() 放在路由之后,那么到路由处理函数时请求体还未被解析。
然后在路由模块里定义 POST 端点:
typescript
import { createTodo } from '../models/todo';
router.post('/', (req: Request, res: Response) => {
const { title } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({ error: 'title 字段是必填的非空字符串' });
}
const todo = createTodo(title.trim());
res.status(201).json(todo);
});201 Created 是 POST 成功的典型状态码。返回的新建对象中包含服务端生成的 id 和默认字段。
端点中的校验虽然简单,但已足以拦截空字符串、缺失字段、非字符串等情况。
实现更新与删除端点(PUT / DELETE)
PUT 和 DELETE 通常操作同一个资源路径 /:id。可以使用 router.route() 将同一路径的不同方法链式定义,减少路径重复书写:
typescript
import { updateTodo, deleteTodo } from '../models/todo';
router.route('/:id')
.put((req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'id 必须是数字' });
}
const { title, completed } = req.body;
// 至少提供一个要更新的字段
if (title === undefined && completed === undefined) {
return res.status(400).json({ error: '至少需要提供 title 或 completed 字段' });
}
const updated = updateTodo(id, { title, completed });
if (!updated) {
return res.status(404).json({ error: 'Todo not found' });
}
res.json(updated);
})
.delete((req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'id 必须是数字' });
}
const deleted = deleteTodo(id);
if (!deleted) {
return res.status(404).json({ error: 'Todo not found' });
}
res.status(204).send();
});PUT 成功返回更新后的资源对象和 200 OK;DELETE 成功返回 204 No Content,没有响应体。res.status(204).send() 只发送状态码,这是 REST 里删除操作的常见做法。
PUT 的校验策略是:title === undefined && completed === undefined 才判定为无效请求。允许只传一个字段——这正是 updateTodo 中 Partial 的含义。
异步端点的错误捕获
当前所有端点都是同步函数,Express 会自动捕获同步抛出的错误并交给错误处理中间件。一旦引入异步操作(例如数据库读写),async 函数内部抛出的错误不会自动传递,必须显式调用 next(err)。在前一篇中已经讨论过 asyncHandler 的实现,这里直接沿用。在项目中定义一个工具函数,供所有异步端点复用:
typescript
// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}为了演示异步错误如何被统一错误处理中间件捕获,在路由里添加一个测试端点,它使用 asyncHandler 包装并主动抛出错误:
typescript
// 在 todos 路由中添加
import { asyncHandler } from '../utils/asyncHandler';
router.get('/async-error', asyncHandler(async (req, res) => {
// 模拟一个异步操作中抛出的错误(例如数据库查询失败)
throw new Error('异步数据库写入失败');
}));当请求 GET /todos/async-error 时,asyncHandler 会捕获 async 函数内部抛出的错误,并调用 next(err) 将错误交给统一错误处理中间件。
错误处理管道与 404 兜底
在所有路由之后放置 404 兜底和统一错误处理中间件,作为最后防线。统一的错误处理中间件必须声明四个参数 (err, req, res, next),Express 才能识别。
typescript
// 在所有路由之后
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
const status = err.status || 500;
res.status(status).json({
error: status === 500 ? 'Internal Server Error' : err.message,
});
});这里的 err.status 是约定:当某处需要抛出带有特定状态码的业务错误时,可以在 Error 对象上附加 status 属性,否则一律按 500 处理。
404 兜底中间件也放在所有路由之后、错误处理中间件之前:
typescript
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});最终 src/app.ts 中的注册顺序示意如下:
express.json()- 各资源路由器(
/todos) - 404 兜底
- 统一错误处理
所有未命中任何路由的请求都会收到 404 JSON。如果路由内部调用 next(err),会跳过 404 直接进入错误处理。
用 curl 验收每一个端点
服务启动在 http://localhost:3000,另开一个终端逐步测试。
1. 获取全部
bash
curl -s http://localhost:3000/todos | json_pp预期输出(取决于初始的两条数据):
json
[
{
"completed" : false,
"id" : 1,
"title" : "学习 Express"
},
{
"completed" : false,
"id" : 2,
"title" : "写 REST API"
}
]2. 获取单条
bash
curl -s http://localhost:3000/todos/1 | json_pp正常返回 id:1 对应的对象。
3. 获取不存在的资源
bash
curl -s http://localhost:3000/todos/999 | json_ppjson
{ "error" : "Todo not found" }状态码 404。
4. id 格式错误
bash
curl -s http://localhost:3000/todos/abcjson
{ "error" : "id 必须是数字" }状态码 400。
5. 创建新待办
bash
curl -s -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"测试 API"}' | json_pp返回新建对象,id 为 3,completed 为 false。状态码 201。
6. 创建时缺少 title
bash
curl -s -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{}' | json_ppjson
{ "error" : "title 字段是必填的非空字符串" }状态码 400。
7. 更新资源
bash
curl -s -X PUT http://localhost:3000/todos/3 \
-H "Content-Type: application/json" \
-d '{"completed":true}' | json_pp返回更新后的对象,completed 变为 true。
8. 更新不存在的资源
bash
curl -s -X PUT http://localhost:3000/todos/999 \
-H "Content-Type: application/json" \
-d '{"title":"nope"}' | json_ppjson
{ "error" : "Todo not found" }状态码 404。
9. 删除资源
bash
curl -s -X DELETE http://localhost:3000/todos/3 -w "%{http_code}"输出 204,没有响应体。
10. 访问未定义路由
bash
curl -s http://localhost:3000/not-exist | json_pp返回 {"error":"Not Found"},状态码 404。
11. 验证删除后再查询
bash
curl -s http://localhost:3000/todos/3应返回 404。
12. 异步端点触发内部错误
bash
curl -s http://localhost:3000/todos/async-error | json_pp返回 {"error":"Internal Server Error"},状态码 500。同时在服务端控制台可以看到 异步数据库写入失败 的堆栈输出,确认错误已通过 asyncHandler 传递到错误处理中间件。
小结:从零搭建 REST API 的思维路径
整个搭建过程可以提炼为一条流水线:
- 定义数据结构——在内存中用数组和纯函数封装增删改查,层与层之间通过函数签名通信。
- 用
Router拆模块——每个资源一个路由器,再挂载到主应用的前缀,避免所有路径挤在一个文件。 - 实现 CRUD 端点时同步堵上错误分支:缺字段、格式错、资源不存在,都返回明确的状态码和 JSON 错误。
- 引入
express.json()解析请求体,并且一定要放在路由之前,否则req.body永远是undefined。 - 在所有路由之后放 404 兜底和统一错误处理,建立最后的防线。即便当前大部分端点是同步的,也要用
asyncHandler包装异步端点,确保异步错误能被统一处理。
这套流程不依赖任何数据库或 ORM,在本地就能完整跑通,适合用来练习 Express 的路由、中间件和错误处理。理解这些之后,再接真实的数据库或外部服务,结构上不会发生本质变化。
参考链接
- Express 安装指南: http://expressjs.com/en/starter/installing.html
- Express “Hello World” 入门: http://expressjs.com/en/starter/hello-world.html
- Express 路由指南: http://expressjs.com/en/guide/routing.html
- Express 4.x API 参考 - Router: http://expressjs.com/en/4x/api.html#router
- Express 4.x API 参考 - app.use: http://expressjs.com/en/4x/api.html#app.use
- Express 4.x API 参考 - req.params, req.query: http://expressjs.com/en/4x/api.html#req.params
- Express 4.x API 参考 - express.json: http://expressjs.com/en/4x/api.html#express.json
- Express 4.x API 参考 - req.body: http://expressjs.com/en/4x/api.html#req.body
- Express 4.x API 参考 - res.status, res.json, res.send: http://expressjs.com/en/4x/api.html#res.status
- Express 4.x API 参考 - app.post, app.put, app.delete: http://expressjs.com/en/4x/api.html#app.post
- Express 路由指南 - app.route: http://expressjs.com/en/guide/routing.html
- Express 错误处理指南: http://expressjs.com/en/guide/error-handling.html
- Express 使用中间件指南: http://expressjs.com/en/guide/using-middleware.html
- Express 4.x API 参考 - res.json 行为: http://expressjs.com/en/4x/api.html#res.json
- RFC 7231 Section 4.3 (HTTP 方法语义): https://datatracker.ietf.org/doc/html/rfc7231#section-4.3
- Express 4.x API 参考 - res.sendStatus: http://expressjs.com/en/4x/api.html#res.sendStatus
参考资料:
[1] [官方] 命令:npm install express 将 Express 安装为项目依赖;npm init 可生成 package.json。来源:Express 安装指南 URL: http://expressjs.com/en/starter/installing.html
[2] [官方] 示例:最小 Express 应用——const express = require('express'); const app = express(); app.get('/', (req, res) => res.send('Hello World!')); app.listen(3000); 展示了创建 app、定义路由、启动服务的完整骨架。来源:Express 入门指南(Hello world) URL: http://expressjs.com/en/starter/hello-world.html
[3] [官方] 定义:Express 路由由 HTTP 方法、路径模式和处理函数组成。app.METHOD(path, handler) 中的 METHOD 是 HTTP 方法的小写形式,如 get、post、put、delete。来源:Express 路由指南 URL: http://expressjs.com/en/guide/routing.html
[4] [官方] 概念:express.Router() 创建模块化、可挂载的路由处理器。Router 实例可看作一个迷你应用,可定义自己的路由和中间件,再通过 app.use() 挂载到指定前缀路径。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#router
[5] [官方] API:app.use([path,] callback) 用于挂载中间件或路由器;不传 path 时匹配所有请求,传入 '/todos' 时匹配以 /todos 开头的所有请求,是挂载路由子模块的常用方式。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#app.use
[6] [官方] API:req.params 保存路径参数,例如路由 /todos/:id 匹配 /todos/5 时 req.params.id 为 '5';req.query 保存查询字符串解析后的对象,例如 /todos?completed=false 得到 req.query.completed === 'false'。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#req.params
[7] [官方] 中间件:express.json() 解析 Content-Type 为 application/json 的请求体,并把解析结果挂载到 req.body。在 Express 4.16.0 起内置,使用前不需要单独安装。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#express.json
[8] [官方] API:req.body 包含请求体中的键值对;若未使用 express.json() 或 express.urlencoded() 等解析中间件,req.body 为 undefined。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#req.body
[9] [官方] API:res.status(code) 设置 HTTP 状态码并返回 res,支持链式调用;res.json(obj) 将对象序列化为 JSON 并发送;res.send(body) 发送各种类型的响应。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#res.status
[10] [官方] API:app.post(path, handler) 处理 POST 请求;app.put(path, handler) 处理 PUT 请求;app.delete(path, handler) 处理 DELETE 请求。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#app.post
[11] [官方] 方法:app.route(path) 允许在同一路径上链式定义多个 HTTP 方法处理器,例如 app.route('/todos/:id').get(...).put(...).delete(...),适合 REST API 的同一资源操作。来源:Express 路由指南 URL: http://expressjs.com/en/guide/routing.html
[12] [官方] 工作原理:Express 自动捕获路由处理函数中同步抛出的错误;异步错误必须调用 next(err),将错误传给错误处理中间件。错误处理中间件必须声明为 (err, req, res, next) 四参数形式,否则不会被识别。来源:Express 错误处理指南 URL: http://expressjs.com/en/guide/error-handling.html
[13] [官方] 注意点:中间件执行顺序按照挂载顺序。express.json() 等解析中间件必须放在依赖 req.body 的路由之前;404 兜底中间件和错误处理中间件必须放在所有路由之后,否则无法生效。来源:Express 使用中间件指南 URL: http://expressjs.com/en/guide/using-middleware.html
[14] [官方] 示例:404 兜底中间件——app.use((req, res) => { res.status(404).send('Not Found'); });。放在所有路由之后,匹配所有尚未被处理的请求并返回 404。来源:Express 错误处理指南 URL: http://expressjs.com/en/guide/error-handling.html
[15] [官方] 行为:res.json() 会将传入对象通过 JSON.stringify 转换,并设置 Content-Type 为 application/json; charset=utf-8。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#res.json
[16] [官方] [标准] 定义:HTTP 请求方法语义——GET 请求获取目标资源的当前表示;POST 方法请求目标资源根据资源自身的语义处理请求中的表示,通常用于创建;PUT 方法用请求负载替换目标资源的表示;DELETE 方法删除目标资源。来源:RFC 7231 Section 4.3 URL: https://datatracker.ietf.org/doc/html/rfc7231#section-4.3
[17] [官方] API:res.sendStatus(code) 直接发送状态码作为响应,例如 res.sendStatus(404) 等价于 res.status(404).send('Not Found');也可用 res.status(404).json({ error: 'Not Found' }) 返回 JSON 错误。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#res.sendStatus
[18] [官方] 项目结构:使用 express.Router 拆分路由时,可以为每个资源创建独立的 Router 模块,再在入口文件 app.js 中通过 app.use('/todos', todosRouter) 挂载。来源:Express 路由指南 URL: http://expressjs.com/en/guide/routing.html
[19] [官方] 命令:app.listen(port, callback) 绑定并监听指定端口;在服务器开始监听时触发回调。来源:Express 4.x API 参考 URL: http://expressjs.com/en/4x/api.html#app.listen
参考链接
- [1] http://expressjs.com/en/starter/installing.html
- [2] http://expressjs.com/en/starter/hello-world.html
- [3] http://expressjs.com/en/guide/routing.html
- [4] http://expressjs.com/en/4x/api.html#router
- [5] http://expressjs.com/en/4x/api.html#app.use
- [6] http://expressjs.com/en/4x/api.html#req.params
- [7] http://expressjs.com/en/4x/api.html#express.json
- [8] http://expressjs.com/en/4x/api.html#req.body
- [9] http://expressjs.com/en/4x/api.html#res.status
- [10] http://expressjs.com/en/4x/api.html#app.post
- [12] http://expressjs.com/en/guide/error-handling.html
- [13] http://expressjs.com/en/guide/using-middleware.html
- [15] http://expressjs.com/en/4x/api.html#res.json
- [16] https://datatracker.ietf.org/doc/html/rfc7231#section-4.3
- [17] http://expressjs.com/en/4x/api.html#res.sendStatus
- [19] http://expressjs.com/en/4x/api.html#app.listen
