CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
API 设计实战入门/课时/REST 资源设计
RawBlame
课时目录12 节

API 设计实战入门

  1. 01API 基础
  2. 02HTTP 协议层
  3. 03REST 资源设计
  4. 04错误处理
  5. 05认证
  6. 06授权模型
  7. 07API 安全
  8. 08API 风格
  9. 09架构与集成
  10. 10测试
  11. 11可观测与合规
  12. 12文档与工具

REST 资源设计

URI、版本、分页与幂等。

30 分钟

REST 资源设计

这一课你将能做到

  1. 设计出符合 REST 规范的 URI,避免动词、层级过深等常见错误
  2. 实现 API 版本控制,掌握 URI 版本、Header 版本、参数版本三种方案
  3. 设计分页、排序、过滤的查询接口,并保证幂等性

为什么要学

URI 是 API 的门面。/api/getLesson?id=1 和 /api/lessons/1 哪个更专业?答案显而易见。版本策略决定你的 API 能不能平滑升级,分页设计影响性能和用户体验。面试必问:怎么设计幂等接口?API 版本怎么管理?

先建立图像

资源层级设计:
┌────────────────────────────────────────┐
│  /api/tracks/java-web                  │  ← 学习路径(集合)
│  /api/tracks/java-web/lessons          │  ← 路径下的课程(子集合)
│  /api/lessons/123                      │  ← 具体课程(单个资源)
│  /api/lessons/123/submissions          │  ← 课程的提交记录(子集合)
│  /api/submissions/456                  │  ← 具体提交(单个资源)
└────────────────────────────────────────┘

分页响应结构:
{
  "code": 200,
  "data": {
    "content": [...],           ← 当前页数据
    "page": 0,                  ← 当前页码(0-based)
    "size": 20,                 ← 每页大小
    "totalElements": 156,       ← 总条数
    "totalPages": 8             ← 总页数
  }
}

跟着做

步骤 1:URI 设计规范

好的 URI:

GET    /api/lessons              # 课程列表
GET    /api/lessons/123          # 单个课程
POST   /api/lessons              # 创建课程
PUT    /api/lessons/123          # 全量更新
PATCH  /api/lessons/123          # 部分更新
DELETE /api/lessons/123          # 删除

GET    /api/lessons/123/submissions    # 某课程的提交列表
POST   /api/lessons/123/submissions    # 提交作业

坏的 URI:

GET  /api/getLesson?id=123       # 动词 get
POST /api/createLesson           # 动词 create
GET  /api/lesson/list            # 单数 + 动词
GET  /api/lessons/123/submissions/456/comments/789/likes   # 层级太深

原则:URI 里只有名词,用 HTTP 方法表达动作;层级不超过 3 层。

步骤 2:版本控制

三种主流方案:

方案 A:URI 版本(最直观)

GET /api/v1/lessons
GET /api/v2/lessons
@RestController
@RequestMapping("/api/v1/lessons")
public class LessonV1Controller { ... }

@RestController
@RequestMapping("/api/v2/lessons")
public class LessonV2Controller { ... }

优点:简单、可缓存、浏览器直接访问。缺点:URI 污染。

方案 B:Header 版本(最优雅)

GET /api/lessons
Accept: application/vnd.codeforge.v1+json
@GetMapping(produces = "application/vnd.codeforge.v1+json")
public ApiResponse<List<LessonV1>> listV1() { ... }

@GetMapping(produces = "application/vnd.codeforge.v2+json")
public ApiResponse<List<LessonV2>> listV2() { ... }

优点:URI 干净。缺点:测试不方便(需要改 header),缓存复杂。

方案 C:参数版本(最灵活)

GET /api/lessons?version=1

优点:实现简单。缺点:不 RESTful,缓存不友好。

推荐:对外公开 API 用 URI 版本(v1/v2),内部服务用 Header 版本。

步骤 3:分页实现

@GetMapping
public ApiResponse<Page<Lesson>> list(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "id,desc") String sort) {

    // sort 格式:字段,方向
    String[] sortParams = sort.split(",");
    Sort.Direction direction = sortParams.length > 1 && "desc".equalsIgnoreCase(sortParams[1])
            ? Sort.Direction.DESC : Sort.Direction.ASC;
    Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortParams[0]));

    Page<Lesson> lessons = lessonRepository.findAll(pageable);
    return ApiResponse.success(lessons);
}

响应:

{
  "code": 200,
  "data": {
    "content": [...],
    "page": 0,
    "size": 20,
    "totalElements": 156,
    "totalPages": 8,
    "first": true,
    "last": false
  }
}

步骤 4:过滤与搜索

@GetMapping("/search")
public ApiResponse<Page<Lesson>> search(
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) String trackId,
        @RequestParam(required = false) String difficulty,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

    LessonQuery query = LessonQuery.builder()
            .keyword(keyword)
            .trackId(trackId)
            .difficulty(difficulty)
            .build();

    Page<Lesson> result = lessonService.search(query, PageRequest.of(page, size));
    return ApiResponse.success(result);
}

步骤 5:幂等性设计

问题:用户双击「提交」按钮,或网络超时重试,导致重复创建。

方案一:唯一约束(数据库层面)

@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "lesson_id"}))
public class Submission { ... }

方案二:幂等键(Idempotency Key)

