【禁止血压飙升】阿里大佬写的 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<T> {
    // 状态码: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 里翻来翻去了。

2. DTO/VO 转换:用工具代替 “手撸”

很多人在 Controller 里写 DTO 转 Entity、Entity 转 VO 的代码,比如这样:

// 不优雅的转换方式
OrderVO orderVO = new OrderVO();
orderVO.setOrderId(orderDO.getOrderId());
orderVO.setGoodsName(orderDO.getGoodsName());
orderVO.setAmount(orderDO.getAmount());
orderVO.setStatusDesc(orderDO.getStatus() == 1 ? "待支付" : "已支付");
// 一堆set方法...

如果字段多,这代码能写几十行,还容易漏改。阿里大佬的做法是:用 MapStruct 工具自动生成转换代码,不用手动写 set 方法。首先在 pom.xml 里加依赖(以 Maven 为例):

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.3.Final</version>
</dependency>
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.5.3.Final</version>
    <scope>provided</scope>
</dependency>

然后定义一个转换接口:

// DTO/VO/Entity 转换接口
@Mapper(componentModel = "spring") // componentModel="spring" 表示生成的实现类会被Spring管理
publicinterface OrderConverter {

    // 单例(MapStruct会自动实现)
    OrderConverter INSTANCE = Mappers.getMapper(OrderConverter.class);

    // Entity转VO
    OrderVO doToVo(OrderDO orderDO);

    // DTO转Entity
    @Mapping(target = "id", ignore = true) // 忽略id字段(新增时自动生成)
    @Mapping(target = "createTime", expression = "java(new java.util.Date())") // 自定义createTime为当前时间
    OrderDO dtoToDo(OrderCreateDTO orderDTO);

    // 批量转换:List<Entity>转List<VO>
    List<OrderVO> doListToVoList(List<OrderDO> orderDOList);
}

这里的 @Mapping 注解很强大:

  • ignore = true:忽略某个字段,比如新增时不用传 id。
  • expression:自定义字段值,比如 createTime 设为当前时间。
  • source:指定源字段,比如 DTO 里的 goodsId 对应 Entity 里的 productId,可以写 @Mapping (source = "goodsId", target = "productId")。

然后在 Service 里直接用:

// Entity转VO
OrderVO orderVO = OrderConverter.INSTANCE.doToVo(orderDO);

// DTO转Entity
OrderDO orderDO = OrderConverter.INSTANCE.dtoToDo(orderDTO);

// 批量转换
List<OrderVO> orderVOList = OrderConverter.INSTANCE.doListToVoList(orderDOList);

MapStruct 会在编译时自动生成实现类,底层还是 set 方法,但不用你手动写了 —— 既优雅又不容易出错。如果字段名一致,连 @Mapping 都不用加,直接写方法就行。

3. 数据库操作:Service 调用 Mapper

数据库操作(CRUD)应该放在 Mapper 层(MyBatis 或 JPA),Service 调用 Mapper,Controller 不直接碰数据库。

比如 OrderMapper:

@Mapper
public interface OrderMapper {
    OrderDO selectById(String orderId);

    int insert(OrderDO orderDO);

    int updateById(OrderDO orderDO);

    List<OrderDO> selectByUserId(Long userId);
}

Service 里注入 Mapper 调用:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    public OrderDO getOrderById(String orderId) {
        return orderMapper.selectById(orderId);
    }
}

这样分层清晰:Controller -> Service -> Mapper,每个层只做自己的事,符合 “单一职责原则”。以后要换数据库框架(比如从 MyBatis 换成 JPA),只需要改 Mapper 层,Service 和 Controller 都不用动。

五、第四步:锦上添花 —— 让 Controller 更专业

解决了参数校验、异常处理、职责边界这三个核心问题,Controller 已经很优雅了。但阿里大佬还会加一些 “细节”,让 Controller 更专业、更好用。

1. 接口版本控制

随着业务迭代,接口可能需要升级,比如 V1 版的订单接口返回的字段少,V2 版需要加更多字段。这时候不能直接改旧接口,否则会影响正在使用 V1 接口的用户。

阿里常用的做法是 “URL 路径版本控制”,在 URL 里加版本号:

@RestController
@RequestMapping("/order/{version}") // 版本号放在URL路径里
publicclass OrderController {

    // V1版接口:返回基础字段
    @PostMapping("/create")
    public Result<OrderVO> createOrderV1(
            @PathVariable("version") String version, // 版本号
            @Validated@RequestBody OrderCreateDTO orderDTO) {
        if (!"v1".equals(version)) {
            thrownew BusinessException("版本号无效");
        }
        OrderVO orderVO = orderService.createOrderV1(orderDTO);
        return Result.success(orderVO);
    }

    // V2版接口:返回更多字段
    @PostMapping("/create")
    public Result<OrderVO> createOrderV2(
            @PathVariable("version") String version,
            @Validated@RequestBody OrderCreateDTO orderDTO) {
        if (!"v2".equals(version)) {
            thrownew BusinessException("版本号无效");
        }
        OrderVO orderVO = orderService.createOrderV2(orderDTO);
        return Result.success(orderVO);
    }
}

调用的时候,V1 接口是 /order/v1/create,V2 接口是 /order/v2/create—— 旧用户继续用 V1,新用户用 V2,互不影响。也可以用 “请求头版本控制”,在请求头里加 X-API-Version: v1,然后在 Controller 里用 @RequestHeader 获取版本号,这种方式 URL 更简洁,但需要前端配合传请求头。

2. 接口文档自动生成

手写接口文档又麻烦又容易错,阿里大佬都会用 Swagger 或 Knife4j 自动生成接口文档 —— 写代码的时候加几个注解,就能生成在线文档,前端同学可以直接在文档上测试接口。

