【Java项目技术亮点】统一异常处理与全局响应封装

写在前面:说实话,我刚入行那会儿,每个 Controller 里都写 try-catch,异常信息直接怼给前端。结果前端同事天天在群里@我:"这个 NullPointerException 是什么意思?""这个 SQLSyntaxErrorException 我该展示什么?"后来我才明白,用户不需要知道你的堆栈,他们只需要知道"出了什么问题,该怎么办"。这篇文章就是我这几年在异常处理上踩坑、填坑的完整总结。

文章目录

    • 一、为什么需要统一异常处理?
      • [1.1 一个让人崩溃的真实场景](#1.1 一个让人崩溃的真实场景)
      • [1.2 生活类比:餐厅服务员](#1.2 生活类比:餐厅服务员)
      • [1.3 统一处理的核心价值](#1.3 统一处理的核心价值)
    • 二、全局响应封装设计
      • [2.1 统一响应体结构](#2.1 统一响应体结构)
      • [2.2 状态码设计规范](#2.2 状态码设计规范)
      • [2.3 Result<T> 泛型封装类](#2.3 Result 泛型封装类)
    • 三、统一异常处理实现
      • [3.1 业务异常定义](#3.1 业务异常定义)
      • [3.2 全局异常处理类](#3.2 全局异常处理类)
      • [3.3 异常分类处理策略](#3.3 异常分类处理策略)
      • [3.4 异常日志记录与 MDC 链路追踪](#3.4 异常日志记录与 MDC 链路追踪)
    • 四、参数校验与国际化
      • [4.1 @Valid + @Validated 分组校验](#4.1 @Valid + @Validated 分组校验)
      • [4.2 自定义校验注解](#4.2 自定义校验注解)
      • [4.3 校验错误信息国际化(i18n)](#4.3 校验错误信息国际化(i18n))
    • [五、ResponseBodyAdvice 全局包装](#五、ResponseBodyAdvice 全局包装)
      • [5.1 自动包装 Controller 返回值](#5.1 自动包装 Controller 返回值)
      • [5.2 跳过包装的注解](#5.2 跳过包装的注解)
      • [5.3 使用示例](#5.3 使用示例)
    • 六、踩坑指南
      • [6.1 @ExceptionHandler 优先级问题](#6.1 @ExceptionHandler 优先级问题)
      • [6.2 ResponseBodyAdvice 与 Swagger 冲突](#6.2 ResponseBodyAdvice 与 Swagger 冲突)
      • [6.3 异常处理中的循环依赖](#6.3 异常处理中的循环依赖)
      • [6.4 异步方法异常捕获不到](#6.4 异步方法异常捕获不到)
    • 七、问题与解答
      • [Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?](#Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?)
      • [Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?](#Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?)
      • [Q3:全局异常处理怎么和 Feign 客户端的异常结合?](#Q3:全局异常处理怎么和 Feign 客户端的异常结合?)
    • 八、面试高频考点汇总
      • [考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?](#考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?)
      • [考点 2:@ExceptionHandler 的匹配规则是什么?](#考点 2:@ExceptionHandler 的匹配规则是什么?)
      • [考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?](#考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?)
      • [考点 4:如何设计一个支持国际化的异常处理系统?](#考点 4:如何设计一个支持国际化的异常处理系统?)
      • [考点 5:全局异常处理在微服务架构中怎么设计?](#考点 5:全局异常处理在微服务架构中怎么设计?)
    • 九、模拟面试官提问
      • [场景题 1:设计一个完整的异常处理体系](#场景题 1:设计一个完整的异常处理体系)
      • [场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?](#场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?)
      • [场景题 3:如何防止异常处理逻辑本身抛出异常?](#场景题 3:如何防止异常处理逻辑本身抛出异常?)
      • [场景题 4:前后端分离项目中,异常信息怎么给前端?](#场景题 4:前后端分离项目中,异常信息怎么给前端?)
      • [场景题 5:生产环境出现异常,如何快速定位问题?](#场景题 5:生产环境出现异常,如何快速定位问题?)
    • 十、互动话题
    • 参考资料

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

1.1 一个让人崩溃的真实场景

看看下面这个 Controller,眼熟不?

java 复制代码
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
    try {
        User user = userService.getById(id);
        if (user == null) {
            return null;  // 前端收到 200,但 data 是 null,懵了
        }
        return user;
    } catch (SQLException e) {
        e.printStackTrace();  // 堆栈直接打印,用户啥也看不到
        return null;
    } catch (Exception e) {
        return null;  // 所有异常都吞掉,出了问题根本不知道
    }
}

这种代码我见过太多了。问题在哪?

  • 异常信息直接暴露堆栈,安全风险
  • 每个接口都写 try-catch,代码臃肿
  • 前端收到的响应格式不统一,难以处理

1.2 生活类比:餐厅服务员

想象你去餐厅点菜。厨房可能出问题:没食材了、厨师请假了、设备坏了。但服务员给你的回复永远是标准化的:

"抱歉,这道菜暂时无法提供,您看换一道可以吗?"

而不是:

"厨师刚辞职了,灶台的煤气阀也坏了,冰箱里的牛肉过期了,所以您的菜做不了。"

统一异常处理就是那个"服务员"------不管后端出了什么问题,给前端的回复应该是统一、友好、可控的。

1.3 统一处理的核心价值

价值点 说明
代码整洁 业务代码里不再写 try-catch,专注业务逻辑
用户体验好 前端收到统一格式的错误信息,知道怎么展示
安全性高 堆栈信息不暴露,攻击者无法获取系统内部细节
维护成本低 异常处理逻辑集中管理,改一处全局生效

二、全局响应封装设计

2.1 统一响应体结构

json 复制代码
{
  "code": 200,
  "message": "success",
  "data": {
    "userId": 1,
    "username": "zhangsan"
  }
}

三个字段,简单清晰:

  • code:业务状态码,不是 HTTP 状态码
  • message:给用户的友好提示
  • data:实际业务数据

2.2 状态码设计规范

状态码 含义 使用场景
200 成功 一切正常
400 参数错误 请求参数缺失、格式不对
401 未授权 Token 缺失或过期
403 禁止访问 没有权限
404 资源不存在 查询的数据找不到
500 系统错误 服务器内部异常
600+ 业务错误 自定义业务场景,如余额不足(601)、库存不足(602)

踩坑提醒:我见过有人直接用 HTTP 状态码当业务码,比如 404 表示"用户不存在"。千万别这样!HTTP 404 是"接口找不到",业务上的"用户不存在"应该用 200 + 业务码 60001。两者完全不是一个层面的事。

2.3 Result 泛型封装类

java 复制代码
import com.fasterxml.jackson.annotation.JsonInclude;

import java.io.Serializable;

/**
 * 统一响应结果封装
 * @param <T> 数据类型
 */
@JsonInclude(JsonInclude.Include.NON_NULL)  // null 字段不序列化
public class Result<T> implements Serializable {

    private static final long serialVersionUID = 1L;

    /** 状态码 */
    private Integer code;

    /** 提示信息 */
    private String message;

    /** 响应数据 */
    private T data;

    /** 时间戳 */
    private Long timestamp;

    /** 请求追踪 ID */
    private String traceId;

    private Result() {
        this.timestamp = System.currentTimeMillis();
        this.traceId = MDC.get("traceId");  // 链路追踪 ID
    }

    // ==================== 成功响应 ====================

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

    public static <T> Result<T> success(T data) {
        Result<T> result = success();
        result.data = data;
        return result;
    }

    public static <T> Result<T> success(String message, T data) {
        Result<T> result = success(data);
        result.message = message;
        return result;
    }

    // ==================== 失败响应 ====================

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

    public static <T> Result<T> error(String message) {
        return error(500, message);
    }

    public static <T> Result<T> badRequest(String message) {
        return error(400, message);
    }

    // ==================== 业务错误 ====================

    public static <T> Result<T> businessError(Integer businessCode, String message) {
        return error(businessCode, message);
    }

    // ==================== 判断方法 ====================

    public boolean isSuccess() {
        return Integer.valueOf(200).equals(this.code);
    }

    // ==================== Getter / Setter ====================

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public Long getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(Long timestamp) {
        this.timestamp = timestamp;
    }

    public String getTraceId() {
        return traceId;
    }

    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }

    @Override
    public String toString() {
        return "Result{" +
                "code=" + code +
                ", message='" + message + '\'' +
                ", data=" + data +
                ", traceId='" + traceId + '\'' +
                '}';
    }
}

三、统一异常处理实现

3.1 业务异常定义

java 复制代码
/**
 * 业务异常
 * 用于表示可预期的业务错误,如余额不足、库存不足等
 */
public class BusinessException extends RuntimeException {

    private final Integer code;

    public BusinessException(String message) {
        super(message);
        this.code = 600;  // 默认业务错误码
    }

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

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

    public Integer getCode() {
        return code;
    }
}

3.2 全局异常处理类

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;
import java.util.stream.Collectors;

/**
 * 全局异常处理
 * 统一捕获所有异常,返回标准格式的错误响应
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常处理
     */
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e, HttpServletRequest request) {
        log.warn("[业务异常] URI={}, traceId={}, code={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getCode(),
                e.getMessage());

        return Result.error(e.getCode(), e.getMessage());
    }

    /**
     * 参数校验异常 - @Valid 校验失败
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));

        log.warn("[参数校验失败] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                message);

        return Result.badRequest("参数校验失败:" + message);
    }

    /**
     * 参数绑定异常 - @ModelAttribute 校验失败
     */
    @ExceptionHandler(BindException.class)
    public Result<Void> handleBindException(BindException e, HttpServletRequest request) {
        String message = e.getFieldErrors().stream()
                .map(error -> error.getField() + ":" + error.getDefaultMessage())
                .collect(Collectors.joining("; "));

        log.warn("[参数绑定失败] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                message);

        return Result.badRequest("参数绑定失败:" + message);
    }

    /**
     * 非法参数异常 - IllegalArgumentException
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public Result<Void> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
        log.warn("[非法参数] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getMessage());

        return Result.badRequest(e.getMessage());
    }

    /**
     * 非法状态异常 - IllegalStateException
     */
    @ExceptionHandler(IllegalStateException.class)
    public Result<Void> handleIllegalStateException(IllegalStateException e, HttpServletRequest request) {
        log.warn("[非法状态] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getMessage());

        return Result.error(500, "系统状态异常,请稍后重试");
    }

    /**
     * 空指针异常 - NullPointerException
     */
    @ExceptionHandler(NullPointerException.class)
    public Result<Void> handleNullPointerException(NullPointerException e, HttpServletRequest request) {
        log.error("[空指针异常] URI={}, traceId={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e);  // 打印完整堆栈

        return Result.error("系统繁忙,请稍后重试");
    }

    /**
     * 算术异常 - ArithmeticException
     */
    @ExceptionHandler(ArithmeticException.class)
    public Result<Void> handleArithmeticException(ArithmeticException e, HttpServletRequest request) {
        log.error("[算术异常] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getMessage());

        return Result.error("计算错误,请检查输入数据");
    }

    /**
     * 数组越界异常 - ArrayIndexOutOfBoundsException
     */
    @ExceptionHandler(ArrayIndexOutOfBoundsException.class)
    public Result<Void> handleArrayIndexOutOfBoundsException(ArrayIndexOutOfBoundsException e, HttpServletRequest request) {
        log.error("[数组越界] URI={}, traceId={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e);

        return Result.error("数据索引错误,请稍后重试");
    }

    /**
     * 类型转换异常 - ClassCastException
     */
    @ExceptionHandler(ClassCastException.class)
    public Result<Void> handleClassCastException(ClassCastException e, HttpServletRequest request) {
        log.error("[类型转换异常] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getMessage());

        return Result.error("数据类型不匹配");
    }

    /**
     * 数字格式异常 - NumberFormatException
     */
    @ExceptionHandler(NumberFormatException.class)
    public Result<Void> handleNumberFormatException(NumberFormatException e, HttpServletRequest request) {
        log.warn("[数字格式异常] URI={}, traceId={}, message={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e.getMessage());

        return Result.badRequest("数字格式错误:" + e.getMessage());
    }

    /**
     * 所有其他未捕获的异常
     */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e, HttpServletRequest request) {
        log.error("[系统异常] URI={}, traceId={}",
                request.getRequestURI(),
                MDC.get("traceId"),
                e);  // 打印完整堆栈,便于排查

        return Result.error("系统繁忙,请稍后重试");
    }
}

3.3 异常分类处理策略

复制代码
┌────────────────────────────────────────────────────────────┐
│                    异常分类处理策略                          │
├────────────────────────────────────────────────────────────┤
│  业务异常 (BusinessException)                               │
│  ├── 日志级别:WARN                                         │
│  ├── 返回:业务错误码 + 友好提示                             │
│  └── 示例:余额不足、库存不足、订单已取消                     │
├────────────────────────────────────────────────────────────┤
│  参数校验异常 (ValidationException)                         │
│  ├── 日志级别:WARN                                         │
│  ├── 返回:400 + 具体校验错误信息                            │
│  └── 示例:手机号格式错误、必填字段为空                       │
├────────────────────────────────────────────────────────────┤
│  系统异常 (RuntimeException / Exception)                    │
│  ├── 日志级别:ERROR(打印完整堆栈)                         │
│  ├── 返回:500 + 统一友好提示(不暴露细节)                   │
│  └── 示例:NPE、数据库连接失败、第三方服务超时                │
└────────────────────────────────────────────────────────────┘

3.4 异常日志记录与 MDC 链路追踪

java 复制代码
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

/**
 * 请求追踪过滤器
 * 为每个请求生成唯一的 traceId,用于全链路日志追踪
 */
@Component
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID_KEY = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            // 优先从请求头获取(微服务间调用传递)
            String traceId = request.getHeader(TRACE_ID_KEY);
            if (traceId == null || traceId.isEmpty()) {
                traceId = UUID.randomUUID().toString().replace("-", "");
            }

            // 放入 MDC,后续日志自动带上 traceId
            MDC.put(TRACE_ID_KEY, traceId);

            // 响应头也返回 traceId,方便前端排查问题
            response.setHeader(TRACE_ID_KEY, traceId);

            filterChain.doFilter(request, response);
        } finally {
            // 请求结束后清理 MDC,防止线程池复用导致 traceId 污染
            MDC.clear();
        }
    }
}

四、参数校验与国际化

4.1 @Valid + @Validated 分组校验

java 复制代码
import javax.validation.constraints.*;
import javax.validation.groups.Default;

/**
 * 用户请求 DTO
 * 使用分组校验区分新增和更新场景
 */
public class UserRequest {

    // 更新时需要传 ID,新增时不需要
    @NotNull(message = "用户ID不能为空", groups = UpdateGroup.class)
    private Long id;

    @NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
    @Size(min = 2, max = 20, message = "用户名长度必须在2-20之间")
    private String username;

    @NotBlank(message = "密码不能为空", groups = CreateGroup.class)
    @Size(min = 6, max = 32, message = "密码长度必须在6-32之间", groups = {CreateGroup.class, UpdateGroup.class})
    private String password;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;

    @Min(value = 0, message = "年龄不能小于0")
    @Max(value = 150, message = "年龄不能大于150")
    private Integer age;

    // 分组接口
    public interface CreateGroup extends Default {}
    public interface UpdateGroup extends Default {}

    // Getter / Setter 省略...
}
java 复制代码
/**
 * Controller 中使用分组校验
 */
@RestController
@RequestMapping("/users")
public class UserController {

    @PostMapping
    public Result<Long> create(@Validated(UserRequest.CreateGroup.class) @RequestBody UserRequest request) {
        Long userId = userService.create(request);
        return Result.success("创建成功", userId);
    }

    @PutMapping("/{id}")
    public Result<Void> update(@PathVariable Long id,
                               @Validated(UserRequest.UpdateGroup.class) @RequestBody UserRequest request) {
        userService.update(id, request);
        return Result.success("更新成功", null);
    }
}

4.2 自定义校验注解

java 复制代码
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;

/**
 * 手机号校验注解
 */
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

/**
 * 手机号校验器
 */
class PhoneValidator implements ConstraintValidator<Phone, String> {

    private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;  // @NotBlank 负责判空
        }
        return value.matches(PHONE_REGEX);
    }
}
java 复制代码
/**
 * 身份证号校验注解
 */
@Documented
@Constraint(validatedBy = IdCardValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface IdCard {
    String message() default "身份证号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class IdCardValidator implements ConstraintValidator<IdCard, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;
        }
        // 简化版校验,实际应该用完整的身份证号校验算法
        return value.matches("^\\d{17}[\\dXx]$");
    }
}

4.3 校验错误信息国际化(i18n)

java 复制代码
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;

import java.util.Locale;

/**
 * 国际化配置
 */
@Configuration
public class I18nConfig {

    @Bean
    public LocaleResolver localeResolver() {
        AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
        resolver.setDefaultLocale(Locale.CHINA);
        return resolver;
    }

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");  // 加载 messages_zh_CN.properties、messages_en_US.properties
        messageSource.setDefaultEncoding("UTF-8");
        messageSource.setUseCodeAsDefaultMessage(true);  // 找不到配置时返回 code 本身
        return messageSource;
    }
}
properties 复制代码
# messages_zh_CN.properties
user.username.notBlank=用户名不能为空
user.email.invalid=邮箱格式不正确
user.phone.invalid=手机号格式不正确
properties 复制代码
# messages_en_US.properties
user.username.notBlank=Username is required
user.email.invalid=Invalid email format
user.phone.invalid=Invalid phone number
java 复制代码
/**
 * 在全局异常处理中使用国际化消息
 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e,
                                               HttpServletRequest request,
                                               Locale locale) {
    String message = e.getBindingResult().getFieldErrors().stream()
            .map(error -> messageSource.getMessage(error, locale))  // 使用国际化消息
            .collect(Collectors.joining("; "));

    return Result.badRequest(message);
}

五、ResponseBodyAdvice 全局包装

5.1 自动包装 Controller 返回值

java 复制代码
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

/**
 * 全局响应体包装器
 * 自动将 Controller 的返回值包装为 Result<T> 格式
 */
@RestControllerAdvice(basePackages = "com.example.controller")
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {

    private final ObjectMapper objectMapper;

    public GlobalResponseAdvice(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * 判断是否需要进行包装
     */
    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        // 如果方法或类上有 @IgnoreResponseWrapper 注解,跳过包装
        if (returnType.hasMethodAnnotation(IgnoreResponseWrapper.class)) {
            return false;
        }
        // 如果返回值已经是 Result 类型,不需要再包装
        if (returnType.getParameterType().isAssignableFrom(Result.class)) {
            return false;
        }
        // 如果返回值是 String 类型,需要特殊处理(后面说)
        return true;
    }

    /**
     * 执行包装逻辑
     */
    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        // 已经是 Result 类型,直接返回
        if (body instanceof Result) {
            return body;
        }

        // String 类型需要特殊处理,因为 StringHttpMessageConverter 会直接写字符串
        if (body instanceof String) {
            try {
                Result<Object> result = Result.success(body);
                return objectMapper.writeValueAsString(result);
            } catch (JsonProcessingException e) {
                throw new RuntimeException("响应序列化失败", e);
            }
        }

        // 其他类型直接包装
        return Result.success(body);
    }
}

5.2 跳过包装的注解

java 复制代码
import java.lang.annotation.*;

/**
 * 标记该方法/类的返回值不需要被全局包装
 * 适用于文件下载、特殊接口等场景
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IgnoreResponseWrapper {
}

5.3 使用示例

java 复制代码
@RestController
@RequestMapping("/api")
public class DemoController {

    // 自动包装为 Result.success(data)
    @GetMapping("/user")
    public User getUser() {
        return new User("zhangsan", 20);
    }

    // 返回 Result,不会重复包装
    @GetMapping("/custom")
    public Result<String> customResponse() {
        return Result.success("自定义响应");
    }

    // 跳过包装,直接返回原始内容(如下载接口)
    @IgnoreResponseWrapper
    @GetMapping("/download")
    public byte[] download() {
        return new byte[0];  // 文件内容
    }
}

踩坑提醒 :ResponseBodyAdvice 这个坑我踩过------Swagger 的 /v3/api-docs 接口也会被包装,导致 Swagger UI 打不开。解决方法是配置 basePackages 限定只包装业务接口,或者在 supports() 方法里排除 Swagger 相关路径。


六、踩坑指南

6.1 @ExceptionHandler 优先级问题

Spring 的异常处理是按声明顺序匹配的,第一个匹配的处理器就会被调用。

java 复制代码
// 错误示例:Exception 放在最前面,后面的都不会被触发
@ExceptionHandler(Exception.class)      // 这个会捕获所有异常!
public Result<Void> handleException(Exception e) { ... }

@ExceptionHandler(BusinessException.class)  // 永远不会被触发
public Result<Void> handleBusinessException(BusinessException e) { ... }

正确做法:子类异常放前面,父类异常放最后。

java 复制代码
@ExceptionHandler(BusinessException.class)      // 1. 业务异常
@ExceptionHandler(ValidationException.class)    // 2. 校验异常
@ExceptionHandler(RuntimeException.class)       // 3. 运行时异常
@ExceptionHandler(Exception.class)              // 4. 兜底异常

6.2 ResponseBodyAdvice 与 Swagger 冲突

Swagger 的 API 文档接口返回的是 JSON Schema,如果被 GlobalResponseAdvice 包装,Swagger UI 就无法解析。

解决方案

java 复制代码
@RestControllerAdvice(basePackages = "com.example.controller")
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
    // 只处理业务包下的 Controller,排除 Swagger
}

或者在 supports() 里排除:

java 复制代码
@Override
public boolean supports(MethodParameter returnType,
                        Class<? extends HttpMessageConverter<?>> converterType) {
    String path = // 从 request 获取路径
    if (path.startsWith("/swagger") || path.startsWith("/v3/api-docs")) {
        return false;
    }
    // ...
}

6.3 异常处理中的循环依赖

这个坑比较隐蔽。如果你的 GlobalExceptionHandler 注入了某个 Service,而这个 Service 又间接依赖了 Web 层的 Bean,就可能导致循环依赖。

解决方案

  • 异常处理器里尽量不注入复杂依赖
  • 如果必须注入,用 @Lazy 延迟加载
  • 或者把日志记录逻辑抽成一个独立的 ErrorLogService,只依赖底层组件

6.4 异步方法异常捕获不到

java 复制代码
@Service
public class AsyncService {

    @Async
    public void asyncMethod() {
        throw new BusinessException("异步任务异常");  // 这个异常不会被 @RestControllerAdvice 捕获!
    }
}

原因@Async 会在新的线程中执行,而 @RestControllerAdvice 只能捕获请求线程中的异常。

解决方案

java 复制代码
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;

import java.lang.reflect.Method;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncUncaughtExceptionHandler() {
            @Override
            public void handleUncaughtException(Throwable ex, Method method, Object... params) {
                // 统一处理异步方法的异常
                if (ex instanceof BusinessException) {
                    log.error("[异步业务异常] method={}, message={}", method.getName(), ex.getMessage());
                } else {
                    log.error("[异步系统异常] method={}", method.getName(), ex);
                }
                // 可以在这里发送告警通知
            }
        };
    }
}

踩坑提醒 :异步方法的异常处理最容易被忽略。建议项目里统一配置 AsyncUncaughtExceptionHandler,别让异步异常"无声无息"地吞掉。


七、问题与解答

Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?

A:一般情况下不需要。全局异常处理已经覆盖了绝大部分场景。

但有一种情况例外:你需要在 catch 里做资源清理事务回滚时,可以写 try-finally 或 try-catch-rethrow:

java 复制代码
@PostMapping("/transfer")
public Result<Void> transfer(@RequestBody TransferRequest request) {
    try {
        accountService.transfer(request);
        return Result.success("转账成功");
    } catch (BusinessException e) {
        // 记录转账失败日志,然后重新抛出让全局处理器处理
        log.warn("转账失败:{}", e.getMessage());
        throw e;
    }
}

记住原则:能抛就抛,让全局处理器统一处理

Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?

A :不会。ResponseBodyAdvice 只包装正常执行到 Controller 并返回的响应。

如果是 404(接口不存在)、405(方法不允许)这种由 Spring 直接返回的错误,不会经过 ResponseBodyAdvice

要统一处理这些错误,需要配合 ErrorController

java 复制代码
@RestController
public class CustomErrorController implements ErrorController {

    @RequestMapping("/error")
    public Result<Void> handleError(HttpServletRequest request) {
        Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
        String message = (String) request.getAttribute("javax.servlet.error.message");

        if (statusCode == 404) {
            return Result.error(404, "请求的资源不存在");
        }
        if (statusCode == 405) {
            return Result.error(405, "请求方法不允许");
        }
        return Result.error(statusCode, message);
    }
}

Q3:全局异常处理怎么和 Feign 客户端的异常结合?

A :Feign 调用失败会抛出 FeignException,你可以在全局处理器里统一转换:

java 复制代码
@ExceptionHandler(FeignException.class)
public Result<Void> handleFeignException(FeignException e, HttpServletRequest request) {
    log.error("[服务调用异常] URI={}, service={}, status={}",
            request.getRequestURI(),
            e.request().url(),
            e.status());

    // 尝试解析下游服务的错误信息
    String responseBody = e.contentUTF8();
    if (responseBody != null && !responseBody.isEmpty()) {
        try {
            Result<?> result = objectMapper.readValue(responseBody, Result.class);
            return Result.error(result.getCode(), "下游服务异常:" + result.getMessage());
        } catch (Exception ignored) {
        }
    }

    return Result.error("服务暂时不可用,请稍后重试");
}

八、面试高频考点汇总

考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?

答案

  • @ControllerAdvice@Controller + @ExceptionHandler 的组合,返回值需要配合 @ResponseBody 才会序列化为 JSON。
  • @RestControllerAdvice 等于 @ControllerAdvice + @ResponseBody,返回值自动序列化为 JSON。
  • 在 RESTful 项目中,统一用 @RestControllerAdvice

考点 2:@ExceptionHandler 的匹配规则是什么?

答案

  • 声明顺序从上到下匹配,第一个匹配的处理器生效。
  • 匹配时看抛出的异常类型是否是指定类型的子类
  • 因此要把子类异常放在前面父类异常放在后面
  • 如果多个 @ExceptionHandler 都能匹配,Spring 会选择最具体的那个(异常层次最近的)。

考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?

答案

特性 ResponseBodyAdvice HandlerInterceptor
作用时机 Controller 返回之后,序列化之前 请求进入 Controller 之前 / 之后
主要用途 修改响应体内容(如统一包装) 权限校验、日志记录、耗时统计
能否修改返回值 不能直接修改返回值
异常时是否执行 否(异常不走正常返回流程) preHandle 一定执行,postHandle 异常时不执行

考点 4:如何设计一个支持国际化的异常处理系统?

答案

  1. 异常类里不直接存中文消息,而是存消息 key ,如 error.user.notFound
  2. 全局异常处理器里通过 MessageSource 根据当前 Locale 解析具体消息。
  3. 前端通过 Accept-Language 请求头传递语言偏好。
  4. 准备多份 messages_xx.properties 资源文件。
java 复制代码
// 异常里存 key
throw new BusinessException("error.user.notFound");

// 处理器里解析
String message = messageSource.getMessage(e.getMessage(), null, locale);

考点 5:全局异常处理在微服务架构中怎么设计?

答案

  • 网关层:统一处理 404、405、限流、鉴权失败等网关级错误。
  • 业务服务层 :每个服务都有自己的 @RestControllerAdvice,处理业务异常和系统异常。
  • Feign 调用 :封装 ErrorDecoder,把下游服务的错误响应转换为本地异常。
  • 统一错误码规范 :全公司一套错误码规范,方便问题定位。比如 A01001 表示 A 服务 01 模块的 001 号错误。

九、模拟面试官提问

场景题 1:设计一个完整的异常处理体系

面试官:从零开始设计一个项目的异常处理体系,你会怎么设计?

参考答案

  1. 异常分层

    • BusinessException:业务异常,用户可感知
    • SystemException:系统异常,用户不可感知
    • ThirdPartyException:第三方服务异常
  2. 统一响应体Result<T> 包含 code、message、data、traceId

  3. 全局异常处理器@RestControllerAdvice 分类处理各种异常

  4. 参数校验@Valid + 自定义注解 + 分组校验

  5. 响应包装ResponseBodyAdvice 自动包装返回值

  6. 链路追踪:MDC + traceId,全链路日志可追踪

  7. 异步异常AsyncUncaughtExceptionHandler 处理异步任务异常

场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?

面试官:有个接口返回 String,结果被包装成了 JSON 字符串的字符串,怎么解决?

参考答案

  • 原因是 StringHttpMessageConverter 直接把 String 写入响应体,不会走 JSON 序列化。
  • 解决方案:在 beforeBodyWrite 里判断如果是 String 类型,手动用 ObjectMapper 序列化包装后的 Result 对象,再返回 String。
java 复制代码
if (body instanceof String) {
    return objectMapper.writeValueAsString(Result.success(body));
}

场景题 3:如何防止异常处理逻辑本身抛出异常?

面试官:如果全局异常处理器里又抛出了异常,会怎么样?

参考答案

  • Spring 会捕获异常处理器的异常,返回 500,但响应格式不受控。
  • 防御措施:
    1. 异常处理器里的代码尽量简单,不要调用复杂逻辑。
    2. 日志打印用 try-catch 保护,确保日志异常不会影响主流程。
    3. 关键操作(如发送告警)用异步线程执行,避免阻塞。

场景题 4:前后端分离项目中,异常信息怎么给前端?

面试官:有些错误需要给用户看,有些只需要给前端看,怎么区分?

参考答案

  • message 字段:给用户的友好提示,前端直接展示。
  • 如果需要给前端更详细的信息(但不展示给用户),可以扩展字段:
json 复制代码
{
  "code": 400,
  "message": "提交失败,请检查输入",
  "data": {
    "fieldErrors": {
      "email": "邮箱格式不正确",
      "phone": "手机号已存在"
    }
  }
}
  • 前端用 message 做 Toast 提示,用 data.fieldErrors 做表单字段级错误展示。

场景题 5:生产环境出现异常,如何快速定位问题?

面试官:线上报了一个 500 错误,用户截图只有一个"系统繁忙",你怎么排查?

参考答案

  1. 让用户提供响应头里的 traceId
  2. 根据 traceId 在日志系统(如 ELK)里搜索全链路日志。
  3. 查看异常堆栈,定位具体代码位置。
  4. 结合链路追踪(如 SkyWalking)查看调用链,确认是哪个服务/数据库/缓存出了问题。
  5. 如果是第三方服务问题,查看熔断器状态和降级日志。

十、互动话题

你在项目里做异常处理时,遇到过什么让人崩溃的问题?是 @ExceptionHandler 死活不生效?还是 Swagger 被包装后打不开?又或者是异步任务的异常无声无息地消失了?欢迎在评论区聊聊你的踩坑经历。


参考资料

Spring 官方文档 - Exception Handling