第十二章. common-core 工具类(九):RegexUtils 与 ReUtil 文本处理

第十二章. common-core 工具类(九):RegexUtilsReUtil 文本处理

摘要:本章我们将深入 RVP 的 文本处理 利器 RegexUtils 及其父类 Hutool ReUtil。我们将从 Java 原生 PatternMatcher 的繁琐出发,引出 ReUtil 的便捷封装。本章将包含 正则表达式核心语法 的速成讲解,并重点实战 ReUtil查找、提取、替换、分组 等方面的强大功能。

本章学习路径

RegexUtils 与 ReUtil 正则工具学习


12.1. RegexUtils 概览与 ReUtil 继承关系

我们首先定位 RVP 框架中的正则表达式工具类。

12.1.1. 文件定位:RegexUtils (RVP) 与 ReUtil (Hutool)

RVP 在 common-core 中提供了 RegexUtils

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java

打开源码,我们看到的第一行就揭示了它的“身份”:

1
2
3
4
5
import cn.hutool.core.util.ReUtil;
// ...
public final class RegexUtils extends ReUtil {
// ... RVP 增强的方法 ...
}

分析
RegexUtils 继承了 Hutool 的 cn.hutool.core.util.ReUtilReUtil 是 Hutool 提供的正则表达式工具类,它已经非常强大和全面。RVP 再次使用了“继承与增强”的设计模式。

这意味着,我们在 RVP 项目中调用 RegexUtils 时,实际上是在调用 ReUtil 中所有的方法,同时 RVP 额外增加了一些它自己独有的方法。

12.1.2. RegexUtils 的增强:extractFromString() 方法

RVP RegexUtils 增强的方法非常少,主要是 extractFromString()

1
2
3
4
5
6
7
8
9
10
11
// 位于 RVP 的 RegexUtils.java
public static String extractFromString(String input, String regex, String defaultInput) {
try {
// 核心:它调用的是 Hutool ReUtil.get()
// 并且【硬编码】只获取第 1 分组 (group 1)
String str = ReUtil.get(regex, input, 1);
return str == null ? defaultInput : str;
} catch (Exception e) {
return defaultInput;
}
}

分析
RVP 的 extractFromString 是一个“带有默认值”且“专用于获取分组 1”的 ReUtil.get() 封装。

既然 RVP 的工具类 99% 的功能都来自 Hutool ReUtil,那么本章的 重点 就是 学懂 ReUtil 是如何解决 Java 原生正则表达式痛点的


12.2. 【痛点引入】原生 PatternMatcher 的繁琐

在 Hutool ReUtil 出现之前,如果我们想用 Java 原生的 API 从字符串 "aeb" 中匹配出所有的小写字母(ab),需要编写如下代码:

12.2.1. 源码演示:Pattern.compile(), matcher.find(), matcher.group()

假设我们没有 ReUtil,我们想实现这个简单的需求,需要记住并使用两个核心类:java.util.regex.Patternjava.util.regex.Matcher

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/utils/test/RegexUtilsTest.java
(我们创建一个新的 main 方法测试类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package org.dromara.demo.utils.test;

import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
// 导入 Java 原生正则 API
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexUtilsTest {

private static final Log console = LogFactory.get();

public static void main(String[] args) {
testNativeRegex();
}

/**
* 痛点演示:Java 原生 API
*/
public static void testNativeRegex() {
console.info("--- 1. 测试原生 Java 正则 ---");

// 1. 定义一个正则表达式 (匹配单个小写字母)
String regex = "[a-z]";
String content = "aEb1c";

// 2. 【步骤一】编译正则表达式,得到 Pattern 对象
Pattern pattern = Pattern.compile(regex);

// 3. 【步骤二】使用 Pattern 对象创建 Matcher (匹配器)
Matcher matcher = pattern.matcher(content);

// 4. 【步骤三】使用 while 循环和 matcher.find() 查找
console.info("开始查找...");
while (matcher.find()) {
// 5. 【步骤四】使用 matcher.group() 获取匹配到的结果
String group = matcher.group(); // group(0) 代表获取完整匹配
console.info("找到匹配: {}", group);
}
console.info("查找结束.");
}
}

12.2.2. 实战 (rag1):演示原生 API 如何匹配多个结果

运行 main 方法,控制台输出

1
2
3
4
5
6
... INFO ... --- 1. 测试原生 Java 正则 ---
... INFO ... 开始查找...
... INFO ... 找到匹配: a
... INFO ... 找到匹配: b
... INFO ... 找到匹配: c
... INFO ... 查找结束.

痛点总结:我们成功匹配到了 acE1 被忽略)。但是,为了这么一个简单的功能,我们必须:

  1. 记忆 PatternMatcher 两个类
  2. 执行 compile() -> matcher() -> find() -> group() 四个步骤。
  3. 编写一个 while 循环 来处理多个匹配。

