# scaffold 项目之通用全局异常处理

# 简介

异常相关的统一响应、异常处理、业务异常、错误码。

# 统一响应

后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功:

  • 如果成功,成功的数据是什么。后续,前端会将数据渲染到页面上
  • 如果失败,失败的原因是什么。一般,前端会将原因弹出提示给用户

因此,需要有统一响应,而不能是每个接口定义自己的风格。一般来说,统一响应返回信息如下:

  • 成功时,返回成功的状态码 + 数据
  • 失败时,返回失败的状态码 + 错误提示

在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 作为状态码。

关于 HTTP 响应状态码:

HTTP 响应状态码是一个三位数字的代码,它告诉客户端请求的结果。状态码可以分为 5 类:信息响应 (100-199),成功响应 (200-299),重定向 (300-399),客户端错误 (400-499),以及服务器错误 (500-599)。

以下是一些常见的 HTTP 状态码及其含义:

  • 200 OK :请求已成功,并且返回了请求的资源。
  • 204 No Content :请求已成功处理,但没有内容返回。
  • 206 Partial Content :客户端执行范围请求,服务器成功执行了部分 GET 请求。
  • 301 Moved Permanently :请求的资源已被永久移动到新的 URL。
  • 302 Found :请求的资源临时移动到新的 URL。
  • 304 Not Modified :客户端发起条件请求时,资源未更新,可以继续使用缓存的版本。
  • 400 Bad Request :服务器无法处理请求,因为客户端的请求语法错误。
  • 401 Unauthorized :请求需要用户验证。
  • 403 Forbidden :服务器拒绝请求,客户端没有权限访问所请求的资源。
  • 404 Not Found :服务器无法找到客户端请求的资源。
  • 500 Internal Server Error :服务器遇到意外情况,导致它无法完成请求。
  • 503 Service Unavailable :服务器暂时无法处理请求,可能是因为超载或维护。

一般来说,我们实践很少这么去做,主要原因如下:

  • 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等
  • 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的

# CommontResult 通用返回

在项目实践中,将状态码放到 responseBody 中,定义如下:

package com.tz.scaffold.framework.common.pojo;
/**
 * <p> Project: scaffold - CommonResult </p>
 *
 * 通用返回
 *
 * @param <T> 数据泛型
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Data
public class CommonResult<T> implements Serializable {
    /**
     * 错误码
     *
     * @see ErrorCode#getCode ()
     */
    private Integer code;
    /**
     * 返回数据
     */
    private T data;
    /**
     * 错误提示,用户可阅读
     *
     * @see ErrorCode#getMsg () ()
     */
    private String msg;
    
    public static <T> CommonResult<T> error(Integer code, String message) {
        Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code), "code 必须是错误的!");
        CommonResult<T> result = new CommonResult<>();
        result.code = code;
        result.msg = message;
        return result;
    }
    public static <T> CommonResult<T> success(T data) {
        CommonResult<T> result = new CommonResult<>();
        result.code = GlobalErrorCodeConstants.SUCCESS.getCode();
        result.data = data;
        result.msg = "";
        return result;
    }
   
}

分成只有两大情况返回:

  1. 成功。状态码固定,返回成功的数据 调用 success 方法
  2. 失败。返回对应的错误码,业务或者系统的错误码(这里的错误码使用 全局错误码 ),并返回对应的错误信息 调用 error 方法

# 异常处理

当 Restful Api 接口发生异常,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。

# SpringMvc 的异常

一般就是 参数绑定异常缺少参数异常 等等

@RestControllerAdvice 是 Spring MVC 中的一个特殊注解,它是 @ControllerAdvice 的特化版本,专门用于处理 @RestController 类型的控制器。 @RestController@Controller@ResponseBody 的组合,意味着其方法的返回值会自动作为 HTTP 响应的正文。

@RestControllerAdvice 可以用于全局异常处理、数据绑定、数据预处理等,类似于 @ControllerAdvice ,但它默认只适用于带有 @RestController 注解的控制器。如果你需要它同时适用于 @Controller@RestController ,可以通过设置 @RestControllerAdvicebasePackagesbaseControllerClasses 属性来指定。

