10.4 Java 8 DSL的实际应用

在10.3节中,我们学习了使用Java开发DSL的三种模式,包括它们的优缺点。表10-1总结了迄今为止我们介绍的所有内容。

表 10-1 DSL模式及其优缺点

模式名 优点 缺点
方法链接 □ 方法名可以作为关键字参数 □ 与optional参数的兼容性很好 □ 可以强制DSL的用户按照预定义的顺序调用方法 □ 很少使用或者基本不使用静态方法 □ 可能的语法噪声很低 □ 实现起来代码很冗长 □ 需要使用胶水语言整合多个构建器 □ 领域对象的层级只能通过代码的缩进公约定义
嵌套函数 □ 实现代码比较简洁 □ 领域对象的层次与函数嵌套保持一致 □ 大量使用了静态方法 □ 参数通过位置而非变量名识别 □ 支持可选参数需要实现重载方法
使用Lambda的函数序列 □ 对可选参数的支持很好 □ 很少或者基本不使用静态方法 □ 领域对象的层次与Lambda的嵌套保持一致 □ 不需要为支持构建器而使用胶水语言 □ 实现代码很冗长 □ DSL中的Lambda表达式会带来更多的语法噪声

接下来我们会通过分析这些模式在三个著名Java库中的应用来对之前介绍的内容做一个总结。这三个库分别是:一个SQL映射工具、一个行为驱动的开发框架以及一个实现企业级集成模式的工具。

10.4.1 jOOQ

SQL是最通用且应用最广泛的DSL。基于这个事实,如果我跟你说有人为Java编写了一个很不错的DSL,通过它可以编写和执行SQL语句,你应该不会感到意外。jOOQ作为类型安全的嵌入式语言,是直接用Java实现的一种内部DSL。源代码生成器逆向工程了数据库模式,如此一来Java编译器就可以对复杂的SQL语句进行类型检查了。你可以使用这种逆向工程生成的信息对你的数据库进行操作。下面是一个数据库查询的简单示例:

  1. SELECT * FROM BOOK
  2. WHERE BOOK.PUBLISHED_IN = 2016
  3. ORDER BY BOOK.TITLE

使用jOOQ DSL可以将其重写为下面这种形式:

  1. create.selectFrom(BOOK)
  2. .where(BOOK.PUBLISHED_IN.eq(2016))
  3. .orderBy(BOOK.TITLE)

jOOQ DSL的另一个非常好用的特性是它能够与Stream API无缝联合使用。这一特性让你可以使用流畅语句在内存中对SQL查询的结果进行操作,代码清单如下。

代码清单 10-17 使用JOOQ DSL从数据库中查询图书信息

  1. Class.forName("org.h2.Driver");
  2. try (Connection c =
  3. getConnection("jdbc:h2:~/sql-goodies-with-mapping", "sa", "")) { ←---- 创建SQL数据库的连接
  4. DSL.using(c) ←---- 使用刚刚创建的数据库连接启动jOOQ SQL语句
  5. .select(BOOK.AUTHOR, BOOK.TITLE) ←---- 通过jOOQ DL定义SQL语句
  6. .where(BOOK.PUBLISHED_IN.eq(2016))
  7. .orderBy(BOOK.TITLE)
  8. .fetch() ←---- 从数据库中返回数据,jOOQ语句终止于此
  9. .stream() ←---- 开始使用Stream API处理从数据库中取得的数据
  10. .collect(groupingBy( ←---- 按照作者对图书进行分类
  11. r -> r.getValue(BOOK.AUTHOR),
  12. LinkedHashMap::new,
  13. mapping(r -> r.getValue(BOOK.TITLE), toList())))
  14. .forEach((author, titles) -> ←---- 把作者名及其所著书名一起打印出来
  15. System.out.println(author + " is author of " + titles));
  16. }

很明显,jOOQ DSL选择使用的主要DSL模式是方法链接。实际上,这个模式(支持可选参数,某些方法只能按照预先定义的顺序执行调用)的很多特征对模仿格式规范的SQL查询语法都非常重要。这些特性以及它们很小的语法噪声,使得方法链接模式非常适合jOOQ的需求。

