下载本文的 PDF 版本 PDF

DSL 入门

领域特定语言弥合了编程中的语义鸿沟

Debasish Ghosh,Anshinsoft


软件项目失败的主要原因之一是业务用户(他们实际上了解问题领域)与开发人员(他们设计和实施软件模型)之间缺乏沟通。业务用户理解领域术语,他们使用的词汇对于软件人员来说可能非常陌生;难怪沟通模型会在项目生命周期的开始阶段就崩溃。

DSL(领域特定语言)1,3 通过鼓励通过共享词汇进行更好的协作,弥合了业务用户和开发人员之间的语义鸿沟。开发人员构建的领域模型使用与业务相同的术语。DSL 提供的抽象与问题领域的语法和语义相匹配。因此,用户可以在项目的整个生命周期中参与验证业务规则。

本文介绍了 DSL 在建模富有表现力的业务规则中所起的作用。它从领域建模的基础知识开始,然后介绍 DSL,并根据实现技术对其进行分类。然后,本文详细解释了来自证券交易操作领域的嵌入式 DSL 的设计和实现。

领域建模

当您对领域进行建模时,7 您会识别出各种实体及其协作关系。每个实体都有一个名称,通过该名称在该特定领域中识别它;应该是领域专家的业务分析师将仅使用该特定名称来指代该实体。当您将问题域工件转换为解决方案域时,您将构建相同问题的软件模型。作为新软件解决方案的设计者,您希望它以与原始问题相同的方式工作。

走向通用词汇表

众所周知,大多数失败的项目都是因为业务用户和实施人员之间缺乏适当的沟通结构。项目各个利益相关者使用的术语差异阻碍了有意义的协作。

更有效的方法是让所有与系统设计和实施相关的各方在项目生命周期的早期采用通用词汇表。这可以作为统一实施的约束力。这意味着业务用户的日常术语也出现在建模者创建的用例中;程序员在命名抽象时使用相同的术语;数据架构师在设计数据模型时也这样做;测试人员使用相同的通用词汇表来命名测试用例。埃里克·埃文斯在他的关于领域驱动设计的书中称之为通用语言8

什么是 DSL?

在通用词汇表中,不仅领域的名词被映射到解决方案空间;您还需要使用相同的领域语言来描述领域内的所有协作。领域的小型语言在您的软件抽象范围内建模,而您开发的软件则使用领域语言。考虑以下来自证券交易操作领域的示例

 newOrder.to.buy(100.shares.of('IBM')) {  limitPrice 300  allOrNone true  valueAs {qty, unitPrice -> qty * unitPrice - 500} } 

这是交易员在交易所大厅使用的语言的响亮表达,简洁地捕获为编程语言中的嵌入式抽象。这是一个 DSL,1 一种针对特定问题领域的编程语言,它在与领域本身相同的抽象级别上建模语法和语义。4

您可能想知道这个特定的 DSL 示例是如何从领域模型和业务用户使用的通用词汇表发展而来的。它涉及四个主要步骤

  1. 与业务用户协作,您得出需要在开发周期的各个方面使用的领域的通用词汇表。
  2. 您使用通用词汇表和底层宿主语言的编程语言抽象来构建领域模型。
  3. 再次与业务用户协作,您开发了将各种领域模型元素粘合在一起的语法结构,为 DSL 用户发布了语法。与您预先提出共享词汇表,然后仅基于该词典驱动应用程序开发的过程相比,这是一个主要优势。在基于 DSL 的开发中,您实际上是使用共享词汇表作为业务规则的构建块来开发 DSL 结构。实际规则是在这些语法结构之上开发的。
  4. 然后,您使用上一步的语法开发业务规则。在某些情况下,实际的领域用户也可能参与开发。

DSL 简介

设计 DSL 并不像设计通用编程语言那样令人生畏。DSL 的焦点非常有限,其表面积仅限于当前建模的领域。事实上,当今使用的大多数常见 DSL 都被设计为现有编程语言结构内的纯嵌入式程序。稍后我们将展示如何完成此嵌入过程以创建小型语言,同时使用底层实现语言的基础结构。

DSL 分类

Martin Fowler 根据 DSL 的实现方式对 DSL 进行了分类。3 在底层编程语言之上实现的 DSL 称为内部 DSL,嵌入在实现它的语言中(因此,它也称为嵌入式 DSL)。内部 DSL 脚本本质上是用宿主语言编写的程序,并使用宿主语言的整个基础结构。

被设计为独立语言而不使用现有宿主语言的基础结构的 DSL 称为外部 DSL。它有自己的语法、语义和语言基础结构,由设计者单独实现(因此,它也称为独立 DSL)。

