API设计是软件架构中最难改变的部分之一。一旦API发布并被使用,任何修改都可能破坏现有客户端。更糟糕的是,作为独立开发者,你可能同时是API的设计者和使用者,很容易陷入"现在能用就行"的短视思维。但三个月后当你需要添加新功能时,会发现当初的设计处处掣肘。如何在初期就设计出既满足当前需求、又能优雅扩展的API?

API设计的核心原则

在深入具体技术之前,先要建立几个基本原则。这些原则看似简单,但能避免90%的设计问题。

原则1:以资源为中心,而非动作

RESTful设计的核心思想是把API看作资源的集合,而不是远程过程调用(RPC)。

// ❌ 不好:以动作为中心
POST /api/createUser
POST /api/deleteUser
POST /api/updateUserEmail

// ✅ 好:以资源为中心
POST   /api/users          // 创建用户
DELETE /api/users/:id      // 删除用户
PATCH  /api/users/:id      // 更新用户

资源导向的设计更容易理解、更容易扩展。当你需要添加新操作时,只需要考虑"这个操作对资源做了什么",而不是"我该起什么函数名"。

原则2:使用正确的HTTP方法和状态码

HTTP协议已经定义了丰富的语义,不要重新发明轮子。

// HTTP方法的正确使用
GET    /api/users          // 获取列表(幂等、安全)
GET    /api/users/:id      // 获取单个(幂等、安全)
POST   /api/users          // 创建(非幂等)
PUT    /api/users/:id      // 完整替换(幂等)
PATCH  /api/users/:id      // 部分更新(非幂等)
DELETE /api/users/:id      // 删除(幂等)

// 状态码的正确使用
200 OK                     // 成功
201 Created                // 创建成功
204 No Content             // 成功但无返回内容(如删除)
400 Bad Request            // 客户端错误(参数错误)
401 Unauthorized           // 未认证
403 Forbidden              // 已认证但无权限
404 Not Found              // 资源不存在
409 Conflict               // 冲突(如邮箱已存在)
422 Unprocessable Entity   // 验证失败
500 Internal Server Error  // 服务器错误

原则3:保持URL结构清晰和一致

URL应该是自解释的,遵循一致的命名规范。

// ✅ 好的URL设计
/api/users                    // 用户列表
/api/users/:id                // 单个用户
/api/users/:id/posts          // 用户的文章
/api/users/:id/posts/:postId  // 用户的特定文章
/api/posts                    // 所有文章
/api/posts/:id/comments       // 文章的评论

// 命名规范
- 使用复数形式(users而非user)
- 使用小写字母
- 用连字符分隔多个单词(user-profiles而非userProfiles)
- 避免深层嵌套(最多3层)

原则4:版本化从第一天开始

即使你现在只有一个版本,也应该在URL或Header中包含版本号。

// 方式1:URL版本化(推荐,简单直观)
/api/v1/users
/api/v2/users

// 方式2:Header版本化(更RESTful,但客户端实现复杂)
GET /api/users
Accept: application/vnd.myapp.v1+json

// 方式3:查询参数版本化(不推荐,容易被忽略)
/api/users?version=1

对于独立开发者,URL版本化是最实用的选择。当你需要做破坏性改动时,创建v2版本,同时保持v1继续运行一段时间,给客户端迁移的缓冲期。

请求和响应的标准化设计

统一的响应格式

所有API应该返回一致的数据结构,即使是错误响应。

// 成功响应
interface SuccessResponse<T> {
  success: true;
  data: T;
  meta?: {
    timestamp: string;
    requestId: string;
  };
}

// 错误响应
interface ErrorResponse {
  success: false;
  error: {
    code: string;        // 机器可读的错误码
    message: string;     // 人类可读的错误信息
    details?: any;       // 详细错误信息(如验证错误)
  };
  meta?: {
    timestamp: string;
    requestId: string;
  };
}

// 实际例子
// GET /api/users/123 成功
{
  "success": true,
  "data": {
    "id": "123",
    "name": "John Doe",
    "email": "john@example.com"
  },
  "meta": {
    "timestamp": "2024-01-22T10:00:00Z",
    "requestId": "req_abc123"
  }
}

// POST /api/users 验证失败
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求数据验证失败",
    "details": {
      "email": ["邮箱格式不正确"],
      "password": ["密码至少8位"]
    }
  },
  "meta": {
    "timestamp": "2024-01-22T10:00:00Z",
    "requestId": "req_abc124"
  }
}

这种统一的格式让客户端可以用同样的方式处理所有响应,大大简化了错误处理逻辑。

分页的标准化

列表接口必然涉及分页,从一开始就设计好。

// 请求参数
GET /api/posts?page=2&pageSize=20&sort=-createdAt&filter[status]=published

