统一响应封装与全局异常处理设计方法详解(AI 大模型评测平台的接口规约)

接口返回结构一天被问好几遍,根子不在前端,而在后端各写各的。更头疼的是异常处理,早期某个接口把堆栈直接抛给了前端,页面上冒出一串英文报错,用户根本看不懂。当时我对比过两种做法:一种是在每个 Controller 里各自 try-catch 然后统一返回,试了几天发现重复代码满天飞,异常处理还越写越乱;另一种是从架构上定一套全局规约,定义好”成功长什么样、失败长什么样”,让框架统一接管。我选了后者——BaseResponse<T> + ResultUtils + @RestControllerAdvice 三件套,从项目一开始就强制统一,前端只需要写一个响应拦截器就能吃下所有接口。

一、统一响应对象

先定义响应外壳,解决”所有接口返回格式不统一”的问题:

public class BaseResponse<T> {
    private int code;
    private T data;
    private String message;

    public BaseResponse(int code, T data, String message) {
        this.code = code; this.data = data; this.message = message;
    }
}

三段式:业务码、数据、消息。这里有个细节,code 用的是自定义业务码而不是 HTTP 状态码——HTTP 状态码语义太粗,200 既能表示成功也能表示”HTTP 正常但业务失败”,业务码能精确到”参数错误-密码为空”这种粒度。前端约定只看 code === 0 就当成功,其他都按业务错误处理,拦截器里一个判断就够。

这套外壳还顺带承担了”活文档”的作用。接口文档不用再为每个接口描述返回结构,前端看到 BaseResponse 就知道外层固定三字段,真正变化的只有 data 内部结构;平台后来接上接口文档自动生成后,前端连 data 里的字段都能自动拿到,对接效率又提了一截。

外壳定下来之后,我又在响应头里加了 traceId。早期排查问题时,一个请求从网关到数据库要穿四五层,日志分散在多处,没有关联标识只能靠时间戳猜。现在全局处理器打日志时带上 traceId,前端把请求失败时的 traceId 贴回来,顺着它就能把整条链路串起来。成本极低,排障效率却提升了一大截。

二、ResultUtils 工具类

BaseResponse 构造函数参数多,每个接口都 new 一遍很啰嗦,所以包一层静态工厂,解决”构造响应对象太繁琐”的问题:

public class ResultUtils {
    public static <T> BaseResponse<T> success(T data) {
        return new BaseResponse<>(0, data, "ok");
    }
    public static <T> BaseResponse<T> error(ErrorCode ec, String msg) {
        return new BaseResponse<>(ec.getCode(), null, msg);
    }
    public static BaseResponse<?> error(ErrorCode ec) {
        return new BaseResponse<>(ec.getCode(), null, ec.getMessage());
    }
}

Controller 写 return ResultUtils.success(data),比直接 new BaseResponse 干净一截。建议 success 和 error 的重载保持对称,团队用起来才不容易记混;也避免有人在业务代码里手滑把 code 和 data 传反,这类低级 bug 我们早期真的出过一次。后来我们还约定 error 的重载都必须传 ErrorCode 而不是裸 code,强制业务方在枚举里选错误码,新错误先登记再使用,错误码文档基本不会和实现脱节。

三、BusinessException 与 ErrorCode

业务异常用一个统一类,避免每个业务模块自定义异常子类导致异常类型爆炸。先定义异常本身:

public class BusinessException extends RuntimeException {
    private final int code;
    public BusinessException(ErrorCode ec) { super(ec.getMessage()); this.code = ec.getCode(); }
    public BusinessException(ErrorCode ec, String msg) { super(msg); this.code = ec.getCode(); }
    public int getCode() { return code; }
}

ErrorCode 用枚举集中所有错误码,平台约 20 个:

public enum ErrorCode {
    SUCCESS(0, "ok"),
    PARAMS_ERROR(40000, "请求参数错误"),
    NOT_LOGIN_ERROR(40100, "未登录"),
    NO_AUTH_ERROR(40101, "无权限"),
    NOT_FOUND_ERROR(40400, "请求数据不存在"),
    FORBIDDEN_ERROR(40300, "禁止访问"),
    SYSTEM_ERROR(50000, "系统内部异常"),
    OPERATION_ERROR(50001, "操作失败");
    // ... 业务码
}

这样设计的好处是错误码全平台统一、可搜索,排障时看日志里的 code 就能定位到具体模块。业务代码里想抛异常就 throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码不能为空"),不需要关心异常后续怎么被翻译成响应,职责边界很清楚。

四、全局异常处理器

