3. 请求与响应优化:请求参数校验最佳实践、响应体结构设计、统一错误码体系

接口设计里,请求和响应就像一扇门的两面。门没装好锁,坏人随便进;门里东西乱堆,客人拿不到想要的。我见过太多项目,前期图省事,后期被参数校验和错误码折磨得死去活来。今天咱们就把这两件事彻底聊透。

3.1 请求参数校验:别让脏数据进门

参数校验不是简单的「if null return」。它是一套防御体系。我个人习惯把它分成三层:边界校验、业务校验、安全校验。

校验层级 校验内容 典型场景
边界校验 非空、长度、格式、范围 用户ID不能为空,手机号11位
业务校验 状态、关联关系、权限 订单状态为「已支付」才能退款
安全校验 SQL注入、XSS、越权 参数中不能包含恶意脚本

核心原则:边界校验在网关层做,业务校验在服务层做,安全校验两层都要做。别把业务逻辑写在参数校验注解里,那会让代码变得一团乱麻。

3.1.1 注解校验 vs 编程式校验

Java生态里,JSR-303(Bean Validation)是主流。但很多人用错了。我举个例子:

// 错误示范:把业务逻辑塞进注解
@NotNull(message = "订单状态不能为空")
@Pattern(regexp = "^(PAID|SHIPPED|DONE)$", message = "状态值不合法")
private String status;

// 正确做法:注解只做边界校验
@NotNull(message = "订单ID不能为空")
@Size(min = 1, max = 32, message = "订单ID长度不合法")
private String orderId;

// 业务校验单独写
public void validateRefund(CancelOrderRequest req) {
    Order order = orderRepo.findById(req.getOrderId());
    if (order.getStatus() != OrderStatus.PAID) {
        throw new BizException(ErrorCode.ORDER_NOT_PAID);
    }
}

为什么?因为注解里的正则表达式一旦写错,改起来要重新编译。而业务逻辑变化频繁,写在代码里更灵活。我在项目中遇到过,有人把几十个状态的枚举值写在注解里,结果每次加状态都要改注解、重新部署——这简直是给自己挖坑。

小技巧:用分组校验(Group)区分新增和更新场景。比如创建用户时ID为空,更新时ID必填。用 @Validated 注解配合分组接口,比写两套DTO清爽得多。

3.1.2 全局异常处理:统一拦截校验错误

参数校验失败后,返回的错误信息要统一。别让前端看到「status: 400, message: 参数错误」这种模糊信息。我习惯用 @RestControllerAdvice 统一处理:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors()
            .stream()
            .map(err -> err.getField() + ":" + err.getDefaultMessage())
            .collect(Collectors.joining("; "));
        return Result.error(ErrorCode.PARAM_INVALID.getCode(), msg);
    }

    @ExceptionHandler(BizException.class)
    public Result<Void> handleBiz(BizException e) {
        return Result.error(e.getCode(), e.getMessage());
    }
}

这样前端拿到的永远是统一结构:{ "code": 10001, "message": "orderId: 订单ID不能为空", "data": null }。调试起来一目了然。

3.2 响应体结构设计:给前端一个稳定的家

响应体结构,说白了就是前后端之间的「契约」。契约不稳定,两边都痛苦。我见过最离谱的接口,成功返回 { "data": {...} },失败返回 { "error": "xxx" },前端每次都要判断字段存在不存在。这哪是接口,简直是猜谜游戏。

3.2.1 统一响应体:Result 模式

我推荐使用泛型 Result 类,所有接口返回同一结构:

public class Result<T> {
    private int code;       // 业务状态码
    private String message; // 提示信息
    private T data;         // 数据体
    private long timestamp; // 时间戳,方便排查

    // 成功
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "success", data, System.currentTimeMillis());
    }

    // 失败
    public static <T> Result<T> error(int code, String message) {
        return new Result<>(code, message, null, System.currentTimeMillis());
    }
}

为什么加 timestamp?有一次线上排查问题,客户端和服务端时间不一致,导致缓存数据过期判断出错。加上时间戳后,一眼就能看出数据是什么时候生成的。这个字段救过我很多次。

注意:code 和 HTTP 状态码是两回事。HTTP 状态码表示网络层面的结果(200、400、500),code 表示业务层面的结果。比如「用户余额不足」是业务错误,HTTP 状态码依然是 200,但 code 是 10010。前端只看 code,不看 HTTP 状态码。

3.2.2 分页响应:别让前端自己算

