JavaCC 用于支持终端用户对 DB2 UDB 数据库编制简单
级别: 初级
JoAnn P. Brereton , 高级软件工程师,IBM
2004 年 5 月 01 日
JavaCC 是一个功能极其强大的‘编译器的编译器’工具,可用于编制上下文无关的语法。本文演示了如何将 JavaCC 用于支持终端用户对 DB2 UDB 数据库编制简单的布尔查询。
JavaCC 简介
许多基于 Web 的项目都包含即席(ad-hoc)查询系统以允许终端用户搜索信息。因此,终端用户会需要某种语言来表达他们所希望搜索的内容。以前,用户查询语言的定义极其简单。如果终端用户满足于使用与最典型的 Google 搜索一般简单的语言,那么 Java 的 StringTokenizer 对于解析任务就绰绰有余了。然而,如果用户希望有一种更健壮的语言,比如要添加括号和“AND”/“OR”逻辑,那么我们很快就会发现我们需要更强大的工具。我们需要一种方法,用以首先定义用户将要使用的语言,然后用该定义解析相应的条目并且对各种后端数据库制定正确的查询。
这就是工具 JavaCC 出现的原因。JavaCC 代表“Java® Compiler Compiler”,是对 YACC(“Yet Another Compiler Compiler”)的继承(YACC 是 AT&T 为了构建 C 和其他高级语言解析器而开发的一个基于 C 的工具)。YACC 和其伙伴词法记号赋予器(tokenizer)——“Lex”——接收由常用的巴科斯-诺尔范式(Backus-Nauer form,又称 Bacchus Normal Form,BNF)形式的语言定义的输入,并生成一个“C”程序,用以解析该语言的输入以及执行其中的功能。JavaCC 与 YACC 一样,是为加快语言解析器逻辑的开发过程而设计的。但是,YACC 生成 C 代码,而 JavaCC 呢,正如您想像的那样,JavaCC 生成的是 Java 代码。
JavaCC 的历史极具传奇色彩。它起源于 Sun 公司的“Jack”。Jack 后来辗转了几家拥有者,比如著名的 Metamata 和 WebGain,最后变成了 JavaCC,然后又回到了 Sun。Sun 公司最后在 BSD 的许可下将它作为开放源代码的代码发布。JavaCC 目前的 Web 主页是 http://javacc.net.java.net。
JavaCC 的长处在于它的简单性和可扩展性。要编译由 JavaCC 生成的 Java 代码,无需任何外部 JAR 文件或目录。仅仅用基本的 Java 1.2 版编译器就可以进行编译。而该语言的布局也使得它易于添加产生式规则和行为。该 Web 站点甚至描述了如何编制异常以便给出用户合适的语法提示。
|
问题定义
让我们假设您有一位客户在一个出租视频节目的商店里,该商店拥有一个简单的电影数据库。该数据库包含表 MOVIES、ACTORS 和 KEYWORDS。MOVIES 表列举他商店中每部电影的相关数据,即如每部电影的名称和导演等内容。ACTORS 表列举每部电影中的演员姓名。而 KEYWORDS 表则列举描述电影的词语,例如“action”、“drama”、“adventure”等等。
客户希望能够对该数据库发出稍微复杂的查询。例如,他想输入以下形式的查询
|
并且希望返回由 Christopher Reeve 主演的 Superman 系列电影。他还希望像下面这样用括号来说明求值次序以区分查询
|
这样可能返回不是由 Christopher Reeve 主演的电影
|
这样则总会返回 Christopher Reeve 主演的电影。
|
解决方案
对于该任务,您将分两个阶段来定义解决方案。在第 1 阶段中,您将用 JavaCC 定义语言,要确保能够正确解析终端用户的查询。在第 2 阶段中,您将向 JavaCC 代码添加行为以产生 DB2® SQL 代码,从而确保返回正确的电影来响应终端用户的查询。
阶段 1 - 定义用户的查询语言
将在名为 UQLParser.jj 的文件里定义该语言。该文件将被 JavaCC 工具编译成为一组 .java 类型的 Java 类文件。要在 JJ 文件中定义语言,您需要做以下 5 件事:
- 定义解析环境
- 定义“空白”
- 定义“标记(token)”
- 按照标记定义语言本身的语法
- 定义每个解析阶段中将发生的行为
您可以通过所展示的代码段定义自己的 UQLParser.jj 文件,也可以通过本文的相关代码进行效仿。对于步骤 1 到 4,在 JavaCCPaper/stage1/src 中使用 UQLParser.jj 的副本。而步骤 5 则在 JavaCCPaper/stage2/src 中进行。样本数据库的 DDL 可以在 JavaCCPaper/moviedb.sql 中找到。如果使用相同的用户标识创建数据库和运行解析器,该实例将运行得最好。Ant 文件(build.xml)可用于加快编译过程。
步骤 1. 定义解析环境
JavaCC .jj 文件通过执行 JavaCC 将被转换为 .java 文件。JavaCC 将获取 .jj 文件里 PARSER_BEGIN 与 PARSER_END 的中间部分并将之复制到 Java 结果文件中。作为解析器设计者,您可以将解析前后所有与解析器有关的动作置于该文件中。您还可以在其中将 Java 代码链接到步骤 4 和 5 中将会定义的解析器动作上。
在以下所示的实例中,解析器相对比较简单。构造函数 UQLParser 接收一个字符串输入,通过 Java 的 java.io.StringReader 类将其读入,然后调用另一个不可见的构造函数将 StringReader 强制转换为 Reader。这里定义的惟一其他方法就是 static main 方法,该方法将在调用构造函数之后再调用迄今还未定义的名为 parse() 的方法。
正如您可能已猜到的,JavaCC 已经提供了一个 Java Reader 类的构造函数。而我们添加了基于字符串的构造函数,以便易于使用和测试。
清单 1. 解析器的 Java 环境
|
步骤 2. 定义空白
在该语言中,您希望将空格、跳格、回车和换行作为分隔符处理,而不是将其忽略。这些字符都被称为 空白。在 JavaCC 中,我们在 SKIP 区域中定义这些字符,如清单 2 中所示。
清单 2. 在 SKIP 区域中定义空白
|
步骤 3. 定义标记
接下来,您将定义该语言所识别的标记。 标记是将对解析程序有意义的解析字符串的最小单位。扫描输入字符串以及判断是何标记的过程称作 记号赋予器(tokenizer)。在以下查询中,
actor = "Christopher Reeve"
其标记为
- actor
- =
- "Christopher Reeve"
在您的语言中,您要将 actor 和等号(=)作为该语言中的保留标记,尽管字 if和 instanceof在 Java 语言中都是带有特殊意义的保留标记。通过保留字和其他特殊标记,程序员希望解析器会逐字地识别这些字并为其指派特定的意义。如果您正在保留这些字,请继续进行下去并且保留不等号(<>)和左右括号。还要保留名称、导演和关键字以表示用于用户搜索的特定字段。
要定义所有这些内容,请使用 JavaCC 的 TOKEN 指令。每个标记的定义都用尖括号(< 和 >)括起来。在冒号(:)的左边给出标记的名称,并在右边给出正则表达式。正则表达式是定义将要匹配的文本部分的方式。在其最简单的形式中,正则表达式可以匹配精确的字符序列。使用下列代码来定义六个匹配精确字的标记和四个匹配符号的标记。当分析器看到任何一个字时,将会用符号 AND、OR、TITLE、ACTOR、DIRECTOR 或 KEYWORD 来加以匹配。在匹配符号之后,解析器将相应地返回 LPAREN、RPAREN、EQUALS 或 NOTEQUAL。清单 3 展示了 JavaCC 保留标记的定义。
清单 3. 定义保留标记
|
对于像“Christopher Reeve”一样的字符串,您或许无法在我们的语言中将所有的演员姓名存储为保留字。但是,您可以通过使用正则表达式定义的字符模式识别 STRING 或 QUOTED_STRING 类型的标记。 正则表达式是定义匹配模式的字符串。定义匹配所有字符串或引用字符串的正则表达式要比定义精确的字匹配更具技巧性。
您将定义一个由一个或更多字符系列构成的字符串(STRING),其中的有效字符为大小写的 A 到 Z 以及数字 0 到 9。为了简单起见,不考虑定影明星或电影名称的重音字符或其他不规则体。您可以按下列方式将该模式写为一个正则表达式。
|
加号表示围在括号中的模式(从 A 到 Z、a 到 z 或 0 到 9 中的任何字符)可依次出现一次或多次。在 JavaCC 中,您还可以用星号(*)来表示模式的零次或多次出现以及用问号(?)来表示 0 或 1 此重复。
QUOTED_STRING 就更具技巧性了。如果您定义一个以引号开头,以引号结尾并在其中包含任何其他字符的字符串,那么该字符串就是一个 QUOTED_STRING。其正则表达式为 "\\"" (~["\\""])+ "\\""
,这肯定有些眼花缭乱的。简单一点理解就是,由于引用字符本身对于 JavaCC 是有意义的,因此我们需要将对它的引用转换为对我们的语言而非 JavaCC 是有意义的。为了转换该引用,我们在它之前使用了一个反斜杠。字符颚化符号(~)意味着并非是针对 JavaCC 记号赋予器的。 (~["\\""])+
是对于一个或更多非引用字符的速写。合在一起, "\\"" (~["\\""])+ "\\""
就意味着“一个引用加上一个或更多非引用再加上一个引用”。
您必须在保留字规则之后添加 STRING 和 QUOTED_STRING 的记号赋予器规则。保持该次序是极其重要的,因为记号赋予器规则在文件中出现的次序就是应用标记规则的次序。您需要确定“title”是被视作保留字而非字符串的。清单 4 中显示了 STRING 和 QUOTED_STRING 标记的完整定义。
清单 4. 定义 STRING 和 QUOTED_STRING
|
步骤 4. 按照标记定义语言
既然已经定义了标记,那么现在是时候按照标记来定义解析规则了。用户输入表达式形式的查询。一个 表达式就是一系列由 布尔运算符and或 or连接的一个或更多查询项。
为了表达这一点,我们需要编写一个解析规则,也称作 产生式。将清单 5 中的产生式写入 JavaCC UQLParser.JJ 文件。
清单 5. expression() 产生式
|
当对 .jj 文件运行 Javacc 时,产生式将被转换为方法。所有的 JavaCC 产生式方法的返回都必须为空。第一组花括号包含产生式方法所需的所有声明。这里暂时为空。第二组花括号包含以 JavaCC 理解的方式所写的产生式规则。请注意先前所定义的 AND 和 OR 标记的用法。还请注意,queryTerm() 是作为方法调用而写的。实际上,queryTerm() 是另一个产生式方法。
现在,就让我们定义 queryTerm() 产生式。queryTerm() 要么是一个单独的判别式(例如 title="The Matrix"),要么是一个由括号括起来的表达式。JavaCC 中通过 expression() 递归地定义了 queryTerm(),这使得您可以通过清单 6 中所示的代码简明地总结该语言。
清单 6. JavaCC 中的 queryTerm() 产生式方法(UQLParser.jj)
|
这就是我们所需的所有规则。两个产生式中总结了全部的语言解析器。
将 JavaCC 当作测试驱动器
在这个时候,您应该已经有了一个有效的 JavaCC 文件。在进行到步骤 5 之前,您可以编译并“运行”该程序以查看您的解析器运作是否正确。
随本文一起提供的 ZIP 文件应包含了阶段 1 的 JavaCC 示例文件 UQLParser.jj。将整个 ZIP 文件解压到一个空目录下。要编译 stage1/UQLParser.jj,您首先需要下载 JavaCC 并根据 JavaCC Web 页 上的指导进行安装。为了简单起见,请务必将 Javacc.bat 的执行路径填入 PATH 环境变量中。编译十分容易,将目录更改为卸载 UQLParser.jj 的位置并输入下列命令。
javacc "debug_parser " output_directory=.\\com\\demo\\stage1 UQLParser.jj
如果您愿意,也可以使用附带的 Ant 文件 build.xml。您必须将上方的属性文件调整为指向 JavaCC 安装。在您第一次运行它时,JavaCC 将生成如清单 7 中所示的消息。
清单 7. UQLParser.jj 的编译输出
|
除了已提到的四个文件,JavaCC 还将产生 UQLParser.java、UQLParserConstants.java 和 UQLParserTokenManager.java。所有这些文件都被写入了 com\\demo\\stage1 目录。此时起,您就能够编译这些文件且无需向默认的运行时类路径做任何添加了。如果 JavaCC 步骤运行成功,Ant 文件的默认目标将自动执行 Java 编译。如果没有成功,您可以用以下命令编译顶层目录(JavaCCPaper/stage1)的文件:
javac "d bin src\\com\\demo\\stage1\\*.java
Java 类文件一旦就位,您就可以通过向您所定义的 "main" java 方法输入下列用户示例查询来测试新的解析器了。如果您正使用同一代码,请从 JavaCCPaper/stage1 目录开始并在命令行中输入下列命令。
java "cp bin com.demo.stage1.UQLParser "actor = \\"Tom Cruise\\""
我们在 JavaCC 步骤中所使用的 -debug_parser
选项确保将输出下列有用的跟踪消息,以显示用户查询是如何被解析的。其输出应该如清单 8 中所示。
|
要测试带括号表达式的递归路径,请尝试以下测试。
java "cp bin com.demo.stage1.UQLParser "(actor=\\"Tom Cruise\\" or actor=\\"Kelly McGillis\\") and keyword=drama"
这将产生清单 9 中的输出。
清单 9. 查询 (actor="Tom Cruise" or actor="Kelly McGillis") and keyword=drama 的 UQL1Parser 输出
|
该输出十分有用,因为它演示了通过 queryTerm 和 expression 的递归。queryTerm 的第一个实例实际上就是一个由两个 queryTerm 组成的表达式。 图 1展示了该解析路径的图形视图。
图 1. 解析用户查询的图形表示
如果您对于 JavaCC 生成怎样的 Java 代码感到好奇,就想尽方法看一看(但不要试图进行任何更改!)。您将找到以下内容。
UQLParser.java —— 在这一文件中,您将找到您在 UQLParser.jj 文件里的 PARSER_BEGIN 和 PARSER_END 之间所放置的代码。您还会发现 JJ 产生式方法已经被改变为 Java 方法了。
例如,expression() 规则已将被扩展为清单 10 中的代码了。
清单 10. UQLParser.java
|
它与您最初在其中写入 queryTerm()、AND 和 OR 所呈现的样子有些相像,但其余的就是 JavaCC 所添加的解析细节。
UQLParserConstants.java —— 该文件易于得到。您所定义的所有标记都在这里。JavaCC 只不过将它们记录在数组中并提供整型常数来引用该数组。清单 11 展示了 UQLParserConstants.java 的内容。
清单 11. UQLParserConstants.java
|
UQLParserTokenManager.java —— 这是一个嵌接文件。JavaCC 将该类用作记号赋予器。这是一段确定标记为什么的代码。这里让人感兴趣的首要例程是 GetNextToken。解析器产生式方法将用该例程来判断采用哪条路经。
SimpleCharStream.java —— UQLParserTokenManager 用该文件来表示将被解析的字符的 ASCII 流。
Token.java —— 其中提供了 Token 类来表示标记本身。本文的下一部分将演示 Token 类的用途。
TokenMgrError.java and ParseException—— 这些类分别表示记号赋予器和分析器中的异常状况。
阶段 2 - 给 JavaCC 代码添加行为
注意:关于教程的这一部分,请查阅代码的 stage2 子目录。从这里开始所展示的 JJ 文件就是 JavaCCPaper/stage2/UQLParser.jj。为了运行示例 SQL 查询,您还应该通过附带的 moviedb.sql 文件创建 MOVIEDB 数据库。请通过 db2 -tf moviedb.sql
执行 DDL。
既然已经进行了解析,我们就需要对单个表达式采取行动了。这一阶段的目标是生成可运行的 DB2 SQL 查询并将返回用户期望的结果。
该过程应该首先从一个包含空白处的模板 SELECT,解析器将填写此空白处。 清单 12中显示了 SELECT 模板。解析器所生成的查询或许不像人类 DBA 所写的那样为最佳的,但是它将返回终端用户所期望的正确结果。
清单 12. SELECT 语句
|
解析器填入的内容取决于它通过记号赋予器所采用的路径。例如,如果用户从上面输入查询:
(actor="Tom Cruise" or actor="Kelly McGillis") and keyword=drama"
那么解析器将根据 图 2 在 SQL 查询的遗漏部分中发出文本。它将回送括号,输入终端 queryTerm 的子查询并且用 INTERSECT 代替 AND 以及 UNION 代替 OR。
图 2. SQL 查询的解析器输出
这将确保 SQL 查询发出
(actor = "Tom Cruise" or actor = "Kelly McGillis") and keyword=drama
将如清单 13 中所示。
清单 13. 完整的 SELECT 语句
|
正如前面提到的,很可能存在更快、更优的方法来编写这个特定的查询,但是此 SQL 将生成正确的结果。DB2 优化器通常可以解决性能方面的不足。
因此,需要向 JavaCC 源代码添加什么来生成该查询呢?您必须添加动作和其他支持所定义语法的代码。 动作是指为响应特定产生式而执行的 Java 代码。在添加动作之前,首先要添加将向调用程序返回完整 SQL 的方法。为此,要在 JavaCC 文件的最上部分添加一个名为 getSQL()
的方法。您还应该给解析器的内部成员添加 private StringBuffer sqlSB。该变量将表示任何解析阶段的当前 SQL 字符串。 清单 14 展示了 UQLParser.jj 的 PARSER_BEGIN/PARSER_END 部分。最后,在 main() 测试方法中添加一些代码,用以输出和执行所生成的 SQL 查询。
|
现在,填入由解析器来完成的动作。我们将先从一个容易的开始。在解析一个表达式的时候,解析器每当解析“AND”时就发出字“INTERSECT”,而解析“OR”时就发出“UNION”。为此,要在 expression 产生式中的 <AND> 和 <OR> 标记之后插入自包含的 Java 代码块。该代码应向 sqlSB StringBuffer 追加 INTERSECT 或 UNION。清单 15 中显示了这些代码。
清单 15. expression 所执行的动作
|
|
产生式内需要执行多个动作。这些任务如下:
- 将搜索名称映射到它们各自的 DB2 表和列上
- 保存比较器(comparator)标记
- 将比较字(comparand)转换为 DB2 可以理解的形式,例如,除去 QUOTED_STRING 标记的双引号
- 向 sqlSB 发送合适的子查询
- 对于递归表达式的情况,随之发出括号。
对于所有这些任务,您将需要一些局部变量。如清单 16 中所示,这些变量是在产生式中第一对花括号之间定义的。
清单 16. queryTerm() 的局部变量
|
第一个任务可用清单 17 中的代码来完成。设置与所遇标记关联的合适的 DB2 表和列。
清单 17. 将搜索名称映射到 DB2
|
第二个任务可用清单 18 中的代码来完成。保存标记以便可用于 SQL 缓冲区。
清单 18. 保存比较器
|
第三个任务可用清单 19 中的代码来完成。相应地设置比较字的值,如果有必要,就从 QUOTED_STRING 标记中除去双引号。
清单 19. 准备比较字
|
第四个任务可用清单 20 中的代码来完成。完整的查询项被追加到了 sql 缓冲区。
清单 20. 编写 SQL 表达式
|
最后对于递归表达式的情况,当解析器在表达式递归中看到括号时,就应该简单地进行回送,如清单 21 中所示。
清单 21. 回送括号
|
清单 22 展示了完整的 queryTerm() 产生式。
清单 22. 完整的 queryTerms() 产生式
|
像前面一样编译并运行 UQLParser.jj。访问 UQLParser.java 并注意产生式规则是如何被整齐地插入生成代码中的。清单 23 中展示了一个 expression() 方法的扩展实例。请注意 jj_consume_token 调用之后的代码。
清单 23. UQLParser.java 中的 expression() 方法
|
按前面一样运行该代码。您必须在 CLASSPATH 中包含 db2java.zip。这次,当您运行
java 'cp bin;c:/sqllib/db2java.zip com.demo.stage2.UQLParser "(actor=\\"Tom Cruise\\" or actor=\\"Kelly McGillis\\") and keyword=drama"
时,它将生成清单 24 中的输出。
清单 24. 查询 (actor="Tom Cruise" or actor="Kelly McGillis") and keyword=drama 的 UQL2Parser 输出
|
尝试更多使用您的解析器的查询。试一试使用 NOTEQUAL 标记的查询,比如 actor<>"Harrison Ford"。尝试一些像“title=”一样的非法查询,看看将发生什么情况。通过非常少的几行 JavaCC 代码,您就生成了非常有效的终端用户查询语言。
|
最后要考虑的问题
JavaCC 除了提供解析器生成器之外,还提供 JJDOC 工具,用以编制巴科斯-诺尔范式(Bacchus-Nauer Form)表示的语法。JJDOC 可以使您易于向终端用户提供他们所使用语言的描述。例如,在附带代码中提供的 ant 文件有一个“bnfdoc”目标。
JavaCC 还提供名为 JJTree 的工具。该工具提供树和节点类,使您易于将代码分成单离的解析和动作类。继续该实例,您可以考虑为查询编写一个简单优化器,以消除不必要的 INTERSECT 和 UNION。您可以通过访问解析树的节点以及合并相似的相邻节点(例如,actor="Tom Cruise" 和 actor="Kelly McGillis")来完成该工作。
JavaCC 拥有一个丰富的语法库。您在自己编写解析器之前,一定要查看 JavaCC 的 examples 目录,以便可能获取已构建好的解决方案。
请务必阅读 JavaCC Web 页上的 Frequently Asked Questions 并访问 comp.compilers.tools.javacc 上的 javacc 新闻组以便更好地理解 JavaCC 的所有功能和特性。
|
结束语
JavaCC 是一个健壮的工具,可用于定义语法并且易于在 Java 商业应用程序中包含该语法的解析和异常处理。通过本文,我们说明了 JavaCC 可用于为数据库系统的终端用户提供一种功能强大却很简单的查询语言。
|
参考资料
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
- 可在 JavaCC 网站上找到 JavaCC 程序包。
- 在 Johnson,Stephen C(AT&T Bell Laboratories,Murray Hill,New Jersey 07974)所写论文 YACC: Yet Another Compiler Compiler中第一次描述了这个可广泛获得的“编译器的编译器”。
- 一篇由 Oliver Ensileng 撰写的 JavaWorld 的好文章 Build your own Languages with JavaCC。
- Jocelyn Paine 的 Introduction to JJTree。
- 可在 Wikipedia 中找到对于上下文无关语法的很好解释。
|
关于作者
JoAnn Brereton 是 IBM 的 Software Group,Federal Software Services 的一位高级软件工程师。她已为 IBM 编程近 20 年了。她最近的项目包括为 CBS、Warner Brothers 和 CNN 电视网构建视档案搜索引擎。 |