There are two ways of constructing a software design. One way is to make it so simple that there are obviously no deficiencies. And the other way is to make it so complicated that there are no obvious deficiencies. -- C.A.R. Hoarejava
本文是《Programming DSL》
系列文章的第2
篇,若是該主題感興趣,能夠查閱以下文章:git
本文經過「JSpec
」的設計和實現的過程,加深認識「內部DSL
」設計的基本思路。JSpec
是使用Java8
實現的一個簡單的「BDD
」測試框架。數據結構
在Java
社區中,JUnit
是一個普遍被使用的測試框架。不幸的是,JUnit
的測試用例必須遵循嚴格的「標識符」命名規則,給程序員帶來了很大的不便。app
Junit
爲了作到「自動發現」機制,在運行時完成用例的組織,規定全部的測試用例必須遵循public void testXXX()
的函數原型。框架
public void testTrue() { Assert.assertTrue(true); }
自Java 1.5
支持「註解」以後,社區逐步意識到了「註解優於命名模式」的最佳實踐,JUnit
使用@Test
註解,加強了用例的表現力。jsp
@Test public void alwaysTrue() { Assert.assertTrue(true); }
Given-When-Then
通過實踐證實,基於場景驗收的Given-When-Then
命名風格具備強大的表現力。但JUnit
遵循嚴格的標示符命名規則,程序員須要承受巨大的痛苦。ide
這種混雜「駝峯」和「下劃線」的命名風格,雖然在社區中獲得了普遍的應用,但在重命名時,變得很是不方便。函數
public class GivenAStack { @Test public void should_be_empty_when_created() { } @Test public void should_pop_the_last_element_pushed_onto_the_stack() { } }
以RSpec, Cucumber, Jasmine
等爲表明的[BDD」(Behavior-Driven Development)
測試框架以強大的表現力,迅速獲得了社區的普遍應用。其中,RSpec, Jasmine
就是我較爲喜好的測試框架。例如,Jasmine
的JavaScript
測試用例是這樣的。
describe("A suite", function() { it("contains spec with an expectation", function() { expect(true).toBe(true); }); });
咱們將嘗試設計和實現一個Java
版的BDD
測試框架:JSpec
。它的風格與Jasmine
基本相似,並與Junit4
配合得完美無瑕。
@RunWith(JSpec.class) public class JSpecs {{ describe("A spec", () -> { List<String> items = new ArrayList<>(); before(() -> { items.add("foo"); items.add("bar"); }); after(() -> { items.clear(); }); it("runs the before() blocks", () -> { assertThat(items, contains("foo", "bar")); }); describe("when nested", () -> { before(() -> { items.add("baz"); }); it("runs before and after from inner and outer scopes", () -> { assertThat(items, contains("foo", "bar", "baz")); }); }); }); }}
public class JSpecs {{ ...... }}
嵌套兩層{}
,這是Java
的一種特殊的初始化方法,常稱爲初始化塊
。其行爲與以下代碼類同,但它更加簡潔、漂亮。
public class JSpecs { public JSpecs() { ...... } }
describe, it, before, after
都存在一個() -> {...}
代碼塊,以便實現行爲的定製化,爲此先抽象一個Block
的概念。
@FunctionalInterface public interface Block { void apply() throws Throwable; }
定義以下幾個函數,明確JSpec DSL
的基本雛形。
public class JSpec { public static void describe(String desc, Block block) { ...... } public static void it(String behavior, Block block) { ...... } public static void before(Block block) { ...... } public static void after(Block block) { ...... }
describe
能夠嵌套describe, it, before, after
的代碼塊,而且外層的describe
給內嵌的代碼塊創建了「上下文」環境。
例如,items
在最外層的describe
中定義,它對describe
整個內部均可見。
describe
能夠嵌套describe
,而且describe
爲內部的結構創建「上下文」,所以describe
之間創建了一棵「隱式樹」。
爲此,抽象出了Context
的概念,用於描述describe
的運行時。也就是是,Context
描述了describe
內部可見的幾個重要實體:
List<Block> befores:before
代碼塊集合
List<Block> afters:after
代碼塊集合
Description desc:
包含了父子之間的層次關係等上下文描述信息
Deque<Executor> executors:
執行器的集合。
Executor
在後文介紹,能夠將Executor
理解爲Context
及其Spec
的運行時行爲;其中,Context
對於於desribe
子句,Spec
對於於it
子句。
由於describe
之間存在「隱式樹」的關係,Context
及Spec
之間也就造成了「隱式樹」的關係。
public class Context { private List<Block> befores = new ArrayList<>(); private List<Block> afters = new ArrayList<>(); private Deque<Executor> executors = new ArrayDeque<>(); private Description desc; public Context(Description desc) { this.desc = desc; } public void addChild(Context child) { desc.addChild(child.desc); executors.add(child); child.addBefore(collect(befores)); child.addAfter(collect(afters)); } public void addBefore(Block block) { befores.add(block); } public void addAfter(Block block) { afters.add(block); } public void addSpec(String behavior, Block block) { Description spec = createTestDescription(desc.getClassName(), behavior); desc.addChild(spec); addExecutor(spec, block); } private void addExecutor(Description desc, Block block) { Spec spec = new Spec(desc, blocksInContext(block)); executors.add(spec); } private Block blocksInContext(Block block) { return collect(collect(befores), block, collect(afters)); } }
addChild
describe
嵌套describe
時,經過addChild
完成了兩件重要工做:
「子Context
」向「父Context
」的註冊;也就是說,Context
之間造成了「樹」形結構;
控制父Context
中的before/after
的代碼塊集合對子Context
的可見性;
public void addChild(Context child) { desc.addChild(child.desc); executors.add(child); child.addBefore(collect(befores)); child.addAfter(collect(afters)); }
其中,collect
定義於Block
接口中,完成before/after
代碼塊「集合」的迭代處理。這相似於OO
世界中的「組合模式」,它們表明了一種隱式的「樹狀結構」。
public interface Block { void apply() throws Throwable; static Block collect(Iterable<? extends Block> blocks) { return () -> { for (Block b : blocks) { b.apply(); } }; } }
addExecutor
其中,Executor
存在兩種狀況:
Spec:
使用it
定義的用例的代碼塊
Context:
使用describe
定義上下文。
爲此,addExecutor
被addSpec, addChild
所調用。addExecutor
調用時,將Spec
註冊到Executor
集合中,並定義了Spec
的「執行規則」。
private void addExecutor(Description desc, Block block) { Spec spec = new Spec(desc, blocksInContext(block)); executors.add(spec); } private Block blocksInContext(Block block) { return collect(collect(befores), block, collect(afters)); }
blocksInContext
將it
的「執行序列」行爲固化。
首先執行before
代碼塊集合;
而後執行it
代碼塊;
最後執行after
代碼塊集合;
Executor
以前談過,Executor
存在兩種狀況:
Spec:
使用it
定義的用例的代碼塊
Context:
使用describe
定義上下文。
也就是說,Executor
構成了一棵「樹狀」的數據結構;it
扮演了「葉子節點」的角色;Context
扮演了「非葉子節點」的角色。爲此,Executor
的設計採用了「組合模式」。
import org.junit.runner.notification.RunNotifier; @FunctionalInterface public interface Executor { void exec(RunNotifier notifier); }
Spec
Spec
完成對it
行爲的封裝,當exec
時完成it
代碼塊() -> {...}
的調用。
public class Spec implements Executor { public Spec(Description desc, Block block) { this.desc = desc; this.block = block; } @Override public void exec(RunNotifier notifier) { notifier.fireTestStarted(desc); runSpec(notifier); notifier.fireTestFinished(desc); } private void runSpec(RunNotifier notifier) { try { block.apply(); } catch (Throwable t) { notifier.fireTestFailure(new Failure(desc, t)); } } private Description desc; private Block block; }
Context
public class Context implements Executor { ...... private Description desc; @Override public void exec(RunNotifier notifier) { for (Executor e : executors) { e.exec(notifier); } } }
DSL
有了Context
的領域模型的基礎,DSL
的實現變得簡單了。
public class JSpec { private static Deque<Context> ctxts = new ArrayDeque<Context>(); public static void describe(String desc, Block block) { Context ctxt = new Context(createSuiteDescription(desc)); enterCtxt(ctxt, block); } public static void it(String behavior, Block block) { currentCtxt().addSpec(behavior, block); } public static void before(Block block) { currentCtxt().addBefore(block); } public static void after(Block block) { currentCtxt().addAfter(block); } private static void enterCtxt(Context ctxt, Block block) { currentCtxt().addChild(ctxt); applyBlock(ctxt, block); } private static void applyBlock(Context ctxt, Block block) { ctxts.push(ctxt); doApplyBlock(block); ctxts.pop(); } private static void doApplyBlock(Block block) { try { block.apply(); } catch (Throwable e) { it("happen to an error", failing(e)); } } private static Context currentCtxt() { return ctxts.peek(); } }
但爲了控制Context
之間的「樹型關係」(即describe
的嵌套關係),爲此創建了一個Stack
的機制,保證運行時在某一個時刻Context
的惟一性。
只有describe
的調用會開啓「上下文的創建」,並完成上下文「父子關係」的連接。其他操做,例如it, before, after
都是在當前上下文進行「元信息」的註冊。
使用靜態初始化塊,完成「虛擬根結點」的註冊;也就是說,在運行時初始化時,棧中已存在惟一的 Context("JSpec: All Specs")
虛擬根節點。
public class JSpec { private static Deque<Context> ctxts = new ArrayDeque<Context>(); static { ctxts.push(new Context(createSuiteDescription("JSpec: All Specs"))); } ...... }
爲了配合JUnit
框架將JSpec
運行起來,須要定製一個JUnit
的Runner
。
public class JSpec extends Runner { private Description desc; private Context root; public JSpec(Class<?> suite) { desc = createSuiteDescription(suite); root = new Context(desc); enterCtxt(root, reflect(suite)); } @Override public Description getDescription() { return desc; } @Override public void run(RunNotifier notifier) { root.exec(notifier); } ...... }
在編寫用例時,使用@RunWith(JSpec.class)
註解,告訴JUnit
定製化了運行器的行爲。
@RunWith(JSpec.class) public class JSpecs {{ ...... }}
在以前已討論過,JSpec
的run
無非就是將「以樹形組織的」Executor
集合調度起來。
reflect
JUnit
在運行時,首先看到了@RunWith(JSpec.class)
註解,而後反射調用JSpec
的構造函數。
public JSpec(Class<?> suite) { desc = createSuiteDescription(suite); root = new Context(desc); enterCtxt(root, reflect(suite)); }
經過Block.reflect
的工廠方法,將開始執行測試用例集的「初始化塊」。
public interface Block { void apply() throws Throwable; static Block reflect(Class<?> c) { return () -> { Constructor<?> cons = c.getDeclaredConstructor(); cons.setAccessible(true); cons.newInstance(); }; } }
此刻,被@RunWith(JSpec.class)
註解標註的「初始化塊」被執行。
@RunWith(JSpec.class) public class JSpecs {{ ...... }}
在「初始化塊」中順序完成對describe, it, before, after
等子句的調用,其中:
describe
開闢新的Context
;
describe
能夠遞歸地調用內部嵌套的describe
;
describe
調用it, before, after
時,將信息註冊到了Context
中;
最終Runner.run
將Executor
集合按照「樹」的組織方式調度起來;
JSpec
已上傳至GitHub:
https://github.com/horance-liu/jspec,代碼細節請參考源代碼。