ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

全局异常处理-把错误拦在业务之外

全局异常处理-把错误拦在业务之外

07-全局异常处理-把错误拦在业务之外

大家好,我是黒漂技术佬。上篇我们把分页壳子PageResult讲透了,这篇聊一个"平时看不见、出事要你命"的东西——全局异常处理。

新手写业务最容易踩的坑:在一个方法里try-catch包半屏,catch 里e.printStackTrace(),然后return null。前端拿到null一脸问号,不知道是参数错了、没登录还是服务器炸了。更惨的是数据库抛个SQLException,直接把堆栈塞进 HTTP 响应,用户看见一坨红色异常页面。

“URM Ultra"的后端用 Spring Boot 3 的@RestControllerAdvice把异常收口得极其干净。今天我们就拆开看它是怎么做到的,以及为什么"业务代码里不该出现 try-catch”。


一、先搞懂:异常到底该在哪处理

很多教程一上来就贴@RestControllerAdvice,但没讲清楚为什么。我们先建立一条原则:

业务方法的职责是"把事做成",不是"把错兜住"。

什么意思?假设你写"扣库存":

// 反面教材:在业务里疯狂 try-catchpublicvoiddeductStock(LongdeviceId,StringslotNo,intqty){try{DeviceProductdp=repository.findByDeviceIdAndSlotNo(deviceId,slotNo);if(dp==null){thrownewRuntimeException("货道不存在");}if(dp.getStock()<qty){thrownewRuntimeException("库存不足");}dp.setStock(dp.getStock()-qty);repository.save(dp);}catch(Exceptione){e.printStackTrace();// 错:日志没、异常吞了throwe;// 或者 return; 错上加错}}

这种写法的毛病:

  1. 业务代码里夹着一堆异常处理,读起来像在跟异常搏斗,看不清主干逻辑;
  2. catch后e.printStackTrace()既不进日志框架、也进不了监控系统;
  3. 抛出的还是RuntimeException,前端收到一个 500 + 堆栈,根本不知道"库存不足"是业务逻辑错还是系统崩了。

正确思路:业务里只管"抛",统一在一个地方"接"。抛异常是廉价的、语义明确的;怎么把异常翻译成前端能懂的报文,交给全局处理器。


二、业务异常 BusinessException:给错误一个"业务身份"

项目先定义了一个专属异常,把"业务错误"和普通系统错误区分开:

// 项目源码:com.urm.ultra.common.BusinessException@GetterpublicclassBusinessExceptionextendsRuntimeException{privatefinalintcode;publicBusinessException(Stringmessage){this(500,message);}publicBusinessException(intcode,Stringmessage){super(message);this.code=code;}}

注意三点:

  • 它继承RuntimeException,是运行时异常,不用方法签名上写throws,业务里随手throw new BusinessException(...)即可;
  • 带了一个code字段。这就是"业务码"——Result里那个code,0 表示成功,非 0 表示各类错误;
  • 提供了两个构造:只传message时默认code=500;传(code, message)时自定义业务码,比如库存不足可以抛new BusinessException(4001, "库存不足"),前端就能按码做特定提示。

有了它,扣库存就能写得极其干净:

// 项目源码风格(示意):业务里只抛不抓publicvoiddeductStock(LongdeviceId,StringslotNo,intqty){DeviceProductdp=repository.findByDeviceIdAndSlotNo(deviceId,slotNo);if(dp==null){thrownewBusinessException("货道不存在");}if(dp.getStock()==null||dp.getStock()<qty){thrownewBusinessException(4001,"库存不足");}dp.setStock(dp.getStock()-qty);repository.save(dp);}

主干逻辑三行,错误用throw一句带过。这就是"把错误拦在业务之外"的第一层含义:业务代码只描述正常流程 + 在异常点抛出自带语义的异常。


三、@RestControllerAdvice:全局的"异常接盘侠"

关键角色登场。@RestControllerAdvice是 Spring 的"统一异常拦截器",专盯所有@RestController里抛出的异常。项目里就这么一个类:

// 项目源码:com.urm.ultra.common.GlobalExceptionHandler@Slf4j@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(BusinessException.class)publicResult<Void>handleBusiness(BusinessExceptione){returnResult.error(e.getCode(),e.getMessage());}@ExceptionHandler({MethodArgumentNotValidException.class,BindException.class})publicResult<Void>handleValidation(Exceptione){Stringmsg="参数校验失败";if(einstanceofMethodArgumentNotValidExceptionex&&ex.getBindingResult().getFieldError()!=null){msg=ex.getBindingResult().getFieldError().getDefaultMessage();}elseif(einstanceofBindExceptionex&&ex.getBindingResult().getFieldError()!=null){msg=ex.getBindingResult().getFieldError().getDefaultMessage();}returnResult.error(400,msg);}@ExceptionHandler(Exception.class)publicResult<Void>handleException(Exceptione){log.error("系统异常",e);returnResult.error(500,"系统异常: "+e.getMessage());}}

