本节目标:能写出可维护的 Java 正则,分清
matches和find,用命名分组提取字段,知道什么时候该用正则、什么时候该写解析代码,并能识别灾难性回溯的风险。
适用版本:JDK 25(对照 JDK 21);java.util.regex的 API 在这两个版本间没有影响本节的变化。
上一节留下的问题
library 的图书数据来自多个渠道:出版社给的 Excel、书店网站复制来的信息、管理员手工录入。ISBN 的写法五花八门:
978-0-13-468599-1
9780134685991
0-13-468599-7
ISBN 9780134685991
如果 Book 的 isbn 字段直接存用户输入,同一本书会出现多条记录:带连字符的和不带连字符的被认为是两个 ISBN。更糟的是,有些输入看起来像 ISBN,但校验位是错的,照样被存进去。
这一节用两个场景讲正则:ISBN 的格式与校验,以及检索语法的解析。前者说明正则只适合「形状」,不适合「校验」;后者说明分组能把一段文本拆成结构化字段。
三层 API:String、Pattern、Matcher
Java 的正则有三层入口:
// 1. String 的便捷方法,每次调用都重新编译
boolean ok = input.matches("\\d{13}");
String replaced = input.replaceAll("\\d", "#");
String[] parts = input.split(",");
// 2. 预编译,适合重复使用
Pattern pattern = Pattern.compile("\\d{13}");
Matcher matcher = pattern.matcher(input);
boolean matched = matcher.matches();
// 3. 在输入中查找多处匹配
while (matcher.find()) {
System.out.println(matcher.group() + " @" + matcher.start());
}
选择规则:
- 只匹配一次、输入很短:
String.matches够用。 - 同一个正则在循环或高频路径里用:
Pattern.compile一次,存成static final。 - 要查找多处、提取分组、替换:用
Matcher。
matches() 要求整个输入都匹配,find() 只要求找到一段子串。这是最常见的混淆点。Pattern.compile("\\d+").matcher("abc123").matches() 是 false,find() 才是 true。如果只想判断「包含数字」,用 find();如果要求「必须是数字」,用 matches()。
ISBN 的格式与校验位
ISBN-10 有 10 位,最后一位可以是 X(代表 10);ISBN-13 以 978 或 979 开头,共 13 位。用户可能输入连字符或空格,所以第一步是规范化:
public static String normalize(String raw) {
if (raw == null) {
throw new LibraryException(ErrorCode.INVALID_COMMAND, "ISBN 不能为空");
}
String compact = raw.replace("-", "").replace(" ", "").toUpperCase();
if (!ISBN_10.matcher(compact).matches() && !ISBN_13.matcher(compact).matches()) {
throw new LibraryException(ErrorCode.INVALID_COMMAND, "ISBN 格式不正确: " + raw);
}
if (!hasValidCheckDigit(compact)) {
throw new LibraryException(ErrorCode.INVALID_COMMAND, "ISBN 校验位不正确: " + raw);
}
return compact;
}
两个预编译模式:
private static final Pattern ISBN_10 = Pattern.compile("^(?:\\d{9}[\\dX])$");
private static final Pattern ISBN_13 = Pattern.compile("^(?:97[89]\\d{10})$");
^ 和 $ 是锚点,配合 matches() 其实是双保险:matches() 本身已经要求整串匹配,但显式写锚点能让意图更清楚,也能防止将来改成 find() 时行为悄悄变化。(?:...) 是非捕获分组,只用于分组,不占 group(1)。
校验位不能靠正则算。ISBN-10 的规则是前 9 位分别乘 10 到 2,加上最后一位(X 算 10),总和能被 11 整除;ISBN-13 是前 12 位交替乘 1 和 3,校验位让总和能被 10 整除:
static boolean hasValidCheckDigit(String isbn) {
if (isbn.length() == 10) {
int sum = 0;
for (int i = 0; i < 10; i++) {
char c = isbn.charAt(i);
int digit = (c == 'X') ? 10 : (c - '0');
sum += digit * (10 - i);
}
return sum % 11 == 0;
}
int sum = 0;
for (int i = 0; i < 12; i++) {
int digit = isbn.charAt(i) - '0';
sum += (i % 2 == 0) ? digit : digit * 3;
}
int check = (10 - sum % 10) % 10;
return check == isbn.charAt(12) - '0';
}
本机跑一组输入:
978-0-13-468599-1 -> true
9780134685991 -> true
0-13-468599-7 -> true
0134685997 -> true
978-0-13-468599-2 -> false
978013468599X -> false
ISBN 9780134685991 -> false
最后一行说明正则只校验形状,不负责清洗前缀。ISBN 978... 里的 ISBN 不是数字,规范化时没有去掉,所以格式检查失败。如果业务允许这种前缀,应该在 normalize 里先剥掉,而不是把正则写得更宽松——宽松的正则会让真正的错误也混进来。
前四行通过,其中两行带连字符、两行不带;第五行格式对但校验位错;第六行把 ISBN-13 的最后一位写成 X,形状就不对。格式、规范化、校验位是三件事,分开做,错误信息才能说清楚是哪一步失败。
分组与命名分组
ISBN-13 带连字符时,可以按出版前缀拆开:
Pattern isbn13 = Pattern.compile(
"^(?<prefix>97[89])-(?<group>\\d)-(?<registrant>\\d{2,5})-(?<publication>\\d{2,6})-(?<check>\\d)$");
Matcher matcher = isbn13.matcher("978-0-13-468599-1");
if (matcher.matches()) {
System.out.println("prefix = " + matcher.group("prefix"));
System.out.println("group = " + matcher.group("group"));
System.out.println("check = " + matcher.group("check"));
}
本机输出:
prefix = 978
group = 0
check = 1
命名分组 (?<name>...) 比数字分组可读,尤其是在正则很长、插入新分组时。group("name") 取到的是匹配到的子串,不是它所在的位置。要位置用 start("name") / end("name")。
注意 matches() 必须在 group() 之前调用。直接 group() 会抛 IllegalStateException: No match found,因为匹配还没执行。
在输入中查找与替换
find() 适合从一段文本里抽出所有匹配:
Pattern date = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
Matcher dates = date.matcher("借出 2026-10-14,应还 2026-10-28,逾期 2026-11-13");
while (dates.find()) {
System.out.println("date = " + dates.group() + " @" + dates.start());
}
System.out.println("replace= " + dates.replaceAll("[date]"));
本机输出:
date = 2026-10-14 @3
date = 2026-10-28 @17
date = 2026-11-13 @31
replace= 借出 [date],应还 [date],逾期 [date]
start() 返回匹配在输入中的起始索引,单位是 char(UTF-16 代码单元),不是字节。中文在 Java 字符串里通常占一个 char,所以索引和肉眼看到的字符位置一致;emoji 会占两个,索引会跳。
replaceAll 的替换串里,$1 表示第一个分组,${name} 表示命名分组。如果替换内容本身包含 $ 或 \,会被当成特殊字符:
System.out.println("quote = " + Matcher.quoteReplacement("$1"));
quote = \$1
Matcher.quoteReplacement 把 $ 和 \ 转义,让它们按字面量输出。用户输入要替换进模板时,必须先 quote,否则一个 $ 就可能抛 IllegalArgumentException 或产生意外结果。
在 library 里落地
ISBN 进入领域对象
Book 的紧凑构造器里调用 Isbn.normalize,让进入领域层的 ISBN 已经是「无连字符、大写、校验位正确」的规范形式:
public record Book(String isbn, String title, String author, int year) {
public Book {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(author, "author");
if (title.isBlank()) {
throw new IllegalArgumentException("title 不能为空");
}
if (year < 0 || year > 3000) {
throw new IllegalArgumentException("year 不合理: " + year);
}
isbn = Isbn.normalize(isbn);
}
}
这样 new Book("978-0-13-468599-1", ...) 和 new Book("9780134685991", ...) 得到相同的 isbn,仓储的 Map<String, Book> 不会出现两条重复记录。规范化只做一次,后续所有比较都是字符串相等,不需要每次再处理连字符。
检索语法解析
library 的 search 命令支持 title:java author:bloch year:2018 这样的条件。用命名分组把每个 字段:值 拆开:
public record SearchQuery(String title, String author, Integer year) {
private static final Pattern TERM =
Pattern.compile("(?<field>\\w+):(?<value>\"[^\"]*\"|\\S+)");
public static SearchQuery parse(String input) {
String title = null;
String author = null;
Integer year = null;
Matcher matcher = TERM.matcher(input);
while (matcher.find()) {
String field = matcher.group("field");
String value = matcher.group("value").replace("\"", "");
switch (field) {
case "title" -> title = value;
case "author" -> author = value;
case "year" -> year = Integer.valueOf(value);
default -> throw new LibraryException(
ErrorCode.INVALID_COMMAND, "未知检索字段: " + field);
}
}
return new SearchQuery(title, author, year);
}
}
本机解析 title:"Effective Java" author:Bloch year:2018:
query = {title=Effective Java, author=Bloch, year=2018}
"[^"]*"|\S+ 这个分支的意思是:值要么是被双引号包起来的一段(允许里面有空格),要么是不含空白的连续字符。这样 title:"Effective Java" 能整体取到 Effective Java,而 author:Bloch 不需要引号。
这个解析器故意很简单:
- 不支持转义引号、括号、
AND/OR。 - 未知字段直接报错,而不是忽略。
year用Integer.valueOf,非数字会抛NumberFormatException,需要在上层翻译成用户可读错误。
如果检索语法继续变复杂,应该写一个真正的词法分析器,而不是把正则越堆越长。正则适合「形状固定的片段」,不适合「有嵌套结构的语言」。
常见错误与边界
matches() 和 find() 混用。 matches() 要求整串匹配;想在一段文本里找子串用 find()。String.matches 每次调用都重新编译正则,循环里用 Pattern 预编译。
Java 字符串里的反斜杠。 正则 \d 在 Java 源码里要写成 "\\d";文本块里也仍然要写 \\d,因为文本块只处理换行和引号,不处理反斜杠转义。写复杂正则时,先在 JShell 里验证,再放进代码。
split 丢弃末尾空串。 "a,b,,".split(",") 得到 ["a","b"],中间的 ,, 保留一个空串,末尾两个被丢掉。要保留全部,用 split(",", -1)。
替换串里的 $ 和 \。 replaceAll("(\\d+)", "$1元") 里 $1 是分组引用;如果用户输入被直接拼进替换串,先 Matcher.quoteReplacement。
灾难性回溯。 (a+)+$ 这类嵌套量词在长的不匹配输入上会指数级回溯。本机不演示,因为可能卡住几分钟。避免写法:把嵌套量词改成互斥的字符类,比如 a+$;限制输入长度;对用户输入的正则做白名单,不要让用户直接传 Pattern。
\w 默认只匹配 ASCII。 中文、带重音的字母不在 \w 里。需要 Unicode 字母时用 \p{L},或者加 Pattern.UNICODE_CHARACTER_CLASS。
把正则当解析器。 嵌套括号、引号转义、运算符优先级,这些用正则表达会迅速失控。超过两三层结构,换成手写解析或成熟库。
小结
matches()要求整串匹配,find()查找子串;高频路径预编译Pattern。- ISBN 的格式用正则,校验位用算术;规范化、格式检查、校验分开做,错误信息才准确。
- 命名分组
(?<name>...)让提取字段可读,group()前必须先matches()或find()。 replaceAll的$和\有特殊含义,用户输入先用Matcher.quoteReplacement。split默认丢末尾空串,需要时传-1。- 正则适合固定形状的片段,不适合嵌套结构;嵌套量词有灾难性回溯风险。
library在Book构造器里规范化 ISBN,检索语法用命名分组拆字段,未知字段直接报错。
到这里,library 的规则、IO、时间和文本都齐了。但规则越多,手工验证越不可靠:借期、续借、罚金、ISBN、检索,每一处都可能改坏。下一章引入 JUnit 5,把这些规则变成可重复执行的测试。
阅读导航:上一节:15.2 String、StringBuilder 与文本块 · 下一节:16.1 JUnit 5:断言、生命周期与参数化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。