☰
唯品会Java开发手册之异常处理:从 8 大规约到 vjtools 的源码级实践
2026/9/28 3:18:33 网站建设 项目流程
  • 开发工具
  • 可观测性
  • 后端

【免费下载链接】vjtools

The vip.com's java coding standard, libraries and tools

项目地址:https://gitcode.com/gh_mirrors/vj/vjtools
点击查看免费下载

导读

本文以《唯品会Java开发手册》(vjtools 仓库 docs/standard 章节)中的“异常处理”一章为骨架,系统讲解 Java 异常创建成本、静态异常复用、异常抛出/捕获/处理及 finally 块等 8 大规约,并逐一对应到仓库中 vjkit 工具库(ExceptionUtil、CloneableException、IOUtil 等)与 sonar-vj 定制规则的源码实现。读完本文,你将掌握一套可直接落地的异常编码规范,并理解其底层原理与配套的静态检查落地方式。

一、异常处理规范总览

本章(docs/standard/chapter10.md)是《唯品会Java开发手册》(docs/standard/README.md)的第十章,共 8 条规则,其中【强制】4 条、【推荐】4 条,覆盖从异常“产生”到“消亡”的完整生命周期:

规则关注点级别
Rule 1创建异常的消耗大,只用在真正异常的场景强制
Rule 2特定场景避免每次构造异常(静态异常复用)推荐
Rule 3自定义异常建议继承 RuntimeException推荐
Rule 4异常日志应包含排查问题的足够信息推荐
Rule 5异常抛出的原则(标准异常优先、按需定义)推荐
Rule 6异常捕获的原则(按需捕获、多异常合并)推荐
Rule 7异常处理的原则(不可吞异常、不捕获则处理)强制
Rule 8finally 块的处理原则(资源关闭、禁止 return)强制

值得注意的是,本章在阿里手册基础上做了“增补与删减”(完整对照见 docs/standard/ali.md),其核心思路是:异常是昂贵的控制流,能规避就规避,能复用就复用,捕获后必须负责到底。

二、Rule 1(强制):创建异常的消耗大,只用在真正异常的场景

构造异常对象时,JVM 需要获得整个调用栈(fillInStackTrace),这是一笔不小的开销。因此异常不应被用来做流程控制或条件控制——条件判断的效率远高于异常处理。

典型反例:用捕获NullPointerException来做空值判断:

//WRONG try { return obj.method(); } catch (NullPointerException e) { return false; }

正确做法:对发生概率较高的条件,先做检查规避:

//RIGHT if (obj == null) { return false; }

如果代码里频繁捕获IndexOutOfBoundsException、NullPointerException这类“本可预防”的异常,通常意味着存在坏味道。这条规则对应 Sonar 规则 RSPEC-1696: "NullPointerException" should not be caught。

三、Rule 2(推荐):在特定场景,避免每次构造异常

承接 Rule 1,如果异常频繁发生且不需要打印完整调用栈,可以考虑绕过异常构造函数。文档给出三种手段:

1)message 不变:将异常定义为静态成员变量

private static RuntimeException TIMEOUT_EXCEPTION = ExceptionUtil.setStackTrace(new RuntimeException("Timeout"), MyClass.class, "mymethod"); ... throw TIMEOUT_EXCEPTION;

这里的ExceptionUtil即 vjkit 工具库中的 ExceptionUtil.java。其setStackTrace(Throwable, Class, String)方法参考 Netty 的做法,为静态异常设置仅一层的 StackTrace(抛出点类名 + 方法名),替代完整调用栈:

public static <T extends Throwable> T setStackTrace(@NotNull T throwable, Class<?> throwClass, String throwClazz) { throwable.setStackTrace( new StackTraceElement[] { new StackTraceElement(throwClass.getName(), throwClazz, null, -1) }); return throwable; }

该方法返回异常对象本身,便于静态初始化时链式赋值。对应的测试见 ExceptionUtilTest.java 的staticException()用例:它断言静态异常的 StackTrace 文本只有 2 行,且指向ExceptionUtilTest.hello这个自定义抛出点,证明“单层 StackTrace”效果确实生效。

补充说明:若异常可能在多个地方抛出,应使用setStackTrace显式指定抛出类与方法;而 ExceptionUtil.clearStackTrace() 则用于“无法控制生成端,但能控制打印端”的场景——它沿 Cause 链逐层清空 StackTrace(注意 Cause 链本身无法清除)。

2)message 会变化:对静态异常实例 clone() 后再修改 message

private static CloneableException TIMEOUT_EXCEPTION = new CloneableException("Timeout") .setStackTrace(My.class, "hello"); ... throw TIMEOUT_EXCEPTION.clone("Timeout for 40ms");

Java 默认异常并不实现 Cloneable,vjkit 为此提供了 CloneableException.java(继承了Exception):

  • clone():基于super.clone()复制实例,不经过构造函数,也就避免了重新获得 StackTrace;
  • clone(String message):克隆后直接设置新 message;
  • setStackTrace(Class, String):内部委托给ExceptionUtil.setStackTrace,用于静态初始化。
