(十六)深度解析领域特定语言(DSL)第二章——内部DSL基本架构

        内部DSL的构成要素主要有三项:脚本、表达式构建器和语义模型,如图 2.10所示。可以看到,与外部DSL相比,其架构要简单得多。尤其是编译器部分,只包含两类元素,并且也不是必需的组件。

(十六)深度解析领域特定语言(DSL)第二章——内部DSL基本架构_第1张图片

图 2.10 内部DSL构成要素

        有关内部DSL的概念,相信读者此刻已经对其有了一定的了解。与外部DSL的根本区别在于它的实现形式一般是由通用语言编写,从项目的角度来看则是指宿主语言。这样的特点意味着会出现如下三个问题:

  1. 区分DSL与一般的代码将会很难。(一般来说)它们都是由相同的项目宿主语言所编写,彼此间的界线非常模糊。
  2. 内部DSL的表现形式将受限于具体的通用语言。尽管马丁·福勒在其著作当中展示了多种不同形式的外部DSL,但语言不同,能够支持的形式也存在差异。
  3. 宿主语言不同,将会导致内部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();

(十六)深度解析领域特定语言(DSL)第二章——内部DSL基本架构_第2张图片

图 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的初衷相悖。

上一章  下一章

你可能感兴趣的:(DSL,领域特定语言,开发语言,java,软件构建)