本项目就是通过 @RestControllerAdvice + @ExceptionHandler 的方式将指定的异常转换成 CommonResult 类型,统一响应。

代码如下:

package com.tz.scaffold.framework.web.core.handler;
/**
 * <p> Project: scaffold - GlobalExceptionHandler </p>
 *
 * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@RestControllerAdvice
@AllArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
    private final String applicationName;
    private final ApiErrorLogFrameworkService apiErrorLogFrameworkService;
    /**
     * 处理所有异常,主要是提供给 Filter 使用
     * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。
     *
     * @param request 请求
     * @param ex 异常
     * @return 通用返回
     */
    public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {
        if (ex instanceof MissingServletRequestParameterException) {
            return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
        }
        if (ex instanceof MethodArgumentTypeMismatchException) {
            return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
        }
        if (ex instanceof MethodArgumentNotValidException) {
            return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
        }
        if (ex instanceof BindException) {
            return bindExceptionHandler((BindException) ex);
        }
        if (ex instanceof ConstraintViolationException) {
            return constraintViolationExceptionHandler((ConstraintViolationException) ex);
        }
        if (ex instanceof ValidationException) {
            return validationException((ValidationException) ex);
        }
        if (ex instanceof NoHandlerFoundException) {
            return noHandlerFoundExceptionHandler(request, (NoHandlerFoundException) ex);
        }
        if (ex instanceof HttpRequestMethodNotSupportedException) {
            return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
        }
        if (ex instanceof ServiceException) {
            return serviceExceptionHandler((ServiceException) ex);
        }
        if (ex instanceof AccessDeniedException) {
            return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
        }
        return defaultExceptionHandler(request, ex);
    }
    /**
     * 处理 SpringMVC 请求参数缺失
     *
     * 例如说,接口上设置了 @RequestParam ("xx") 参数,结果并未传递 xx 参数
     */
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public CommonResult<?> missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) {
        log.warn("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName()));
    }
    /**
     * 处理 SpringMVC 请求参数类型错误
     *
     * 例如说,接口上设置了 @RequestParam ("xx") 参数为 Integer,结果传递 xx 参数类型为 String
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public CommonResult<?> methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) {
        log.warn("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage()));
    }
    /**
     * 处理 SpringMVC 参数校验不正确
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public CommonResult<?> methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) {
        log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex);
        FieldError fieldError = ex.getBindingResult().getFieldError();
        // 断言,避免告警
        assert fieldError != null;
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }
    /**
     * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验
     */
    @ExceptionHandler(BindException.class)
    public CommonResult<?> bindExceptionHandler(BindException ex) {
        log.warn("[handleBindException]", ex);
        FieldError fieldError = ex.getFieldError();
        // 断言,避免告警
        assert fieldError != null;
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }
    /**
     * 处理 Validator 校验不通过产生的异常
     */
    @ExceptionHandler(value = ConstraintViolationException.class)
    public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next();
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage()));
    }
    /**
     * 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常
     */
    @ExceptionHandler(value = ValidationException.class)
    public CommonResult<?> validationException(ValidationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        // 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读
        return CommonResult.error(BAD_REQUEST);
    }
    /**
     * 处理 SpringMVC 请求地址不存在
     *
     * 注意,它需要设置如下两个配置项:
     * 1. spring.mvc.throw-exception-if-no-handler-found 为 true
     * 2. spring.mvc.static-path-pattern 为 /statics/**
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public CommonResult<?> noHandlerFoundExceptionHandler(HttpServletRequest req, NoHandlerFoundException ex) {
        log.warn("[noHandlerFoundExceptionHandler]", ex);
        return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL()));
    }
    /**
     * 处理 SpringMVC 请求方法不正确
     *
     * 例如说,A 接口的方法为 GET 方式,结果请求方法为 POST 方式,导致不匹配
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public CommonResult<?> httpRequestMethodNotSupportedExceptionHandler(HttpRequestMethodNotSupportedException ex) {
        log.warn("[httpRequestMethodNotSupportedExceptionHandler]", ex);
        return CommonResult.error(METHOD_NOT_ALLOWED.getCode(), String.format("请求方法不正确:%s", ex.getMessage()));
    }
    /**
     * 处理 Resilience4j 限流抛出的异常
     */
    public CommonResult<?> requestNotPermittedExceptionHandler(HttpServletRequest req, Throwable ex) {
        log.warn("[requestNotPermittedExceptionHandler][url({}) 访问过于频繁]", req.getRequestURL(), ex);
        return CommonResult.error(TOO_MANY_REQUESTS);
    }
    /**
     * 处理 Spring Security 权限不足的异常
     *
     * 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截
     */
    @ExceptionHandler(value = AccessDeniedException.class)
    public CommonResult<?> accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) {
        log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req),
                req.getRequestURL(), ex);
        return CommonResult.error(FORBIDDEN);
    }
    /**
     * 处理业务异常 ServiceException
     *
     * 例如说,商品库存不足,用户手机号已存在。
     */
    @ExceptionHandler(value = ServiceException.class)
    public CommonResult<?> serviceExceptionHandler(ServiceException ex) {
        log.info("[serviceExceptionHandler]", ex);
        return CommonResult.error(ex.getCode(), ex.getMessage());
    }
    /**
     * 处理系统异常,兜底处理所有的一切
     */
    @ExceptionHandler(value = Exception.class)
    public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {
        // 情况一:处理表不存在的异常
        CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
        if (tableNotExistsResult != null) {
            return tableNotExistsResult;
        }
        // 情况二:部分特殊的库的处理
        if (Objects.equals("io.github.resilience4j.ratelimiter.RequestNotPermitted", ex.getClass().getName())) {
            return requestNotPermittedExceptionHandler(req, ex);
        }
        // 情况三:处理异常
        log.error("[defaultExceptionHandler]", ex);
        // 插入异常日志
        this.createExceptionLog(req, ex);
        // 返回 ERROR CommonResult
        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
    }
    private void createExceptionLog(HttpServletRequest req, Throwable e) {
        // 插入错误日志
        ApiErrorLog errorLog = new ApiErrorLog();
        try {
            // 初始化 errorLog
            initExceptionLog(errorLog, req, e);
            // 执行插入 errorLog
            apiErrorLogFrameworkService.createApiErrorLog(errorLog);
        } catch (Throwable th) {
            log.error("[createExceptionLog][url({}) log({}) 发生异常]", req.getRequestURI(),  JsonUtils.toJsonString(errorLog), th);
        }
    }
    private void initExceptionLog(ApiErrorLog errorLog, HttpServletRequest request, Throwable e) {
        // 处理用户信息
        errorLog.setUserId(WebFrameworkUtils.getLoginUserId(request));
        errorLog.setUserType(WebFrameworkUtils.getLoginUserType(request));
        // 设置异常字段
        errorLog.setExceptionName(e.getClass().getName());
        errorLog.setExceptionMessage(ExceptionUtil.getMessage(e));
        errorLog.setExceptionRootCauseMessage(ExceptionUtil.getRootCauseMessage(e));
        errorLog.setExceptionStackTrace(ExceptionUtils.getStackTrace(e));
        StackTraceElement[] stackTraceElements = e.getStackTrace();
        Assert.notEmpty(stackTraceElements, "异常 stackTraceElements 不能为空");
        StackTraceElement stackTraceElement = stackTraceElements[0];
        errorLog.setExceptionClassName(stackTraceElement.getClassName());
        errorLog.setExceptionFileName(stackTraceElement.getFileName());
        errorLog.setExceptionMethodName(stackTraceElement.getMethodName());
        errorLog.setExceptionLineNumber(stackTraceElement.getLineNumber());
        // 设置其它字段
        errorLog.setTraceId(TracerUtils.getTraceId());
        errorLog.setApplicationName(applicationName);
        errorLog.setRequestUrl(request.getRequestURI());
        Map<String, Object> requestParams = MapUtil.<String, Object>builder()
                .put("query", ServletUtils.getParamMap(request))
                .put("body", ServletUtils.getBody(request)).build();
        errorLog.setRequestParams(JsonUtils.toJsonString(requestParams));
        errorLog.setRequestMethod(request.getMethod());
        errorLog.setUserAgent(ServletUtils.getUserAgent(request));
        errorLog.setUserIp(ServletUtils.getClientIP(request));
        errorLog.setExceptionTime(LocalDateTime.now());
    }
    /**
     * 处理 Table 不存在的异常情况
     *
     * @param ex 异常
     * @return 如果是 Table 不存在的异常,则返回对应的 CommonResult
     */
    private CommonResult<?> handleTableNotExists(Throwable ex) {
        String message = ExceptionUtil.getRootCauseMessage(ex);
        if (!message.contains("doesn't exist")) {
            return null;
        }
        // 1. 数据报表
        if (message.contains("report_")) {
            log.error("[报表模块 scaffold-module-report - 表结构未导入]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[报表模块 scaffold-module-report - 表结构未导入]");
        }
        return null;
    }
}

