📖 第04课:请求与响应

阶段一:RESTful基础

API的请求与响应是客户端和服务端之间的契约。设计良好的请求格式让调用者直觉正确,设计良好的响应格式让数据处理变得简单。本课深入请求体验证、响应结构设计、内容协商、数据序列化等核心主题。

📨 请求设计

请求体格式

Content-Type选择指南

Content-Type适用场景示例
application/json结构化数据(默认推荐)创建/更新资源
multipart/form-data文件上传 + 表单混合上传头像+用户信息
application/x-www-form-urlencoded简单表单(无文件)登录表单
text/plain纯文本数据配置文件内容
application/octet-stream二进制文件流直接上传文件

请求验证模式

// validation.js - 请求验证中间件
class Validator {
  constructor(schema) {
    this.schema = schema;
  }

  validate(data) {
    const errors = [];

    for (const [field, rules] of Object.entries(this.schema)) {
      const value = data[field];

      // 必填检查
      if (rules.required && (value === undefined || value === null || value === '')) {
        errors.push({ field, message: `${field} 为必填项` });
        continue;
      }

      // 可选字段且未提供则跳过后续检查
      if (value === undefined || value === null) continue;

      // 类型检查
      if (rules.type) {
        const actualType = Array.isArray(value) ? 'array' : typeof value;
        if (actualType !== rules.type) {
          errors.push({ field, message: `${field} 应为 ${rules.type} 类型,实际为 ${actualType}` });
          continue;
        }
      }

      // 字符串长度
      if (rules.type === 'string' || typeof value === 'string') {
        if (rules.minLength && value.length < rules.minLength) {
          errors.push({ field, message: `${field} 最少 ${rules.minLength} 个字符` });
        }
        if (rules.maxLength && value.length > rules.maxLength) {
          errors.push({ field, message: `${field} 最多 ${rules.maxLength} 个字符` });
        }
        if (rules.pattern && !rules.pattern.test(value)) {
          errors.push({ field, message: `${field} 格式不正确` });
        }
      }

      // 数值范围
      if (typeof value === 'number') {
        if (rules.min !== undefined && value < rules.min) {
          errors.push({ field, message: `${field} 不能小于 ${rules.min}` });
        }
        if (rules.max !== undefined && value > rules.max) {
          errors.push({ field, message: `${field} 不能大于 ${rules.max}` });
        }
      }

      // 枚举值
      if (rules.enum && !rules.enum.includes(value)) {
        errors.push({ field, message: `${field} 必须为 ${rules.enum.join('/')} 之一` });
      }

      // 自定义验证
      if (rules.custom) {
        const customError = rules.custom(value);
        if (customError) errors.push({ field, message: customError });
      }
    }

    return errors;
  }
}

// 使用示例:用户注册验证
const userSchema = {
  username: {
    required: true,
    type: 'string',
    minLength: 3,
    maxLength: 20,
    pattern: /^[a-zA-Z0-9_]+$/,
  },
  email: {
    required: true,
    type: 'string',
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  },
  password: {
    required: true,
    type: 'string',
    minLength: 8,
    custom: (val) => {
      if (!/[A-Z]/.test(val)) return '密码必须包含至少一个大写字母';
      if (!/[a-z]/.test(val)) return '密码必须包含至少一个小写字母';
      if (!/[0-9]/.test(val)) return '密码必须包含至少一个数字';
      return null;
    },
  },
  role: {
    required: false,
    type: 'string',
    enum: ['admin', 'editor', 'viewer'],
  },
  age: {
    required: false,
    type: 'number',
    min: 0,
    max: 150,
  },
};

// Express中间件
function validate(schema) {
  const validator = new Validator(schema);
  return (req, res, next) => {
    const errors = validator.validate(req.body);
    if (errors.length > 0) {
      return res.status(400).json({
        success: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: '请求数据验证失败',
          details: errors,
        },
      });
    }
    next();
  };
}

module.exports = { Validator, validate, userSchema };

📬 响应设计

响应结构标准化

// response-builder.js - 统一响应构建器

class ResponseBuilder {
  // 成功响应 - 单个资源
  static resource(data, options = {}) {
    const response = {
      success: true,
      data,
    };

    // 可选:包含关联资源
    if (options.included) {
      response.included = options.included;
    }

    // 可选:包含元数据
    if (options.meta) {
      response.meta = options.meta;
    }

    // 可选:HATEOAS链接
    if (options.links) {
      response.links = options.links;
    }

    response.timestamp = new Date().toISOString();
    return response;
  }

  // 成功响应 - 集合
  static collection(items, pagination, options = {}) {
    const response = {
      success: true,
      data: items,
      pagination: {
        page: pagination.page,
        limit: pagination.limit,
        total: pagination.total,
        totalPages: Math.ceil(pagination.total / pagination.limit),
        hasNext: pagination.page * pagination.limit < pagination.total,
        hasPrev: pagination.page > 1,
      },
    };

    if (options.links) {
      response.links = options.links;
    }

    response.timestamp = new Date().toISOString();
    return response;
  }

