📖 第02课:HTTP协议与REST

阶段一:RESTful基础

REST(Representational State Transfer)是Roy Fielding在2000年博士论文中提出的架构风格,而HTTP协议是REST最常用的实现载体。理解HTTP协议的语义和REST的约束条件,是设计高质量API的根基。

🌐 HTTP协议深度解析

HTTP请求结构

一个HTTP请求由四部分组成:请求行、请求头、空行、请求体。

POST /api/users HTTP/1.1          ← 请求行:方法 + URL + 协议版本
Host: api.example.com             ← 请求头开始
Content-Type: application/json
Authorization: Bearer eyJhbGci...
Accept: application/json
X-Request-ID: abc123def456        ← 请求头结束
                                  ← 空行(CRLF)
{"name":"张三","email":"zhang@example.com"}  ← 请求体

HTTP请求头分类

类别常用头说明
内容协商Accept, Accept-Encoding, Accept-Language客户端期望的响应格式
内容描述Content-Type, Content-Length, Content-Encoding请求体的格式和长度
认证授权Authorization, WWW-Authenticate身份验证凭据
缓存控制Cache-Control, ETag, If-None-Match缓存策略与验证
条件请求If-Modified-Since, If-Match条件化请求执行
连接控制Connection, Keep-Alive连接管理
自定义头X-Request-ID, X-RateLimit-*应用层扩展

HTTP响应结构

HTTP/1.1 201 Created              ← 状态行:版本 + 状态码 + 原因短语
Content-Type: application/json     ← 响应头
Location: /api/users/42
X-Request-ID: abc123def456
X-RateLimit-Remaining: 99
                                  ← 空行
{"id":42,"name":"张三","email":"zhang@example.com"}  ← 响应体

📨 HTTP方法与语义

HTTP方法(动词)定义了对资源的操作语义。正确使用HTTP方法是RESTful设计的核心。

八大HTTP方法详解

方法语义幂等安全有请求体有响应体
GET获取资源表示
POST创建资源/触发处理
PUT全量替换资源可选
PATCH部分更新资源
DELETE删除资源可选可选
HEAD获取资源元数据
OPTIONS查询支持的方法
TRACE回显请求(调试用)
💡 幂等性是关键:GET、PUT、DELETE是幂等的——调用一次和多次效果相同。POST不是幂等的——多次调用可能创建多个资源。PATCH理论上不是幂等的,但实践中应尽量设计为幂等。幂等性让客户端可以安全重试,是分布式系统可靠性的基础。

PUT vs PATCH 的区别

// 原始资源
GET /api/users/1
{"id":1, "name":"张三", "email":"zhang@old.com", "role":"user"}

// PUT:全量替换(必须发送完整资源)
PUT /api/users/1
{"id":1, "name":"张三", "email":"zhang@new.com", "role":"user"}
// ✅ 结果:邮箱更新,其他字段不变

PUT /api/users/1
{"id":1, "name":"张三", "email":"zhang@new.com"}
// ⚠️ 结果:role字段被删除!PUT是替换不是合并

// PATCH:部分更新(只发送需要修改的字段)
PATCH /api/users/1
{"email":"zhang@new.com"}
// ✅ 结果:只有邮箱更新,其他字段不受影响

🏛️ REST架构的六大约束

REST不是协议而是架构风格,它定义了六条约束条件:

约束1:客户端-服务器(Client-Server)

关注点分离:客户端负责用户交互,服务器负责数据存储和处理。两者独立演进,通过统一接口通信。

// 客户端不需要知道数据如何存储
GET /api/products/42
// 客户端只关心返回的JSON,不关心后端是MySQL还是MongoDB

// 服务器不需要知道客户端如何展示
// 同一个API可以被Web、iOS、Android、CLI共同使用

约束2:无状态(Stateless)

每个请求必须包含所有必要信息,服务器不保存客户端会话状态。这是可伸缩性的基础。