这对于“只是想快速拿个结果”的场景来说,太繁琐了


12.3. 【核心理论】正则表达式语法速成

在深入了解 RVP 提供的 RegexUtils 工具之前,我们必须先掌握其背后的语言——正则表达式。RegexUtils 只是一个强大的“扳手”,而正则表达式语法才是你需要拧的“螺母”。不掌握语法,再好的工具也无从下手。

本节内容旨在快速掌握正则表达式的核心语法,目标是能够 看懂 RVP RegexConstants 中的常量,并能 动手修改编写 常见的业务正则。

12.3.1. 必备工具:在线正则测试平台

永远不要在 IDE 里盲写正则表达式。 专业的开发者会使用在线工具来实时验证和调试。

  • 首选: regex101.com (功能最强,能详细解释匹配过程、支持多种语言引擎)
  • 备选: 菜鸟工具 (中文界面,对新手友好)

学习方法:将下文中的所有示例,都亲手在 regex101 中输入一遍,观察匹配结果,这是最快的学习路径。

12.3.2. 基础构建块:元字符与字符集

“元字符”是正则表达式中有特殊含义的字符,它们是构成表达式的“原子”。

元字符名称含义与解释示例 (正则)能匹配的字符串 (部分)
.匹配 除换行符外 的任意 单个 字符。a.cabc, axc, a_c
|匹配 `` 左边 右边的表达式。cat|dog
[]字符集匹配 [] 内的 任意一个 字符。gr[ae]ygray, grey
[a-z]范围匹配指定范围内的任意一个字符。[0-9]0, 5, 9
[^]排除型字符集匹配 [] 内的任意一个字符。[^0-9]a, _, (空格)
\转义符将紧随其后的元字符转义为普通字符。\d+\.\d+12.34 (匹配带小数点的数字)

12.3.3. 控制数量:量词 (贪婪 vs. 懒惰)

“量词”紧跟在元字符或分组之后,用于指定其出现的次数。

量词名称含义 (默认贪婪模式)示例 (正则)能匹配的字符串 (部分)
*星号匹配 0 次或多次go*gleggle, google, gooooogle
+加号匹配 1 次或多次go+glegoogle, gooooogle
?问号匹配 0 次或 1 次colou?rcolor, colour
{n}n 次精确匹配 n 次\d{3}123, 987
{n,m}n 到 m 次匹配 至少 n 次,至多 m 次\d{2,4}12, 123, 1234
{n,}至少 n 次匹配 至少 n 次\d{2,}12, 12345

核心概念:贪婪 (Greedy) vs. 懒惰 (Lazy) 模式 默认情况下,量词 *, +, {} 都是 贪婪的,它们会尽可能多地匹配符合规则的字符。

贪婪a[a-z]*c 匹配 “a bcdefg c”

懒惰:在量词后加 ? 可变为懒惰模式,即 尽可能少地 匹配。

懒惰a[a-z]*?c 匹配 “a b c”(在 abcdefgc 中)

12.3.4. 常用简写与位置断言

为了提高编写效率,正则表达式提供了一些常用字符集的简写。

简写等价于含义
\d[0-9]匹配任意 数字
\D[^0-9]匹配任意 非数字
\w[a-zA-Z0-9_]匹配任意 字母、数字、下划线 (word character)
\W[^a-zA-Z0-9_]匹配任意 字母、数字、下划线
\s[ \t\n\r\f]匹配任意 空白符 (space)
\S[^ \t\n\r\f]匹配任意 非空白符

“位置断言”不匹配任何字符,而是匹配一个 位置

元字符名称含义
^开头匹配字符串的 开始位置^a 匹配 "abc",不匹配 "bac"
$结尾匹配字符串的 结束位置c$ 匹配 "abc",不匹配 "cba"

关键用法: ^$ 结合使用,如 ^\d+$,就将匹配模式从“包含数字”变成了“必须完全由数字组成”。

12.4. 【核心理论】分组与查找(实战)

这是正则表达式中最重要、但也最易混淆的部分。ReUtil.get(..., 1)ReUtil.replaceAll(..., "$1") 完全依赖 于“分组”。

12.4.1. 捕获分组 ()group(0) vs group(1)

