上周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 是不是全废了"。不是,它是增强,不是替代。原来的写法:@RestControllerAdvicepublic class GlobalExceptionHandler {@ExceptionHandler(BusinessException.class)public Result handleBusiness(BusinessException e) {return Result.error(e.getCode(), e.getMessage());}}换成ProblemDetail版本,只需继承ResponseEntityExceptionHandler:@RestControllerAdvicepublic 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@Overrideprotected 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配置能解决的问题,不值得写一百行代码。大多数时候,我们不是缺技术方案,是没注意到框架已经

