当前位置: 首页 > news >正文

如何正确写Controller?参数校验、异常处理

参考:https://mp.weixin.qq.com/s/gzX7NAVdHs20zbxp6WWkCg

兄弟们,大家是不是也有过这样的经历:打开项目里的 Controller 文件,密密麻麻的代码像一团乱麻,if-else 叠得比汉堡胚还多,参数校验写得比业务逻辑还长,好不容易找到个核心接口,调试的时候还得在一堆 try-catch 里绕圈圈?上次我帮同事排查个接口问题,点开那个 UserController,直接给我整懵了:一个新增用户的接口,从参数非空判断到手机号格式校验,再到业务逻辑处理,足足写了 200 多行,中间还夹杂着好几个 catch 块,一会儿抛个 “参数错误”,一会儿又返回个 “系统异常”,前端同学吐槽说 “你们这接口返回的状态码比我银行卡密码还乱”。后来跟阿里的一位大佬聊起这事儿,他甩过来一段 Controller 代码,我看完直接拍大腿:这才叫优雅!没有冗余的校验,没有混乱的异常处理,代码清爽得像刚冰镇过的可乐,喝一口都解腻。今天就把阿里大佬这套优雅的 Controller 写法拆解开,从参数校验到异常处理,再到职责边界,一步步教你怎么写,以后再也不用对着乱糟糟的代码血压飙升了。
一、先吐槽:你写的 Controller 是不是也这样?
在讲优雅写法之前,咱先把 “反面教材” 摆出来,看看你中了几条 ——1. 参数校验:if-else 写成 “千层饼”最常见的就是参数校验,比如一个创建订单的接口,要校验订单金额不能为负、商品 ID 不能为空、收货地址不能太长… 很多人会这么写:
@PostMapping(“/createOrder”)
public String createOrder(OrderDTO orderDTO) {
// 校验商品ID
if (orderDTO.getGoodsId() == null || orderDTO.getGoodsId().isEmpty()) {
return “商品ID不能为空”;
}
// 校验订单金额
if (orderDTO.getAmount() == null || orderDTO.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
return “订单金额必须大于0”;
}
// 校验收货地址
if (orderDTO.getAddress() == null || orderDTO.getAddress().length() > 200) {
return “收货地址不能为空且长度不能超过200字”;
}
// 校验支付方式
if (orderDTO.getPayType() == null || !Arrays.asList(1,2,3).contains(orderDTO.getPayType())) {
return “支付方式无效(1-微信,2-支付宝,3-银行卡)”;
}
// 后面才是业务逻辑…
orderService.createOrder(orderDTO);
return “创建订单成功”;
}你瞅瞅,光参数校验就写了十几行,要是参数再多一点,这 if-else 能叠到天上去。更坑的是,每个接口都要这么写一遍,复制粘贴的时候还容易漏改,上次我同事就把 “收货地址” 写成了 “收货地址 1”,线上报了错才发现。2. 异常处理:try-catch 裹成 “粽子”再说说异常处理,很多人怕接口报错,就把整个业务逻辑裹在 try-catch 里,有的甚至一个 Controller 里塞十几个 catch 块:@GetMapping(“/getOrderDetail”)
public Result getOrderDetail(String orderId) {
try {
// 校验订单ID
if (orderId == null || orderId.isEmpty()) {
return Result.fail(“订单ID不能为空”);
}
// 查订单详情
OrderDetailDTO detail = orderService.getDetail(orderId);
if (detail == null) {
return Result.fail(“订单不存在”);
}
// 转换DTO
OrderVO orderVO = new OrderVO();
orderVO.setOrderId(detail.getOrderId());
orderVO.setGoodsName(detail.getGoodsName());
// 一堆转换代码…
return Result.success(orderVO);
} catch (NullPointerException e) {
log.error(“空指针异常”, e);
return Result.fail(“系统异常,请重试”);
} catch (BusinessException e) {
log.error(“业务异常”, e);
return Result.fail(e.getMessage());
} catch (Exception e) {
log.error(“未知异常”, e);
return Result.fail(“系统繁忙,请稍后再试”);
}
}
这代码看着就累 —— 每个接口都要写一遍 try-catch,异常信息返回得还不统一,有的返回 “系统异常”,有的返回 “请重试”,前端同学还得专门做适配。更要命的是,一旦忘了加 log,出了问题连排查都没法排查。3. 职责混乱:Controller 变成 “大杂烩”最离谱的是有些 Controller 里塞满了业务逻辑,查数据库、调第三方接口、数据转换… 啥都干,比如这样:@PostMapping(“/refundOrder”)
public Result refundOrder(String orderId) {
try {
// 1. 校验订单状态(业务逻辑)
OrderDO orderDO = orderMapper.selectById(orderId);
if (orderDO == null) {
return Result.fail(“订单不存在”);
}
if (orderDO.getStatus() != 2) { // 2代表已支付
return Result.fail(“只有已支付的订单才能退款”);
}
// 2. 调用支付接口退款(第三方交互)
PayRefundRequest request = new PayRefundRequest();
request.setOrderId(orderId);
request.setAmount(orderDO.getAmount());
PayRefundResponse response = payClient.refund(request);
if (!“SUCCESS”.equals(response.getCode())) {
return Result.fail(“退款失败:” + response.getMsg());
}
// 3. 更新订单状态(数据库操作)
orderDO.setStatus(3); // 3代表已退款
orderDO.setRefundTime(new Date());
orderMapper.updateById(orderDO);
// 4. 发送退款通知(消息推送)
noticeClient.sendNotice(orderDO.getUserId(), “您的订单” + orderId + “已退款”);
return Result.success();
} catch (Exception e) {
log.error(“退款异常”, e);
return Result.fail(“退款失败”);
}
}这 Controller 简直是个 “全能选手”,从业务校验到数据库操作,再到第三方调用,全堆在这儿了。后来要加 “退款金额校验”,得在这堆代码里插一句;要改通知模板,又得在这儿找半天。维护的时候,鼠标滚轮都快磨平了。如果你也写过这样的 Controller,别慌,不是你菜,是没找对方法。接下来咱就跟着阿里大佬的思路,把这些问题一个个解决,让 Controller 清爽起来。
二、第一步:参数校验 —— 用注解代替 “千层饼” if-else阿里大佬说:参数校验不该是 Controller 的 “负担”,用 Spring 自带的校验注解,一句话就能搞定。咱先把 Spring Validation 这个工具用起来,它能帮你把参数校验的逻辑从 Controller 里 “摘” 出去,用注解的方式定义规则,简单又高效。1. 基础玩法:给 DTO 加注解首先,把参数封装成 DTO(数据传输对象),然后在字段上加上校验注解,比如 @NotNull、@NotBlank、@Min 这些:// 订单创建DTO
@Data
public class OrderCreateDTO {
// 商品ID:不能为空
@NotBlank(message = “商品ID不能为空”)
private String goodsId;
// 订单金额:不能为null,且大于0
@NotNull(message = “订单金额不能为空”)
@DecimalMin(value = “0.01”, message = “订单金额必须大于0”)
private BigDecimal amount;
// 收货地址:不能为空,且长度不超过200
@NotBlank(message = “收货地址不能为空”)
@Size(max = 200, message = “收货地址长度不能超过200字”)
private String address;
// 支付方式:只能是1、2、3
@NotNull(message = “支付方式不能为空”)
@InEnum(value = PayTypeEnum.class, message = “支付方式无效(1-微信,2-支付宝,3-银行卡)”)
private Integer payType;
}这里有几个细节要注意:@NotBlank 用于字符串,校验 “不为空且不是纯空格”;@NotNull 用于非字符串(比如 Integer、BigDecimal),校验 “不为 null”;@NotEmpty 用于集合,校验 “不为空且长度大于 0”—— 别用混了。@InEnum 是自定义注解(后面会讲),用来校验参数是否在枚举值里,比原来的 Arrays.asList 优雅多了。每个注解都加了 message,这样校验失败时能直接返回明确的提示,不用再手动写。然后在 Controller 方法的参数前加 @Validated 注解,Spring 就会自动帮你校验:@RestController
@RequestMapping(“/order”)
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping(“/create”)
public Result createOrder(@Validated @RequestBody OrderCreateDTO orderDTO) {
// 这里不用写一行校验代码!校验失败会自动抛异常
orderService.createOrder(orderDTO);
return Result.success(“创建订单成功”);
}
}你看,原来十几行的校验代码,现在一行都不用写了!如果参数不符合规则,Spring 会抛出 MethodArgumentNotValidException 异常,比如传的金额是 0,就会抛出 “订单金额必须大于 0” 的异常信息。2. 进阶玩法:分组校验有时候同一个 DTO 要在不同场景下用不同的校验规则,比如 “新增用户” 和 “修改用户”:新增时不用传 userId(自动生成),但修改时必须传 userId。这时候就需要 “分组校验”。首先定义两个空接口,代表不同的分组:// 新增分组
public interface AddGroup {}
// 修改分组
public interface UpdateGroup {}然后在 DTO 的注解里指定分组:@Data
public class UserDTO {
// 修改时必须传,新增时不用传
@NotNull(message = “用户ID不能为空”, groups = UpdateGroup.class)
private Long userId;
// 新增和修改都必须传
@NotBlank(message = “用户名不能为空”, groups = {AddGroup.class, UpdateGroup.class})
private String username;
// 新增时必须传,修改时可选
@NotBlank(message = “密码不能为空”, groups = AddGroup.class)
private String password;
}最后在 Controller 里指定要使用的分组:@RestController
@RequestMapping(“/user”)
public class UserController {
@Autowired
private UserService userService;
// 新增用户:用AddGroup分组的校验规则
@PostMapping(“/add”)
public Result addUser(@Validated(AddGroup.class) @RequestBody UserDTO userDTO) {
userService.addUser(userDTO);
return Result.success(“新增用户成功”);
}
// 修改用户:用UpdateGroup分组的校验规则
@PutMapping(“/update”)
public Result updateUser(@Validated(UpdateGroup.class) @RequestBody UserDTO userDTO) {
userService.updateUser(userDTO);
return Result.success(“修改用户成功”);
}
}这样一来,新增用户时不传 userId 也没问题,修改时不传 userId 就会校验失败 —— 不用再写两个 DTO,也不用在 Controller 里加 if-else 判断场景,优雅!3. 高级玩法:自定义校验注解有时候自带的注解不够用,比如要校验 “手机号格式”,这时候就可以自定义校验注解。比如定义一个 @Phone 注解:// 自定义手机号校验注解
@Target({ElementType.FIELD}) // 作用在字段上
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@Constraint(validatedBy = PhoneValidator.class) // 指定校验器
public @interface Phone {
// 校验失败的提示信息
String message() default “手机号格式不正确”;

// 分组
Class<?>[] groups() default {};// 负载
Class<? extends Payload>[] payload() default {};

}然后写一个校验器 PhoneValidator,实现 ConstraintValidator 接口:// 手机号校验器
publicclass PhoneValidator implements ConstraintValidator<Phone, String> {

// 手机号正则表达式
privatestaticfinal Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");@Override
public boolean isValid(String value, ConstraintValidatorContext context) {// 如果手机号为空,不校验(空值校验交给@NotBlank)if (value == null || value.isEmpty()) {returntrue;}// 匹配正则return PHONE_PATTERN.matcher(value).matches();
}

}之后在 DTO 里直接用 @Phone 注解:@Data
public class UserDTO {
// 其他字段…

@NotBlank(message = "手机号不能为空")
@Phone(message = "手机号格式不正确(请输入11位有效手机号)")
private String phone;

}
这样一来,手机号格式不对就会自动校验失败,不用再写 if (!PHONE_PATTERN.matcher(phone).matches()) 这种代码了。阿里大佬说,自定义校验注解能解决 90% 的复杂参数校验场景,而且复用性极高,下次其他 DTO 要校验手机号,直接加个注解就行。

