📖 第03课:URL设计

阶段一:RESTful基础

URL是API的门面。一个好的URL结构让API自解释、可预测、易记忆;一个糟糕的URL结构让开发者困惑、文档混乱、维护噩梦。本课深入URL设计的每个细节——命名规范、路径结构、查询参数、嵌套资源、以及那些容易被忽略的边界情况。

🏷️ URL的核心组成

https://api.example.com/v2/users/42/orders?status=active&sort=-created_at&page=1
\___/   \_______________/\__/\____/\_/\_____/\_____/ \_________________________/
  |             |          |    |    |    |       |                |
scheme      host         ver  coll  id  sub-c  sub-id         query params
                         s1   ectio      ollect
                         ion   n      ion

URL各部分命名规范

部分规则示例
基础路径统一前缀,如/api/api
版本号路径中或请求头中/v1 或 Header: Api-Version: 1
资源集合复数名词,小写,连字符分隔/users, /blog-posts
资源标识路径参数,有意义的ID/users/42, /articles/my-first-post
子资源表达从属关系/users/42/orders
查询参数过滤、排序、分页、字段选择?status=active&sort=name

📐 命名规范详解

1. 使用复数名词

✅ 推荐
GET /api/users
GET /api/users/42
GET /api/products
GET /api/orders
❌ 避免
GET /api/user
GET /api/getUser/42
GET /api/product/list
GET /api/order

复数名词作为集合,ID定位具体资源:/users是所有用户的集合,/users/42是集合中的特定用户。这比混合单复数更一致。

2. 小写字母 + 连字符

✅ 推荐
/api/blog-posts
/api/user-profiles
/api/shipping-addresses
❌ 避免
/api/blogPosts      # camelCase
/api/BlogPosts      # PascalCase
/api/blog_posts     # 下划线
/api/Blog-Posts     # 混合
💡 RFC 3986规范:URL路径段应使用小写字母,因为URL是大小写敏感的。/Users/users是不同的路径。统一小写避免歧义。多词用连字符(kebab-case),这是URL中最常用的分隔符。

3. 资源名 vs 动词

URL应该描述"是什么"(资源),而不是"做什么"(操作)。HTTP方法已经表达了操作意图。

✅ 面向资源
POST   /api/users          # 创建用户
GET    /api/users/42       # 获取用户
PUT    /api/users/42       # 更新用户
DELETE /api/users/42       # 删除用户
❌ 面向操作
POST /api/createUser
POST /api/getUser
POST /api/updateUser
POST /api/deleteUser

4. 何时可以使用动词

有些操作不适合用CRUD表达——它们是"动作"而非"资源变换"。此时可以在资源路径下使用动词:

# 快捷操作——本质是执行而非状态变更
POST /api/users/42/activate          # 激活用户
POST /api/orders/123/cancel          # 取消订单
POST /api/files/upload               # 上传文件
POST /api/payments/charge            # 发起支付

# 批量操作
POST /api/users/batch-delete         # 批量删除
POST /api/notifications/batch-read   # 批量标记已读

# 搜索——特殊资源
POST /api/users/search               # 复杂搜索条件用POST
GET  /api/users/search?q=zhang       # 简单搜索用GET
⚠️ 动词使用原则:动词路径应该是例外而非规则。如果能用资源状态变更表达(如PATCH修改status字段),就不要用动词路径。动词路径适合有"副作用"或"流程性"的操作。

🗂️ 嵌套资源设计

当资源之间存在明确的从属关系时,嵌套路径能清晰表达这种关系。

嵌套层级规则

经验法则:最多两层嵌套

# ✅ 合理的嵌套
GET /api/users/42/orders              # 用户的订单
GET /api/users/42/orders/123          # 用户的特定订单
GET /api/articles/slug/comments       # 文章的评论

# ❌ 过深嵌套——难以阅读和维护
GET /api/users/42/orders/123/items/5/reviews/8

# ✅ 更好的方式:扁平化 + 过滤
GET /api/order-items/5/reviews/8                     # 直接定位
GET /api/reviews/8                                    # 甚至更直接
GET /api/orders/123/items?user_id=42                  # 用查询参数过滤

独立资源 vs 嵌套资源

当子资源也有独立存在的意义时,应该同时提供直接访问的路径:

# 评论既属于文章,也可以独立查询
GET /api/articles/:slug/comments     # 某文章的评论
GET /api/comments/:id                # 直接访问评论
GET /api/comments?article=slug       # 按文章过滤评论