// ❌ 有状态:服务器记住用户会话
// 请求1: POST /api/login  → 服务器创建session
// 请求2: GET /api/profile → 服务器靠session识别用户

// ✅ 无状态:每个请求自带认证信息
GET /api/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
// 服务器从token中识别用户,无需session

约束3:缓存(Cacheable)

响应必须明确标识是否可缓存,避免客户端重复请求不变的数据。

// 服务器响应中包含缓存信息
HTTP/1.1 200 OK
Cache-Control: max-age=3600         // 缓存1小时
ETag: "abc123"                       // 资源版本标识
Last-Modified: Wed, 15 Jan 2025 08:00:00 GMT

// 客户端后续请求可使用条件请求
GET /api/products
If-None-Match: "abc123"
// 如果资源未变化,服务器返回304 Not Modified(无响应体)

约束4:统一接口(Uniform Interface)

这是REST最核心的约束,包含四个子约束:

约束5:分层系统(Layered System)

客户端不需要知道是直接连接到终端服务器还是中间代理。允许插入负载均衡器、缓存代理等中间层。

约束6:按需代码(Code on Demand)—— 可选

服务器可以临时扩展客户端功能,例如返回JavaScript代码由客户端执行。这是唯一可选的约束。

🛠️ 实战:Express实现完整REST API

// rest-api.js - 完整RESTful API实现
const express = require('express');
const app = express();

app.use(express.json());

// ===== 中间件 =====

