10.1 领域特定语言

DSL是为了解决某个特定业务领域问题的一种自定义语言。譬如,你可能正在开发一个财务出纳软件。你的业务领域包括了像银行存款证明这样的概念以及对账这样的操作。你可以创建一个定制的DSL来描述该领域的问题。在Java中,你需要实现一系列的类和方法来描述这个领域。某种程度上,可以把DSL当成与特定领域建立联系的API。

DSL并非通用编程语言,它对能执行的操作以及其中涉及的概念都做了限定,其只针对某个领域,这意味着使用DSL可以减少程序员受到的干扰,让他们能够更专注于解决重要的业务问题。你的DSL应该有能力帮助程序员从无关的事物中解脱出来,只处理该领域的复杂性。别的底层实现细节都应该被隐藏——其原理就像将类方法的底层实现细节声明为私有一样。以这种方式设计的DSL才是一个用户友好的DSL。

那么DSL到底是什么呢?DSL不是简单的文本描述。它也不是让领域专家实现底层业务逻辑的语言。以下两个原因会驱动你开发一个DSL。

  • 沟通为王。你的代码应该能清晰地表达它的意图,而且要能被“非程序员”所理解。只有这样,领域专家才能及时加入以验证代码是否符合业务需求。
  • 代码只编写一次,但会被阅读很多次。代码的可读性对软件的可维护性非常重要。换句话说,写代码时应该尽量保持结构优美、注释清晰,这样才能方便他人阅读。

设计良好的DSL能带来非常多的益处。尽管如此,开发并使用定制的DSL既有其优点也有其弊端。下一小节会深入探讨这些优点和弊端,这样你在决定是否应该为某个场景创建DSL时才能有所依据。

10.1.1 DSL的优点和弊端

与软件工程中很多其他的技术与解决方案一样,DSL也并非“银弹”。利用DSL处理你的领域问题既有其优势也有其弊端。DSL是你的宝贵资产,其原因很直白,因为它提升了你代码的抽象层次,使其能更加专注于业务目标,继而具有更好的可读性。不过它也可能成为你的负担,因为实现DSL的是独立的代码,这部分代码也需要测试和维护。因此,深入研究DSL能带来的好处与可能存在的弊端对于你评估是否要为你的项目添加DSL,确保其能带来正面收益至关重要。

DSL具有以下优点。

  • 简洁——DSL提供的API非常贴心地封装了业务逻辑,让你可以避免编写重复的代码,最终你的代码将会非常简洁。
  • 可读性——DSL使用领域中的术语描述功能和行为,让代码的逻辑很容易理解,即使是不懂代码的非领域专家也能轻松上手。由于DSL的这个特性,代码和领域知识能在你的组织内无缝地分享与沟通。
  • 可维护性——构建于设计良好的DSL之上的代码既易于维护又便于修改。可维护性对于业务相关的代码尤其重要,应用这部分的代码很可能需要经常变更。
  • 高层的抽象性——DSL中提供的操作与领域中的抽象在同一层次,因此隐藏了那些与领域问题不直接相关的细节。
  • 专注——使用专门为表述业务领域规则而设计的语言,可以帮助程序员更专注于代码的某个部分。结果是生产效率得到了提升。
  • 关注点隔离——使用专用的语言描述业务逻辑使得与业务相关的代码可以同应用的基础架构代码相分离。以这种方式设计的代码将更容易维护。

讲了那么多优点,DSL也有其短板。在你的代码中加入DSL可能会带来下面的弊端。

  • DSL的设计比较困难——要想用精简有限的语言描述领域知识本身就是件困难的事情。
  • 开发代价——向你的代码库中加入DSL是一项长期投资,尤其是其启动开销很大,这在项目的早期可能导致进度延迟。此外,DSL的维护和演化还需要占用额外的工程开销。
  • 额外的中间层——DSL会在额外的一层中封装你的领域模型,这一层的设计应该尽可能地薄,只有这样才能避免带来性能问题。
  • 又一门要掌握的语言——当今时代,开发者已经习惯了使用多种语言进行开发。然而,在你的项目中加入新的DSL意味着你和你的团队又需要掌握一门新的语言。如果你决定在你的项目中使用多个DSL以处理来自不同业务领域的作业,并将它们无缝地整合在一起,那这种代价就更大了,因为DSL的演化也是各自独立的。
  • 宿主语言的局限性——有些通用型的语言(比如Java)一向以其烦琐和僵硬而闻名。这些语言使得设计一个用户友好的DSL变得相当困难。实际上,构建于这种烦琐语言之上的DSL已经受限于其臃肿的语法,使得其代码几乎不具备可读性。好消息是,Java 8引入的Lambda表达式提供了一个强大的新工具可以缓解这个问题。

要依据这个优缺点的列表,决定是否为你的项目添加一个DSL并不是件容易的事情。除了实现自己的DSL,你还有一些其他的选择。在你研究到底该用哪种模式和策略开发一个可读性好又容易使用的DSL之前,先快速了解一下Java 8及之后版本提供的可选项:它们提供了哪些解决方案,都适合什么样的环境。