逐行拆:

  • @RestControllerAdvice:告诉 Spring"我是全局异常处理中心",作用范围覆盖所有 REST 接口;和@ControllerAdvice的区别是它默认把返回值当@ResponseBody处理,直接写 JSON,不用再标@ResponseBody。
  • @Slf4j:Lombok 注解,自动生成log对象,兜底异常时打日志用。

四、三个 @ExceptionHandler 的分工

@ExceptionHandler(Xxx.class)表示"这个方法专门处理 Xxx 类型的异常"。匹配顺序是最具体的优先,所以三个 handler 的层级是这样:

1. handleBusiness —— 业务异常,原样回传

@ExceptionHandler(BusinessException.class)publicResult<Void>handleBusiness(BusinessExceptione){returnResult.error(e.getCode(),e.getMessage());}

业务里抛的BusinessException,这里把它翻译成Result.error(code, message)。比如前面"库存不足"抛的是new BusinessException(4001, "库存不足"),前端就收到:

{"code":4001,"message":"库存不足","data":null}

前端按code=4001弹"该货道库存不足,请重试",体验直接拉满。

2. handleValidation —— 参数校验失败,统一 400

@ExceptionHandler({MethodArgumentNotValidException.class,BindException.class})publicResult<Void>handleValidation(Exceptione){...}

这两个异常来自参数校验(下一篇细讲):DTO 上标了@NotBlank但前端没传,Spring 在校验阶段就抛MethodArgumentNotValidException。这里统一转成Result.error(400, "<字段消息>)——把"哪个字段错、错在哪"的提示直接塞进message,前端照着弹就行。BindException是表单/路径变量绑定失败时抛的同类异常,一并接住,保证 GET 和 POST 两种校验失败都走同一套返回。

3. handleException —— 兜底,任何漏网的都归 500

@ExceptionHandler(Exception.class)publicResult<Void>handleException(Exceptione){log.error("系统异常",e);returnResult.error(500,"系统异常: "+e.getMessage());}

这是最后一道防线。任何你没预料的异常——NPE、SQL 报错、JSON 解析失败——都会被它接住,转成code=500的标准报文,同时用log.error把完整堆栈打进日志。

这一层至关重要:

  • 对前端:永远拿到结构统一的Result,不会出现"有时返回 Result、有时抛出 Tomcat 默认错误页"的分裂;
  • 对运维:异常进日志了,出问题能查;
  • 对用户:不会看到一坨英文堆栈,而是一句"系统异常: xxx"。

注意顺序——@ExceptionHandler(Exception.class)放在最后。因为它最泛,能接所有异常;前面的BusinessException、MethodArgumentNotValidException更具体,Spring 会优先匹配具体的。如果把Exception放第一,业务异常反而会被它吃掉,那就乱套了。


五、为什么这叫"与业务解耦"

回看开头那句原则,现在我们能说清"解耦"在哪:

  1. 业务方法零 try-catch:所有异常沿调用栈一路抛到GlobalExceptionHandler,业务代码只剩" happy path + 语义化 throw",可读性和可维护性暴涨;
  2. 异常翻译集中:所有"异常 → HTTP 响应"的映射只有一个类负责。哪天你想把 500 报文从"系统异常: xxx"改成"服务器开小差了",只改这一处;
  3. 响应结构恒定:无论成功失败,前端永远解析同一套Result{code,message,data},不用为不同错误写不同解析分支;
  4. 日志与对外信息分离:兜底异常把堆栈记进日志(给开发看),对外只回"系统异常"加简短信息(给用户看),避免把内部细节泄漏出去。

这四点合起来,就是"把错误拦在业务之外"——错误在边界(全局处理器)被统一栏截、翻译、记录,业务核心保持纯粹。


六、一个常见误区:401/404 要不要也管

细心的朋友会问:登录过期(401)、资源不存在(404)呢?

项目约定上,BusinessException的code是可扩展的——比如"设备不存在"你可以抛new BusinessException(4040, "设备不存在"),统一走code业务码,前端按码提示,HTTP 状态码仍是 200。这种"业务码优先"风格在前后端分离 + 小程序里很常见:小程序端统一只看Result.code,不依赖 HTTP 状态码分支。

当然你也可以让 Spring Security 这类安全框架直接返回 HTTP 401,那属于另一套鉴权链路。Demo 阶段"URM Ultra"用业务码表达业务失败,足够清晰,也避免了 HTTP 状态码和业务的语义打架。


七、小结与避坑清单

  • 业务里只抛不抓,异常沿调用栈抛到全局处理器,业务主干才干净;
  • 定义BusinessException(code, message),给错误一个"业务身份",前端按code做差异化提示;
  • @RestControllerAdvice+ 多个@ExceptionHandler,按"具体优先、兜底最后"的顺序分层接异常;
  • BusinessException→ 原样Result.error(code,msg);校验异常 → 统一 400;其它 → 兜底 500 + 打日志;
  • 兜底 handler 务必打log.error,否则线上异常就"消失"了,你永远不知道系统炸过;
  • 好处的本质:异常翻译集中、响应结构恒定、业务与错误处理解耦。

下一篇我们顺着MethodArgumentNotValidException这根线,把"参数校验"讲透——看看@NotBlank是怎么在进业务之前就把脏数据挡在门外的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进