() (小括号) 的作用是“捕获分组”。它将括号内的匹配结果单独“抓取”出来,存入内存中。

  • Group 0:永远代表 整个正则表达式匹配到的全部内容
  • Group 1:代表 第 1 个 小括号 () 捕获的内容。
  • Group 2:代表 第 2 个 小括号 () 捕获的内容。

实战 (rag2 模拟):我们来实战原生 API,看它如何处理分组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 位于 RegexUtilsTest.java
public static void testGrouping() {
console.info("--- 2. 测试分组 (Grouping) ---");

// 正则:(小写字母) 跟着 (大写字母)
String regex = "([a-z])([A-Z])";
String content = "aB cD";

Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(content);

while (matcher.find()) {
console.info("--------------------");
// group(0) 是完整匹配
console.info("Group 0 (完整匹配): {}", matcher.group(0));
// group(1) 是第一个 ()
console.info("Group 1 (第一个括号): {}", matcher.group(1));
// group(2) 是第二个 ()
console.info("Group 2 (第二个括号): {}", matcher.group(2));
}
}

public static void main(String[] args) {
// testNativeRegex();
testGrouping();
}

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
... INFO --- 2. 测试分组 (Grouping) ---
--------------------
Group 0 (完整匹配): aB
Group 1 (第一个括号): a
Group 2 (第二个括号): B
--------------------
Group 0 (完整匹配): cD
Group 1 (第一个括号): c
Group 2 (第二个括号): D

分析

  • group(0) 拿到了 aBcD
  • group(1) 拿到了 ac
  • group(2) 拿到了 BD
  • RVP RegexUtils.extractFromString 默认拿 group(1),就是拿 ac

12.4.2. 非捕获分组 (?:...):提升性能

有时候,我们使用 () 只是为了 提高优先级(例如 a(b|c),匹配 abac),并 不关心 (b|c) 这个分组的结果。

  • a(b|c):会创建 group(1) 来存储 bc消耗内存
  • a(?:b|c)?: 告诉正则引擎:“这只是个普通括号,不要 为它创建捕获组”。group(1) 将不存在。

在编写复杂正则时,对不需要捕获的 () 使用 (?:...) 是提升匹配性能的好习惯。


12.5. 【ReUtil 实战精解】告别繁琐,拥抱便捷

掌握了前面的语法“内功”后,我们再来看 Hutool ReUtil (即 RVP RegexUtils 的父类) 提供的“招式”。它将原生 Java API 的 Pattern 编译、Matcher 创建、循环查找、结果提取等繁琐步骤,全部封装进了简单易用的静态方法中。

12.5.1. 核心提取 API:get()findAll()

这类 API 用于从文本中 获取 匹配的内容,是日常使用频率最高的。

方法签名功能解释
get(regex, content, groupIndex)content 中查找 第一个 匹配 regex 的子串,并返回指定 groupIndex 的内容。
findAll(regex, content, groupIndex, list)content 中查找 所有 匹配 regex 的子串,将每个匹配的 groupIndex 内容存入 list 中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 测试文本和正则
String content = "订单号:SN2025001, 金额: 199.99; 订单号: SN2025002, 金额: 88.00";
// 正则:捕获 "SN" 开头的订单号,并将其存入 group 1
String regex = "订单号:(SN\\d+)";

// --- 1. 使用 get() 获取第一个订单号 ---
// groupIndex = 0: 获取完整匹配,即 "订单号: SN2025001"
String firstFullMatch = RegexUtils.get(regex, content, 0);
// groupIndex = 1: 获取第一个括号捕获的内容,即 "SN2025001"
String firstOrderNo = RegexUtils.get(regex, content, 1);

System.out.println("第一个完整匹配: " + firstFullMatch); // -> 订单号: SN2025001
System.out.println("第一个订单号: " + firstOrderNo); // -> SN2025001

// --- 2. 使用 findAll() 获取所有订单号 ---
List <String> orderNoList = new ArrayList <>();
// 传入 groupIndex = 1,表示我们只关心括号里捕获的订单号本身
RegexUtils.findAll(regex, content, 1, orderNoList);

System.out.println("所有订单号: " + orderNoList); // -> [SN2025001, SN2025002]

解读get()findAll()groupIndex 参数,完美对应了我们 12.4.5 节学习的分组概念。0 代表完整匹配,1 代表第一个 (),以此类推。

12.5.2. 替换与删除 API:replaceAll()delAll()

这类 API 用于对文本进行修改。

