跳至主要內容

API 设计规范:一个后端应该知道的 RESTful 最佳实践

郑天祺大约 13 分钟架构设计APIRESTful

API 设计规范:一个后端应该知道的 RESTful 最佳实践

前言

作为一个后端开发者,你写的 API 可能被前端、移动端、第三方系统调用。一个好的 API 设计不只是"能跑就行",它决定了团队协作效率、系统可维护性和调用方的开发体验。

我在职业生涯中见过太多"反模式"的 API 设计:URL 里全是动词、状态码永远 200、返回格式千奇百怪、命名毫无规范……这篇文章将系统性地梳理 RESTful API 设计的核心规范,让你写的每一个接口都经得起审视。


一、REST 核心原则

1.1 什么是 REST

REST(Representational State Transfer,表述性状态转移)由 Roy Fielding 在 2000 年博士论文中提出。它不是协议,而是一种架构风格,核心思想是:

  1. 资源导向(Resource-Oriented):一切皆资源,每个资源有唯一 URL
  2. 无状态(Stateless):每个请求包含所有必要信息,服务器不保存客户端状态
  3. 统一接口(Uniform Interface):使用标准 HTTP 方法操作资源
  4. 表述(Representation):资源可以有多种表述(JSON、XML、HTML)
  5. HATEOAS:超媒体驱动(理想化,实践中很少完全实现)

1.2 Richardson 成熟度模型

Level 0: 一个 URL,一个 HTTP 方法(SOAP 风格)
Level 1: 多个 URL,区分资源
Level 2: 使用 HTTP 动词(GET/POST/PUT/DELETE) ← 大多数 API 的目标
Level 3: HATEOAS(超媒体控制)

大多数企业级 API 做到 Level 2 就足够了。


二、URL 设计规范

2.1 基本规则

✅ 正确示范
GET    /users           # 获取用户列表
GET    /users/123       # 获取单个用户
POST   /users           # 创建用户
PUT    /users/123       # 完整更新用户
PATCH  /users/123       # 部分更新用户
DELETE /users/123       # 删除用户
GET    /users/123/orders # 获取用户的订单列表

❌ 错误示范
GET    /getUserList     # URL 中不要动词
POST   /createUser      # 动词应通过 HTTP 方法表达
GET    /user/123        # 不用单数
DELETE /deleteUser?id=123 # 不要在查询参数中传资源 ID

2.2 命名规范

规则说明示例
使用名词复数资源集合用复数/users, /orders
小写字母全部小写/user-profiles
连字符分隔不用下划线/order-items/order_items
避免文件扩展名不暴露实现/users/users.json
层级不超过 3 层避免过深嵌套/users/123/orders

2.3 资源层级关系

# 资源嵌套设计
/users                          # 用户集合
/users/{userId}                 # 单个用户
/users/{userId}/orders          # 用户的订单
/users/{userId}/orders/{orderId} # 订单详情
/orders/{orderId}/items         # 订单的商品项
/orders/{orderId}/items/{itemId} # 订单商品项

# 避免过深的嵌套
❌ /users/1/orders/100/items/20/comments/5
# 应该平铺
✅ /comments/5  (在返回体中关联上下文)

2.4 处理非 CRUD 操作

某些操作确实难以映射到 CRUD,处理方式:

# 方案1:将动作建模为子资源
POST /users/{userId}/activation    # 激活用户
DELETE /users/{userId}/activation  # 停用用户

# 方案2:使用动词作为资源名(最后手段)
POST /password-resets              # 发起密码重置

# 方案3:在资源上执行动作
POST /orders/{orderId}/cancel      # 取消订单
POST /orders/{orderId}/refund      # 退款

原则:优先尝试建模为资源,实在不行再用动词。


三、HTTP 方法的正确使用

3.1 方法语义

方法语义幂等性安全性请求体
GET获取资源✅ 幂等✅ 安全
POST创建资源❌ 非幂等
PUT完整替换✅ 幂等
PATCH部分更新❌ 非幂等*
DELETE删除资源✅ 幂等通常无
HEAD获取元信息✅ 幂等✅ 安全
OPTIONS获取支持的方法✅ 幂等✅ 安全

