十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

如何用SpringBoot优雅处理接口异常与参数校验

如何用SpringBoot优雅处理接口异常与参数校验 生产环境炸了。用户点击提交订单前端展示的不是友好提示而是一整屏Java堆栈——NullPointerException at com.example.order.service...客户端工程师骂骂咧咧产品经理火急火燎你作为后端只能低头承认接口异常处理不是加几个try-catch这么简单。代码里的异常就像洗衣机排出的脏水。你不能让脏水直接流到用户脸上而是要修一条下水道让它流向该去的地方。SpringBoot给了我们一套很体面的工具但用不好就成了糊在墙上的牛皮癣。错误响应体第一印象决定生死前端要的不是堆栈而是一个结构稳定的JSON。你得先定一个“协议”让所有异常返回的格式统一。比如{ code: 50001, message: 订单金额不能为负数, data: null }有了这个壳前后端才能坐下来谈。很多项目死在第一步有人返回{error: xx}有人返回{msg: xx}还有人直接返回空。这不是技术问题这是协作的信仰问题。定义响应体时别把message塞给用户看。用户只关心“您的余额不足”而不是“AccountBalanceNotEnoughException”。所以错误码要分层对外看的是业务码对内看的是异常类名和堆栈。这两者之间你需要一座桥。RestControllerAdvice全局拦水坝SpringMVC早就给你准备好了拦截器——RestControllerAdvice。它像一个站在所有Controller前面的保安统一检查异常并按规则放行。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResponseEntityResult handleBusiness(BusinessException e) { return ResponseEntity.status(HttpStatus.OK).body(Result.fail(e.getCode(), e.getMessage())); } ExceptionHandler(Exception.class) public ResponseEntityResult handleOther(Exception e) { log.error(系统异常, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.fail(500, 系统开小差了)); } }ExceptionHandler是按异常类型精确匹配的子类比父类优先。所以你先处理业务异常再兜底处理Exception。这个兜底很重要它保证了任何漏网的异常都不会变成秃头堆栈。但这里有个暗坑ExceptionHandler默认只能捕获Controller里面抛出的异常。如果你在Filter或者Interceptor里抛异常这个保安是看不见的。怎么办在Spring Boot 3.x里可以注册一个ErrorController或者使用HandlerExceptionResolver把过滤器里的异常也转成统一响应。这不是锦上添花而是堵住最后的下水道口。参数校验把脏数据挡在门外异常处理是事后补救参数校验才是事前防御。很多老项目还在Controller里写一堆ifif (user.getName() null || user.getName().trim().isEmpty()) { throw new IllegalArgumentException(姓名不能为空); }写十行八行还好写上百行就变成“if地狱”了。Bean Validation的注解方案才是长久之道。在POJO上声明规则public class CreateUserRequest { NotBlank(message 姓名不能为空) private String name; Min(value 1, message 年龄至少为1岁) Max(value 150, message 年龄最大150岁) private Integer age; Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; }然后在Controller的参数前加上Valid或者ValidatedPostMapping(/users) public Result createUser(Valid RequestBody CreateUserRequest request) { ... }一旦校验失败Spring会抛出MethodArgumentNotValidException然后你的全局异常处理器就能接住它并把第一条错误信息返回给前端。用注解代替if-else不是减少代码量而是把逻辑的可读性提高了一个维度。自定义校验注解告别复制粘贴框架给你提供了NotBlank、Email、Pattern但业务里总有奇怪的规则。比如“用户昵称不能包含管理员屏蔽词”或者“银行卡号必须通过Luhn算法校验”。这时候你要学会写自己的注解。一个自定义注解分三步定义注解标上Constraint。实现ConstraintValidator接口。在POJO上使用它。Target({ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) Constraint(validatedBy NotInBlacklistValidator.class) public interface NotInBlacklist { String message() default 昵称包含敏感词; Class?[] groups() default {}; Class? extends Payload[] payload() default {}; }public class NotInBlacklistValidator implements ConstraintValidatorNotInBlacklist, String { Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value null) return true; // 空值交给NotBlank管 return !value.contains(违禁词); } }自定义注解的乐趣在于你成了规则的主宰而不是被框架的规则束缚。当别人还在复制if时你已经能声明式地表达业务约束了。分组校验让同一实体在不同场景说不同话一个UserRegisterRequest在注册时要求phone必填在更新资料时phone选填。怎么办硬写两个类还是用groups分组public interface RegisterGroup {} public interface UpdateGroup {} public class UserRequest { NotBlank(groups RegisterGroup.class, message 注册时手机号必填) private String phone; NotBlank(message 昵称必填) private String nickname; }Controller里指定使用哪个组PostMapping(/register) public Result register(Validated(RegisterGroup.class) RequestBody UserRequest request) { ... } PutMapping(/users/{id}) public Result update(Validated(UpdateGroup.class) RequestBody UserRequest request) { ... }分组校验解决了“一个实体多处复用”的痛点但也带来了新的复杂度组多了以后你需要记住哪些字段属于哪个组。所以分组的颗粒度要收敛别搞出几十个组。能用组合就用组合——组的继承和接口多继承也能让你少写几个标记接口。校验陷阱与深水区你以为加了Valid就万事大吉太天真了。陷阱一嵌套对象不会自动校验。如果UserRequest里有个Address address你要在address字段上加Valid否则Address内部的校验注解全部失效。public class UserRequest { Valid NotNull(message 地址不能为空) private Address address; }陷阱二方法参数校验和RequestBody校验是两回事。如果接口是REST风格用RequestBody异常是MethodArgumentNotValidException。如果接口是普通方法比如从PathVariable取参数然后你给方法参数加了NotBlank那就得在类上标Validated异常类型也变成了ConstraintViolationException。不知道这几种异常的区别你就会出现“为什么我加了注解却不生效”的困惑。陷阱三校验顺序不可控。多个字段同时失败时默认返回哪个字段的错误这取决于Hibernate Validator的实现。你要么不依赖顺序保持“第一条错误即可”要么用GroupSequence定义有序分组。真正优雅的项目不依赖校验顺序而是依赖错误消息的准确性。日志与监控别把异常吞掉全局异常处理器里你写了log.error(系统异常, e)别嫌多。异常日志是半夜报警的救命稻草省了这行日志就等于蒙着眼睛跑黑夜。但日志也要分级。业务异常如“密码错误”属于“可预期”的不必打error栈warn就够。系统异常如“连接超时”、“空指针”才是真正的error。区分业务异常和系统异常不仅是日志级别的问题更是对系统健康状况的清醒认知。更进一步的优雅是给错误码一个枚举。定义一个ErrorCode枚举统一管理所有业务错误码、HTTP状态码、错误消息public enum ErrorCode { USER_NOT_FOUND(40401, HttpStatus.NOT_FOUND, 用户不存在), ORDER_AMOUNT_INVALID(40001, HttpStatus.BAD_REQUEST, 订单金额非法), SYSTEM_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, 系统繁忙); }这样异常处理器只需要一个方法搞定根据ErrorCode构造响应。错误码表不是一张Excel而是代码里的单一数据源。别把错误码散落在各个异常类里否则上线后你根本没法跟运维解释“为什么同样的错误两个码都不一样”。优雅的终点自动化测试与文档化异常处理逻辑写得好不好不能靠感觉靠测试。给全局异常处理器写单元测试用MockMvc去调用一个必定抛异常的接口断言返回的JSON结构。这比一百次手工点Postman都管用。另外把错误码文档化。可以借助SpringDoc或Swagger注解在接口文档里列出可能抛出的错误码。前后端撕逼的根源往往是“你报的错我看不懂”。当你把错误码、错误消息、参考链接都写清楚接口就变成了一个友好的服务员而不是一个暴躁的自动售货机。最后一块砖从处理异常到设计异常体系最终你会发现优雅不是某个注解的灵丹妙药而是一整套体系。你的异常类要有层级BaseException带code和message。BusinessException继承BaseException表示业务逻辑上的失败。SystemException继承BaseException表示基础设施故障。Controller里你可以继续抛出BusinessException让全局处理器去收拾。但真正的高手会在Service层就把异常翻译成领域错误而不是让一个SQLException穿透三层。参数校验也一样在DTO上声明规则在Service边界再次用Validated校验接口参数这叫“双重防御”。不是多此一举而是防止别人直接调用Service跳过了Controller校验。接口异常处理与参数校验是你的系统对外界提供的“契约精神”——你承诺要么返回正确数据要么返回明确错误绝不模棱两可。回头再看那个甩堆栈的接口你大概明白该怎么做了定义一个响应体写一个全局异常处理器给每个DTO加上校验注解再把业务异常翻译成业务码最后写几个测试钉住行为。这一套下来你的接口才算真正“穿上了衣服”。优雅不是华丽而是让每一种偏离预期的行为都有它应该去的地方。 SpringBoot已经给了你工具剩下的是你愿不愿意设计那条下水道。
返回列表