📖 第07课:版本管理

阶段二:RESTful进阶

API一旦发布就有了使用者,任何改动都可能影响现有客户端。版本管理让你在不破坏旧客户端的前提下持续演进API。本课详解版本策略、变更分类、过渡方案和废弃流程——这是API长期运营的核心能力。

📊 变更分类:什么需要版本化

兼容性变更(不需要新版本)

破坏性变更(需要新版本)

💡 Postel法则(鲁棒性原则):发送时保守,接收时宽容。新增字段是安全的,客户端应忽略未知字段。这让你可以在不升级版本的情况下扩展API。但"接收时宽容"的前提是客户端正确处理——这也是为什么客户端不应该假设响应只有已知字段。

🔢 版本策略详解

五种版本化策略对比

方式示例优点缺点代表
URL路径/api/v2/users直观可见、易测试、易缓存URL变化、路由膨胀GitHub, Stripe
查询参数/api/users?v=2URL路径不变容易被忽略、不够显式Google Data
自定义请求头Api-Version: 2URL不变、RESTful不可见、难测试Microsoft REST
Content-TypeAccept: application/vnd.myapi.v2+jsonHTTP标准、内容协商冗长、不直观GitHub(早期)
Host子域名v2.api.example.com完全隔离部署DNS/证书管理复杂AWS(部分)

URL路径版本化——最推荐的方案

// 版本号放在URL路径中,清晰直观
GET /api/v1/users/42
GET /api/v2/users/42

// 优点:
// 1. 浏览器直接可访问,无需特殊工具
// 2. 代理和CDN自动缓存不同版本
// 3. 日志中直接可见版本信息
// 4. 文档URL与版本一一对应
⚠️ 版本号的粒度:版本号只用主版本号(v1, v2, v3),不要使用v1.1、v2.3。小版本变更应该是向后兼容的,不需要版本号区分。如果你发现需要v1.1,说明你的v1设计不够宽容,或者这个变更实际是破坏性的。

🛠️ 实战:多版本API完整实现

// 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');
});

测试版本化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 .

🗓️ 废弃流程(Deprecation Lifecycle)

标准废弃时间线

阶段时间点行动HTTP响应头
预告T-12个月发布废弃公告,更新文档Warning: 299
标记T-6个月响应头标记废弃状态Deprecation: true
倒计时T-3个月邮件通知使用者,文档加粗提醒Sunset: <date>
下线T-0返回410 Gone410 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);

🔄 迁移指南编写

V1 → V2 迁移清单模板

# 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. 测试所有接口

📝 本课小结

  1. 新增字段是兼容的,删除/修改字段是破坏性的——理解区别是版本管理的前提
  2. URL路径版本化最直观、最常用,GitHub和Stripe都采用此方案
  3. 版本号只用主版本(v1, v2),不用小版本号
  4. 废弃流程至少6个月:预告→标记→倒计时→下线
  5. 使用标准HTTP头(Deprecation/Sunset/Link/Warning)标记废弃状态
  6. 已下线版本返回410 Gone,而非404 Not Found
  7. 提供详细的迁移指南和版本发现端点
  8. 遵守Postel法则让API更容易兼容演进

💪 练习

练习1:设计V3迁移方案

V2的firstName/lastName决定合并回name字段,但增加fullName、locale和timezone。请:

  1. 列出所有破坏性变更
  2. 设计V3的响应格式
  3. 编写V2→V3迁移指南
  4. 实现废弃中间件

练习2:实现请求头版本化

将本课的API改为通过Api-Version: 2请求头选择版本,URL保持/api/users不变。对比URL版本化和请求头版本化的实现复杂度。

练习3:监控API版本使用情况

实现一个中间件,统计每个版本的请求次数,输出到日志。当V1的请求量低于5%时,自动发送提醒。

// 版本使用统计中间件
const versionStats = { v1: 0, v2: 0 };

app.use('/api/:version', (req, res, next) => {
  versionStats[req.params.version]++;
  // 每小时输出统计
  next();
});

🏆 本课成就:版本管理专家