方法签名功能解释
replaceAll(content, regex, template)content 中所有匹配 regex 的部分,替换为 template 字符串。模板中可用 $1, $2 引用捕获分组。
delAll(regex, content)删除 content 中所有匹配 regex 的部分。它本质上是 replaceAll(content, regex, "") 的快捷方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 场景:脱敏用户手机号,将中间四位替换为 ****
String phone = "用户手机: 13812345678";
// 正则:(\\d{3}) (\\d{4}) (\\d{4})
// group 1: 前三位 (138)
// group 2: 中间四位 (1234)
// group 3: 后四位 (5678)
String regex = "(\\d{3})(\\d{4})(\\d{4})";

// 模板 "$1****$ 3" 引用了第 1 和第 3 个分组
String maskedPhone = RegexUtils.replaceAll(phone, regex, "$1****$3");
System.out.println("脱敏后: " + maskedPhone); // -> 用户手机: 138 **** 5678

// 场景:清除 HTML 标签
String html = "<p> 这是一个 <b> 加粗 </b> 的段落。</p>";
// 正则: <.*?> 懒惰匹配所有 <> 包裹的内容
String text = RegexUtils.delAll("<.*?>", html);
System.out.println("清除标签后: " + text); // -> 这是一个加粗的段落。

解读replaceAll() 的强大之处在于 template 参数,它利用 $n 语法,让我们能够灵活地重组和格式化匹配到的内容。

12.5.3. 判断与预处理 API

方法签名功能解释
isMatch(regex, content)判断 整个 content 字符串是否 从头到尾 完全匹配 regex。对应原生 Matcher.matches()
contains(regex, content)判断 content 字符串中是否 包含 能匹配 regex子串。对应原生 Matcher.find()
escape(content)转义。将 content 中所有正则元字符 (如 *, +, ?) 前面加上 \,使其变为普通字符。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
String text = "我的手机号是 13812345678";

// --- isMatch vs. contains 的关键区别 ---

// isMatch 要求完全匹配,所以 false
boolean isMatch1 = RegexUtils.isMatch("\\d{11}", text);
System.out.println("isMatch `\\d{11}`? " + isMatch1); // -> false

// contains 只要求包含,所以 true
boolean contains1 = RegexUtils.contains("\\d{11}", text);
System.out.println("contains `\\d{11}`? " + contains1); // -> true

// 如果要让 isMatch 返回 true,正则必须能匹配整个字符串
boolean isMatch2 = RegexUtils.isMatch("我的手机号是\\d{11}", text);
System.out.println("isMatch `我的...`? " + isMatch2); // -> true

// --- escape 的应用 ---
// 场景:用户输入一个字符串,我们要把它作为精确查找的关键字
String userInput = "c++"; // 用户想查找 "c++" 这个词
// 如果直接用 userInput 作为正则,"+" 会被当作量词,匹配 "c"、"cc"、"ccc" 等
// 我们需要先转义
String escapedInput = RegexUtils.escape(userInput);
System.out.println("转义后: " + escapedInput); // -> c\+\+
// 现在用转义后的正则去匹配,就能精确查找 "c++"

解读isMatchcontains 是最容易混淆的 API,务必记住它们的区别:isMatch 是“等于”,contains 是“包含”。而 escape 在处理用户输入作为正则表达式一部分时,是保证安全和正确的关键步骤。


12.6. 【RVP 增强】 extractFromString() 源码与实战

我们已经知道 RegexUtils 继承了 ReUtil,但它也增加了一个 RVP 独有的方法:extractFromString()

【二开思考】:为什么 ReUtil.get() 还不够用?

ReUtil.get(regex, content, groupIndex) 有两个“陷阱”:

  1. null 陷阱:如果正则表达式 没有匹配到 任何内容,ReUtil.get() 会返回 null
  2. Exception 陷阱:如果你请求 groupIndex = 1,但你的 regex没有写 () 捕获分组ReUtil.get()直接抛出 IndexOutOfBoundsException: No group 1 异常,导致程序崩溃。

RVP RegexUtils.extractFromString() 的设计目的,就是为了“容错”——它是一个更健壮、更安全的 ReUtil.get(..., 1)

12.6.1. extractFromString() 源码解析

