《Java 编程入门》15.3 正则表达式与 Pattern/Matcher

从 ISBN 的连字符、空格和校验位讲起,区分 matches 与 find、分组与命名分组、replaceAll 的替换陷阱;再用 library 的 ISBN 校验和检索语法解析,配真实输出说明正则能做什么、不能做什么,以及灾难性回溯的边界。

本节目标:能写出可维护的 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:断言、生命周期与参数化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发