阶段一:RESTful基础
URL是API的门面。一个好的URL结构让API自解释、可预测、易记忆;一个糟糕的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
| 部分 | 规则 | 示例 |
|---|---|---|
| 基础路径 | 统一前缀,如/api | /api |
| 版本号 | 路径中或请求头中 | /v1 或 Header: Api-Version: 1 |
| 资源集合 | 复数名词,小写,连字符分隔 | /users, /blog-posts |
| 资源标识 | 路径参数,有意义的ID | /users/42, /articles/my-first-post |
| 子资源 | 表达从属关系 | /users/42/orders |
| 查询参数 | 过滤、排序、分页、字段选择 | ?status=active&sort=name |
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是集合中的特定用户。这比混合单复数更一致。
/api/blog-posts
/api/user-profiles
/api/shipping-addresses
/api/blogPosts # camelCase
/api/BlogPosts # PascalCase
/api/blog_posts # 下划线
/api/Blog-Posts # 混合
/Users和/users是不同的路径。统一小写避免歧义。多词用连字符(kebab-case),这是URL中最常用的分隔符。
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
有些操作不适合用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
当资源之间存在明确的从属关系时,嵌套路径能清晰表达这种关系。
# ✅ 合理的嵌套
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 # 用查询参数过滤
当子资源也有独立存在的意义时,应该同时提供直接访问的路径:
# 评论既属于文章,也可以独立查询
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"}]
# 只返回需要的字段——减少传输量
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-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)"}
]
}
| 场景 | 推荐设计 | 替代方案 |
|---|---|---|
| 用户登录 | POST /api/auth/login | POST /api/sessions |
| 用户登出 | POST /api/auth/logout | DELETE /api/sessions/current |
| 修改密码 | PATCH /api/users/42/password | POST /api/users/42/change-password |
| 文件上传 | POST /api/files (multipart) | POST /api/uploads |
| 数据导出 | POST /api/exports (异步) | GET /api/users?format=csv (同步) |
| 搜索 | GET /api/search?q=xxx | POST /api/search (复杂条件) |
| 统计 | GET /api/stats/orders | GET /api/orders/stats |
| 批量操作 | POST /api/users/batch | PATCH /api/users |
# 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
# 服务端同时支持两种查找方式
将以下不符合规范的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
为以下资源设计URL:商品、商品分类、购物车、购物车项、订单、订单项、收货地址、支付
启动本课的url-linter.js,用curl测试5个你设计的URL,查看规范检查结果。