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 中的 LA 也会被误匹配。正则的边界处理往往是 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 替换算法设计

目标:将所有标题词转为小写。

算法思路:

  1. 维护一个 StringBuilder 作为输出
  2. 记录上一个匹配的结束位置 lastIndex
  3. 遍历每个匹配:
    • 追加 lastIndex 到当前 start() 之间的原始文本(非匹配部分)
    • 追加处理后的匹配内容
    • 更新 lastIndex = matcher.end()
  4. 循环结束后,追加末尾剩余部分

✅ 核心思想:把字符串切成“非匹配 + 匹配”交替的片段,逐个处理

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


原始标题:How to Use Regular Expressions to Replace Tokens