# 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; | |
} | |
} |
分成只有两大情况返回:
- 成功。状态码固定,返回成功的数据 调用
success
方法 - 失败。返回对应的错误码,业务或者系统的错误码(这里的错误码使用
全局错误码
),并返回对应的错误信息 调用error
方法
# 异常处理
当 Restful Api 接口发生异常,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。
# SpringMvc 的异常
一般就是 参数绑定异常
, 缺少参数异常
等等
@RestControllerAdvice
是 Spring MVC 中的一个特殊注解,它是@ControllerAdvice
的特化版本,专门用于处理@RestController
类型的控制器。@RestController
是@Controller
和@ResponseBody
的组合,意味着其方法的返回值会自动作为 HTTP 响应的正文。
@RestControllerAdvice
可以用于全局异常处理、数据绑定、数据预处理等,类似于@ControllerAdvice
,但它默认只适用于带有@RestController
注解的控制器。如果你需要它同时适用于@Controller
和@RestController
,可以通过设置@RestControllerAdvice
的basePackages
或baseControllerClasses
属性来指定。
本项目就是通过 @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 发生业务异常如何处理进行返回呢? 我们在处理业务的时候,都会有业务上的异常,比如,用户不存在,商品库存不足等等,主要有两种解决方案:
- 使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行
return
返回 - 使用 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, "未知错误"); | |
} |
# 业务错误码
模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。
业务错误码一共 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)
}
每个业务模块,定义自己的
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, "父菜单不存在");
...
}