10.3 使用Java创建DSL的模式与技巧

DSL提供了用户友好、可读性强的API,能帮你高效地处理特定的领域模型。我们由定义一个简单的领域模型展开本节内容,接着会讨论在此模型之上创建DSL有哪些模式可用。

本节例子的领域模型包括三样东西。首先是简单的Java beans,用于对指定市场的股票报价进行建模:

  1. public class Stock {
  2. private String symbol;
  3. private String market;
  4. public String getSymbol() {
  5. return symbol;
  6. }
  7. public void setSymbol(String symbol) {
  8. this.symbol = symbol;
  9. }
  10. public String getMarket() {
  11. return market;
  12. }
  13. public void setMarket(String market) {
  14. this.market = market;
  15. }
  16. }

其次是按照给定价格买入或者卖出指定数量股票的交易:

  1. public class Trade {
  2. public enum Type { BUY, SELL }
  3. private Type type;
  4. private Stock stock;
  5. private int quantity;
  6. private double price;
  7. public Type getType() {
  8. return type;
  9. }
  10. public void setType(Type type) {
  11. this.type = type;
  12. }
  13. public int getQuantity() {
  14. return quantity;
  15. }
  16. public void setQuantity(int quantity) {
  17. this.quantity = quantity;
  18. }
  19. public double getPrice() {
  20. return price;
  21. }
  22. public void setPrice(double price) {
  23. this.price = price;
  24. }
  25. public Stock getStock() {
  26. return stock;
  27. }
  28. public void setStock(Stock stock) {
  29. this.stock = stock;
  30. }
  31. public double getValue() {
  32. return quantity * price;
  33. }
  34. }

最后是客户提交的要求完成一个或多个交易的订单:

  1. public class Order {
  2. private String customer;
  3. private List<Trade> trades = new ArrayList<>();
  4. public void addTrade(Trade trade) {
  5. trades.add(trade);
  6. }
  7. public String getCustomer() {
  8. return customer;
  9. }
  10. public void setCustomer(String customer) {
  11. this.customer = customer;
  12. }
  13. public double getValue() {
  14. return trades.stream().mapToDouble(Trade::getValue).sum();
  15. }
  16. }

这个领域模型很直白。譬如,创建表示订单的对象非常烦琐。如果你要为你的客户BigBank定义一个包含两项交易的订单,代码清单如下。

代码清单 10-4 直接使用领域对象的API创建股票交易订单

  1. Order order = new Order();
  2. order.setCustomer("BigBank");
  3. Trade trade1 = new Trade();
  4. trade1.setType(Trade.Type.BUY);
  5. Stock stock1 = new Stock();
  6. stock1.setSymbol("IBM");
  7. stock1.setMarket("NYSE");
  8. trade1.setStock(stock1);
  9. trade1.setPrice(125.00);
  10. trade1.setQuantity(80);
  11. order.addTrade(trade1);
  12. Trade trade2 = new Trade();
  13. trade2.setType(Trade.Type.BUY);
  14. Stock stock2 = new Stock();
  15. stock2.setSymbol("GOOGLE");
  16. stock2.setMarket("NASDAQ");
  17. trade2.setStock(stock2);
  18. trade2.setPrice(375.00);
  19. trade2.setQuantity(50);
  20. order.addTrade(trade2);

这段代码的烦琐程度让人几乎无法接受,你不可能期望一个非开发人员领域专家能够快速理解并验证它。你需要一个能够反映你的领域模型并能通过直接且直观方式修改它的DSL。你有很多途径能达到这一效果。接下来的一节会讨论这些途径的优缺点。

10.3.1 方法链接

方法链接(method chaining)是我们要探讨的第一个DSL类型,也是最常见的类型。它允许你用单链方法调用定义一个交易订单。下面的代码清单是这种类型DSL的一个示例。

代码清单 10-5 使用方法链接创建一个股票交易订单

  1. Order order = forCustomer( "BigBank" )
  2. .buy( 80 )
  3. .stock( "IBM" )
  4. .on( "NYSE" )
  5. .at( 125.00 )
  6. .sell( 50 )
  7. .stock( "GOOGLE" )
  8. .on( "NASDAQ" )
  9. .at( 375.00 )
  10. .end();