# filter 的异常

在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。只能通过 try catch 的方式来实现,代码如下:

下面是过滤器代码,可以看到在抛出异常捕获后的处理

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String token = SecurityFrameworkUtils.obtainAuthorization(request,
                securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
        if (StrUtil.isNotEmpty(token)) {
            Integer userType = WebFrameworkUtils.getLoginUserType(request);
            try {
                // 1.1 基于 token 构建登录用户
                LoginUser loginUser = buildLoginUserByToken(token, userType);
                // 1.2 模拟 Login 功能,方便日常开发调试
                if (loginUser == null) {
                    loginUser = mockLoginUser(request, token, userType);
                }
                // 2. 设置当前用户
                if (loginUser != null) {
                    SecurityFrameworkUtils.setLoginUser(loginUser, request);
                }
            } catch (Throwable ex) {
                CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
                ServletUtils.writeJSON(response, result);
                return;
            }
        }
        // 继续过滤链
        chain.doFilter(request, response);
    }

# 业务异常

在 service 发生业务异常如何处理进行返回呢? 我们在处理业务的时候,都会有业务上的异常,比如,用户不存在,商品库存不足等等,主要有两种解决方案:

  1. 使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回
  2. 使用 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出

选择方案一 CommonResult 会存在两个问题:

  • 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦
  • 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的

