(九)深度解析领域特定语言(DSL)第一章——深入认识外部DSL

         虽然笔者在前面章节中对外部DSL进行了介绍,但比较简单。本小节,让我们对其做一些相对正式地说明。

        外部DSL的核心价值在于简化库的使用、配置领域模型以及实现代码生成等功能。鉴于需自主开发语法分析程序,其实现方式主要分为以下两类:

  1. 基于通用编程语言(比如Java)来手动编写语法分析器。此方法实现复杂度高,开发周期长且易出错,对技术人员要求较高。然而,手工优化的分析器在运行效率和灵活性上具有显著优势,本书将对此展开重点讨论。
  2. 通过语法分析器生成工具如ANTLR来自动生成一个语法分析器。该方式因能大幅节省开发成本而备受青睐。开发者只需定义语言的语法规则(即文法),工具即可自动生成分析器代码。此方法在工业化软件开发中更具吸引力。

        显然,自主设计外部DSL语法分析器对技术人员的专业素养要求颇高,不仅需掌握编译原理,设计、实现、调试、测试及迭代维护等环节所需的资源投入也相当可观。这正是语法分析器生成器广受欢迎的原因所在——对程序员的要求相对较低,只需具备基本英语能力,参照文档说明即可上手。此外,一些工具支持在文法中嵌入通用编程语言,以增强DSL的功能,可控性极强。以代码1-15为例,其展示了如何在ANTLR文法文件中插入Java代码:

代码1-15

decl: type ID ';' {System.out.println("found a decl");} ;
type: 'int' | 'float' ;

        从文法与通用编程语言的结合方式来看,其实现颇为简便。依据ANTLR规范,只需将代码置于合适位置并用大括号({})包裹即可。若担心嵌入代码污染文法规则,还可将通用编程语言编写的代码(通常为方法)封装至独立类中,再在文法文件中调用。

        关于外部DSL的规模,并没有一定的限制。代码1-14属于轻量型的,一行语句即可。而在复杂业务场景中,DSL脚本可能呈现较高复杂度。以笔者曾经参与的一个项目为例,该系统主要用于对云产品的订购业务进行支撑。订购的类型有很多,比较典型的种类包括新购、升配、续订等,其中升配是指对已经订购的产品(一般称之为资源)的配置进行升级;续订则是延长使用周期。每笔成功地订购都会生成一条订单信息,但前提是必须符合对应的订购约束才可以。以升级为例,订购约束便包括了“被升级的资源不能是被冻结状态”、“资源必须处在使用周期之内”、“资源所属客户状态为非冻结”等内容。不同类型的订购,对应的业务约束也不同。当然,同一个约束也有可能被多个不同类型的订购业务所共同使用。比如上述的第三项,就可能会同时被升级和续订使用到。

        在上述案例中,细心的读者不难发现以下三个特征:

  1. 约束的数量是不固定的,会随着业务的扩展而发生变化。
  2. 不同类型的订购业务会采用不同的约束规则。
  3. 约束规则可以被共享。

        由此会衍生出如下两个问题:

  1. 虽然我们可以采用责任链设计模式将事先预定义好的约束规则配置到每一类订购场景之中,但每次对规则进行增减都需要变更代码以及无法避免的编译过程(Java实现)。
  2. 约束规则缺乏有效的管理。当约束规则数量较多时,缺乏系统化管理机制可能导致规则间逻辑冲突(如互斥规则并存),增加调试与维护成本。

        针对上述两个问题,引入DSL可能是有效的解决方案。设计得当的DSL不仅能避免代码重新编译,还可简化技术人员对规则的梳理与维护。尽管有观点认为,对于简单的业务验证场景,引入新技术可能存在学习与实现成本过高的问题,但当订购约束规则数量庞大时,DSL可显著改善规则管理混乱的现状。除提供业务支撑外,DSL本身还可作为规则的说明文档,这对技术团队的协作与维护具有积极意义。  

        针对该需求设计的外部DSL片段如代码1-16所示:

代码1-16

rules
    resourceIsNotFreezed ResNotF; //资源不能是冻结状态
    resourceIsNotExpired ResNotE; //资源处在使用周期之内
    accountIsNotFreezed AccNotF; //账户未冻结
end

service_types
    upgrade; //配置升级
    renew; //服务延时