这段代码现在看起来清爽多了,这是个重大的改进,难道你不这样觉得吗?现在你的领域专家不用花太多精力就能理解这段代码。不过,怎样实现DSL才能达到这个效果呢?你需要几个能通过流畅API创建领域对象的构建器。顶层的构建器创建并封装订单,一个或多个交易可以被添加到订单之中,代码清单如下。

代码清单 10-6 提供方法链接DSL的订单构建器

  1. public class MethodChainingOrderBuilder {
  2. public final Order order = new Order(); ←---- 由构建器封装的订单对象
  3. private MethodChainingOrderBuilder(String customer) {
  4. order.setCustomer(customer);
  5. }
  6. public static MethodChainingOrderBuilder forCustomer(String customer) {
  7. return new MethodChainingOrderBuilder(customer); ←---- 静态工厂方法,用于创建指定客户订单的构建器
  8. }
  9. public TradeBuilder buy(int quantity) {
  10. return new TradeBuilder(this, Trade.Type.BUY, quantity); ←---- 创建一个TradeBuilder,构造一个购买股票的交易
  11. }
  12. public TradeBuilder sell(int quantity) {
  13. return new TradeBuilder(this, Trade.Type.SELL, quantity); ←---- 创建一个TradeBuilder,构造一个卖出股票的交易
  14. }
  15. public MethodChainingOrderBuilder addTrade(Trade trade) {
  16. order.addTrade(trade); ←---- 向订单中添加交易
  17. return this; ←---- 返回订单构建器自身,允许你流畅地创建和添加新的交易
  18. }
  19. public Order end() {
  20. return order; ←---- 终止创建订单并返回它
  21. }
  22. }

订单构建器的buy()sell()方法创建并返回另一个构建器,该构建器会构建一个交易,并将其添加到本订单中:

  1. public class TradeBuilder {
  2. private final MethodChainingOrderBuilder builder;
  3. public final Trade trade = new Trade();
  4. private TradeBuilder(MethodChainingOrderBuilder builder,
  5. Trade.Type type, int quantity) {
  6. this.builder = builder;
  7. trade.setType( type );
  8. trade.setQuantity( quantity );
  9. }
  10. public StockBuilder stock(String symbol) {
  11. return new StockBuilder(builder, trade, symbol);
  12. }
  13. }

TradeBuilder的唯一一个公有方法用于创建更深一层的构建器,这个构建器会创建股票类的实例:

  1. public class StockBuilder {
  2. private final MethodChainingOrderBuilder builder;
  3. private final Trade trade;
  4. private final Stock stock = new Stock();
  5. private StockBuilder(MethodChainingOrderBuilder builder,
  6. Trade trade, String symbol) {
  7. this.builder = builder;
  8. this.trade = trade;
  9. stock.setSymbol(symbol);
  10. }
  11. public TradeBuilderWithStock on(String market) {
  12. stock.setMarket(market);
  13. trade.setStock(stock);
  14. return new TradeBuilderWithStock(builder, trade);
  15. }
  16. }

StockBuilder仅有一个方法on(),它负责设定股票的市场,将股票添加到交易中,并返回上一个构建器:

  1. public class TradeBuilderWithStock {
  2. private final MethodChainingOrderBuilder builder;
  3. private final Trade trade;
  4. public TradeBuilderWithStock(MethodChainingOrderBuilder builder,
  5. Trade trade) {
  6. this.builder = builder;
  7. this.trade = trade;
  8. }
  9. public MethodChainingOrderBuilder at(double price) {
  10. trade.setPrice(price);
  11. return builder.addTrade(trade);
  12. }
  13. }

公有方法TradeBuilderWithStock设定了交易股票的单位价格,并返回了原始的订单构建器。如你所见,使用这个方法你可以流畅地向订单中添加交易,直到终结方法MethodChainingOrderBuilder被调用。做出使用多个构建器类——尤其是使用两个不同的交易构建器的选择,是为了强制该DSL的用户调用它的流畅API之前明确其调用顺序,确保在用户启动创建下一个交易之前交易的配置都没有问题。这种方式的另一个好处是,用于设置订单的参数都保存在构建器的范畴之内。这种方式极大地减少了静态方法的使用,使得方法名可以作为命名参数传递,从而进一步改善了这种风格DSL的代码可读性。最后,使用该技巧的流畅DSL可能的语义噪声也最少。

