欢迎光临
我们一直在努力

【项目扩展实战|第3篇】Spring Boot 统一异常处理和业务异常封装

前言

前面我们已经完成了 JWT 登录认证和 Redis 验证码登录,这两个功能都属于比较典型的业务接口开发。

但是在真实项目里面,只会写正常流程还不够。比如登录时密码错误、验证码错误、用户不存在、参数为空、数据库操作异常,这些情况都需要给前端返回明确的错误信息。

如果每个 Controller 里面都写一堆 try…catch,代码会变得非常乱。所以这一篇我们就来学习一个项目里很常见、也很实用的功能:统一异常处理和业务异常封装。

本篇主要实现下面几个内容:

  • 自定义业务异常 BusinessException
  • 定义统一错误码枚举 ResultCode
  • 使用 @RestControllerAdvice 统一捕获异常
  • 在 Service 层主动抛出业务异常
  • 让 Controller 层保持干净

  • 一、为什么需要统一异常处理

    先看一种比较常见的写法:

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO) {
    try {
    String token = userService.login(loginDTO);
    return Result.success(token);
    } catch (Exception e) {
    return Result.error("登录失败");
    }
    }

    这种写法虽然能跑,但是问题很明显:

  • Controller 代码不够干净
  • 每个接口都要重复写 try…catch
  • 错误信息不统一
  • 前端不好判断具体错误原因
  • 后期维护成本高
  • 所以更推荐的方式是:

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO) {
    String token = userService.login(loginDTO);
    return Result.success(token);
    }

    如果业务失败,就在 Service 层抛出异常:

    throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);

    然后交给全局异常处理器统一返回。


    二、准备统一返回结果类

    如果项目里已经有 Result 类,可以直接复用。这里给一个比较常见的写法。

    package com.example.common;

    import lombok.Data;

    @Data
    public class Result<T> {

    private Integer code;

    private String message;

    private T data;

    public static <T> Result<T> success() {
    Result<T> result = new Result<>();
    result.setCode(200);
    result.setMessage("操作成功");
    return result;
    }

    public static <T> Result<T> success(T data) {
    Result<T> result = new Result<>();
    result.setCode(200);
    result.setMessage("操作成功");
    result.setData(data);
    return result;
    }

    public static <T> Result<T> error(String message) {
    Result<T> result = new Result<>();
    result.setCode(500);
    result.setMessage(message);
    return result;
    }

    public static <T> Result<T> error(Integer code, String message) {
    Result<T> result = new Result<>();
    result.setCode(code);
    result.setMessage(message);
    return result;
    }
    }

    文字说明

    这个 Result 类主要用于统一接口返回格式。

    正常返回:

    {
    "code": 200,
    "message": "操作成功",
    "data": {}
    }

    异常返回:

    {
    "code": 4001,
    "message": "账号或密码错误",
    "data": null
    }

    这样前端只需要统一判断 code,就可以知道接口是否成功。


    三、定义错误码枚举

    项目里面如果直接到处写字符串,比如:

    throw new BusinessException("账号不存在");
    throw new BusinessException("验证码错误");
    throw new BusinessException("密码错误");

    短期看没问题,但后期接口多了以后,错误信息会越来越分散。

    所以我们可以单独定义一个错误码枚举。

    package com.example.common;

    import lombok.Getter;

    @Getter
    public enum ResultCode {

    SUCCESS(200, "操作成功"),

    PARAM_ERROR(400, "参数错误"),

    UNAUTHORIZED(401, "用户未登录或登录已过期"),

    FORBIDDEN(403, "没有权限访问"),

    NOT_FOUND(404, "资源不存在"),

    SYSTEM_ERROR(500, "系统异常,请稍后再试"),

    USER_NOT_FOUND(1001, "用户不存在"),

    USER_PASSWORD_ERROR(1002, "账号或密码错误"),

    USER_DISABLED(1003, "用户已被禁用"),

    CAPTCHA_ERROR(2001, "验证码错误或已过期"),

    TOKEN_INVALID(3001, "Token 无效或已过期");

    private final Integer code;

    private final String message;

    ResultCode(Integer code, String message) {
    this.code = code;
    this.message = message;
    }
    }

    文字说明

    这里使用枚举统一管理错误码和错误信息。

    比如:

    ResultCode.USER_PASSWORD_ERROR

    表示账号或密码错误。

    这样写的好处是:

  • 错误码集中管理
  • 避免魔法值到处出现
  • 方便前后端约定
  • 后期修改错误提示更方便

  • 四、自定义业务异常 BusinessException

    接下来定义一个业务异常类。

    业务异常和系统异常不一样。

    业务异常一般是我们主动抛出的,比如:

  • 用户不存在
  • 密码错误
  • 验证码错误
  • 库存不足
  • 当前状态不允许操作
  • 这些不是程序崩了,而是业务规则不满足。

    package com.example.exception;

    import com.example.common.ResultCode;
    import lombok.Getter;

    @Getter
    public class BusinessException extends RuntimeException {

    private final Integer code;

    private final String message;

    public BusinessException(ResultCode resultCode) {
    super(resultCode.getMessage());
    this.code = resultCode.getCode();
    this.message = resultCode.getMessage();
    }

    public BusinessException(Integer code, String message) {
    super(message);
    this.code = code;
    this.message = message;
    }

    public BusinessException(String message) {
    super(message);
    this.code = 500;
    this.message = message;
    }
    }

    文字说明

    这里让 BusinessException 继承 RuntimeException。

    原因很简单:

  • 业务异常通常不希望每一层都强制 throws
  • 可以直接在 Service 层抛出
  • 最终由全局异常处理器统一捕获
  • Controller 不需要关心异常细节
  • 比如后面可以这样写:

    throw new BusinessException(ResultCode.CAPTCHA_ERROR);

    代码含义非常清楚:当前业务失败,原因是验证码错误。


    五、编写全局异常处理器

    统一异常处理的核心就是 @RestControllerAdvice。

    package com.example.exception;

    import com.example.common.Result;
    import com.example.common.ResultCode;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.validation.BindException;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;

    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
    log.warn("业务异常:{}", e.getMessage());
    return Result.error(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    String message = e.getBindingResult().getFieldError() == null
    ? "参数校验失败"
    : e.getBindingResult().getFieldError().getDefaultMessage();

    log.warn("请求体参数校验异常:{}", message);
    return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
    }

    @ExceptionHandler(BindException.class)
    public Result<Void> handleBindException(BindException e) {
    String message = e.getBindingResult().getFieldError() == null
    ? "参数绑定失败"
    : e.getBindingResult().getFieldError().getDefaultMessage();

    log.warn("请求参数绑定异常:{}", message);
    return Result.error(ResultCode.PARAM_ERROR.getCode(), message);
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
    log.error("系统异常:", e);
    return Result.error(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMessage());
    }
    }

    文字说明

    这个类是全局异常处理的核心。

    @RestControllerAdvice 可以理解为 Controller 的全局增强。只要 Controller 执行过程中抛出了异常,就可以被这里统一捕获。

    这里分别处理了几类异常:

  • BusinessException
  • 这是我们自己定义的业务异常,比如登录失败、验证码错误、用户不存在等。

  • MethodArgumentNotValidException
  • 这个异常通常出现在 @RequestBody 配合参数校验时。

  • BindException
  • 这个异常通常出现在普通请求参数绑定失败时。

  • Exception
  • 这是兜底异常,防止系统出现未知异常时直接把错误堆栈返回给前端。

    这里有一个很重要的点:系统异常不要直接把 e.getMessage() 返回给前端。

    因为系统异常里可能包含数据库字段、SQL、路径、服务信息等,不适合暴露给用户。


    六、在登录业务中使用业务异常

    下面以登录功能为例,看看业务异常应该放在哪里。

    1. Controller 层

    package com.example.controller;

    import com.example.common.Result;
    import com.example.dto.LoginDTO;
    import com.example.service.UserService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.*;

    @RestController
    @RequestMapping("/user")
    @RequiredArgsConstructor
    public class UserController {

    private final UserService userService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO) {
    String token = userService.login(loginDTO);
    return Result.success(token);
    }
    }

    文字说明

    Controller 层只负责三件事:

  • 接收请求参数
  • 调用 Service
  • 返回结果
  • 它不需要写大量 try…catch。

    如果登录失败,Service 会抛出业务异常,然后交给全局异常处理器处理。


    2. Service 层接口

    package com.example.service;

    import com.example.dto.LoginDTO;

    public interface UserService {

    String login(LoginDTO loginDTO);
    }


    3. Service 实现类

    package com.example.service.impl;

    import com.example.common.ResultCode;
    import com.example.dto.LoginDTO;
    import com.example.entity.User;
    import com.example.exception.BusinessException;
    import com.example.mapper.UserMapper;
    import com.example.service.UserService;
    import com.example.util.JwtUtil;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    import org.springframework.util.StringUtils;

    @Service
    @RequiredArgsConstructor
    public class UserServiceImpl implements UserService {

    private final UserMapper userMapper;

    @Override
    public String login(LoginDTO loginDTO) {
    if (loginDTO == null
    || !StringUtils.hasText(loginDTO.getUsername())
    || !StringUtils.hasText(loginDTO.getPassword())) {
    throw new BusinessException(ResultCode.PARAM_ERROR);
    }

    User user = userMapper.selectByUsername(loginDTO.getUsername());

    if (user == null) {
    throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
    }

    if (!loginDTO.getPassword().equals(user.getPassword())) {
    throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
    }

    if (user.getStatus() != null && user.getStatus() == 0) {
    throw new BusinessException(ResultCode.USER_DISABLED);
    }

    return JwtUtil.generateToken(user.getId(), user.getUsername());
    }
    }

    文字说明

    这里的业务判断都放在 Service 层。

    比如:

    if (user == null) {
    throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
    }

    用户不存在时,不直接返回 null,而是抛出一个业务异常。

    再比如:

    if (!loginDTO.getPassword().equals(user.getPassword())) {
    throw new BusinessException(ResultCode.USER_PASSWORD_ERROR);
    }

    密码错误也抛出同一个错误。

    这里故意把“用户不存在”和“密码错误”都返回成“账号或密码错误”,是为了避免接口暴露过多账号信息。实际开发中,这也是比较常见的安全处理。


    4. Mapper 层

    package com.example.mapper;

    import com.example.entity.User;
    import org.apache.ibatis.annotations.Mapper;
    import org.apache.ibatis.annotations.Select;

    @Mapper
    public interface UserMapper {

    @Select("select id, username, password, status from user where username = #{username}")
    User selectByUsername(String username);
    }

    文字说明

    Mapper 层只负责查询数据库。

    这里根据用户名查询用户信息,然后交给 Service 层判断业务规则。

    也就是说:

  • Mapper 管数据查询
  • Service 管业务判断
  • Controller 管请求响应
  • ExceptionHandler 管异常返回
  • 这样整体结构会比较清晰。


    七、验证码业务中的异常使用

    再看一个验证码登录的例子。

    @Override
    public String loginByCode(CodeLoginDTO loginDTO) {
    String cacheCode = redisTemplate.opsForValue().get("login:code:" + loginDTO.getPhone());

    if (!StringUtils.hasText(cacheCode)) {
    throw new BusinessException(ResultCode.CAPTCHA_ERROR);
    }

    if (!cacheCode.equals(loginDTO.getCode())) {
    throw new BusinessException(ResultCode.CAPTCHA_ERROR);
    }

    User user = userMapper.selectByPhone(loginDTO.getPhone());

    if (user == null) {
    throw new BusinessException(ResultCode.USER_NOT_FOUND);
    }

    return JwtUtil.generateToken(user.getId(), user.getUsername());
    }

    文字说明

    验证码登录里面,最常见的异常就是验证码错误或过期。

    这类异常非常适合封装成业务异常:

    throw new BusinessException(ResultCode.CAPTCHA_ERROR);

    这样 Controller 仍然可以保持很简单:

    @PostMapping("/login/code")
    public Result<String> loginByCode(@RequestBody CodeLoginDTO loginDTO) {
    String token = userService.loginByCode(loginDTO);
    return Result.success(token);
    }

    这就是统一异常处理带来的好处。


    八、参数校验异常的配合使用

    实际项目中,经常会配合 Validation 做参数校验。

    1. DTO 参数类

    package com.example.dto;

    import jakarta.validation.constraints.NotBlank;
    import lombok.Data;

    @Data
    public class LoginDTO {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;
    }

    如果是 Spring Boot 2 项目,一般使用:

    import javax.validation.constraints.NotBlank;

    如果是 Spring Boot 3 项目,一般使用:

    import jakarta.validation.constraints.NotBlank;

    2. Controller 使用 @Valid

    @PostMapping("/login")
    public Result<String> login(@RequestBody @Valid LoginDTO loginDTO) {
    String token = userService.login(loginDTO);
    return Result.success(token);
    }

    文字说明

    当请求参数不符合校验规则时,就会抛出 MethodArgumentNotValidException。

    这个异常前面已经在 GlobalExceptionHandler 中统一处理了,所以前端会收到类似这样的结果:

    {
    "code": 400,
    "message": "用户名不能为空",
    "data": null
    }

    这比直接报一大堆异常信息要友好很多。


    九、涉及知识点

    1. @RestControllerAdvice

    @RestControllerAdvice 是 Spring MVC 提供的全局异常处理注解。

    它可以统一处理 Controller 层抛出的异常,并自动返回 JSON 数据。

    2. @ExceptionHandler

    @ExceptionHandler 用来指定某个方法处理哪一种异常。

    比如:

    @ExceptionHandler(BusinessException.class)

    表示这个方法专门处理 BusinessException。

    3. 业务异常和系统异常

    业务异常一般是可预期的,比如:

  • 密码错误
  • 用户不存在
  • 库存不足
  • 验证码过期
  • 系统异常一般是不可预期的,比如:

  • 空指针异常
  • 数据库连接失败
  • SQL 执行异常
  • 文件读写失败
  • 业务异常可以把具体原因返回给前端,系统异常一般只返回统一提示。

    4. Controller 不应该堆业务判断

    Controller 的职责应该尽量简单。

    如果 Controller 里面写了大量业务判断和异常处理,后期接口一多,代码会很难维护。


    十、常见问题

    1. 为什么业务异常要继承 RuntimeException?

    因为业务异常通常不希望每一层都强制 throws。

    如果继承 Exception,很多方法都要显式声明异常,代码会变得比较啰嗦。

    2. 为什么还要保留 Exception.class 兜底处理?

    因为项目运行中可能出现一些没有预料到的异常。

    如果没有兜底处理,前端可能会收到不统一的错误格式,甚至看到后端异常堆栈。

    3. 系统异常能不能直接返回 e.getMessage()?

    不建议。

    因为系统异常信息可能暴露内部实现,比如 SQL、数据库字段、服务器路径等。更推荐返回统一文案,例如:

    系统异常,请稍后再试

    同时后端通过日志记录完整异常,方便排查。

    4. 错误码一定要用枚举吗?

    不是必须,但推荐。

    如果项目比较小,直接写字符串也能用;但只要接口开始变多,错误码集中管理会更清晰。


    十一、实际开发建议

    1. Controller 层不要到处写 try-catch

    一般接口不需要自己捕获业务异常。

    业务异常交给全局异常处理器,Controller 只负责正常流程即可。

    2. 业务失败优先抛 BusinessException

    比如:

    throw new BusinessException(ResultCode.USER_NOT_FOUND);

    不要简单返回 null 或 false,否则调用方还要继续判断,代码会越来越乱。

    3. 错误码要提前规划

    可以按照模块划分错误码:

    1000 – 用户模块
    2000 – 验证码模块
    3000 – Token 模块
    4000 – 订单模块
    5000 – 文件模块

    这样前后端联调时更容易定位问题。

    4. 日志和返回信息要区分

    返回给前端的信息要简洁、安全。

    日志里面可以记录更详细的异常信息,方便后端排查问题。


    十二、总结

    这一篇我们完成了 Spring Boot 项目里非常重要的一块内容:统一异常处理和业务异常封装。

    它解决的不是某一个接口的问题,而是整个项目的接口规范问题。

    通过这一篇,我们把异常处理从 Controller 中抽离出来,让业务失败统一抛 BusinessException,再通过 GlobalExceptionHandler 统一返回给前端。

    这样做之后,项目结构会更加清晰:

  • Controller 负责接收请求
  • Service 负责业务判断
  • Mapper 负责数据库操作
  • ExceptionHandler 负责异常响应
  • 这也是从“能写接口”到“写得像一个完整项目”的关键一步。

    赞(0)
    未经允许不得转载:171主机测评 » 【项目扩展实战|第3篇】Spring Boot 统一异常处理和业务异常封装
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址