你的统一返回体,Spring Boot 3内置了

上周Code Review,一个新同事提交了100多行异常处理代码。一个 Result 类,一个 ResultCode 枚举,一个 GlobalExceptionHandler,再来一堆 try-catch。

我问:"你知道Spring Boot 3已经内置了这套东西吗?"

他懵了。

大多数Java项目Controller里还在返回自封装的 Result,code=200/message="success"/data=xxx。这种约定没有错,但它有两个硬伤:第一,每个项目都得重新写一遍,代码重复但不完全一样,新项目上线别人得重新理解你的Result结构;第二,跟框架的异常处理链路是脱节的——参数校验失败走框架默认路径,业务异常走自定义Handler,两种格式不一致,前端要写两套解析逻辑。

Spring Boot 3的ProblemDetail机制就是来解决这个问题的,而且这玩意儿是RFC 7807国际标准,不是Spring自己拍脑袋发明的。

结论:三行配置替掉你的Result类

先看效果。假设你有个接口:

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
}

不开ProblemDetail时,返回的是一串白标错误页面或JSON:

{
  "timestamp": "2026-06-29T10:30:00",
  "status": 404,
  "error": "Not Found",
  "path": "/users/999"
}

前端拿到这个,还得自己从 status 里推断到底出了什么问题。

开启ProblemDetail只需两件事。第一,application.yml 加一行:

spring:
  mvc:
    problemdetails:
      enabled: true # 开启RFC 7807支持

第二,自定义异常实现 ErrorResponseException:

// 业务异常统一继承ErrorResponseException,框架自动渲染
public class UserNotFoundException extends ErrorResponseException {

    public UserNotFoundException(Long userId) {
        // HttpStatus + ProblemDetail,框架自动拼装响应体
        super(HttpStatus.NOT_FOUND,
              ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
                  "用户 " + userId + " 不存在"), null);
        // 设置type URI:指向你的错误码文档,前端可据此跳转
        getBody().setType(URI.create("https://api.your-domain.com/errors/user-not-found"));
        getBody().setTitle("用户不存在");
    }
}

请求 /users/999,返回:

{
  "type": "https://api.your-domain.com/errors/user-not-found",
  "title": "用户不存在",
  "status": 404,
  "detail": "用户 999 不存在",
  "instance": "/users/999"
}

前端拿到这个后,直接读 type 就能路由到对应的错误提示,读 detail 就能展示给用户。不需要任何Result包装类。

它跟你的GlobalExceptionHandler怎么配合?

大部分人对ProblemDetail的误解是"那我原来写的 @RestControllerAdvice 是不是全废了"。不是,它是增强,不是替代。

原来的写法:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result handleBusiness(BusinessException e) {
        return Result.error(e.getCode(), e.getMessage());
    }
}

换成ProblemDetail版本,只需继承
ResponseEntityExceptionHandler:

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // 处理自定义业务异常,返回ProblemDetail而非Result
    @ExceptionHandler(BusinessException.class)
    public ProblemDetail handleBusiness(BusinessException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, e.getMessage());
        // 自定义扩展字段:带上业务错误码
        pd.setProperty("bizCode", e.getCode());
        pd.setProperty("timestamp", Instant.now());
        return pd; // 注意:直接返回ProblemDetail,不用包在ResponseEntity里
    }

    // 处理参数校验异常——这个以前要写一堆代码
    // 继承ResponseEntityExceptionHandler后,框架自动处理MethodArgumentNotValidException
    @Override
    protected ResponseEntity handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(status,
                "请求参数校验失败");
        // 将字段校验错误注入到errors扩展属性中
        List errors = ex.getBindingResult()
                .getFieldErrors().stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .toList();
        pd.setProperty("errors", errors);
        return ResponseEntity.status(status).body(pd);
    }
}

关键点:继承
ResponseEntityExceptionHandler 之后,Spring MVC 内置的40多种异常(
HttpMessageNotReadableException、TypeMismatchException、
MissingPathVariableException等等)全都会被自动转换成ProblemDetail格式。以前这些异常要么返回500白页,要么你一个个写Handler去兜底——现在全托管了。

扩展属性:为你的领域定制字段

RFC 7807规定 type、title、status、detail、instance 是标准字段,但你完全可以通过 setProperty() 扩展任意字段,比如带上traceId方便排查:

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnknown(Exception e, HttpServletRequest request) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误");
    // 注入traceId:运维查日志时直接用这个ID定位
    pd.setProperty("traceId", MDC.get("traceId"));
    // 注入请求路径:方便前端判断要不要重试
    pd.setProperty("path", request.getRequestURI());
    return pd;
}

返回:

{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "服务器内部错误",
  "instance": "/api/orders/submit",
  "traceId": "a1b2c3d4e5f6",
  "path": "/api/orders/submit"
}

前端收到500错误,直接把 traceId 截个图发给后端,后端 grep a1b2c3d4e5f6 app.log 秒定位。比原来靠时间戳翻日志快了不止一个数量级。

什么时候不该用ProblemDetail?

实事求是的说,ProblemDetail不是银弹。它主要解决的是异常响应的标准化问题,正常业务数据的返回格式你仍然可以自己做主。但项目里最让人头疼的从来不是正常返回,是异常情况下的各类兜底处理。ProblemDetail恰好替你扛住了这一块。

另外一个需要注意的点:ErrorResponseException 的构造方法里,如果 detail 参数直接拼接了用户输入(比如上面的 "用户 " + userId + " 不存在"),在前端渲染时要做好XSS防护。这个跟ProblemDetail本身没关系,是你写代码的基本安全习惯。

最后说一句。如果你的项目还在Spring Boot 2.x,没用ProblemDetail,那你维护的自定义Result体系继续用着,没问题。但如果项目已经是Spring Boot 3.x甚至更高版本,再多维护一套Result类就属于重复造轮子了。三行YAML配置能解决的问题,不值得写一百行代码。

大多数时候,我们不是缺技术方案,是没注意到框架已经替你做了。

更新时间:2026-07-02

标签:科技   异常   字段   框架   用户   错误   代码   业务   项目   参数   格式

1 2 3 4 5

上滑加载更多 ↓
Top