java与模式怎么用:从准备到完成的实操步骤

科技资讯5小时前发布 jdkjadmin
0 00
java与模式怎么用:从准备到完成的实操步骤

java与模式怎么用:从准备到完成的实操步骤

在 Java 中,“模式”常指正则表达式模式。它不是 Java 语言里的独立语法,而是由 java.util.regex 包提供的一套文本匹配工具。最常用的两个类是 Pattern 和 Matcher:前者保存编译后的正则表达式,后者负责在具体文本上执行匹配、查找、分组和替换。

正则表达式适合描述有稳定规律的文本,例如简单字段校验、日志提取、分隔符处理和批量替换。它不适合代替完整的解析器:面对嵌套结构、复杂业务规则或需要严格遵循某种格式规范的输入,应优先考虑专用解析器或分步校验。本文使用 Java 17 语法,核心 API 在较早版本中也已存在。

一、先确定任务:校验、查找还是提取

开始写表达式前,先回答三个问题。第一,是要判断整个字符串是否符合格式,还是只要在其中找到一段内容?第二,是只需要真假结果,还是需要取出匹配部分?第三,输入来自可信数据还是外部用户?这三个答案决定了应调用的方法,也影响表达式的复杂度和安全要求。

  • matches():判断当前输入的全部内容是否匹配模式,适合格式校验。
  • find():从当前位置开始查找下一段匹配内容,适合从日志或文本中提取数据。
  • lookingAt():只要求从输入开头开始匹配,不要求覆盖整个字符串。

一个常见错误是写了看起来像校验规则的表达式,却使用 find()。例如表达式 \d{4} 配合 find(),会在 订单编号 A1234B 中找到四位数字,并不意味着整串文本都是四位数字。做完整字段校验时,一般使用 matches(),或者显式加上 ^ 与 $ 并谨慎处理换行行为。

二、准备项目并编译第一个模式

使用正则表达式不需要额外依赖。新建文件 RegexDemo.java,将下面代码保存后执行 javac RegexDemo.java 和 java RegexDemo。

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexDemo {
    public static void main(String[] args) {
        Pattern pattern = Pattern.compile("[A-Z]{2}\\d{4}");
        String input = "订单 AB1234 已创建";

        Matcher matcher = pattern.matcher(input);
        while (matcher.find()) {
            System.out.println(matcher.group());
            System.out.println("起始位置:" + matcher.start());
            System.out.println("结束位置:" + matcher.end());
        }
    }
}

输出为:

AB1234
起始位置:3
结束位置:9

Pattern.compile() 将表达式编译成可重复使用的模式;pattern.matcher(input) 为某个输入创建匹配器。Matcher 保存了当前搜索位置和匹配状态,因此不要把同一个 Matcher 当成无状态对象在多个线程间共享。通常可以共享不可变的 Pattern,并在每次处理文本时创建新的 Matcher。

Java 字符串和正则表达式各有一层转义。正则中的数字字符类通常写成 \d,但 Java 字符串里的反斜杠也要转义,所以源码中写作 "\\d"。同理,正则里的字面点号 \. 在 Java 字符串中写成 "\\."。若从文件或配置读取模式,内容不经过 Java 字符串字面量解析,转义方式要按该配置格式单独判断。

三、常用语法:字符、数量、边界与分组

正则表达式的基本元素可以组合使用。以下是日常最常见的一组写法:

  • .:匹配一个字符;默认情况下不匹配行终止符。是否跨行与 DOTALL 标志有关。
  • \d:数字字符类;\w:单词字符类;\s:空白字符类。具体字符范围受 Unicode 相关标志影响,不应未经确认就把它们等同于“所有语言中的数字、字母或空白”。
  • [abc]:匹配方括号中的任意一个字符;[^abc]:匹配不在其中的字符。
  • {n}、{n,}、{n,m}:分别表示固定次数、至少 n 次、n 到 m 次。
  • *、+、?:分别表示零次或多次、一次或多次、零次或一次。
  • ^、$:通常表示输入或行的边界;开启多行模式后,它们的语义会发生变化。
  • (...):捕获分组;(?:...):非捕获分组;|:选择其左边或右边的表达式。

量词默认是贪婪的,会尽量匹配更多字符。例如 <.*> 用在 <b>甲</b><b>乙</b> 上,可能从第一个左尖括号一直匹配到最后一个右尖括号。把量词改成懒惰形式 *?,例如 <.*?>,可以让它优先匹配较短内容,但这不等于用正则表达式解析任意 HTML 是可靠做法。HTML 有嵌套、属性、注释和转义等结构,实际解析应使用 HTML 解析器。

