异常处理最佳实践指南

核心设计理念

三层异常分类

三层异常分类-aaf0cb88

错误类型区分

错误类型 状态码 处理方式 返回内容
CLIENT 4xxxx 返回原样 message + description
SERVER 5xxxx 隐藏敏感信息 通用消息 + Trace ID
SUCCESS 0 正常返回 成功消息 + 数据

架构概览

类设计关系图

ErrorCode (枚举)
├─ code: int
├─ message: String
├─ description: String
└─ type: ErrorType
    ├─ CLIENT
    ├─ SERVER
    └─ SUCCESS

        ↓ 用于构造

BusinessException (异常)
├─ code: int
├─ message: String
├─ description: String
└─ errorType: ErrorType

        ↓ 捕获并处理

GlobalExceptionHandler (处理器)
└─ 生成 BaseResponse

BaseResponse (响应)
├─ code: int
├─ message: String
├─ description: String
├─ data: T
├─ timestamp: long
└─ traceId: String

响应格式示例

成功响应

{
  "code": 0,
  "message": "success",
  "description": null,
  "data": {
    "id": 1,
    "username": "john"
  },
  "timestamp": 1704067200000,
  "traceId": null
}

客户端错误响应

{
  "code": 10001,
  "message": "user not found",
  "description": "User with id 999 does not exist",
  "data": null,
  "timestamp": 1704067200000,
  "traceId": null
}

服务器错误响应

