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
describe("A suite is just a function", function() {it("and so is a spec", function() {var a = true;expect(a).toBe(true);});});
如果读者不熟悉JavaScript,阅读这段代码可能会稍感疑惑。下面我们使用Java 8实现一个类似的框架时会一步一步来,只需要记住,在JavaScript中我们使用function() { … }来表示Lambda表达式。
让我们分别来看看这些概念:
- 每一个规则描述了程序的一种行为;
- 期望是描述应用行为的一种方式,在规则中定义;
- 多个规则合在一起,形成一个套件。
这些概念在传统的测试框架,比如JUnit中,都有对应的概念。规则对应一个测试方法,期望对应断言,套件对应一个测试类。
8.2.1 使用Java编写DSL
让我们先看一下实现后的Java BDD框架长什么样子,例8-28描述了一个Stack的某些行为。
例8-28 描述Stack的案例
public class StackSpec {{describe("a stack", it -> {it.should("be empty when created", expect -> {expect.that(new Stack()).isEmpty();});it.should("push new elements onto the top of the stack", expect -> {Stack<Integer> stack = new Stack<>();stack.push(1);expect.that(stack.get(0)).isEqualTo(1);});it.should("pop the last element pushed onto the stack", expect -> {Stack<Integer> stack = new Stack<>();stack.push(2);stack.push(1);expect.that(stack.pop()).isEqualTo(2);});});}}
首先我们使用动词describe为套件起头,然后定义一个名字表明这是描述什么东西的行为,这里我们使用了"a stack"。
每一条规则读起来尽可能接近英语中的句子。它们均以it.should打头,其中it指正在描述的对象。然后用一句简单的英语描述行为,最后使用expect.that做前缀,描述期待的行为。
检查规则时,会从命令行得到一个简单的报告,表明是否有规则失败。你会发现pop操作期望的返回值是2,而不是1,因此“pop the last element pushed onto the stack”这条规则就失败了:
a stackshould pop the last element pushed onto the stack[expected:➊ but was:➋ ]should be empty when createdshould push new elements onto the top of the stack
8.2.2 实现
读者已经领略了使用Lambda表达式的DSL所带来的便利,现在该看看我们是如何实现该框架的。我们希望会让大家看到,自己实现一个这样的框架是多么简单。
描述行为首先看到的是describe这个动词,简单导入一个静态方法就够了。为套件创建一个Description实例,在此处理各种各样的规则。Description类就是我们定义的DSL中的it(详见例8-29)。
例8-29 从describe方法开始定义规则
public static void describe(String name, Suite behavior) {Description description = new Description(name);behavior.specifySuite(description);}
每个套件的规则描述由用户使用一个Lambda表达式实现,因此我们需要一个Suite函数接口来表示规则组成的套件,如例8-30所示。该接口接收一个Description对象作为参数,我们在describe方法里将其传入。
例8-30 每个测试套件都由一个实现该接口的Lambda表达式实现
public interface Suite {public void specifySuite(Description description);}
在我们定义的DSL中,不仅套件由Lambda表达式实现,每一条规则也是一个Lambda表达式。它们也需要定义一个函数接口:Specification(如例8-31所示)。示例代码中的expect变量是Expect类的实例,我们稍后描述:
例8-31 每条规则都是一个实现该接口的Lambda表达式
public interface Specification {public void specifyBehaviour(Expect expect);}
之前来回传递的Description实例这里就派上用场了。我们希望用户可以使用it.should命名他们的规则,这就是说Description类需要有一个should方法(如例8-32所示)。这里是真正做事的地方,该方法通过调用specifySuite执行Lambda表达式。如果规则失败,会抛出一个标准的Java AssertionError,而其他任何Throwable对象则认为是一个错误:
例8-32 将用Lambda表达式表示的规则传入should方法
public void should(String description, Specification specification) {try {Expect expect = new Expect();specification.specifyBehaviour(expect);Runner.current.recordSuccess(suite, description);} catch (AssertionError cause) {Runner.current.recordFailure(suite, description, cause);} catch (Throwable cause) {Runner.current.recordError(suite, description, cause);}}
规则通过expect.that描述期望的行为,也就是说Expect类需要一个that方法供用户调用,如例8-33所示。这里可以封装传入的对象,然后暴露一些常用的方法,如isEqualTo。如果规则失败,抛出相应的断言。
例8-33 期望链的开始
public final class Expect {public BoundExpectation that(Object value) {return new BoundExpectation(value);}// 省去类定义的其他部分
读者可能会注意到,我一直忽略了一个细节,该细节与Lambda表达式无关。StackSpec类并没有直接实现任何方法,我直接将代码写在里边。这里我偷了个懒,在类定义的开头和结尾使用了双括号:
public class StackSpec {{...}}
这其实是一个匿名构造函数,可以执行任意的Java代码块,所以这等价于一个完整的构造函数,只是少了一些样板代码。这段代码也可以写作:
public class StackSpec {public StackSpec() {...}}
要实现一个完整的BDD框架还有很多工作要做,本节只是为了向读者展示如何使用Lambda表达式创建领域专用语言。我在这里讲解了与DSL中Lambda表达式交互的部分,以期能帮助读者管中窥豹,了解如何实现这种类型的DSL。
8.2.3 评估
流畅性的一方面表现在DSL是否是IDE友好的。换句话说,你只需记住少量知识,然后用代码自动补全功能补齐代码。这就是使用Description和Expect对象的原因。当然也可以导入静态方法it或expect,一些DSL中就使用了这种方式。如果选择向Lambda表达式传入对象,而不是导入一个静态方法,就能让IDE的使用者轻松补全代码。
用户唯一要记住的是调用describe方法,这种方式的好处通过单纯阅读可能无法体会,我建议大家创建一个示例项目,亲自体验这个框架。
另一个值得注意的是大多数测试框架提供了大量注释,或者很多外部“魔法”,或者借助于反射。我们不需要这些技巧,就能直接使用Lambda表达式在DSL中表达行为,就和使用普通的Java方法一样。