需要匹配正则元字符本身时,要转义。例如,匹配点号要使用 \\.;在字符类之外,美元符号、括号、加号等也有特殊含义。对一段不可信或不应被解释为正则语法的字面文本,可使用 Pattern.quote(),不要手工拼接转义规则。

四、完整校验:用 matches 判断整串输入

下面示例校验一个简单的订单号:必须以两位大写字母开头,后跟四位 ASCII 数字。此规则只是演示,不代表任何现实业务编号规范。表达式使用 [0-9] 明确限定 ASCII 数字,避免把“数字”的范围交给隐含的字符类设置决定。

import java.util.regex.Pattern;

public class OrderIdCheck {
    private static final Pattern ORDER_ID =
            Pattern.compile("[A-Z]{2}[0-9]{4}");

    public static boolean isValid(String value) {
        return value != null && ORDER_ID.matcher(value).matches();
    }

    public static void main(String[] args) {
        System.out.println(isValid("AB1234"));  // true
        System.out.println(isValid("xAB1234")); // false
        System.out.println(isValid("AB12345")); // false
        System.out.println(isValid(null));      // false
    }
}

matches() 本身要求整个输入序列匹配,因此这里无需再加首尾锚点。若改用 find(),就不能得到相同的完整校验效果。也要明确空值策略:Pattern.matcher(null) 会抛出 NullPointerException,业务代码应在调用前决定空值是非法、缺省还是需要单独报错。

字符类写得越明确,规则通常越容易审查。例如业务只允许 ASCII 数字时,[0-9] 比含义可能随标志变化的简写更清楚。反过来,如果需求明确包含其他语言的数字字符,就应先确认允许范围,并针对目标字符集设计和测试规则,而不是只把 [0-9] 机械替换成一个字符类就认为需求已完成。

五、查找与提取:使用 Matcher 和捕获组

从一行日志中提取时间、级别和消息,可以通过捕获组获得各字段。下面的规则只服务于示例中的固定日志格式,并不尝试覆盖所有日期时间格式。

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class LogExtract {
    private static final Pattern LOG =
            Pattern.compile("([0-9]{4}-[0-9]{2}-[0-9]{2}) " +
                            "\\[(INFO|WARN|ERROR)\\] (.*)");

    public static void main(String[] args) {
        String line = "2025-03-08 [WARN] 磁盘空间不足";
        Matcher matcher = LOG.matcher(line);

        if (matcher.matches()) {
            System.out.println("日期:" + matcher.group(1));
            System.out.println("级别:" + matcher.group(2));
            System.out.println("消息:" + matcher.group(3));
        } else {
            System.out.println("格式不符合预期");
        }
    }
}

捕获组从 1 开始编号;group(0) 表示整段匹配。分组较多时,编号容易因改动表达式而错位,可以使用命名捕获组:

Pattern pattern = Pattern.compile(
    "(?<date>[0-9]{4}-[0-9]{2}-[0-9]{2}) " +
    "\\[(?<level>INFO|WARN|ERROR)\\] (?<message>.*)"
);

Matcher matcher = pattern.matcher(
    "2025-03-08 [WARN] 磁盘空间不足"
);

if (matcher.matches()) {
    System.out.println(matcher.group("date"));
    System.out.println(matcher.group("level"));
    System.out.println(matcher.group("message"));
}

命名组名称必须符合 Java 正则表达式对组名的要求:以英文字母开头,后续由英文字母和数字构成。改动模式时应同步运行测试,尤其要检查可选组;未参与匹配的可选组在调用 group() 时可能得到 null,不能假定每个组都一定有值。

若一段文本含有多条记录,可以用 find() 循环查找。每次成功调用后,group()、start() 和 end() 对应当前匹配结果。不要在没有检查返回值的情况下读取组:当 find() 返回 false 时,读取匹配结果会触发 IllegalStateException。

六、替换与拆分:注意字面量和分隔符语义

replaceAll() 会替换所有匹配项,replaceFirst() 只替换第一个。替换内容中,$1 这样的写法引用捕获组,反斜杠也有特殊处理。如果替换文本来自用户输入,且应按字面内容使用,可通过 Matcher.quoteReplacement() 避免其中的美元符号或反斜杠被当成替换语法。

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ReplaceDemo {
    public static void main(String[] args) {
        Pattern digits = Pattern.compile("[0-9]+");
        String input = "订单 1234,批次 56";
        System.out.println(digits.matcher(input).replaceAll("#"));
        // 输出:订单 #,批次 #

        String userText = "$5\\done";
        String safeReplacement = Matcher.quoteReplacement(userText);
        System.out.println(digits.matcher("编号 12").replaceAll(safeReplacement));
        // 输出:编号 $5\done
    }
}