本文主要关注内部或嵌入式 DSL。

使用 DSL 的优势

DSL 旨在使领域业务规则在程序中更加明确。以下是 DSL 的一些优势

• 更容易与业务用户协作。 由于 DSL 与问题域共享通用词汇表,因此业务用户可以在项目的整个生命周期中更有效地与程序员协作。他们可以参与在领域模型之上开发实际的 DSL 语法,并且可以帮助使用该语法开发一些业务规则。即使业务用户无法使用语法进行编程,他们也可以在规则被编程时验证规则的实现,并且可以参与开发一些测试脚本。

• 领域规则中更好的表达性。 设计良好的 DSL 是在更高的抽象级别开发的。DSL 的用户不必关心低级实现策略,例如资源分配或复杂数据结构的管理。这使得 DSL 代码更容易由没有开发它的程序员维护。

• 基于 DSL 的 API 的简洁表面积。 DSL 包含业务规则的本质,因此 DSL 用户可以专注于代码库的非常小的表面积来建模问题域工件。

• 基于 DSL 的开发可以扩展。 对于重要的领域模型,基于 DSL 的开发可以比典型的编程模型提供更高的回报。您需要预先投入一些时间来设计和实现 DSL,但随后它可以被大量程序员有效地使用,其中许多人可能不是底层宿主语言的专家。

使用 DSL 的缺点

与任何开发模型一样,基于 DSL 的开发并非没有缺陷。您的项目最终可能会因使用设计不良的 DSL 而变得一团糟。其中一些缺点是

• 困难的设计问题。 与 API 设计一样,DSL 设计是为专家准备的。您需要了解目标用户的领域和使用模式,并使 API 在正确的抽象级别上具有表现力。并非您团队的每个成员都能提供高质量的 DSL 设计。

• 前期成本。 除非项目至少具有中等复杂性,否则设计 DSL 可能不具有成本效益。产生的前期成本可能会抵消在开发周期的后期阶段通过提高生产力节省的时间。

• 使用多种语言的趋势。 除非经过仔细控制,否则这种多语言编程可能会导致语言混乱并导致设计臃肿。

DSL 的结构

本节介绍如何设计内部 DSL 并将其嵌入到底层宿主语言中。我们研究了嵌入式 DSL 的通用结构,并讨论了如何使 DSL 语法与核心领域模型解耦。最后,我们开发了一个嵌入在 Scala 中的示例 DSL。

语义模型之上的语言抽象

DSL 提供了专门的语法结构,用于建模业务用户的日常语言。这种表达性是作为丰富的领域模型之上的轻量级语法结构实现的。图 1 提供了此结构的图示。

Anatomy of a DSL

在图中,基本抽象指的是使用底层宿主语言的习惯用法设计的领域模型。基本抽象的实现独立于最终位于其顶部的 DSL。这使得在单个领域模型之上托管多个 DSL 成为可能。考虑以下 DSL 示例,该示例建模了在证券交易所进行证券交易的指令

 new_trade 'T-12435' for account 'acc-123'  to buy 100 shares of 'IBM',  at UnitPrice=100, Principal=12000, Tax=500 

这是一个嵌入在 Ruby 中作为宿主语言的内部 DSL,与交易员在交易台上说话的方式非常相似。请注意,由于它是嵌入式 DSL,因此它可以利用 Ruby 提供的完整基础结构,例如语法处理、异常处理、垃圾回收等。

实体使用交易员理解的词汇命名。图 2 注释了 DSL,显示了它使用的一些领域词汇以及我们为用户引入的一些“气泡词”,使其更具英语感觉。

DSL snippet showing domain vocabulary and bubble words

为了实现此 DSL,您需要一个由 Ruby 中的一组抽象组成的底层领域模型。这就是我们所说的语义模型(或领域模型)。之前的 DSL 代码片段通过特定于我们为用户提供的语言的自定义构建的解释器与语义模型交互。这有助于将模型与在其之上设计的语言解耦。这是设计 DSL 时要遵循的最佳实践之一。

开发嵌入式 DSL

嵌入式 DSL 继承了现有宿主语言的基础结构,并以帮助您抽象建模领域的方式对其进行调整。如前所述,您将 DSL 构建为在核心领域抽象之上的解释器,这些抽象是您使用底层语言的语法和语义开发的。

选择宿主语言

DSL 在更高的抽象级别上提供抽象。因此,重要的是您用来实现 DSL 的语言提供类似的抽象能力。语言的表达性越强,语言的本地抽象与您为其 DSL 构建在其之上的自定义抽象之间的语义差距就越小。当您选择一种语言来嵌入 DSL 时,请注意它提供的抽象级别。

