强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

HTTP 协议详解教程 / 第 18 章:API 设计最佳实践

第 18 章:API 设计最佳实践

良好的 API 设计是后端服务成功的关键。本章总结 HTTP API 设计的核心原则和最佳实践。


18.1 RESTful 设计原则

REST 核心约束

约束说明
客户端-服务器关注点分离
无状态每个请求包含所有必要信息
可缓存响应明确标识是否可缓存
统一接口标准化的资源操作方式
分层系统客户端无需知道是否直连服务器
按需代码(可选)服务器可返回可执行代码

资源命名规范

# ✅ 正确:使用名词复数
GET    /api/v1/users
GET    /api/v1/users/123
POST   /api/v1/users
PUT    /api/v1/users/123
DELETE /api/v1/users/123

# ✅ 正确:使用嵌套表示关系
GET    /api/v1/users/123/orders
GET    /api/v1/users/123/orders/456

# ❌ 错误:使用动词
GET    /api/v1/getUsers
POST   /api/v1/createUser
POST   /api/v1/deleteUser/123

# ✅ 特殊情况:操作无法映射到 CRUD
POST   /api/v1/orders/123/cancel
POST   /api/v1/users/123/activate
POST   /api/v1/reports/generate

HTTP 方法映射

操作HTTP 方法URL 模式状态码
获取列表GET/users200
获取单个GET/users/{id}200 / 404
创建POST/users201
完整替换PUT/users/{id}200 / 201
部分更新PATCH/users/{id}200
删除DELETE/users/{id}204

18.2 版本控制

版本策略对比

方案示例优点缺点
URL 路径/api/v1/users简单直观URL 不够"纯粹"
查询参数/api/users?version=1可选不够清晰
请求头Accept: application/vnd.api.v1+jsonURL 纯粹调试不便
自定义头X-API-Version: 1灵活不标准

推荐:URL 路径版本

# URL 路径版本(最常用)
GET /api/v1/users
GET /api/v2/users

# Nginx 路由
location /api/v1/ {
    proxy_pass http://backend-v1;
}

location /api/v2/ {
    proxy_pass http://backend-v2;
}

版本迁移策略

// 版本兼容层
app.get('/api/v1/users', (req, res) => {
    const users = getUsersFromDB();
    
    // v1 格式
    res.json({
        users: users.map(u => ({
            id: u.id,
            name: u.name,
            email: u.email
        }))
    });
});

app.get('/api/v2/users', (req, res) => {
    const users = getUsersFromDB();
    
    // v2 格式(新增字段)
    res.json({
        data: users.map(u => ({
            id: u.id,
            full_name: u.name,  // 字段重命名
            email: u.email,
            avatar_url: u.avatar,  // 新增字段
            created_at: u.createdAt
        })),
        meta: {
            total: users.length,
            page: 1,
            per_page: 20
        }
    });
});

18.3 分页设计

分页方案

# 方案 1:偏移量分页(最常用)
GET /api/v1/users?page=2&limit=20
GET /api/v1/users?offset=20&limit=20

# 方案 2:游标分页(大数据集推荐)
GET /api/v1/users?cursor=eyJpZCI6MTAwfQ&limit=20

# 方案 3:页码分页
GET /api/v1/users?page=2&per_page=20

分页响应格式

{
    "data": [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"}
    ],
    "pagination": {
        "total": 150,
        "page": 2,
        "per_page": 20,
        "total_pages": 8,
        "has_next": true,
        "has_prev": true
    },
    "links": {
        "self": "/api/v1/users?page=2&limit=20",
        "first": "/api/v1/users?page=1&limit=20",
        "prev": "/api/v1/users?page=1&limit=20",
        "next": "/api/v1/users?page=3&limit=20",
        "last": "/api/v1/users?page=8&limit=20"
    }
}

游标分页实现

// 游标分页(适合大数据集、实时数据)
app.get('/api/v1/users', async (req, res) => {
    const { cursor, limit = 20 } = req.query;
    const parsedLimit = Math.min(parseInt(limit), 100);
    
    let query = { deletedAt: null };
    
    if (cursor) {
        const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
        query.id = { $gt: decoded.id };
    }
    
    const users = await db.users.find(query)
        .sort({ id: 1 })
        .limit(parsedLimit + 1);  // 多查一条判断是否有下一页
    
    const hasMore = users.length > parsedLimit;
    const data = hasMore ? users.slice(0, parsedLimit) : users;
    const lastItem = data[data.length - 1];
    
    const nextCursor = hasMore
        ? Buffer.from(JSON.stringify({ id: lastItem.id })).toString('base64')
        : null;
    
    res.json({
        data,
        cursor: nextCursor,
        has_more: hasMore
    });
});

18.4 错误处理

标准错误响应格式

{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "请求参数验证失败",
        "details": [
            {
                "field": "email",
                "code": "INVALID_FORMAT",
                "message": "邮箱格式不正确"
            },
            {
                "field": "age",
                "code": "OUT_OF_RANGE",
                "message": "年龄必须在 0-150 之间",
                "min": 0,
                "max": 150
            }
        ],
        "request_id": "req-550e8400",
        "documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
    }
}