*注:PATCH 在 JSON Merge Patch(RFC 7396)下实际上是幂等的。

3.2 PUT vs PATCH 的区别

// 现有资源
{
  "id": 1,
  "name": "张三",
  "email": "zhangsan@example.com",
  "phone": "13800138000"
}

// PUT /users/1 —— 完整替换,缺失字段会被删除
{
  "name": "张三三",
  "email": "newemail@example.com"
  // phone 会变成 null!
}

// PATCH /users/1 —— 仅更新指定字段
{
  "name": "张三三"
  // email 和 phone 保持不变
}

3.3 方法在 Java 中的映射

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    public Result<Page<UserVO>> list(@Valid PageRequest page) { }

    @GetMapping("/{id}")
    public Result<UserVO> getById(@PathVariable Long id) { }

    @PostMapping
    public Result<UserVO> create(@Valid @RequestBody UserCreateRequest req) { }

    @PutMapping("/{id}")
    public Result<UserVO> update(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest req) { }

    @PatchMapping("/{id}")
    public Result<UserVO> patch(@PathVariable Long id, @Valid @RequestBody UserPatchRequest req) { }

    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) { }
}

四、HTTP 状态码的正确选择

4.1 常用状态码速查

2xx 成功

状态码含义使用场景
200 OK请求成功GET、PUT、PATCH 成功
201 Created资源已创建POST 成功后返回
202 Accepted请求已接受,待处理异步任务
204 No Content成功但无响应体DELETE 成功

3xx 重定向

状态码含义使用场景
301 Moved Permanently永久重定向API 迁移
304 Not Modified未修改缓存验证(ETag/If-None-Match)

4xx 客户端错误

状态码含义使用场景
400 Bad Request请求参数错误参数校验失败
401 Unauthorized未认证Token 缺失或过期
403 Forbidden无权限已认证但权限不足
404 Not Found资源不存在查询/操作不存在的资源
405 Method Not Allowed方法不允许GET 走了 POST 等
409 Conflict资源冲突并发更新冲突、唯一约束冲突
422 Unprocessable Entity参数语义错误参数格式正确但业务逻辑不通过
429 Too Many Requests请求频率超限触发限流

5xx 服务端错误

状态码含义使用场景
500 Internal Server Error服务器内部错误未预期的异常
502 Bad Gateway网关错误上游服务不可用
503 Service Unavailable服务暂不可用维护中、过载
504 Gateway Timeout网关超时上游服务响应超时

4.2 常见错误用法

// ❌ 永远返回 200,在 body 中用 code 区分
@PostMapping("/users")
public ApiResponse create(@RequestBody UserRequest req) {
    try {
        userService.create(req);
        return ApiResponse.success(); // 永远是 200
    } catch (Exception e) {
        return ApiResponse.error("USER_EXISTS", "用户已存在");
        // 状态码还是 200!!!完全滥用 HTTP 协议
    }
}

// ✅ 正确做法:让 HTTP 状态码做它该做的事
@PostMapping("/users")
public ResponseEntity<?> create(@RequestBody @Valid UserRequest req) {
    try {
        User user = userService.create(req);
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201
            .body(Result.success(user));
    } catch (DuplicateUserException e) {
        return ResponseEntity
            .status(HttpStatus.CONFLICT) // 409
            .body(Result.error("USER_EXISTS", "用户已存在"));
    }
}

4.3 401 vs 403 的区分

这是最常被混淆的两个状态码:

401 Unauthorized(未认证):
"我不知道你是谁"
- Token 缺失
- Token 过期
- Token 无效
→ 需要重新登录

403 Forbidden(无权限):
"我知道你是谁,但你不配"
- 普通用户访问管理员接口
- IP 黑名单
- 账号被禁用
→ 需要提升权限,但不是重新登录的问题

五、统一返回体设计

5.1 标准返回格式

