阶段二:RESTful进阶
API一旦发布就有了使用者,任何改动都可能影响现有客户端。版本管理让你在不破坏旧客户端的前提下持续演进API。本课详解版本策略、变更分类、过渡方案和废弃流程——这是API长期运营的核心能力。
| 方式 | 示例 | 优点 | 缺点 | 代表 |
|---|---|---|---|---|
| URL路径 | /api/v2/users | 直观可见、易测试、易缓存 | URL变化、路由膨胀 | GitHub, Stripe |
| 查询参数 | /api/users?v=2 | URL路径不变 | 容易被忽略、不够显式 | Google Data |
| 自定义请求头 | Api-Version: 2 | URL不变、RESTful | 不可见、难测试 | Microsoft REST |
| Content-Type | Accept: application/vnd.myapi.v2+json | HTTP标准、内容协商 | 冗长、不直观 | GitHub(早期) |
| Host子域名 | v2.api.example.com | 完全隔离部署 | DNS/证书管理复杂 | AWS(部分) |
// 版本号放在URL路径中,清晰直观
GET /api/v1/users/42
GET /api/v2/users/42
// 优点:
// 1. 浏览器直接可访问,无需特殊工具
// 2. 代理和CDN自动缓存不同版本
// 3. 日志中直接可见版本信息
// 4. 文档URL与版本一一对应
// versioned-api.js - 完整的多版本API
const express = require('express');
const app = express();
app.use(express.json());
// ===== 通用中间件 =====
// 请求ID
app.use((req, res, next) => {
res.setHeader('X-Request-ID', Date.now().toString(36));
next();
});
// ===== 数据层 =====
const users = [
{ id: 1, firstName: '三', lastName: '张', email: 'zhang@example.com', role: 'admin', createdAt: '2025-01-01' },
{ id: 2, firstName: '四', lastName: '李', email: 'li@example.com', role: 'editor', createdAt: '2025-01-02' },
{ id: 3, firstName: '五', lastName: '王', email: 'wang@example.com', role: 'viewer', createdAt: '2025-01-03' },
];
const orders = [
{ id: 101, userId: 1, items: ['键盘', '鼠标'], total: 798, status: 'completed', createdAt: '2025-01-10' },
{ id: 102, userId: 2, items: ['显示器'], total: 2999, status: 'shipping', createdAt: '2025-01-11' },
{ id: 103, userId: 1, items: ['耳机'], total: 1299, status: 'pending', createdAt: '2025-01-12' },
];
// ===== V1 API:基础版本 =====
const v1 = express.Router();
// V1用户:扁平name字段
v1.get('/users', (req, res) => {
const data = users.map(u => ({
id: u.id,
name: `${u.lastName}${u.firstName}`, // V1: 合并为name
email: u.email,
}));
res.json({ success: true, data });
});
v1.get('/users/:id', (req, res) => {
const u = users.find(u => u.id === parseInt(req.params.id));
if (!u) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: { id: u.id, name: `${u.lastName}${u.firstName}`, email: u.email } });
});
// V1订单:简单结构
v1.get('/orders', (req, res) => {
const data = orders.map(o => ({
id: o.id,
user_id: o.userId, // V1: snake_case外键
items: o.items,
total: o.total,
status: o.status,
}));
res.json({ success: true, data });
});
// V1搜索:仅关键词
v1.get('/users/search', (req, res) => {
const q = (req.query.q || '').toLowerCase();
const results = users.filter(u =>
`${u.lastName}${u.firstName}`.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q)
).map(u => ({ id: u.id, name: `${u.lastName}${u.firstName}`, email: u.email }));
res.json({ success: true, data: results });
});
// ===== V2 API:增强版本 =====
const v2 = express.Router();
// V2用户:拆分firstName/lastName + 元数据
v2.get('/users', (req, res) => {
const { page = 1, limit = 20, role, fields } = req.query;
let data = [...users];
// 过滤
if (role) data = data.filter(u => u.role === role);
// 分页
const p = Math.max(1, parseInt(page));
const l = Math.min(100, Math.max(1, parseInt(limit)));
const total = data.length;
data = data.slice((p - 1) * l, p * l);
// 映射V2字段
const mapped = data.map(u => ({
id: u.id,
firstName: u.firstName, // V2: 拆分姓名
lastName: u.lastName,
displayName: `${u.lastName}${u.firstName}`, // V2: 显示名
email: u.email,
role: u.role, // V2: 新增角色
createdAt: u.createdAt, // V2: 新增时间戳
_links: { // V2: HATEOAS链接
self: `/api/v2/users/${u.id}`,
orders: `/api/v2/users/${u.id}/orders`,
},
}));
// 字段选择
let result = mapped;
if (fields) {
const fieldList = fields.split(',');
result = mapped.map(u => {
const filtered = {};
fieldList.forEach(f => { if (u[f] !== undefined) filtered[f] = u[f]; });
return filtered;
});
}
res.json({
success: true,
data: result,
pagination: { page: p, limit: l, total, totalPages: Math.ceil(total / l) },
});
});
v2.get('/users/:id', (req, res) => {
const u = users.find(u => u.id === parseInt(req.params.id));
if (!u) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `用户 ${req.params.id} 不存在` } });
const userOrders = orders.filter(o => o.userId === u.id);
res.json({
success: true,
data: {
id: u.id,
firstName: u.firstName,
lastName: u.lastName,
displayName: `${u.lastName}${u.firstName}`,
email: u.email,
role: u.role,
createdAt: u.createdAt,
_links: {
self: `/api/v2/users/${u.id}`,
orders: `/api/v2/users/${u.id}/orders`,
},
},
included: { orders: userOrders }, // V2: 嵌入关联资源
});
});
// V2订单:增强结构
v2.get('/orders', (req, res) => {
const { status, minTotal, sort } = req.query;
let data = [...orders];
if (status) data = data.filter(o => o.status === status);
if (minTotal) data = data.filter(o => o.total >= Number(minTotal));
if (sort === 'total') data.sort((a, b) => b.total - a.total);
const mapped = data.map(o => ({
id: o.id,
userId: o.userId, // V2: camelCase
items: o.items,
total: o.total,
currency: 'CNY', // V2: 新增币种
status: o.status,
createdAt: o.createdAt,
_links: {
self: `/api/v2/orders/${o.id}`,
user: `/api/v2/users/${o.userId}`,
},
}));
res.json({ success: true, data: mapped });
});
// V2搜索:高级过滤
v2.get('/users/search', (req, res) => {
const { q, role, createdAfter, sort } = req.query;
let data = [...users];
if (q) {
const keyword = q.toLowerCase();
data = data.filter(u =>
`${u.lastName}${u.firstName}`.toLowerCase().includes(keyword) ||
u.email.toLowerCase().includes(keyword)
);
}
if (role) data = data.filter(u => u.role === role);
if (createdAfter) data = data.filter(u => u.createdAt > createdAfter);
if (sort === 'name') data.sort((a, b) => `${a.lastName}${a.firstName}`.localeCompare(`${b.lastName}${b.firstName}`));
const mapped = data.map(u => ({
id: u.id, firstName: u.firstName, lastName: u.lastName,
displayName: `${u.lastName}${u.firstName}`, email: u.email, role: u.role,
}));
res.json({ success: true, data: mapped, meta: { total: mapped.length, query: { q, role, createdAfter } } });
});
// 用户订单嵌套资源(V2新增)
v2.get('/users/:id/orders', (req, res) => {
const userOrders = orders.filter(o => o.userId === parseInt(req.params.id));
res.json({
success: true,
data: userOrders,
_links: { user: `/api/v2/users/${req.params.id}` },
});
});
// ===== 版本路由注册 =====
app.use('/api/v1', v1);
app.use('/api/v2', v2);
// ===== 版本发现 =====
app.get('/api', (req, res) => {
res.json({
success: true,
data: {
name: 'Example API',
currentVersion: 'v2',
supportedVersions: [
{
version: 'v2',
status: 'stable',
baseUrl: '/api/v2',
changelog: 'https://docs.example.com/api/v2/changelog',
},
{
version: 'v1',
status: 'deprecated',
baseUrl: '/api/v1',
sunset: '2025-12-31',
migration: 'https://docs.example.com/api/migration/v1-to-v2',
},
],
},
});
});
// ===== 废弃警告中间件 =====
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2025 23:59:59 GMT');
res.setHeader('Link', '; rel="successor-version"');
next();
});
app.listen(3000, () => {
console.log('📦 版本化API运行在 http://localhost:3000');
console.log(' V1: http://localhost:3000/api/v1/users (已废弃)');
console.log(' V2: http://localhost:3000/api/v2/users (当前)');
console.log(' 版本发现: http://localhost:3000/api');
});
# V1 vs V2 响应对比
# V1: 扁平name
curl -s http://localhost:3000/api/v1/users/1 | jq .
{
"success": true,
"data": {"id":1,"name":"张三","email":"zhang@example.com"}
}
# V2: 拆分姓名 + 角色 + 时间戳 + HATEOAS
curl -s http://localhost:3000/api/v2/users/1 | jq .
{
"success": true,
"data": {
"id": 1, "firstName": "三", "lastName": "张",
"displayName": "张三", "email": "zhang@example.com",
"role": "admin", "createdAt": "2025-01-01",
"_links": {"self":"/api/v2/users/1","orders":"/api/v2/users/1/orders"}
},
"included": {"orders":[...]}
}
# V1废弃响应头
curl -I http://localhost:3000/api/v1/users
# 版本发现
curl -s http://localhost:3000/api | jq .
# V2高级搜索
curl -s "http://localhost:3000/api/v2/users/search?role=admin&createdAfter=2024-12-31" | jq .
# V2订单过滤
curl -s "http://localhost:3000/api/v2/orders?minTotal=1000&sort=total" | jq .
| 阶段 | 时间点 | 行动 | HTTP响应头 |
|---|---|---|---|
| 预告 | T-12个月 | 发布废弃公告,更新文档 | Warning: 299 |
| 标记 | T-6个月 | 响应头标记废弃状态 | Deprecation: true |
| 倒计时 | T-3个月 | 邮件通知使用者,文档加粗提醒 | Sunset: <date> |
| 下线 | T-0 | 返回410 Gone | 410 Gone |
// 废弃中间件 - 自动处理废弃期和下线
function deprecated(sunsetDate, successorPath) {
return (req, res, next) => {
const now = new Date();
const sunset = new Date(sunsetDate);
if (now >= sunset) {
// 已下线:返回410 Gone
return res.status(410).json({
success: false,
error: {
code: 'VERSION_SUNSET',
message: `此API版本已于 ${sunsetDate} 下线`,
migration: {
successor: successorPath,
guide: `https://docs.example.com/api/migration`,
},
},
});
}
// 废弃期:添加标准废弃头
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', sunset.toUTCString());
res.setHeader('Link', `<${successorPath}>; rel="successor-version"`);
const daysLeft = Math.ceil((sunset - now) / (1000 * 60 * 60 * 24));
res.setHeader('Warning', `299 - "API deprecated, will be removed in ${daysLeft} days"`);
next();
};
}
// 使用
app.use('/api/v1', deprecated('2025-12-31', '/api/v2'), v1Router);
# API迁移指南:V1 → V2
## 破坏性变更
### 1. 用户模型变更
| V1字段 | V2字段 | 说明 |
|--------|--------|------|
| name | firstName + lastName | 姓名拆分 |
| - | displayName | 新增:显示名称 |
| - | role | 新增:用户角色 |
| - | createdAt | 新增:创建时间 |
### 2. 命名风格变更
| V1 | V2 | 说明 |
|----|----|------|
| user_id | userId | snake_case → camelCase |
| created_at | createdAt | 同上 |
### 3. 响应结构变更
- 列表接口增加 pagination 对象
- 详情接口增加 included 嵌入关联资源
- 所有响应增加 _links HATEOAS链接
## 迁移步骤
1. 更新基础URL:/api/v1 → /api/v2
2. 更新字段映射:name → displayName(或firstName/lastName)
3. 处理新增的必需字段
4. 更新错误处理逻辑(V2错误格式更详细)
5. 测试所有接口
V2的firstName/lastName决定合并回name字段,但增加fullName、locale和timezone。请:
将本课的API改为通过Api-Version: 2请求头选择版本,URL保持/api/users不变。对比URL版本化和请求头版本化的实现复杂度。
实现一个中间件,统计每个版本的请求次数,输出到日志。当V1的请求量低于5%时,自动发送提醒。
// 版本使用统计中间件
const versionStats = { v1: 0, v2: 0 };
app.use('/api/:version', (req, res, next) => {
versionStats[req.params.version]++;
// 每小时输出统计
next();
});