// 请求日志
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} → ${res.statusCode} (${duration}ms)`);
  });
  next();
});

// CORS 支持
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

// ===== 数据层 =====

const products = new Map();
let nextId = 1;

function initProducts() {
  const items = [
    { name: '机械键盘', price: 599, category: '外设', stock: 120 },
    { name: '无线鼠标', price: 199, category: '外设', stock: 250 },
    { name: '4K显示器', price: 2999, category: '显示器', stock: 45 },
    { name: 'USB-C扩展坞', price: 399, category: '配件', stock: 80 },
    { name: '降噪耳机', price: 1299, category: '音频', stock: 60 },
  ];
  items.forEach(item => {
    const id = nextId++;
    const product = {
      id,
      ...item,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    products.set(id, product);
  });
}
initProducts();

// ===== 响应工具 =====

function success(res, data, status = 200, meta = {}) {
  res.status(status).json({
    success: true,
    data,
    meta: { ...meta, timestamp: new Date().toISOString() },
  });
}

function error(res, status, code, message, details = []) {
  res.status(status).json({
    success: false,
    error: { code, message, details },
    meta: { timestamp: new Date().toISOString() },
  });
}

// ===== 路由 =====

// GET /api/products - 列表(支持过滤、排序)
app.get('/api/products', (req, res) => {
  let result = [...products.values()];

  // 过滤
  if (req.query.category) {
    result = result.filter(p => p.category === req.query.category);
  }
  if (req.query.minPrice) {
    result = result.filter(p => p.price >= Number(req.query.minPrice));
  }
  if (req.query.maxPrice) {
    result = result.filter(p => p.price <= Number(req.query.maxPrice));
  }
  if (req.query.inStock === 'true') {
    result = result.filter(p => p.stock > 0);
  }

  // 排序
  const sortBy = req.query.sortBy || 'id';
  const order = req.query.order === 'desc' ? -1 : 1;
  result.sort((a, b) => (a[sortBy] > b[sortBy] ? order : -order));

  // 分页
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
  const total = result.length;
  const offset = (page - 1) * limit;
  result = result.slice(offset, offset + limit);

  success(res, result, 200, {
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
  });
});

// GET /api/products/:id - 详情
app.get('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const product = products.get(id);
  if (!product) return error(res, 404, 'NOT_FOUND', `产品 ${id} 不存在`);
  success(res, product);
});

// POST /api/products - 创建
app.post('/api/products', (req, res) => {
  const { name, price, category, stock } = req.body;

  // 验证
  const errors = [];
  if (!name) errors.push({ field: 'name', message: '产品名称为必填项' });
  if (price === undefined) errors.push({ field: 'price', message: '价格为必填项' });
  else if (price < 0) errors.push({ field: 'price', message: '价格不能为负数' });
  if (stock !== undefined && stock < 0) errors.push({ field: 'stock', message: '库存不能为负数' });

  if (errors.length > 0) {
    return error(res, 400, 'VALIDATION_ERROR', '请求数据验证失败', errors);
  }

  const id = nextId++;
  const product = {
    id,
    name,
    price,
    category: category || '未分类',
    stock: stock || 0,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
  products.set(id, product);

  // 设置Location头(REST最佳实践)
  res.location(`/api/products/${id}`);
  success(res, product, 201);
});

// PUT /api/products/:id - 全量更新
app.put('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const existing = products.get(id);
  if (!existing) return error(res, 404, 'NOT_FOUND', `产品 ${id} 不存在`);

  const { name, price, category, stock } = req.body;
  if (!name || price === undefined) {
    return error(res, 400, 'VALIDATION_ERROR', 'PUT请求必须包含完整资源数据');
  }

  const updated = {
    id,
    name,
    price,
    category: category || '未分类',
    stock: stock || 0,
    createdAt: existing.createdAt,
    updatedAt: new Date().toISOString(),
  };
  products.set(id, updated);
  success(res, updated);
});

// PATCH /api/products/:id - 部分更新
app.patch('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const existing = products.get(id);
  if (!existing) return error(res, 404, 'NOT_FOUND', `产品 ${id} 不存在`);

  const allowed = ['name', 'price', 'category', 'stock'];
  const updates = {};
  for (const key of allowed) {
    if (req.body[key] !== undefined) updates[key] = req.body[key];
  }

  if (Object.keys(updates).length === 0) {
    return error(res, 400, 'VALIDATION_ERROR', '至少需要一个更新字段');
  }

  const updated = {
    ...existing,
    ...updates,
    updatedAt: new Date().toISOString(),
  };
  products.set(id, updated);
  success(res, updated);
});

// DELETE /api/products/:id - 删除
app.delete('/api/products/:id', (req, res) => {
  const id = parseInt(req.params.id);
  if (!products.has(id)) return error(res, 404, 'NOT_FOUND', `产品 ${id} 不存在`);
  products.delete(id);
  res.status(204).end(); // 204 No Content - DELETE标准响应
});

// OPTIONS /api/products - CORS预检
app.options('/api/products', (req, res) => {
  res.setHeader('Allow', 'GET, POST, OPTIONS');
  res.status(204).end();
});

// HEAD /api/products - 只返回头
app.head('/api/products', (req, res) => {
  res.setHeader('X-Total-Count', products.size.toString());
  res.status(200).end();
});

// ===== 404兜底 =====
app.use((req, res) => {
  error(res, 404, 'NOT_FOUND', `路径 ${req.method} ${req.path} 不存在`);
});

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

// ===== 启动 =====
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`🚀 REST API 运行在 http://localhost:${PORT}`);
  console.log('接口列表:');
  console.log('  GET    /api/products         - 产品列表');
  console.log('  GET    /api/products/:id     - 产品详情');
  console.log('  POST   /api/products         - 创建产品');
  console.log('  PUT    /api/products/:id     - 全量更新');
  console.log('  PATCH  /api/products/:id     - 部分更新');
  console.log('  DELETE /api/products/:id     - 删除产品');
  console.log('  HEAD   /api/products         - 资源元数据');
  console.log('  OPTIONS /api/products        - 支持的方法');
});

测试所有HTTP方法

# 安装express并启动
npm install express
node rest-api.js

