在我的另一篇文章《 使用 Antlr 开发领域语言》中对 Antlr 是什么、它能做什么以及如何安装使用都做了说明。今天我们的主要工作是关注如何使用 Antlr 处理文本。
Antlr 是一个语言识别工具,主要用于处理计算机编程语言。用户根据编程语言处理的特点,自定义的上下文无关文法。Antlr 根据这些文法,自动生成词法分析器(Lexer)、语法分析器(Parser)和树分析器 (Tree Parser)。其中词法分析器的输入是字符流,输出是记号流。语法分析器把记号流作为输入,输出抽象语法树。树分析器遍历整个语法树,输出目标代码。3 类分析器的设计使用了管道过滤器风格,前一个分析器的输出是后一个的输入,其整体的工作流程如图 1 所示。
图 1. Antlr 工作流程
在使用 Antlr 处理文本时, 我们主要使用词法分析器(Lexer)来完成工作。在处理文本时, 文本本身是一个字符流。Antlr 的词法分析器是一个强大的文本处理工具,它能够把字符流依据词法规则分解成不同的记号,形成记号流,供后续处理。
文本处理工作在我们的日常工作中非常普遍,最常见的是日志分析,比如对应用服务器控制台输出日志进行分析,对应用自身的系统日志进行分析等等;其他一些时候我们甚至会把配置文件、源程序文件、XML 文件、HTML 文件等当成文本进行处理
正则表达式被认为是文本处理的首选工具,当我们使用正则表示式时,首先定义一个正则表达式,然后和预期文本进行匹配,最终再按照正则表示式中的分组,逐一获取相匹配的数据,然后再进行下一步的处理(输出、替换等等)。在进行比较复杂一些的问题时,使用正则表达式,整体处理过程比较漫长,有时为了处理一个问题,写出的正则表达式晦涩难懂,很不便于维护。
在 Antlr 中词法分析器使用了和语法分析器相同的技术来构造,对词法记号 Token 的匹配使用了递归下降的策略,使得词法分析器具有处理上下文无关文法的能力,而正则表达式所能处理的文法只包含正则文法(线性文法),因此词法分析器可以处理很多正则表达式难以处理的问题,比如左括号和右括号的成对匹配等。
此外,在 Antlr 中词法分析器所要匹配的词法记号,通过相互引用的方式进行嵌套和递归定义,比正则表达的书写更直观,更加便于维护。
总的来说,使用 Antlr 词法分析器处理文本和正则表达式相比,处理能力更强大,便于开发和测试,在本文的后续部分中,我们一起来看一下如何使用 Antlr 词法分析器完成抽取、转换、重写这三类文本处理工作。
一个抽取器负责从一个文本中把满足特定特征的文本提取出来。一个常见的例子是网络爬虫,爬虫程序需要从一个 HTML 文本中抽取出 URL 用于后续的网络遍历。
这里演示例子的是抽取一个数据库 SQL 执行日志,把满足某些特征的 SQL 文本抽取出来。SQL 日志的详细信息请参考附件中的 sql.out 文件。SQL 日志中记载了一批 INSERT 语句的执行结果。其中一些成功执行,而另一些由于主键冲突的原因执行失败了。我们需要开发一个抽取器,用于从日志中把所有执行成功的 INSERT 语句提取出来。
使用 Antlr 开发抽取器的第一步是,建立词法文件 SqlExtrator.g。 在文件的第一行使用两个 Antlr 的关键字 lexer grammar 声明这是一个词法文件,如清单 1 所示
lexer grammar SqlExtrator; |
唯一需要注意的是词法的名称必须和文件名称一致,否则
Antlr 生成词法分析器时会报错,错误类似于 SqlExtrator.g contains grammar xxx; names must be identical
,这里统一使用 SqlExtrator。
在处理文本时,我们往往只关注与词法记号相匹配的文本,而忽略掉其他文本,这在 Antlr 中通过使用全局语法选项 filter 来达到这个目的。在词法文件中使用 filter=true 即表明忽略掉所有和词法记号不匹配的文本,如清单 2 所示。此外 filter 选项只适用于 Lexer 文件中,Parser 和 Tree Parser 中不能使用这一个选项。
options{filter=true;} |
后续的工作只是根据我们要抽取的文本的特点定义其对应的词法规则。在本例中我们要抽取的
SQL文本具有如下形式,如清单 3 所示
INSERT INTO SYSA.IF_EMPUSRRLA(USRNUM,EMPNUM) VALUES('U037508','275159') DB20000I The SQL command completed successfully. INSERT INTO SYSA.IF_USRSTNRLA(USRNUM,STNNUM) VALUES('U037710','00026') DB20000I The SQL command completed successfully. |
根据上述
SQL 的特点,分别定义了
ID, INT, WS, SqlFrg四类记号,这四类记号前均有关键字
fragment,如清单 4 所示。
fragment 用于指示
Antlr这些记号只是一个记号片段,其主要作用在于构造其他记号,被其他记号调用,
词法分析器 并不会把它们当成一个完整记号向外传递。四类记号的词法定义显而易见,不做过多的说明。
fragment SqlFrg :'INSERT INTO SYSA.' ID '(' ID ',' ID ')' WS 'VALUES' '(\'' ID '\',\'' INT '\')'; fragment WS : (' ' |'\t' |'\r' |'\n' )+ ; fragment INT: '0'..'9' + ; fragment ID : ('a'..'z' |'A'..'Z' |'_' ) ('a'..'z' |'A'..'Z' |'_' |'0'..'9' )*; |
抽取器的其他词法定义如清单
5 所示, 我们定义了一个
Sql 词法和一个 EOL 词法。EOL 定义了一个换行符;而 Sql 词法引用了之前定义的 SqlFrg,并在匹配 SqlFrg 之后在词法文件中嵌入了语义动作 (action),用于向控制台输出匹配的结果。所谓语义动作,在这里就表现为合法的 Java 代码。
Sql:SqlFrg {System.out.println($SqlFrg.text);} EOL ' DB20000I The SQL command completed successfully.' EOL; fragment EOL: '\n' | '\r' | '\r\n'; |
至此词法
SqlExtrator 的定义已经全部完成,在命令行运行 java org.antlr.Tool SqlExtrator.g,即得到我们的目标 SqlExtrator.java,SqlExtrator.java 代表了一个抽取器。调用 Antlr 提供的运行时 API,为抽取器编写以下测试代码,如清单 6 所示,至此完成了一个完整的抽取器的例子。
public static void main(String[] args) throws Exception { String filename = "errsql.out"; InputStream in = new FileInputStream(filename); ANTLRInputStream input = new ANTLRInputStream(in); SqlExtrator lexer = new SqlExtrator(input); CommonTokenStream tokens = new CommonTokenStream(lexer); for (Object obj : tokens.getTokens()) ; } |
测试代码中构造了一个 ANTLRInputStream 流,并将它作为抽取器 SqlExtrator 的参数生成抽取器的一个实例,最后用抽取器作为参数构造 CommonTokenStream 记号流,最后一个 for 循环语句用于从记号流中获取所有的记号,从而完成对整个文本的匹配。这里的 for 循环是一个空语句,因为在抽取器中,我们已经为匹配的文本加入了输出到控制台的动作。
转换器将在抽取器的基础上,做更多的工作,除了从文本中匹配预期目标外,转换器的输出直接将匹配的文本转换成另一种形式。
在前面抽取器的例子基础上,除了对执行成功的
INSERT SQL进行匹配外,我们需要将这些
INSERT语句转换成相对应的
DELETE语句。比如
INSERT INTO SYSA.IF_USRSTNRLA(USRNUM,STNNUM) VALUES('U037697','00007') |
将被转换成
DELETE FROM SYSA.IF_USRSTNRLA WHERE USRNUM='U037698' AND STNNUM='00007'; |
和抽取器的创建步骤类似,首先创建词法文件 SqlTranslator.g,指定文件的类型为 lexer,开启 filter=true 选项。之后开始定义各个词法记号的匹配规则,由于转换器所要匹配的目标和前面的抽取器类似,这里对各个词法定义不做过多说明,唯一区别与抽取器的是 SqlFrg 的定义,除了要匹配目标 INSERT SQL 外,需要根据 SQL 的语义做出相应的转换,这些转换主要通过在词法匹配的过程中嵌入语义动作 (action) 完成。如清单 7 所示。
fragment SqlFrg :'INSERT INTO SYSA.' t=ID '(' c1=ID ',' c2=ID ')' WS 'VALUES' \ '(\'' v1=ID '\',\'' v2=INT '\')' { StringBuffer buffer = new StringBuffer(); buffer.append("DELETE FROM SYSA."); buffer.append(t.getText()); buffer.append(" WHERE "); buffer.append(c1.getText()); buffer.append("='"); buffer.append(v1.getText()); buffer.append("' AND "); buffer.append(c2.getText()); buffer.append("='"); buffer.append(v2.getText()); buffer.append("';"); System.out.println(buffer.toString()); } ; |
在定义 SqlFrg 时引用的各个片段记号 (fragment tokens) 可以赋值为不同的变量,如清单 7 中的 t、c1、c2、v1、v2,这些变量在语义动作中可以直接使用,调用它们的 getText() 方法,即可得到各自在文本中对应的匹配内容。
完成
SqlTranslator 的定义后, 运行 java org.antlr.Tool SqlTranslator.g,由 Antlr 生成词法分析器 SqlTranslator.java,SqlTranslator.java 就是我们需要的转换器。调用 Antlr 提供的运行时 API,为抽取器编写以下测试代码,如清单 8 所示,至此完成了一个完整的转换器的例子。测试代码和抽取器几乎一致,唯一的区别是把抽取器换成了转换器。
public static void main(String[] args) throws Exception { String filename = "errsql.out"; InputStream in = new FileInputStream(filename); ANTLRInputStream input = new ANTLRInputStream(in); SqlTranslator lexer = new SqlTranslator(input); CommonTokenStream tokens = new CommonTokenStream(lexer); for (Object obj : tokens.getTokens()) ; } |
重写器是这样一类转换器,除了完成和特定目标的匹配转换外,其他未匹配的文本,原封不动的输出出来。除了对匹配项的转换外,重写器的输出文本和输入文本几乎一模一样。
这里介绍一个对 HTTP 输出 (HTTP Response) 进行重写的例子。在重写器中,我们将所有的 URL 匹配出来进行了转换,其他非 URL 文本原封不动的输出到客户端。对 URL 转换的目的是将所有的 URL 都转发到一个全局的服务端组件去处理,在我们的实际应用中这样做的目的是要在应用服务器集群内部做一个 HTTP 代理,便于对集群中各个服务器的运行情况进行监控,跨过负载均衡设备的请求分发。读者不必对这个细节做过多的分析,只需了解重写器的输入是 HTTP 响应,重写器的输出仍然是 HTTP 响应,只是对输入中的 URL 做了某些转换。
创建词法文件 UrlRewriter.g,根据 URL 的词法特性定义 URL 的匹配规则。如清单 9 所示。
fragment Url: SEPRATOR ('/'ID)+ ('.'Postfix)? ('?' ( options {greedy=false;} : . )* )? SEPRATOR; fragment SEPRATOR : '"' | '\''; fragment ID : ('a'..'z' |'A'..'Z' |'_' ) ('a'..'z' |'A'..'Z' |'_' |'0'..'9' |'-'| '.')* ; fragment Postfix: ('jsp'|'js'|'action'|'html'|'htm'|'css'); |
定义了 SEPRATOR、ID、Postfix 和 Url 四个词法片段,共同完成了对 URL 的定义。现在我们只需定义词法匹配 Url 并同时完成对 URL 的转换。转换的方式是将所有的 URL 都替换为一个新的固定的 URL,并将原 URL 作为这个 URL 的第一个参数,原 URL 其他参数成为新 URL 的后续参数。如清单 10 所示。
URL:u=Url { String url = u.getText(); url=url.replace("?", "&"); url=url.substring(0,1) + "/infrastructure/ProxyAction_forward.action?&url=" + url.substring(1); try{ out.write(url.getBytes());}catch(IOException e){e.printStackTrace();} } ; |
到此为止,我们所完成的和前一节的转换器没有太多的区别。完成重写器的关键在于后续的词法定义,匹配其他所有非 URL 文本,以及对非 URL 文本的输出。这里我们定义一个词法匹配任意文本,如清单 11 所示。
Other:c=AnyChar { try{ out.write($c.getText().getBytes()); } catch(IOException e){ e.printStackTrace();} }; fragment AnyChar : . ; |
非 URL 的词法定义通过 AnyChar 来体现,它的定义非常简单,用通配符 (.) 表示匹配任意字符。Other 调用了 AnyChar,并把匹配结果直接写入到输出流中。
重写器的词法定义基本结束了,有一个尤其需要注意的是,词法 URL 和 Other 的定义顺序非常重要。如果先定义 Other 后定义 URL,最终的输出和输入完全一致,没有做任何的转换,从而也没有达到重写的目的。只有先定义 URL 后定义 Other,才能达到我们的目的。出现这一现象的原因是,Antlr 中词法定义,包括语法定义是按照定义的先后顺序去做匹配的,优先定义的规则将优先匹配。Other 规则的定义能够匹配 URL 规则定义的所有内容,所以当 Other 先定义时,达不到我们预期的目标。
完成
UrlRewriter 的定义后, 运行 java org.antlr.Tool UrlRewriter.g,由 Antlr 生成词法分析器 UrlRewriter.java,UrlRewriter.java 就是我们需要的重写器。调用 Antlr 提供的运行时 API,为重写器编写以下测试代码,如清单 8 所示,至此完成了一个完整的重写器的例子。
public static void main(String[] args) throws Exception { InputStream in = new FileInputStream("index.html"); ANTLRInputStream input = new ANTLRInputStream(in, "UTF-8"); PrintStream out = System.out; UrlRewriter lexer = new UrlRewriter(input, out); CommonTokenStream tokens = new CommonTokenStream(lexer); for (Object obj : tokens.getTokens()); } |
和之前的测试代码相比,创建重写器时我们使用了新的构造函数,用于向重写器传递输出流,实际应用中输出流被赋值为 ServletOutputStream,例子中简单起见直接使用了控制台输出流。重写器的新构造函数是通过定义词法文件时,通过 member 关键字,直接定义在词法文件中的,如清单 13 所示。
@members { private java.io.OutputStream out; public UrlRewriter(CharStream input, OutputStream out){ this(input); this.out = out; } } |
文本处理是软件开发人员经常面临的工作之一,本文结合开源语言识别工具 Antlr,详细介绍了如何使用 Antlr 开发词法分析器,进而将词法分析器作为 Extractor、Translator 和 Rewriter,进行常规的文本处理。对正则表达式感兴趣的读者,可以使用正则表达式来完成这些工作,并把你做法和本文的做法进行对比,可以进一步发现两者的优劣。
学习