使用 Antlr 处理文本

来源:互联网 发布:禁止来宾用户安装软件 编辑:程序博客网 时间:2024/05/21 17:22
高 尚 (gaoshang1999@163.com), 软件工程师, 中国农业银行软件开发中心

简介: Antlr 是一个基于 Java 开发的功能强大的语言识别工具,其主要功能原本是用于识别编程语言,但是当我们遇到一些常规的文本处理工作时,使用 Antlr 做这些工作可能比单纯的使用正则表达式高效、有趣的多。本文将和您一起探讨如何使用 Antlr 完成抽取(Extract)、转换(Translate)和重写(Rewrite)这 3 类常见的文本处理工作。

发布日期: 2011 年 8 月 08 日 
级别: 高级 
访问情况 : 29416 次浏览 
评论:  (查看 | 添加评论 - 登录)

平均分 4 星 共 9 个评分 平均分 (9个评分)
为本文评分

Antlr 和文本处理

在我的另一篇文章《 使用 Antlr 开发领域语言》中对 Antlr 是什么、它能做什么以及如何安装使用都做了说明。今天我们的主要工作是关注如何使用 Antlr 处理文本。

Antlr 是一个语言识别工具,主要用于处理计算机编程语言。用户根据编程语言处理的特点,自定义的上下文无关文法。Antlr 根据这些文法,自动生成词法分析器(Lexer)、语法分析器(Parser)和树分析器 (Tree Parser)。其中词法分析器的输入是字符流,输出是记号流。语法分析器把记号流作为输入,输出抽象语法树。树分析器遍历整个语法树,输出目标代码。3 类分析器的设计使用了管道过滤器风格,前一个分析器的输出是后一个的输入,其整体的工作流程如图 1 所示。

图 1. Antlr 工作流程

在使用 Antlr 处理文本时, 我们主要使用词法分析器(Lexer)来完成工作。在处理文本时, 文本本身是一个字符流。Antlr 的词法分析器是一个强大的文本处理工具,它能够把字符流依据词法规则分解成不同的记号,形成记号流,供后续处理。

文本处理工作在我们的日常工作中非常普遍,最常见的是日志分析,比如对应用服务器控制台输出日志进行分析,对应用自身的系统日志进行分析等等;其他一些时候我们甚至会把配置文件、源程序文件、XML 文件、HTML 文件等当成文本进行处理

词法分析器与正则表达式

正则表达式

正则表达式被认为是文本处理的首选工具,当我们使用正则表示式时,首先定义一个正则表达式,然后和预期文本进行匹配,最终再按照正则表示式中的分组,逐一获取相匹配的数据,然后再进行下一步的处理(输出、替换等等)。在进行比较复杂一些的问题时,使用正则表达式,整体处理过程比较漫长,有时为了处理一个问题,写出的正则表达式晦涩难懂,很不便于维护。

词法分析器

在 Antlr 中词法分析器使用了和语法分析器相同的技术来构造,对词法记号 Token 的匹配使用了递归下降的策略,使得词法分析器具有处理上下文无关文法的能力,而正则表达式所能处理的文法只包含正则文法(线性文法),因此词法分析器可以处理很多正则表达式难以处理的问题,比如左括号和右括号的成对匹配等。

此外,在 Antlr 中词法分析器所要匹配的词法记号,通过相互引用的方式进行嵌套和递归定义,比正则表达的书写更直观,更加便于维护。

总的来说,使用 Antlr 词法分析器处理文本和正则表达式相比,处理能力更强大,便于开发和测试,在本文的后续部分中,我们一起来看一下如何使用 Antlr 词法分析器完成抽取、转换、重写这三类文本处理工作。

抽取器 Extractor

介绍

一个抽取器负责从一个文本中把满足特定特征的文本提取出来。一个常见的例子是网络爬虫,爬虫程序需要从一个 HTML 文本中抽取出 URL 用于后续的网络遍历。

一个例子

