RESTful API 设计:API版本控制、错误处理、分页、HATEOAS

说实话,RESTful API 设计这件事,很多团队一开始都不太当回事。我见过太多项目,API 说改就改,客户端说崩就崩。等到线上出问题了,大家才开始慌。

今天咱们聊聊四个核心话题:版本控制、错误处理、分页、HATEOAS。这四点做好了,你的 API 才能算得上「专业」。

1. API 版本控制

为什么要做版本控制?说白了,就是怕改接口的时候,把老客户端搞挂了。我早期参与过一个项目,API 没有版本号,后端一改字段名,前端直接白屏。嗯,那场面,至今难忘。

常见的版本控制策略

策略 示例 优缺点
URL 路径版本 /api/v1/users 直观、易缓存;但 URL 会变
请求头版本 Accept: application/vnd.myapp.v2+json URL 干净;但调试稍麻烦
查询参数版本 /api/users?version=2 简单;容易污染缓存

我个人习惯用 URL 路径版本。为什么?因为一眼就能看出来当前用的是哪个版本,排查问题的时候省心。你想想看,线上出 bug 了,运维看一眼 URL 就知道是不是版本不匹配,多方便。

推荐做法:在 Kotlin + Spring Boot 中,可以用 @RequestMapping("/api/v{version}") 来统一处理。
@RestController
@RequestMapping("/api/v1/users")
class UserControllerV1 {
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): UserDto {
        // 返回 v1 版本的用户数据
    }
}

@RestController
@RequestMapping("/api/v2/users")
class UserControllerV2 {
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): UserDtoV2 {
        // 返回 v2 版本的用户数据,可能多了个字段
    }
}
小技巧:不要轻易废弃旧版本。我一般会同时维护最近两个大版本,给客户端留足迁移时间。

2. 错误处理

错误处理做得好不好,直接决定了前端同学会不会在心里骂你。我曾经见过一个 API,报错只返回 {"error": "something went wrong"}。你说这让人怎么排查?

好的错误处理,应该做到三点:

  • 状态码准确:200 就是成功,400 就是客户端问题,500 就是服务端问题。别什么都返回 200。
  • 错误信息可读:告诉调用方「哪里错了」和「怎么改」。
  • 统一格式:所有错误返回同一个结构,方便客户端统一处理。

推荐错误响应结构

{
  "status": 400,
  "code": "INVALID_PARAMETER",
  "message": "用户ID不能为空",
  "details": [
    {
      "field": "userId",
      "reason": "must not be null"
    }
  ],
  "timestamp": "2025-03-21T10:30:00Z",
  "path": "/api/v1/users"
}

在 Kotlin 中,我习惯定义一个全局异常处理器:

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException::class)
    fun handleBadRequest(ex: IllegalArgumentException, request: HttpServletRequest): ResponseEntity<ErrorResponse> {
        val error = ErrorResponse(
            status = 400,
            code = "INVALID_PARAMETER",
            message = ex.message ?: "请求参数错误",
            path = request.requestURI
        )
        return ResponseEntity.badRequest().body(error)
    }

    @ExceptionHandler(Exception::class)
    fun handleGeneralError(ex: Exception, request: HttpServletRequest): ResponseEntity<ErrorResponse> {
        // 记录日志,避免泄露敏感信息
        log.error("Unexpected error", ex)
        val error = ErrorResponse(
            status = 500,
            code = "INTERNAL_ERROR",
            message = "服务器内部错误,请稍后重试",
            path = request.requestURI
        )
        return ResponseEntity.status(500).body(error)
    }
}
注意:千万不要把堆栈信息直接返回给客户端。我曾经见过一个 API 把 SQL 异常堆栈原样返回,结果数据库表结构都暴露了。安全第一。

3. 分页

分页这件事,看起来简单,但坑不少。我记得有一次,一个接口没有做分页,结果一次返回了 10 万条数据,前端直接卡死。从那以后,我所有列表接口都强制分页。

推荐的分页参数

参数 说明 示例
page 页码,从 0 或 1 开始 page=0
size 每页条数 size=20
sort 排序字段和方向 sort=createdAt,desc

分页响应结构

{
  "content": [ ... ],  // 当前页数据
  "page": 0,
  "size": 20,
  "totalElements": 156,
  "totalPages": 8,
  "first": true,
  "last": false
}

在 Kotlin 中,配合 Spring Data 的 Page 接口,分页非常方便:

@GetMapping
fun listUsers(
    @RequestParam(defaultValue = "0") page: Int,
    @RequestParam(defaultValue = "20") size: Int,
    @RequestParam(defaultValue = "createdAt,desc") sort: String
): Page<UserDto> {
    val pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
    return userRepository.findAll(pageable).map { it.toDto() }
}
避坑指南:我曾经遇到过客户端传 size=10000 的情况。建议在服务端做上限限制,比如最大 100 条。超出就返回 400 错误,或者直接截断。

4. HATEOAS

HATEOAS 这个词听起来很高大上,其实说白了就是:API 返回的数据里,带上下一步可以做什么的链接。让客户端不用硬编码 URL,而是跟着 API 的「指引」走。

举个例子,你查询一个用户,API 不光返回用户信息,还告诉你「这个用户可以修改」、「这个用户可以删除」。客户端看到链接,就知道能做什么。

HATEOAS 示例

{
  "id": 1,
  "name": "张三",
  "email": "zhangsan@example.com",
  "_links": {
    "self": { "href": "/api/v1/users/1" },
    "update": { "href": "/api/v1/users/1", "method": "PUT" },
    "delete": { "href": "/api/v1/users/1", "method": "DELETE" },
    "orders": { "href": "/api/v1/users/1/orders" }
  }
}

在 Kotlin 中,可以用 Spring HATEOAS 库来实现:

import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*

data class UserResource(
    val id: Long,
    val name: String,
    val email: String
) : RepresentationModel<UserResource>()

@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): UserResource {
    val user = userService.findById(id)
    val resource = UserResource(user.id, user.name, user.email)
    resource.add(linkTo(methodOn(UserController::class.java).getUser(id)).withSelfRel())
    resource.add(linkTo(methodOn(UserController::class.java).deleteUser(id)).withRel("delete"))
    resource.add(linkTo(methodOn(OrderController::class.java).listOrdersByUser(id)).withRel("orders"))
    return resource
}
核心思想:HATEOAS 让 API 变成「自描述」的。客户端不需要提前知道所有 URL,跟着链接走就行了。这在微服务架构中尤其有用。

知识体系总览

下面这张图,帮你把今天讲的内容串起来:

RESTful API 设计 API 版本控制 错误处理 分页 HATEOAS URL路径 / 请求头 / 查询参数 同时维护最近两个大版本 统一错误格式 + 准确状态码 不暴露堆栈信息 page / size / sort 参数 服务端做上限限制 返回下一步可操作的链接

总结一下

今天聊了四个点:

  • 版本控制:别让老客户端崩了,我推荐 URL 路径方式。
  • 错误处理:统一格式,准确状态码,别暴露敏感信息。
  • 分页:强制分页,服务端做上限限制。
  • HATEOAS:让 API 自描述,客户端跟着链接走。

这些看起来都是细节,但正是这些细节,决定了你的 API 是「能用」还是「好用」。我在项目中踩过不少坑,希望你能少走弯路。


公众号:蓝海资料掘金营,微信deep3321