AI摘要

文章以作者亲历的微服务接口混乱为引,系统梳理RESTful设计:用资源与状态转移替代动词CRUD,统一复数名词路径;规范HTTP方法、状态码、分页、过滤、版本管理及文档;给出电商订单API完整示例,强调直观、一致、可预测、兼容,为前端与协作效率服务。

前传:我们为什么需要这份指南?

我曾参与一个中型的微服务项目,大约有20个服务。最初,每个团队按照自己的理解设计API。结果,当我需要调用不同团队的接口时,发现自己像是在阅读不同的语言:

  • 用户服务:GET /api/v1/user/getUserInfo?id=123
  • 订单服务:POST /api/order/queryOrderList
  • 支付服务:GET /payment-service/retrievePayment/123

同一个项目里,有的用单数名词,有的用复数;有的用动词,有的用名词;有的路径带api前缀,有的带服务名。当新同事加入时,光是学习这些不一致的API规范就需要一周时间。更糟糕的是,前端对接时经常抱怨:"你们后端自己都没统一,我怎么知道该调哪个?"

从那次经历开始,我意识到一套统一的API设计规范不是"锦上添花",而是"雪中送炭"。

第一章:RESTful的灵魂——你真的理解了吗?

1.1 别把REST当CRUD的简单包装

很多开发者认为RESTful就是给CRUD操作换个HTTP方法的外衣,于是写出了这样的代码:

// 反面教材:这只是CRUD的HTTP包装
@PostMapping("/api/user/create")
public User createUser(@RequestBody User user) { ... }

@GetMapping("/api/user/read/{id}")  
public User getUser(@PathVariable Long id) { ... }

@PostMapping("/api/user/update")
public User updateUser(@RequestBody User user) { ... }

@PostMapping("/api/user/delete/{id}")
public void deleteUser(@PathVariable Long id) { ... }

问题在哪?

  • 在URI中使用动词(create/read/update/delete)
  • 用POST方法处理所有操作
  • 资源的状态变化不清晰

1.2 RESTful的核心:资源与状态转移

REST(Representational State Transfer)的核心思想是资源状态转移。让我们重构上面的例子:

// 正确示例:基于资源的操作
@PostMapping("/api/users")  // 创建用户 -> 往用户集合中添加资源
public User createUser(@RequestBody User user) { ... }

@GetMapping("/api/users/{id}")  // 获取特定用户
public User getUser(@PathVariable Long id) { ... }

@PutMapping("/api/users/{id}")  // 全量更新用户
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }

@PatchMapping("/api/users/{id}")  // 部分更新用户(可选)
public User partialUpdateUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) { ... }

@DeleteMapping("/api/users/{id}")  // 删除用户
public void deleteUser(@PathVariable Long id) { ... }

关键理解:

  • URI标识的是资源(用户),而不是操作
  • HTTP方法表示对资源的操作类型
  • 同一个URI,不同的HTTP方法可以有不同的含义

第二章:命名规范——让API自己会说话

2.1 资源命名:名词、复数、小写

糟糕的命名:

GET /getAllUsers
POST /createNewOrder
GET /fetchProductDetails/123

问题诊断:

  • 在URI中使用动词
  • 命名不一致(getAll vs fetch)
  • 单复数混用

优雅的命名:

GET    /users              # 获取用户列表
POST   /users              # 创建用户
GET    /users/{id}         # 获取特定用户
PUT    /users/{id}         # 更新用户
DELETE /users/{id}         # 删除用户

GET    /orders             # 获取订单列表  
POST   /orders             # 创建订单
GET    /orders/{id}        # 获取特定订单

2.2 关联资源的表达

当一个资源从属于另一个资源时,如何表达?

案例:获取用户123的所有订单

方案A:嵌套路径(当订单生命周期完全依赖用户时)

GET /users/123/orders

方案B:查询参数(当只是过滤条件时)

GET /orders?userId=123

选择标准:

  • 如果订单不能脱离用户存在(用户删了,订单也删了),用嵌套
  • 如果订单可以独立存在,只是按用户过滤,用查询参数

2.3 避免的常见陷阱

// 陷阱1:在URI中暴露操作类型
@PostMapping("/api/searchUsers")  // 不好
@GetMapping("/api/users?name=xxx") // 好

