当前位置: 首页 > news >正文

(四十三)深度解析领域特定语言(DSL)第七章——语法分析器组合子(Parser Combinators)

        组合子(Combinator)的概念源于数理逻辑与计算机科学领域,在函数式编程和组合逻辑(Combinatory Logic)中具有广泛应用。其本质是通过组合简单函数构建复杂函数,即通过逻辑单元的组合完成复杂任务。类比到语法分析领域,语法分析器组合子指将多个小型、简单的分析器进行组装,以实现复杂解析功能,且每个组合子仅处理一条基本语法规则。该方法的核心在于模块化与组合性:模块化强调将语法分析器拆解为独立运作的小型单元;组合性则指通过简单解析器的组合构建复杂解析器。相较于传统递归下降分析器,组合子方法进一步强化了模块化特性,具备代码简洁易读、可重用性高的显著优势。  

        在前面的文章中,递归下降语法分析算法是主要实现方式,该算法为每个非终结符创建对应的方法(函数)。语法分析器组合子的实现同样可基于此分析框架,其核心差异在于以对象而非方法表示非终结符,这一设计本质上体现了面向对象编程思想。为清晰阐释组合子模式的实践应用,本章将以全新的DSL案例——业务受理规则验证器为例,系统展示如何通过组合子模式实现语法分析器。

1、DSL脚本示例及业务说明

        笔者曾经在前面的文章中举过一个订购验证的案例,该DSL可用于配置云产品受理时所需遵循的业务约束,比如资源到期后不可以进行配置的变更、订购时用户不应处理冻结状态等。为方便阅读,笔者将DSL脚本示例复制到本章,如代码8-1所示:

代码8-1#受理规则名称定义
rulesresourceIsNotFreezed ResNotF; //资源不能是冻结状态resourceIsNotExpired ResNotE; //资源未过期accountIsNotFreezed AccNotF; //账户未冻结
end#受理类型定义
service_typesupgrade; //配置升级renew; //服务延时
end#规则绑定定义
bind_rulesupgrade {ResNotF, ResNotE, AccNotF};renew {AccNotF};
end

        rules代码段中的内容用于标识规则的完整名称和别名(一般是完整名称的简写),不可重复。service_types代码段指定了受理的类型信息,不可重复。bind_rules代码段则用于将受理类型信息和规则名称进行绑定,以确定某个受理类型所涉及的规则有哪些。绑定的信息分为两部分内容:受理的类型和受理规则名称列表。其中受理类型信息必须在service_types代码段中进行过定义且不可重复;规则列表中的内容则需要使用大括号({})括起来,且各元素之间必须通过逗号进行分隔,内容为规则名称的简写,必须在rules中进行过定义且不可重复。

        读者可将上述DSL视为一种配置信息,其作用与Spring框架中常见的*.yml类型配置文件并无本质差异。事实上,应用程序配置是DSL的核心应用场景之一。在日常开发中,当遇到业务配置相关需求时,尝试引入DSL或许能带来意外的惊喜与收获。回归正题,当使用DSL描述受理类型与受理规则的对应关系时,需重点解决以下两个问题:  

  1. DSL解析的实现。这是本章的核心内容,笔者将在后文提供具体的实现方案与代码示例。  
  2. DSL的应用逻辑。相关业务设定可参考图 8.1,该图清晰呈现了当前案例的业务场景与数据流向。

图 8.1 在业务受理场景中使用DSL翻译的结果执行业务规则验证

        图 8.1可知,整体业务流程可分为如下三步:

  1. 受理服务启动的时候对DSL脚本进行解析并构建对应的语义模型。
  2. 根据语义模型信息,调用规则模块所提供的接口将所有的受理规则实例化,并放到缓存中供后续步骤使用。这一过程中通常会涉及到反射的使用,即根据规则的完整名称如resourceIsNotFreezed在代码中找到对应的类并进行实例化。原则上,受理规则对象都应为单例模式,且应实现了某个特定的接口,比如图 7.1中的Validatable接口。只有这样才能避免并发问题,同时也可被规则处理框架所使用。
  3. 根据bind_rules内的配置信息,将规则对象按受理类型进行分组。

