1. 概述
在 Java 中处理字符串替换或查找时,正则表达式(Regular Expressions)是绕不开的利器。我们通常使用 String.replaceAll()
或 Matcher.replaceAll()
来批量替换匹配的内容——这种方式简单粗暴✅,适合统一替换。
但实际开发中,我们经常遇到更复杂的需求:每个匹配到的 token 需要根据上下文做不同的替换。比如:
- 模板引擎中替换
${name}
这类占位符 - 对特殊字符逐个转义
- 动态规则替换
本文将带你实现一个支持自定义逻辑的逐个 token 替换算法,并分享几个实用技巧。⚠️ 注意:这不是 replaceAll
的简单封装,而是基于 Matcher.find()
的精细化控制。
2. 逐个处理匹配项
要实现“按需替换”,必须深入理解 Java 正则 API 的底层机制。核心是 Matcher.find()
和捕获组(capturing groups)的配合使用。
2.1 标题词转小写:一个实际例子
假设我们要把字符串中所有“标题词”转为小写。所谓标题词,是指:
- 以大写字母开头
- 后面全是小写字母或单个大写字母(如 "I")
- 前后必须是非字母字符或字符串边界
输入示例:
"First 3 Capital Words! then 10 TLAs, I Found"
期望匹配到的词:
- First
- Capital
- Words
- I
- Found
对应的正则表达式:
"(?<=^|[^A-Za-z])([A-Z][a-z]*)(?=[^A-Za-z]|$)"
我们来拆解一下这个正则:
片段 | 说明 |
---|---|
`(?<=^ | [^A-Za-z])` |
([A-Z][a-z]*) |
捕获组 1:大写字母开头,后跟零或多个小写字母 |
`(?=[^A-Za-z] | $)` |
✅ 使用非捕获组(?<=
和 ?=
)的好处是:它们只用于边界判断,不会影响 group()
的索引。
⚠️ 踩坑提醒:如果不加边界判断,像 TLAs
中的 L
和 A
也会被误匹配。正则的边界处理往往是 bug 的高发区,建议用 Regexr 可视化调试。
2.2 编写单元测试验证匹配
先定义常量:
private static final String EXAMPLE_INPUT = "First 3 Capital Words! then 10 TLAs, I Found";
private static final Pattern TITLE_CASE_PATTERN = Pattern.compile("(?<=^|[^A-Za-z])([A-Z][a-z]*)(?=[^A-Za-z]|$)");
用 Matcher.find()
遍历所有匹配:
@Test
void givenTitleCasePattern_whenFindMatches_thenCorrect() {
Matcher matcher = TITLE_CASE_PATTERN.matcher(EXAMPLE_INPUT);
List<String> matches = new ArrayList<>();
while (matcher.find()) {
matches.add(matcher.group(1)); // 只取捕获组1
}
assertThat(matches).containsExactly("First", "Capital", "Words", "I", "Found");
}
关键点:
find()
返回true
表示找到下一个匹配group(0)
是整个匹配内容,group(1)
是第一个捕获组- 捕获组索引从 1 开始,不是 0 ❌
2.3 更深入理解 Matcher 的状态
除了 group()
,Matcher
还提供了位置信息:
while (matcher.find()) {
System.out.println("Match: " + matcher.group(0));
System.out.println("Start: " + matcher.start()); // 起始索引(含)
System.out.println("End: " + matcher.end()); // 结束索引(不含)
}
输出:
Match: First
Start: 0
End: 5
Match: Capital
Start: 8
End: 15
...
这些信息至关重要——我们靠 start()
和 end()
来定位原始字符串中的非匹配区域,这是实现“精准替换”的基础。
3. 逐个替换匹配项
现在我们不只想“找到”,还想“替换”。但 replaceAll()
无法满足差异化处理,必须手动拼接。
3.1 替换算法设计
目标:将所有标题词转为小写。
算法思路:
- 维护一个
StringBuilder
作为输出 - 记录上一个匹配的结束位置
lastIndex
- 遍历每个匹配:
- 追加
lastIndex
到当前start()
之间的原始文本(非匹配部分) - 追加处理后的匹配内容
- 更新
lastIndex = matcher.end()
- 追加
- 循环结束后,追加末尾剩余部分
✅ 核心思想:把字符串切成“非匹配 + 匹配”交替的片段,逐个处理。
3.2 Java 实现
private static String convert(String token) {
return token.toLowerCase();
}
public static String replaceTitleWords(String original) {
int lastIndex = 0;
StringBuilder output = new StringBuilder();
Matcher matcher = TITLE_CASE_PATTERN.matcher(original);
while (matcher.find()) {
// 追加上一个匹配结束到本次匹配开始之间的文本
output.append(original, lastIndex, matcher.start());
// 追加处理后的匹配内容
output.append(convert(matcher.group(1)));
lastIndex = matcher.end();
}
// 追加最后一段
if (lastIndex < original.length()) {
output.append(original, lastIndex, original.length());
}
return output.toString();
}
✅ 技巧:StringBuilder.append(CharSequence s, int start, int end)
支持直接截取子串,避免创建临时字符串,性能更好。
4. 通用化替换算法
上面的代码只能处理标题词。我们将其抽象为通用工具方法。
4.1 提取可变部分为参数
变化的只有两点:
- 正则模式(
Pattern
) - 每个匹配的处理逻辑(
Function<Matcher, String>
)
改造后:
public static String replaceTokens(
String input,
Pattern tokenPattern,
Function<Matcher, String> converter
) {
int lastIndex = 0;
StringBuilder output = new StringBuilder();
Matcher matcher = tokenPattern.matcher(input);
while (matcher.find()) {
output.append(input, lastIndex, matcher.start())
.append(converter.apply(matcher));
lastIndex = matcher.end();
}
if (lastIndex < input.length()) {
output.append(input, lastIndex, input.length());
}
return output.toString();
}
4.2 测试通用版本
@Test
void givenTitleCase_whenReplaceTokens_thenLowercased() {
String result = replaceTokens(
"First 3 Capital Words! then 10 TLAs, I Found",
TITLE_CASE_PATTERN,
match -> match.group(1).toLowerCase()
);
assertThat(result).isEqualTo("first 3 capital words! then 10 TLAs, i found");
}
✅ Lambda 表达式让调用非常简洁,逻辑一目了然。
5. 实际应用场景
5.1 转义正则特殊字符
需求:手动对正则中的特殊字符加反斜杠转义(类似 Pattern.quote()
,但更灵活)。
Pattern regexCharacters = Pattern.compile("[<(\\[{\\\\^\\-=$!|\\]})?*+.>]");
String result = replaceTokens(
"A regex character like [",
regexCharacters,
match -> "\\" + match.group() // 每个匹配前加 \
);
assertThat(result).isEqualTo("A regex character like \\[");
⚠️ 注意:Java 字符串和正则表达式双重转义,写起来容易眼花。建议把复杂正则拆成常量并加注释。
5.2 替换模板占位符
典型场景:模板渲染,如 "Hi ${name} at ${company}"
。
Map<String, String> placeholderValues = new HashMap<>();
placeholderValues.put("name", "Bill");
placeholderValues.put("company", "Baeldung");
Pattern placeholderPattern = Pattern.compile("\\$\\{(?<placeholder>[A-Za-z0-9-_]+)}");
String result = replaceTokens(
"Hi ${name} at ${company}",
placeholderPattern,
match -> placeholderValues.getOrDefault(match.group("placeholder"), "")
);
assertThat(result).isEqualTo("Hi Bill at Baeldung");
✅ 关键技巧:使用命名捕获组 (?<placeholder>...)
,通过 match.group("placeholder")
取值,代码可读性远高于 group(1)
。
6. 总结
本文从一个简单的标题词处理需求出发,逐步构建了一个高可扩展的 token 替换框架:
- ✅ 掌握了
Matcher.find()
+start()
/end()
的精准控制能力 - ✅ 实现了支持自定义处理逻辑的通用替换方法
- ✅ 应用于字符转义、模板填充等常见场景
相比简单的 replaceAll
,这种模式虽然代码略多,但灵活性和可维护性大幅提升。尤其适合处理复杂文本转换的中间件或工具类。
所有示例代码已托管至 GitHub:https://github.com/yourname/core-java-regex-2