让我们考虑一个使用 Scala 作为宿主语言为特定领域设计小型 DSL 的示例。Scala2,5 是 Martin Odersky 设计的一种对象函数式语言,它提供了用于抽象设计的大量函数式和面向对象的功能。它具有灵活的语法,带有类型推断、可扩展的对象系统、体面的模块系统和强大的函数式编程能力,可以更轻松地开发富有表现力的 DSL。使 Scala 成为嵌入 DSL 的合适语言的其他功能包括词法作用域开放类、隐式参数和使用结构类型的静态检查鸭子类型能力。2

问题领域

此示例涉及来自证券交易操作领域的业务规则,其中交易员代表其客户在证券交易所(也称为市场)买卖证券,基于一些已下达的订单。客户订单在交易所执行并生成交易。根据交易是买入还是卖出,现金在客户和交易员之间交换。交换的现金金额称为交易的净现金价值,并且随交易执行的市场而变化。

我们示例中使用的业务规则确定了特定交易的现金价值计算策略。我们在领域模型的核心抽象之上构建了一个 DSL,该 DSL 使业务规则在程序中明确,并且可以轻松地由业务用户验证。此处显示的核心抽象为了演示目的而简化;实际生产级别的抽象将更加详细和复杂。主要思想是展示如何将 DSL 嵌入到像 Scala 这样的强大语言中,以便为用户提供领域友好的 API。

解决方案领域模型

领域模型提供了业务的核心抽象。在我们的示例中,我们使用 Scala 中代数数据类型的强大功能来建模一些主要对象。交易是领域的主要抽象。以下是使用 Scala case 类对其进行建模的方式

 case class Trade(  account: Account,  instrument: Instrument,  refNo: String,  market: Market,  unitPrice: BigDecimal,  quantity: BigDecimal,  tradeDate: Date = Calendar.getInstance.getTime,  valueDate: Option[Date] = None,  taxFees: Option[List[(TaxFeeId, BigDecimal)]] = None,  netAmount: Option[BigDecimal] = None) 

实际上,交易抽象将有更多细节。类似于交易,我们也可以使用 case 类来实现账户工具的抽象。我们暂时省略它们,因为它们的详细实现可能与当前上下文无关。

我们将在此处使用的另一个抽象是市场,为了示例也保持简单

 sealed trait Market case object HongKong extends Market case object Singapore extends Market case object NewYork extends Market case object Tokyo extends Market 

这些示例使用 case 类表示代数数据类型,使用 case 对象表示单例。Scala case 类提供了一些不错的功能,使代码简洁明了

• 构造函数参数作为类的公共字段

equals, toStringhashCode的默认实现基于构造函数字段

• 包含apply()方法和基于构造函数字段的提取器的伴生对象

Case 类还通过其神奇的提取器自动生成提供模式匹配。我们在设计 DSL 时使用了 case 类的模式匹配。有关 case 类如何成为良好的代数数据类型的更多详细信息,请参阅Scala 编程2

嵌入式 DSL

在我们深入研究建模交易净现金价值计算的 DSL 实现之前,以下是我们在设计中需要考虑的一些业务规则

• 净现金价值计算逻辑随交易执行的市场而异。

• 我们可以为香港或新加坡等个别市场制定特定的市场规则。

• 我们可以制定适用于所有其他市场的默认规则。

• 如果需要,用户还可以在 DSL 中为现金价值计算指定自定义策略和特定领域的优化。

在示例中,DSL 结构被设计为领域模型之上的语言抽象。业务用户在与开发人员协作以确保在发布的语法中投入适当的表现力方面发挥着重要作用。它必须与核心抽象(交易, 账户, 工具等)松散耦合,并且必须使用用户的领域语言。DSL 语法还需要是可组合的,以便用户可以在基础语言提供的功能之上使用自定义领域逻辑扩展语言。

一旦有了语法结构,您就可以使用它们来开发应用程序业务规则。在以下示例中,我们在 DSL 发布的语法之上开发了交易的现金价值计算逻辑的业务规则。

Scala 提供了丰富的类型系统,我们可以使用它来建模一些业务规则。我们将交易的现金价值计算逻辑建模为从交易净额的函数,在 Scala 中表示为交易 => 净额。现在,每个这样的计算策略都由一个市场驱动,这意味着每个这样的函数仅针对市场的特定值定义。我们将其建模为

 PartialFunction[Market, Trade => NetAmount]. 

除了将计算逻辑的基于市场的调度结构表示为抽象数据类型之外,PartialFunction在 Scala 中是可扩展的,并且可以使用组合器(例如andThenorElse)链接在一起。有关如何使用PartialFunction进行组合的更多详细信息,请参阅 Scala 网站。5