// 成功响应
{
  "code": 0,
  "message": "success",
  "data": {
    "id": 1,
    "name": "张三",
    "email": "zhangsan@example.com"
  },
  "timestamp": 1715616000000,
  "requestId": "req-abc-123"
}

// 分页响应
{
  "code": 0,
  "message": "success",
  "data": {
    "records": [...],
    "total": 100,
    "page": 1,
    "pageSize": 20,
    "totalPages": 5
  },
  "timestamp": 1715616000000,
  "requestId": "req-abc-123"
}

// 错误响应
{
  "code": 10001,
  "message": "用户已存在",
  "data": null,
  "timestamp": 1715616000000,
  "requestId": "req-abc-123",
  "details": [
    {
      "field": "email",
      "message": "该邮箱已被注册"
    }
  ]
}

5.2 Java 实现

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
    private int code;
    private String message;
    private T data;
    private long timestamp;
    private String requestId;

    public static <T> Result<T> success(T data) {
        return new Result<>(0, "success", data,
                System.currentTimeMillis(), MDC.get("requestId"));
    }

    public static <T> Result<T> error(int code, String message) {
        return new Result<>(code, message, null,
                System.currentTimeMillis(), MDC.get("requestId"));
    }

    public static <T> Result<T> error(ErrorCode errorCode) {
        return new Result<>(errorCode.getCode(), errorCode.getMessage(), null,
                System.currentTimeMillis(), MDC.get("requestId"));
    }
}

// 分页结果
@Data
public class PageResult<T> {
    private List<T> records;
    private long total;
    private int page;
    private int pageSize;
    private int totalPages;

    public PageResult(List<T> records, long total, int page, int pageSize) {
        this.records = records;
        this.total = total;
        this.page = page;
        this.pageSize = pageSize;
        this.totalPages = (int) Math.ceil((double) total / pageSize);
    }
}

5.3 全局异常处理

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException e) {
        List<FieldError> details = e.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
            .collect(Collectors.toList());

        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST) // 400
            .body(Result.error(ErrorCode.PARAM_ERROR, details));
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Result<Void>> handleBusiness(BusinessException e) {
        return ResponseEntity
            .status(e.getHttpStatus())
            .body(Result.error(e.getCode(), e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Result<Void>> handleUnknown(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR) // 500
            .body(Result.error(ErrorCode.INTERNAL_ERROR));
    }
}

六、错误码体系设计

6.1 错误码分段设计

错误码格式:模块前缀(2位) + 分类(1位) + 具体错误(2位)

示例:
USER_AUTH_001 → 用户模块 - 认证类 - 第1个错误

模块前缀:
  USER  - 用户模块
  ORDER - 订单模块
  PAY   - 支付模块
  SYS   - 系统级

错误分类:
  AUTH  - 认证授权
  PARAM - 参数校验
  BIZ   - 业务逻辑
  THIRD - 第三方调用
  SYS   - 系统内部

6.2 Java 枚举实现

public enum ErrorCode {

    // 系统级
    SUCCESS(0, "success"),
    SYS_INTERNAL_ERROR(99999, "系统内部错误"),
    SYS_SERVICE_UNAVAILABLE(99998, "服务暂不可用"),

    // 认证授权
    USER_AUTH_001(10001, "未登录或 Token 已过期"),
    USER_AUTH_002(10002, "用户名或密码错误"),
    USER_AUTH_003(10003, "权限不足"),

    // 用户模块 - 参数
    USER_PARAM_001(10101, "用户名不能为空"),
    USER_PARAM_002(10102, "邮箱格式不正确"),

    // 用户模块 - 业务
    USER_BIZ_001(10201, "用户已存在"),
    USER_BIZ_002(10202, "用户不存在"),
    USER_BIZ_003(10203, "手机号已被绑定"),

    // 订单模块
    ORDER_PARAM_001(20101, "订单金额不能为空"),
    ORDER_BIZ_001(20201, "订单不存在"),
    ORDER_BIZ_002(20202, "订单状态不允许此操作"),
    ORDER_BIZ_003(20203, "库存不足"),

    // 支付模块
    PAY_BIZ_001(30201, "支付金额与订单不匹配"),
    PAY_THIRD_001(30301, "支付网关调用失败");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
}
// 使用示例
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;
    private final HttpStatus httpStatus;

    public BusinessException(ErrorCode errorCode, HttpStatus httpStatus) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
    }

    public int getCode() { return errorCode.getCode(); }
    public HttpStatus getHttpStatus() { return httpStatus; }
}