这里演示例子的是抽取一个数据库 SQL 执行日志,把满足某些特征的 SQL 文本抽取出来。SQL 日志的详细信息请参考附件中的 sql.out 文件。SQL 日志中记载了一批 INSERT 语句的执行结果。其中一些成功执行,而另一些由于主键冲突的原因执行失败了。我们需要开发一个抽取器,用于从日志中把所有执行成功的 INSERT 语句提取出来。

使用 Antlr 开发抽取器的第一步是,建立词法文件 SqlExtrator.g。 在文件的第一行使用两个 Antlr 的关键字 lexer grammar 声明这是一个词法文件,如清单 1 所示


清单 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 中不能使用这一个选项。


清单 2. 指定 filter 选项
  options{filter=true;} 

后续的工作只是根据我们要抽取的文本的特点定义其对应的词法规则。在本例中我们要抽取的SQL文本具有如下形式,如清单 3 所示


清单 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这些记号只是一个记号片段,其主要作用在于构造其他记号,被其他记号调用,词法分析器 并不会把它们当成一个完整记号向外传递。四类记号的词法定义显而易见,不做过多的说明。


清单 4. 抽取器辅助词法定义
  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 代码。


清单 5. 抽取器主要词法定义
  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 所示,至此完成了一个完整的抽取器的例子。


清单 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 循环是一个空语句,因为在抽取器中,我们已经为匹配的文本加入了输出到控制台的动作。

转换器 Translator

介绍

转换器将在抽取器的基础上,做更多的工作,除了从文本中匹配预期目标外,转换器的输出直接将匹配的文本转换成另一种形式。

一个例子

在前面抽取器的例子基础上,除了对执行成功的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 所示。


清单 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 所示,至此完成了一个完整的转换器的例子。测试代码和抽取器几乎一致,唯一的区别是把抽取器换成了转换器。


清单 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())  ;  } 

重写器 Rewriter

介绍

重写器是这样一类转换器,除了完成和特定目标的匹配转换外,其他未匹配的文本,原封不动的输出出来。除了对匹配项的转换外,重写器的输出文本和输入文本几乎一模一样。

一个例子

这里介绍一个对 HTTP 输出 (HTTP Response) 进行重写的例子。在重写器中,我们将所有的 URL 匹配出来进行了转换,其他非 URL 文本原封不动的输出到客户端。对 URL 转换的目的是将所有的 URL 都转发到一个全局的服务端组件去处理,在我们的实际应用中这样做的目的是要在应用服务器集群内部做一个 HTTP 代理,便于对集群中各个服务器的运行情况进行监控,跨过负载均衡设备的请求分发。读者不必对这个细节做过多的分析,只需了解重写器的输入是 HTTP 响应,重写器的输出仍然是 HTTP 响应,只是对输入中的 URL 做了某些转换。

创建词法文件 UrlRewriter.g,根据 URL 的词法特性定义 URL 的匹配规则。如清单 9 所示。


清单 9. URL 的词法定义
 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 所示。


清单 10. URL 的转换
  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 所示。


清单 11. 非 URL 的匹配
  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 所示,至此完成了一个完整的重写器的例子。


清单 12. 重写器的测试代码
  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 所示。


清单 13. 为重写器指定输出流
  @members {  private java.io.OutputStream out;  public UrlRewriter(CharStream input, OutputStream out){   this(input);   this.out = out;  }  } 

结束语

文本处理是软件开发人员经常面临的工作之一,本文结合开源语言识别工具 Antlr,详细介绍了如何使用 Antlr 开发词法分析器,进而将词法分析器作为 Extractor、Translator 和 Rewriter,进行常规的文本处理。对正则表达式感兴趣的读者,可以使用正则表达式来完成这些工作,并把你做法和本文的做法进行对比,可以进一步发现两者的优劣。


参考资料

学习

  • 使用 Antlr 开发领域语言,我的另一篇关于 Antlr 的文章,全面介绍了 Antlr 在领域语言开发方面的各种技术。

  • Antlr 全球站点, 有关于 Anltr 的最全面的参考资料。

  • The Definitive ANTLR Reference (Building Domain-Specific Languages): Terence Parr 最新的 Antlr 著作。

  • Domain Specific Languages (Martin Fowler,Addison-Wesley,2010 年):Fowler 的新书。

  • developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。 

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。