本节目标:让
libraryd的构建在出现rawtypes、unchecked、serial这类告警时直接失败,而不是把警告留在日志里等下一个人忽略。
适用版本:JDK 25.0.5、Maven 3.9.12、maven-compiler-plugin 3.16.0。
承接的问题
第 1 章把模块和依赖整理好之后,构建已经能稳定产出 jar。但「能构建」不等于「构建时说的话被听见」。javac 默认只显示少量警告,很多问题必须显式打开 -Xlint 才会出现;即使出现了,只要退出码是 0,CI 就继续往下走。
卷一里有两类代码最容易留下警告:
- 原始类型与未检查转换。为了把
List塞进Map<String, List>,写一个(List<String>) value的强转,编译器只发一条unchecked,运行时才可能抛ClassCastException。 - 可序列化类没有
serialVersionUID。异常类继承RuntimeException就会触发serial,提醒你序列化兼容性没有显式声明。
这些警告的共同点是「不影响这次编译,但影响下一次改动」。门禁的作用不是让代码变漂亮,而是让这类隐患在合并前必须被处理:修掉,或者用一条有理由的抑制声明标记为已知。
javac 的告警分类:先看 -Xlint 支持什么
javac --help-lint 会列出所有关键字。本机 JDK 25 的输出里,和业务代码最相关的几组是:
all 启用所有警告类别
deprecation 有关使用了已过时项的警告。
fallthrough 有关从 switch 语句的一个 case 向下顺序执行到下一个 case 的警告。
finally 有关 finally 子句未正常终止的警告。
overloads 有关与方法重载相关的问题的警告。
overrides 有关与方法覆盖相关的问题的警告。
processing 有关与批注处理相关的问题的警告。
rawtypes 有关使用了原始类型的警告。
removal 有关使用了标记为待删除的 API 的警告。
serial 有关没有 serialVersionUID 字段的可序列化类的警告。
this-escape 构造器调用的方法可能在外部子类中被覆盖时发出警告。
unchecked 有关未检查操作的警告。
本机输出节选,中文是 JDK 的本地化消息。这些类别可以单独开关,例如 -Xlint:rawtypes,unchecked 只看两类,-Xlint:all,-processing 打开全部但关闭注解处理相关告警。
processing 被排除,是因为它报告的是注解处理器在编译期产生的问题。libraryd 在第 2 章还没有注解处理器;Hibernate Validator 是运行期校验,不是编译期处理器。等到第 8 章接入校验时,如果某个依赖带入处理器并产生噪音,再单独评估,而不是现在就把所有处理告警放进来。
this-escape 值得单独说。它在构造器调用可被覆写的方法时触发,因为子类构造器还没执行完,方法可能看到未初始化的字段。这个告警在 JDK 21 之后才常见,老代码升级 JDK 25 时经常第一次见到。处理方式通常是避免在构造器里调用可覆写方法,或者把类声明为 final。
从警告到错误:-Werror 的行为
先看一个最小例子。下面的类故意使用原始类型和未检查转换:
import java.util.ArrayList;
import java.util.List;
public final class Demo {
private Demo() {
}
public static void main(String[] args) {
List raw = new ArrayList();
raw.add("libraryd");
List<String> names = (List<String>) raw;
System.out.println(names.get(0));
}
}
用 -Xlint:all 编译,但不加 -Werror:
$ javac -Xlint:all Demo.java
Demo.java:9: 警告: [rawtypes] 找到原始类型: List
List raw = new ArrayList();
^
缺少泛型类List<E>的类型参数
Demo.java:9: 警告: [rawtypes] 找到原始类型: ArrayList
List raw = new ArrayList();
^
Demo.java:10: 警告: [unchecked] 对作为原始类型List的成员的add(E)的调用未经过检查
raw.add("libraryd");
^
Demo.java:11: 警告: [unchecked] 未经检查的转换
List<String> names = (List<String>) raw;
^
4 个警告
本机输出节选,省略了重复的上下文行。退出码是 0,Demo.class 正常生成。CI 看到 0 就认为这一步通过。
加上 -Werror:
$ javac -Xlint:all -Werror Demo.java
警告部分与上面相同,末尾多出:
错误: 发现警告, 但指定了 -Werror
1 个错误
4 个警告
本机输出,退出码 1。-Werror 不改变那四条警告,只把「有警告」翻译成「构建失败」。这正是门禁需要的行为:日志可以很长,但退出码必须诚实。
一个容易被忽略的细节:本机 locale 是 zh_CN,所以消息是中文。英文环境会显示 warning: [rawtypes] found raw type: List,关键字 [rawtypes]、[unchecked] 不变。排查时按方括号里的类别搜索即可,不要按整句翻译搜索。
在 Maven 里配置门禁
javac 的参数通过 maven-compiler-plugin 的 <compilerArgs> 传入。父 POM 里的配置如下:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
<configuration>
<release>${maven.compiler.release}</release>
<parameters>true</parameters>
<showWarnings>true</showWarnings>
<compilerArgs>
<arg>-Xlint:all,-processing</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</plugin>
三个参数各管一件事。showWarnings 让 Maven 把 javac 的警告转印到构建日志;-Xlint:all,-processing 决定打开哪些类别;-Werror 决定警告是否让构建失败。少任何一个,门禁都不完整:只开 -Xlint 不加 -Werror,警告仍然只是日志;只加 -Werror 不开 -Xlint,很多类别根本不会出现。
配置放在 pluginManagement 里,所有子模块的 compile 和 testCompile 都继承。测试代码同样受管,这一点是有意的:测试里的原始类型和未检查转换同样会在运行时埋雷。
本机执行编译:
$ mvn -B -DskipTests clean compile
[INFO] Compiling 6 source files with javac [debug parameters release 25] to target/classes
[INFO] Compiling 1 source file with javac [debug parameters release 25] to target/classes
[INFO] Compiling 1 source file with javac [debug parameters release 25] to target/classes
[INFO] Compiling 1 source file with javac [debug parameters release 25] to target/classes
[INFO] Compiling 1 source file with javac [debug parameters release 25] to target/classes
[INFO] BUILD SUCCESS
本机输出。五行编译对应五个子模块:domain 有 6 个源文件,其余模块各 1 个。没有警告,所以 -Werror 没有触发。这个「安静的成功」就是门禁生效后的常态:要么零警告,要么构建失败,不存在「有一些警告但算了」。
在 libraryd 里落地:让告警可修
门禁真正的难点不是打开开关,而是打开之后代码要能过。libraryd 的领域代码从一开始就按「零警告」写,靠的是三个习惯。
第一,泛型参数写全,不用原始类型。仓储端口返回 Optional<Book>、List<Loan>,不返回 Optional 或 List:
package com.plume.library.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface BookRepository {
Optional<Book> findByIsbn(String isbn);
List<Book> findAll();
List<Loan> findActiveLoansByMember(String memberId);
void save(Book book);
void saveLoan(Loan loan);
default long countActiveLoans(String memberId, LocalDate today) {
return findActiveLoansByMember(memberId).stream()
.filter(loan -> loan.active() && !loan.dueOn().isBefore(today))
.count();
}
}
这个接口只依赖 JDK 类型,编译时不会产生 rawtypes 或 unchecked。default 方法里的 stream() 从 List<Loan> 推导出 Stream<Loan>,filter 的参数类型也是明确的,不需要强转。
第二,可序列化的异常显式写 serialVersionUID。LibraryException 继承 RuntimeException,如果不写,-Xlint:all 会报 serial:
Boom.java:1: 警告: [serial] 可序列化类Boom没有 serialVersionUID 的定义
public class Boom extends RuntimeException {
^
1 个警告
本机用一个最小类复现的输出。修复方式是加一行:
public final class LibraryException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final ErrorCode errorCode;
public LibraryException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ErrorCode errorCode() {
return errorCode;
}
}
serialVersionUID 的值不重要,重要的是它显式存在。序列化兼容性由这个值决定;不写时,编译器会按类结构生成一个,类一改就变,反序列化旧数据会失败。
第三,确实需要抑制时,把范围缩到最小,并写原因。比如从 Map<String, Object> 里取一个已知类型的列表,强转无法避免:
@SuppressWarnings("unchecked")
static List<Book> booksFrom(Object value) {
return (List<Book>) value;
}
注解放在方法上,而不是类上;括号里写具体类别 unchecked,不写 all。这样同一类的其他警告仍然会出现。方法上方还应该有一行注释说明「value 由本类的 writer 写入,元素类型固定为 Book」,否则半年后没人敢删这段抑制。
LibraryException 声明为 final 还有一个副作用:SpotBugs 不会对它报 CT_CONSTRUCTOR_THROW。这个告警在第 2.2 节会展开,这里先记住「构造器里抛异常 + 类可被继承」是一个静态分析会盯上的组合。
常见错误与边界
只加 -Xlint 不加 -Werror。 这是最常见的「假门禁」:日志里每天都有几十条警告,没有人处理,CI 仍然是绿的。判断方法是在 CI 日志里搜索 warning:,如果每次都有,说明门禁没有生效。
用 @SuppressWarnings("all") 图省事。 它会把该类里真正的新警告一起压掉。应该写具体类别,例如 @SuppressWarnings("unchecked")、@SuppressWarnings("serial"),并且尽量放在方法或字段上。
把 -Werror 加到所有 profile。 本地开发时,IDE 可能生成临时代码或使用不同的编译参数;可以把 -Werror 放在 CI 专用 profile 里,本地只开 -Xlint。libraryd 选择默认就开,因为代码量还小,越早养成习惯成本越低;如果团队有大量历史代码,可以先从新增模块开始。
忽略 this-escape。 在构造器里调用 registerListener(this) 或调用可覆写方法,JDK 25 会警告。修复方式通常是「构造器只赋值,注册放到工厂方法里」,而不是简单抑制。
把测试代码排除在门禁外。 只配置 maven-compiler-plugin 的 compile 执行、不配置 testCompile,测试里的原始类型就不会被拦。父 POM 的 pluginManagement 配置默认同时作用于两个阶段,除非显式覆盖。
小结
javac默认只显示部分警告,-Xlint:all打开全部类别,-Xlint:all,-processing排除注解处理噪音。-Werror把「有警告」变成「构建失败」,它不改变诊断内容,只改变退出码。- Maven 里的三件套是
showWarnings、compilerArgs、-Werror,缺一个门禁就不完整。 libraryd用完整泛型、显式serialVersionUID、最小范围@SuppressWarnings保持零警告。- 测试代码同样受
testCompile约束,不要把测试排除在门禁之外。 - 本机
mvn -B -DskipTests clean compile在-Werror下构建成功,说明当前六个源文件没有告警。
编译期的告警管住了「编译器能看见的问题」,但还有一类问题编译器不会说:equals 写了却没写 hashCode、构造器里留下半初始化对象、资源没有关闭。下一节用 Checkstyle 和 SpotBugs 补上这一层。
阅读导航:上一节:1.3 可复现构建:插件锁定与可重复打包 · 下一节:2.2 静态分析:Checkstyle 与 SpotBugs 的规则取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。