因此,项目采用方案二 ServiceException 异常。

# ServiceException 类

定义 ServiceException 类继承 RuntimeException (非受检),用于定义业务异常,代码如下:

package com.tz.scaffold.framework.common.exception;
import com.tz.scaffold.framework.common.exception.enums.ServiceErrorCodeRange;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * <p> Project: scaffold - ServiceException </p>
 *
 * 业务逻辑异常 Exception
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Data
@EqualsAndHashCode(callSuper = true)
public final class ServiceException extends RuntimeException {
    /**
     * 业务错误码
     *
     * @see ServiceErrorCodeRange
     */
    private Integer code;
    /**
     * 错误提示
     */
    private String message;
    /**
     * 空构造方法,避免反序列化问题
     */
    public ServiceException() {
    }
    public ServiceException(ErrorCode errorCode) {
        this.code = errorCode.getCode();
        this.message = errorCode.getMsg();
    }
    public ServiceException(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
    public Integer getCode() {
        return code;
    }
    public ServiceException setCode(Integer code) {
        this.code = code;
        return this;
    }
    @Override
    public String getMessage() {
        return message;
    }
    public ServiceException setMessage(String message) {
        this.message = message;
        return this;
    }
}

为什么继承 RuntimeException 异常?

大多数业务场景下,我们无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。
如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。

# serviceExceptionUtil 类

在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil 类的 #exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。代码如下:

package com.tz.scaffold.framework.common.exception.util;
import com.tz.scaffold.framework.common.exception.ErrorCode;
import com.tz.scaffold.framework.common.exception.ServiceException;
import com.tz.scaffold.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.google.common.annotations.VisibleForTesting;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
 * <p> Project: scaffold - ServiceExceptionUtil </p>
 *
 * {@link ServiceException} 工具类
 * <p>
 * 目的在于,格式化异常信息提示。
 * <p>
 * 考虑到 String.format 在参数不正确时会报错,因此使用 {} 作为占位符,并使用 {@link #doFormat (int, String, Object...)} 方法来格式化
 * <p>
 * 因为 {@link #MESSAGES} 里面默认是没有异常信息提示的模板的,所以需要使用方自己初始化进去。目前想到的有几种方式:
 * <p>
 * <li>
 *     1. 异常提示信息,写在枚举类中,例如说,cn.iocoder.oceans.user.api.constants.ErrorCodeEnum 类 + ServiceExceptionConfiguration
 * <li>
 *     2. 异常提示信息,写在 .properties 等等配置文件
 * <li>
 *     3. 异常提示信息,写在 Apollo 等等配置中心中,从而实现可动态刷新
 * <li>
 *     4. 异常提示信息,存储在 db 等等数据库中,从而实现可动态刷新
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Slf4j
public class ServiceExceptionUtil {
    /**
     * 错误码提示模板
     */
    private static final ConcurrentMap<Integer, String> MESSAGES = new ConcurrentHashMap<>();
    public static void putAll(Map<Integer, String> messages) {
        ServiceExceptionUtil.MESSAGES.putAll(messages);
    }
    public static void put(Integer code, String message) {
        ServiceExceptionUtil.MESSAGES.put(code, message);
    }
    public static void delete(Integer code, String message) {
        ServiceExceptionUtil.MESSAGES.remove(code, message);
    }
    // ========== 和 ServiceException 的集成 ==========
    public static ServiceException exception(ErrorCode errorCode) {
        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg());
        return exception0(errorCode.getCode(), messagePattern);
    }
    public static ServiceException exception(ErrorCode errorCode, Object... params) {
        String messagePattern = MESSAGES.getOrDefault(errorCode.getCode(), errorCode.getMsg());
        return exception0(errorCode.getCode(), messagePattern, params);
    }
    /**
     * 创建指定编号的 ServiceException 的异常
     *
     * @param code 编号
     * @return 异常
     */
    public static ServiceException exception(Integer code) {
        return exception0(code, MESSAGES.get(code));
    }
    /**
     * 创建指定编号的 ServiceException 的异常
     *
     * @param code 编号
     * @param params 消息提示的占位符对应的参数
     * @return 异常
     */
    public static ServiceException exception(Integer code, Object... params) {
        return exception0(code, MESSAGES.get(code), params);
    }
    public static ServiceException exception0(Integer code, String messagePattern, Object... params) {
        String message = doFormat(code, messagePattern, params);
        return new ServiceException(code, message);
    }
    public static ServiceException invalidParamException(String messagePattern, Object... params) {
        return exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), messagePattern, params);
    }
    // ========== 格式化方法 ==========
    /**
     * 将错误编号对应的消息使用 params 进行格式化。
     *
     * @param code           错误编号
     * @param messagePattern 消息模版
     * @param params         参数
     * @return 格式化后的提示
     */
    @VisibleForTesting
    public static String doFormat(int code, String messagePattern, Object... params) {
        StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50);
        int i = 0;
        int j;
        int l;
        for (l = 0; l < params.length; l++) {
            j = messagePattern.indexOf("{}", i);
            if (j == -1) {
                log.error("[doFormat][参数过多:错误码({})|错误内容({})|参数({})", code, messagePattern, params);
                if (i == 0) {
                    return messagePattern;
                } else {
                    sbuf.append(messagePattern.substring(i));
                    return sbuf.toString();
                }
            } else {
                sbuf.append(messagePattern, i, j);
                sbuf.append(params[l]);
                i = j + 2;
            }
        }
        if (messagePattern.indexOf("{}", i) != -1) {
            log.error("[doFormat][参数过少:错误码({})|错误内容({})|参数({})", code, messagePattern, params);
        }
        sbuf.append(messagePattern.substring(i));
        return sbuf.toString();
    }
}