以 Knife4j(Swagger 的增强版,更符合国内习惯)为例,先加依赖:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>

然后写个配置类:

@Configuration
@EnableOpenApi// 启用Swagger
public class Knife4jConfig {

    @Bean
    public Docket createRestApi() {
        returnnewDocket(DocumentationType.OAS_30)
                .apiInfo(apiInfo())
                .select()
                // 扫描所有有@RestController注解的类
                .apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
                .paths(PathSelectors.any())
                .build();
    }

    // 文档信息
    privateApiInfoapiInfo() {
        returnnewApiInfoBuilder()
                .title("订单系统接口文档")
                .description("订单系统的所有接口,包括创建订单、查询订单、退款等")
                .version("1.0.0")
                .contact(new Contact("技术团队", "https://xxx.com", "xxx@xxx.com"))
                .build();
    }
}

然后在 Controller 和 DTO 里加注解:

// Controller 注解
@RestController
@RequestMapping("/order")
@Api(tags = "订单管理接口") // 接口分组名称
public class OrderController {

    // 接口注解
    @PostMapping("/create")
    @ApiOperation("创建订单") // 接口名称
    @ApiImplicitParams({
            @ApiImplicitParam(name = "orderDTO", value = "订单创建参数", required = true, dataType = "OrderCreateDTO")
    }) // 接口参数描述
    public Result<OrderVO> createOrder(@Validated@RequestBody OrderCreateDTO orderDTO) {
        // ...
    }
}

// DTO 注解
@Data
@ApiModel("订单创建参数") // DTO描述
public class OrderCreateDTO {

    @NotBlank(message = "商品ID不能为空")
    @ApiModelProperty(value = "商品ID", required = true, example = "goods123") // 字段描述
    private String goodsId;

    @NotNull(message = "订单金额不能为空")
    @DecimalMin(value = "0.01", message = "订单金额必须大于0")
    @ApiModelProperty(value = "订单金额", required = true, example = "99.99")
    private BigDecimal amount;
}

启动项目后,访问 http://localhost:8080/doc.html,就能看到在线接口文档,还能直接填写参数测试接口 —— 前端同学再也不用追着你要文档了,你也不用再手动维护文档了。

3. 接口限流(可选)

如果接口访问量很大,比如秒杀接口,需要加限流,防止系统被打垮。阿里常用的是 Redis + 注解实现限流,比如自定义一个 @RateLimit 注解:

// 限流注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    // 限流key前缀
    String prefix() default "rate_limit:";

    // 限流时间(秒)
    int time() default 60;

    // 限流次数
    int count() default 100;
}

然后写一个切面,拦截加了 @RateLimit 注解的方法:

@Aspect
@Component
@Slf4j
publicclass RateLimitAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Pointcut("@annotation(com.xxx.annotation.RateLimit)")
    publicvoid rateLimitPointcut() {}

    @Around("rateLimitPointcut() && @annotation(rateLimit)")
    publicObject around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        // 1. 生成限流key(比如:rate_limit:order:create:192.168.1.1)
        String ip = getClientIp(); // 获取客户端IP
        String methodName = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();
        String key = rateLimit.prefix() + methodName + ":" + ip;

        // 2. 用Redis实现限流(INCR + EXPIRE)
        Long currentCount = redisTemplate.opsForValue().increment(key, 1);
        if (currentCount == 1) {
            redisTemplate.expire(key, rateLimit.time(), TimeUnit.SECONDS);
        }

        // 3. 判断是否超过限流次数
        if (currentCount > rateLimit.count()) {
            log.warn("接口限流:key={}, count={}, limit={}", key, currentCount, rateLimit.count());
            thrownew BusinessException("请求过于频繁,请稍后再试");
        }

        // 4. 没超过限流,执行原方法
        return joinPoint.proceed();
    }

    // 获取客户端IP(简化版)
    privateString getClientIp() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

然后在需要限流的接口上加 @RateLimit 注解:

@PostMapping("/seckill")
@RateLimit(time = 60, count = 10) // 60秒内最多访问10次
public Result<Void> seckillOrder(@RequestParam String goodsId) {
    orderService.seckillOrder(goodsId);
    return Result.success("秒杀成功");
}

这样一来,同一个 IP 60 秒内最多只能访问 10 次秒杀接口,防止恶意刷接口 —— 这个功能不是所有接口都需要,但对于高并发接口来说很有用。

六、总结:优雅 Controller 的 “黄金法则”

看到这里,你应该明白阿里大佬的 Controller 为什么优雅了 —— 不是用了多高深的技术,而是把 “简单的事情做到极致”。最后总结一下优雅 Controller 的 “黄金法则”:

  1. 参数校验:注解化

用 Spring Validation 注解代替 if-else,复杂场景自定义校验注解,分组校验适配多场景。

  1. 异常处理:全局化

用 @RestControllerAdvice + @ExceptionHandler 统一捕获异常,自定义业务异常区分业务错误和系统错误,统一响应格式。

  1. 职责边界:清晰化

Controller 只做 “接收请求、返回响应、调用 Service”,业务逻辑放 Service,数据库操作放 Mapper,DTO/VO 转换用 MapStruct。

  1. 细节优化:专业化

加接口版本控制避免兼容问题,用 Knife4j 自动生成接口文档,高并发接口加限流保护系统。

按照这个法则写出来的 Controller,代码清爽、职责明确、易于维护,同事接手的时候不会骂街,你自己调试的时候也不会血压飙升 —— 这才是阿里大佬真正的 “优雅”。

posted @ 2025-12-09 16:06  weieast  阅读(10)  评论(0)    收藏  举报