curl -X POST http://localhost:8080/api/submissions \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: a1b2c3d4-e5f6-7890" \
  -d '{"lessonId":123,"code":"..."}'
@PostMapping
public ApiResponse<Submission> create(
        @RequestBody @Valid SubmissionRequest request,
        @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {

    if (idempotencyKey != null) {
        Optional<Submission> existing = submissionRepository
                .findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return ApiResponse.success(existing.get());
        }
    }

    Submission submission = submissionService.create(request, idempotencyKey);
    return ApiResponse.success(submission);
}

方案三:PUT 代替 POST(客户端生成 ID)

curl -X PUT http://localhost:8080/api/submissions/a1b2c3d4 \
  -H "Content-Type: application/json" \
  -d '{"lessonId":123,"code":"..."}'

讲清楚要点

  1. URI 是资源的地址,不是操作的命令:把 URI 想成「文件路径」,HTTP 方法是「操作类型」。/api/lessons/123 是「第 123 号课程」这个资源。
  2. 版本控制是 API 的生命线:一旦发布就不能随便改字段、删字段。新增字段可以,改类型、删字段必须升版本。
  3. 分页必须返回总数:前端需要知道「共 156 条,分 8 页」才能渲染分页组件。只返当前页数据是不够的。
  4. 过滤参数放 Query String:?trackId=java-web&difficulty=easy 比 /api/lessons/track/java-web/difficulty/easy 更灵活,参数可选、可组合。
  5. 幂等性不是可选的:网络不可靠,用户会重试,消息队列会重发。写操作必须考虑「执行一次和执行十次结果一样」。

你可能会懵的地方

  • PUT 和 PATCH 在版本升级时怎么处理? v1 用 PUT 全量更新,v2 可以引入 PATCH 支持部分更新。但注意:如果 v1 的客户端不传某个字段,v2 的 PATCH 不能把这个字段清空。版本升级时要保持向后兼容。

  • 分页用 page 还是 cursor? page 适合「跳页」场景(第 1 页、第 5 页);cursor 适合「无限滚动」(微信朋友圈)。cursor 性能更好(不需要 OFFSET),但不能跳页。大数据量推荐 cursor。

  • 批量操作怎么设计? 批量创建:POST /api/lessons/batch,body 传数组。批量删除:DELETE /api/lessons?ids=1,2,3 或 POST /api/lessons/batch-delete。批量操作要限制数量(如最多 100 条),防止超时。

  • HATEOAS 是什么?需要实现吗? HATEOAS(Hypermedia as the Engine of Application State)是 REST 的最高成熟度:响应里带相关资源的链接。比如返回课程时带 "links": {"submissions": "/api/lessons/123/submissions"}。实际项目中很少全量实现,太啰嗦。

  • Spring Data REST 能用吗? Spring Data REST 自动生成 Repository 的 REST API,适合原型和内部系统。但可控性差,复杂业务建议手写 Controller。

错法对照

错法问题正解
/api/lessons/getByTrackId?trackId=1URI 里出现动词/api/lessons?trackId=1
升级 API 直接改 v1 的返回结构老客户端崩溃新增 v2,v1 保持不动
分页只返 List<Lesson>前端不知道总页数返回 Page<Lesson> 或自定义分页对象
批量删除用 GET危险,可能被缓存或预取POST /api/lessons/batch-delete
过滤条件用路径参数参数可选性受限,组合爆炸Query String

卡点急救

  1. 分页性能差:OFFSET 100000 LIMIT 20 会扫描前 10 万条。大表用 cursor 分页(WHERE id > lastId LIMIT 20)。
  2. 版本冲突:两个客户端同时更新同一资源,后提交的覆盖先提交的。用乐观锁(@Version)或 ETag。
  3. 幂等键失效:Idempotency-Key 存 Redis 要设过期时间(如 24 小时),防止无限膨胀。

面试常问

  • REST 成熟度模型(Richardson Maturity Model)? Level 0:POX(Plain Old XML),用 POST 发 XML,URI 无意义。Level 1:Resources,URI 标识资源,但仍用 POST 做所有操作。Level 2:HTTP Verbs,用 GET/POST/PUT/DELETE,状态码正确。Level 3:Hypermedia(HATEOAS),响应带链接。大多数公司做到 Level 2。

  • 怎么保证接口幂等? 数据库唯一约束、幂等键(Idempotency-Key)、乐观锁、状态机(已支付不能重复支付)。能结合场景讲 2-3 种方案。

  • API 版本策略怎么选? URI 版本简单直观,适合对外 API;Header 版本干净,适合内部微服务;参数版本灵活但缓存不友好。能说清优缺点和适用场景。

自测三问

  1. 设计「用户收藏课程」的 API,URI 和方法怎么写?考虑取消收藏。
  2. 如果课程表有 1000 万条数据,分页查询 page=50000&size=20 会有什么问题?怎么优化?
  3. 为什么 PUT 是幂等的,POST 不是?各举一个业务场景。

小结一句话

REST 资源设计的核心是「名词 + 动词分离」:URI 定位资源,HTTP 方法表达操作;版本控制和幂等性是生产环境 API 的必备设计。

延伸

  • Richardson Maturity Model
  • Spring Data REST 文档
  • 下一课:错误处理——Problem Details 标准与统一异常处理。
加载本节练习…
← 上一课HTTP 协议层下一课 →错误处理