# 订单项既可以嵌套也可以独立
GET /api/orders/:id/items            # 某订单的商品
GET /api/order-items/:id             # 直接访问订单项

🔍 查询参数设计

查询参数分类

类型参数示例
过滤字段名=值?status=active&category=book
排序sort / sort_by?sort=-created_at,name(-降序)
分页page/limit 或 cursor?page=2&limit=20
字段选择fields / select?fields=id,name,email
搜索q / search / query?q=keyword
嵌入include / expand?include=author,comments
格式format?format=csv

排序设计

# 方式1:前缀表示方向(推荐)
?sort=-created_at,name
# -表示降序,无前缀表示升序
# 先按创建时间降序,再按名称升序

# 方式2:参数指定方向
?sort_by=created_at&order=desc

# 方式3:JSON格式(适合复杂排序)
?sort=[{"field":"created_at","order":"desc"}]

字段选择(Sparse Fieldsets)

# 只返回需要的字段——减少传输量
GET /api/users?fields=id,name,email

# 移动端可能只需要少量字段
GET /api/users?fields=id,avatar

# 后台管理需要完整字段
GET /api/users  # 返回所有字段

关联资源嵌入

# 默认:文章不包含作者详情
GET /api/articles/42
{"id":42,"title":"...","author_id":1}

# 嵌入作者信息——避免N+1查询
GET /api/articles/42?include=author
{"id":42,"title":"...","author_id":1,"author":{"id":1,"name":"张三"}}

# 嵌入多个关联
GET /api/articles/42?include=author,comments
{"id":42,"title":"...","author":{...},"comments":[...]}

🛠️ 实战:URL设计验证工具

// url-linter.js - API URL规范检查工具
const http = require('http');

// URL设计规范规则
const rules = [
  {
    name: '路径段应使用小写',
    test: (segments) => segments.some(s => s !== s.toLowerCase() && /[a-zA-Z]/.test(s)),
    message: '路径段包含大写字母,应全部使用小写',
  },
  {
    name: '资源名应使用复数',
    test: (segments) => {
      const singulars = ['user', 'product', 'order', 'comment', 'article', 'tag', 'item'];
      return segments.some(s => singulars.includes(s.toLowerCase()));
    },
    message: '资源名应为复数形式(如users而非user)',
  },
  {
    name: '避免在路径中使用动词',
    test: (segments) => {
      const verbs = ['get', 'create', 'update', 'delete', 'list', 'find', 'search', 'add', 'remove'];
      return segments.some(s => verbs.includes(s.toLowerCase()));
    },
    message: '路径中包含动词,应使用HTTP方法表达操作',
  },
  {
    name: '避免使用下划线',
    test: (segments) => segments.some(s => s.includes('_')),
    message: '路径中使用下划线,应使用连字符(kebab-case)',
  },
  {
    name: '避免驼峰命名',
    test: (segments) => segments.some(s => /[a-z][A-Z]/.test(s)),
    message: '路径中使用驼峰命名,应使用连字符(kebab-case)',
  },
  {
    name: '嵌套层级不超过2层',
    test: (segments) => segments.length > 5, // /api/v1/ 算3层,再加2层资源
    message: '路径嵌套层级过深,建议不超过2层资源嵌套',
  },
];

function lintUrl(urlStr) {
  try {
    const url = new URL(urlStr);
    const pathSegments = url.pathname.split('/').filter(Boolean);

    console.log(`\n🔍 检查URL: ${urlStr}`);
    console.log(`   路径段: [${pathSegments.map(s => `"${s}"`).join(', ')}]`);

    const issues = [];
    for (const rule of rules) {
      if (rule.test(pathSegments)) {
        issues.push(rule);
      }
    }

    if (issues.length === 0) {
      console.log('   ✅ URL符合规范');
    } else {
      issues.forEach(issue => {
        console.log(`   ❌ ${issue.name}: ${issue.message}`);
      });
    }

    return issues;
  } catch (e) {
    console.error(`   ⚠️ 无效URL: ${e.message}`);
    return [];
  }
}

// 测试用例
const testUrls = [
  'https://api.example.com/v1/users',
  'https://api.example.com/v1/Users',
  'https://api.example.com/v1/user',
  'https://api.example.com/v1/getUser',
  'https://api.example.com/v1/user_profiles',
  'https://api.example.com/v1/userProfiles',
  'https://api.example.com/v1/users/42/orders/123/items/5/reviews/8',
  'https://api.example.com/v1/blog-posts',
  'https://api.example.com/v1/users/42/activate',
  'https://api.example.com/v2/shipping-addresses?fields=id,name',
];