非常不幸,这个方法也有其弊端。主要的问题是,方法链接需要实现非常冗长的构建器。为了将顶层构建器与底层构建器相融合,需要使用大量的胶水代码。另一个明显的缺点是,你之前可能要求你领域中对象的嵌套层次遵守统一的缩进规范,而在这种DSL中你没有有效的方法强制执行同样的标准。

下一节将研究具有完全不同特性的第二个DSL类型。

10.3.2 使用嵌套函数

嵌套函数DSL(nested function DSL)模式的名称源于它使用嵌套于其他函数的函数来生成领域模型。下面的代码清单就是使用这种DSL风格的一个例子。

代码清单 10-7 使用嵌套函数创建股票交易订单

  1. Order order = order("BigBank",
  2. buy(80,
  3. stock("IBM", on("NYSE")),
  4. at(125.00)),
  5. sell(50,
  6. stock("GOOGLE", on("NASDAQ")),
  7. at(375.00))
  8. );

实现这种DSL风格所需的代码比我们在10.3.1节中看到的更加精简。

如下面代码清单中的NestedFunctionOrderBuilder所示,你可以用这种DSL风格为你的用户提供API(假设下面代码清单中需要的所有静态方法都已经默认导入)。

代码清单 10-8 提供嵌套函数DSL的订单构建器

  1. public class NestedFunctionOrderBuilder {
  2. public static Order order(String customer, Trade... trades) {
  3. Order order = new Order(); ←---- 为指定用户创建订单
  4. order.setCustomer(customer);
  5. Stream.of(trades).forEach(order::addTrade); ←---- 将所有的交易添加到订单
  6. return order;
  7. }
  8. public static Trade buy(int quantity, Stock stock, double price) {
  9. return buildTrade(quantity, stock, price, Trade.Type.BUY); ←---- 创建一个买入股票的交易
  10. }
  11. public static Trade sell(int quantity, Stock stock, double price) {
  12. return buildTrade(quantity, stock, price, Trade.Type.SELL); ←---- 创建一个卖出股票的交易
  13. }
  14. private static Trade buildTrade(int quantity, Stock stock, double price,
  15. Trade.Type buy) {
  16. Trade trade = new Trade();
  17. trade.setQuantity(quantity);
  18. trade.setType(buy);
  19. trade.setStock(stock);
  20. trade.setPrice(price);
  21. return trade;
  22. }
  23. public static double at(double price) { ←---- 用于定义交易股票单位价格的虚拟方法
  24. return price;
  25. }
  26. public static Stock stock(String symbol, String market) {
  27. Stock stock = new Stock(); ←---- 创建交易股票
  28. stock.setSymbol(symbol);
  29. stock.setMarket(market);
  30. return stock;
  31. }
  32. public static String on(String market) { ←---- 定义股票交易市场的桩方法
  33. return market;
  34. }
  35. }

跟方法链接比较起来,这种技术的优点是你领域对象的层次结构(这个例子中,一个订单包含一个或多个交易,每个交易对应一支股票)由于函数的嵌套包含关系一目了然,非常清晰。

不过这种方法也存在一定的问题。你可能也注意到了,最终的DSL包含了大量的圆括号。此外,传递给静态方法的参数列表必须严格地预先定义好。如果你的领域中的对象存在一些可选字段,那么你需要为那些方法分别实现对应的重载版本,这样你才可以忽略那些缺失的参数。最后,不同参数的意义是由其位置决定的,而不是变量名。你可以创建几个桩方 法来缓解最后一个问题,就像我们在NestedFunctionOrderBuilder中的at()on()方法一样,其唯一的功能就是声明参数的角色。

到目前为止,我们介绍的这两个DSL模式都不怎么需要使用Lambda表达式。接下来一节要介绍的第三个技巧利用了Java 8的函数式能力。

10.3.3 使用Lambda表达式的函数序列

接下来要介绍的DSL模式利用了Lambda表达式定义的函数序列(function sequencing)。基于我们经常使用的股票交易模型,实现该风格的DSL后,你可以定义一个订单,代码清单如下。