@Override public CloneableException clone() { // NOSONAR try { return (CloneableException) super.clone(); } catch (CloneNotSupportedException e) {// NOSONAR return null; } }

测试用例同样验证了该行为:TIMEOUT_EXCEPTION2.clone("Timeout for 30ms")后,打印的 StackTrace 仍只有 2 行(消息变为新值,抛出点保持ExceptionUtilTest.hello),即克隆没有重新生成完整调用栈。

如果希望异常直接继承RuntimeException(契合 Rule 3),仓库还提供了对应的 CloneableRuntimeException.java,API 与 CloneableException 完全一致,可按需选用。

3)重载 fillInStackTrace() 为空函数

自定义异常也可以重载fillInStackTrace()为空函数来跳过调用栈生成,但相对不够灵活——无法像方案 1/2 那样按场景指定一层 StackTrace。

四、Rule 3(推荐):自定义异常,建议继承 RuntimeException

详见《Clean Code》,该争论已经结束,不再推荐初衷很好的 CheckedException。原因在于:

  • CheckedException 需要在“抛出异常的地方”与“捕获处理异常的地方”之间层层定义throws XXX来传递,底层代码一旦改动,将影响所有上层函数的签名,导致编译出错,对封装的破坏严重;
  • 对 CheckedException 的处理也给上层程序员带来额外负担;
  • 其他主流语言都没有 CheckedException 的设计。

这与 vjkit 的设计一脉相承:CloneableRuntimeException、UncheckedException(见 UncheckedException.java)均继承RuntimeException。其中UncheckedException是 CheckedException 的包装器,ExceptionUtil.unchecked(t)会把 CheckedException 包装后重新抛出,减少函数签名中的 CheckedException 定义,其测试用例(ExceptionUtilTest.java 的unchecked())确认:RuntimeException 与 Error 原样抛出,仅普通 Exception 被包装。

五、Rule 4(推荐):异常日志应包含排查问题的足够信息

异常信息应包含排查问题时足够的上下文;捕获并记录异常日志的地方,还需要记录“未包含在异常信息中、但排查问题需要的信息”,比如捕获处的上下文。

//WRONG new TimeoutException("timeout"); logger.error(e.getMessage(), e); //RIGHT new TimeoutException("timeout:" + eclapsedTime + ", configuration:" + configTime); logger.error("user[" + userId + "] expired:" + e.getMessage(), e);

这条规则对应 Facebook-Contrib 的 Style 规则:Method throws exception with static message string。要点:异常 message 携带业务度量(耗时、配置值),日志再补充调用上下文(用户 ID),二者配合才能快速定位问题。

六、Rule 5(推荐):异常抛出的原则

5.1 尽量使用 JDK 标准异常与项目标准异常

优先使用 JDK 标准的 RuntimeException,如IllegalArgumentException、IllegalStateException、UnsupportedOperationException;业务上使用项目定义的ServiceException。标准异常语义清晰,调用方无需额外学习成本。

5.2 根据调用者的需要来定义异常类

是否定义独立的异常类,关键看调用者会如何处理这个异常。如果没有特殊处理需求,直接抛出RuntimeException也是允许的。这避免了为异常而异常、无谓地扩充异常类层级。

七、Rule 6(推荐):异常捕获的原则

6.1 按需要捕获异常,捕获 Exception 或 Throwable 是允许的

如果无特殊处理逻辑,统一捕获Exception统一处理是允许的。捕获Throwable则用于捕获Error类异常,包括其实无法处理的OOM、StackOverflow、ThreadDeath,以及类加载/反射时可能抛出的NoSuchMethodError、NoClassDefFoundError等。

6.2 多个异常处理逻辑一致时,使用 JDK7 的多 catch 语法

try { ... } catch (AException | BException | CException ex) { handleException(ex); }

对应 Sonar 规则 RSPEC-2147: Catches should be combined,避免重复代码。

八、Rule 7(强制):异常处理的原则

7.1 捕获异常一定要处理;故意忽略须注释写明原因

空 catch 块是典型的坏味道。若确实需要忽略(比如循环中的单项失败),必须用注释说明原因,方便阅读者确认“此处不是漏了处理”:

//WRONG try { } catch(Exception e) { } //RIGHT try { } catch(Exception ignoredExcetpion) { //continue the loop }

vjtools 在 Sonar 落地时进一步放宽了这一条:定制规则 CatchUsesExceptionWithContextCheck.java 在实现 Sonar S1166 时,忽略异常变量名含 "ignore" 字样的检查(catch(Exception ignore)视为有意忽略,不再报警),见 sonar-vj/README.md 规则表第 1166 行。

7.2 不能吞掉原异常:要么打日志,要么在重新抛出的异常里包含原异常

//WRONG throw new MyException("message"); //RIGHT 记录日志后抛出新异常,向上次调用者屏蔽底层异常 logger.error("message", ex); throw new MyException("message"); //RIGHT 传递底层异常 throw new MyException("message", ex);

对应 Sonar 规则 RSPEC-1166: Exception handlers should preserve the original exceptions。该规则默认包含若干例外:InterruptedException、NumberFormatException、NoSuchMethodException等。在 CatchUsesExceptionWithContextCheck.java 中,这些例外类型被显式配置为exceptions属性默认值,同时额外加入了ParseException、MalformedURLException、DateTimeParseException等解析类异常——这些场景捕获后不处理通常可接受。

