9.4 DSL的成长和演化

我们在应用开发实践中大都使用过DSL。它的主要用途是对系统中频繁改变的组件(比如配置参数和业务规则)进行建模。而在面临改变的时候,应该按照什么样的原则去指导DSL的演化,这是一个需要我们去思考完善的问题。我们甚至在部署DSL的第一个版本之前,就应该考虑好DSL的演化策略。

9.4.1 DSL的版本化

DSL的使用情况决定了我们需要的版本化策略。如果我们的DSL只有一小群固定的、联系紧密的用户,那么不规定专门的版本化策略也是可以的。不管是修正错误还是增加需求,我们可以随时用新的版本替换旧的版本,只要随新版本附上一份标出向后兼容事项的简单说明足矣。

但如果不只一组用户在使用我们的DSL呢?那就必须规划增量式的版本化策略了。由于不是所有的用户都对新版本感兴趣,所以我们要同时采取以下两条策略:

  • 代码仓库的版本管理体系必须能够同时兼顾多个发行版的维护工作;
  • 必须建立专门的部署脚本,且该脚本要能够部署DSL的多个版本。

不管采取什么样的策略,至少以下问题是必须解决好的,因为任何软件模块都很容易在演进过程中遭遇这些困难:

  • 处理向后兼容;
  • 照顾那些不适合推广为一般情况的个别的用户需求。

我们有可能遇到的诸多困难情况实际上不一定是DSL特有的问题,而是软件部署的普遍情况。下一小节我们将针对版本化过程中出现的各种难题,讨论在设计阶段可以采取的一些措施。

9.4.2 DSL平稳演化的最佳实践

假设我们在应用里使用了第三方的DSL,且应用已经部署到多个用户处。现在我们打算给应用增加一些功能,同时发现DSL的新版本刚好增加了我们需要的特性。可是,新版的DSL并不能向后兼容我们当前使用的版本。我们该怎么办呢?

再来想象一个场景。假设我们用DSL来建模证券交易的业务规则,而这些业务规则可能会因为部署地的证券交易机构而各不相同。碰巧现在东京证券交易所修改了几条规则,于是我们需要推出一个针对东京证券交易的新版本。这太可怕了!我们要同时维护几个版本,即使睡着了都要被吓醒。

还是看看有什么预防的办法吧,免得总被这些瘆人的问题闹得半夜不得安宁。

1.隐式上下文模式更能适应版本演化

请回顾一下第4.2.1小节中这段基于连贯接口的Ruby内部DSL:

  1. Account.create do
  2. number "CL-BXT-23765"
  3. holders "John Doe", "Phil McCay"
  4. address "San Francisco"
  5. type "client"
  6. email "client@example.com"
  7. end.save.and_then do |a|
  8. Registry.registNer(a)
  9. Mailer.new
  10. .to(a.email_address)
  11. .cc(a.email_address)
  12. .subject("New Account Creation")
  13. .body("Client account created for #{a.no}")
  14. .send
  15. end

这段表现账户创建过程的DSL运用了内部DSL设计的隐式上下文模式。比起一个规定好所有参数顺序和数量的create方法,这种模式写出来的DSL更容易适应未来的演化。我们可以在不影响任何原有用户的前提下,向Account抽象增加新的属性。

2.用自动代换解决向后兼容

这个策略的做法是通过设定适当的默认值,将旧的API自动代换为新的版本。例如,下面的Scala DSL片段定义了一笔固定收益型交易,这是我们在第6.4.1小节用过的例子:

  1. val fixedIncomeTrade =
  2. 200 discount_bonds IBM for_client NOMURA on NYSE at 72.ccy(USD)

用户对这段DSL很满意。交易按照脚本中指定的货币进行(即代码片段中的USD),这种货币称为交易货币。交易最后还要经过一个结算过程,我们的DSL假定结算和交易用的是同一种货币。未来某一天,规则毫无悬念地发生了变化,用户收到通知说,现在允许按照交易货币之外的另一种货币(称为结算货币)结算。于是DSL也相应地变成了下面的新版本:

  1. val fixedIncomeTrade =
  2. 200 discount_bonds IBM for_client NOMURA on NYSE at 72.ccy(USD)
  3. settled in JPY

现在的问题是,那些用旧版处理引擎写成的DSL脚本会发生什么情况?这些脚本很可能会崩溃,因为它们的语义模型里根本找不到一个表示结算货币的值。

我们可以对语义模型做一个自动代换,从而解决这个问题,把缺少的结算货币取值默认地设为与交易货币相同。这样一来,用户可以平稳地迁移到新版本的DSL,同时旧的DSL脚本也能继续顺利执行下去。

3.门面式的DSL设计可以解决诸多版本化问题

还记得我们在5.2.1小节讨论过的DSL门面吗?门面充当了模型API的保护层,方便我们调整和塑造公开给用户的语法外观。假如后续的版本需要修改DSL语法,可以将改动限制在DSL门面的范围内,这样就不会对下层的模型有任何影响。这个策略尤其适用于新版DSL仅包含少量语法变动的情况。

4.遵从优秀抽象设计的各项原则

附录A对优秀抽象设计的原则做了很详细的解说,请你务必一读再读。只有遵循这些设计原则,用户才能和我们的DSL一起从容地面对演化。DSL的版本化和API的版本化同样重要。API越是死板僵化,就越难跟随新版本一起演变。

无论我们选择什么样的策略,都必须留有余地,让多个版本的DSL可以在同一个应用中共存。DSL开发领域还处于摸索阶段,需要更多的时间才能成熟。只要我们能多考虑DSL用户的未来需要,就是对DSL发展的积极帮助。