代码清单 10-9 使用函数序列创建股票交易订单

  1. Order order = order( o -> {
  2. o.forCustomer( "BigBank" );
  3. o.buy( t -> {
  4. t.quantity( 80 );
  5. t.price( 125.00 );
  6. t.stock( s -> {
  7. s.symbol( "IBM" );
  8. s.market( "NYSE" );
  9. } );
  10. });
  11. o.sell( t -> {
  12. t.quantity( 50 );
  13. t.price( 375.00 );
  14. t.stock( s -> {
  15. s.symbol( "GOOGLE" );
  16. s.market( "NASDAQ" );
  17. } );
  18. });
  19. } );

为了实现这个方法,你需要创建几个接受Lambda表达式的构建器,调用它们从而生成领域模型。这些构建器保存了要创建对象的中间状态,这一点同之前使用方法链接实现DSL一样。方法链接模式中,你通过顶层构建器创建顺序,而这一次,构建器接受一个Comsumer对象作为参数,DSL的用户可以使用Lambda表达式实现它们。下面是实现这种方法的途径。

代码清单 10-10 一个提供函数序列DSL的订单构建器

  1. public class LambdaOrderBuilder {
  2. private Order order = new Order(); ←---- 构建器封装的订单对象
  3. public static Order order(Consumer<LambdaOrderBuilder> consumer) {
  4. LambdaOrderBuilder builder = new LambdaOrderBuilder();
  5. consumer.accept(builder); ←---- 执行传递给订单构建器的Lambda表达式
  6. return builder.order; ←---- 返回执行OrderBuilderConsumer所生成的订单
  7. }
  8. public void forCustomer(String customer) {
  9. order.setCustomer(customer); ←---- 设置下单的客户名
  10. }
  11. public void buy(Consumer<TradeBuilder> consumer) {
  12. trade(consumer, Trade.Type.BUY); ←---- 使用TradeBuilder创建一个购买股票的交易
  13. }
  14. public void sell(Consumer<TradeBuilder> consumer) {
  15. trade(consumer, Trade.Type.SELL); ←---- 使用TradeBuilder创建一个卖出股票的交易
  16. }
  17. private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
  18. TradeBuilder builder = new TradeBuilder();
  19. builder.trade.setType(type);
  20. consumer.accept(builder); ←---- 执行传递给TradeBuilderLambda表达式
  21. order.addTrade(builder.trade); ←---- 将执行TradeBuilderConsumer生成的交易添加到订单中
  22. }
  23. }

订单构建器的buy()sell()方法接受的两个Lambda表达式是Consumer 。一旦执行,这些方法会生成买入或者卖出的交易,如下所示:

  1. public class TradeBuilder {
  2. private Trade trade = new Trade();
  3. public void quantity(int quantity) {
  4. trade.setQuantity( quantity );
  5. }
  6. public void price(double price) {
  7. trade.setPrice( price );
  8. }
  9. public void stock(Consumer<StockBuilder> consumer) {
  10. StockBuilder builder = new StockBuilder();
  11. consumer.accept(builder);
  12. trade.setStock(builder.stock);
  13. }
  14. }

最后,TradeBuilder接受了第三个构建器的Consumer,它定义了交易的股票:

  1. public class StockBuilder {
  2. private Stock stock = new Stock();
  3. public void symbol(String symbol) {
  4. stock.setSymbol( symbol );
  5. }
  6. public void market(String market) {
  7. stock.setMarket( market );
  8. }
  9. }

这种模式整合了前两种DSL风格的优点。它可以像方法链接模式那样以流畅方式定义交易顺序。此外,通过不同Lambda表达式的嵌套层次,它也像嵌套函数的风格那样,保留了领域对象的层次结构。

然而,它也有缺点。采用这种方式需要编写大量的配置代码,并且DSL自身也会受到Java 8 Lambda表达式语法的干扰。

到底选择这三种DSL风格中的哪一种主要还是看你的品味。为你的领域模型寻找合适的选项创建领域语言需要一点儿经验。此外,将两个甚至多个DSL整合为一个也是有可能的,下一节会讨论这部分内容。

10.3.4 把它们都放到一起

正如你看到的那样,所有这三种DSL模式都有其优点与弊端,然而并没有什么限制阻止你在一个DSL中同时使用这三种模式。你可以开发一个新的DSL定义你自己的股票交易顺序,代码清单如下。