console.log('═══════════════════════════════════════');
console.log('   API URL 规范检查工具');
console.log('═══════════════════════════════════════');

testUrls.forEach(lintUrl);

// HTTP服务模式
const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url.startsWith('/lint')) {
    const targetUrl = decodeURIComponent(req.url.split('?url=')[1] || '');
    if (!targetUrl) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: '请提供 ?url= 参数' }));
    }
    const issues = lintUrl(targetUrl);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      url: targetUrl,
      compliant: issues.length === 0,
      issues: issues.map(i => ({ rule: i.name, message: i.message })),
    }));
  }
});

server.listen(3001, () => {
  console.log('\n🔧 URL检查服务: http://localhost:3001/lint?url=YOUR_URL');
});
# 运行URL检查工具
node url-linter.js
🔍 检查URL: https://api.example.com/v1/users
   路径段: ["v1", "users"]
   ✅ URL符合规范

🔍 检查URL: https://api.example.com/v1/Users
   ❌ 路径段应使用小写: 路径段包含大写字母,应全部使用小写

🔍 检查URL: https://api.example.com/v1/user
   ❌ 资源名应使用复数: 资源名应为复数形式

🔍 检查URL: https://api.example.com/v1/getUser
   ❌ 避免在路径中使用动词: 路径中包含动词
   ❌ 资源名应使用复数
# API方式调用
curl "http://localhost:3001/lint?url=https://api.example.com/v1/getUser"
{
  "url": "https://api.example.com/v1/getUser",
  "compliant": false,
  "issues": [
    {"rule":"避免在路径中使用动词","message":"路径中包含动词,应使用HTTP方法表达操作"},
    {"rule":"资源名应使用复数","message":"资源名应为复数形式(如users而非user)"}
  ]
}

🧩 URL设计的特殊场景

非CRUD操作的URL设计

场景推荐设计替代方案
用户登录POST /api/auth/loginPOST /api/sessions
用户登出POST /api/auth/logoutDELETE /api/sessions/current
修改密码PATCH /api/users/42/passwordPOST /api/users/42/change-password
文件上传POST /api/files (multipart)POST /api/uploads
数据导出POST /api/exports (异步)GET /api/users?format=csv (同步)
搜索GET /api/search?q=xxxPOST /api/search (复杂条件)
统计GET /api/stats/ordersGET /api/orders/stats
批量操作POST /api/users/batchPATCH /api/users

使用slug还是ID

# ID —— 内部引用、高性能
GET /api/articles/42

# Slug —— 面向用户、SEO友好
GET /api/articles/how-to-design-rest-api

# 混合使用
GET /api/articles/42              # 内部系统
GET /api/articles/how-to-design-rest-api  # 公开URL
# 服务端同时支持两种查找方式
💡 选择建议:内部API用数字ID,简洁高效;面向用户的公开URL用slug,可读性强。两者可以共存,服务端根据参数类型自动判断。

📝 本课小结

  1. URL使用复数名词、小写、连字符分隔
  2. 面向资源设计:URL描述"是什么",HTTP方法描述"做什么"
  3. 嵌套资源最多两层,过深则扁平化
  4. 查询参数用于过滤、排序、分页、字段选择
  5. 动词路径只用于不适合CRUD表达的操作
  6. 使用URL规范检查工具确保一致性
  7. 特殊场景灵活处理,但保持全局一致性

💪 练习

练习1:重构以下URL

将以下不符合规范的URL改为RESTful风格:

POST /api/getAllUsers
POST /api/createNewOrder
GET  /api/userInfo?id=42
POST /api/deleteProduct
GET  /api/orderListByUser?userId=42
PUT  /api/updateUserProfile/42

参考答案

GET    /api/users
POST   /api/orders
GET    /api/users/42
DELETE /api/products/:id
GET    /api/users/42/orders
PATCH  /api/users/42

练习2:设计电商系统URL

为以下资源设计URL:商品、商品分类、购物车、购物车项、订单、订单项、收货地址、支付

练习3:运行URL检查工具

启动本课的url-linter.js,用curl测试5个你设计的URL,查看规范检查结果。

🏆 本课成就:URL架构师