String.split() 的参数是正则表达式,不是普通字符串分隔符。按点号拆分时应写 "\\.";按竖线拆分时也需要转义或放入字符类。还有一个容易忽略的区别:无参数限制的 split(regex) 会丢弃结果末尾的空字符串;使用负数限制值可以保留这些空项。

String[] a = "a,b,".split(",");
String[] b = "a,b,".split(",", -1);

System.out.println(a.length); // 2
System.out.println(b.length); // 3
System.out.println("末尾字段:" + b[2]); // 空字符串

限制值的含义也值得记住:正数限制最多产生指定数量的结果,最后一个结果可能包含尚未拆开的剩余内容;零表示采用默认行为并丢弃尾部空项;负数表示不丢弃尾部空项。拆分后的数组长度和空字段处理应根据协议格式验证,不能只凭肉眼判断。

七、标志与 Unicode:不要把简写字符类想当然

编译模式时可以传入标志,例如 Pattern.CASE_INSENSITIVE 用于忽略大小写,Pattern.MULTILINE 影响 ^ 和 $ 的行级行为,Pattern.DOTALL 让点号也能匹配行终止符。多个标志可以用按位或组合:

Pattern pattern = Pattern.compile(
    "^error:.*$",
    Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL
);

标志改变的是匹配语义,组合前应先说明需求。尤其是 DOTALL 与 MULTILINE 不是一回事:前者主要影响点号是否匹配行终止符,后者改变行边界锚点的行为。若输入可能包含多种换行符,还应针对目标数据测试,避免默认假设文本永远只有一种换行格式。

\d、\w 和 \s 的行为涉及字符类别与 Unicode 规则。Java 提供 Pattern.UNICODE_CHARACTER_CLASS 标志,使预定义字符类和 POSIX 字符类采用 Unicode 感知的行为;具体效果应以 Java 版本对应的 API 文档和实际需求为准。若字段只允许英文字母或 ASCII 数字,直接写出允许字符通常更清晰。若需支持国际化文本,应明确接受的字母、数字和组合符范围,并用代表性字符测试,而不是假设一个简写类就等于所有语言中的“字母”。

正则表达式按字符序列进行匹配,但某些 Unicode 字符由多个 UTF-16 代码单元组成,用户看到的一个字形也可能由多个码点组合而成。若需求涉及“用户看到的字符数量”,简单使用点号或字符串长度未必符合业务含义;应先弄清要限制的是 UTF-16 代码单元数、Unicode 码点数,还是字素簇数量,再选择合适的处理方式。

八、动态构造模式:区分正则代码和普通数据

将变量直接拼进表达式,是一种常见且危险的误用。假设要搜索用户输入的普通文字,输入中的 .、* 或括号可能改变模式含义,甚至让表达式变得昂贵。将普通文本作为字面内容嵌入时,应使用 Pattern.quote():

import java.util.regex.Pattern;

public class LiteralSearch {
    public static void main(String[] args) {
        String userInput = "a+b";
        Pattern pattern = Pattern.compile(Pattern.quote(userInput));

        System.out.println(pattern.matcher("表达式 a+b 是字面文本").find());
        System.out.println(pattern.matcher("表达式 aaab").find());
    }
}

第一行输出 true,第二行输出 false。Pattern.quote() 适用于“这段内容应按字面字符匹配”的场景;如果输入本来就是受信任、经过审核的正则表达式,则不应对整个模式调用它,否则正则语法也会被当成普通文字。应把动态部分和固定的正则结构分开处理,避免把数据误当代码。

Pattern.quote() 处理的是正则模式中的字面文本,并不负责 SQL、HTML、Shell 或其他上下文的转义。输出到不同语言或格式时,要遵循对应上下文的编码和转义规则,不能把正则转义误当成通用安全措施。

九、性能与安全:重点防范不可信模式和灾难性回溯

正则表达式的运行成本取决于模式、输入内容和匹配方式。某些带有重叠选择或嵌套量词的表达式,在特定输入下会让回溯匹配器反复尝试不同路径。风险尤其值得关注的例子包括 (a+)+、(a|aa)+ 一类结构:当输入包含大量重复字符并以不匹配字符结尾时,匹配时间可能迅速增长。这类问题常称为灾难性回溯,属于正则表达式拒绝服务风险(ReDoS)的来源之一。

