本文基于若依3.9.2、SpringBoot3版本。

异常体系设计:三类策略(i18n code / message / 受检异常)
业务代码出错时,一般直接抛出异常 throw new RuntimeException("用户不存在")。提示语写死在代码里,改文案就要动代码;多个模块对同类错误各写各的文案,不统一;想把提示语做成多语言,更是无处下手。
若依在 common 模块的 com.ruoyi.common.exception 包下定义了 18 个自定义异常类,把"抛什么异常、带什么信息、由谁处理"整理成一套体系:包内按模块划分 base、file、user、job 四个子包,异常按消息处理策略分成三类。
本文沿这三类策略展开,依次看每类异常的定义、使用位置和实现细节。
十八个异常类的全景
先看继承关系,两个根节点分别对应 Java 的非受检异常与受检异常,注释标注了每个类的消息策略:
arduino
RuntimeException(非受检异常)
├── BaseException i18n code 驱动,支持国际化消息
│ ├── file.FileException module 固定为 "file"
│ │ ├── FileNameLengthLimitExceededException
│ │ └── FileSizeLimitExceededException
│ └── user.UserException module 固定为 "user"
│ ├── BlackListException
│ ├── CaptchaException
│ ├── CaptchaExpireException
│ ├── UserNotExistsException
│ ├── UserPasswordNotMatchException
│ └── UserPasswordRetryLimitExceedException
├── ServiceException (final) 直接 message 驱动,业务异常
├── GlobalException 直接 message 驱动,全局异常
├── UtilException 工具类异常
└── DemoModeException 演示模式异常
Exception(受检异常)
├── file.FileUploadException 文件上传异常,手动维护 cause 链
│ └── InvalidExtensionException
│ ├── InvalidImageExtensionException 静态内部类
│ ├── InvalidFlashExtensionException 静态内部类
│ ├── InvalidMediaExtensionException 静态内部类
│ └── InvalidVideoExtensionException 静态内部类
└── job.TaskException 定时任务异常,内含 Code 枚举
按消息策略归纳,三类各有分工:
- i18n code 驱动 (
BaseException一系):异常只携带 code 和参数,文案维护在资源文件里,getMessage()时实时解析,适合框架内置的固定错误场景 - 直接 message 驱动 (
ServiceException、GlobalException、UtilException、DemoModeException):异常直接携带现成的提示文本,不经过 i18n 解析,适合业务代码随手抛出的即席提示 - 受检异常 (
FileUploadException、TaskException):继承Exception,编译期强制调用方 try-catch 或声明 throws,适合调用方能针对性恢复的错误
BaseException:code 与文案分离
BaseException 继承 RuntimeException,是 i18n 驱动异常的基类,定义四个字段:
java
/** 所属模块 */
private String module;
/** 错误码 */
private String code;
/** 错误码对应的参数 */
private Object[] args;
/** 错误消息 */
private String defaultMessage;
module 标识异常来自哪个模块(如 "file"、"user"),code 是资源文件里的消息键,args 是消息模板的占位符参数,defaultMessage 是 code 为空时的兜底文案。构造方法有五个重载,最终都委托给全参构造:
java
public BaseException(String module, String code, Object[] args, String defaultMessage) {
this.module = module;
this.code = code;
this.args = args;
this.defaultMessage = defaultMessage;
}
核心是重写的 getMessage(),消息在这个方法被调用时才解析:
java
@Override
public String getMessage() {
String message = null;
if (!StringUtils.isEmpty(code)) {
message = MessageUtils.message(code, args);
}
if (message == null) {
message = defaultMessage;
}
return message;
}
code 非空就交给 MessageUtils 解析,code 为空才回落到 defaultMessage。这里有一个行为要注意:MessageUtils 底层调用的是不带兜底参数的 messageSource.getMessage(code, args, locale),code 非空但资源文件里没有这个键时,会直接抛 NoSuchMessageException,并不会回落到 defaultMessage。使用这套体系时,每个 code 都要在资源文件里有对应条目。
MessageUtils 本身只有一层封装,从 Spring 容器取出 MessageSource 并调用:
java
public static String message(String code, Object... args) {
MessageSource messageSource = SpringUtils.getBean(MessageSource.class);
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
衔接它的都是 Spring 与 JDK 的既有机制:MessageSource 是 Spring 的国际化消息源接口,从 classpath 读取 messages.properties(默认)和按语言命名的 messages_xx.properties,按 Locale 决定读哪份;LocaleContextHolder.getLocale() 返回当前线程绑定的语言环境;查到的模板中 {0}、{1} 占位符由 JDK 的 MessageFormat 按 args 顺序填充。若依目前只提供了中文的 messages.properties,位于 ruoyi-admin 模块的 i18n 目录:
java
user.jcaptcha.error=验证码错误
user.jcaptcha.expire=验证码已失效
user.not.exists=用户不存在/密码错误
user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定{1}分钟
login.blocked=很遗憾,访问IP已被列入系统黑名单
upload.filename.exceed.length=上传的文件名最长{0}个字符
以 UserPasswordRetryLimitExceedException 为例,code 为 user.password.retry.limit.exceed,args 为 [5, 10],getMessage() 的最终产出是"密码输入错误5次,帐户锁定10分钟"。
code 与文案分离的价值在这里体现:异常类只定义"发生了哪类错误",文案集中在一个资源文件里维护,改提示语不动代码;将来要支持多语言,补一份按语言命名的资源文件即可,异常类同样不用改。
file 与 user 子包:固定 module 的模块化异常
BaseException 的 module 字段在两个子包基类中落地。FileException 与 UserException 的构造方法都把模块名写死,子类只需传 code 和参数:
java
public FileException(String code, Object[] args) {
super("file", code, args, null);
}
UserException 的构造方法与之对称,模块名固定为 user:
java
public UserException(String code, Object[] args) {
super("user", code, args, null);
}
user 子包六个异常对应的 code 与文案如下,前五个无需参数,args 传 null:
| 类 | code | 文案 |
|---|---|---|
BlackListException |
login.blocked |
很遗憾,访问IP已被列入系统黑名单 |
CaptchaException |
user.jcaptcha.error |
验证码错误 |
CaptchaExpireException |
user.jcaptcha.expire |
验证码已失效 |
UserNotExistsException |
user.not.exists |
用户不存在/密码错误 |
UserPasswordNotMatchException |
user.password.not.match |
用户不存在/密码错误 |
UserPasswordRetryLimitExceedException |
user.password.retry.limit.exceed |
密码输入错误{0}次,帐户锁定{1}分钟 |
需要参数的异常把数值包装成 args 数组:
java
public UserPasswordRetryLimitExceedException(int retryLimitCount, int lockTime) {
super("user.password.retry.limit.exceed", new Object[]{retryLimitCount, lockTime});
}
file 子包两个异常的 code 是 upload.filename.exceed.length(文件名超长)和 upload.exceed.maxSize(文件大小超限),参数分别是允许的文件名长度上限和大小上限(MB)。
这些异常集中在登录与文件上传链路抛出:SysPasswordService.validate() 在密码错误次数达到上限时抛 UserPasswordRetryLimitExceedException;SysLoginService.validateCaptcha() 在验证码失效、错误时分别抛 CaptchaExpireException、CaptchaException,登录前置校验在 IP 命中黑名单时抛 BlackListException。
使用中有一处交叉值得注意:认证时 UserDetailsServiceImpl.loadUserByUsername() 处理用户不存在、已删除、已封禁三种情形,没有用 UserException 子类,而是先手动解析 i18n 再包成 ServiceException:
java
throw new ServiceException(MessageUtils.message("user.not.exists"));
UserNotExistsException 实际只在 loginPreCheck() 的用户名/密码为空校验中使用。同一个消息键 user.not.exists,一条路径走 BaseException 体系,另一条走 ServiceException 体系,两套异常的模块边界没有被严格执行。
ServiceException:业务异常的主力
ServiceException 是框架里使用最广的业务异常,继承 RuntimeException,声明为 final,定义三个字段:
java
/** 错误码 */
private Integer code;
/** 错误提示 */
private String message;
/** 错误明细,内部调试错误 */
private String detailMessage;
与 BaseException 的关键区别在消息来源:这里的 message 是现成的提示文本,getMessage() 直接返回它,不经过任何解析:
java
@Override
public String getMessage() {
return message;
}
code 是 Integer 类型,取 HTTP 状态码风格(如 401、500),可以不传。
它不继承 BaseException,原因是两者的消息策略相反:BaseException.getMessage() 把传入的字符串当消息键去资源文件查询,ServiceException 的用法是把传入的字符串当文案直接返回。
业务代码里的写法是第二种,比如 throw new ServiceException("不允许操作超级管理员用户")。如果 ServiceException 继承 BaseException,要么放弃直接传文案的用法、为每条提示维护一个 i18n 键,要么重写 getMessage() 绕开解析,继承只剩空壳。所以它独立设计;final 修饰则把业务异常的出口收敛到这一个类,禁止再派生。
两个链式 setter 方便构造后补充字段,以 setMessage 为例:
java
public ServiceException setMessage(String message) {
this.message = message;
return this;
}
使用面覆盖各业务模块:system 模块各 Service 的业务校验(删除已分配的角色、操作超管账号等)、限流切面抛"访问过于频繁,请稍候再试";需要携带状态码时把 code 一起传入,SecurityUtils 在获取不到当前登录用户时就是这样做的:
java
throw new ServiceException("获取用户ID异常", HttpStatus.UNAUTHORIZED);
同包下还有三个直接 message 驱动的"配角":
GlobalException:字段与ServiceException几乎一致,只是没有 code 且非 final。整个代码库没有任何地方抛出它,javadoc 里引用的CommonResult类也不存在,属于遗留代码,功能上可被ServiceException替代UtilException:工具类的统一出口,构造时把底层异常包成RuntimeException,如SqlUtil检测到 SQL 注入风险、ExcelUtil导出失败时抛出,避免工具方法在签名上强制声明 throwsDemoModeException:空实现的标记异常,全局异常处理器里有它的专属分支,返回"演示模式,不允许操作",当前版本没有代码抛出它,属于预留
UtilException 的包装构造只有一行,底层异常的消息和 cause 都被保留:
java
public UtilException(Throwable e) {
super(e.getMessage(), e);
}
受检异常:强制调用方处理
18 个异常类中有 4 个继承 Exception,集中在文件上传和定时任务两个模块。先看 FileUploadException,它与常见写法不同的地方是手动维护 cause 链:
java
public class FileUploadException extends Exception {
private final Throwable cause;
public FileUploadException(String msg, Throwable cause) {
super(msg);
this.cause = cause;
}
@Override
public void printStackTrace(PrintStream stream) {
super.printStackTrace(stream);
if (cause != null) {
stream.println("Caused by:");
cause.printStackTrace(stream);
}
}
@Override
public Throwable getCause() {
return cause;
}
}
自定义 cause 字段、重写 printStackTrace 和 getCause,这是 Java 1.4 之前的异常链写法------现代 Throwable 已内置 cause 机制,构造时 super(msg, cause) 即可,本类完全可以简化。这套写法沿袭自 Apache Commons FileUpload 的同名异常。
继承 Exception 的意图在 FileUploadUtils.upload() 的方法签名上:
java
public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension, boolean useCustomNaming)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException, InvalidExtensionException {
编译器由此强制每个调用方显式处理这几种异常:要么 try-catch 做针对性处理(如超限时提示用户换个文件),要么继续向上声明。
文件超限这类错误调用方有能力恢复,值得用受检异常强制;业务异常最终由全局异常处理器统一兜底,逐层声明 throws 没有意义,所以都走 RuntimeException。
InvalidExtensionException 继承 FileUploadException,表示文件扩展名校验不通过。它的构造方法直接拼中文消息,不走 i18n:
java
public InvalidExtensionException(String[] allowedExtension, String extension, String filename) {
super("文件[" + filename + "]后缀[" + extension + "]不正确,请上传" + Arrays.toString(allowedExtension) + "格式");
this.allowedExtension = allowedExtension;
this.extension = extension;
this.filename = filename;
}
它内部还定义了 4 个静态内部类,对应图片、Flash、媒体、视频四类文件。FileUploadUtils.assertAllowed() 校验扩展名时,按传入的白名单数组选择抛哪个子类:
java
if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION) {
throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION) {
throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION) {
throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.VIDEO_EXTENSION) {
throw new InvalidExtensionException.InvalidVideoExtensionException(allowedExtension, extension, fileName);
} else {
throw new InvalidExtensionException(allowedExtension, extension, fileName);
}
分子类的意义是类型安全的捕获,调用方可以单独 catch (InvalidImageExtensionException e) 只处理图片校验失败。不过当前的上传接口统一 catch (Exception e) 兜底,这个能力实际没有被使用。
定时任务模块的 TaskException 同样继承 Exception,用内部 Code 枚举标识错误类型:
java
public class TaskException extends Exception {
private Code code;
public enum Code {
TASK_EXISTS, NO_TASK_EXISTS, TASK_ALREADY_STARTED, UNKNOWN, CONFIG_ERROR, TASK_NODE_NOT_AVAILABLE
}
}
ScheduleUtils 设置 misfire 策略遇到不支持的取值时抛出,错误类型与消息分离:
java
throw new TaskException("The task misfire policy '" + job.getMisfirePolicy()
+ "' cannot be used in cron schedule tasks", Code.CONFIG_ERROR);
调用方拿到 getCode() 可以按类型分支处理,而不必解析消息文本。
异常如何变成统一返回
自定义异常抛出后,由 framework 模块的 GlobalExceptionHandler(@RestControllerAdvice)统一捕获并转成 AjaxResult。与三类策略相关的处理分支如下:
ServiceException有专属 handler,code 非空时填进返回结果的 code 字段,为空则用默认 500DemoModeException有专属 handler,固定返回"演示模式,不允许操作"BaseException及其子类(file/user 一系)没有专属 handler,落进RuntimeException兜底 handlerAccessDeniedException返回 403 没有权限,BindException等参数校验异常取第一条错误消息
ServiceException 的专属 handler 保留了它的 code 字段:
java
@ExceptionHandler(ServiceException.class)
public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request) {
log.error(e.getMessage(), e);
Integer code = e.getCode();
return StringUtils.isNotNull(code) ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
}
BaseException 一系走兜底 handler,提示语并不缺:兜底返回 AjaxResult.error(e.getMessage()),而 getMessage() 在此刻执行 i18n 解析,前端拿到的仍是"密码输入错误5次,帐户锁定10分钟"这类完整文案。两类异常最终都汇入同一个统一返回结构,业务代码只管 throw,不必关心返回格式。
思考
两套消息策略对应两类维护者 :BaseException 一系的错误场景是框架内置的(登录、上传),场景固定、文案需要集中管理和多语言能力,code 与资源文件分离把文案的修改权从代码里拿出来;ServiceException 服务业务开发中的即席校验,文案随业务代码一起写、一起改,为每条提示维护 i18n 键反而是负担。两套并存各有服务对象,代价是要同时理解两套约定,且边界靠使用者自觉------UserDetailsServiceImpl 用 ServiceException 包装 i18n 消息就是两种策略的混用。
受检异常只用在调用方能针对性恢复的场景 :18 个异常类里只有 4 个受检异常,全部集中在文件上传和任务调度。共同点是调用方拿到异常后有明确的恢复动作:上传超限提示换文件,任务配置错误修正配置再试。其余场景调用方除了转成统一返回没有别的动作,受检异常的强制声明只会层层传递 throws,所以统一交给 RuntimeException 加全局处理器。
直接 message 驱动最终收敛为一个类 :GlobalException 与 ServiceException 字段几乎一致却从未被抛出,DemoModeException 预留未用。这条策略实际收敛为 ServiceException 一个入口,final 修饰连派生也一并关闭。异常体系的复杂度不在类的数量,而在消息策略和受检与否这两处真正影响调用方式的分叉上;"全局""演示模式"这类语义细分,一个类加上不同的抛出位置就能表达。