// 陷阱2:在URI中包含动词
@PostMapping("/api/convertToPDF")  // 不好
// 更好的设计:将"转换"视为创建PDF资源
@PostMapping("/api/pdf-documents")  // 好

// 陷阱3:使用奇怪的缩写
@GetMapping("/api/usr/{id}")  // 不好
@GetMapping("/api/users/{id}") // 好

第三章:HTTP方法——各司其职,不要乱来

3.1 每个方法都有明确的语义

方法语义是否幂等是否安全
GET获取资源
POST创建资源
PUT全量更新/创建资源
PATCH部分更新资源
DELETE删除资源

常见的错误用法:

// 错误:用GET执行有副作用的操作
@GetMapping("/api/users/{id}/disable")
public void disableUser(@PathVariable Long id) {
    // 修改了用户状态,但用的是GET方法
    userService.disableUser(id);
}

// 正确:将"禁用"视为更新操作
@PatchMapping("/api/users/{id}")
public User updateUser(@PathVariable Long id, 
                      @RequestBody UserUpdateRequest request) {
    // request中包含要更新的字段,如status=DISABLED
    return userService.partialUpdate(id, request);
}

3.2 POST vs PUT:创建还是更新?

POST的特点:

  • 用于创建资源
  • 客户端不知道资源ID(由服务器生成)
  • 响应通常返回201 Created
@PostMapping("/api/users")
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<User> createUser(@RequestBody @Valid UserCreateRequest request) {
    User user = userService.createUser(request);
    
    return ResponseEntity
        .created(URI.create("/api/users/" + user.getId()))  // 在Location头中返回资源URI
        .body(user);
}

PUT的特点:

  • 用于全量更新现有资源
  • 客户端知道资源ID
  • 也可用于创建(如果客户端指定ID),但这不是常见用法
  • 响应通常返回200 OK或204 No Content
@PutMapping("/api/users/{id}")
public User updateUser(@PathVariable Long id,
                      @RequestBody @Valid UserUpdateRequest request) {
    return userService.updateUser(id, request);
}

3.3 PATCH:部分更新的艺术

当只需要更新资源的几个字段时,PATCH比PUT更合适。

// PATCH请求体示例
{
  "op": "replace",
  "path": "/email",
  "value": "new-email@example.com"
}

// 或者使用简化的JSON Merge Patch
{
  "email": "new-email@example.com",
  "phone": null  // 设置phone为null
}

实现示例:

@PatchMapping("/api/users/{id}")
public User partialUpdateUser(@PathVariable Long id,
                             @RequestBody JsonNode patches) {
    // 使用自定义的逻辑处理部分更新
    return userService.partialUpdate(id, patches);
}

第四章:状态码——不要只用200和500

4.1 常用状态码及其含义

// 正确使用状态码
@GetMapping("/api/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    return userService.findById(id)
        .map(user -> ResponseEntity.ok(user))
        .orElse(ResponseEntity.notFound().build());  // 404 Not Found
}

@PostMapping("/api/users")
public ResponseEntity<User> createUser(@RequestBody @Valid UserCreateRequest request) {
    User user = userService.createUser(request);
    
    return ResponseEntity
        .created(URI.create("/api/users/" + user.getId()))  // 201 Created
        .body(user);
}

4.2 业务错误如何表达?

糟糕的做法:所有错误都返回200

{
  "code": 5001,
  "message": "用户不存在"
}
// HTTP状态码却是200 OK,这会让HTTP客户端困惑

好一点的做法:使用400系列状态码

@PostMapping("/api/orders")
public ResponseEntity<Order> createOrder(@RequestBody @Valid OrderCreateRequest request) {
    try {
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    } catch (InsufficientInventoryException e) {
        // 库存不足是业务逻辑错误,不是客户端参数错误
        // 但用400 Bad Request也不完全合适
        throw new ResponseStatusException(HttpStatus.CADHED, e.getMessage());
    }
}

更好的做法:定义清晰的错误响应格式

// 统一的错误响应体
public class ErrorResponse {
    private String code;        // 业务错误码,如"INSUFFICIENT_INVENTORY"
    private String message;     // 用户友好的错误消息
    private String detail;      // 开发调试用的详细错误信息(仅开发环境返回)
    private LocalDateTime timestamp;
    private String path;        // 请求路径
    