  // 创建成功
  static created(data, location) {
    const response = this.resource(data);
    response.meta = {
      ...response.meta,
      location,
      createdAt: data.createdAt || new Date().toISOString(),
    };
    return response;
  }

  // 删除成功
  static deleted(id) {
    return {
      success: true,
      data: null,
      meta: {
        deletedId: id,
        timestamp: new Date().toISOString(),
      },
    };
  }

  // 错误响应
  static error(statusCode, code, message, options = {}) {
    const response = {
      success: false,
      error: {
        code,
        message,
      },
    };

    if (options.details) {
      response.error.details = options.details;
    }

    if (options.stack && process.env.NODE_ENV === 'development') {
      response.error.stack = options.stack;
    }

    response.timestamp = new Date().toISOString();
    response.meta = {
      requestId: options.requestId,
      documentation: options.docsUrl,
    };

    return response;
  }
}

module.exports = ResponseBuilder;

HATEOAS示例

// Level 3 REST: 响应中包含操作链接
{
  "success": true,
  "data": {
    "id": 42,
    "title": "RESTful API设计",
    "status": "draft",
    "author": {"id": 1, "name": "张三"}
  },
  "links": {
    "self": "/api/articles/42",
    "author": "/api/users/1",
    "publish": {
      "href": "/api/articles/42/publish",
      "method": "POST"
    },
    "edit": {
      "href": "/api/articles/42",
      "method": "PATCH"
    },
    "delete": {
      "href": "/api/articles/42",
      "method": "DELETE"
    },
    "comments": "/api/articles/42/comments"
  }
}

🛠️ 实战:完整请求响应API

// full-api.js - 带完整验证和响应的API
const express = require('express');
const { validate, userSchema } = require('./validation');
const ResponseBuilder = require('./response-builder');
const { v4: uuidv4 } = require('crypto');

const app = express();
app.use(express.json());

// 请求ID中间件
app.use((req, res, next) => {
  req.requestId = req.headers['x-request-id'] || uuidv4();
  res.setHeader('X-Request-ID', req.requestId);
  next();
});

// 数据存储
const users = new Map();
const posts = new Map();
let userIdSeq = 1;
let postIdSeq = 1;

// 初始化数据
users.set(1, { id: 1, username: 'zhangsan', email: 'zhang@example.com', role: 'admin', createdAt: '2025-01-01T00:00:00Z' });
users.set(2, { id: 2, username: 'lisi', email: 'li@example.com', role: 'editor', createdAt: '2025-01-02T00:00:00Z' });

posts.set(1, { id: 1, title: '第一篇文章', content: '内容...', authorId: 1, status: 'published', createdAt: '2025-01-10T00:00:00Z' });
posts.set(2, { id: 2, title: '第二篇文章', content: '内容...', authorId: 2, status: 'draft', createdAt: '2025-01-11T00:00:00Z' });

// ===== 用户接口 =====

// 注册
app.post('/api/users', validate(userSchema), (req, res) => {
  const { username, email, role, age } = req.body;

  // 业务验证
  if ([...users.values()].some(u => u.username === username)) {
    return res.status(409).json(
      ResponseBuilder.error(409, 'CONFLICT', `用户名 ${username} 已存在`, { requestId: req.requestId })
    );
  }
  if ([...users.values()].some(u => u.email === email)) {
    return res.status(409).json(
      ResponseBuilder.error(409, 'CONFLICT', `邮箱 ${email} 已注册`, { requestId: req.requestId })
    );
  }

  const id = userIdSeq++;
  const user = {
    id,
    username,
    email,
    role: role || 'viewer',
    age,
    createdAt: new Date().toISOString(),
  };
  users.set(id, user);

  res.setHeader('Location', `/api/users/${id}`);
  res.status(201).json(ResponseBuilder.created(user, `/api/users/${id}`));
});

// 用户列表
app.get('/api/users', (req, res) => {
  const { page = 1, limit = 20, role, fields } = req.query;

  let result = [...users.values()];

  // 过滤
  if (role) result = result.filter(u => u.role === role);

  // 字段选择
  if (fields) {
    const fieldList = fields.split(',');
    result = result.map(u => {
      const filtered = {};
      fieldList.forEach(f => { if (u[f] !== undefined) filtered[f] = u[f]; });
      return filtered;
    });
  }

  // 分页
  const p = Math.max(1, parseInt(page));
  const l = Math.min(100, Math.max(1, parseInt(limit)));
  const total = result.length;
  result = result.slice((p - 1) * l, p * l);

  res.json(ResponseBuilder.collection(result, { page: p, limit: l, total }));
});

