REST 与资源设计
这一课你将能做到
- 用「名词路径 + HTTP 方法 + 状态码」三件套设计一组 tracks/lessons API,而不是
/getTrackList 式动词路径
- 分清路径参数(标识资源身份)与查询参数(过滤/分页/瘦身),并说明公开读
/api/** 与管理写 /admin/** 的职责分离
- 用 curl 拉列表、详情、lite 变体,亲手写出一张「方法 + 路径 + 成功码 + 失败码」的迷你 API 合同
为什么要学
API 是前后端的合同。路径乱七八糟、动词塞 URL、错误一律 200,客户端、网关、监控全都痛苦:缓存不知道能不能存、前端拦截器没法统一分支、新人接手要靠口口相传。
REST(Representational State Transfer)在工程实践里落地为一套可抄的骨架:资源用 URL 命名,动作用 HTTP 方法表达,结果用状态码表达。面试里「如何设计一个 XX 的 API」考的就是这套骨架运用。本课用课表 API 当样板,不空谈学位论文式 REST 纯度。
先建立图像
资源:tracks 集合、单个 track、其下 lessons
操作:GET 读 / POST 建 / PUT 换 / DELETE 删
结果:200/201/204/404/401…
一张可直接抄进接口文档的设计表:
| 意图 | 方法 | 路径 | 成功码 | 失败码 |
|---|
| 课表列表 | GET | /api/tracks | 200 | — |
| 课表详情 | GET | /api/tracks/{slug} | 200 | 404 |
| 某课的课节列表 | GET | /api/tracks/{slug}/lessons | 200 | 404 |
| 创建课表(管理) | POST | /admin/tracks | 201 | 400/401/403 |
| 全量更新 | PUT | /admin/tracks/{id} | 200 | 400/404 |
| 局部更新 | PATCH | /admin/tracks/{id} | 200 | 400/404 |
| 删除 | DELETE | /admin/tracks/{id} | 204 | 401/403/404 |
页面(给人看): /tracks/http-protocol-essentials → HTML(前端服务)
API(给程序): /api/tracks/http-protocol-essentials → JSON(Spring)
结构相似、职责不同:页面可缓存可跳转,API 契约稳定可演进。
跟着做
步骤 1:写一个「资源感」正确的 Controller
@RestController
public class TrackController {
// 集合:GET /api/tracks
// 查询参数做过滤,不改资源身份
@GetMapping("/api/tracks")
public List<Map<String, Object>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String q) {
// page/size/q 是「这次查询的条件」,不是「哪个资源」
return List.of(Map.of("slug", "http-protocol-essentials", "title", "HTTP 必修"));
}
// 成员:路径变量标识单个资源
@GetMapping("/api/tracks/{slug}")
public ResponseEntity<Map<String, Object>> detail(@PathVariable String slug) {
if (!"http-protocol-essentials".equals(slug)) {
return ResponseEntity.notFound().build(); // 找不到 → 404
}
return ResponseEntity.ok(Map.of(
"slug", slug,
"title", "HTTP 必修",
"lessonCount", 12));
}
// 子资源:资源归属用路径层级表达
@GetMapping("/api/tracks/{slug}/lessons")
public List<Map<String, Object>> lessons(@PathVariable String slug) { return List.of(); }
}
步骤 2:curl 验证集合与成员
# 集合(注意引号包裹,防 & 被 shell 吃掉)
curl -s "http://localhost:8080/api/tracks?page=0&size=20&q=http"
# 成员
curl -s http://localhost:8080/api/tracks/http-protocol-essentials
# 不存在 → 404
curl -i http://localhost:8080/api/tracks/no-such-slug
/api/tracks
└─ /{slug} ← 路径参数:资源身份
└─ /lessons ← 子资源:归属关系
步骤 3:lite 变体——同一资源的多重表示
列表卡片不需要详情的全部字段,给查询参数加一个 lite:
@GetMapping("/api/tracks/{slug}/lessons")
public List<Map<String, Object>> lessons(
@PathVariable String slug,
@RequestParam(defaultValue = "false") boolean lite) {
if (lite) {
return List.of(Map.of("title", "HTTP 是什么", "order", 1)); // 瘦身版
}
return List.of(Map.of("title", "HTTP 是什么", "order", 1,
"contentMd", "# 很长的正文…", "prereq", List.of("http-intro"))); // 完整版
}
curl -s "http://localhost:8080/api/tracks/http-protocol-essentials/lessons?lite=true"
这体现 REST 的 R——Representational:同一资源可以有多种表示,由参数/Accept 协商,而不是为每种场景造一个新 URL。
步骤 4:管理写接口与鉴权前缀分离
@RestController
@RequestMapping("/admin/tracks")
public class TrackAdminController {
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody TrackForm form) {
// ... 校验 + 落库(MySQL)
return ResponseEntity.created(URI.create("/api/tracks/" + form.getSlug()))
.body(Map.of("slug", form.getSlug())); // 201 + Location + 新资源表示
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
// ... 逻辑删除
return ResponseEntity.noContent().build(); // 204
}
}
/api/** 公开读为主(可缓存、可匿名)
/admin/** 写操作 + 鉴权(Spring Security 按前缀配规则)
权限必须在服务端强制——前端藏按钮只是体验,不是安全。
步骤 5:搜索怎么设计?
GET /api/tracks?q=nginx&status=published
两个例外可接受 POST:条件体巨大(超 URL 长度限制)、涉及复杂嵌套结构。此时定义动作资源并文档标明:
POST /api/tracks/search body: {"keywords":[...],"filters":{...}}
注意 POST 搜索放弃缓存,能 GET 就 GET。
步骤 6:Network 里对照页面与 API 的分工
- 打开课程页 → Network → 找 document 请求(返回
text/html)
- 再找
api/... 的 XHR/fetch(返回 application/json)
- 问自己两个问题:页面路由和 API 路径为什么长得像?数据为什么是 JS 再拉而不是直接嵌进 HTML?
(答案:SSR 页面会把数据预取嵌进 HTML——所以有些页面你看不到独立 API 请求;CSR 页面才明显「先壳后数据」。)
步骤 7:写一份迷你 API 合同(练习)
为「学习进度」假想资源在笔记里写下这张表(不必实现):
| 意图 | 方法 | 路径 | 成功 | 失败 |
|---|
| 读我的进度 | GET | /api/tracks/{slug}/progress | 200 | 401 |
| 标记完成 | PUT | /api/tracks/{slug}/progress | 200 | 400/401 |
| 清除进度 | DELETE | /api/tracks/{slug}/progress | 204 | 401 |
自检三问:路径是名词层级吗?写操作是否落在需鉴权前缀?状态码是否与状态码课的合同一致?
讲清楚要点
- 因为 URL 标识资源,所以用复数名词
tracks,把动作交给方法——/getTrackList、/deleteTrack 让方法语义失效,缓存与监控无从下手。
- 因为集合与成员层级清晰,所以嵌套不宜超过两三层——再深(
/tracks/{a}/lessons/{b}/notes/{c})改用查询参数或独立资源,URL 一长维护成本飙升。
- 因为状态码是合同的一部分,所以创建 201(带 Location)、删除 204、找不到 404、校验失败 400——客户端才能写统一拦截器,监控才能按码告警。
- 因为查询参数不改「是哪个资源」的身份,所以过滤/排序/分页/瘦身放 query——
page、size、q、lite=true;身份标识(id/slug)放 path。
- 因为页面与 API 的消费者不同,所以
/tracks(HTML)与 /api/tracks(JSON)分离——同域由 Nginx 拼接,互不干扰又能共享鉴权与缓存策略。
- 因为 REST 是风格不是宗教,所以不必为 HATEOAS 折腾——「资源化命名 + 正确方法 + 正确状态码」贡献了九成收益;超媒体链接留给确有需要的场景。
你可能会懵的地方
-
必须严格 REST 才能上线吗?
不必原教旨。内部 RPC、GraphQL 都有生存空间。但对公网 HTTP API,「资源化命名 + 方法语义 + 状态码合同」收益最高、学习成本最低,团队一致性比理论纯度重要。
-
对外标识用 slug 还是数字 id?
slug 可读、可 SEO(/api/tracks/http-protocol-essentials),但要处理唯一性与改名;数字 id 稳定不泄漏业务含义。常见折中:对外 URL 用 slug,库内主键 id 不暴露;选一种当对外主键并保持稳定。
-
PUT 必须全量字段吗?
语义上是「整体替换」:没传的字段视为「清空/默认值」。如果只想让客户端提交部分字段却用了 PUT,团队会逐渐混乱——要么真全量,要么 PATCH,要么文档写清「忽略未传字段」(此时其实是 UPSERT 语义)。
-
版本号放路径还是头?
/api/v1/tracks 简单直白、日志好查,主流选择;Accept: application/vnd.xxx.v2+json 更纯粹但调试麻烦。公开 API 可先不带版本,出现破坏性变更再冻结 v1 加 v2。
-
分页参数用 page/size 还是 cursor?
小数据量、需要跳页 → page/size + total;大数据流、无限滚动 → cursor(?after=xxx)避免深分页拖垮 MySQL(LIMIT 100000,20 的问题)。两者都可放进查询参数,不违背资源设计。
错法对照
| 错法 | 问题 | 正解 |
|---|
/api/getTrackList + 一律 GET | 动作塞路径,方法语义与缓存全废 | GET /api/tracks,动作用方法表达 |
/api/tracks/delete/1 + GET 删除 | GET 会被预取/爬虫触发,可能误删 | DELETE /api/tracks/1 |
业务失败仍 200 + {success:false} | 拦截器/监控/缓存全部失明 | HTTP 状态码表达大类,body 详述细节 |
过滤条件拼成一长串路径 /tracks/published/http | URL 失去资源语义,组合爆炸 | ?status=published&q=http 查询参数 |
| 管理写接口与公开读混在同一无鉴权路径 | 权限逻辑散落每个方法,漏一处即事故 | /admin/** 前缀 + 网关/Security 统一强制鉴权 |
卡点急救
- 前端说「404」,后端说「接口在」:八成是路径拼接不一致或 context-path 前缀。让双方各自 curl 一遍完整 URL 对比;检查
server.servlet.context-path 与网关路由前缀是否叠了两层。
- 同一个资源详情,列表页和详情页字段对不上:列表用了 lite 版、详情是全量版——这不是 Bug 是表示差异。把「列表返回哪些字段」写进 API 合同,前端按文档消费,别两个页面互抄字段名。
- POST 创建成功后前端要再发一次 GET 拿详情:浪费一次往返。POST 返回 201 + Location + 新资源 body,前端直接用响应体渲染——「创建接口只回 id」是没有必要的保守。
面试常问
-
如何为一个「文章管理」设计 RESTful API?
给出三件套表:GET /api/articles(列表,?page&q 过滤)、GET /api/articles/{id}(详情)、POST /api/articles(创建,201+Location)、PUT/PATCH /api/articles/{id}(更新)、DELETE /api/articles/{id}(204)。补充:管理操作走 /admin 鉴权前缀;错误按 400/401/403/404 分族;能 GET 不 POST 以保住缓存。
-
RESTful API 的核心原则?
资源化(URL 是名词、层级表归属)、统一接口(HTTP 方法语义)、无状态(每个请求自包含凭证)、分层系统(客户端不必知道后面是缓存还是源站)、可缓存(GET 可缓存)。回答时结合「状态码合同 + 查询参数 vs 路径参数」落地,比背六条学术原则更有说服力。
-
接口设计里,PUT 和 PATCH 的幂等性有什么讲究?
PUT 语义是整体替换,天然幂等,重试安全;PATCH 取决于补丁格式——JSON Merge Patch 幂等,但「自增/追加」类补丁不幂等。涉及计数、追加日志的场景要慎用 PATCH 重试,或引入幂等键。
自测三问
- 写出 tracks 资源的列表、详情、创建、删除四条「方法 + 路径 + 成功码」。
- 路径参数与查询参数如何分工?各举一个反例说明「放错了会怎样」。
- 页面路由
/tracks/... 与 API /api/tracks/... 为什么要分开,却又可以同域共存?
小结一句话
REST 落地三件套:资源用 URL、操作用方法、结果用状态码——公开读与 /admin 写分离,路径参数定身份、查询参数定条件。
延伸