异常体系设计:三类策略(i18n code、message、受检异常)

本文基于若依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 导出失败时抛出,避免工具方法在签名上强制声明 throws
  • DemoModeException:空实现的标记异常,全局异常处理器里有它的专属分支,返回"演示模式,不允许操作",当前版本没有代码抛出它,属于预留

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 字段,为空则用默认 500
  • DemoModeException 有专属 handler,固定返回"演示模式,不允许操作"
  • BaseException 及其子类(file/user 一系)没有专属 handler,落进 RuntimeException 兜底 handler
  • AccessDeniedException 返回 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 修饰连派生也一并关闭。异常体系的复杂度不在类的数量,而在消息策略和受检与否这两处真正影响调用方式的分叉上;"全局""演示模式"这类语义细分,一个类加上不同的抛出位置就能表达。

相关推荐
海宇服务1 小时前
零信任架构实战:基于海宇公安二要素认证即时版构建自动化跨境个人额度申报实名网关
运维·人工智能·架构·自动化
Spatial3Ddddd2 小时前
2026空间三维重建服务商横向对比
java·服务器·人工智能
言之。2 小时前
Agent 与沙箱:架构与关系解析
架构
淡海水2 小时前
03-03-架构篇-SGen GC架构解析
unity·架构·c#·gc·sgen
Rain的Java大神之路2 小时前
Java 三层架构,90% 的人只答对一半:Controller 不是瘦,Service 不是透传,Dao 不是只写 SQL
java·后端·面试
Thomas.Sir2 小时前
第29课:微服务异步调用、线程池优化 & 事件驱动开发
java·redis·微服务
.Hypocritical.2 小时前
【Java 进阶】泛型深度解析|泛型类、泛型方法、通配符、类型擦除、PECS 原则
java·开发语言·泛型
这个DBA有点耶2 小时前
MySQL B-tree索引并发控制优化:写密集场景的锁瓶颈与内核级解法
java·数据库·程序人生·算法·database·dba
java_nnnn2 小时前
Redis 初识:从单体架构到分布式架构的演进
redis·分布式·架构