语言工作台实战 - MPS
使用语言工作台与使用传统的领域特定语言非常不同。这是一个使用 JetBrains 元编程系统 (MPS) 构建小型但有趣的 DSL 的示例。您可以使用它来了解使用语言工作台的感觉。
2005 年 6 月 12 日
(如果您不熟悉面向语言的编程和语言工作台 - 或者至少我不使用这些术语,您应该阅读我关于 语言工作台 的概述文章。本文讨论了使用语言工作台的示例,并假设您熟悉我在那篇文章中讨论的概念。)
这些语言工作台之一是来自 JetBrains 的 元编程系统 (MPS)。当我写我的语言工作台文章时,我想在实际的语言工作台中包含一个更实质性的示例,以帮助您更好地了解使用此类工具的感觉。这个例子最终变得很大,所以我决定把它分成单独的文章。
我决定使用 MPS,不是因为我对哪个语言工作台是最好的有任何意见(毕竟它们还处于非常早期的开发阶段),而是因为 JetBrains 的办公室就在我住的地方附近。开车很短的距离使协作变得容易得多。因此,当您阅读本文时,请记住,重点的一部分是查看 MPS。本文的真正目的是让您了解这类工具的使用感觉。表面上,每个工具都非常不同 - 但它们共享许多底层概念。
JetBrains 在其早期访问计划中开放了 MPS,该计划允许人们下载 MPS 的开发版本以供玩耍。您将在其中找到本文中的示例。但是请记住,该工具仍在开发中,因此您现在看到的内容可能与我写这篇文章时看到的内容大不相同。特别是某些屏幕可能已更改,我不希望将此处的屏幕截图与每次更改保持同步。还有许多粗糙的边缘,这是正在开发中的新型工具的典型特征。但是我认为它仍然值得一看,因为原理才是最重要的。
协议 DSL
此示例使用我多次遇到的模式,我现在称之为 协议调度程序。协议调度程序背后的理念是,系统从外部世界接收事件,并根据各种因素对事件做出不同的反应,其中最主要的一个因素是主机公司与事件所涉及的方之间的协议。
也许进一步讨论这个问题最简单的方法是展示一个我将用作示例的 DSL 示例。
图 1:常规计划的协议 DSL。
这段 DSL 指示了一个假想的公用事业公司如何对在其常规计划上的客户的事件做出反应。协议定义由值和事件处理程序组成,两者都是时间性的 - 它们的值会随着时间的推移而改变。
此协议有一个值 - 客户被收取的电费基本费率。从 1999 年 10 月 1 日起,它被设定为每千瓦时 10 美元,在 12 月 1 日,它急剧上涨至每千瓦时 12 美元。
该协议显示了对三种事件类型的反应:使用(电力)、服务呼叫(例如有人来修理电表)和税收。处理程序与基本费率一样是时间性的;我们可以看到,服务呼叫的处理程序也在 12 月 1 日发生了变化。
处理程序指示一个简单的反应 - 将一些货币价值记入帐户。该帐户直接在 DSL 中声明,金额使用公式计算。该公式可以包括在协议中定义的值以及事件上的属性。使用事件包括一个使用属性,该属性指示在此计费周期中使用了多少千瓦时的电力。USAGE 事件的记账规则表明,当我们收到使用事件时,我们将此使用量与基本费率的乘积记入客户的基本使用帐户。
图 2:低薪案例的另一个 DSL 片段。
图 2 显示了第二个协议,该协议适用于低收入人群的特殊计划。这里唯一有趣的补充是,使用公式涉及一个条件,用 Excel 语法表示。
首先要注意的是,这些片段非常面向领域,并且在领域方面可读。虽然 COBOL 推理萦绕在我的脑海中,但我敢说它们对非程序员领域专家来说是可读的。
这些 DSL 片段将生成适合用 Java 编写的框架的代码,实际上,这些 DSL 描述了我在协议调度程序描述中使用的相同场景。
为了比较,以下是使用 Java 编写的相同配置代码。
public class AgreementRegistryBuilder { public void setUp(AgreementRegistry registry) { registry.register("lowPay", setUpLowPay()); registry.register("regular", setUpRegular()); } public ServiceAgreement setUpLowPay() { ServiceAgreement result = new ServiceAgreement(); result.registerValue("BASE_RATE"); result.setValue("BASE_RATE", 10.0, MfDate.PAST); result.registerValue("CAP"); result.setValue("CAP", new Quantity(50, Unit.KWH), MfDate.PAST); result.setValue("CAP", new Quantity(60, Unit.KWH), new MfDate(1999, 12, 1)); result.registerValue("REDUCED_RATE"); result.setValue("REDUCED_RATE", 5.0, MfDate.PAST); result.addPostingRule(EventType.USAGE, new PoorCapPR(AccountType.BASE_USAGE, true), new MfDate(1999, 10, 1)); result.addPostingRule(EventType.SERVICE_CALL, new AmountFormulaPR(0, Money.dollars(10), AccountType.SERVICE, true), new MfDate(1999, 10, 1)); result.addPostingRule(EventType.TAX, new AmountFormulaPR(0.055, Money.dollars(0), AccountType.TAX, false), new MfDate(1999, 10, 1)); return result; } public ServiceAgreement setUpRegular() { ServiceAgreement result = new ServiceAgreement(); result.registerValue("BASE_RATE"); result.setValue("BASE_RATE", 10.0, MfDate.PAST); result.setValue("BASE_RATE", 12.0, new MfDate(1999, 12, 1)); result.addPostingRule(EventType.USAGE, new MultiplyByRatePR(AccountType.BASE_USAGE, true), new MfDate(1999, 10, 1)); result.addPostingRule(EventType.SERVICE_CALL, new AmountFormulaPR(0.5, Money.dollars(10), AccountType.SERVICE, true), new MfDate(1999, 10, 1)); result.addPostingRule(EventType.SERVICE_CALL, new AmountFormulaPR(0.5, Money.dollars(15), AccountType.SERVICE, true), new MfDate(1999, 12, 1)); result.addPostingRule(EventType.TAX, new AmountFormulaPR(0.055, Money.dollars(0), AccountType.TAX, false), new MfDate(1999, 10, 1)); return result; } }
配置代码并不完全相同。记账规则带有我们尚未添加到 DSL 中的可征税布尔标记。此外,公式被各种 Java 类替换,这些类可以针对最常见的情况进行参数化 - 这通常比尝试在 Java 解决方案中动态创建公式更好。但我认为基本信息已经传达出来了 - 在 Java 中很难看到领域逻辑,因为 Java 的语法确实妨碍了。对于非程序员来说尤其如此。
(如果您对生成的框架的实际工作原理感兴趣,请查看 协议调度程序模式 - 我不会在这里详细介绍。该模式中的示例类似,但并不完全相同。)
您可能已经注意到,DSL 示例使用的是屏幕截图而不是文本 - 这是因为虽然 DSL 看起来像文本,但它们实际上不是文本。相反,它们是底层抽象表示的投影,我们在编辑器中操作这些投影。
图 3:添加新的费率
图 3 指示了这一点。在这里,我添加了新的基本费率。编辑器指示我需要填写的字段,并根据需要输入适当的值。我实际上没有输入太多文本 - 通常我的主要任务是从选择列表中选择。目前,日期以结构化的数字形式输入,但在完全开发的系统中,您可以使用日历小部件来输入日期。
最有趣的元素之一是在计划中使用 Excel 风格的公式。这是我在公式中添加一个项时的编辑器。
图 4:编辑公式。
请注意,弹出窗口包括您可能希望在公式中使用的各种表达式,以及在计划中定义的值,以及在此上下文中正在处理的事件上的属性。编辑器正在使用大量关于上下文的知识来帮助程序员正确输入代码 - 就像后 IntelliJ IDE 一样。
关于公式的另一个要点是,它们来自与用于定义协议的语言不同的语言。因此,任何需要使用类似 Excel 的公式的 DSL 都可以将公式导入其语言,而无需自己创建所有定义。此外,这些公式可以包含使用公式语言的语言中的符号。这是一个很好的例子,说明了语言工作台力求实现的符号集成。您需要能够使用其他人定义的语言,但同时也要尽可能无缝地将它们编织到您自己的语言中。
(为了完全披露,这个公式语言实际上是针对开发这个例子而编写的,但它是分开的,以便其他语言可以使用它。这是我们看到一个正在开发的工具以及 MPS 开发理念的意外结果:找到 MPS 的有趣应用,并使用这些应用的需求来驱动 MPS 的功能和设计。这是一个我喜欢的开发理念。)
最后一个屏幕截图显示了另一个重要要点。您会注意到,当我切换到公式时,我没有完成对新费率的工作。过去这类智能或结构化编辑器的一个问题是,它们无法处理不正确的输入。在您继续之前,每个输入都必须正确。这样的要求是一个很大的可用性问题。在编程时,您需要能够轻松地切换 - 即使这意味着将无效信息保留在原位。对于投影编辑器来说,这样做的结果是,您需要能够在抽象表示中处理无效信息。实际上,您希望能够做到这一点,并且仍然能够尽可能地发挥作用。在这种情况下,一个选择是从计划生成代码,忽略那些有错误的时间元素。这种在面对肆意无效性时的鲁棒行为是语言工作台的一个重要功能。
此处的 MPS 示例使用文本式投影。到目前为止,MPS 专注于这种投影。相比之下,Microsoft DSL 工具专注于图形投影。我预计,随着工具的发展,它们将提供文本和图形投影。尽管建模人群痴迷于说“一张图片胜过千言万语”,但文本表示仍然非常有用。我预计成熟的语言工作台将支持文本和图形投影 - 以及许多人 不认为是编程环境 的投影。
定义模式
现在我们可以看到语言是什么样子,我们可以看看我们是如何定义它的。我不会在这里介绍整个语言定义,我只想挑选一些重点,让您了解它的工作原理。
图 5:Plan 的模式
图 5 显示了 plan 结构的模式。(我还显示了左侧此协议语言中其他概念的列表。)如果您做过任何数据建模,尤其是元建模,那么这应该不会有任何意外。我不会在这里解释定义的所有元素 - 只有重点。像往常一样,请记住,这目前正在发生变化,它可能不再完全像这样。
我们定义一个概念,允许它扩展(继承自)其他概念。我们可以为我们的概念提供属性和链接(类似于属性和关系),无论是在实例级别还是概念(类级别)。使用链接,我们指示多重性(在两个方向上)以及目标概念。
因此,在这种情况下,我们看到一个计划由多个值和事件组成,每个值和事件都有自己的定义。 图 6 显示了事件的定义,这非常简单。
图 6:Event 的模式
我们在记账规则时间属性中获得了一些新东西。值和记账规则最终都受这种时间规则的控制,因此将具有日期键逻辑的通用能力分解出来是有意义的。因此,我们既有时间属性定义 ( 图 7),又将其扩展为记账规则的时间属性 ( 图 8)。
图 7:时间属性的模式
图 8:与记账规则的时间链接的模式。
在这种情况下,时间属性定义了有效日期和值的概念。发布规则时间属性扩展了这一点——但它以一种与面向对象语言中的继承略有不同的方式进行。它不是添加新的链接,而是专门化了这个值链接,表明它只能链接到发布规则。这类似于你在编程语言中使用泛型所能实现的。这种专门化关系的想法存在于几种建模语言中(包括 UML)。我发现它对于大多数建模来说并不十分有用,但对于元建模来说却非常方便。你可以把它看作是一种特殊的约束形式。
最后,我将展示发布规则本身是如何定义的。
图 9:发布规则的模式。
它扩展了一个名为公式的概念,该概念实际上是单独的公式语言的一部分。
图 10:来自单独语言的公式模式。
因此,从这里你可以了解在 MPS 中设置模式所涉及的内容。对于每个概念,你都会编辑定义,从而在元素之间建立各种链接。我怀疑数据模型或类似 UML 的类图在这里会更有效——这是在图示形式下效果很好的东西。但是,这种类型的编辑器也运行得很好,并且可以让你相当快地输入新的语言模式。
如果你在想我希望你正在想的事情,你会注意到其他事情。用于编辑模式的屏幕看起来非常像用于编辑 DSL 的屏幕。正如你可能猜到的那样,有一个用于编辑模式的 DSL——在 MPS 中称为结构语言。我使用该 DSL 中的一部分编辑器来编辑模式。这种元循环自举在语言工作台中很常见。
构建编辑器
现在让我们看看如何在 MPS 中定义编辑器。
图 11:计划的编辑器定义。
图 11 显示了计划的编辑器。通常,我们为模型中的每个概念构建一个编辑器。(它并不完全是一对一,但这是一个很好的起点。)为了定义计划的编辑器,我们使用编辑器的编辑器(在这里很难避免元循环)。编辑器被定义为单元的层次结构。层次结构的叶子可以是常量,也可以是对模式中元素的引用。编辑器编辑器(我们现在正在查看)使用一些符号来帮助分隔编辑器的部分。虽然这些符号有点神秘,但重要的是不要担心语言工作台的符号,因为符号很容易改变。
这个单元层次结构的顶部是整个编辑器的单元集合。我通过选择“[/”单元来选择它。
图 12:选择单元层次结构的顶部。
当你使用编辑器编辑器时,我们之前没有使用过的检查器框架(左下角)现在变得很重要。检查器以与 GUI 构建器中使用属性编辑器相同的方式使用。这里,检查器显示我们有一个垂直单元集合。子单元是
- 以“[> plan”开头的行
- 空行
- 包含“% value %”及其后续行的行。
- 另一个空行
- 包含“% event %”及其后续行的行。
正如你所看到的,这种投影的一个问题是很难弄清楚实际的单元层次结构。使用空单元来显示缩进和空白也是值得怀疑的。我希望将来会对如何使编辑器编辑器更易用进行更多工作。
三个非空行对应于计划编辑器中命名计划的行、值行和事件行。
图 1:这是常规计划的示例。看看计划编辑器中的三个非空行是如何对应于计划中的三个内容区域:名称、值和事件。
现在我将深入研究这些内容区域中的第一个,即计划的名称。这有助于它是最简单的区域,但即使这样,也很难在像这样的文章中描述它,因为编辑器编辑器使用检查器来提供大量信息——因此我需要使用很多屏幕截图。
图 14:计划行的单元集合。
计划的名称出现在计划编辑器整体单元集合中的单个单元中。这个单元是一个单元集合,这次是一个水平集合,包含两个子单元:一个常量和一个属性。(编辑器编辑器通过“[/”表示垂直单元集合,通过“[>”表示水平集合。)
图 15:计划行中“plan”一词的常量。
常量只是工作计划。你可以使用常量单元将任何标记或提示放置到编辑器中。你还可以使用空白常量单元来进行布局,例如空白行和缩进。计划编辑器中的分隔符(例如“[/”和“[>”)也是在编辑器的编辑器中定义的常量。
图 16:计划行中名称的属性。
我们使用属性单元显示计划的名称。属性可以是编辑器正在为其定义的概念上的任何属性。这里我显示我正在编辑检查器上的属性字段,并且我有一个弹出窗口显示了计划概念上的所有属性——在这种情况下只有一个。
编辑器中的空白行是简单的常量单元。值和事件行涉及子编辑器。我将跳过值行,并深入研究事件。
事件行是垂直单元集合中的一个单元,它本身是一个水平单元集合,包含两个子单元:一个空白常量单元和一个用“(>”标记的 ref 节点列表单元。
图 17:事件的链接单元。
链接节点比我们迄今为止看到的其他节点具有更复杂的检查器,但我们这里感兴趣的是两部分信息。正如你可能从名称中猜到的那样,ref 节点列表单元将根据在模式中遵循链接来列出元素。编辑器会告诉你要遵循哪个链接以及列表应该如何垂直排列。在 图 17 中,我正在显示编辑器窗格本身中用于选择链接的弹出窗口(实际上这次可以选择)。我也可以在检查器中进行操作。编辑器窗格在“%”分隔符内显示链接名称。
这个小例子在定义编辑器时提出了一个有趣的问题:你应该使用单独的检查器进行编辑,还是直接在编辑器窗格本身中进行编辑?将内容保留在编辑器窗格之外,可以让你在编辑器窗格中获得整体结构,并更好地了解编辑器定义与编辑器最终使用之间的关系。但是,如果你将所有内容都放在检查器中,你就会不断地四处查找以查看单元是什么。这是使用简洁标记(如 [/ 和 [>)的部分理由。你可以点击标记以查看它们在检查器中的内容,但当你习惯了那个特定的编辑器时,你就会习惯直接阅读编辑器窗格。当你习惯了它之后,简洁性是有帮助的,因为它允许你的眼睛在更小的空间内看到更多内容。
你还可以想象为不同目的创建多个编辑器,一些适合人们的经验,另一些只适合人们的偏好。例如,意图编辑器通常允许你根据自己的偏好快速地在不同的投影之间切换。在编辑像这样的嵌套表格时,你可以选择在嵌套表格(称为 boxy)、类似 lisp 的表示(lispy)或带有属性的树视图(没有可爱的名称)之间切换。为了编辑条件逻辑,有一个类似 C 的编程语言视图或表格表示。在投影之间快速切换很有用,因为你通常可以在不同的投影中看到问题的不同方面,因此你通常可以从简单的投影之间的轻松更改中获得更多理解。
但让我们回到示例。我们已经看到计划编辑器有一个垂直列出事件的单元。我们如何编辑这些事件?在这一点上,我们切换到事件编辑器。我们的最终工具将把这些事件编辑器嵌入到计划编辑器中。
在我们查看编辑器之前,让我们回顾一下事件的模式。
图 6:Event 的模式
这是事件编辑器的定义,只使用编辑平面中的内容。
图 19:事件编辑器的编辑器窗格定义。
如果你的眼睛已经习惯了简洁的符号,你应该能够在不使用检查器的情况下获得大部分内容。本质上,我们有一个包含两个元素的垂直单元集合。这两个元素的底部是一个 ref 节点列表单元,用于列出发布规则时间属性,它将使用为该概念定义的编辑器。然而,顶部的单元显示了一些我们还没有看到的内容。
顶部的单元是一个水平单元集合。它有两个子单元。左边的子单元是一个带有“event”一词的常量单元——这里没有新内容。新的元素是第二个单元,它是一个 ref 节点单元。ref 节点单元类似于 ref 节点列表单元,但用于所引用的链接是单值的情况——就像这里针对事件类型一样。
ref 节点单元本身有两个部分。第一个指示要遵循哪个链接,在本例中是“type”。第二个指示要显示目标的哪个属性。这是一个可选的部分——如果我们省略它,事件类型将使用其常规编辑器进行渲染。这里我们表示,与其这样做,我们只想渲染一个属性:类型的名称。
现在让我们看看发布规则的编辑器定义。在示例计划( 图 1)中,我们看到编辑器显示了规则的生效日期,然后是规则本身的详细信息。这是编辑器定义
图 20:发布规则时间属性的编辑器
这次,根单元是一个水平单元集合,包含三个子单元:一个用于日期的 ref 节点单元、一个用于“:”的常量单元以及另一个用于发布规则本身的 ref 节点单元。日期和发布规则都使用它们自己的编辑器进行渲染。
我将展示的最后一个编辑器是发布规则编辑器。
图 21:发布规则的编辑器
希望现在这已经很熟悉了。根是一个垂直单元集合,包含两个水平单元集合作为子单元。顶部的单元包含常量“amount:”和一个用于表达式的 ref 节点。表达式由表达式的编辑器进行渲染,该编辑器是公式语言的一部分。底部的单元包含常量“account:”以及一个用于帐户的 ref 节点,该节点显示帐户的名称属性。
在文本中描述这样的编辑器很尴尬,在某些时候,使用编辑器的屏幕截图可能更容易理解。编辑器编辑器有点难用。这部分是因为我不习惯定义编辑器,部分是因为需要更多工作才能使编辑器编辑器可用。这是一个新的领域,因此 JetBrains 仍在学习这种事情应该如何运作。
从这里得出的重要结论是,你需要很大的灵活性来定义编辑器,以便它们像最终的计划编辑器一样干净。为了提供这种灵活性,你最终会得到一个复杂的编辑器编辑器。虽然可能有很多方法可以使它们更易用,但我怀疑定义一个对语言有效的编辑器仍然需要一些努力。但是,由于编辑器与 DSL 的其他元素紧密集成,因此相对容易地进行实验并更改编辑器定义以探索最佳编辑器。
这里主编辑窗口和检查器之间的相互作用揭示了关于更复杂 DSL(如编辑器语言)的编辑器的另一个要点。与其尝试通过单个投影来完成所有编辑,不如使用显示不同内容的多个投影。这里,我们在主编辑器窗格中看到了编辑器的整体结构,并在检查器中看到了很多细节。在设计编辑器时,你可以将不同的元素移动到不同的窗格之间。
在这种情况下,我可以看到第三个窗格,显示单元的层次结构,将提供一个有用的第三个投影,它将补充检查器和所见即所得的主编辑器窗格。
定义生成器
三部曲的最后一步是编写生成器。在这种情况下,我们将生成一个 Java 类,该类将使用我们当前的框架创建适当的对象。这个计划构建器类将为我们使用 DSL 定义的每个计划创建服务协议类的实例。
我们将生成的代码与我们之前看到的 Java 等效代码略有不同。这是因为我们处理计算公式的方式。在纯 Java 版本中,我使用参数化但有限的公式类来设置公式。在这个版本中,公式由公式语言提供。
以下是生成器定义的编辑窗格投影
图 22:生成定义。
以下是它生成的代码:(我在代码中添加了一些换行符,以便于在网页上格式化。)
package postingrules; /*Generated by MPS*/ import postingrules.AgreementRegistry; import postingrules.ServiceAgreement; import postingrules.EventType; import postingrules.AccountType; import jetbrains.mps.formulaLanguage.api.MultiplyOperation; import jetbrains.mps.formulaLanguage.api.DoubleConstant; import jetbrains.mps.formulaLanguage.api.IfFunction; import formulaAdapter.*; import mf.*; public class AgreementRegistryBuilder { public void setUp(AgreementRegistry registry) { registry.register("regular", this.setUpRegular()); registry.register("lowPay", this.setUpLowPay()); } public ServiceAgreement setUpRegular() { ServiceAgreement result = new ServiceAgreement(); result.registerValue("BASE_RATE"); result.setValue("BASE_RATE", 10.0, MfDate.PAST); result.setValue("BASE_RATE", 12.0, new MfDate(1999, 12, 1)); result.addPostingRule( EventType.USAGE, new PostingRule_Formula(AccountType.BASE_USAGE, true, new MoneyAdapter(new MultiplyOperation( new ValueDouble("BASE_RATE"), new UsageDouble()), Currency.USD)), new MfDate(1999, 10, 1)); result.addPostingRule( EventType.SERVICE_CALL, new PostingRule_Formula(AccountType.SERVICE, true, new MoneyAddOperation( new MoneyMultiplyOperation(new FeeMoney(), new DoubleConstant(0.5)), new MoneyConstant(10.0, Currency.USD))), new MfDate(1999, 10, 1)); result.addPostingRule( EventType.SERVICE_CALL, new PostingRule_Formula(AccountType.SERVICE, true, new MoneyAddOperation( new MoneyMultiplyOperation(new FeeMoney(), new DoubleConstant(0.5)), new MoneyConstant(15.0, Currency.USD))), new MfDate(1999, 12, 1)); result.addPostingRule( EventType.TAX, new PostingRule_Formula(AccountType.TAX, false, new MoneyMultiplyOperation(new FeeMoney(), new DoubleConstant(0.055))), new MfDate(1999, 10, 1)); return result; } public ServiceAgreement setUpLowPay() { ServiceAgreement result = new ServiceAgreement(); result.registerValue("BASE_RATE"); result.registerValue("REDUCED_RATE"); result.registerValue("CAP"); result.setValue("BASE_RATE", 10.0, MfDate.PAST); result.setValue("REDUCED_RATE", 5.0, MfDate.PAST); result.setValue("CAP", new Quantity(50.0, Unit.KWH), MfDate.PAST); result.setValue("CAP", new Quantity(60.0, Unit.KWH), new MfDate(1999, 12, 1)); result.addPostingRule( EventType.USAGE, new PostingRule_Formula(AccountType.BASE_USAGE, true, new IfFunction<Money>( new QuantityGreaterThenOperation(new UsageQuantity(), new ValueQuantity("CAP")), new MoneyAdapter( new MultiplyOperation(new ValueDouble("BASE_RATE"), new UsageDouble()), Currency.USD), new MoneyAdapter( new MultiplyOperation(new ValueDouble("REDUCED_RATE"), new UsageDouble()), Currency.USD))), new MfDate(1999, 10, 1)); result.addPostingRule( EventType.SERVICE_CALL, new PostingRule_Formula(AccountType.SERVICE, true, new MoneyConstant(10.0, Currency.USD)), new MfDate(1999, 10, 1)); result.addPostingRule( EventType.TAX, new PostingRule_Formula(AccountType.TAX, false, new MoneyMultiplyOperation(new FeeMoney(), new DoubleConstant(0.055))), new MfDate(1999, 10, 1)); return result; } }
像往常一样,我将选择生成的一些部分来进行逐步讲解,而不会深入所有内容。特别是从公式生成的代码是一个相当丑陋的解释器公式。这需要清理,我们希望在不久的将来做到这一点。
与任何模板语言一样,MPS 的生成器语言允许您以模板形式编写类,并带有参数引用。语言工作台的一个主要区别在于您使用投影编辑器来定义模板。因此,我们可以为 Java 类生成创建一个投影编辑器,该编辑器了解 Java 语法并使用此信息来帮助您进行模板生成。在这里,您可以看到生成器编辑器为我们在 Java 程序中看到的各种元素提供了标记。这个只包含方法,所以其他标记没有使用。
MPS 的生成器语言使用两种类型的参数引用:属性宏(用 $
标记)和节点宏($$
)。属性宏查询抽象表示并返回一个字符串,该字符串将插入到模板化的输出中。节点宏查询抽象表示并返回更多节点以进行更多处理。通常,您将使用节点宏来处理其他模板系统中循环的等效项。
两种类型的宏都由支持 Java 类中的 Java 方法实现。随着时间的推移,MPS 团队希望用一个专门用于查询抽象语法以进行生成的 DSL 来替换 Java,但目前他们使用 Java 代码。
属性宏显示为 $[_registryBuilder_]
之类的引用。选择 $ 允许您在检查器中查看宏调用的 Java 方法。
图 23:链接到 Java 属性宏
MPS 和 JetBrains 的 IntelliJ Java IDE 之间的集成允许我使用传统的 IntelliJ <CTRL>-B 并转到 Java 中宏的定义
public static String propertyMacro_RegistryBuilder_ClassName(SemanticNode sourceNode, SemanticNode templateNode, PropertyDeclaration property, ITemplateGenerator generator) { return NameUtil.capitalize(generator.getSourceModel().getName()) + "RegistryBuilder"; }
如您所见,这是一个非常简单的方法。它基本上做的就是将我们正在处理的模型的名称与“RegistryBuilder”连接起来以合成类名。
这种方法允许您合成各种字符串以插入到生成的代码中。当您在方法中时,您可以访问抽象表示的各个部分:协议 DSL 和生成器 DSL。
- sourceNode 是源语言中的当前节点 - 在这种情况下是协议语言。
- templateNode 是生成器语言中的当前节点,在这种情况下是生成器定义中用于构建器的当前节点
- property 是我们正在应用宏的当前属性
- property declaration 是此属性的声明(来自模式)。
- generator 是当前的生成器实例 - 这与当前项目和模型相关联。
您可以在编辑器投影中看到此参数引用在编辑器投影中有一个名称:_registryBuilder_
。这是一个标签,允许在编辑器中进行多个引用。您可以在模板中稍后看到一个示例。每个协议都使用单独的方法(setUpRegular()
和 setUpLowPay()
)构建。这些需要从整体设置方法中调用。因此,这些方法的名称必须从方法定义和调用中引用。标签 _setUp_plan_
允许我们做到这一点。在 图 22 中,您可以看到标签在 setUp
方法的重复行中以及在为每个方法生成的模板中的方法名称中。实际上,由于模板编辑器是一个投影编辑器,因此我们可以获得弹出菜单来帮助我们在需要时选择这些标签。由于编辑器知道我们正在为 Java 程序构建模板,因此它可以使用此信息来帮助我们在投影中进行编辑。
我们可以看到的第二种宏是节点宏。节点宏在编辑器中显示为 $$[more template code]
。括号中包含的模板代码将应用于宏返回的每个节点。以下是我们的协议创建方法的屏幕。
图 24:链接到节点宏
这链接到以下 Java 代码。
public static List<SemanticNode> templateSourceQuery_Plans(SemanticNode parentSourceNode, ITemplateGenerator generator) { List<SemanticNode> list = new LinkedList<SemanticNode>(); List<SemanticNode> roots = generator.getSourceModel().getRoots(); for (SemanticNode node : roots) { if (node instanceof Plan) { list.add(node); } } return list; }
如您所见,虽然属性宏返回一个字符串,但节点查询返回一个语义节点列表 - 在这种情况下,它遍历抽象表示的根并返回那里的所有计划节点。然后生成器将为每个计划生成包含的定义代码。(这样它就有点像 VTL 中的 循环指令)。
当您在节点宏中时,包含的模板将为宏返回的每个节点应用一次 - 将 sourceNode 参数设置为该节点。因此,当我们稍后命名方法时,我们可以使用以下 Java 代码。
public static String propertyMacro_Plan_SetUpMethod_Name(SemanticNode sourceNode, SemanticNode templateNode, PropertyDeclaration property, ITemplateGenerator generator) { Plan plan = (Plan) sourceNode; return "setUp" + plan.getName(); }
由于源节点是计划节点,并且计划的模式有一个字符串名称,因此我们只需使用该名称来生成方法的名称。
模板的其余部分基本上以相同的方式工作。您可以从当前源节点获取属性,也可以使用节点宏获取另一个节点来处理。
在 MPS 中定义模板与传统的 基于模板的方法 非常相似。同样,我们有一个抽象表示,我们查询它并将结果插入到生成的代码中。从这个例子中可以看到的主要区别是我们能够为不同类型的模板化输出构建投影编辑器 - 在这种情况下是 Java 类。
总结
我希望这个例子能让您了解使用语言工作台的感觉 - 即使它仍然有点像胚胎。从很多方面来说,这个例子主要缺乏的是它仍然有点像传统的文本 DSL。正如我在 语言工作台 中提到的,我认为真正有趣的 DSL 实际上会非常不同。但这项工作的一部分是我们还无法真正看到它们会是什么样子。
重大修订
2005 年 6 月 12 日:首次发布。