这不意味着所有量词嵌套都必然危险;实际风险与表达式结构、输入长度、Java 版本及输入内容有关,不能用一个固定公式给出统一耗时。关键原则是:不要在未经约束的情况下,让外部用户提交任意正则表达式并直接在服务端运行;对外部输入限定最大长度,审查模式结构,避免不必要的歧义和重叠分支,并用最坏情况样例测试。对高风险的公开服务,还可以通过隔离执行、请求超时或专用正则引擎等措施降低影响。

Java 标准 Pattern API 没有提供一个可直接传入的通用“匹配超时”参数。不要编造或依赖不存在的 Matcher.setTimeout() 之类接口。把匹配放进另一个线程再调用 Future.cancel(),也不能在所有情况下保证底层正则运算会立即停止。因此,输入长度上限、模式审查和避免高风险结构是更基础的控制手段。具体上限应依据业务、部署资源和实测确定,不存在对所有服务都正确的固定数字。

模式如果是应用内固定常量,可以在类初始化时编译一次,重复使用 Pattern,减少重复编译开销。不要把这个优化理解为“越复杂越值得复用”:外部动态模式仍需验证来源和使用边界。表达式本身难以阅读时,可拆分成具名常量、使用注释模式,或改用更清楚的解析流程。性能问题应通过代表性数据测量,而不是仅凭表达式长度推断。

十、常见错误与排查顺序

  1. Java 转义层数不对。 先写出目标正则,再换成 Java 字符串形式。例如正则 \d+ 对应 Java 字符串 "\\d+"。
  2. 把查找当成校验。 校验整个输入使用 matches();查找子串使用 find()。测试时同时准备合法、前后带额外字符、空串和近似错误样例。
  3. 误解点号和锚点。 点号默认不匹配行终止符;锚点行为会受标志影响。遇到多行文本,明确需要整段文本边界还是每行边界。
  4. 把字符串当成正则分隔符。split() 接收正则。对用户提供的普通分隔符,应先用 Pattern.quote() 构造安全模式。
  5. 忘记替换字符串也有语法。 替换文本中的美元符号和反斜杠可能有特殊意义。按字面替换时使用 Matcher.quoteReplacement()。
  6. 组编号因修改而变化。 多组表达式优先考虑命名组;可选组读取前检查是否为 null。
  7. 把校验格式当成业务校验。 正则可以检查形状,不一定能验证语义。例如日期看起来符合 yyyy-MM-dd,不代表它是有效日期;需要用日期 API 解析并处理异常。
  8. 遇到特殊格式仍继续堆表达式。 JSON、XML、HTML、编程语言源代码和复杂日期时间等格式通常有专用解析工具。正则可以做前置筛选或提取简单片段,但不应代替结构化解析。

排查时,可以先把表达式缩小到最短可复现案例,再分别打印 matches()、find() 结果和各组内容。对复杂模式,可调用 pattern() 查看当前模式文本,并在单元测试中覆盖边界条件。不要把线上原始敏感数据直接写入日志;调试输出应经过脱敏处理。

十一、从准备到交付的实操流程

  1. 写出需求边界。 明确允许字符、长度、空值策略、大小写规则、是否允许换行,以及是整串校验还是片段查找。
  2. 先准备样例。 列出合法输入、非法输入、边界长度、空字符串、Unicode 输入和额外前后缀。样例能暴露大量模糊需求。
  3. 选择最简单的结构。 先使用字符类、数量范围和明确分组。若模式越来越难读,考虑拆成多步验证或使用解析器。
  4. 用 Java API 验证。 通过 Pattern.compile() 让不合法的模式在测试阶段暴露;以 matches()、find() 或其他适合的方法执行任务。
  5. 处理动态数据。 普通文本放入模式时使用 Pattern.quote();普通替换内容使用 Matcher.quoteReplacement();不要把两者混为一谈。
  6. 检查性能与安全边界。 为输入长度制定业务上限,避免高风险回溯结构;若模式由用户提供,评估更严格的隔离与限制策略。
  7. 编写回归测试。 每次修改表达式都重新测试合法、非法、临界和恶意构造输入,并记录表达式针对的业务规则。

结语

在 Java 中使用正则表达式,核心是分清模式与匹配器的职责:Pattern 表达规则,Matcher 对具体文本执行操作。整串校验用 matches(),文本检索用 find(),提取字段用捕获组,替换与拆分则要额外留意各自的转义和空项规则。

好的正则不是越短越高级,而是规则边界明确、输入范围可控、失败方式可预测,并且有测试覆盖。简单文本模式用正则通常高效直接;一旦需求涉及复杂结构、严格语义或不可信表达式,就应考虑更适合的解析工具和安全设计。

© 版权声明

相关文章