为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常?

错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。

# 动态加载错误信息

通过添加 ErrorCodeLoader 类来实现,具体原理是当程序启动完毕后冲数据库第一次加载, 之后定时刷新,或者手动触发,代码如下:

package com.tz.scaffold.framework.errorcode.core.loader;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.exceptions.ExceptionUtil;
import com.tz.scaffold.framework.common.util.date.DateUtils;
import com.tz.scaffold.module.system.api.errorcode.ErrorCodeApi;
import com.tz.scaffold.module.system.api.errorcode.dto.ErrorCodeRespDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.LocalDateTime;
import java.util.List;
/**
 * <p> Project: scaffold - ErrorCodeLoaderImpl </p>
 *
 * ErrorCodeLoader 的实现类,从 infra 的数据库中,加载错误码。
 * <p>
 * 考虑到错误码会刷新,所以按照 {@link #REFRESH_ERROR_CODE_PERIOD} 频率,增量加载错误码。
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@RequiredArgsConstructor
@Slf4j
public class ErrorCodeLoaderImpl implements ErrorCodeLoader {
    /**
     * 刷新错误码的频率,单位:毫秒
     */
    private static final int REFRESH_ERROR_CODE_PERIOD = 60 * 1000;
    /**
     * 应用分组
     */
    private final String applicationName;
    /**
     * 错误码 Api
     */
    private final ErrorCodeApi errorCodeApi;
    /**
     * 缓存错误码的最大更新时间,用于后续的增量轮询,判断是否有更新
     */
    private LocalDateTime maxUpdateTime;
    /**
     * Async: 异步,保证项目的启动过程,毕竟非关键流程
     */
    @Override
    @EventListener(ApplicationReadyEvent.class)
    @Async
    public void loadErrorCodes() {
        loadErrorCodes0();
    }
    @Override
    @Scheduled(fixedDelay = REFRESH_ERROR_CODE_PERIOD, initialDelay = REFRESH_ERROR_CODE_PERIOD)
    public void refreshErrorCodes() {
        loadErrorCodes0();
    }
    private void loadErrorCodes0() {
        try {
            // 加载错误码
            List<ErrorCodeRespDTO> errorCodeRespDTOs = errorCodeApi.getErrorCodeList(applicationName, maxUpdateTime);
            if (CollUtil.isEmpty(errorCodeRespDTOs)) {
                return;
            }
            log.info("[loadErrorCodes0][加载到 ({}) 个错误码]", errorCodeRespDTOs.size());
            // 刷新错误码的缓存
            errorCodeRespDTOs.forEach(errorCodeRespDTO -> {
                // 写入到错误码的缓存
                putErrorCode(errorCodeRespDTO.getCode(), errorCodeRespDTO.getMessage());
                // 记录下更新时间,方便增量更新
                maxUpdateTime = DateUtils.max(maxUpdateTime, errorCodeRespDTO.getUpdateTime());
            });
        } catch (Exception ex) {
            log.error("[loadErrorCodes0][加载错误码失败({})]", ExceptionUtil.getRootCauseMessage(ex));
        }
    }
}

