8.2 使用Lambda表达式的领域专用语言

领域专用语言(DSL)是针对软件系统中某特定部分的编程语言。它们通常比较小巧,表达能力也不如Java这样能应对大多数编程任务的通用语言强。DSL高度专用:不求面面俱到,但求有所专长。

人们通常将DSL分为两类:内部DSL和外部DSL。外部DSL脱离程序源码编写,然后单独解析和实现。比如级联样式表(CSS)和正则表达式,就是常用的外部DSL。

内部DSL嵌入编写它们的编程语言中。如果读者使用过JMock和Mockito等模拟类库,或用过SQL构建API,如JOOQ或Querydsl,那么就知道什么是内部DSL。从某种角度上说,内部DSL就是普通的类库,提供API方便使用。虽然简单,内部DSL却功能强大,让你的代码变得更加精炼、易读。理想情况下,使用DSL编写的代码读起来就像描述问题所使用的语言。

有了Lambda表达式,实现DSL就更简单了,那些想尝试DSL的程序员又多了一件趁手的工具。我们将通过实现一个用于行为驱动开发(BDD)的DSL:LambdaBehave,来探索其中遇到的各种问题。

BDD是测试驱动开发(TDD)的一个变种,它的重点是描述程序的行为,而非一组需要通过的单元测试。我们的设计灵感源于一个叫Jasmine的JavaScript BDD框架,前端开发中会大量使用该框架。例8-27展示了如何使用Jasmine创建测试用例。

例8-27 Jasmine

  1. describe("A suite is just a function", function() {
  2. it("and so is a spec", function() {
  3. var a = true;
  4. expect(a).toBe(true);
  5. });
  6. });

如果读者不熟悉JavaScript,阅读这段代码可能会稍感疑惑。下面我们使用Java 8实现一个类似的框架时会一步一步来,只需要记住,在JavaScript中我们使用function() { … }来表示Lambda表达式。

让我们分别来看看这些概念:

  • 每一个规则描述了程序的一种行为;
  • 期望是描述应用行为的一种方式,在规则中定义;
  • 多个规则合在一起,形成一个套件。

这些概念在传统的测试框架,比如JUnit中,都有对应的概念。规则对应一个测试方法,期望对应断言,套件对应一个测试类。

8.2.1 使用Java编写DSL

让我们先看一下实现后的Java BDD框架长什么样子,例8-28描述了一个Stack的某些行为。

例8-28 描述Stack的案例

  1. public class StackSpec {{
  2. describe("a stack", it -> {
  3. it.should("be empty when created", expect -> {
  4. expect.that(new Stack()).isEmpty();
  5. });
  6. it.should("push new elements onto the top of the stack", expect -> {
  7. Stack<Integer> stack = new Stack<>();
  8. stack.push(1);
  9. expect.that(stack.get(0)).isEqualTo(1);
  10. });
  11. it.should("pop the last element pushed onto the stack", expect -> {
  12. Stack<Integer> stack = new Stack<>();
  13. stack.push(2);
  14. stack.push(1);
  15. expect.that(stack.pop()).isEqualTo(2);
  16. });
  17. });
  18. }}

首先我们使用动词describe为套件起头,然后定义一个名字表明这是描述什么东西的行为,这里我们使用了"a stack"

每一条规则读起来尽可能接近英语中的句子。它们均以it.should打头,其中it指正在描述的对象。然后用一句简单的英语描述行为,最后使用expect.that做前缀,描述期待的行为。

检查规则时,会从命令行得到一个简单的报告,表明是否有规则失败。你会发现pop操作期望的返回值是2,而不是1,因此“pop the last element pushed onto the stack”这条规则就失败了:

  1. a stack
  2. should pop the last element pushed onto the stack[expected:➊ but was:➋ ]
  3. should be empty when created
  4. should push new elements onto the top of the stack

8.2.2 实现

读者已经领略了使用Lambda表达式的DSL所带来的便利,现在该看看我们是如何实现该框架的。我们希望会让大家看到,自己实现一个这样的框架是多么简单。