文件路径ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/regex/RegexUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 从输入字符串中提取匹配的部分,如果没有匹配则返回默认值
*
* @param input 要提取的输入字符串
* @param regex 用于匹配的正则表达式
* @param defaultInput 如果没有匹配时返回的默认值
* @return 如果找到匹配的部分,则返回匹配的部分,否则返回默认值
*/
public static String extractFromString(String input, String regex, String defaultInput) {
try {
// 1. 核心:它调用的是 Hutool ReUtil.get()
// 2. 关键:它【硬编码】只获取第 1 分组 (group 1)
String str = ReUtil.get(regex, input, 1);

// 3. 处理“null 陷阱”:如果 ReUtil.get() 返回 null (未匹配到),则返回默认值
return str == null ? defaultInput : str;
} catch (Exception e) {
// 4. 处理“Exception 陷阱”:如果 ReUtil.get() 抛出任何异常
// (最常见的就是 IndexOutOfBoundsException: No group 1)
// 它会 catch 住,并返回默认值
return defaultInput;
}
}

分析
extractFromString 是一个“容错型”的 ReUtil.get(..., 1)。它假设你的目的 永远是获取“分组 1”,并且不希望程序因为“没写分组”或“没匹配到”而崩溃,于是提供了一个 defaultInput 兜底。

12.6.2. extractFromString() 实战

我们在 RegexUtilsTest.java 中添加 testRvpEnhancement 方法来验证这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 位于 RegexUtilsTest.java
public static void testRvpEnhancement() {
console.info("--- 9. 测试 RVP extractFromString (容错提取) ---");
String content = "aEb1c";
String defaultVal = "我是默认值";

// 场景 1:【成功】正则有分组 1
// 正则:([a-z])
String regex1 = "([a-z])";
String result1 = RegexUtils.extractFromString(content, regex1, defaultVal);
console.info("【有分组】结果: {}", result1); // 结果: a

// 场景 2:【失败->兜底】正则没有分组 1 (Hutool 会抛异常)
// 正则:[a-z] (没有括号)
String regex2 = "[a-z]";
String result2 = RegexUtils.extractFromString(content, regex2, defaultVal);
console.info("【无分组】结果: {}", result2); // 【无分组】结果: 我是默认值

// 场景 3:【失败->兜底】正则未匹配
// 正则:([0-9]) (有分组,但匹配不到)
String regex3 = "([0-9])";
String result3 = RegexUtils.extractFromString(content, regex3, defaultVal);
console.info("【未匹配】结果: {}", result3); // 结果: 我是默认值
}

public static void main(String[] args) {
// ...
// testGrouping();
// testGreedyVsLazy();
// testReUtilApi(); // testReUtilApi 包含了 12.5.1, 12.5.2, 12.5.3 的实战

testRvpEnhancement(); // 调用本节测试
}

结论

  • 场景 1:ReUtil.get(..., 1) 成功捕获 “a”,返回 “a”。
  • 场景 2:ReUtil.get(..., 1) 抛出 IndexOutOfBoundsException,被 catch 块捕获,返回 “我是默认值”。
  • 场景 3:ReUtil.get(..., 1) 返回 null,被三元表达式 str == null 捕获,返回 “我是默认值”。

extractFromString 是一个非常健壮的“安全提取器”


12.7. 本章总结

在本章中,我们完整地学习了 RegexUtilsReUtil)这一强大的文本处理工具。我们没有“记流水账”,而是从“痛点”出发,深入了其设计哲学与核心用法。

  1. 痛点与价值:我们首先体验了 Java 原生 PatternMatcher API 的繁琐(4 个步骤 + 循环),从而理解了 Hutool ReUtilRegexUtils 的父类)的核心价值——将所有繁琐步骤封装为简洁的静态方法

  2. 核心语法:我们快速掌握了正则表达式的必备语法,包括 元字符., [], \d, \w)、量词*, +, ?)、位置^, $)以及 贪婪/懒惰 (.*?)。

  3. 分组(核心中的核心):我们深刻理解了“捕获分组 ()”的意义。

    • group(0):代表 完整的匹配
    • group(1):代表 第一个 () 捕获的内容。
  4. ReUtil 实战:我们掌握了 ReUtil 的核心 API:

    • 提取get(..., group)(获取第一个)和 findAll(..., group)(获取所有)。
    • 替换replaceAll(..., "$1"),利用 $1, $2 模板引用捕获分组。
    • 判断isMatch()(完全匹配)和 contains()(包含子串)的 重要区别
  5. RVP 增强:RVP RegexUtils.extractFromString() 是一个**“容错型”ReUtil.get(..., 1) 封装,它通过 try-catchnull 判断,确保在“无分组”或“未匹配”时也能安全返回一个 默认值**。我们本章学习的 RegexUtils / ReUtil 是为了 文本处理(提取、替换)。在 RVP common-core 中,还存在 另一套 基于正则的工具体系,它专门用于 数据校验

在下一章中,我们将深入 RVP 独创的“Constants -> Factory -> Validator”三层校验器架构。