简介
尽管您可能认为编写需要分析文本的 Java 应用程序是一项简单任务,但象许多事情一样,它会很快变得复杂起来。那的确是我在编写代码以解析 HTML 页面时的经验。开始的时候,我偶尔会使用 Perl5 正则表达式(regexp)。但是,由于某些原因(稍后说明),我后来常常使用它们。
背景知识
在我的经验中,大多数 Java 开发人员都需要解析某种文本。通常,这意味着他们最初要花一些时间使用象 indexOf 或 substring 那样的与 Java 字符串相关的函数或方法,并且希望输入格式永远不变。但是,如果输入格式改变,那么用于读取新格式的代码维护起来就会变得更复杂、更困难。最后,代码可能需要支持自动换行(word wrapping)、区分大小写等。
由于逻辑变得更加复杂,所以维护也变得很困难。因为任何更改都可能产生副作用并使文本解析器的其它部分停止工作,所以开发人员需要时间修正这些小错误。
有一定 Perl 经验的开发人员可能也有过使用正则表达式的经验。如果够幸运(或优秀)的话,这位开发人员能够说服团队其余的人(或至少是团队领导)使用这项技术。新的方法将取消编写用来调用 String 方法的多行代码,它意味着将解析器逻辑的核心委托出去,并替换为 regexp 库。
接受了有 Perl5 经验的开发人员的建议后,团队必须选择哪个 regex 实现最适合他们的项目。然后他们需要学习如何使用它。
在简要地研究了从因特网上找到的众多可选方案后,假设团队决定从人们更熟悉的库中选择一个使用,如属于 Jakarta 项目的 Oro。接下来,对解析器进行较大程度地重构或几乎重新编写,并且解析器最终使用了 Oro 的类,如 Perl5Compiler、Perl5Matcher 等。
这一决定的后果很明显:
去耦的好处
有没有办法使团队知道哪个实现最适合他们的需要呢(不仅现在能将来也能)?让我们试着寻找答案。
避免依赖任何特定的实现
前面的情形在软件工程中十分常见。在有些情况中,这样的情形会导致较大的投资和较长的延期。当不了解所有后果就作出决定而且决策制定人不太走运或缺乏必需的经验时,就常常会发生这种情况。
可将该情形概括如下:
这一问题的解决方法是使代码更加独立于提供者。这引入了新的层 ― 同时去除客户机和提供者的耦合的层。
在服务器端开发中,很容易找到使用该方法的模式或体系结构。下面引用一些示例:
在假想的开发团队示例中,他们正在寻找这样的层:
听起来不错,但……
任何去耦方法都至少有一个缺点:如果客户机代码仅需要一个实现所提供的特定功能,怎么办?您不能使用任何其它实现,因此您最终将代码与该实现耦合。也许将来会在这方面有所改善,但您现在却束手无策。
这样的示例并不象您想的那样少。在 regexp 领域中,一些编译器选项仅被某些实现支持。如果您的客户机代码需要这种特定的功能,那么这个一般层是不够的 ― 至少从迄今对它描述来看是不够的。
附加层是否应支持每个实现的所有非公共功能,并且如果选择了不支持该实现的附加层则抛出异常?那可以是一种解决方案,但它并不支持仅定义公共抽象概念这一最初目标。
有一个 GoF 模式非常适合这种情形:职责链(Chain of Responsibility)。它在设计中引入了另一种间接方法。用这种方法,客户机代码向能处理其所发消息的实体列表发送消息或命令。列表项被组织成链,因此消息可按顺序被处理并且在到达链尾之前被用掉。
在这种情况中,可以通过特殊类型的消息对仅被某些实现支持的特定功能建模。由链中的每一项根据其是否了解这些功能来决定是否将该消息传给下一项。
定义一个公共 API
这里讲述的 API 名为 RegexpPlugin。已将它设计成遵循刚刚讨论的方法,并且它在 regexp 库和使用该库的代码之间支持去耦。
RegexpPlugin
在以下示例中,我将总结一下使用具体实现(Jakarta Oro)和使用 RegexpPlugin API 之间的差别。
我从一个非常简单的 regexp 开始:假定您必须要解析的文本只是人名。您接收的格式是象 John A. Smith 这样的内容,而您只想获取名字(John)。但您不知道单词由什么分隔,是空格、换行符、制表符还是这些字符的组合。能处理这样的输入格式的 regexp 只是 .*\s*(.*?)\s+.*。我将一步一步地说明如何使用该 regexp 来抽取信息。
第一部分是点号和星号字符 .*,它们在这里表示任意数量的空格和 (.*?) 组之前的任何字符。第二部分比较引人注意(因为它被圆括号括起来)。问号表示取第一个符合条件的项。
接下来的符号表示任意数量的空格、换行或制表符(\s),但至少要有一个(+)。最后的点号和星号 .* 仅代表文本的余下部分(对它没有兴趣)。
因此,该 regexp 相当于:取空格前的第一段文本。让我们来编写 Java 代码。
上机实践
要在 Java 代码中使用正则表达式,通常需要完成以下七个步骤:
第 1 步:创建编译器实例。如果使用 Jakarta Oro,则必须实例化 Perl5Compiler:
org.apache.oro.text.regex.Perl5Compiler compiler = |
使用 RegexpPlugin 时的等同代码是相似的:
org.acmsl.regexpplugin.Compiler compiler = |
但存在差异。正如前面提到的,该 API 对实际使用哪个具体实现加以隐藏。您可以选择一个具体实现或保留缺省的 Jakarta Oro。如果所选的库在运行时不可用,则 RegexpPlugin API 会尝试用它的类名创建一个编译器。如果该操作失败,它会将异常发回 API 的客户机。
假定您一直在使用 JDK 1.4 的内置 regexp 类。那样的话,包含始终不会使用的额外 jar 文件毫无意义。那就是为什么仅仅调用 createCompiler() 方法还不够的原因。您需要管理这样的异常:每当所选的库不存在时就会抛出该异常。因而必须更新示例:
try |
第 2 步:编译 regexp 模式。将正则表达式本身编译到 Pattern 对象中。
org.apache.oro.text.regex.Pattern pattern = |
注:您必须转义反斜杠(\)字符。
该模式对象代表以文本格式定义的正则表达式。请尽可能多地重用模式实例。然后,如果 regexp 是固定的(缺少任何可变部分,如“(.*?)Tom.*”),则模式应是类中的静态成员。
compile 方法适合用标志(如 EXTENDED_MASK)来配置(请参阅参考资料以获得更详细的 regexp 教程)。但是,RegexpPlugin 并不允许随意的标志。受支持的标志只有 case sensitivity 和 multiline,因为所有受支持的库都可以处理它们。
编译器实例有特定的特性来定义这些标志:
compiler.setMultiline(true); |
第 3 步:创建 Matcher 对象。在 Jakarta Oro 中,这一步非常简单:
org.apache.oro.text.regex.Perl5Matcher matcher = |
它之所以如此简单是因为它不需要构造任何信息。在后来的 regexp 中,它将变得具体。基本上,RegexpPlugin 中的步骤差不多相似。您不必亲自创建 matcher,而是可以将其代理给 RegexpManager 类:
org.acmsl.regexpplugin.Matcher matcher = |
区别和前面一样,您需要处理 RegexpEngineNotFoundException。实际上,RegexpManager 需要为您所选的库或缺省库创建 matcher 适配器。如果这样的类在运行时不可用,它会抛出该异常。
第 4 步:评估正则表达式。matcher 对象需要解释正则表达式并抽取所需的信息。这在一行代码中完成:
if (matcher.contains("John A. Smith", pattern)) |
如果输入文本与正则表达式匹配,则该方法返回 true。隐含的副作用是,执行该行代码之后,matcher 对象包含在输入文本中找到的第一个匹配项。接下来的一步演示如何实际获取感兴趣的信息。
通过使用 RegexpPlugin API,在此时根本没有任何不同。
第 5 步:检索找到的第一个匹配项。这一简单的步骤仅用一行完成:
org.apache.oro.text.regex.MatchResult matchResult = matcher.getMatch(); |
您可以声明一个局部变量来存储这样的对象,该对象含有与 regexp 匹配的一段文本。在这两种情况下,该步骤是相同的,除了变量声明(因为一个是另一个的适配器):
org.acmsl.regexpplugin.MatchResult matchResult = |
第 6 步:获取感兴趣的 group。您可以使用两种方法:
因为您的 regexp 是 .*\s*(.*?)\s+.*,所以您只有一个组:(.*?)
MatchResult 对象包含已排序列表中的所有组。您只需要知道要获取的组的位置。因为该示例只有一个组,所以毫无疑问:
String name = matchResult.group(1); |
变量 name 现在包含文本 John,那正是您需要的。
第 7 步:如果需要,则重复该过程。如果您需要的信息可多次出现,而您想分析所有出现的信息而不只是第一个,那么您只需循环执行第 5 步到第 7 步,直到不满足第 4 步中描述的条件为止:
while (matcher.contains("John A. Smith", pattern)) |
映射
除了编写公共抽象 API,主要的工作实际上是实现 Java 环境中某些已存在的 regexp 引擎的适配器。
以下各表提供了对如何从一个库迁移至另一个库的详细描述。有些情况中,概念明显不同。也有些情况中,却不是那么明显。
Regexp 概念 | GNU Regexp 1.2 |
---|---|
编译器 | gnu.regexp.RE |
模式 | gnu.regexp.RE |
匹配程序 | gnu.regexp.REMatchEnumeration gnu.regexp.RE |
匹配结果 | gnu.regexp.REMatch |
畸形模式异常 | gnu.regexp.REException |
Regexp 概念 | Jakarta Oro 2.0.6 |
---|---|
编译器 | org.apache.oro.text.regex.Perl5Compiler |
模式 | org.apache.oro.text.regex.Pattern |
匹配程序 | org.apache.oro.text.regex.Perl5Matcher |
匹配结果 | org.apache.oro.text.regex.MatchResult |
畸形模式异常 | org.[..].regex.MalformedPatternException |
Regexp 概念 | Jakarta Regexp 1.3 |
---|---|
编译器 | org.apache.regexp.RE org.apache.regexp.RECompiler org.apache.regexp.REProgram |
模式 | org.apache.regexp.REProgram org.apache.regexp.RE |
匹配程序 | org.apache.regexp.RE org.apache.regexp.REProgram |
匹配结果 | org.apache.regexp.RE |
畸形模式异常 | org.apache.regexp.RESyntaxException |
Regexp 概念 | JDK 1.4 regex 包 |
---|---|
编译器 | java.util.regex.Pattern |
模式 | java.util.regex.Pattern |
匹配程序 | java.util.regex.Matcher |
匹配结果 | java.util.regex.Matcher |
畸形模式异常 | java.util.regex.PatternSyntaxException |
基准
该 API 较显著的用法之一是用来比较实现、测量性能、对 Perl5 语法的兼容性或其它标准之间的差异。
为这些测试开发的基准实用程序使用 HTML 解析器来处理 Web 内容,更新有关链接、表单和表等元素的信息。但是,重要的是解析逻辑用正则表达式来表示,因此会通过 RegexpPlugin API 实现。
基准测试包括对非常简单的 HTML 页面解析 10000 次。结果在下表中显示。
Regexp 库 | Benchmark 结果(秒) |
---|---|
Jakarta Oro 2.0.6 | 130,71 |
Jakarta Regexp 1.2 | 23,261 |
GNU Regexp 1.1.4 | 1,966.939 |
JDK1.4 | 33,222 |
您可以用多种方法在实际应用程序中改进性能。最重要的是,当您使用 regexp 库时,不需要每次都编译模式,而是编译它们并重用各自的实例。但是,如果 regexp 本身不固定,则不能忽略编译过程。
因为基准需要在实现之间切换以比较性能,所以必须始终废弃已编译模式以避免库之间的交互。但是,正如您所见,大多数已评估的库有相似的响应时间,尽管更详细的基准能让我们更好的理解每个库在不同环境下的行为。
结束语
正则表达式解析器有强大的功能。一旦团队适应了它,解析逻辑就会改进,这有助于降低维护。但是,开发人员需要了解 regexp 语法以理解这些代码是如何工作的。本文已经用一个非常简单的示例说明了如何使用这些库中的一个。除此之外,本文还描述了使用附加层去除客户机代码与 regexp 引擎本身之间的耦合的好处。
参考资料
关于作者 Jose San Leandro Armendariz 是经验丰富的软件工程师,他在过去几年中从事过许多 J2EE 项目。可以通过 jsanleandro@yahoo.es 与 Jose 联系。 |