// 响应格式
{
  "success": true,
  "data": [
    { "id": "1", "title": "文章1" },
    { "id": "2", "title": "文章2" }
  ],
  "pagination": {
    "page": 2,
    "pageSize": 20,
    "total": 156,        // 总记录数
    "totalPages": 8,     // 总页数
    "hasNext": true,     // 是否有下一页
    "hasPrev": true      // 是否有上一页
  },
  "links": {
    "first": "/api/posts?page=1&pageSize=20",
    "prev": "/api/posts?page=1&pageSize=20",
    "next": "/api/posts?page=3&pageSize=20",
    "last": "/api/posts?page=8&pageSize=20"
  }
}

**游标分页(Cursor-based Pagination)**适合实时数据流:

// 请求
GET /api/posts?cursor=eyJpZCI6MTIzfQ&limit=20

// 响应
{
  "success": true,
  "data": [...],
  "pagination": {
    "nextCursor": "eyJpZCI6MTQzfQ",  // 下一页的游标
    "hasMore": true
  }
}

游标分页的优势是:

  • 不会出现"第N页"问题(新数据插入时页码会偏移)
  • 性能更好(不需要COUNT查询)
  • 适合无限滚动

筛选和排序

提供灵活的查询能力,但要设置合理的限制。

// 筛选
GET /api/posts?filter[status]=published&filter[author]=123

// 排序(-表示降序)
GET /api/posts?sort=-createdAt,title

// 字段选择(减少数据传输)
GET /api/posts?fields=id,title,author

// 关联数据加载
GET /api/posts?include=author,comments

// 实现示例
interface QueryParams {
  filter?: Record<string, string>;
  sort?: string;
  fields?: string;
  include?: string;
  page?: number;
  pageSize?: number;
}

function buildQuery(params: QueryParams) {
  let query = db.posts.find();
  
  // 筛选
  if (params.filter) {
    query = query.where(params.filter);
  }
  
  // 排序
  if (params.sort) {
    const sortFields = params.sort.split(',').map(field => {
      if (field.startsWith('-')) {
        return { [field.slice(1)]: -1 };
      }
      return { [field]: 1 };
    });
    query = query.sort(Object.assign({}, ...sortFields));
  }
  
  // 字段选择
  if (params.fields) {
    query = query.select(params.fields.split(','));
  }
  
  // 分页
  const page = params.page || 1;
  const pageSize = Math.min(params.pageSize || 20, 100);  // 限制最大100
  query = query.skip((page - 1) * pageSize).limit(pageSize);
  
  return query;
}

处理关联数据的策略

关联数据是API设计中最复杂的部分。有几种常见策略:

策略1:嵌入(Embedding)

直接在响应中包含关联数据。

// GET /api/posts/123
{
  "id": "123",
  "title": "我的文章",
  "author": {
    "id": "456",
    "name": "John Doe",
    "email": "john@example.com"
  },
  "comments": [
    { "id": "789", "content": "很好的文章", "author": {...} }
  ]
}

优点:一次请求获取所有数据,减少网络往返
缺点:数据冗余,响应体积大,嵌套层级深时难以控制

策略2:引用(Referencing)

只返回ID,客户端需要时再请求。

// GET /api/posts/123
{
  "id": "123",
  "title": "我的文章",
  "authorId": "456",
  "commentIds": ["789", "790"]
}

// 客户端需要作者信息时
// GET /api/users/456

优点:响应简洁,避免冗余,缓存友好
缺点:需要多次请求,N+1查询问题

策略3:可选包含(Optional Include)

让客户端决定是否包含关联数据。

// 不包含关联数据
GET /api/posts/123

// 包含作者
GET /api/posts/123?include=author

// 包含作者和评论
GET /api/posts/123?include=author,comments

// 包含嵌套关联
GET /api/posts/123?include=author,comments.author

// 响应
{
  "id": "123",
  "title": "我的文章",
  "authorId": "456",
  "author": {  // 只有请求include=author时才有
    "id": "456",
    "name": "John Doe"
  },
  "commentIds": ["789"],
  "comments": [  // 只有请求include=comments时才有
    { "id": "789", "content": "很好" }
  ]
}

这是最灵活的方案,推荐用于复杂的数据关系。

策略4:GraphQL式的字段选择

更进一步,让客户端精确指定需要的字段。

// 只要标题和作者名字
GET /api/posts/123?fields=title,author.name

// 响应
{
  "title": "我的文章",
  "author": {
    "name": "John Doe"
  }
}

实现复杂度较高,但对于数据量大、网络条件差的场景很有价值。

扩展性设计:为未来留空间

1. 使用对象而非数组作为顶层结构

// ❌ 不好:顶层是数组,无法添加元数据
[
  { "id": "1", "name": "User 1" },
  { "id": "2", "name": "User 2" }
]

