(三十四)深度解析领域特定语言(DSL)第六章——语法分析:第三个案例——优惠规则语法分析器
经过前期内容的铺垫,本章最后一个案例将以代码6-6(文法5-8)定义的优惠规则DSL为对象,剖析其语法分析器的实现方式。相较于二进制字符串分析与减法表达式计算案例,优惠规则DSL的语法规则更为复杂,需考量的要素更多。然而,学习过程本就遵循由浅入深的逻辑,唯有如此方能应对现实场景中的复杂需求。鉴于前文已对语法树相关知识进行了巩固阐释,本案例将进一步展示如何在语法分析过程中构建抽象语法树,以及如何基于该语法树组装语义模型。此外,此前案例未涉及语法分析错误处理逻辑,而该部分是语法分析程序的重要组成部分,因此本案例将补充这一内容。
首先阐释语义模型的设计。本例采用“领域模型直接作为语义模型”的实现方式,该模型基于DDD理论构建:Discount类型作为聚合根,承担业务逻辑的核心封装职责;Rate等类型作为值对象,负责基础数据的承载。读者可参考图 1.3了解具体模型结构,此处不再展开说明。
在展示语义模型代码前需特别说明:虽然本例直接复用领域模型作为语义模型,但此方式并非最优实践。对于优惠规则这类结构复杂的聚合,其构造逻辑通常较为复杂,业界常规做法是为其设计独立的工厂对象,将构造逻辑封装其中——工厂以用户输入的DTO(数据传输对象)为输入,输出聚合根对象。因此,更合理的设计是将DTO作为语义模型的输入层,通过工厂完成优惠聚合的构建。但为简化案例实现,本例暂未引入该设计,读者在实际项目中需注意这一关键设计技巧。
回归语义模型定义本身,受限于篇幅,此处仅展示核心代码(语法分析器的实现细节为本章重点)。首先呈现优惠规则聚合根实体Discount的结构,如代码6-11所示:
代码6-11class Discount {Rate rate;ConditionGroup conditions;Discount(Rate rate, ConditionGroup conditions) {this.rate = rate;this.conditions = conditions;}...
}
值对象类型Rate表示优惠比例,聚合于Discount对象之中,其定义如代码6-12所示:
代码6-12class Rate {float rate;Rate(float rate) {this.rate = rate;}...
}
值对象类型ConditionGroup表示优惠条件组,是优惠条件对象的容器,同样作为Discount对象的字段而存在,其定义如代码6-13所示:
代码6-13class ConditionGroup {List<Condition> conditions;ConditionGroup(List<Condition> conditions) {this.conditions = conditions;}...
}
类型ConditionGroup内包含了一个用于表示优惠条件列表的字段conditions,其中的元素类型为值对象Condition,其定义如代码 6-14所示:
代码 6-14class Condition {String fieldName;Operator operator;String value;Condition(String fieldName, Operator operator, String value) {this.fieldName = fieldName;this.operator = operator;this.value = value;}...
}
关于语义模型的代码,笔者仅展示上述四组片段,足以满足后续使用需求。接下来将呈现优惠规则语法分析器的定义,如代码6-15所示。读者需重点关注语法分析的具体步骤,同时建议将其与前两个案例的代码进行对比,分析其独特性。与之前案例一致,词法分析器的实现可参考5.2.2节内容,此处不再重复说明。
代码6-15class DiscountParser {...Discount parse() {ASTBuilder builder = new ASTBuilder(this.lexer);AST tree = builder.build();//代码1Discount discount = this.buildDiscount(tree);//代码2return discount;}
}
parse()方法包含了两个主要步骤:代码1处,执行语法分析并构建语法树;代码2处,构建语义模型。为了简化实现,第一个步骤的工作由ASTBuilder类型的对象来代理;而后一个步骤则直接交由语法分析器自已去实现。当然,如果语义模型构造过程特别复杂的话也可考虑使用专用的对象,那样的设计更加优雅。
前文曾经说过,语法树的构建通常是在语法分析过程中完成的,所以ASTBuilder类同时也承担了语法分析的工作,让我们看一下它是如何定义的,如代码6-16所示。代码比较长,笔者分开对它们进行展示。首先展示的是它的构造函数及属性部分:
代码6-16class ASTBuilder {Lexer lexer;Token lookahead;ASTBuilder(Lexer lexer) {this.lexer = lexer;lookahead = this.lexer.scanNext();}
}
ASTBuilder类包含的信息和代码6-2中的BinaryStringParser类似,所以我们不再对其进行过多地解释。下面要展示的是语法分析相关的逻辑,不过在深入研究代码之前笔者强烈建议您再回看一下文法5-8,因为语法分析器的代码框架是与该文法严格对应的。与此同时,为了方便阅读,笔者还会以注释的方式将文法放到代码之中。代码6-17展示了ASTBuilder类的入口方法build()以及开始符号所对应的函数:
代码6-17AST build() {AST tree = new AST();this.s(tree);return tree;
}//'set' RATE 'where' CONDITIONS ';'
void s(AST tree) {this.set();this.rate(tree.root);this.where();this.conditions(tree.root);this.semicolon();
}
build()方法会首先构建一个语法树实例,并调用函数s()来执行语法分析逻辑并对语法树进行完善,返回值则是一棵已经构建好的语法树对象。如果读者觉得麻烦的话,其实也可以将build()与s()合二为一,不过我并不想让ASTBuilder外部的代码直接调用s()函数,因为该方法只是语法分析的入口,而ASTBuilder类的作用其实是构建语法树,请读者仔细体会二者的区别。
s()函数包含五个子调用,与文法符号S的右部元素一一对应。读者可能存在疑问:set和where作为终结符,为何需对应独立方法?递归下降语法分析方法通常仅要求为非终结符建立对应函数。此处需说明:此种实现方式仅为提升代码可读性,无其他设计意图。事实上,set()、where()两个方法均仅包含一行代码,完全可内联至s()函数中。鉴于前文提及这两个方法,以下展示其具体实现,如代码6-18所示:
代码6-18void where() {this.matchAndMove(TokenType.WHERE);
}void set() {this.matchAndMove(TokenType.SET);
}
关于递归下降语法分析方法的实现思路,需进一步补充说明:该分析方法的核心逻辑是为每个非终结符设计一个解析函数,语法分析过程本质上是函数的递归调用过程。需要明确的是,递归调用仅针对非终结符对应的函数。这表明在实现分析器时,可针对非终结符定义相应方法(如代码6-18所示),这些方法的主要作用是增强代码可读性。由于此类方法不会被递归调用,因此无需担忧其对主分析逻辑产生干扰。
回到代码上来。联系前面的案例,您会发现笔者数次调用了名为matchAndMove()的方法,它的核心逻辑很简单:判断向前看符号的类型是否为参数指定的类型,是的话就调用词法分析器来获取下一个词法单元,并让向前看符号指向它;否则就抛出ParseException类型的异常。虽然前文中展示过该方法的实现逻辑,但这次笔者要在其中加入更为详实的错误提示信息以方便DSL的调试,新版本的matchAndMove()方法如代码6-19所示:
代码6-19void matchAndMove(TokenType target) {if (this.lookahead.type == target || this.lookahead.type.isA(target)) {lookahead = this.lexer.scanNext();return;}this.interrupt(target);
}void interrupt(TokenType target) {int lineNo = this.lookahead.lineIndex;String lexeme = this.lookahead.lexeme;int columnNo = this.lookahead.colIndex - lexeme.length();String template = "syntax error, lineNo:%s, colNo:%s, expected:%s, actual:%s";String error = String.format(template, lineNo, columnNo, target.name, lexeme);throw new ParserException(error);
}
interrupt()方法用于组装语法分析错误的提示信息,包含了故障点所对应的词素及其在脚本中的行号、列号信息。之所以将这一段逻辑提取出来,是因为它也会被其他的分析逻辑使用到。友好的错误提示对于语法分析器而言非常重要。以代码6-19为例,当输入DSL为“set set”的时候,输出错误提示为“syntax error, lineNo:1, colNo:4, expected:rate, actual:set”。可能无法和Intellij IDEA对标,但用于问题诊断已经足够了。
接下来要展示的方法为rate(),用于解析优惠比例信息,如代码6-20所示:
代码6-20//RATE -> 'rate' '=' number
void rate(Node parent) {this.matchAndMove(TokenType.RATE);this.matchAndMove(TokenType.EQ);Node node = new RateNode(this.lookahead);this.matchAndMove(TokenType.NUM);parent.addChild(node);
}
对于rate的解析如果没有出错的话,就会建立一个RateNode类型的语法树节点并将其加入到父节点中(注:此时的父节点为语法树的根节点)。接下来我们再看一下针对条件组(CONDITIONS)的解析,也是逻辑最为复杂的一部分,如代码6-21所示:
代码6-21//CONDITIONS -> SPEC | SPEC 'and' CONDITIONS
void conditions(Node parent) {Node conditions = new Node(NodeType.CONDITIONS);this.spec(conditions); //代码1while (this.lookahead.type == TokenType.AND) { //代码2this.matchAndMove(TokenType.AND);this.spec(conditions);}if (this.lookahead.type == TokenType.EOF) {//代码3parent.addChild(conditions);return;}this.interrupt(TokenType.AND);
}
不知读者是否注意到了,笔者在处理CONDITIONS符号的时候,并不是使用的递归调用,而是以循环的方式(代码2处)来处理产生式CONDITIONS中重复出现的符号。虽然实现方式与文法定义不太一致,但却体现了代码实现的灵活性,以及理解解析器工作原理的重要性。值得注意的是,递归和循环在大多数情况下其实是可以相互转换的。换言之,您也可以使用递归的方式来实现和代码6-21同等的逻辑。具体如何选择,读者在实践中灵活把握即可。
让我们再观察一下conditions()方法的实现逻辑。按照既定需求,DSL代码中至少要包含一个优惠条件(SPEC)才可以。也就是说如果where后面没有内容的话,该DSL就是不正确的。所以笔者在“代码1”处首先调用spec()方法进行优惠条件语法的分析,之后再在“代码2处”通过循环的方式,对其他条件对象(如果存在的话)进行分析。“代码3处”,当向前看符号类型为EOF的时候,表示到读取到了DSL脚本的尾部,分析工作正常完成,此时只需将分析过程中构造的条件组类型的节点作为conditions()方法参数的子节点即可。不过,如果条件组的内容不完整的话,比如当代码为“where current_date >= "2024-11-01" and”的时候,也应该抛出异常来中断分析流程,所以我们又一次调用了interrupt()方法。
下一个要展示的方法为spec(),用于验证优惠条件部分的语法并构建对应的语法树节点,如代码6-22所示:
代码6-22//SPEC -> 'field' OPERATOR VALUE
void spec(Node parent) {Token field = this.lookahead;this.matchAndMove(TokenType.FIELD);Token operator = this.lookahead;this.matchAndMove(TokenType.OPERATOR);Token value = this.lookahead;if (value.type != TokenType.STRING) {throw new ParserException(field.lexeme);}this.matchAndMove(TokenType.STRING);Node spec = new SpecNode(field, operator, value);parent.addChild(spec);
}
最后要展示的方法为semicolon(),用于验证分号,如代码6-23所示:
代码6-23void semicolon() {this.matchAndMove(TokenType.SEMICOLON);if (this.lookahead.type != TokenType.EOF) {this.interrupt(TokenType.EOF);}
}
至此,我们已经完成了语法分析逻辑以及抽象语法树构建相关代码的展示,总体来看还是比较简单的。那么最终的语法树长什么样呢?请参看图 6.8。
对于图 6.8所示的语法树结构,也许会与读者的理解不太一致,问题很可能出现在如下几点上面:
- 缺少S节点。S节点代表了整个语句的结构,缺少它的话可能无法完整地表达语法规则。不过对于当前案例而言,我们只会关注rate和conditions这两个部分中的信息,所以即使缺少S节点也不会影响后续的处理。
- spec节点中包含的三个值field、operator和value关非独立的节点。理论上来说,spec是一个独立的程序构造,我们应该将其视为一个树内结点才可以。但图 6.8所反映的,却仅仅是一个叶子节点。很明显,语法树中的叶子节点并不代表程序构造,而是数据。之所以采用这样的设计,是因为上述三个值已经无法再进行拆分,内嵌到spec节点中作为数据的话反而会让语法树更加简洁,同时也避免了创建额外节点所需要的花销。况且,实例化语义模型Condition的时候,其所需要的三个数据都在spec节点中,一次读取即可,还能避免遍历语法树的花销,又何乐而不为呢?
综上所述,语法树的具体结构设计完全取决于实际需求与后续处理逻辑,不存在绝对的正误标准。换言之,读者需重点考量的是所构建的语法树能否为后续环节提供便利。以当前案例为例,其后续需求是通过遍历语法树完成语义模型的创建。图 6.8所示的结构既保持了足够的简洁性,又能满足这一功能性需求。在此情形下,无需为追求理论层面的完整性而耗费额外成本修改代码。
接下来再回到类DiscountParser的定义,看一下如何根据语法树来构建语义模型。如代码6-24所示:
代码6-24Discount buildDiscount(AST tree) {Node rateNode = tree.queryFirstNode(NodeType.RATE);if (rateNode == null) {throw new ParserException("no rate data");}RateNode current = (RateNode)rateNode;Rate rate = new Rate(Float.parseFloat(current.rate.lexeme));Node conditionsNode = tree.queryFirstNode(NodeType.CONDITIONS);if (conditionsNode == null || CollUtils.isEmpty(conditionsNode.children)) {throw new ParserException("no condition data");}List<Condition> conditions = new ArrayList<>();for (Node conditionNode : conditionsNode.children) {if (!(conditionNode instanceof SpecNode)) {continue;}SpecNode specNode = (SpecNode)conditionNode;String field = specNode.field.lexeme;String operator = specNode.operator.lexeme;String value = specNode.value.lexeme;Condition condition = new Condition(field, new Operator(operator), value);conditions.add(condition);}ConditionGroup group = new ConditionGroup(conditions);return new Discount(rate, group);
}
使用语法树的显著优势在于能够随时通过遍历获取所需信息。在某些情况下,引入符号表也可解决部分问题。对于一些简单的DSL,符号表确实可替代语法树。然而,读者务必正确认识语法树的作用,它用于表达程序的语法结构,通过树形结构展现语法关系。以图 6.8为例,从图中可知一个优惠规则由优惠比例和多个优惠条件构成,而符号表难以清晰表达这种关系。此外,语法树还能清晰呈现程序的控制流程,并为中间代码生成、代码优化等环节提供必要信息,这些都是符号表无法实现的。实际上,语法树的重要性在于它维护了程序代码的语法结构和部分语义上下文,使编译器或解释器能够理解程序逻辑。
至此,我们已展示了优惠规则DSL语法分析器的全部代码,但还有两项内容尚未说明:一是回溯的处理;二是前面内容中遗留的类TokenBuffer(即词法分析器RegexBasedLexer的输出)未作介绍。这是因为上述三个案例采用的是递归下降语法分析以及LL(1)文法,无需考虑回溯处理。当然,并非绝对不能支持回溯,只是让递归下降语法分析支持回溯可能会引发性能问题,这一点需留意。尽管如此,笔者仍会在下一章引入新案例,展示如何实现回溯,同时也会对类TokenBuffer的代码进行说明。
上一章 下一章