这是规约落地的关键一环。所有 Controller 不写 try-catch,异常都抛到这里统一翻译,解决”异常散落各处、响应格式不统一”的问题:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(BusinessException.class)
    public BaseResponse<?> business(BusinessException e) {
        log.warn("业务异常 code={} msg={}", e.getCode(), e.getMessage());
        return ResultUtils.error(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public BaseResponse<?> valid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldError().getDefaultMessage();
        return ResultUtils.error(ErrorCode.PARAMS_ERROR, msg);
    }

    @ExceptionHandler(Exception.class)
    public BaseResponse<?> all(Exception e) {
        log.error("系统异常", e);
        return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统繁忙");
    }
}

三个分支覆盖三种典型场景:业务异常走 business 分支,参数校验失败走 valid 分支拿到具体字段错误,未知异常走 all 分支兜底。@Slf4j 把完整堆栈打进日志,前端拿到的是脱敏后的”系统繁忙”。需要留意的是,valid 分支让 @Valid 注解真正可用起来——以前参数校验错误信息是乱糟糟的默认格式,现在能精准返回”哪个字段错了、为什么错”。

这套处理器上线后,前端同学的体验变化很明显。以前参数校验报错,他们拿到的是 Spring 默认的一长串英文,现在能直接看到具体字段和原因,定位问题从翻代码变成看提示。接口联调的速度肉眼可见地变快,后端也不用再被追着问”这个报错是什么意思”。

五、为什么一定要做这件事

这套规约刚推行时,有同事觉得多此一举,直到项目进入联调期,对比才变得明显。把”各接口自己管”和”统一规约”两条路放在一起看,差别一目了然:

维度 各 Controller 自己管 BaseResponse + 全局异常
响应结构 每个接口自定,data/result 混用 统一三字段,前端拦截器一把接
异常出口 散落各处,有的堆栈直接抛给前端 全局处理器统一翻译、统一打日志
新增接口成本 每个都要写 try-catch 和响应组装 直接 return,框架兜底
排障效率 翻接口源码逐段找 看全局日志流和错误码分布

结论很直接:规约的收益随接口数量线性放大,接口越多越省。不统一响应会出现的问题:

  1. 前端要写一堆适配:有的接口返回 data,有的返回 result,有的直接返回对象。
  2. 错误码语义丢失:异常堆栈直接吐到前端,既不安全也不友好。
  3. 国际化困难:错误消息要本地化时,没有 message 字段就只能改源码。
  4. 日志难关联:异常散落在各处,全局处理器能在这一层打统一日志,把 traceId 串起来。

这些问题我们早期几乎全踩过一遍,所以强烈建议任何新项目从第一个接口就定好规约——后面再补的代价远高于一开始就做。等接口数量上到几百个,再回头统一格式就是一场伤筋动骨的改造。还有一个容易被忽略的收益:统一响应让监控报警变得好做。全局处理器是异常的统一出口,告警系统只要盯住这一个点,就能看到全平台哪里在报错、错误码分布长什么样,排障路径从”翻遍每个接口”收敛成”看一条日志流”。

六、踩过的坑

规约跑顺之后,坑主要集中在边界场景。下面四条是平台实际踩过的,每一条都对应一个线上小事故:

  • static 工具类被 Spring 接管:早期写过 ResultUtils.success(null),前端拿到 data: null 报错,改为统一返回空对象 new HashMap<>()。
  • 鉴权失败要返回 401 而不是 200:因为全局处理器返回的是 BaseResponse,默认 HTTP 200,业务码 40100 表示未登录,前端按业务码处理。如果前端要 HTTP 状态码判断,需要在处理器加 @ResponseStatus(HttpStatus.UNAUTHORIZED)。
  • 超大异常不要进日志:调用大模型超时堆栈很深,5MB 起步,要 catch 后只记摘要。
  • 404 与 500 风格不一:NoHandlerFoundException 默认走 Spring 默认页,要在 application.yml 配 spring.mvc.throw-exception-if-no-handler-found=true 让它也进全局处理器。

这套规约在平台运行期间一直很稳定,几乎没有为响应格式返过工。真要找还能改进的地方,就是错误码目前二十多个,随着业务增长会越来越多,后续打算按模块分段规划,比如 41xxx 留给用户中心、42xxx 留给评测任务,让码段本身就带归属信息,查日志时连模块都不用猜。

常见问题(FAQ)

Q1:BaseResponse 的 code 用 HTTP 状态码还是自定义?

自定义。HTTP 状态码语义粗(200/401/500),业务码(0/40000/40100)能精确到”参数错误-密码为空”这种细粒度,前端可以基于业务码做不同提示。

Q2:全局异常会不会吞掉重要信息?

不会。在 all 分支先 log.error("系统异常", e) 把堆栈打全,再返回脱敏后的”系统繁忙”给前端。

Q3:要不要每个业务都自定义异常子类?

不用。BusinessException + ErrorCode 枚举足够覆盖 95% 场景,强行继承只会让异常类爆炸。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部