10.4.2 Cucumber

行为驱动开发(behavior-driven development,BDD)是测试驱动开发的延伸,它使用由结构化语句构成的简单领域特定脚本语言描述业务场景,这些业务场景可以是多种多样的。与其他的BDD框架一样,Cucumber可以将这种结构化语句转化为可执行的测试用例。因此,采用这种开发技术的脚本既能作为可执行的测试,也能作为该业务特性的接受标准。BDD还专注于帮助大家快速地发布高优先级、可验证的业务价值,同时通过让领域专家和程序员共享业务词汇,减少他们在需求理解上的差异。

这些概念听起来都很抽象,我们接下来会借助一个BDD工具——Cucumber,作为实际的例子进行介绍。Cucumber可以帮助开发者通过纯英文书写业务场景,如下所示:

  1. Feature: Buy stock
  2. Scenario: Buy 10 IBM stocks
  3. Given the price of a "IBM" stock is 125$
  4. When I buy 10 "IBM"
  5. Then the order value should be 1250$

Cucumber使用声明将业务需求分成了三部分:需求定义的前提(Given)、测试时对领域对象的实际调用(When),以及检查测试用例结果的断言(Then)。

定义测试场景的脚本使用外部DSL编写,它的关键字数量有限,除此之外你可以随心所欲地书写语句,没有别的规则。测试用例会通过正则表达式匹配这些语句,捕获其中的变量,将其作为参数传递给实现测试的方法自身。以10.3节开始时介绍的股票交易领域模型为例,我们可以开发一个Cucumber测试用例,检查股票交易订单是否计算正确,代码清单如下。

代码清单 10-18 使用Cucumber注解实现一个测试场景

  1. public class BuyStocksSteps {
  2. private Map<String, Integer> stockUnitPrices = new HashMap<>();
  3. private Order order = new Order();
  4. @Given("^the price of a \"(.*?)\" stock is (\\d+)\\$$") ←---- 定义该场景的前置条件和股票的单位价格
  5. public void setUnitPrice(String stockName, int unitPrice) {
  6. stockUnitValues.put(stockName, unitPrice); ←---- 保存股票的单位价格
  7. }
  8. @When("^I buy (\\d+) \"(.*?)\"$") ←---- 定义测试领域模型时的动作
  9. public void buyStocks(int quantity, String stockName) {
  10. Trade trade = new Trade(); ←---- 生成相应的领域模型
  11. trade.setType(Trade.Type.BUY);
  12. Stock stock = new Stock();
  13. stock.setSymbol(stockName);
  14. trade.setStock(stock);
  15. trade.setPrice(stockUnitPrices.get(stockName));
  16. trade.setQuantity(quantity);
  17. order.addTrade(trade);
  18. }
  19. @Then("^the order value should be (\\d+)\\$$") ←---- 定义期望的场景输出
  20. public void checkOrderValue(int expectedValue) {
  21. assertEquals(expectedValue, order.getValue()); ←---- 检查测试的断言
  22. }
  23. }

Java 8引入的Lambda表达式赋予了Cucumber新的活力,借助于新语法,你可以使用带两个参数的方法替换掉注释,这两个参数分别是:包含之前注释中期望值的正则表达式以及实现测试方法的Lambda表达式。使用第二种标记法,你可以像下面这样重写测试场景:

  1. public class BuyStocksSteps implements cucumber.api.java8.En {
  2. private Map<String, Integer> stockUnitPrices = new HashMap<>();
  3. private Order order = new Order();
  4. public BuyStocksSteps() {
  5. Given("^the price of a \"(.*?)\" stock is (\\d+)\\$$",
  6. (String stockName, int unitPrice) -> {
  7. stockUnitValues.put(stockName, unitPrice);
  8. });
  9. // ……为了简洁起见,我们省略了更多的Lambda,譬如什么情况要做什么
  10. }
  11. }

第二种实现方法明显更加紧凑。尤其是使用匿名Lambda替换了测试方法后,你再也不用绞尽脑汁地替方法构思有意义的名字了(测试场景中,这并不会为可读性带来太多的提升)。