三、第二步:异常处理 —— 全局 “抓包” 代替 “粽子” try-catch参数校验失败会抛异常,业务逻辑出错也会抛异常,总不能每个接口都写 try-catch 吧?阿里大佬的做法是:用全局异常处理器,把所有异常统一 “抓包” 处理。Spring 提供了 @RestControllerAdvice 和 @ExceptionHandler 注解,能帮你实现全局异常处理 —— 不管哪个 Controller 抛了异常,都会被对应的 @ExceptionHandler 方法捕获,然后统一返回格式。

  1. 先定义统一响应格式首先得有个统一的响应类,让所有接口返回的格式都一样,比如这样:

// 统一响应类
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclass Result {
// 状态码:200成功,其他失败
private Integer code;
// 提示信息
private String message;
// 响应数据
private T data;

// 成功:无数据
public static Result<Void> success() {returnnew Result<>(200, "操作成功", null);
}// 成功:有数据
publicstatic <T> Result<T> success(T data) {returnnew Result<>(200, "操作成功", data);
}// 成功:自定义提示
public static Result<Void> success(String message) {returnnew Result<>(200, message, null);
}// 失败:自定义状态码和提示
public static Result<Void> fail(Integer code, String message) {returnnew Result<>(code, message, null);
}// 失败:默认状态码(400)
public static Result<Void> fail(String message) {returnnew Result<>(400, message, null);
}

}