代码清单 10-11 使用多个DSL模式创建股票交易订单

  1. Order order =
  2. forCustomer( "BigBank", ←---- 设定顶层订单熟悉的嵌套函数
  3. buy( t -> t.quantity( 80 ) ←---- 创建单个交易的Lambda表达式
  4. .stock( "IBM" ) ←---- Lambda表达式中使用了方法链接,用于生成交易对象
  5. .on( "NYSE" )
  6. .at( 125.00 )),
  7. sell( t -> t.quantity( 50 )
  8. .stock( "GOOGLE" )
  9. .on( "NASDAQ" )
  10. .at( 125.00 )) );

这个例子整合使用了嵌套函数模式与Lambda方法。每个交易通过一个TradeBuilderConsumer创建,TradeBuilder借由Lambda表达式实现,代码清单如下。

代码清单 10-12 一个提供多种风格混合DSL的订单构建器

  1. public class MixedBuilder {
  2. public static Order forCustomer(String customer,
  3. TradeBuilder... builders) {
  4. Order order = new Order();
  5. order.setCustomer(customer);
  6. Stream.of(builders).forEach(b -> order.addTrade(b.trade));
  7. return order;
  8. }
  9. public static TradeBuilder buy(Consumer<TradeBuilder> consumer) {
  10. return buildTrade(consumer, Trade.Type.BUY);
  11. }
  12. public static TradeBuilder sell(Consumer<TradeBuilder> consumer) {
  13. return buildTrade(consumer, Trade.Type.SELL);
  14. }
  15. private static TradeBuilder buildTrade(Consumer<TradeBuilder> consumer,
  16. Trade.Type buy) {
  17. TradeBuilder builder = new TradeBuilder();
  18. builder.trade.setType(buy);
  19. consumer.accept(builder);
  20. return builder;
  21. }
  22. }

最终,它内部使用的辅助类TradeBuilderStockBuilder(本段之后就是其实现代码)提供了实现方法链接模式的流畅API。做完这个决定,你就可以开始编写Lambda表达式的主体了,通过它就能以最精简的方式生成交易:

  1. public class TradeBuilder {
  2. private Trade trade = new Trade();
  3. public TradeBuilder quantity(int quantity) {
  4. trade.setQuantity(quantity);
  5. return this;
  6. }
  7. public TradeBuilder at(double price) {
  8. trade.setPrice(price);
  9. return this;
  10. }
  11. public StockBuilder stock(String symbol) {
  12. return new StockBuilder(this, trade, symbol);
  13. }
  14. }
  15. public class StockBuilder {
  16. private final TradeBuilder builder;
  17. private final Trade trade;
  18. private final Stock stock = new Stock();
  19. private StockBuilder(TradeBuilder builder, Trade trade, String symbol){
  20. this.builder = builder;
  21. this.trade = trade;
  22. stock.setSymbol(symbol);
  23. }
  24. public TradeBuilder on(String market) {
  25. stock.setMarket(market);
  26. trade.setStock(stock);
  27. return builder;
  28. }
  29. }

代码清单10-12演示的就是本章所讨论的如何将三种DSL整合在一起,实现一个更具可读性的DSL。采用这种方式,你还能充分利用各种DSL的优点,不过它也有一个小小的不足: 最终的DSL与单一模式的DSL比较起来看上去没那么一致,DSL的用户很可能需要更多的时间学习。

至此,你已经成功使用Lambda创建了DSL。不过,正如我们在Comparator和Stream API中所看到的,使用方法引用能进一步改善很多DSL的可读性。我们会在下一节中,借助股票交易领域模型,通过一个实际的例子进一步展示这一点。

10.3.5 在DSL中使用方法引用

这一节中,我们试图为你的股票交易领域模型添加一个简单的新特性。该特性的主要功能是在订单的净值基础之上,再追加零项或多项税,从而计算订单的最终价格。代码清单如下。

代码清单 10-13 依据订单净值计算的税

  1. public class Tax {
  2. public static double regional(double value) {
  3. return value * 1.1;
  4. }
  5. public static double general(double value) {
  6. return value * 1.3;
  7. }
  8. public static double surcharge(double value) {
  9. return value * 1.05;
  10. }
  11. }