7.3 最外层业务使用者必须处理异常

如果不想处理异常,可以不捕获(让异常向上传播);但最外层的业务使用者必须处理异常,将其转化为用户可以理解的内容,而不是把堆栈直接抛给用户。

九、Rule 8(强制):finally 块的处理原则

8.1 必须关闭资源对象/流对象,或使用 try-with-resource

关闭动作必须放在 finally 块,不能放在 try 块或 catch 块(这是经典错误)。更推荐直接使用 JDK7 的 try-with-resource 语法自动关闭 Closeable 资源:

try (Writer writer = ...) { writer.append(content); }

8.2 处理过程中如有抛出异常的可能,也要 try-catch,防止 finally 中的异常顶替原异常

//WRONG try { ... throw new TimeoutException(); } finally { file.close();//如果file.close()抛出IOException, 将代替TimeoutException } //RIGHT, 在finally块中try-catch try { ... throw new TimeoutException(); } finally { IOUtil.closeQuietly(file); //该方法中对所有异常进行了捕获 }

规则背后的原理:finally 块中抛出的异常会顶替try 块中尚未抛出的异常。对应 Sonar 规则 RSPEC-1163: Exceptions should not be thrown in finally blocks。

文档示例中的IOUtil.closeQuietly(file)正是 vjkit 的 IOUtil.java 中提供的“安静关闭”方法:内部捕获 IOException 并仅打 warn 日志,保证不干扰原有异常流:

public static void closeQuietly(Closeable closeable) { if (closeable == null) { return; } try { closeable.close(); } catch (IOException e) { logger.warn("IOException thrown while closing Closeable.", e); } }

它还兼容closeable == null的情况(资源可能未实际创建),可以放心地在 finally 中使用。

8.3 禁止在 finally 块中使用 return

finally 块中的 return 将代替try 块中的 return 及 throw Exception:

//WRONG try { ... return 1; } finally { return 2; //实际return 2 而不是1 } try { ... throw TimeoutException(); } finally { return 2; //实际return 2 而不是TimeoutException }

对应 Sonar 规则 RSPEC-1143: Jump statements should not occur in "finally" blocks。

十、规范落地:Sonar 定制规则如何支撑本章

《唯品会Java开发手册》的落地主要依赖代码格式模板与 Sonar 代码规则检查(见 docs/standard/README.md 的“规范落地”一节)。由于官方 Sonar 规则存在误报,vjtools 在 standard/sonar-vj 中定制了与本章直接相关的若干规则:

编号规则描述与本章的对应修改
S1166Exception handlers should preserve the original exceptions对应 Rule 7.2;忽略异常变量名含 ignore 字样的检查
S1163Exceptions should not be thrown in finally blocks对应 Rule 8.2
S1143Jump statements should not occur in "finally" blocks对应 Rule 8.3
S2147Catches should be combined对应 Rule 6.2
S1696"NullPointerException" should not be caught对应 Rule 1

这些规则以源码形式存放在 standard/sonar-vj/src/main/java/com/vip/vjkit/sonarvj/checks/ 目录下,编译后放入 Sonar 的 lib 目录并重启,即可用“带 VJ 字样”的规则替代官方同编号规则(具体步骤见 sonar-vj/README.md)。此外,CatchUsesExceptionWithContextCheck.java 还额外处理了Enum.valueOf()捕获 IllegalArgumentException 的合法场景,可见落地时对真实业务场景的细致考量。

十一、总结:异常处理的八条心法

  1. 异常创建昂贵,只用于真正异常的场景,可预防的条件先检查规避(Rule 1);
  2. 高频异常可静态复用:message 不变用ExceptionUtil.setStackTrace,message 变化用CloneableException.clone()(Rule 2);
  3. 自定义异常继承 RuntimeException,避免 CheckedException 污染签名(Rule 3);
  4. 异常信息与日志都要包含足够的排查上下文(Rule 4);
  5. 抛出:优先 JDK 标准异常,按调用者需要定义异常类(Rule 5);
  6. 捕获:按需捕获,逻辑一致的多异常用多 catch 合并(Rule 6);
  7. 处理:捕获必处理,忽略需注释,永不吞异常,最外层必须面向用户转化(Rule 7);
  8. finally:资源必关(优先 try-with-resource)、不抛异常、不 return(Rule 8)。

以上八条,配合 vjtools 仓库中 ExceptionUtil.java、CloneableException.java、IOUtil.java 等工具类以及 sonar-vj 的定制规则,即可在日常开发中实现从“编写规范”到“自动检查”的完整闭环。

  • 开发工具
  • 可观测性
  • 后端

【免费下载链接】vjtools

The vip.com's java coding standard, libraries and tools

项目地址:https://gitcode.com/gh_mirrors/vj/vjtools
点击查看免费下载

相关推荐

上一篇:微信读书笔记助手:你的数字阅读效率提升神器
下一篇:更快更私密:Thorium浏览器快速上手,3步开始使用

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询