CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
API 语义审查
这一课你将能做到
拿到一个 Spring Boot Controller 的 PR,能在 5 分钟内识别出 REST 语义错误(动词用错、状态码乱返、资源命名不规范)
判断接口契约变更是否破坏向后兼容,并给出迁移方案
审查 DTO、校验注解、错误响应结构是否一致且可演进
为什么要学
API 是后端和前端、和其他系统之间的「合同」。合同一旦签出去,改一个字都可能让调用方线上炸掉。Code Review 时审 API 语义,就是在签合同前帮双方把条款看清楚:路径是不是名词、方法是不是幂等、错误码前端能不能直接用、这次改动老版本 App 还能不能调通。
人话:实现写错了,改一行代码就行;API 语义设计错了,会跟着你三年,因为总有客户端在用旧版本。
先建立图像
一个语义清晰的接口长这样:
资源: /api/v1/courses ← 名词复数,无动词
├─ GET /api/v1/courses 200 列表查询(幂等)
├─ POST /api/v1/courses 201 创建(返回 Location)
├─ GET /api/v1/courses/{id} 200 / 404
├─ PUT /api/v1/courses/{id} 200 全量更新(幂等)
├─ PATCH /api/v1/courses/{id} 200 部分更新
└─ DELETE /api/v1/courses/{id} 204 删除(幂等)
错误响应统一结构:
{ "code": "COURSE_NOT_FOUND", "message": "...", "traceId": "..." }
审查时就拿这张图去对照:路径里出现 getCourse、deleteCourseById 这种动词的,一律打回。
跟着做
步骤 1:审路径和方法语义
❌ 反例(本站早期风格的坏味道):
@RestController
@RequestMapping("/api")
public class CourseController {
@PostMapping("/getCourseList") // 用 POST 做查询
public List<CourseVO> list() { ... }
@GetMapping("/deleteCourse") // GET 做删除,浏览器预加载都会误删
public void delete(@RequestParam Long id) { ... }
@PostMapping("/updateCourseInfo")
public Result update(@RequestBody Course course) { ... }
}
✅ 正例:
@RestController
@RequestMapping("/api/v1/courses")
public class CourseController {
@GetMapping
public PageResult<CourseVO> list(CourseQuery query) { ... }
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
courseService.delete(id);
return ResponseEntity.noContent().build(); // 204
}
@PutMapping("/{id}")
public CourseVO update(@PathVariable Long id,
@Valid @RequestBody CourseUpdateReq req) { ... }
}
审查动作:逐条接口问三个问题——路径是纯名词吗?HTTP 方法幂等性匹配吗(GET 只读、PUT/DELETE 幂等)?状态码准确吗(创建返 201 而不是 200)?
步骤 2:审向后兼容
❌ 反例:直接改字段名、删字段、改类型
// 上一版
public class CourseVO {
private String courseName; // v1 已上线
}
// 这次 PR 直接改名
public class CourseVO {
private String title; // 老前端读 courseName 全是 null
}
✅ 正例:只增不改,废弃用 @Deprecated 标记并给迁移期
public class CourseVO {
@Deprecated(since = "1.2", forRemoval = true)
private String courseName; // 保留并继续填充,下个大版本删
private String title;
}
审查动作:凡是涉及线上已有接口,检查「老客户端还能不能正常工作」。破坏性变更必须走版本号(/api/v2/)或双字段过渡期。
步骤 3:审 DTO 与校验 ❌ 反例:Controller 直接收实体、无校验、错误信息裸奔
@PostMapping
public Result create(@RequestBody Course course) { // 实体直接暴露,createTime 都能被客户端塞
courseService.save(course);
return Result.ok();
}
public record CourseCreateReq(
@NotBlank(message = "课程名不能为空")
@Size(max = 64) String title,
@NotNull @Positive(message = "价格必须大于 0")
BigDecimal price
) {}
@PostMapping
public ResponseEntity<CourseVO> create(@Valid @RequestBody CourseCreateReq req) {
CourseVO vo = courseService.create(req);
URI location = URI.create("/api/v1/courses/" + vo.getId());
return ResponseEntity.created(location).body(vo); // 201 + Location
}
步骤 4:审错误响应结构 前端 Next.js 侧通常是统一封装 fetch 的,错误结构不稳定,前端就要为每个接口写 if-else。
❌ 反例:有时返 {"msg":"fail"},有时返 {"error":-1},有时直接抛 500 堆栈。
✅ 正例:全局异常处理统一结构,业务码可读、带 traceId 方便排查
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ErrorBody> handle(BizException e, HttpServletRequest req) {
return ResponseEntity.status(e.getStatus())
.body(new ErrorBody(e.getCode(), e.getMessage(),
MDC.get("traceId")));
}
}
步骤 5:审分页与排序契约 @GetMapping
public PageResult<CourseVO> list(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Max(100) int size) { ... }
审查点:size 必须设上限,否则有人传 size=100000 直接拖垮 MySQL;分页参数风格全站统一(page/size 还是 offset/limit,别混用)。
讲清楚要点
REST 的核心是资源而不是动作 :URL 描述「什么东西」,HTTP 方法描述「干什么」。/courses/{id} + DELETE 才是删除,/deleteCourse 是 RPC 风格混进 REST。
幂等性是语义审查的灵魂 :GET/PUT/DELETE 必须幂等(调一次和调十次结果一样),POST 不幂等所以要做防重复提交(唯一索引或幂等键)。
兼容性三原则 :只加字段不改字段;不改字段类型;不改默认值语义。做不到就升版本。
状态码是 API 的一部分 :200/201/204/400/401/403/404/409/422 各有语义,一律返 200 再在 body 里塞 code 是对 HTTP 的浪费(虽然国内很多团队这么干,但新项目建议语义化)。
DTO 与实体必须分离 :实体映射数据库,DTO 是契约。混用等于把数据库表结构暴露给前端,加字段都变成兼容风险。
你可能会懵的地方
POST 查询列表到底行不行?
复杂查询条件放 URL 里有长度限制且不便缓存,业内普遍接受 POST /courses/search 这类妥协。审查时允许,但要求团队统一约定,别同一个项目两种风格并存。
PUT 和 PATCH 到底差在哪?
PUT 是全量替换,不传字段就该被清空;PATCH 只改传的字段。审查时看到 PUT 实现里只更新非 null 字段,说明实现是 PATCH 语义却挂了 PUT 的名字。
删除成功返 200 还是 204?
没有 body 要返回就用 204;想返回被删资源快照用 200。两者都对,错的是同一个项目里随心情切换。
业务错误码和 HTTP 状态码重复吗?
不重复。HTTP 状态码给网关、监控、浏览器看;业务码(如 COURSE_ALREADY_PUBLISHED)给前端做精确提示。审查时看两层是否都在。
接口要不要版本号?
对外 API 建议带(/api/v1/),纯内部前后端一体部署可以不带。审查重点是有没有「版本策略」,而不是非要 v1。
错法对照 错法 问题 正解 @GetMapping("/deleteCourse")GET 有副作用,CDN/预加载会误触发 DELETE /courses/{id} 更新接口直接改 VO 字段名 老前端白屏 双字段过渡或升 v2 Controller 直接收/返实体 表结构泄露、被恶意赋值 专用 Req/VO + MapStruct 所有错误都抛 RuntimeException 前端拿到的全是 500 堆栈 业务异常 + @RestControllerAdvice size 无上限恶意大分页拖垮 DB @Max(100) 硬限制
卡点急救
前端说「接口改了不通知」 :建立契约文件(OpenAPI/Springdoc 自动生成),PR 里契约文件 diff 必须人工确认;CI 加 springdoc-openapi 导出对比。
不确定这次改动兼不兼容 :本地起两个版本,用老前端调新后端跑一遍核心路径;或者直接用契约测试(Spring Cloud Contract)。
团队为「200 vs 业务码」吵个不停 :查项目里占比高的那种风格,跟多数派走,写进团队规范文档,别在单个 PR 里翻案。
面试常问
RESTful 的幂等性怎么理解?哪些方法幂等?
同一请求执行一次与多次,服务端资源状态相同。GET/PUT/DELETE/HEAD/OPTIONS 幂等;POST 不幂等。实际项目中 POST 创建订单要通过幂等键或唯一约束把「不幂等」兜住,防止重复下单。
如何做接口版本管理?
URL 路径版本(/v1/)、请求头版本(Accept: application/vnd.x.v2+json)、参数版本三种。国内最常用路径版本。关键是「什么时候必须升版本」:删字段、改语义、改类型。
200 统一返回 + body 里 code,还是语义化状态码?
两种都见过。统一 200 的好处是穿透某些网关/代理时不被误处理,前端拦截器简单;语义化的好处是监控、缓存、SEO 友好。回答时讲清两者取舍和你项目的选择依据,比背答案加分。
自测三问
PUT /courses/{id} 的实现里只更新非 null 字段,有没有问题?
一个已上线接口要把 price 从 Integer(分)改成 BigDecimal(元),怎么改才不炸老客户端?
为什么 Controller 不能直接把 JPA/MyBatis 实体当返回值?
小结一句话 API 语义审查就是替所有调用方看合同:路径是名词、方法讲幂等、状态码说真话、变更保兼容。
延伸