2、语义模型说明

        本章案例对应的语义模型如图 8.2所示。需要明确的是,尽管受理流程中会涉及受理单、验证规则等领域模型,但这些并非语义模型的组成部分。正如读者所知,语义模型本质上是DSL的映射产物,而代码8-1中并未包含受理单相关内容——这一差异在实践中需特别留意,避免将领域模型与DSL语义模型混淆。

图 8.2 受理验证DSL语义模型

        相较于此前的所有案例,当前示例的语义模型结构更为简洁。读者可能会质疑自身设计的语义模型是否存在问题,实则不然。如前文所述,语义模型的本质是对DSL的抽象映射,其形态具有灵活性——既可以是包含复杂行为的领域模型(尽管实际应用中较少采用这种方式),也可以是轻量级的数据结构。在当前业务场景下,DSL的核心价值在于简化配置逻辑,用户仅需通过少量代码即可清晰定义不同受理类型对应的业务规则,因此语义模型的结构更趋近于映射表,其简洁性恰恰符合设计预期。

        语义模型的复杂度取决于DSL的应用目标:当DSL用于描述简单的配置映射关系时,轻量级的数据结构足以满足需求;而当涉及复杂的业务逻辑转换时,则需要设计包含行为的领域模型。理解这一点,有助于避免将语义模型的设计与领域模型的复杂度强行绑定,从而更精准地实现DSL与系统逻辑的解耦。

        让我们再对图 8.2多做一些解释。MappingItem用于表示规则完整名称与其别名之间的映射关系,对应于代码8-1中rules代码块内的每一项,如代码8-2所示:

代码8-2class MappingItem {String fullName;String alias;MappingItem(String fullName, String alias) {this.fullName = fullName;this.alias = alias;}
}

        NameContainer对应于代码8-1中的rules块,该类封装了MappingItem类型的对象,并提供了基于规则名称或别名进行检索的能力,具体实现如代码8-3所示。尽管使用原生Map结构也能够实现映射和检索功能,但其方法受限于JDK框架,无法直接满足特定业务需求。例如,若需确保规则别名的唯一性,仅需在NameMapping类中添加验重方法即可实现该约束。而使用Map时,验证逻辑需被放置在其他可被调用的位置,虽然能保证功能正确性,但可能导致代码结构混乱,降低内聚性。通过封装NameContainer类,我们将与规则命名相关的业务逻辑集中管理,既保持了代码的清晰性,也为未来功能扩展提供了更优雅的实现方式。

代码8-3class NameContainer {List<MappingItem> items = new ArrayList<>();void add(String fullName, String alias) {if (containsFullName(fullName)) {String error = String.format("rule full name:%s is duplicated", fullName);throw new SemanticsException(error);}if (containsAlias(alias)) {String error = String.format("rule alias:%s is duplicated", alias);throw new SemanticsException(error);}items.add(new MappingItem(fullName, alias));}boolean containsFullName(String fullName) {return items.stream().anyMatch(e -> Objects.equals(e.fullName, fullName));}boolean containsAlias(String alias) {return items.stream().anyMatch(e -> Objects.equals(e.alias, alias));}
}

        代码8-3中,add()方法用于构建受理规则名称映射对象。可以看到,由于不允许完整名和别名出现重复的情况,笔者在其中加入了验重的逻辑,一旦违背约束的话,就会抛出语义异常SemanticsException来终结语法分析流程。

        类型ServiceTypeContainer包含了所有受理类型的名称信息,对应于代码8-1中的service_types块。同NameContainer类似,该类除了作为信息容器之外,也包含了检查受理类型名称是否重复的逻辑,如代码8-4所示:

代码8-4class ServiceTypeContainer {List<String> names = new ArrayList<>();void add(String name) {if (containsName(name)) {String error = String.format("service type name:%s is duplicated", name);throw new SemanticsException(error);}this.names.add(name);}boolean containsName(String name) {return names.stream().anyMatch(e -> Objects.equals(e, name));}
}

        同类型NameContainer,语义模型ServiceTypeContainer中也包含了用于查重的add()方法。代码比较简单,让我们跳过说明,继续探索代表绑定关系的语义模型BindingItem的实现,其表示了bind_rules代码块中的每一项,如代码8-5所示:

代码8-5class BindingItem {private String serviceType;private List<String> aliases;BindingItem(String serviceType, List<String> aliases) {this.serviceType = serviceType;this.aliases = aliases;}
}

        上述两个以Container作为后缀的语义模型实例都会被聚合在BindingConfig类中。该类所对应的DSL脚本块为bind_rules,描述了服务类型和受理规则之间的绑定关系。又由于bind_rules块中可以包含多组绑定信息,所以其内部实现使用了Map结构来作为维护绑定关系的容器。具体实现如代码8-6所示:

代码8-6class BindingConfig {Map<String, BindingItem> bindingItems = new HashMap<>();NameContainer nameContainer;ServiceTypeContainer serviceTypeContainer;BindingConfig(NameContainer nameContainer,ServiceTypeContainer serviceTypeContainer) {this.nameContainer = nameContainer;this.serviceTypeContainer = serviceTypeContainer;}void addBindingItem(String serviceType, List<String> aliases) {if (!StringUtils.hasLength(serviceType)) {throw new SemanticsException("service type name is empty");}if (!serviceTypeContainer.containsName(serviceType)) {throw new SemanticsException("service type name is not exist");}if (bindingItems.containsKey(serviceType)) {throw new SemanticsException("service type name is duplicate");}aliases = Optional.ofNullable(aliases).orElse(new ArrayList<>());for (String alias : aliases) {if (!nameContainer.containsAlias(alias)) {throw new SemanticsException("alias name is not exist");}}bindingItems.put(serviceType, new BindingItem(serviceType, aliases));}
}

        addBindingItem()方法用于构建绑定信息。从代码8-6可见,该方法在执行过程中需完成多项语义检查,例如校验服务类型名称是否存在、规则别名是否有效、是否存在重复绑定等。至此,读者应能理解由语义模型承担语义分析工作的重要性:若将addBindingItem()方法中的逻辑移至语法分析器中实现,尽管未必影响DSL编译器的正常运行,但代码的可维护性与可读性将显著下降。语义模型通过集中封装语义检查逻辑,实现了语法分析与语义逻辑的解耦,既符合模块化设计原则,也便于后续功能的扩展与维护。这种职责分离的设计模式,能够有效提升系统的可维护性和可扩展性,避免因逻辑混杂导致的代码复杂度失控。

3、文法说明

        接下来要来展示的是代码8-1所对应的文法,如文法8-1所示。需要注意的是,组合子模式本质上是一种语法分析器的实现方式,并不会对文法产生影响。

文法8-1START -> RULE_BLOCK SERVICE_TYPE_BLOCK BINDING_BLOCK
RULE_BLOCK -> 'rules' NAME_MAPPING* 'end'
NAME_MAPPING -> 'id' 'id' ';'
SERVICE_TYPE_BLOCK -> 'service_types' SERVICE_TYPE_NAME* 'end'
SERVICE_TYPE_NAME -> 'id' ';'
BINDING_BLOCK -> 'bind_rules' BINDING* 'end'
BINDING -> 'id' '{' ALIAS_LIST '}' ';'
ALIAS_LIST -> 'id' | 'id', ALIAS_LIST

        上述文法的结构虽然很简单,但仍有一些值得注意的地方:

  1. 大括号的使用。笔者使用单引号将大括号({和})括起来,是为了强调它所代表的元素是一个终结符而非语义动作。标识语义动作的时候,直接使用大括号即可。
  2. 分隔符的使用。对于列表类元素,如rules代码块中的内容,笔者使用了分号进行分隔,这一点在文法8-1中也得到了体现。这样做的原因其实很简单:方便语法分析。还是以rules块中的内容为例,我们可以在该代码块内编写多条用于表示规则名称对应关系的脚本,其中每一条映射关系使用文法符号NAME_MAPPING进行表示。由文法8-1可知,其可以推导为两个id类型的终结符号。

        请读者考虑一下,如果DSL脚本写成如代码8-7所示形式的话,语法分析器的输出结果是什么呢?

