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 本章知识结构图
下面这张图把请求校验、响应结构、错误码体系串起来了。你可以把它当作接口设计的「检查清单」:
3.5 实战建议:从零搭建规范
如果你现在要在一个新项目里落地这些规范,我建议按这个顺序来:
- 先定错误码枚举——这是地基,所有模块都要用
- 再写 Result 和 PageResult——统一响应结构
- 加全局异常处理——拦截校验错误,自动转成统一响应
- 最后优化参数校验——用注解+分组,配合业务校验
别想着一步到位。我见过团队花两周讨论错误码规范,结果代码一行没写。先跑起来,再迭代,比什么都强。
最后一个小建议:把错误码枚举和 Result 类打成公共 jar 包,所有微服务统一引用。这样就不会出现 A 服务 code=10001 表示「参数错误」,B 服务 code=10001 表示「用户不存在」的尴尬情况了。我在公司就是这么干的,效果很好。