// ✅ 好:顶层是对象,可以添加分页、过滤等信息
{
  "data": [
    { "id": "1", "name": "User 1" },
    { "id": "2", "name": "User 2" }
  ],
  "pagination": {...},
  "filters": {...}
}

2. 使用对象而非原始类型

即使现在只需要返回一个字符串,也包装成对象。

// ❌ 不好:未来无法扩展
GET /api/users/123/avatar
Response: "https://cdn.example.com/avatar.jpg"

// ✅ 好:可以添加更多信息
GET /api/users/123/avatar
Response: {
  "url": "https://cdn.example.com/avatar.jpg",
  "size": 102400,
  "format": "jpeg",
  "uploadedAt": "2024-01-01T00:00:00Z"
}

3. 预留扩展字段

对于可能变化的枚举类型,使用字符串而非数字。

// ❌ 不好:数字枚举,添加新状态时容易冲突
{
  "status": 1  // 1=draft, 2=published, 3=archived
}

// ✅ 好:字符串枚举,语义清晰,易扩展
{
  "status": "published"  // draft | published | archived | scheduled
}

4. 时间戳字段的标准化

统一使用ISO 8601格式,包含时区信息。

// ✅ 好
{
  "createdAt": "2024-01-22T10:30:00Z",
  "updatedAt": "2024-01-22T15:45:00+08:00"
}

// ❌ 不好
{
  "createdAt": 1705915800,  // Unix时间戳,无时区信息
  "updatedAt": "2024-01-22 15:45:00"  // 无时区信息
}

5. 软删除和历史记录

对于重要数据,不要真正删除,而是标记为已删除。

// 用户表
{
  "id": "123",
  "name": "John Doe",
  "deletedAt": null  // 未删除
}

// 删除操作
DELETE /api/users/123
// 实际上是 UPDATE users SET deleted_at = NOW() WHERE id = 123

// 查询时默认过滤已删除的
GET /api/users  // 只返回 deletedAt IS NULL 的记录

// 管理员可以查看已删除的
GET /api/users?includeDeleted=true

错误处理的最佳实践

好的错误响应应该:

  1. 明确告诉客户端出了什么问题
  2. 提供足够的信息用于调试
  3. 对用户友好(可以直接展示给用户)
// 错误码设计
enum ErrorCode {
  // 客户端错误 (4xx)
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  UNAUTHORIZED = 'UNAUTHORIZED',
  FORBIDDEN = 'FORBIDDEN',
  NOT_FOUND = 'NOT_FOUND',
  CONFLICT = 'CONFLICT',
  RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
  
  // 服务器错误 (5xx)
  INTERNAL_ERROR = 'INTERNAL_ERROR',
  SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
  DATABASE_ERROR = 'DATABASE_ERROR',
}

// 错误响应示例
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求数据验证失败",
    "details": {
      "email": [
        "邮箱格式不正确",
        "该邮箱已被注册"
      ],
      "password": [
        "密码长度至少8位",
        "密码必须包含数字和字母"
      ]
    },
    "documentation": "https://docs.example.com/errors/validation"
  },
  "meta": {
    "requestId": "req_abc123",  // 用于追踪问题
    "timestamp": "2024-01-22T10:00:00Z"
  }
}

实现统一的错误处理中间件

// Express示例
class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message);
  }
}

// 错误处理中间件
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // 生成请求ID
  const requestId = req.headers['x-request-id'] || generateId();
  
  // 记录错误日志
  logger.error({
    requestId,
    error: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
  });
  
  // 返回错误响应
  if (err instanceof ApiError) {
    res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
      meta: {
        requestId,
        timestamp: new Date().toISOString(),
      },
    });
  } else {
    // 未知错误,不暴露细节
    res.status(500).json({
      success: false,
      error: {
        code: 'INTERNAL_ERROR',
        message: '服务器内部错误',
      },
      meta: {
        requestId,
        timestamp: new Date().toISOString(),
      },
    });
  }
});

// 使用示例
app.post('/api/users', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    
    // 验证
    const errors: Record<string, string[]> = {};
    if (!isValidEmail(email)) {
      errors.email = ['邮箱格式不正确'];
    }
    if (password.length < 8) {
      errors.password = ['密码长度至少8位'];
    }
    
    if (Object.keys(errors).length > 0) {
      throw new ApiError(400, 'VALIDATION_ERROR', '请求数据验证失败', errors);
    }
    
    // 检查邮箱是否已存在
    const existing = await db.users.findOne({ email });
    if (existing) {
      throw new ApiError(409, 'CONFLICT', '该邮箱已被注册');
    }
    
    // 创建用户
    const user = await db.users.create({ email, password });
    
    res.status(201).json({
      success: true,
      data: user,
    });
  } catch (error) {
    next(error);
  }
});