这样不管是成功还是失败,前端拿到的都是 {code:…, message:…, data:…} 的格式,不用再适配不同的返回值了。2. 写全局异常处理器然后写一个全局异常处理器,捕获各种异常:// 全局异常处理器
@RestControllerAdvice
@Slf4j
publicclass GlobalExceptionHandler {

// 1. 捕获参数校验异常(MethodArgumentNotValidException)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {// 获取校验失败的提示信息String message = e.getBindingResult().getFieldError().getDefaultMessage();log.warn("参数校验失败:{}", message);// 返回400状态码和提示信息return Result.fail(message);
}// 2. 捕获自定义业务异常(BusinessException)
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {log.warn("业务异常:{}", e.getMessage());// 业务异常一般返回400或自定义状态码return Result.fail(e.getCode(), e.getMessage());
}// 3. 捕获空指针异常(NullPointerException)
@ExceptionHandler(NullPointerException.class)
public Result<Void> handleNullPointerException(NullPointerException e) {log.error("空指针异常:", e); // 打印堆栈信息,方便排查// 空指针属于系统异常,返回500状态码,不暴露具体信息return Result.fail(500, "系统繁忙,请稍后再试");
}// 4. 捕获其他所有异常(Exception)
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {log.error("未知异常:", e); // 打印堆栈信息return Result.fail(500, "系统繁忙,请稍后再试");
}

}这里有几个关键要点:分异常类型处理:参数校验异常(用户输入错了)返回具体提示,业务异常(比如 “订单已退款”)返回业务提示,系统异常(空指针、数据库异常)返回通用提示 —— 既给用户明确的反馈,又不暴露系统内部信息。统一日志记录:参数校验和业务异常用 warn 级别,系统异常用 error 级别并打印堆栈,方便排查问题。以前每个接口都要写 log,现在一次搞定。不用再写 try-catch:Controller 里抛异常就行,比如业务逻辑里判断 “订单已退款”,就抛 BusinessException:@Service
public class OrderService {

public void refundOrder(String orderId) {OrderDO orderDO = orderMapper.selectById(orderId);if (orderDO.getStatus() == 3) { // 3代表已退款// 抛自定义业务异常throw new BusinessException(400, "订单已退款,无需重复操作");}// 其他业务逻辑...
}

}Controller 里就不用加 try-catch 了,清爽得很:@PostMapping(“/refund”)
public Result refundOrder(@RequestParam String orderId) {
orderService.refundOrder(orderId);
return Result.success(“退款成功”);
}如果订单已退款,就会自动返回 {code:400, message:“订单已退款,无需重复操作”, data:null},前端直接拿 message 提示用户就行 —— 再也不用在 Controller 里写 “return Result.fail (…)” 了。3. 自定义业务异常上面用到了自定义的 BusinessException,这里简单实现一下:// 自定义业务异常
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclass BusinessException extends RuntimeException {
// 状态码
private Integer code;
// 提示信息
private String message;

// 简化构造方法:默认状态码400
public BusinessException(String message) {this.code = 400;this.message = message;
}

}继承 RuntimeException 是因为 Spring 只捕获运行时异常(RuntimeException),如果继承 Exception(受检异常),就需要在方法上声明 throws,麻烦。有了这个异常,业务逻辑里遇到不符合规则的情况,直接抛就行,比如 “库存不足”、“用户未登录”,全局异常处理器会自动捕获并返回统一格式。
四、第三步:职责边界 ——Controller 只做 “传话筒”阿里大佬反复强调:Controller 的职责只有三个:接收请求、返回响应、调用 Service,别把业务逻辑、数据库操作、第三方调用塞进来。咱先看一个优雅的 Controller 应该长什么样:@RestController
@RequestMapping(“/order”)
@Slf4j
publicclass OrderController {

@Autowired
private OrderService orderService;// 创建订单
@PostMapping("/create")
public Result<OrderVO> createOrder(@Validated@RequestBody OrderCreateDTO orderDTO) {log.info("创建订单:{}", JSON.toJSONString(orderDTO));OrderVO orderVO = orderService.createOrder(orderDTO);return Result.success(orderVO);
}// 订单详情
@GetMapping("/detail")
public Result<OrderVO> getOrderDetail(@NotBlank(message = "订单ID不能为空") @RequestParamString orderId) {log.info("查询订单详情:orderId={}", orderId);OrderVO orderVO = orderService.getOrderDetail(orderId);return Result.success(orderVO);
}// 订单退款
@PostMapping("/refund")
public Result<Void> refundOrder(@NotBlank(message = "订单ID不能为空") @RequestParamString orderId) {log.info("订单退款:orderId={}", orderId);orderService.refundOrder(orderId);return Result.success("退款成功");
}

}你看,每个方法就三行左右代码:打印日志(可选)、调用 Service、返回结果。没有任何业务逻辑,没有数据库操作,没有第三方调用 ——Controller 就像个 “传话筒”,把请求传给 Service,把 Service 的结果返回给前端。那原来 Controller 里的那些逻辑,该放哪儿呢?1. 业务逻辑:全交给 Service比如 “订单退款” 的逻辑,应该放在 Service 里:@Service
@Slf4j
publicclass OrderService {

@Autowired
private OrderMapper orderMapper;@Autowired
private PayClient payClient;@Autowired
private NoticeClient noticeClient;@Transactional// 事务注解也在Service里加
public void refundOrder(String orderId) {// 1. 校验订单状态(业务逻辑)OrderDO orderDO = getOrderById(orderId);checkOrderRefundStatus(orderDO);// 2. 调用支付接口退款(第三方交互)PayRefundResponse response = callPayRefund(orderDO);// 3. 更新订单状态(数据库操作)updateOrderRefundStatus(orderDO);// 4. 发送退款通知(消息推送)sendRefundNotice(orderDO);log.info("订单退款成功:orderId={}", orderId);
}// 私有方法:拆分逻辑,提高可读性
private OrderDO getOrderById(String orderId) {OrderDO orderDO = orderMapper.selectById(orderId);if (orderDO == null) {thrownew BusinessException("订单不存在");}return orderDO;
}private void checkOrderRefundStatus(OrderDO orderDO) {if (orderDO.getStatus() != 2) { // 2代表已支付thrownew BusinessException("只有已支付的订单才能退款");}if (orderDO.getRefundStatus() == 1) { // 1代表已申请退款thrownew BusinessException("订单已申请退款,请勿重复操作");}
}private PayRefundResponse callPayRefund(OrderDO orderDO) {PayRefundRequest request = new PayRefundRequest();request.setOrderId(orderDO.getOrderId());request.setAmount(orderDO.getAmount());PayRefundResponse response = payClient.refund(request);if (!"SUCCESS".equals(response.getCode())) {thrownew BusinessException("调用支付接口失败:" + response.getMsg());}return response;
}private void updateOrderRefundStatus(OrderDO orderDO) {OrderDO updateDO = new OrderDO();updateDO.setId(orderDO.getId());updateDO.setStatus(3); // 3代表已退款updateDO.setRefundStatus(1);updateDO.setRefundTime(new Date());int rows = orderMapper.updateById(updateDO);if (rows != 1) {thrownew BusinessException("更新订单状态失败");}
}private void sendRefundNotice(OrderDO orderDO) {try {noticeClient.sendNotice(orderDO.getUserId(), "您的订单" + orderDO.getOrderId() + "已退款");} catch (Exception e) {// 通知失败不影响主流程,记录日志即可log.error("发送退款通知失败:userId={}, orderId={}", orderDO.getUserId(), orderDO.getOrderId(), e);}
}

}这样拆分后,每个方法只做一件事,可读性极高 —— 要改 “退款状态校验”,就找 checkOrderRefundStatus 方法;要改支付接口参数,就找 callPayRefund 方法。以后维护的时候,不用再在 Controller 里翻来翻去了。

http://www.dtcms.com/a/389836.html

相关文章:

  • 线性代数:LU与Cholesky分解
  • 饮用水在线监测设备:实时、精准地捕捉水体中的关键参数,为供水安全提供全方位保障
  • 【环境搭建】Conda安装教程
  • Java与机器学习的结合:库与应用!
  • DHCP基本原理及实验(ENSP配置)
  • 高系分十一:软件需求工程
  • MCP Server Chart AntV 项目解析
  • 2025药物市场调研分析案例(模板资源分享)
  • 飞网出口网关:安全便捷地访问受限资源
  • 大模型训练的三大显存优化策略
  • 动态加载js链接、异步传参加载组件、有趣打印
  • 【Python】Python异常、模块与包
  • 第三方网站系统测试:【基于Pytest的自动化测试框架的测试】
  • 每个 SIwave 求解器的正确激励
  • 给 C++ Protobuf“装上 Abseil”版本确认、Bazel/CMake 实战与避坑
  • Java 大视界 -- Java 大数据在智能物流运输车辆智能调度与路径优化中的技术实现
  • 电脑中的32位和64位
  • 如何免费使用AWS服务器?AWS Free Tier免费套餐申请与避坑指南
  • QML界面调用C++层阻塞函数,如何不卡界面
  • JVM GC 调优:GC 问题发现工具,五大 GC 异常模式,四大调优方案与案例实战
  • Excel处理控件Aspose.Cells教程:如何使用Python在Excel中创建下拉列表
  • React 18.2中使用Redux 5.0.1
  • 程序开发的基本规律
  • Day26_【深度学习(6)_神经网络NN(1.1)激活函数_softmax详解篇】
  • 通过调用deepseek大模型接口对千条评论信息进行文本分析/词频分析/情感分析
  • 攻坚家电代工转型痛点|远望电器牵手盘古信息,以IMS重塑数字制造根基
  • SpringBoot实现Markdown语法转HTML标签
  • DeepSeek:大语言模型在中文生态中的技术突破与应用探索
  • 【Agent博客分享】从多Agent问题到新的上下文工程方法
  • 点云分割中 offset 与 batch 表示的转换详解