end

bind_rules
    upgrade {ResNotF, ResNotE, AccNotF };
    renew {AccNotF };
end

        显然,相较于优惠规则场景的DSL,上述外部DSL已具备一定复杂度。代码1-14属于典型的行式DSL,每行代表一个完整业务逻辑,可通过换行符或分号等符号实现语句分隔。此类DSL的语法分析器通常较为简单,基于分隔符即可完成语法解析。而代码1-16的显著特征是引入了代码块概念——以“rules”关键字起始,至“end”关键字结束,其间所有语句共同构成一个上下文整体。这要求语法分析器必须支持对语句块的解析,因为单一语句在脱离代码块上下文时可能语义不完整。此外,代码块内语句间可能存在逻辑关联,仅依赖分隔符进行单语句解析的方式已无法满足需求。

        尽管代码1-16的规模不及C或Java等通用编程语言,但在DSL设计中仍需遵循简洁性原则:若行式DSL足以满足需求(如优惠规则场景),则避免过度复杂化设计。具体而言,DSL应聚焦“够用即可”,避免为炫技添加无实质价值的功能。DSL的核心目标是简化配置或库的使用,若复杂度导致学习成本激增,将背离其设计初衷。

        结束本文之前,笔者觉得有必要再谈一下使用外部DSL的时机以及注意事项,具体如下所示:

        1.当需要对应用程序进行增强时

        在Java生态中,增强应用程序并非只能依赖外部DSL,注解与面向切面编程(AOP)等技术同样提供了强大的扩展能力。相较于这两者,外部DSL的实现复杂度更高,但其优势在于能够以极简的表达方式应对高度复杂的特定需求,尤其在使用片段型DSL时表现尤为突出。以代码1-12为例,其采用类自然语言的方式实现方法参数验证,仅需寥寥数行代码,便在表达能力与灵活性上数倍于Java原生实现。这种方式不仅显著提升了代码可读性,还通过减少样板代码量,大幅增强了系统的可维护性。

        2.当需要在不修改代码的前提下变更业务流程时

        在某些场景下,外部DSL(尤其是独立型DSL)确实容易让人产生与配置文件混淆的错觉。尽管DSL本质上属于编程语言,其表达能力与应用范围远超过配置文件,但这种直观感受仍值得读者在深入理解外部DSL的实现模式后仔细辨析。为进一步说明这一差异,我们来看一个新的案例:通过阿里巴巴的Seata框架实现Saga分布式事务。该框架使用了JSON作为配置语言,片段如代码1-17所示(注:内容有省略)。

代码1-17