分页接口的响应体,我建议统一成:

public class PageResult<T> {
    private List<T> list;       // 当前页数据
    private long total;         // 总记录数
    private int page;           // 当前页码
    private int size;           // 每页大小
    private int totalPages;     // 总页数(帮前端算好)
    private boolean hasMore;    // 是否还有更多(移动端常用)
}

前端拿到 totalPages 和 hasMore,直接渲染分页组件,不用自己算。少一个计算,就少一个 Bug。

3.3 统一错误码体系:让错误可追溯

错误码是接口的「病历本」。好的错误码体系,能让运维和开发在 5 秒内定位问题。差的错误码体系……嗯,我见过一个项目,错误码只有 0 和 1,0 代表成功,1 代表失败。失败原因全靠看日志,那叫一个酸爽。

3.3.1 错误码设计原则

原则 说明 反例
唯一性 每个错误码只对应一种错误 10001 既表示「参数错误」又表示「用户不存在」
可扩展 按模块分段,预留空间 用户模块 10000-19999,订单模块 20000-29999
可读性 错误码+错误消息,消息要具体 「系统错误」不如「订单状态不允许退款」
版本兼容 已发布的错误码不能删除或修改含义 旧版本用 10001 表示「参数错误」,新版本改成「权限不足」

3.3.2 错误码分段示例

// 通用错误码:0-9999
SUCCESS(0, "成功"),
PARAM_INVALID(1000, "参数校验失败"),
UNAUTHORIZED(1001, "未登录或Token过期"),
FORBIDDEN(1002, "无权限访问"),
NOT_FOUND(1003, "资源不存在"),
METHOD_NOT_ALLOWED(1004, "请求方法不支持"),
TOO_MANY_REQUESTS(1005, "请求频率过高"),

// 用户模块:10000-19999
USER_NOT_EXIST(10000, "用户不存在"),
USER_PASSWORD_ERROR(10001, "密码错误"),
USER_ACCOUNT_LOCKED(10002, "账号已被锁定"),
USER_PHONE_DUPLICATE(10003, "手机号已被注册"),

// 订单模块:20000-29999
ORDER_NOT_EXIST(20000, "订单不存在"),
ORDER_STATUS_INVALID(20001, "订单状态不允许操作"),
ORDER_REFUND_EXCEED(20002, "退款金额超过订单金额");

我曾经在一个项目中,把错误码定义在枚举里,然后通过 ErrorCode 接口统一暴露。这样 IDE 里按 Ctrl+点击就能跳转到定义,比看文档快多了。

避坑指南:错误码不要用负数,不要用 0 以外的数字表示成功。我曾经见过一个项目,成功返回 code=1,失败返回 code=0。结果前端判断逻辑写反了,线上出了大问题。统一用 0 表示成功,非 0 表示失败,这是行业惯例,别搞特殊。

3.4 本章知识结构图

下面这张图把请求校验、响应结构、错误码体系串起来了。你可以把它当作接口设计的「检查清单」:

请求与响应优化知识结构图 接口请求与响应优化核心结构 请求参数校验 响应体结构设计 统一错误码体系 边界校验(非空/长度/格式) 业务校验(状态/权限) 安全校验(SQL注入/XSS) 全局异常统一处理 分组校验(新增/更新) 统一 Result<T> 泛型 code + message + data timestamp 时间戳 分页响应 PageResult 业务码 ≠ HTTP状态码 唯一性:一码一义 可扩展:按模块分段 可读性:具体错误消息 版本兼容:不删不改 枚举定义 + IDE跳转 三者协同:校验失败 → 统一错误码 → 统一响应体返回,前端只认 code 和 message

3.5 实战建议:从零搭建规范

如果你现在要在一个新项目里落地这些规范,我建议按这个顺序来:

  1. 先定错误码枚举——这是地基,所有模块都要用
  2. 再写 Result 和 PageResult——统一响应结构
  3. 加全局异常处理——拦截校验错误,自动转成统一响应
  4. 最后优化参数校验——用注解+分组,配合业务校验

别想着一步到位。我见过团队花两周讨论错误码规范,结果代码一行没写。先跑起来,再迭代,比什么都强。

最后一个小建议:把错误码枚举和 Result 类打成公共 jar 包,所有微服务统一引用。这样就不会出现 A 服务 code=10001 表示「参数错误」,B 服务 code=10001 表示「用户不存在」的尴尬情况了。我在公司就是这么干的,效果很好。