// 抛出
throw new BusinessException(ErrorCode.ORDER_BIZ_002, HttpStatus.CONFLICT);

七、API 版本控制

7.1 三种版本策略对比

策略示例优点缺点
URL 版本/api/v1/users直观、易调试URL 不够简洁
Header 版本Accept: application/vnd.api.v2+jsonURL 干净不易调试、缓存问题
查询参数版本/api/users?version=2简单污染查询参数、语义不清

7.2 推荐:URL 版本

// URL 版本是最务实的选择
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    // v1 版本
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    // v2 版本:breaking changes
}

7.3 Spring Boot 版本控制实现

// 方案:自定义注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping
public @interface ApiVersion {
    int value();
}

// 自定义 RequestMappingHandlerMapping
public class ApiVersionHandlerMapping extends RequestMappingHandlerMapping {
    @Override
    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return apiVersion != null ? new ApiVersionCondition(apiVersion.value()) : null;
    }
}

// 使用
@RestController
@ApiVersion(1)
@RequestMapping("/users")  // 自动映射为 /api/v1/users
public class UserController { }

7.4 何时升级版本

需要升级版本的 Breaking Changes:
- 删除字段
- 修改字段类型(String → Integer)
- 修改字段含义(cents → yuan)
- 删除整个接口
- 修改认证方式

不需要升级版本的兼容变更:
- 新增接口
- 新增可选字段
- 新增错误码
- 修改限流值

八、分页、排序、过滤设计

8.1 分页参数规范

GET /api/users?page=1&pageSize=20

请求参数:
  page     - 页码,从 1 开始,默认 1
  pageSize - 每页条数,默认 20,最大 100

响应头:
  X-Total-Count: 100
  X-Total-Pages: 5
  Link: </api/users?page=2>; rel="next", </api/users?page=5>; rel="last"
// 统一分页请求
@Data
public class PageRequest {
    @Min(1)
    private Integer page = 1;

    @Min(1)
    @Max(100)
    private Integer pageSize = 20;

    // 游标分页(适合大数据量)
    private String cursor;  // 上一页最后一条的游标值
}

8.2 游标分页 vs 偏移分页

偏移分页(OFFSET):
✅ 简单,支持跳页
❌ 大页码性能差(OFFSET 1000000 LIMIT 20 需要扫描 1000020 行)
适用:管理后台、数据量可控的场景

游标分页(Cursor):
✅ 性能恒定,不受页码影响
✅ 基于索引,扫描行数最少
❌ 不支持跳页
❌ 只能"上一页/下一页"
适用:Feed 流、大数据量列表、移动端
// 游标分页请求
GET /api/users?cursor=eyJpZCI6MTIzfQ==&limit=20

// 游标分页响应
{
  "code": 0,
  "data": {
    "records": [...],
    "nextCursor": "eyJpZCI6MTQzfQ==",
    "hasMore": true
  }
}

8.3 排序

GET /api/users?sort=-createdAt,+name
  - 表示降序,+ 表示升序

GET /api/users?sortBy=createdAt&sortOrder=desc
  更简单的方案
// 排序参数解析
@Component
public class SortParser {
    public Sort parse(String sort) {
        List<Sort.Order> orders = new ArrayList<>();
        for (String field : sort.split(",")) {
            field = field.trim();
            if (field.startsWith("-")) {
                orders.add(Sort.Order.desc(field.substring(1)));
            } else {
                String name = field.startsWith("+") ? field.substring(1) : field;
                orders.add(Sort.Order.asc(name));
            }
        }
        return Sort.by(orders);
    }
}