// 用户详情
app.get('/api/users/:id', (req, res) => {
  const user = users.get(parseInt(req.params.id));
  if (!user) {
    return res.status(404).json(
      ResponseBuilder.error(404, 'NOT_FOUND', `用户 ${req.params.id} 不存在`, {
        requestId: req.requestId,
        docsUrl: 'https://docs.example.com/api/errors#not-found',
      })
    );
  }

  // 包含用户的文章
  const userPosts = [...posts.values()].filter(p => p.authorId === user.id);
  res.json(ResponseBuilder.resource(user, {
    included: { posts: userPosts },
    links: {
      self: `/api/users/${user.id}`,
      posts: `/api/users/${user.id}/posts`,
    },
  }));
});

// ===== 内容协商 =====

// 根据Accept头返回不同格式
app.get('/api/users/:id/profile', (req, res) => {
  const user = users.get(parseInt(req.params.id));
  if (!user) return res.status(404).json(ResponseBuilder.error(404, 'NOT_FOUND', '用户不存在'));

  const accept = req.headers.accept || '';

  if (accept.includes('text/html')) {
    // HTML格式
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(`<h1>${user.username}</h1><p>Email: ${user.email}</p><p>Role: ${user.role}</p>`);
  } else if (accept.includes('text/csv')) {
    // CSV格式
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.send(`id,username,email,role\n${user.id},${user.username},${user.email},${user.role}`);
  } else if (accept.includes('text/plain')) {
    // 纯文本
    res.setHeader('Content-Type', 'text/plain; charset=utf-8');
    res.send(`用户: ${user.username}\n邮箱: ${user.email}\n角色: ${user.role}`);
  } else {
    // 默认JSON
    res.json(ResponseBuilder.resource(user));
  }
});

// 全局错误处理
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json(
    ResponseBuilder.error(500, 'INTERNAL_ERROR', '服务器内部错误', {
      requestId: req.requestId,
      stack: err.stack,
    })
  );
});

app.listen(3000, () => console.log('🚀 API运行在 http://localhost:3000'));

测试请求验证和响应格式

# 测试1: 请求验证 - 缺少必填字段
curl -s -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"username":"ab"}' | jq .
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求数据验证失败",
    "details": [
      {"field":"username","message":"username 最少 3 个字符"},
      {"field":"email","message":"email 为必填项"},
      {"field":"password","message":"password 为必填项"}
    ]
  },
  "timestamp":"2025-01-15T10:00:00Z",
  "meta": {"requestId":"..."}
}
# 测试2: 成功创建
curl -s -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"username":"wangwu","email":"wang@example.com","password":"Pass1234","role":"editor"}' | jq .

# 测试3: 重复用户名
curl -s -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"username":"wangwu","email":"other@example.com","password":"Pass1234"}' | jq .

# 测试4: 字段选择
curl -s "http://localhost:3000/api/users?fields=id,username" | jq .

# 测试5: 内容协商 - 请求CSV格式
curl -s http://localhost:3000/api/users/1/profile -H "Accept: text/csv"

# 测试6: 内容协商 - 请求HTML格式
curl -s http://localhost:3000/api/users/1/profile -H "Accept: text/html"

🔄 内容协商(Content Negotiation)

三种内容协商方式

方式机制优点缺点
服务端驱动客户端发Accept头,服务端选择格式HTTP标准,单一URL服务端需维护多种格式
客户端驱动URL后缀或查询参数简单直观,易测试多URL指向同一资源
透明协商中间缓存代理选择减少服务端负载实现复杂,较少使用

常用的Accept头值

# JSON(最常用)
Accept: application/json

# JSON API规范
Accept: application/vnd.api+json

# 自定义媒体类型(带版本)
Accept: application/vnd.myapi.v2+json

# 任意格式
Accept: */*

📝 本课小结

  1. 请求验证是API安全的第一道防线——类型、长度、格式、业务规则层层把关
  2. 响应结构必须统一:success标志 + data/error + meta + timestamp
  3. 集合响应包含分页信息:page, limit, total, hasNext, hasPrev
  4. HATEOAS在响应中包含操作链接,实现Level 3 REST
  5. 内容协商让同一资源支持多种表示格式
  6. 请求ID(requestId)贯穿请求生命周期,便于日志追踪
  7. 字段选择(Sparse Fieldsets)减少不必要的数据传输

💪 练习

练习1:设计书籍管理API的请求验证

为以下字段设计验证规则:title(1-200字), isbn(13位数字), price(>0), category(枚举), tags(数组,每项1-20字)

练习2:实现内容协商

GET /api/products支持JSON和CSV两种输出格式,通过Accept头切换。

练习3:设计错误响应的层次

区分以下三种错误的响应格式:验证错误(400)、业务逻辑错误(422)、系统错误(500)。

// 400 验证错误
{"success":false,"error":{"code":"VALIDATION_ERROR","details":[{"field":"email","message":"格式不正确"}]}}

// 422 业务逻辑错误
{"success":false,"error":{"code":"INSUFFICIENT_STOCK","message":"库存不足","available":5,"requested":10}}

// 500 系统错误
{"success":false,"error":{"code":"INTERNAL_ERROR","message":"服务器内部错误","requestId":"abc123"}}

🏆 本课成就:请求响应专家