关于 @EventListener(ApplicationReadyEvent.class) 注解说明

@EventListener(ApplicationReadyEvent.class) 是一个在 Spring 框架中使用的注解,它用于标记一个方法,以便在 Spring 应用程序上下文(application context)完全初始化并准备好后,自动调用这个方法。

这里的 ApplicationReadyEvent 是 Spring 框架中的一个事件类,当 Spring 应用程序上下文初始化完成后,会发布这个事件。通过在方法上使用 @EventListener 注解,并指定 ApplicationReadyEvent.class 作为参数,你可以注册一个事件监听器,当 ApplicationReadyEvent 被发布时,这个方法就会被调用。

# 错误码

错误码,对应 ErrorCode 类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。代码如下:

package com.tz.scaffold.framework.common.exception;
import com.tz.scaffold.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.tz.scaffold.framework.common.exception.enums.ServiceErrorCodeRange;
import lombok.Data;
/**
 * <p> Project: scaffold - ErrorCode </p>
 *
 * 错误码对象
 * <p>
 * 全局错误码,占用 [0, 999], 参见 {@link GlobalErrorCodeConstants}
 * <p>
 * 业务异常错误码,占用 [1 000 000 000, +∞),参见 {@link ServiceErrorCodeRange}
 *
 * TODO 错误码设计成对象的原因,为未来的 i18 国际化做准备
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Data
public class ErrorCode {
    /**
     * 错误码
     */
    private final Integer code;
    /**
     * 错误提示
     */
    private final String msg;
    public ErrorCode(Integer code, String message) {
        this.code = code;
        this.msg = message;
    }
}

# 错误码分类

错误码分成两类:全局的系统错误码、模块的业务错误码。