描述行为首先看到的是describe这个动词,简单导入一个静态方法就够了。为套件创建一个Description实例,在此处理各种各样的规则。Description类就是我们定义的DSL中的it(详见例8-29)。

例8-29 从describe方法开始定义规则

  1. public static void describe(String name, Suite behavior) {
  2. Description description = new Description(name);
  3. behavior.specifySuite(description);
  4. }

每个套件的规则描述由用户使用一个Lambda表达式实现,因此我们需要一个Suite函数接口来表示规则组成的套件,如例8-30所示。该接口接收一个Description对象作为参数,我们在describe方法里将其传入。

例8-30 每个测试套件都由一个实现该接口的Lambda表达式实现

  1. public interface Suite {
  2. public void specifySuite(Description description);
  3. }

在我们定义的DSL中,不仅套件由Lambda表达式实现,每一条规则也是一个Lambda表达式。它们也需要定义一个函数接口:Specification(如例8-31所示)。示例代码中的expect变量是Expect类的实例,我们稍后描述:

例8-31 每条规则都是一个实现该接口的Lambda表达式

  1. public interface Specification {
  2. public void specifyBehaviour(Expect expect);
  3. }

之前来回传递的Description实例这里就派上用场了。我们希望用户可以使用it.should命名他们的规则,这就是说Description类需要有一个should方法(如例8-32所示)。这里是真正做事的地方,该方法通过调用specifySuite执行Lambda表达式。如果规则失败,会抛出一个标准的Java AssertionError,而其他任何Throwable对象则认为是一个错误:

例8-32 将用Lambda表达式表示的规则传入should方法

  1. public void should(String description, Specification specification) {
  2. try {
  3. Expect expect = new Expect();
  4. specification.specifyBehaviour(expect);
  5. Runner.current.recordSuccess(suite, description);
  6. } catch (AssertionError cause) {
  7. Runner.current.recordFailure(suite, description, cause);
  8. } catch (Throwable cause) {
  9. Runner.current.recordError(suite, description, cause);
  10. }
  11. }

规则通过expect.that描述期望的行为,也就是说Expect类需要一个that方法供用户调用,如例8-33所示。这里可以封装传入的对象,然后暴露一些常用的方法,如isEqualTo。如果规则失败,抛出相应的断言。

例8-33 期望链的开始

  1. public final class Expect {
  2. public BoundExpectation that(Object value) {
  3. return new BoundExpectation(value);
  4. }
  5. // 省去类定义的其他部分

读者可能会注意到,我一直忽略了一个细节,该细节与Lambda表达式无关。StackSpec类并没有直接实现任何方法,我直接将代码写在里边。这里我偷了个懒,在类定义的开头和结尾使用了双括号:

  1. public class StackSpec {{
  2. ...
  3. }}

这其实是一个匿名构造函数,可以执行任意的Java代码块,所以这等价于一个完整的构造函数,只是少了一些样板代码。这段代码也可以写作:

  1. public class StackSpec {
  2. public StackSpec() {
  3. ...
  4. }
  5. }

要实现一个完整的BDD框架还有很多工作要做,本节只是为了向读者展示如何使用Lambda表达式创建领域专用语言。我在这里讲解了与DSL中Lambda表达式交互的部分,以期能帮助读者管中窥豹,了解如何实现这种类型的DSL。

8.2.3 评估

流畅性的一方面表现在DSL是否是IDE友好的。换句话说,你只需记住少量知识,然后用代码自动补全功能补齐代码。这就是使用DescriptionExpect对象的原因。当然也可以导入静态方法itexpect,一些DSL中就使用了这种方式。如果选择向Lambda表达式传入对象,而不是导入一个静态方法,就能让IDE的使用者轻松补全代码。

用户唯一要记住的是调用describe方法,这种方式的好处通过单纯阅读可能无法体会,我建议大家创建一个示例项目,亲自体验这个框架。

另一个值得注意的是大多数测试框架提供了大量注释,或者很多外部“魔法”,或者借助于反射。我们不需要这些技巧,就能直接使用Lambda表达式在DSL中表达行为,就和使用普通的Java方法一样。