    // 构造方法、getter、setter
}

// 全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(InsufficientInventoryException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientInventory(
            InsufficientInventoryException ex, WebRequest request) {
        
        ErrorResponse error = ErrorResponse.builder()
            .code("INSUFFICIENT_INVENTORY")
            .message("商品库存不足")
            .detail(ex.getMessage())
            .timestamp(LocalDateTime.now())
            .path(request.getDescription(false).replace("uri=", ""))
            .build();
        
        // 使用409 Conflict表示资源状态冲突
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationExceptions(
            MethodArgumentNotValidException ex, WebRequest request) {
        
        // 收集所有验证错误
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.toList());
        
        ErrorResponse error = ErrorResponse.builder()
            .code("VALIDATION_FAILED")
            .message("请求参数验证失败")
            .detail(errors.toString())
            .timestamp(LocalDateTime.now())
            .path(request.getDescription(false).replace("uri=", ""))
            .build();
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

第五章:请求与响应设计

5.1 分页——不要返回全部数据

没有分页的灾难:

@GetMapping("/api/orders")
public List<Order> getAllOrders() {
    // 当订单有10万条时,这个接口会拖垮服务
    return orderService.findAll();
}

正确的分页设计:

// 分页请求参数
public class PageRequest {
    private Integer page = 1;      // 页码,从1开始
    private Integer size = 20;     // 每页大小
    private String sortBy = "id";  // 排序字段
    private String direction = "ASC"; // 排序方向
    
    // 计算offset给数据库使用
    public Integer getOffset() {
        return (page - 1) * size;
    }
}

// 分页响应
public class PageResponse<T> {
    private List<T> content;
    private PageMetadata metadata;
    
    @Data
    public static class PageMetadata {
        private Integer page;
        private Integer size;
        private Long totalElements;
        private Integer totalPages;
        private Boolean hasNext;
        private Boolean hasPrevious;
    }
}

// 使用示例
@GetMapping("/api/orders")
public PageResponse<Order> getOrders(
        @RequestParam(defaultValue = "1") Integer page,
        @RequestParam(defaultValue = "20") Integer size,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "DESC") String direction) {
    
    PageRequest pageRequest = new PageRequest(page, size, sortBy, direction);
    return orderService.findPage(pageRequest);
}

5.2 字段过滤——不要总是返回全部字段

问题: 不同的场景需要不同的字段组合

  • 列表页只需要基本信息
  • 详情页需要完整信息
  • 关联查询可能只需要ID和名称

解决方案1:多个端点

@GetMapping("/api/users")  // 返回精简信息
public List<UserSimple> getUsers() { ... }

@GetMapping("/api/users/{id}")  // 返回详细信息
public UserDetail getUserDetail(@PathVariable Long id) { ... }

解决方案2:查询参数控制字段

GET /api/users/{id}?fields=id,name,email

实现示例:

@GetMapping("/api/users/{id}")
public Map<String, Object> getUser(
        @PathVariable Long id,
        @RequestParam(required = false) String fields) {
    
    User user = userService.findById(id);
    
    if (fields == null || fields.isEmpty()) {
        return objectMapper.convertValue(user, Map.class);
    }
    
    // 根据fields参数过滤字段
    return filterFields(user, fields.split(","));
}

5.3 时间格式——统一处理时区

常见问题: 前端显示的时间和数据库存储的时间不一致

解决方案:统一使用ISO 8601格式

// 配置Jackson
@Configuration
public class JacksonConfig {
    
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
        return builder -> {
            // 统一使用ISO 8601格式
            builder.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
            // 设置时区为UTC
            builder.timeZone(TimeZone.getTimeZone("UTC"));
            // 日期序列化为字符串
            builder.serializers(new LocalDateTimeSerializer(
                DateTimeFormatter.ISO_LOCAL_DATE_TIME));
            // 字符串反序列化为日期
            builder.deserializers(new LocalDateTimeDeserializer(
                DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        };
    }
}

// DTO中使用
public class UserResponse {
    private Long id;
    private String name;
    
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
    private LocalDateTime createdAt;
    
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
    private LocalDateTime updatedAt;
}

第六章:版本管理——如何优雅地演进

6.1 版本在哪里?

常见的版本策略:

  1. URI路径版本(最常用)

    /api/v1/users
    /api/v2/users
  2. 查询参数版本

    /api/users?version=1
    /api/users?version=2
  3. 请求头版本

    GET /api/users
    Accept: application/vnd.myapp.v1+json

推荐使用URI路径版本,原因:

  • 直观,易于理解
  • 浏览器可直接访问测试
  • 不同版本的API可以并行部署

6.2 如何设计向后兼容的变更?

不兼容的变更示例(需要升版本):

// v1: 返回用户全名
public class UserV1 {
    private String fullName;
}

// v2: 拆分为姓和名(不兼容变更)
public class UserV2 {
    private String firstName;
    private String lastName;
}

兼容的变更示例(可在同一版本内):

// 原始版本
public class User {
    private String fullName;
    
    // 增加新字段(向前兼容)
    private String email;
    
    // 弃用旧字段,但不立即删除
    @Deprecated
    private String oldField;
    
    // 提供新旧字段的转换方法
    public String getFirstName() {
        if (fullName == null) return null;
        return fullName.split(" ")[0];
    }
}

6.3 版本迁移策略

// 同时支持v1和v2
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    // v1版本的实现
}

@RestController  
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    // v2版本的实现
}

// 或者在同一个控制器中处理多个版本
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping(params = "version=1")
    public UserV1 getUserV1(@PathVariable Long id) { ... }
    
    @GetMapping(params = "version=2")  
    public UserV2 getUserV2(@PathVariable Long id) { ... }
    
    // 默认版本
    @GetMapping
    public UserV2 getUser(@PathVariable Long id) { ... }
}

第七章:文档——别让API成为黑盒

7.1 代码即文档:使用Swagger/OpenAPI

@Configuration
@EnableOpenApi
public class SwaggerConfig {
    
    @Bean
    public Docket api() {
        return new Docket(DocumentationType.OAS_30)
            .select()
            .apis(RequestHandlerSelectors.basePackage("com.example.controller"))
            .paths(PathSelectors.any())
            .build()
            .apiInfo(apiInfo())
            .tags(
                new Tag("用户管理", "用户相关操作"),
                new Tag("订单管理", "订单相关操作")
            );
    }
    
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
            .title("电商系统API文档")
            .description("RESTful API设计示例")
            .version("1.0")
            .contact(new Contact("开发团队", null, "dev@example.com"))
            .build();
    }
}

// 在控制器中使用注解
@RestController
@RequestMapping("/api/users")
@Tag(name = "用户管理", description = "用户相关操作")
public class UserController {
    
    @GetMapping("/{id}")
    @Operation(summary = "获取用户详情", description = "根据ID获取用户详细信息")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "成功"),
        @ApiResponse(responseCode = "404", description = "用户不存在")
    })
    public ResponseEntity<User> getUser(
            @Parameter(description = "用户ID", required = true, example = "123")
            @PathVariable Long id) {
        // 实现逻辑
    }
}

7.2 编写接口设计文档

即使有Swagger,也应该有一个设计文档。模板示例:

# 用户服务API设计文档

## 1. 概述
- 服务名称:用户服务
- 版本:v1
- 基础路径:/api/v1

## 2. 资源设计

### 用户(User)
字段说明:
- id: 用户ID,自增长
- username: 用户名,唯一
- email: 邮箱
- status: 状态(ACTIVE/INACTIVE)

## 3. 接口详情

### 3.1 获取用户列表
- **端点**:GET /users
- **描述**:分页获取用户列表
- **查询参数**:
  - page: 页码,默认1
  - size: 每页大小,默认20
  - sortBy: 排序字段,默认id
  - direction: 排序方向,默认ASC
  
- **响应示例**:

{
"content": [

{
  "id": 1,
  "username": "john_doe",
  "email": "john@example.com"
}

],
"metadata": {

"page": 1,
"size": 20,
"totalElements": 100,
"totalPages": 5,
"hasNext": true,
"hasPrevious": false

}
}


## 4. 错误码

| 错误码 | HTTP状态码 | 描述 |
|--------|------------|------|
| USER_NOT_FOUND | 404 | 用户不存在 |
| USERNAME_EXISTS | 409 | 用户名已存在 |