# 系统错误码

全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 对应。虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。
系统错误码定义在 GlobalErrorCodeConstants 类,代码如下:

package com.tz.scaffold.framework.common.exception.enums;
import com.tz.scaffold.framework.common.exception.ErrorCode;
/**
 * <p> Project: scaffold - GlobalErrorCodeConstants </p>
 *
 * 全局错误码枚举
 * <p>
 * 0-999 系统异常编码保留
 * <p>
 * 一般情况下,使用 HTTP 响应状态码 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status
 * <p>
 * 虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的
 * <p>
 * 比较特殊的是,因为之前一直使用 0 作为成功,就不使用 200 啦。
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
public interface GlobalErrorCodeConstants {
    ErrorCode SUCCESS = new ErrorCode(0, "成功");
    // ========== 客户端错误段 ==========
    ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确");
    ErrorCode UNAUTHORIZED = new ErrorCode(401, "账号未登录");
    ErrorCode FORBIDDEN = new ErrorCode(403, "没有该操作权限");
    ErrorCode NOT_FOUND = new ErrorCode(404, "请求未找到");
    ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确");
    /**
     * 并发请求,不允许
     */
    ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试");
    ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试");
    // ========== 服务端错误段 ==========
    ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");
    ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启");
    ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "错误的配置项");
    // ========== 自定义错误段 ==========
    /**
     * 重复请求
     */
    ErrorCode REPEATED_REQUESTS = new ErrorCode(900, "重复请求,请稍后重试");
    ErrorCode DEMO_DENY = new ErrorCode(901, "演示模式,禁止写操作");
    ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");
}
# 业务错误码

模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。

  1. 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange 分配,规则与代码如下:

    package com.tz.scaffold.framework.common.exception.enums;
    /**
     * <p> Project: scaffold - ServiceErrorCodeRange </p>
     *
     * 业务异常的错误码区间,解决:解决各模块错误码定义,避免重复,在此只声明不做实际使用
     * <p>
     * 一共 10 位,分成四段
     *
     * 第一段,1 位,类型
     *      1 - 业务级别异常
     *      x - 预留
     * 第二段,3 位,系统类型
     *      001 - 用户系统
     *      002 - 商品系统
     *      003 - 订单系统
     *      004 - 支付系统
     *      005 - 优惠劵系统
     *      ... - ...
     * 第三段,3 位,模块
     *      不限制规则。
     *      一般建议,每个系统里面,可能有多个模块,可以再去做分段。以用户系统为例子:
     *          001 - OAuth2 模块
     *          002 - User 模块
     *          003 - MobileCode 模块
     * 第四段,3 位,错误码
     *       不限制规则。
     *       一般建议,每个模块自增。
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public class ServiceErrorCodeRange {
        // 模块 infra 错误码区间 [1-001-000-000 ~ 1-002-000-000)
        // 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000)
        // 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000)
        // 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000)
        // 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000)
        // 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000)
        // 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000)
        // 模块 product 错误码区间 [1-008-000-000 ~ 1-009-000-000)
        // 模块 trade 错误码区间 [1-011-000-000 ~ 1-012-000-000)
        // 模块 promotion 错误码区间 [1-013-000-000 ~ 1-014-000-000)
        // 模块 crm 错误码区间 [1-020-000-000 ~ 1-021-000-000)
    }
  2. 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。以 scaffold-module-system 模块举例子,代码如下:

    package com.tz.scaffold.module.system.enums;
    import com.tz.scaffold.framework.common.exception.ErrorCode;
    /**
     * <p> Project: scaffold - ErrorCodeConstants </p>
     *
     * System 错误码枚举类
     * <p>
     * system 系统,使用 1-002-000-000 段
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public interface ErrorCodeConstants {
        // ========== AUTH 模块 1-002-000-000 ==========
        ErrorCode AUTH_LOGIN_BAD_CREDENTIALS = new ErrorCode(1_002_000_000, "登录失败,账号密码不正确");
    	...
        // ========== 菜单模块 1-002-001-000 ==========
        ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单");
        ErrorCode MENU_PARENT_NOT_EXISTS = new ErrorCode(1_002_001_001, "父菜单不存在");
    	...
    }