错误码设计

// 错误码枚举
const ErrorCodes = {
    // 通用错误
    INTERNAL_ERROR: 'INTERNAL_ERROR',
    NOT_FOUND: 'NOT_FOUND',
    METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
    
    // 认证授权
    UNAUTHORIZED: 'UNAUTHORIZED',
    FORBIDDEN: 'FORBIDDEN',
    TOKEN_EXPIRED: 'TOKEN_EXPIRED',
    INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
    
    // 参数验证
    VALIDATION_ERROR: 'VALIDATION_ERROR',
    MISSING_PARAMETER: 'MISSING_PARAMETER',
    INVALID_PARAMETER: 'INVALID_PARAMETER',
    
    // 业务逻辑
    USER_NOT_FOUND: 'USER_NOT_FOUND',
    EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS',
    ORDER_ALREADY_PAID: 'ORDER_ALREADY_PAID',
    INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
    
    // 限流
    RATE_LIMITED: 'RATE_LIMITED',
    QUOTA_EXCEEDED: 'QUOTA_EXCEEDED'
};

统一错误处理中间件

class AppError extends Error {
    constructor(statusCode, code, message, details = null) {
        super(message);
        this.statusCode = statusCode;
        this.code = code;
        this.details = details;
    }
    
    static badRequest(code, message, details) {
        return new AppError(400, code, message, details);
    }
    
    static unauthorized(message = '请先登录') {
        return new AppError(401, 'UNAUTHORIZED', message);
    }
    
    static forbidden(message = '权限不足') {
        return new AppError(403, 'FORBIDDEN', message);
    }
    
    static notFound(message = '资源不存在') {
        return new AppError(404, 'NOT_FOUND', message);
    }
    
    static conflict(code, message) {
        return new AppError(409, code, message);
    }
    
    static tooManyRequests(retryAfter) {
        return new AppError(429, 'RATE_LIMITED', '请求过于频繁', { retry_after: retryAfter });
    }
}

// 全局错误处理中间件
app.use((err, req, res, next) => {
    const statusCode = err.statusCode || 500;
    const isProduction = process.env.NODE_ENV === 'production';
    
    const response = {
        error: {
            code: err.code || 'INTERNAL_ERROR',
            message: statusCode === 500 && isProduction
                ? '服务器内部错误'
                : err.message,
            request_id: req.headers['x-request-id']
        }
    };
    
    if (err.details) {
        response.error.details = err.details;
    }
    
    if (statusCode === 500) {
        console.error('Internal Error:', err);
    }
    
    res.status(statusCode).json(response);
});

// 使用
app.get('/api/users/:id', async (req, res, next) => {
    try {
        const user = await db.users.findById(req.params.id);
        if (!user) {
            throw AppError.notFound('用户不存在');
        }
        res.json({ data: user });
    } catch (err) {
        next(err);
    }
});

18.5 限流(Rate Limiting)

限流策略

策略说明适用场景
固定窗口每分钟 N 次简单限流
滑动窗口滑动时间窗口更平滑的限流
令牌桶恒定速率补充令牌允许突发流量
漏桶恒定速率处理请求平滑输出

限流响应

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1715328000
Content-Type: application/json

{
    "error": {
        "code": "RATE_LIMITED",
        "message": "请求过于频繁,请 60 秒后重试",
        "retry_after": 60
    }
}

Redis 限流实现

const Redis = require('ioredis');
const redis = new Redis();

// 滑动窗口限流
async function rateLimit(key, limit, windowSeconds) {
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;
    
    // 移除窗口外的请求记录
    await redis.zremrangebyscore(key, 0, windowStart);
    
    // 获取当前窗口内的请求数
    const count = await redis.zcard(key);
    
    if (count >= limit) {
        // 计算重置时间
        const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
        const resetTime = Math.ceil((parseInt(oldest[1]) + windowSeconds * 1000 - now) / 1000);
        
        return {
            allowed: false,
            remaining: 0,
            resetIn: resetTime
        };
    }
    
    // 记录本次请求
    await redis.zadd(key, now, `${now}-${Math.random()}`);
    await redis.expire(key, windowSeconds);
    
    return {
        allowed: true,
        remaining: limit - count - 1,
        resetIn: windowSeconds
    };
}

// 限流中间件
function rateLimitMiddleware(limit, windowSeconds) {
    return async (req, res, next) => {
        const key = `ratelimit:${req.ip}:${req.path}`;
        const result = await rateLimit(key, limit, windowSeconds);
        
        res.set('X-RateLimit-Limit', limit);
        res.set('X-RateLimit-Remaining', result.remaining);
        
        if (!result.allowed) {
            res.set('Retry-After', result.resetIn);
            return res.status(429).json({
                error: {
                    code: 'RATE_LIMITED',
                    message: `请求过于频繁,请 ${result.resetIn} 秒后重试`,
                    retry_after: result.resetIn
                }
            });
        }
        
        next();
    };
}