{
  "code": 50000,
  "message": "System error, please contact support",
  "description": "系统错误,请联系管理员",
  "data": null,
  "timestamp": 1704067200000,
  "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

各模块详解

1. ErrorCode 枚举设计

编码规范

0        → 成功
40000    → 客户端基础错误(4开头)
50000    → 服务器基础错误(5开头)
1xxxx    → 用户模块错误
2xxxx    → 作品模块错误
3xxxx    → 销售模块错误

为什么要加 description 字段

// ❌ 不够好
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
// 响应:{"code": 10001, "message": "user not found"}
// 问题:用户不知道是哪个用户,前端无法给出有意义的提示

// ✅ 更好
throw new BusinessException(ErrorCode.USER_NOT_FOUND, "User with id 999 does not exist");
// 响应:{"code": 10001, "message": "user not found", "description": "User with id 999 does not exist"}
// 优点:提供详细信息,前端可以显示具体的原因

ErrorType 的作用

// CLIENT 错误:直接返回给客户端
ErrorCode.USER_NOT_FOUND(10001, "user not found", "...", ErrorType.CLIENT)
// → 响应中包含 description,前端可以展示给用户

// SERVER 错误:隐藏敏感信息,返回 Trace ID
ErrorCode.DATABASE_ERROR(50100, "database error", "...", ErrorType.SERVER)
// → 响应中隐藏真实错误,只返回 Trace ID,用于后台追踪

2. BusinessException 异常类

构造方式对比

// 方式1:仅使用错误码(推荐简单场景)
throw new BusinessException(ErrorCode.USER_NOT_FOUND);

// 方式2:错误码 + 自定义描述(推荐大多数场景)
throw new BusinessException(
    ErrorCode.USER_NOT_FOUND,
    "User with id " + userId + " does not exist"
);

// 方式3:完整构造(通常不需要直接使用)
throw new BusinessException(
    "user not found",
    10001,
    "User with id " + userId + " does not exist",
    ErrorType.CLIENT
);

为什么要继承 RuntimeException

RuntimeException
├─ 优点1:无需在方法签名中声明 throws
├─ 优点2:能被 @Transactional 正确识别进行回滚
├─ 优点3:支持链式处理
└─ 缺点:需要程序员显式处理(通过异常处理器)

3. ThrowUtils 工具类

快速异常抛出

// 场景1:参数校验
Long userId = 10L;
ThrowUtils.throwIf(userId == null || userId <= 0, ErrorCode.PARAMS_ERROR);

// 场景2:对象为null
User user = userMapper.selectById(userId);
ThrowUtils.throwIfNull(user, ErrorCode.USER_NOT_FOUND);

// 场景3:字符串为空
String email = getUserInput();
ThrowUtils.throwIfBlank(email, ErrorCode.PARAMS_ERROR);

// 场景4:集合为空
List<User> users = userMapper.selectByIds(ids);
ThrowUtils.throwIfEmpty(users, ErrorCode.NOT_FOUND);

// 场景5:业务条件检查
int price = 50;
ThrowUtils.throwIf(price < 100, ErrorCode.BID_PRICE_TOO_LOW, "Minimum price is 100");

// 场景6:复杂逻辑
User user = userMapper.selectById(userId);
ThrowUtils.throwIfNull(user, ErrorCode.USER_NOT_FOUND);
ThrowUtils.throwIf(!user.isActive(), ErrorCode.FORBIDDEN, "User account is inactive");

为什么使用 ThrowUtils

// ❌ 传统写法(冗长)
User user = userMapper.selectById(userId);
if (user == null) {
    throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}

// ✅ 使用 ThrowUtils(简洁)
User user = ThrowUtils.throwIfNull(
    userMapper.selectById(userId),
    ErrorCode.USER_NOT_FOUND
);

// 优点:
// 1. 代码更简洁,减少 if-then-throw 样板代码
// 2. 返回原对象,支持链式调用
// 3. 增强代码可读性
// 4. 检查和异常处理合二为一

4. ResultUtils 响应工具

响应构造示例

// 成功响应(带数据)
return ResultUtils.success(userData);
// → {"code": 0, "message": "success", "data": {...}, "timestamp": ...}

// 成功响应(无数据)
return ResultUtils.success();
// → {"code": 0, "message": "success", "data": null, "timestamp": ...}

// 错误响应(使用错误码)
return ResultUtils.error(ErrorCode.USER_NOT_FOUND);
// → {"code": 10001, "message": "user not found", "description": "...", "timestamp": ...}

// 错误响应(自定义描述)
return ResultUtils.error(ErrorCode.USER_NOT_FOUND, "User with id 999 not found");
// → {"code": 10001, "message": "user not found", "description": "User with id 999 not found", "timestamp": ...}

// 服务器错误响应
return ResultUtils.serverError(traceId, "Database connection failed");
// → {"code": 50000, "message": "System error, please contact support", "traceId": "...", "timestamp": ...}

@JsonInclude 的作用

@JsonInclude(JsonInclude.Include.NON_NULL)
public class BaseResponse<T> { ... }

// 作用:序列化时忽略 null 值字段
// 成功响应不会包含 traceId 字段(为 null)
// 服务器错误响应不会包含 data 字段(为 null)
// 结果:响应体更小,网络传输更快

5. GlobalExceptionHandler 处理器

异常处理流程

异常处理流程-ff80fb01

Trace ID 的作用

客户端收到错误响应:
{
  "code": 50000,
  "message": "System error, please contact support",
  "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

用户:我收到错误了,Trace ID 是 a1b2c3d4...

后台团队:
1. 在日志系统中搜索 Trace ID
2. 找到该 Trace ID 对应的所有日志
3. 快速定位问题原因
4. 反馈给用户

日志记录示例

业务异常日志(INFO 级别):
2024-01-01 10:00:00 [http-nio-8080-exec-1] WARN  GlobalExceptionHandler -
Business exception at [GET] /api/user/999 - [10001]
BusinessException [code=10001, message=user not found, description=User with id 999 does not exist, type=CLIENT]

系统异常日志(ERROR 级别):
2024-01-01 10:00:00 [http-nio-8080-exec-1] ERROR GlobalExceptionHandler -
Unexpected exception [NullPointerException] at [POST] /api/user -
UserService.java:45#createUser() - IP: 192.168.1.100 - Trace: a1b2c3d4... -
java.lang.NullPointerException: Cannot invoke method on null object
  at com.example.user.service.impl.UserServiceImpl.createUser(UserService.java:45)
  at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  ...

使用场景

场景1:参数校验

@PostMapping("/users")
public BaseResponse<UserDTO> createUser(@RequestBody @Valid CreateUserRequest request) {
    // @Valid 触发参数验证
    // 如果验证失败,自动被 handleValidationException 处理
    // 无需手动处理

    UserDTO user = userService.createUser(request);
    return ResultUtils.success(user);
}

// DTO 中使用 JSR303 注解
public class CreateUserRequest {
    @NotBlank(message = "Username cannot be empty")
    private String username;

    @NotBlank(message = "Password cannot be empty")
    private String password;

    @Email(message = "Invalid email format")
    private String email;
}

场景2:业务规则检查

public void updateUserEmail(Long userId, String newEmail) {
    // 检查用户是否存在
    User user = userMapper.selectById(userId);
    ThrowUtils.throwIfNull(user, ErrorCode.USER_NOT_FOUND,
        "User with id " + userId + " not found");

    // 检查邮箱是否已被使用
    User existing = userMapper.selectByEmail(newEmail);
    ThrowUtils.throwIf(
        existing != null && !existing.getId().equals(userId),
        ErrorCode.EMAIL_ALREADY_EXISTS,
        "Email '" + newEmail + "' is already in use"
    );

    user.setEmail(newEmail);
    userMapper.updateById(user);
}

场景3:外部服务调用失败

public void uploadArtwork(Artwork artwork) {
    try {
        // 调用文件服务上传
        fileService.upload(artwork.getFile());
    } catch (Exception e) {
        log.error("File upload failed for artwork {}", artwork.getId(), e);
        throw new BusinessException(
            ErrorCode.ARTWORK_UPLOAD_FAILED,
            "Failed to upload file: " + e.getMessage()
        );
    }
}

场景4:数据库操作失败

public void deleteUser(Long userId) {
    try {
        int result = userMapper.deleteById(userId);
        ThrowUtils.throwIf(result == 0, ErrorCode.USER_NOT_FOUND);
    } catch (Exception e) {
        log.error("Database error while deleting user {}", userId, e);
        // 不捕获异常,让全局处理器处理
        // 会被识别为服务器错误,返回 Trace ID
        throw e;
    }
}

最佳实践

✅ DO(应该做)

1. 为不同的业务错误定义专有的 ErrorCode

// ✅ 好的做法
public enum ErrorCode {
    USER_NOT_FOUND(10001, "user not found", "..."),
    PASSWORD_ERROR(10002, "password error", "..."),
    EMAIL_ALREADY_EXISTS(10003, "email already exists", "..."),
}

// ❌ 不好的做法
public enum ErrorCode {
    ERROR(40000, "error", "..."),  // 太通用,无法区分
}

2. 给异常添加足够的上下文信息

// ✅ 好的做法
throw new BusinessException(
    ErrorCode.USER_NOT_FOUND,
    "User with id " + userId + " does not exist"
);

// ❌ 不好的做法
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
// 前端收到的响应没有具体信息,无法知道是哪个用户

3. 使用 ThrowUtils 简化异常抛出

// ✅ 好的做法
ThrowUtils.throwIfNull(user, ErrorCode.USER_NOT_FOUND);

// ❌ 不好的做法
if (user == null) {
    throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}

4. 明确区分 CLIENT 和 SERVER 错误

// ✅ 好的做法
// 用户输入有问题
ErrorCode.PARAMS_ERROR(40000, "...", ErrorType.CLIENT)

// 服务器内部问题
ErrorCode.DATABASE_ERROR(50100, "...", ErrorType.SERVER)

// ❌ 不好的做法
// 所有错误都标记为 SERVER
// 这样用户输入错误也会返回 Trace ID,很奇怪

5. 在关键操作前进行校验

// ✅ 好的做法
public void transferMoney(Long fromUserId, Long toUserId, BigDecimal amount) {
    // 参数检查
    ThrowUtils.throwIf(amount.compareTo(BigDecimal.ZERO) <= 0, ErrorCode.PARAMS_ERROR);

    // 业务检查
    User fromUser = ThrowUtils.throwIfNull(
        userMapper.selectById(fromUserId),
        ErrorCode.USER_NOT_FOUND
    );
    ThrowUtils.throwIf(fromUser.getBalance().compareTo(amount) < 0,
        ErrorCode.INSUFFICIENT_BALANCE);

    // 执行操作
    ...
}

// ❌ 不好的做法
public void transferMoney(Long fromUserId, Long toUserId, BigDecimal amount) {
    // 直接执行,出错后才发现问题
    userMapper.transferMoney(fromUserId, toUserId, amount);
}

❌ DON'T(不应该做)

1. 不要吞掉异常

// ❌ 错误
try {
    User user = userMapper.selectById(userId);
} catch (Exception e) {
    // 什么都不做,问题被隐藏了
}

// ✅ 正确
try {
    User user = userMapper.selectById(userId);
} catch (Exception e) {
    log.error("Failed to query user {}", userId, e);
    throw new BusinessException(ErrorCode.DATABASE_ERROR, e.getMessage());
}

2. 不要在异常消息中暴露敏感信息

// ❌ 错误(SERVER 错误)
throw new BusinessException(
    ErrorCode.EXTERNAL_SERVICE_ERROR,
    "Failed to connect to https://payment.example.com on port 8443 due to SSL certificate error"
);
// 前端会显示这个,安全隐患

// ✅ 正确
throw new BusinessException(
    ErrorCode.EXTERNAL_SERVICE_ERROR,
    "Payment service temporarily unavailable, please try again later"
);

3. 不要混淆 message 和 description

// ❌ 错误
ErrorCode.USER_NOT_FOUND(10001, "User with id 999 does not exist", "...")
// message 太长,应该放在 description 中

// ✅ 正确
ErrorCode.USER_NOT_FOUND(10001, "user not found", "User with id {id} does not exist")
// message 简短通用,description 包含具体信息

4. 不要过度使用链式调用造成嵌套过深

// ❌ 错误
ThrowUtils.throwIfNull(
    ThrowUtils.throwIfEmpty(
        ThrowUtils.throwIfNull(...),
        ErrorCode.NOT_FOUND
    ),
    ErrorCode.PARAMS_ERROR
);
// 难以理解

// ✅ 正确
List<User> users = userMapper.selectByIds(ids);
ThrowUtils.throwIfEmpty(users, ErrorCode.NOT_FOUND);
User user = users.get(0);
ThrowUtils.throwIfNull(user, ErrorCode.USER_NOT_FOUND);

5. 不要直接在 Controller 层进行业务逻辑检查

// ❌ 错误
@PostMapping("/transfer")
public BaseResponse transfer(@RequestBody TransferRequest req) {
    if (req.getAmount() <= 0) {
        return ResultUtils.error(ErrorCode.PARAMS_ERROR, "Amount must be positive");
    }
    // 其他检查...
    return ResultUtils.success(transferService.transfer(req));
}
// Controller 充斥着业务逻辑

// ✅ 正确
@PostMapping("/transfer")
public BaseResponse transfer(@RequestBody @Valid TransferRequest req) {
    // 参数验证由 @Valid 和全局处理器处理
    // 业务检查由 Service 层处理
    return ResultUtils.success(transferService.transfer(req));
}

// Service 层
public void transfer(TransferRequest req) {
    ThrowUtils.throwIf(req.getAmount().compareTo(ZERO) <= 0, ErrorCode.PARAMS_ERROR);
    // 业务逻辑和检查混在一起
    ...
}

常见错误

错误1:异常类型判断错误

// ❌ 错误
throw new BusinessException(ErrorCode.DATABASE_ERROR, "...");
// DATABASE_ERROR 是 SERVER 类型,但在业务层抛出
// 业务层应该抛出 CLIENT 类型的错误,不知道具体原因就抛出 SERVER 错误

// ✅ 正确
try {
    // 数据库操作
} catch (Exception e) {
    log.error("Database error", e);
    throw new BusinessException(ErrorCode.DATABASE_ERROR, e.getMessage());
}
// 只在真正的系统异常时抛出 SERVER 类型错误

错误2:错误码设计不合理

// ❌ 错误
// 错误码太分散,难以维护
BID_PRICE_ERROR(30002, "...")
BID_QUANTITY_ERROR(30003, "...")
BID_INVALID_ERROR(30004, "...")
BID_TIMEOUT_ERROR(30005, "...")
// 如果每个小错误都要一个错误码,会导致枚举爆炸

// ✅ 正确
BID_FAILED(30002, "...", ErrorType.CLIENT)
// 使用通用的错误码 + 不同的 description 来区分
throw new BusinessException(ErrorCode.BID_FAILED, "Bid price too low");
throw new BusinessException(ErrorCode.BID_FAILED, "Bid quantity invalid");
throw new BusinessException(ErrorCode.BID_FAILED, "Auction has closed");

错误3:没有考虑线程安全

// ❌ 错误(如果异常处理器中使用了共享状态)
@RestControllerAdvice
public class GlobalExceptionHandler {
    private List<String> errorLog = new ArrayList<>();  // 共享状态

    @ExceptionHandler(Exception.class)
    public BaseResponse handle(Exception e) {
        errorLog.add(e.getMessage());  // 线程不安全!
        return ...;
    }
}

// ✅ 正确
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public BaseResponse handle(Exception e) {
        log.error("Exception occurred", e);  // 使用 SLF4J,已处理线程安全
        return ...;
    }
}

错误4:日志过度记录

// ❌ 错误
@ExceptionHandler(BusinessException.class)
public BaseResponse handle(BusinessException e) {
    log.error("Business exception", e);  // 业务异常不需要打印 ERROR 日志
    return ...;
}

// ✅ 正确
@ExceptionHandler(BusinessException.class)
public BaseResponse handle(BusinessException e) {
    if (e.getErrorType() == ErrorType.SERVER) {
        log.error("Business exception", e);  // 只有 SERVER 类型才打 ERROR
    } else {
        log.warn("Business exception", e);  // CLIENT 类型打 WARN
    }
    return ...;
}

// 或更好的做法
@ExceptionHandler(BusinessException.class)
public BaseResponse handle(BusinessException e) {
    if (e.getErrorType() == ErrorType.CLIENT) {
        log.debug("Client error: {}", e);  // 客户端错误级别降低到 DEBUG
    } else {
        log.error("Server error", e);
    }
    return ...;
}

总结

  1. 统一规范:所有异常都以相同的格式返回
  2. 清晰分类:CLIENT/SERVER 错误类型区分明确
  3. 信息完整:包含 code/message/description/traceId
  4. 追踪能力:服务器错误通过 Trace ID 快速定位
  5. 简洁易用:ThrowUtils 和 ResultUtils 简化开发
  6. 可维护性:业务逻辑集中在 Service 层,易于管理

关键点

  • ✅ 业务检查在 Service 层,参数检查在 Controller/DTO 层
  • ✅ 只有真正的系统异常才抛出 SERVER 类型错误
  • ✅ 给每个错误提供足够的上下文信息
  • ✅ 使用 Trace ID 追踪服务器错误
  • ✅ 保持 message 简短通用,description 详细具体

项目分区导航:⬅️ 05-ZwwwSpringBootBaseTemplate工程模板 | 01-异常处理最佳实践指南 | ➡️ 02-异常处理最佳实践代码