10.1.2 JVM中已提供的DSL解决方案

本节会学习DSL的分类。除此之外,还会介绍除了通过Java实现DSL之外,你还有哪些选择。接下来,我们会介绍如何利用Java提供的特性实现一个DSL。

按照Martin Fowler1引入的对DSL最常见的划分方法,DSL可以分成内部DSL和外部DSL。内部DSL(也被称作嵌入式DSL)借由现有的宿主语言(可以是纯Java代码,也可以是其他语言)实现,而外部DSL通常被称为独立DSL(stand-alone DSL),因为它们都是从无到有进行创建,其语法与宿主语言几乎完全无关。

1马丁·富勒是一个软件开发方面的著作者和国际知名演说家,他专注于面向对象分析与设计、统一建模语言、领域建模,以及敏捷软件开发方法,包括极限编程。

除此之外,JVM还为你提供了第三个备选项,这是一种介于内部DSL与外部DSL之间的解决方案:可以在JVM上运行另一种通用编程语言,而这种语言比Java自身更灵活、更有表现力,譬如Scala,或者Groovy。我们把这样的第三种选项称为“多语言DSL”(polyglot DSL)。

接下来的内容中会依次介绍这三种类型的DSL。

  • 内部DSL

由于本书是关于Java的,因此当我们提到内部DSL时,当然指的是用Java语言编写的DSL。历史上,Java并不是一种“适合编写DSL”的语言,因为它结构臃肿、语法僵化,我们很难用它写出可读性好、简洁、同时又有表现力的DSL。这个问题随着Lambda表达式的引入被逐渐解决了。正如我们在第3章中看到的那样,Lambda能以简洁的方式进行行为参数化,这一点非常有用。实际上,大量地使用Lambda之后,代码不再像匿名内部类那样冗长,以这种方式实现的“信号/噪声比”DSL也更容易被接受。为了演示信号/噪声比,你可以试试用Java 7的语法,搭配Java 8新引入的forEach方法,打印输出一个String列表:

  1. List<String> numbers = Arrays.asList("one", "two", "three");
  2. numbers.forEach( new Consumer<String>() {
  3. @Override
  4. public void accept( String s ) {
  5. System.out.println(s);
  6. }
  7. } );

这段代码中,加粗的部分是我们想要传递的“代码信号”。所有其他部分的代码都是语法上的噪声,并没有带来任何额外的价值,在Java 8中也不再需要(去掉也许更好)。匿名内部类可以通过Lambda表达式替代,如下所示:

  1. numbers.forEach(s -> System.out.println(s));

或者,你可以采用更精简的方式,传递一个方法引用,达到同样的效果:

  1. numbers.forEach(System.out::println);

当你希望你的用户不需要花费太多的精力在实现技术上时,使用Java构建你自己的DSL可能是一个不错的选择。如果Java语法不是什么大问题的话,那么选择使用纯Java开发你的DSL有很多优点。

  • 学习如何实现一个良好的DSL所需的那些模式和技巧,与学习一门新的语言及其工具链,并将其用于开发外部DSL比较起来,所花费的精力和时间要少得多。
  • 如果你的DSL用纯Java编写,它就能与其他的代码一起编译。由于不需要集成新的语言编译器或者其他用于生成外部DSL的工具,你在编译成本这块不会有任何新增开销。
  • 你的开发团队不需要花时间去熟悉新的语言,也不需要去研究那些他们不熟悉的、复杂的外部工具。
  • 你DSL的用户能够使用跟你一样的集成开发环境,充分利用集成开发环境所提供的所有特性,譬如自动补全、代码重构等。虽然现代IDE也在不断地改进它们对别的基于JVM的流行语言的支持,但是目前为止还没有哪一种语言的支持能达到跟Java同等的程度。
  • 如果你需要实现多种DSL来支撑你领域的多个部分,或者支持多个领域,如果它们都是用纯Java编写的,那整合它们不会是一个大问题。
    整合DSL还有另外一种选择,如果你的DSL同样都基于Java字节码,那么可以通过整合基于JVM的编程语言达到同样的效果。我们把这些DSL叫作“多语言DSL”,下一节中对其进行了介绍。
  • 多语言DSL

现在,JVM上可以运行的语言可能已经超过了100种,其中很多语言,譬如Scala和Groovy,都非常流行,大批的开发者对它们都很熟悉。其他语言,包括JRuby和JPython,是由别的知名的编程语言迁移到JVM上的。最后,还有一些新兴语言,譬如Kotlin和Ceylon,最近也获得了很多的关注,因为它们声称能提供与Scala比肩的特性,并且天然更简单,学习曲线更平滑。所有的这些语言都比Java新,设计之初没那么多限制,语法也不那么冗长。这种特质非常重要,由于编程语言具有内建的简洁性,用它实现的DSL也不会太烦琐。

Scala提供了几个特别适合用于开发DSL的特性,譬如柯里化、隐式转换等。第20章会对Scala进行一个概略的介绍,并将它与Java进行比较。就目前而言,我们只想通过一个简单的例子,让你对这些特性能达到什么效果有一个感性的认识。