Cucumber的DSL非常简单,但是它展示了如何有效地整合外部DSL与内部DSL,并且(再一次)证明了使用Lambda可以写出更精简、更具可读性的代码。

10.4.3 Spring Integration

为了支持著名的企业集成模式3,Spring Integration扩展了基于Spring编程模型的依赖注入。Spring Integration的首要目标是要提供一个简单的模型用于实现复杂的企业整合方案,并推广异步、消息驱动架构的采用。

3详情请参考由Gregor Hohpe和Bobby Woolf在2004年出版的Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions

有了Spring Integration之后,在基于Spring的应用中开发轻量级的远程服务(remoting)、消息(messaging),以及计划任务(scheduling)都很方便。这些特性可以借由形式丰富的流畅DSL实现,而这并不只是基于Spring传统XML配置文件构建的语法糖。

Spring Integration实现了创建基于消息的应用所需的所有常用模式,包括管道(channel)、消息处理节点(endpoint)、轮询器(poller)、管道拦截器(channel interceptor)。为了改善可读性,处理节点在该DSL中被表述为动词,集成的过程就是将这些处理节点组合成一个或多个消息流。下面这段代码就是一个展示Spring Integration如何工作的例子,虽然简单,但是“五脏俱全”。

代码清单 10-19 使用Spring Integration DSL配置一个Spring Integration的工作流

  1. @Configuration
  2. @EnableIntegration
  3. public class MyConfiguration {
  4. @Bean
  5. public MessageSource<?> integerMessageSource() {
  6. MethodInvokingMessageSource source =
  7. new MethodInvokingMessageSource(); ←---- 创建一个新消息源,每次调用是以原子操作的方式递增一个整型变量
  8. source.setObject(new AtomicInteger());
  9. source.setMethodName("getAndIncrement");
  10. return source;
  11. }
  12. @Bean
  13. public DirectChannel inputChannel() {
  14. return new DirectChannel(); ←---- 管道传送由消息源发送过来的数据
  15. }
  16. @Bean
  17. public IntegrationFlow myFlow() {
  18. return IntegrationFlows ←---- 以方法链接方式通过一个构建器创建IntegrationFlow
  19. .from(this.integerMessageSource(), ←---- 以之前定义的MessageSource作为IntegrationFlow的来源
  20. c -> c.poller(Pollers.fixedRate(10))) ←---- 轮询MessageSource,对它传递的数据队列执行出队操作,取出数据
  21. .channel(this.inputChannel())
  22. .filter((Integer p) -> p % 2 == 0) ←---- 过滤出那些偶数
  23. .transform(Object::toString) ←---- 将由MessageSource 获取的整数转换为字符串类型
  24. .channel(MessageChannels.queue("queueChannel")) ←---- queueChannel作为该IntegrationFlow的输出管道
  25. .get(); ←---- 终止IntegrationFlow的构建执行,并返回结果
  26. }
  27. }

这段代码中,方法myFlow()构建IntegrationFlow时使用了Spring Integration DSL。它使用的是IntegrationFlow类提供的流畅构建器,该构建器采用的就是方法链接模式。这个例子中,最终的流会以固定的频率轮询MessageSource,生成一个整数序列,过滤出其中的偶数,再将它们转化为字符串,最终将结果发送给输出管道,这种行为与Java 8原生的Stream API非常像。该API允许你将消息发送给流中的任何一个组件,只要你知道它的inputChannel名。如果流始于一个直接管道(direct channel),而非一个MessageSource,你完全可以使用Lambda表达式定义该IntegrationFlow,如下所示:

  1. @Bean
  2. public IntegrationFlow myFlow() {
  3. return flow -> flow.filter((Integer p) -> p % 2 == 0)
  4. .transform(Object::toString)
  5. .handle(System.out::println);
  6. }

如你所见,目前Spring Integration DSL中使用最广泛的模式是方法链接。这种模式非常适合IntegrationFlow构建器的主要用途: 创建一个执行消息传递和数据转换的流。然而,正如我们在上一个例子中看到的那样,它也并非只用一种模式,构建顶层对象时它也使用了Lambda表达式的函数序列(有些情况下,也是为了解决方法内部更加复杂的参数传递问题)。