{
	"StartState": "ReduceInventory",
	"States": {
		"ReduceInventory": {
			"Type": "ServiceTask",
			"ServiceName": "inventoryAction",
			"ServiceMethod": "reduce",
			"CompensateState": "CompensateReduceInventory",
			"Next": "ChoiceState"
		},
		"ChoiceState": {
			"Type": "Choice",
			"Choices": [
				{
					"Expression": "[reduceInventoryResult] == true",
					"Next": "ReduceBalance"
				}
			]
		}
	}
}

         上述代码呈现了Saga事务流程的逻辑走向,尽管结构复杂但具备较高可读性。此时的JSON作为DSL使用,其核心优势在于允许在不修改代码的前提下调整事务流程。若忽略缓存、性能等因素,甚至可实现修改后立即生效。以优惠规则DSL为例,理论上对其修改也能即时生效,仅需额外执行一次缓存清除操作。

        3.当想要突破繁琐的语法限制来简化应用程序时

        DSL的核心价值之一在于“简化库的使用”,这一点在前文已有论述。对于复杂业务模型,尽管通用编程语言(如Java)同样可以实现组装与配置,但过程往往繁琐冗长,代码量庞大且逻辑嵌套复杂,甚至不得不采用硬编码方式。外部DSL的优势在于以更简洁、直观的方式简化业务模型构建。

        虽然Java以严谨而著称,但在特定业务场景下,分号、括号、类型声明等元素常被视为“语言噪声”。外部DSL的自由定制特性使其能够针对特定领域做出技术假设,从而剔除无实际价值的语法元素,实现极致简洁。尽管这种简化可能损失部分信息,但通过领域约束和语义分析可进行补充。例如,循环打印变量x的值10次,Java需通过循环结构实现,而DSL可通过类似“x^10”的简洁语法完成——这正是基于对特定业务操作的预设假设。

        本质上,外部DSL是一种对底层能力的封装方式。尽管内部DSL和外观模式也能实现封装,但其依赖宿主语言的特性限制了表达自由度。相比之下,外部DSL的封装更为灵活,且更贴近领域专家的思维方式,这正是“面向语言编程”的核心优势所在。

        4.当想要与非技术人员进行高效沟通时

        如同外部DSL对应用程序的增强作用一样,在与非技术人员沟通时,虽然并非必须选择DSL,但它确实有可能开创一种全新的沟通方式,或者营造出业务驱动技术的沟通氛围。随着软件行业的发展,各种建模方法不断涌现,但其表现形式相对固定,主要包括图形、文字或两者结合。那么,是否有可能创造出一种新的建模方式,它能被所有人理解和使用,克服文字描述的不精确性,比图形更易于修改,在不失真的情况下高效地将业务模型转化为技术模型,并更清晰地反映领域专家的意图呢?

        基于上述需求,不难发现外部DSL是一个不错的选择。当然,这并不是要废除图形建模方式,而是引入一种更合适的语言作为文字描述的补充。实际上,许多研发人员对建模工作存在误解,他们认为绘制UML等图形就完成了建模,而文字只是附属品。但实际情况恰恰相反,文字才是建模的核心,只有在处理复杂业务时才会考虑用图形进行补充说明。然而,文字容易产生歧义,这也是许多技术人员抱怨需求不明确的主要原因,其根源在于对业务本质理解不足和沟通障碍。外部DSL的优势在于它既具备编程语言的严谨性,又在表现形式上更接近自然语言,而且由于针对性强,能更高效地解决特定场景的问题。

        诚然,上述内容目前还只是一种设想。至少从现状来看,大多数人使用外部DSL主要是为了解决技术问题,但随着DSL技术的不断成熟,这种设想或许有望成为现实。

        5.当内部DSL无法对应用实现更有效地简化时

        通过前文的阐述,读者应已认识到:尽管内部DSL在提升代码可读性方面具有一定优势,但其对编程复杂度的简化存在天然局限。以Java生态为例,主流的内部DSL实现(如方法级联、嵌套函数、Lambda表达式及注解)更多是在语法层面进行优化,难以突破宿主语言的固有范式。此时,外部DSL提供了一条更具想象力的路径——通过领域专属的语法设计,可实现远超内部DSL的表达自由度。

        具体选择策略可总结为:若仅需对领域模型或第三方类库进行轻量化封装,内部DSL已能满足需求;而若追求极致简化、类自然语言的表达效果,则外部DSL更值得尝试。这种技术选择不仅能带来代码层面的优化,更可能在团队协作模式、业务需求传递效率等维度创造惊喜。

        6.优先选择内部DSL

        尽管外部DSL具备独特优势,但在工程实践中应遵循“内部优先”的选择策略。当内部DSL能够满足需求时,无需引入外部DSL——后者的设计、实现、调试及测试过程更为复杂,且需长期迭代才能达到成熟稳定。相较之下,即便是Java+Groovy这类混合式内部DSL,也能显著降低技术成本。

        然而,对于类似代码 1-12这类具有高复用价值的片段型外部DSL,应予以特别关注。尽管其实现复杂度较高,但其简洁性与表达力远超内部DSL,在高频使用场景中能够带来持续的效率提升。这种平衡策略既避免了过度工程化,又能充分发挥DSL的技术价值。

        7.优先考虑建设与维护成本

        DSL的选择本质上是技术选型决策,成本效益始终是核心考量因素。外部DSL虽功能强大,但对研发团队的稳定性、技术深度和持续维护能力提出了更高要求。若团队结构波动较大或技术栈储备不足,贸然引入外部DSL可能导致投入与产出失衡。

        读者需谨记:DSL本身也是软件产品,需要持续迭代优化才能发挥价值。当发现维护成本远超收益时,应果断调整技术路线。技术选型的关键不在于追求“最先进”,而在于找到与团队能力、业务场景相匹配的平衡点。及时止损与适时投入同样重要,这体现了工程实践中的务实思维。

上一章  下一章

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值