8.4 过滤

GET /api/users?status=active&role=admin
GET /api/users?createdAt=gte:2024-01-01&createdAt=lte:2024-06-30
GET /api/users?name=like:张

过滤操作符约定:

操作符示例说明
eq:status=eq:active等于(默认,可省略)
ne:status=ne:deleted不等于
gt:price=gt:100大于
gte:price=gte:100大于等于
lt:price=lt:1000小于
lte:price=lte:1000小于等于
like:name=like:张模糊匹配
in:status=in:active,pending在列表中
null:deletedAt=null:true是否为空

九、安全最佳实践

9.1 认证与授权

// JWT Token 放在 Authorization 头
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

// Spring Security 配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

9.2 输入校验

// 永远不要信任前端传来的数据
@Data
public class UserCreateRequest {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 50, message = "用户名长度 2-50 字符")
    @Pattern(regexp = "^[a-zA-Z0-9_\\u4e00-\\u9fa5]+$", message = "用户名只能包含中英文、数字和下划线")
    private String username;

    @NotBlank
    @Email(message = "邮箱格式不正确")
    private String email;

    @NotBlank
    @Size(min = 8, max = 128, message = "密码长度 8-128 字符")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
             message = "密码必须包含大小写字母、数字和特殊字符")
    private String password;

    @Min(value = 0, message = "年龄不能为负数")
    @Max(value = 150, message = "年龄不能超过 150")
    private Integer age;
}

9.3 敏感信息保护

// 密码等敏感字段不返回
@Data
public class UserVO {
    private Long id;
    private String username;
    private String email;
    @JsonIgnore  // 绝不返回密码
    private String password;
    private LocalDateTime createdAt;
}

// 日志脱敏
@ToString.Exclude
private String password;

// 或者自定义序列化
@JsonSerialize(using = SensitiveSerializer.class)
private String phone;

9.4 限流

// 登录接口限流
@PostMapping("/login")
@RateLimiter(key = "login", permitsPerSecond = 5, timeout = 10)
public Result<LoginVO> login(@RequestBody LoginRequest req) {
    // 防止暴力破解
}

十、OpenAPI / Swagger 文档化

10.1 SpringDoc 整合

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>
@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("电商平台 API")
                .version("1.0.0")
                .description("电商平台后端接口文档")
                .contact(new Contact()
                    .name("开发团队")
                    .email("dev@example.com")))
            .addSecurityItem(new SecurityRequirement().addList("JWT"))
            .components(new Components()
                .addSecuritySchemes("JWT", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")));
    }
}
@RestController
@RequestMapping("/api/users")
@Tag(name = "用户管理", description = "用户 CRUD 相关接口")
public class UserController {

    @Operation(summary = "获取用户列表", description = "支持分页、排序、过滤")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "成功"),
        @ApiResponse(responseCode = "401", description = "未认证")
    })
    @GetMapping
    public Result<PageResult<UserVO>> list(
            @Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") int page,
            @Parameter(description = "每页条数", example = "20") @RequestParam(defaultValue = "20") int pageSize) {
        // ...
    }
}

10.2 文档即契约

API 设计流程:
  1. 前后端根据需求讨论接口
  2. 后端编写 OpenAPI 文档(或使用工具设计)
  3. 前端根据文档并行开发 Mock
  4. 后端实现接口
  5. 联调对接

十一、总结

核心准则清单

最后的话

好的 API 设计是一个团队的"软实力"。它不需要高深的技术,但需要自律和共识。当你的 API 被前端、移动端、第三方顺畅调用时,那种"设计得好"的感觉,是对专业素养的最好褒奖。

记住两个原则:

  1. 为调用方设计:站在前端/第三方的角度思考接口好不好用
  2. 一致性大于完美:不完美的统一规范好过各自为政的"最佳实践"

本文规范适用于 Spring Boot 3.x + Java 17+。

上次编辑于:
贡献者: zhengtianqi