// 使用
app.use('/api/', rateLimitMiddleware(100, 60));  // 每分钟 100 次
app.use('/api/auth/', rateLimitMiddleware(10, 60)); // 登录接口更严格

18.6 幂等性设计

为什么需要幂等性

POST /api/orders
客户端发送请求 → 网络超时 → 客户端不知道是否成功
客户端重试请求 → 可能创建重复订单!

解决:使用幂等键(Idempotency Key)

幂等性实现

const crypto = require('crypto');

// 幂等性中间件
function idempotent(ttlSeconds = 86400) {
    return async (req, res, next) => {
        const idempotencyKey = req.headers['idempotency-key'];
        
        if (!idempotencyKey) {
            return res.status(400).json({
                error: { code: 'MISSING_IDEMPOTENCY_KEY', message: '请提供 Idempotency-Key 头' }
            });
        }
        
        // 检查是否已处理过
        const cached = await redis.get(`idempotent:${idempotencyKey}`);
        if (cached) {
            const { statusCode, body } = JSON.parse(cached);
            return res.status(statusCode).json(body);
        }
        
        // 拦截响应,缓存结果
        const originalJson = res.json.bind(res);
        res.json = function(body) {
            redis.setex(
                `idempotent:${idempotencyKey}`,
                ttlSeconds,
                JSON.stringify({ statusCode: res.statusCode, body })
            );
            return originalJson(body);
        };
        
        next();
    };
}

// 使用
app.post('/api/orders', idempotent(86400), async (req, res) => {
    const order = await createOrder(req.body);
    res.status(201).json({ data: order });
});

客户端使用

import { v4 as uuidv4 } from 'uuid';

// 每次请求生成唯一的幂等键
const idempotencyKey = uuidv4();

const response = await fetch('/api/orders', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey
    },
    body: JSON.stringify({ product_id: 42, quantity: 1 })
});

// 超时后重试,使用相同的幂等键
// 服务器会返回缓存的结果,不会创建重复订单

18.7 API 文档

OpenAPI 规范(Swagger)

openapi: 3.0.3
info:
  title: 用户管理 API
  version: 1.0.0
  description: 用户管理服务 API 文档

paths:
  /api/v1/users:
    get:
      summary: 获取用户列表
      tags: [用户]
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
    
    post:
      summary: 创建用户
      tags: [用户]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name:
                  type: string
                  example: "Alice"
                email:
                  type: string
                  format: email
                  example: "alice@example.com"
      responses:
        '201':
          description: 创建成功
        '400':
          description: 参数错误
        '409':
          description: 邮箱已存在

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
        created_at:
          type: string
          format: date-time
    
    Pagination:
      type: object
      properties:
        total:
          type: integer
        page:
          type: integer
        per_page:
          type: integer

18.8 HATEOAS

{
    "data": {
        "id": 123,
        "name": "Alice",
        "email": "alice@example.com"
    },
    "links": {
        "self": "/api/v1/users/123",
        "orders": "/api/v1/users/123/orders",
        "update": {
            "href": "/api/v1/users/123",
            "method": "PATCH"
        },
        "delete": {
            "href": "/api/v1/users/123",
            "method": "DELETE"
        }
    }
}

18.9 业务场景:完整 API 设计示例

// 电商 API 设计
const express = require('express');
const app = express();

// 商品
GET    /api/v1/products              // 商品列表(带分页、过滤、排序)
GET    /api/v1/products/:id          // 商品详情
POST   /api/v1/products              // 创建商品(管理员)
PATCH  /api/v1/products/:id          // 更新商品
DELETE /api/v1/products/:id          // 删除商品

// 订单
GET    /api/v1/orders                // 我的订单列表
GET    /api/v1/orders/:id            // 订单详情
POST   /api/v1/orders                // 创建订单(幂等)
POST   /api/v1/orders/:id/cancel     // 取消订单
POST   /api/v1/orders/:id/pay        // 支付订单(幂等)

// 用户
GET    /api/v1/users/me              // 当前用户信息
PATCH  /api/v1/users/me              // 更新个人信息
POST   /api/v1/auth/login            // 登录
POST   /api/v1/auth/logout           // 退出
POST   /api/v1/auth/refresh          // 刷新 Token

⚠️ 注意事项

  1. 始终使用 HTTPS:API 必须使用加密连接
  2. 正确的状态码:使用语义正确的 HTTP 状态码
  3. 版本控制:API 必须有版本管理策略
  4. 错误信息:提供有用的错误信息和错误码
  5. 限流:所有 API 都应该有限流保护
  6. 幂等性:写操作应实现幂等性
  7. 分页:列表接口必须支持分页
  8. 文档:使用 OpenAPI 规范编写文档

🔗 扩展阅读


完结:恭喜你完成了 HTTP 协议详解教程的全部 18 章!回顾 _index.md 查看完整目录。