内部DSL的构成要素主要有三项:脚本、表达式构建器和语义模型,如图 2.10所示。可以看到,与外部DSL相比,其架构要简单得多。尤其是编译器部分,只包含两类元素,并且也不是必需的组件。
图 2.10 内部DSL构成要素
有关内部DSL的概念,相信读者此刻已经对其有了一定的了解。与外部DSL的根本区别在于它的实现形式一般是由通用语言编写,从项目的角度来看则是指宿主语言。这样的特点意味着会出现如下三个问题:
综上可见,内部DSL具有高度灵活性,且与宿主语言特性紧密相关。鉴于本书内容均基于Java语言实现,后续将着重介绍具有代表性的实现模式。需注意的是,动态语言(如Groovy、Kotlin)在实现内部DSL时,通常比Java更具简洁性与灵活性。例如,Kotlin的运算符重载和扩展函数等特性,恰恰是Java所欠缺的,这也在一定程度上限制了Java风格内部DSL的表达能力。若读者希望深入研究,建议查阅针对性资料进行学习。
图 2.10展示了内部DSL的构成要素,其编译器部分同样包含语法分析器和语义模型。但与外部DSL不同的是,内部DSL的语法分析器由两部分构成:宿主语言编译器和表达式构建器。前者由宿主语言自身提供,负责处理DSL实现语言的语法分析;后者则需技术人员自行编写,用于解析DSL脚本。关于语义模型,尽管理论上为可选组件,但与外部DSL类似,笔者建议保留(特殊情况除外)。代码生成在内部DSL中较少使用,故不作详述。
需要注意的是,内部DSL同样需要语法分析器,这一点很容易被人们所误解。该类分析器会构建两棵语法树:第一棵由宿主语言编译器自动构建(不可见),第二棵由表达式构建器生成(虚拟树)。但需强调,内部DSL的实现形式多样,此处“肯定”结论仅适用于本书介绍的两种实现方式:方法级联和嵌套函数。
不同形式的内部DSL因其实现方式不同,使得语法分析树的结构也不尽相同。以方法级联调用为例,其语法分析树更像是一个单链表。图 2.11-a展示了代码2-7所对应的语法分析树。
代码2-7
Builder.a().b().c();
图 2.11 代码2-7、代码2-8所对应的语法分析树
嵌套函数的语法分析树是个典型的多叉树结构,图 2.11-b展示了代码2-8所对应的语法分析树,需要采用后序遍历的方式来实现树的遍历。
代码2-8
a(b(), c());
通过对比可以看到,它们的区别还是比较明显的。前文曾说过,语法分析树的本质其实就是函数调用过程中的进出栈过程,相信在图 2.11帮助下应该可以加深您对这一概念的理解。既然如此,那就让我们再深入一点,看一下代码2-9所对应的语法分析树,即图 2.9。相对于前面的两个案例,代码2-9同时集成了方法级联与嵌套函数两个模式,要更复杂一些。
代码2-9
Builder.a(a1(), a2())
.b(b1(), b2())
.c(c1(), c2());
图 2.12 代码2-9所对应的语法分析树
读者需要注意的是,针对上述两类内部DSL,语法分析树并不是其中的核心,表达式构建器才是,即代码2-9中的Builder。这是个复合词,我们应该将它们拆分成两个部分去理解:表达式和构建器。何为表达式?级联调用和函数嵌套都是。那什么又是构建器呢?您可将其理解为一种设计模式,该模式实现了方法级联调用或函数嵌套,为DSL脚本模式的实现提供支撑。当然,您也可以选择让某个领域模型来负责实现内部DSL,比如将Builder替换为Order、Account等,但这样做真的好吗?很显然,并非如此,这样的设计违反了单一责任的原则。
从设计的角度来看,表达式构建器为内部DSL的实现提供了基座,它的引入更符合面向对象设计的要求:责任明确、能力内聚;从编译器的角度来看,表达式构建器负责解释DSL代码的目的,将用户输入转换为对语义模型的操作。因此,从责任的角度来看,它的作用与外部DSL的语法分析器十分的类似,所以笔者将其认定为编译器的一部分。不过读者务必要注意一点:只有在方法级联调用或函数嵌套的前提之下,表达式构建器才是必需的。
有关表达式构建器的更多细节,请读者参看后序文章。接下来我们再计论一下内部DSL中的语义模型。实际上,它的使用模式和作用在内、外两种DSL中是完全类似的。不过从笔者个人的使用感受来看,内部DSL所对应的语义模型更接进领域模型。或者确切地说,内部DSL的表现更像是领域模型之上的语法糖,比外部DSL更加直接。虽然我们认为语义模型通常是领域模型的子集,但大多数时候它们其实只是一些简单的数据结构,这和情况对于外部DSL尤为常见,您甚至可以将其看作是编译器与领域模型之间的“中间件”。而对于内部DSL而言,情况则是完全相反的。具体细节,我们会在后文中通过案例进行展示。
综上内容可以看到,内部DSL的架构要比外部DSL简单很多,除了表达式构建器让人感到陌生之外,其他的并没有什么特别深奥之处。表现形式上,它给人的感觉更像是对通用编程语言的高级应用,而非是一门新的语言。那么这是不是意味着即使不使用也无伤大雅,只不过代码可能会复杂一点、臃肿一点呢?答案当然是否定的。内部DSL的核心内涵是“语言连贯性”,也就是说它可以以一种连贯的形式将人们的思维方式表达出来,这才是我们应该重点学习的。
可是到底什么是“语言连贯性”呢?读者可以仔细观察一下自己所编写的代码,您会发现它们大多数都是以一种离散的状态而存在的。以代码2-10为例:
代码2-10
void doSomething() {
step1();
step2();
...
stepN();
}
方法doSomething()的实现分别调用了step1()、step2()、...、stepN()等方法,从业务角度来看,这意味着doSomething()方法所代表的业务由多个步骤构成,而其中被调用的每一个子方法则代表了业务流程中的一个小环节。虽然过程非常清晰,但从代码中我们却无法看到各个步骤之间是如何衔接的。当然,肯定存在一种负责内部连接的“内力”,但我们并不能从代码中看出来。由于每一个子方法的执行都呈现出了一种离散的状态,就使得业务流程的运作仿佛失去了连续性,给人一种不连贯的感觉。这并不是面向过程编程的错误,换作面向对象编程的话结果也是如此。
人类的思维特性导致我们更喜欢以线性的方式去思考问题。这也是为什么在进行业务建模或分析建模的时候,人们总是更倾向于使用使用活动图、流程图、时序图来描述业务流程。清晰性是一个方面,重要的是这类模型可以将流程中的连贯性表达出来。可是在将业务转换为技术之后,这种连贯性却消失了,这一点通过代码2-10也能看出来。事实上,这一“领域隔离”问题的确很难避免。业务与技术本质上是不同的东西,转换过程中出现失真的情况实属正常,而我们要做的,则是要想办法去尽量减少失真。具体手段有很多,面向对象分析、领域驱动设计等都提供了不错的方法指导,DSL的使用也能在一定程度上缓解该问题。当然,我们也不能夸大它的作用,相对于技术,人的因素才是最为关键的。再回到语言连贯性这一问题上,该特性在Java中的try...catch...finally语法中得到了一定程度的体现,但仍然不够彻底。让我们尝试对代码2-10的实现形式做一下改变,结果如代码2-11所示。相对于原版,新版本的代码能否给您一种连贯感呢?
代码2-11
BusinessFlowBuilder.build()
.steps()
.add(step1())
.add(step2())
.done()
.triggers()
.add(trigger())
.done()
.start();
内部DSL虽具有独特特性,但其适用场景存在明确限定,例如各类算法实现、复杂业务逻辑处理以及简单赋值操作等场景均不适用。仅针对特定类型任务,其价值才能得以体现,这本质上考验的是设计者的创造力。内部DSL作为DSL的一种,自然需面向特定领域,这一原则在任何情况下都不应被违背。
尽管对内部DSL与外部DSL进行比较的实际意义有限,但从技术探索与个人兴趣角度出发,笔者更倾向于认为外部DSL更具趣味性与技术成就感。当然,这一表述难免带有主观偏好,但实际情况确是如此。即便如此,读者在实践中仍需遵循以下原则:优先考虑采用内部DSL或直接使用宿主语言实现业务逻辑,仅在内部DSL无法满足需求时,再依据实际需要决定是否引入外部DSL。务必避免为使用DSL而使用DSL,这与学习DSL的初衷相悖。
上一章 下一章