# 1. GET - 获取产品列表
curl -s http://localhost:3000/api/products | jq .
{
  "success": true,
  "data": [
    {"id":1,"name":"机械键盘","price":599,"category":"外设","stock":120,...},
    {"id":2,"name":"无线鼠标","price":199,"category":"外设","stock":250,...},
    ...
  ],
  "meta": {"pagination":{"page":1,"limit":20,"total":5,"totalPages":1},...}
}
# 2. POST - 创建产品
curl -s -X POST http://localhost:3000/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"蓝牙音箱","price":299,"category":"音频","stock":50}' | jq .
{
  "success": true,
  "data": {"id":6,"name":"蓝牙音箱","price":299,"category":"音频","stock":50,...},
  "meta": {...}
}
# 3. PUT - 全量更新
curl -s -X PUT http://localhost:3000/api/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"机械键盘Pro","price":699,"category":"外设","stock":100}' | jq .

# 4. PATCH - 部分更新(只改价格)
curl -s -X PATCH http://localhost:3000/api/products/2 \
  -H "Content-Type: application/json" \
  -d '{"price":179}' | jq .

# 5. DELETE - 删除产品
curl -s -X DELETE http://localhost:3000/api/products/5 -w "\nHTTP Status: %{http_code}\n"

# 6. HEAD - 只获取元数据
curl -I http://localhost:3000/api/products

# 7. OPTIONS - 查看支持的方法
curl -X OPTIONS http://localhost:3000/api/products -v

# 8. 带过滤条件查询
curl -s "http://localhost:3000/api/products?category=外设&minPrice=100&sortBy=price&order=desc" | jq .

🔄 HTTP/1.1 vs HTTP/2 vs HTTP/3

特性HTTP/1.1HTTP/2HTTP/3
传输层TCPTCPQUIC(UDP)
多路复用❌ 队头阻塞✅ 流式复用✅ 独立流
头部压缩✅ HPACK✅ QPACK
服务器推送✅(已弃用)
连接建立1-RTT1-RTT+TLS0-RTT
API影响需合并请求减少连接无需刻意合并更适合弱网环境
💡 对API设计的影响:HTTP/2的多路复用消除了HTTP/1.1下"合并多个请求为一个"的性能需求。GraphQL proponents常说的"减少请求数"在HTTP/2下不再是核心优势。但实际网络环境复杂,合理的设计仍需考虑两者。

📝 本课小结

  1. HTTP请求/响应结构是Web API的通信基础
  2. HTTP方法有明确的语义:GET获取、POST创建、PUT全量替换、PATCH部分更新、DELETE删除
  3. 幂等性和安全性是方法的关键属性,影响客户端重试策略
  4. REST六大约束:客户端-服务器、无状态、缓存、统一接口、分层系统、按需代码(可选)
  5. PUT是全量替换,PATCH是部分更新——这是常见的混淆点
  6. HTTP/2改变了API性能优化的考量

💪 练习

练习1:为博客系统设计RESTful API

一个博客系统有以下资源:文章、评论、标签、用户。请设计完整的RESTful API路由,包括:

参考

# 文章
GET    /api/articles                    # 列表(?tag=xxx&author=xxx)
GET    /api/articles/:slug              # 详情(用slug而非id更RESTful)
POST   /api/articles
PUT    /api/articles/:slug
PATCH  /api/articles/:slug
DELETE /api/articles/:slug

# 评论(嵌套资源)
GET    /api/articles/:slug/comments
POST   /api/articles/:slug/comments
PATCH  /api/comments/:id               # 或 PUT
DELETE /api/comments/:id

# 标签
GET    /api/tags
POST   /api/tags
DELETE /api/tags/:name

练习2:判断HTTP方法使用是否正确

以下场景分别应该用哪个HTTP方法?

  1. 用户修改密码
  2. 获取某篇文章的评论数
  3. 上传用户头像
  4. 批量导入商品数据
  5. 取消订单

参考:1.PATCH 2.HEAD(返回X-Comment-Count头) 3.POST(创建新资源) 4.POST(批量操作) 5.PATCH(部分更新状态字段)

🏆 本课成就:HTTP语义大师