使用 DSL 工具:异步图形 DSL






4.76/5 (12投票s)
使用DSL工具从图表中生成Pulse&Wait驱动的代码。
目录
引言
广义来说,领域特定语言(简称DSL)有三种。第一种是文本DSL,完全通过文本定义,其结构由处理或编译定义的任何工具预测。[1]第二种是结构DSL,其中内容通过树状或图状编辑器定义。[2]第三种是我将要讨论的图形DSL,其中开发人员使用图形编辑器来创建将转换为代码的视觉结构。
在本文中,我们将创建一个简单的图形DSL,它允许最终用户定义将通过Pulse & Wait机制进行协调的异步操作。为了编译附带的示例,您需要安装Visual Studio 2008和Visual Studio SDK。我们将使用Microsoft DSL Tools(SDK的一部分)来创建我们的DSL。
问题陈述
由于使用Pulse & Wait很困难,我想创建一个图形DSL,允许我定义可以使用Pulse & Wait机制进行协调的操作序列。具体来说,我希望能够在画布上拖放操作块,并能够在它们之间建立连接,以指示整体(异步)执行的继续规则。
创建DSL
在我们开始之前,让我概述一下使用DSL工具时最重要的几点。
- 在DSL工具中,图形DSL本身就是使用图形DSL创建的。这乍一看可能令人困惑,但基本上,您需要认识到我们的大部分异步DSL(我称之为AsyncDsl)将使用视觉辅助工具设计,而不是使用编程语言。当然,幕后会有大量的代码,但我们不会看到太多。
- DSL工具大量使用T4技术。[3]我们的图形DSL实际上就是XML的视觉表示,而T4将XML转换为代码。因此,当您使用DSL工具编辑视觉元素时,您实际上是在编辑XML。
- 您的DSL仍然是用C#编写的,并且可以编译。您可以通过使用部分类等来扩展它,使您的DSL以特定方式运行。本文中我们不会这样做。
- 使用DSL工具进行DSL创建仅涉及视觉部分——即允许用户在视觉上构建XML模型的部分。将其转换为代码的部分是纯文本——我们稍后会看到。
要在Visual Studio中创建DSL项目,请选择“新建项目”,然后导航到“其他项目类型”→“可扩展性”→“领域特定语言设计器”。
按“确定”后,您将看到一个向导,您可以在其中定义正在创建的DSL的一些功能。
- 在第一页上,除了为您的语言命名之外,您还可以选择一个起始模板。此模板决定了您的DSL具有哪些起始功能——例如,选择“任务流”会添加一些建议流程图行为的元素。无论您在此处选择哪个选项,您始终可以通过删除自动生成的元素并添加新元素来重新定义它。
- 第二页允许您选择要用于DSL的扩展名。这是当您的DSL定义位于普通解决方案文件中时使用的扩展名。此外,您还可以定义模型图标。
- 第三页允许您设置定义DSL的一些字符串,例如此DSL所属产品的名称。
- 第四页基本上会强制您使用强名称密钥对DSL的程序集进行签名——要么使用您已有的密钥,要么由系统为您生成密钥。
完成向导后,您将获得一个骨架DSL定义。再说一遍,对于典型的程序员来说,这可能看起来像一场文化冲击,所以让我们来看看Visual Studio发生了什么。
DSL编辑体验的组成部分如下:
- DSL设计器工具箱。此工具箱包含您在设计DSL时将使用的所有元素。使用此工具箱中的项目与使用WinForms等类似——只需抓取一个元素并将其拖到DSL窗口(带有奇怪的方框等的那个)即可。
- DSL编辑器本身。文件本身具有.dsl扩展名,但正如您所见,它的编辑器非常直观——正如我之前所说,DSL本身就是使用DSL创建的。这个特定的DSL有两个部分——左边包含类和关系,右边包含图元素。您可以将这两个部分视为GUI的视觉元素和代码隐藏,视觉元素在右侧,“逻辑”在左侧。
- 解决方案资源管理器。创建DSL时,您会获得两个项目——一个定义您正在创建的DSL,另一个定义与DSL相关的编辑器组件。我们稍后将详细讨论这些——现在唯一重要的是指向“转换所有模板”按钮。
- DSL资源管理器。这是一个新选项卡,以树状形式显示DSL。
- 属性页存在于DSL资源管理器树元素以及视觉DSL元素(各种方框)中。一些DSL元素也可以在屏幕上编辑——例如,您可以通过在视觉设计器中键入来设置关系基数。您可以选择如何执行此操作。
这个按钮非常重要。正如我之前提到的,DSL只是XML,它会被转换为代码。这意味着为了更新DSL定义(本身是DSL)中的更改,您需要将所有模板转换为C#代码。这正是此按钮所做的。如果您发现自己在想为什么在进行更改后DSL没有更新,您很可能忘记在编译前按此按钮。
此树封装了DSL的许多结构方面。重要的是要注意,某些树节点具有属性页(根据属性选项卡),您可以通过按F4来打开它们。
现在是时候深入讨论工具以及如何使用它们来创建我们的DSL了。
排列形状
正如我之前提到的,工具箱包含了您将使用的所有元素。这些元素分为两类——您可以称它们为逻辑和视觉。逻辑元素是定义DSL中结构的元素(即概念)。视觉元素是对应于在实际DSL中绘制的矩形和线的元素。
逻辑DSL结构的核心是域类。此类模型任何内容,具体取决于您建模的内容。由于我们正在处理异步操作,我们的域类之一被称为——您猜怎么着——Operation
。
域类可以具有属性,这些属性只是用户可以设置的值。我们的Operation
域类具有Timeout
、Name
和Description
属性,最终用户可以在将Operation
类的实例拖到其模型上后进行设置。
不过有一个技巧——最终用户实际上并没有将这个类拖到模型上——而是将OperationShape
元素拖到模型上。OperationShape
元素是GeometryShape
(请参阅工具箱)的一种,外观如下:
在定义了概念Operation
和对应的视觉OperationShape
之后,我们需要将它们链接起来。这就是“图元素映射”元素的作用。基本上,它在一个元素和另一个元素之间绘制一条细线,定义它们之间的关联。但是,如果您现在编译项目并尝试调试它们,您将看不到工具箱中的新项目——还需要做很多事情。
关系
在我们制作工具箱项(这是有趣的部分)之前,我们需要讨论关系。有两种类型——嵌入关系和引用关系。您想猜猜它们是什么吗?好吧,如果您使用嵌入关系,元素A将完全包含在元素B内部。例如,如果A是泳道(大块视觉编辑空间),B是类,这是有意义的。但是,如果我想将注释附加到类A,那么我们只需使用引用关系,从而让注释引用该类。
让我们再次看看我们的具体用法。在模型的“根”处,我们有ExampleModel
类(我没有费心更改名称,因为我们实际上不会在任何地方看到它)。为了指定我的模型包含进程和注释,我会在相应的类之间绘制嵌入关系线,得到如下结果:
橙色框是关系,两侧是关系名称和基数。在您使用DSL时,DSL设计器稍后会强制执行基数。至于关系,之所以要有那些橙色框,是因为您可以将它们连接到DSL视觉部分中的连接器形状。
警告:DSL设计器会对您的DSL应用一系列规则,以便您添加的所有元素(即所有域类)都必须“属于”某个地方。换句话说,所有元素都必须有一个共同的拥有者来包含它们。
就像我们看到嵌入关系强制模型包含进程一样,我们可以得到两个可链接的元素,但它们实际上是DSL中的“平等伙伴”(即,没有一个包含另一个)。这里有一个说明:
虚线橙色线表示引用关系,在我们的例子中,这意味着一个操作可以简单地引用一个注释——而不是包含它。当然,这样的关系也可以有自己的视觉元素(从一个画到另一个的线),而这正是我们在DSL中所做的。
工具箱,终于
好的,您已经有了DSL的逻辑和视觉部分,并希望让人们将其放入他们的模型中?这是您开始的地方——DSL资源管理器中的“编辑器”节点 → “工具箱项”。
要添加工具箱项,请右键单击DSL(在我们的例子中是“编辑器”→“工具箱项”下的AsyncDsl节点)。您将看到以下菜单:
有两个选项——连接和元素。连接是连接元素的线条(带或不带箭头)。元素是实际的块状结构。
创建工具箱项后,按F4查看其属性。您将看到类似以下的属性:
关于这些属性,重要的是您必须指定其中的几个——必需的属性——这样系统才不会抱怨。显而易见的包括指定此元素所对应的域类,以及指定工具箱的图标。(已经提供了两个默认图标,但添加其他图标需要创建16×16的蒙版位图。)
运行它!
让我们回顾一下我们为生成DSL所采取的步骤:
- 我们使用向导创建了一个DSL存根。
- 添加了域类来表示我们模型中有用的概念,例如进程、操作等。
- 在域类之间添加了关系,以指定例如操作属于模型并可以具有注释。我们还为类之间添加了转换操作以及开始和结束元素。稍后我们将详细讨论这些元素。
- 定义了由我们的DSL绘制的视觉形状。
- 将视觉形状连接到域类。
- 创建了工具箱项并将其连接到相应的类。
我们的DSL已准备好一半:我们只完成了视觉部分。在我们转换所有模板、编译解决方案并按F5后,我们终于可以在Visual Studio Experimental Hive中玩我们的DSL了。[4]这是屏幕截图:
概念
对于我们的异步DSL,我们定义了以下实体:
- Operation - 这基本上是一项工作单位,例如,“泡茶”。我们假设这项工作可以由单独的线程执行而不会发生干扰。
- Process - 这是一个以图形表示的操作序列。我们的DSL中存在此概念的唯一原因是为了能够在一个类中定义多个操作序列。
- Start 和 Finish - 这些是任何进程定义中都必须存在的状态。毕竟,它总得有个开始和结束的地方,对吧?
- Finish-to-start transition - 此转换表示一个操作必须完成,然后另一个操作才能开始。在.NET术语中,这意味着一个操作在其方法结束时必须脉冲,而另一个操作在其开始时执行等待。
- Start-to-start transition - 此转换表示一个操作只能在另一个操作开始时开始,并且不能更早。在.NET术语中,这意味着一个操作必须在执行任何操作之前脉冲,而另一个操作则等待此操作完成后再执行其工作。
让我们考虑一个实际的例子:吃早餐的过程(我知道这不太令人兴奋)。要吃早餐,我想把水壶烧上,把面包放进烤面包机——顺序不限。当这些异步操作进行时,我想取出果酱——但这只在我开始烤面包后(这是一个start-to-start操作)。只有在我同时拥有烤好的面包和果酱时,我才能做三明治(这是一个finish-to-start操作)。而且只有当我有果酱吐司和茶准备好了,我才能吃早餐(另一个finish-to-start)。
使用我们的DSL,整个过程可以定义如下:
正如您可能猜到的,实线表示finish-to-start,虚线表示start-to-start。
使用T4转换模型
早餐的视觉模型仅作为DSL存在,因此我们需要使用T4将其转换为有意义的代码。幸运的是,当我们到达T4部分时,模型已经被从XML格式转换为对象格式了。我们所要做的就是遍历树并生成合法的C#代码。
T4中的输出生成由几个简单的函数驱动,例如WriteLine()
,它写入一行输出,以及PushIndent()/PopIndent()
,它们允许我们通过保持内部堆栈来控制缩进。
我在这里不展示T4代码——您可以查看解决方案中的内容。相反,我想在定义了早餐过程后显示我们的转换输出:
namespace Debugging
{
using System.Threading;
partial class Breakfast
{
private readonly object MakeSandwichLock = new object();
private readonly object EatBreakfastLock = new object();
private readonly object GetJamLock = new object();
private bool MakeTeaIsDone;
private bool ToastBreadIsDone;
private bool GetJamIsDone;
private bool MakeSandwichIsDone;
private bool MakeTeaStarted;
private bool ToastBreadStarted;
private bool GetJamStarted;
private bool MakeSandwichStarted;
protected internal void MakeTea()
{
MakeTeaImpl();
lock(EatBreakfastLock)
{
MakeTeaIsDone = true;
Monitor.PulseAll(EatBreakfastLock);
}
}
protected internal void ToastBread()
{
lock(GetJamLock)
{
ToastBreadIsDone = true;
Monitor.PulseAll(GetJamLock);
}
ToastBreadImpl();
lock(MakeSandwichLock)
{
ToastBreadIsDone = true;
Monitor.PulseAll(MakeSandwichLock);
}
}
protected internal void GetJam()
{
lock(GetJamLock)
if(!(ToastBreadStarted))
Monitor.Wait(GetJamLock);
GetJamImpl();
lock(MakeSandwichLock)
{
GetJamIsDone = true;
Monitor.PulseAll(MakeSandwichLock);
}
}
protected internal void MakeSandwich()
{
lock(MakeSandwichLock)
if(!(ToastBreadIsDone && GetJamIsDone))
Monitor.Wait(MakeSandwichLock);
MakeSandwichImpl();
lock(EatBreakfastLock)
{
MakeSandwichIsDone = true;
Monitor.PulseAll(EatBreakfastLock);
}
}
protected internal void EatBreakfast()
{
lock(EatBreakfastLock)
if(!(MakeTeaIsDone && MakeSandwichIsDone))
Monitor.Wait(EatBreakfastLock);
EatBreakfastImpl();
}
}
}
这是很多代码!但是,它遵循我们定义的结构,并生成实现我们过程异步方面的代码。现在我们可以填补空白,如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
namespace Debugging
{
partial class Breakfast
{
AutoResetEvent eatHandle = new AutoResetEvent(false);
Random rand = new Random();
public void Prepare()
{
ThreadStart[] ops = new ThreadStart[] {
MakeTea,
GetJam,
ToastBread,
MakeSandwich,
EatBreakfast };
foreach (ThreadStart op in ops)
op.BeginInvoke(null, null);
eatHandle.WaitOne();
}
private int RandomInterval
{
get
{
return (1 + rand.Next() % 10) * 100;
}
}
public void MakeTeaImpl()
{
Thread.Sleep(RandomInterval);
Console.WriteLine("Make tea");
}
public void ToastBreadImpl()
{
Thread.Sleep(RandomInterval);
Console.WriteLine("Toast bread");
}
public void GetJamImpl()
{
Thread.Sleep(RandomInterval);
Console.WriteLine("Get jam");
}
public void MakeSandwichImpl()
{
Thread.Sleep(RandomInterval);
Console.WriteLine("Make sandwich");
}
public void EatBreakfastImpl()
{
Thread.Sleep(RandomInterval);
Console.WriteLine("Eat breakfast");
eatHandle.Set();
}
}
}
我创建了一个非常简单的函数来异步启动所有这些进程,还添加了一个事件,以便我们知道何时完成整个过程。运行此代码将输出以下内容:
Make tea
Toast bread
Get jam
Make sandwich
Eat breakfast
All done
当然,在不同的运行中,“泡茶”可能会出现在“烤面包”之后。[5]
结论
DSL工具是一项复杂但强大的技术。其关键特性是,一旦定义了DSL的结构,就可以轻松地进行编辑。然而,DSL的定义并非易事。本文仅触及了DSL工具可能性的表面。但我希望它能激发您尝试更复杂的设计。(而且,相信我,第二次真的更容易了:)
注释
- 例如,可以看看Boo编程语言。一本关于使用Boo构建DSL的书即将出版。另一个例子是尚未发布的Microsoft Oslo。
- 例如,可以看看JetBrains Meta Programming System。
- T4代表Text Templating Transformation Toolkit。本质上,T4是一种通过模板文件(扩展名为.tt的文件)读取XML文件来将其转换为代码(或其他任何内容)的方法。这些.tt文件本质上是遍历XML模型并将其转换为您想要的任何内容的C#代码。要获取所有T4内容的权威指南,您需要查看Oleg Sych的网站。
- 实验性Hive是另一个注册表Hive,它克隆了VS自带的Hive。它允许您在“干净”的环境中测试Visual Studio。
- 您还会注意到该操作对
BeginInvoke()
方法的执行顺序敏感。这是Pulse & Wait机制的特定特征——您需要在脉冲它之前启动等待线程——否则,脉冲就会浪费。