实现这种税费计算最简单的方法是使用一个接收订单和布尔型标志的静态方法(布尔型标志用于判断哪些税适用)。代码清单如下。

代码清单 10-14 使用布尔型标志集合判断哪些税适用,按照订单净值计算订单的税费

  1. public static double calculate(Order order, boolean useRegional,
  2. boolean useGeneral, boolean useSurcharge) {
  3. double value = order.getValue();
  4. if (useRegional) value = Tax.regional(value);
  5. if (useGeneral) value = Tax.general(value);
  6. if (useSurcharge) value = Tax.surcharge(value);
  7. return value;
  8. }

通过这种方式,可以计算出包括地区税和附加税在内的订单的最终价格,而不是总的税费,如下所示:

  1. double value = calculate(order, true, false, true);

这种实现的可读性问题很明显:我们很难记得布尔型变量的正确顺序,从而理解哪些税计算了,哪些税没有计算。解决这个问题的经典做法是实现一个税率计算器(TaxCalculator),它提供了一个精简DSL,可以一个接一个流畅地设置布尔型标志,代码清单如下。

代码清单 10-15 一个以流畅方式定义所需税费的税费计算器

  1. public class TaxCalculator {
  2. private boolean useRegional;
  3. private boolean useGeneral;
  4. private boolean useSurcharge;
  5. public TaxCalculator withTaxRegional() {
  6. useRegional = true;
  7. return this;
  8. }
  9. public TaxCalculator withTaxGeneral() {
  10. useGeneral= true;
  11. return this;
  12. }
  13. public TaxCalculator withTaxSurcharge() {
  14. useSurcharge = true;
  15. return this;
  16. }
  17. public double calculate(Order order) {
  18. return calculate(order, useRegional, useGeneral, useSurcharge);
  19. }
  20. }

如何使用这个TaxCalculator一目了然,如果你希望在订单的净值之上加上地区税以及附加税的话,可以采用下面的方式:

  1. double value = new TaxCalculator().withTaxRegional()
  2. .withTaxSurcharge()
  3. .calculate(order);

这个解决方案的主要问题是它很冗长。由于你需要为你领域中的每一种税定义一个布尔变量以及方法,因此它无法灵活地扩展。使用Java的函数式特性,你能获得同样的可读性,同时还能更精简和灵活。怎样才能做到呢?可以参考下面的代码清单重构你的TaxCalculator

代码清单 10-16 一个流畅地整合了纳税函数的税费计算器

  1. public class TaxCalculator {
  2. public DoubleUnaryOperator taxFunction = d -> d; ←---- 计算订单所需缴纳所有税费的函数
  3. public TaxCalculator with(DoubleUnaryOperator f) {
  4. taxFunction = taxFunction.andThen(f); ←---- 整合当前的税费以及作为参数传入的税费,得到新的税费计算函数
  5. return this; ←---- 返回当前对象(this),这个动作使得税费计算函数能够流畅地进行连接操作
  6. }
  7. public double calculate(Order order) {
  8. return taxFunction.applyAsDouble(order.getValue()); ←---- 通过传递订单的净值给税费计算函数,计算得出最终的订单价格
  9. }
  10. }

采用这个方案,你只需要一个字段:传入订单的净值时,通过TaxCalculator类一次性地计算所有税费的函数。这个函数刚开始是一个恒等函数(identity function)。这时,还没有加入任何的税费,因此订单的最终值与净值是一样的。新的税目通过with()方法加入时,当前的税费计算函数会整合这些项目得到最终的税费,通过这种方式,所有加入的税费都借由一个单独的函数完成了。最终,当一个订单传递给calculate()方法时,税费计算函数会整合所有的税目,再结合订单的净值就计算出了订单最终的价格。重构后的TaxCalculator如下所示:

  1. double value = new TaxCalculator().with(Tax::regional)
  2. .with(Tax::surcharge)
  3. .calculate(order);

这个解决方案使用了方法引用,读起来很容易理解,代码也很简洁。此外,它还很灵活,如果有新的税目需要添加到Tax类,不需要修改函数式的TaxCalculator就能直接使用。

我们已经讨论了Java 8及更新的版本中实现DSL的各种技术,这些技术和策略在Java的工具和框架中应用的情况如何呢?这是个有趣的话题,接下来的一节就会涉及。