为了方便起见,让我们定义几个类型别名,将用户从 DSL 使用的实际底层数据结构中抽象出来

 type NetAmount = BigDecimal type CashValueCalculationStrategy = PartialFunction[Market, Trade => NetAmount] 

正如问题域所建议的那样,我们可以为特定市场制定专门的现金价值计算逻辑策略。例如,以下是我们如何为香港市场建模 DSL

 val forHongKong: CashValueCalculationStrategy = {  case HongKong => { trade =>   //.. logic for cash value calculation for HongKong  } } 

请注意此抽象如何避免不必要的复杂性。它香港市场定义,并返回一个接受交易并返回计算出的现金价值的函数。(实际计算逻辑被省略,可能与当前上下文无关。)类似地,我们可以为新加坡市场建模 DSL

 val forSingapore: CashValueCalculationStrategy = {  case Singapore => { trade =>   //.. logic for cash value calculation for Singapore  } } 

定义另一个专业化

 val forDefault: CashValueCalculationStrategy = {  case _ => { trade =>   //.. logic for cash value calculation for other markets  } } 

让我们看看如何通过匹配任何市场参数来选择默认策略

此策略针对任何使用它的市场选择。“_”是一个占位符,它匹配传递给它的任何市场。

当用户可以组合多个 DSL 抽象以形成更大的抽象时,DSL 非常有用。在我们的例子中,我们设计了用于选择适当策略的个别代码片段,该策略计算交易的净现金价值。我们如何组合它们,以便用户可以使用 DSL 而无需关心个别市场特定的调度逻辑?orElse我们使用一个组合器,它遍历个别PartialFunctions

 lazy val cashValueComputation: CashValueCalculationStrategy =  forHongKong orElse   forSingapore orElse forDefault 

链,并选择第一个匹配的市场。如果找不到特定市场的策略,则选择默认策略。以下是我们如何将这些代码片段连接在一起

这是 DSL,它对适当的现金价值计算策略进行动态调度,并为默认策略提供回退。它解决了本节开头枚举的前三个业务规则。上面的抽象简洁明了,使用领域语言,并使调度逻辑的排序非常明确。不是程序员的业务用户将能够验证适当的领域规则。orElse设计良好的 DSL 的好处之一是可扩展性。第四个业务规则是这种情况的用例。我们如何扩展 DSL 以允许用户插入他们可能想要为另一个市场添加的自定义现金价值计算逻辑?或者他们可能想要覆盖现有市场的当前逻辑以添加一些新引入的市场规则。我们可以使用

 // pf is the user supplied custom logic lazy val cashValue = { pf: CashValueCalculationStrategy =>  pf orElse cashValueComputation } 

组合器将用户指定的策略与我们现有的策略组合起来。此 DSL 非常直观:它调用用户提供的自定义策略。如果找不到匹配项,则调用我们之前的策略。考虑用户为东京

 val pf: CashValueCalculationStrategy = {  case Tokyo => { trade =>   //.. custom logic for Tokyo  } } 

市场定义自定义策略,并希望使用它而不是默认回退策略的情况

 val trade = //.. trade instance cashValue(pf)(trade.market)(trade) 

现在,用户可以执行以下操作,将首选策略提供给计算逻辑

我们的示例使用 Scala 丰富的类型系统及其强大的函数式抽象来设计一个 DSL,该 DSL 嵌入在宿主语言的类型系统中。请注意,我们如何仅使用静态类型系统的约束以声明方式表达特定领域的规则(例如,计算逻辑需要随特定市场而变化)。生成的 DSL 具有以下特征

• 它具有较小的表面积,因此更容易理解、排除故障和维护。

• 它具有足够的表现力,使业务用户能够理解和验证正确性。

• 它是可扩展的,因为它允许以完全非侵入性的方式将自定义插件逻辑(可能包括特定领域的优化)组合到基本组合器中。

生产力和 DSL

嵌入式 DSL 鼓励在更高的抽象级别进行编程。宿主语言的底层基础结构、类型系统的详细信息、较低级别的数据结构以及资源管理等其他问题都完全从 DSL 用户那里抽象出来,因此他们可以专注于构建业务功能并使用领域的语法和语义。orElse在我们的示例中,PartialFunction的组合器隐藏了组合现金价值计算逻辑的多个策略的所有细节。此外,DSL 可以扩展以与自定义逻辑组合,而不会产生任何附带的复杂性。因此,用户可以专注于实现自定义抽象。

我们已经详细讨论了如何将 DSL 嵌入到其宿主语言中,并利用类型系统来建模特定领域的抽象。您还可以使用动态类型语言(如 Groovy、Ruby 或 Clojure)设计嵌入式 DSL。这些语言提供了强大的元编程功能,允许用户在编译时或运行时生成代码。使用这些功能开发的 DSL 还可以提高开发人员的生产力,因为您只需使用 DSL 编写核心业务功能,而冗长的样板代码由语言基础结构生成。考虑以下在 Rails 中定义领域对象的示例

 class Trade < ActiveRecord::Base  has_one :ref_no  has_one :account  has_one :instrument  has_one :currency  has_many :tax_fees  ## ..  validates_presence_of :account, :instrument, :currency  validates_uniqueness_of :ref_no  ## .. end 

此示例以声明方式定义了交易抽象及其与其他实体的关联。方法has_onevalidates_presence_of清晰地表达了意图,没有任何冗长。这些是 Ruby6 中的类方法,它们使用元编程在运行时生成适当的代码片段。您用于定义交易的 DSL 保持简洁且富有表现力,同时所有附带的复杂性都从公开 API 的表面积中抽象出来。

无论使用静态类型语言还是动态类型语言,您都可以高效地使用 DSL。您只需要使用使语言强大的习惯用法。DSLs in Action1 详细介绍了如何习惯用法地使用多种语言的功能来设计和实现 DSL。

结论

DSL 为项目的开发生命周期增加的主要价值是鼓励开发人员和业务用户之间更好的协作。实现 DSL 的方法有很多种。这里我们讨论了一种使用静态类型编程语言嵌入的方法。这允许您使用宿主语言的基础结构,并专注于开发领域友好的语言抽象。您开发的抽象需要是可组合的和可扩展的,以便用户可以从小抽象构建更大的抽象。最后,抽象需要使用领域词汇,与领域用户使用的语义紧密匹配。

参考文献

1. Ghosh, D. 2010. DSLs in Action. Manning Publications.

2. Odersky, M., Spoon, L., Venners, B. 2010. Scala 编程. Artima.

3. Fowler, M. 2010. 领域特定语言, Addison Wesley.

4. Fowler, M. 2009. 领域特定语言简介。DSL 开发人员大会; http://msdn.microsoft.com/en-us/data/dd727707.aspx.

5. Scala; https://scala-lang.org.cn.

6. Thomas, D., Fowler, C., Hunt, A. 2009. Ruby 1.9 编程. Pragmatic Press.

7. Coplien, J. O. 1988. C++ 中的多范式设计。Addison-Wesley Professional.

8. Evans, E. 2003. 领域驱动设计:应对软件核心的复杂性。 Addison-Wesley Professional.

喜欢它,讨厌它?请告诉我们

[email protected]

Debasish Ghosh 是 Anshinsoft (http://www.anshinsoft.com) 的首席技术布道师,他在那里专门领导为从小公司到财富 500 强公司的客户交付企业级解决方案。他的研究兴趣包括函数式编程、领域特定语言和 NoSQL 数据库。Debasish 是 的高级会员,也是 2010 年 12 月由 Manning 出版的 DSLs In Action 的作者 (http://www.manning.com/ghosh)。您可以在 http://debasishg.blogspot.com 阅读他的编程博客,并通过 [email protected] 与他联系。

© 2011 1542-7730/11/0500 $10.00

acmqueue

最初发表于 Queue vol. 9, no. 6
数字图书馆 中评论本文





更多相关文章

Matt Godbolt - C++ 编译器中的优化
在为编译器提供更多信息方面需要权衡:这可能会使编译速度变慢。链接时优化等技术可以使您两者兼得。编译器中的优化不断改进,间接调用和虚拟函数调度的即将到来的改进可能很快导致更快的多态性。


Ulan Degenbaev, Michael Lippautz, Hannes Payer - 作为合资企业的垃圾回收
跨组件跟踪是解决跨组件边界的引用循环问题的一种方法。只要组件可以形成任意对象图,并且跨 API 边界具有重要的所有权,就会出现此问题。CCT 的增量版本在 V8 和 Blink 中实现,从而能够以安全的方式有效且高效地回收内存。


David Chisnall - C 不是低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间研究根本原因。这两种漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能被添加进来,是为了让 C 程序员继续相信他们正在使用低级语言进行编程,而这种情况已经几十年没有发生了。


Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 你不应该依赖我
大多数网站都使用 JavaScript 库,并且其中许多库已知是易受攻击的。了解问题的范围,以及包含库的许多意想不到的方式,只是改善情况的第一步。这里的目标是,本文中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 保留所有权利。

© . All rights reserved.