假设你要构建一个工具函数,持续执行另一个函数f指定的次数。刚开始,你可能会考虑按照下面这种递归的方式用Scala语言实现(不用担心看不懂这些语法,目前最重要的是理解其中的思想)。

  1. def times(i: Int, f: => Unit): Unit = {
  2. f ←---- 执行f函数
  3. if (i > 1) times(i - 1, f) ←---- 如果计数器i为正,就将其减一,递归调用该函数指定的次数
  4. }

注意在Scala中,使用一个非常大的值i调用该函数也不会导致栈溢出,而这在Java中一定会发生,因为Scala对尾部调用进行了优化,这意味着对times函数的递归调用不会被加入到栈中。关于这个主题,第18章和第19章会有更多的介绍。你可以使用该函数重复调用另外一个函数(譬如下面这个例子,持续打印输出“Hello World”三次),如下所示:

  1. times(3, println("Hello World"))

如果你对times函数进行柯里化,或者将它的参数划分到两个分组之中(第19章会详细讨论“柯里化”),如下所示:

  1. def times(i: Int)(f: => Unit): Unit = {
  2. f
  3. if (i > 1 times(i - 1)(f)
  4. }

把要执行的函数作为参数传递到花括号中很多次的话,你可以获得同样的效果:

  1. times(3) {
  2. println("Hello World")
  3. }

最后,在Scala中你可以定义一个隐式转换将Int转换为一个匿名类,而这只需要一个函数,该函数接受一个需要重复执行的函数作为参数。再次强调一下,不用担心现在看不懂这里的语法及细节。这个例子的目标就是让你了解Java之外还有哪些可能的选项。

  1. implicit def intToTimes(i: Int) = new { ←---- 定义一个由Int向匿名类的隐式转换
  2. def times(f: => Unit): Unit = { ←---- 这个类只有一个times函数,它接受另一个函数f作为参数
  3. def times(i: Int, f: => Unit): Unit = { ←---- 第二个times函数接受两个参数,它定义于第一个times函数的作用域之内
  4. f
  5. if (i > 1) times(i - 1, f)
  6. }
  7. times(i, f) ←---- 调用内部的times函数
  8. }
  9. }

通过这种方式,你基于Scala内建的小型DSL就可以调用一个函数,让它打印输出“Hello World”三次,如下所示:

  1. 3 times {
  2. println("Hello World")
  3. }

如你所见,最终的DSL没有任何语法噪声,它非常容易理解,即便是不懂程序设计的人也没有什么困难。这里的数字3会自动地被编译器转换为类的实例,并将变量保存到字段i中。接着times函数接受一个要被重复执行的函数作为参数,被不带“点”的符号调用。

在Java中要得到类似的效果几乎是不可能的,由此可见使用“DSL友好”的语言能带来的好处多么明显。然而,这一选择也带有一些明显的不足,包括如下几点。

  • 你得学习新的语言,或者你的团队中已经有人熟练掌握了这门语言。用这些语言开发一个优雅的DSL通常会用到一些相对比较高级的特性,只对新的语言有一些浅尝辄止的肤浅认识是远远不够的。
  • 由于需要编译由两种甚至多种语言编写的源代码,你需要整合多个编译器,而这会让你的构建流程变得更加复杂。
  • 最后,虽然运行于JVM上的主流语言都声称与Java百分百兼容,但是让它们与Java实现互操作经常还是需要进行各种尴尬的妥协。此外,这种互操作有时还会导致性能问题。譬如,Scala和Java的集合类型不是完全兼容的,因此当一个Scala的集合需要传递给一个Java函数,或者反之情况出现时,原始的集合都需要进行一次转换,将其转换为目标语言原生API支持的格式。
    • 外部DSL

在你的项目中添加DSL还有第三种选项,那就是实现一个外部DSL。选择这种方式,你得从头开始设计一个新的语言,它要有自己的语法和语义。你还得建立独立的基础架构去解析新语言,分析解析的输出,生成对应的代码来执行你的外部DSL。这可是个大工程!完成这些任务所需的技能也很高,不太容易迅速掌握。如果你希望沿着这个方向发展,那么可以看看ANTLR,这是个应用很广的解析生成器,它也是伴随着Java一路成长而来的工具。

此外,即便是从头设计一种一致的编程语言也并非易事。比较常见的问题是外部DSL经常发生越界,去管一些设计之初并不期望它去管的事情。

开发外部DSL能带来的最大好处是它给你提供了几乎无限的灵活性。外部DSL的出现,让你有可能设计一种完全适合你领域和偏好的语言。如果你干得不错,那么最终的语言将具有极其良好的可读性,非常适合描述和解决你领域中的问题。此外,它还有个正面的结果,即通过外部DSL编写的业务代码与使用Java开发的基础架构代码之间泾渭分明,很容易区分。然而,这种分离是把双刃剑,因为它同时也在DSL与宿主语言之间人为地创建了一层屏障。

本章接下来的部分会学习如何创建使其更有效的基于Java内部DSL的模式和技巧。我们会探讨这些想法如何在原生Java API的设计中得到应用,特别是Java 8及之后版本中新增的那部分API是如何应用的。