第八章:实战:一个完整的API设计示例

让我们设计一个电商系统的订单API:

// 订单状态枚举
public enum OrderStatus {
    PENDING,        // 待支付
    PAID,           // 已支付
    SHIPPED,        // 已发货
    DELIVERED,      // 已送达
    CANCELLED       // 已取消
}

// 订单查询参数
@Data
public class OrderQuery {
    private OrderStatus status;
    private LocalDate startDate;
    private LocalDate endDate;
    private String customerName;
    
    // 分页参数
    private Integer page = 1;
    private Integer size = 20;
    
    // 排序
    private String sortBy = "createdAt";
    private String direction = "DESC";
}

// 订单创建请求
@Data
@Validated
public class OrderCreateRequest {
    @NotNull
    private Long customerId;
    
    @NotEmpty
    private List<OrderItemRequest> items;
    
    @NotBlank
    private String shippingAddress;
    
    @Data
    public static class OrderItemRequest {
        @NotNull
        private Long productId;
        
        @Min(1)
        private Integer quantity;
    }
}

// 订单响应
@Data
public class OrderResponse {
    private Long id;
    private Long customerId;
    private String orderNumber;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private String shippingAddress;
    private List<OrderItemResponse> items;
    
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
    private LocalDateTime createdAt;
    
    @Data
    public static class OrderItemResponse {
        private Long productId;
        private String productName;
        private BigDecimal unitPrice;
        private Integer quantity;
        private BigDecimal subtotal;
    }
}

// 订单控制器
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "订单管理")
public class OrderController {
    
    @GetMapping
    @Operation(summary = "查询订单列表", description = "支持多条件分页查询")
    public PageResponse<OrderResponse> queryOrders(@Valid OrderQuery query) {
        return orderService.queryOrders(query);
    }
    
    @GetMapping("/{orderNumber}")
    @Operation(summary = "获取订单详情")
    public OrderResponse getOrder(@PathVariable String orderNumber) {
        return orderService.getOrder(orderNumber);
    }
    
    @PostMapping
    @Operation(summary = "创建订单")
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseEntity<OrderResponse> createOrder(
            @RequestBody @Valid OrderCreateRequest request) {
        
        OrderResponse order = orderService.createOrder(request);
        
        return ResponseEntity
            .created(URI.create("/api/v1/orders/" + order.getOrderNumber()))
            .body(order);
    }
    
    @PatchMapping("/{orderNumber}/status")
    @Operation(summary = "更新订单状态")
    public OrderResponse updateOrderStatus(
            @PathVariable String orderNumber,
            @RequestBody Map<String, Object> update) {
        
        OrderStatus newStatus = OrderStatus.valueOf(
            (String) update.get("status"));
        
        return orderService.updateStatus(orderNumber, newStatus);
    }
    
    @PostMapping("/{orderNumber}/cancel")
    @Operation(summary = "取消订单")
    public OrderResponse cancelOrder(@PathVariable String orderNumber) {
        return orderService.cancelOrder(orderNumber);
    }
    
    // 嵌套资源:订单项
    @GetMapping("/{orderNumber}/items")
    @Operation(summary = "获取订单项列表")
    public List<OrderItemResponse> getOrderItems(
            @PathVariable String orderNumber) {
        return orderService.getOrderItems(orderNumber);
    }
}

总结:好API的标志

  1. 直观性:看一眼URL就知道要做什么
  2. 一致性:整个系统使用相同的设计模式
  3. 自描述性:请求和响应的含义清晰
  4. 可预测性:遵循行业惯例,不搞特殊化
  5. 灵活性:支持过滤、分页、字段选择等
  6. 兼容性:向前兼容,平滑升级

记住,API设计不是一次性工作,而是持续的过程。开始可能不完美,但有了统一的规范和持续的改进,你的API会变得越来越优雅、可维护。

最关键的,为API消费者(前端、移动端、其他服务)考虑,而不是只考虑自己实现的方便。毕竟,好的API设计,最终会让整个团队(包括你自己)的效率都得到提升。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:RESTful API设计指南:从混乱到优雅的实践之路
▶ 本文链接:https://www.huangleicole.com/backend-related/80.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