代码8-7#受理规则名称映射
rulesresourceIsNotFreezed ResNotF resourceIsNotExpired ResNotE accountIsNotFreezed
end

        词法分析过程中一般会将换行符、空格等无意义的信息过滤掉。所以,针对rules代码块内的内容,词法分析器的输出序列为“resourceIsNotFreezed”、“ResNotF”、“resourceIsNotExpired”,“ResNotE”、“accountIsNotFreezed”。根据非终结符NAME_MAPPING的语法规则,语法分析器会在分析到第三条映射项时抛出语法错误,错误提示也将是针对第三个项的。根据语言设计意图,正确的做法应该是在分析第一条项目时报错才对。此外,如果去掉“accountIsNotFreezed”的话,您会发现该脚本是可以通过语法分析的,这样的设计几乎没有规则可言,实在是太过匪夷所思了。

        在解决上述问题时,需同时对词法分析器和语法分析器进行优化。词法分析阶段应保留具有语义价值的空格,语法分析器也需对空格进行针对性处理,因其可能成为语法规则的组成部分。显然,此类设计会显著增加系统复杂度。而采用显式界符(如分号)则可大幅降低语言分析程序的实现难度,同时提升代码可读性。

        事实上,语言设计本质上是约定而非强制规范。尽管Python、Ruby、Go等语言广泛采用换行符作为隐式界符,但根据实践经验,显式界符更具工程优势。虽然显式界符可能使代码简洁性稍受影响,并存在开发者遗漏书写的潜在风险,但其能极大简化语法分析逻辑。对于缺乏语言设计经验的初学者,强烈建议避免过度追求脚本表面的简洁性——此类“简洁”往往伴随语法分析器复杂度的急剧上升。作为语言设计者,不应为追求华而不实的形式特征忽视核心目标,以免导致设计重心偏离。

        需特别指出的是,显式界符的选择并非否定隐式规则的合理性,而是在开发成本、维护效率与语言复杂度之间寻求工程平衡点。尤其在快速迭代的项目或教育场景中,显式语法结构更有助于开发者聚焦核心逻辑,降低因语法歧义或解析复杂性引发的潜在问题。

上一章  下一章

http://www.lryc.cn/news/603514.html

相关文章:

  • 传统数据库连接已OUT!飞算JavaAI开启Java开发智能新潮流
  • 【C++算法】78.BFS解决FloodFill算法_算法简介
  • 两数之和(每天刷力扣hot100系列)
  • ubuntu 25.04 自带JS引擎gjs运行GTK with JavaScript 应用
  • TensorFlow深度学习实战——基于卷积神经网络进行情感分析
  • vue请求golang后端CORS跨域问题深度踩坑
  • 从0到1学PHP(五):PHP 数组:高效存储与处理数据
  • Linux网络管理
  • 万字详解——OSI七层模型:网络通信的完整架构解析
  • 机器学习-十大算法之一线性回归算法
  • Nginx反向代理的网站服务,然后将http重定向到https
  • 无人机图传:让天空视角 “触手可及”
  • .NET 10 中的新增功能系列文章1——运行时中的新增功能
  • 【C#|C++】C#调用C++导出的dll之非托管的方式
  • 百度前端面试题目整理
  • 基于springboot/java/VUE的旅游管理系统/旅游网站的设计与实现
  • 算法提升之数论(矩阵+快速幂)
  • [2025CVPR-图象分类]ProAPO:视觉分类的渐进式自动提示优化
  • B 站搜一搜关键词优化:精准触达用户的流量密码
  • deepseek+飞书多维表格 打造小红书矩阵
  • 线程崩溃是否导致进程崩溃
  • 【CAN总线】STM32 的 CAN 总线通信开发笔记(基于 HAL)
  • 【开源项目】轻量加速利器 HubProxy 自建 Docker、GitHub 下载加速服务
  • 系统改造:一次系统领域拆分的实战复盘
  • 多态示例。
  • kotlin使用mybatis plus lambdaQuery报错
  • XtestRunner一个比较好用好看的生成测试报告的工具
  • 系统间复制文档
  • 论文阅读--射频电源在半导体领域的应用
  • React--》实现 PDF 文件的预览操作