认证和授权的设计

认证(Authentication):验证用户身份

常见方案:

  1. JWT(JSON Web Token):无状态,适合分布式系统
  2. Session + Cookie:有状态,服务器需要存储会话
  3. OAuth 2.0:第三方登录(Google、GitHub等)

JWT示例:

// 登录接口
POST /api/auth/login
Request: {
  "email": "user@example.com",
  "password": "password123"
}

Response: {
  "success": true,
  "data": {
    "accessToken": "eyJhbGc...",  // 短期token(15分钟)
    "refreshToken": "eyJhbGc...", // 长期token(7天)
    "expiresIn": 900,              // 秒
    "tokenType": "Bearer"
  }
}

// 使用token访问受保护资源
GET /api/users/me
Headers: {
  "Authorization": "Bearer eyJhbGc..."
}

// 刷新token
POST /api/auth/refresh
Request: {
  "refreshToken": "eyJhbGc..."
}

Response: {
  "success": true,
  "data": {
    "accessToken": "eyJhbGc...",  // 新的access token
    "expiresIn": 900
  }
}

授权(Authorization):验证用户权限

基于角色的访问控制(RBAC):

// 用户角色
enum Role {
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
}

// 权限检查中间件
function requireRole(...roles: Role[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = req.user;  // 从JWT中解析出来的用户信息
    
    if (!user) {
      throw new ApiError(401, 'UNAUTHORIZED', '请先登录');
    }
    
    if (!roles.includes(user.role)) {
      throw new ApiError(403, 'FORBIDDEN', '权限不足');
    }
    
    next();
  };
}

// 使用
app.delete('/api/posts/:id', requireRole(Role.ADMIN, Role.EDITOR), async (req, res) => {
  // 只有admin和editor可以删除文章
});

API文档:不可忽视的一环

好的API文档能大幅降低使用门槛。推荐使用OpenAPI(Swagger)规范:

// 使用注释生成文档(swagger-jsdoc)
/**
 * @swagger
 * /api/users:
 *   post:
 *     summary: 创建新用户
 *     tags: [Users]
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required:
 *               - email
 *               - password
 *             properties:
 *               email:
 *                 type: string
 *                 format: email
 *               password:
 *                 type: string
 *                 minLength: 8
 *     responses:
 *       201:
 *         description: 用户创建成功
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 *       400:
 *         description: 请求参数错误
 */
app.post('/api/users', createUser);

或者使用类型安全的方案(如tRPC、Hono),自动生成类型和文档。

性能优化:从设计层面考虑

1. 使用ETag实现条件请求

GET /api/posts/123
Response Headers:
  ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

// 客户端再次请求时带上ETag
GET /api/posts/123
Request Headers:
  If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

// 如果内容未变化,返回304 Not Modified,节省带宽
Response: 304 Not Modified

2. 批量操作接口

避免客户端发起N次请求。

// ❌ 不好:删除多个用户需要多次请求
DELETE /api/users/1
DELETE /api/users/2
DELETE /api/users/3

// ✅ 好:一次请求批量删除
DELETE /api/users
Request: {
  "ids": ["1", "2", "3"]
}

Response: {
  "success": true,
  "data": {
    "deleted": 3,
    "failed": []
  }
}

3. 异步处理长时间任务

对于耗时操作,立即返回任务ID,客户端轮询或通过WebSocket获取结果。

// 提交导出任务
POST /api/exports
Request: {
  "type": "users",
  "filters": {...}
}

Response: {
  "success": true,
  "data": {
    "taskId": "task_abc123",
    "status": "pending",
    "statusUrl": "/api/exports/task_abc123"
  }
}

// 查询任务状态
GET /api/exports/task_abc123
Response: {
  "success": true,
  "data": {
    "taskId": "task_abc123",
    "status": "completed",  // pending | processing | completed | failed
    "progress": 100,
    "result": {
      "downloadUrl": "https://cdn.example.com/export.csv",
      "expiresAt": "2024-01-23T10:00:00Z"
    }
  }
}

最终建议:迭代式API设计

API设计不是一次性的工作,而是持续演进的过程:

  1. MVP阶段:保持简单,快速实现核心功能,但遵循基本规范(RESTful、统一响应格式)
  2. 迭代阶段:根据实际使用情况优化,添加分页、筛选、批量操作等
  3. 成熟阶段:考虑性能优化、版本管理、向后兼容

记住:好的API设计是平衡的艺术——既要满足当前需求,又要为未来留有余地;既要功能强大,又要保持简洁;既要灵活扩展,又要向后兼容。没有完美的设计,只有在特定场景下的最优解。


接下来我将回答问题8:项目什么时候应该从单体架构拆分成微服务?

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