本文为《在Visual Studio 2012中使用VMSDK开发领域特定语言》专题文章的第二部分,在这部分内容中,将以实际应用为例,介绍开发DSL的主要步骤,包括设计、定制、调试、发布以及使用等。
案例:一个单向状态流DSL的设计和开发
假设我们需要设计一个单向状态流DSL,这个单向状态流有着三种不同的状态节点:起始节点、中间节点和结束节点。整个DSL需要满足以下的条件(或具有以下功能):
- 为了简单起见,状态的转换是无条件的(也就是不存在分支、循环等,转换流是一个状态接一个状态的链表形式,这也是“单向”一词的含义)
- 起始状态只能衔接到中间状态;中间状态可以衔接到另一个中间状态或者结束状态;结束状态只能被中间状态衔接
- 起始状态不能被任何状态衔接
- 中间状态只能被起始状态或者另一个中间状态衔接
- 在DSL中,有且仅有一个起始和结束状态,有至少一个中间状态
- 为了简单起见,当中间状态被访问(触发)时,仅需向控制台输出设置于该状态上的文本,无需任何其它操作
下图更直观地表述了上面的描述,这也是该DSL开发完成后在Visual Studio 2012中使用的效果:
DSL解决方案的创建
现在,我们可以在Visual Studio 2012中创建一个名为StateFlowLanguage的DSL解决方案。创建的各个步骤在此就不详述了,只需确保DSL的模板选择“Minimal Language”即可,其它的设置可以根据自己的实际情况而定。当完成解决方案创建以后,在DslDefinition.dsl设计器中,将ExampleModel更名为StateFlowModel,同时在设计器的Diagram Elements部分,将ABCDiagram更名为StateFlowLanguageDiagram(此处ABC为您在创建DSL解决方案时所选的DSL名称)。
请注意,在接下来的讨论中,我们会将重点放在问题分析部分,而不会过多地讨论如何在设计器中添加一个领域类型、如何设置图形的颜色和形状等这些与操作相关的内容。有关DSL设计器的使用,请参考:How to Define a Domain-Specific Language(http://msdn.microsoft.com/en-us/library/bb126581.aspx)。
对单向状态流DSL的分析
在设计DSL之前,我们首先需要了解DSL所包含的领域类型,然后再分析这些类型之间的关系,从而才能正确地在DSL设计器中表述这些内容。根据上面对单向状态流的描述,我们很容易得知,在这个DSL中,主要包含起始状态、中间状态和结束状态三种领域类型,以及这三种状态之间的转换(Transition)关系。更进一步,这个DSL模型必须且只能包含一个起始状态和一个结束状态,并且至少应该包含1个以上的中间状态。所以,我们的DSL模型与这些状态之间的关系可以用下图表示:
再看这三种状态之间的转换关系:起始状态只可以转换到中间状态,也就是说它不能转换到结束状态或者自己本身;中间状态可以转换到另一个中间状态,或者结束状态,但不能转换到起始状态;作为一个转换的接受对象,它又只能接受来自起始状态或者另一个中间状态的转换;而对于结束状态而言,它只能接受来自某个中间状态的转换。
根据上面的分析,我们可以很容易地理清三种状态之间的领域关系。首先是起始状态和中间状态之间的关系:起始状态仅能关联(注:上面也提到过,这种关联关系其实是转换关系)到一个中间状态;而中间状态则可以被一个起始状态所关联,“可以”一词的意思是,中间状态不一定非要被起始状态所关联,它还可以被关联到另一个中间状态。因此,我们可以用下面的DSL设计图来表示起始状态和中间状态之间的关系:
其次,中间状态可以关联到另一个中间状态或结束状态。所以我们需要在两个中间状态之间,以及中间状态与结束状态之间创建“转换”关系。但这里会产生一个问题:由于中间状态可以转换到另一个中间状态,也可以转换到结束状态,因此,在创建的两个领域关系上,“中间状态”和“结束状态”一端的重复数只能是0..1。然而这样也造成了在实际的DSL应用中,一个中间状态可以同时关联到另一个中间状态和结束状态的局面。
为了解决这个问题,我们需要引入领域类型的“继承”关系:将中间状态和结束状态抽象成“非起始状态”,而中间状态和结束状态都是“非起始状态”的子类型。比如:
于是,我们只需要设置中间状态与非起始状态之间的关系即可,在这个关系的“非起始状态”一端,重复数为1..1,也就是说,中间状态必须关联到一个非起始状态。由于中间状态和结束状态都是非起始状态的子类,因此,在实际的DSL应用中,当某个中间状态“转换”到另一个中间状态时,该中间状态则不能再“转换”到其它的中间状态或者结束状态。这就解决了上面的问题。
再进一步分析结束状态与中间状态之间的关系。根据单向状态流DSL的定义,一个结束状态必须由某个中间状态转换而来,因此,在中间状态与结束状态之间的关联上,还需要确保结束状态必须有一个中间状态与之关联。由于在上面的分析中,我们将中间状态和结束状态都归类于非起始状态,所以,我们还需要扩充中间状态和非起始状态之间的关系,指定当所关联的非起始状态为结束状态时,在中间状态一端的重复数是1..1的,也就是确保结束状态必须有一个关联的中间状态。
这种对关联的扩充同样也是通过领域关系的继承实现的:首先使用Reference Relationship创建中间状态与结束状态之间的关系,然后设置该关系的Base Class属性,将其设置为上面我们所设置的中间状态与非起始状态之间的关系类型。在完成了这部分设置之后,我们得到了类似下面的设计:
到目前为止,我们已经分析了单向状态流DSL所涉及的领域类型及其关系,并通过Visual Studio 2012 VMSDK的设计器对其进行了定义和设计。通过这些描述,可以让我们了解到如何从设计的角度去分析和考虑DSL中的领域类型和领域关系,限于篇幅,我并没有介绍在设计器中创建和设置这些类型的步骤,而是把重点放在了设计思路上,因为在接下来的内容中,会对DSL开发过程中所遇到的常见问题进行介绍,相对于“如何创建对象”、“如何设置属性”这样的问题来说会显得更有价值。在文章最后我会给出本案例的源代码工程,读者可以下载并在Visual Studio 2012中打开这个工程来了解整个解决方案的结构。
DSL的验证
使用过Entity Framework的读者一定知道,当EDMX模型设计器中的实体或实体关系的属性设置不正确时,保存的时候会在Visual Studio的错误列表(Error List)中显示错误信息,此时代码也无法自动化产生。开发人员也可以在设计器上单击鼠标右键,在上下文菜单中选择验证功能来实现模型的验证操作。现在,我们也让单向状态流的DSL能够支持这样的验证功能。
首先是启用默认的验证功能。所谓默认的验证功能就是Visual Studio根据DSL的定义设计对模型进行验证的功能。比如根据DSL中领域类型以及领域关系的设置、属性的设置等内容对模型进行验证。启用默认的验证功能很简单,只需要在DSL Explorer中,找到Editor节点下的Validation节点,然后根据需要将所对应的属性设置为True即可。如下图所示:
该图中的设置说明,当用户使用右键菜单或者当用户试图保存时,需要对整个模型进行验证。不仅如此,还需要执行一些自定义的验证逻辑。接下来,就让我们一起看看如何使用Visual Studio 2012 VMSDK来自定义DSL的验证逻辑。
根据本文一开始的应用场景设定,当某个中间状态被触发时,需要将设定在该状态上的文本输出到控制台。所以,在设计DSL的时候,我们需要在“中间状态”这种领域类型上定义一个字符串属性,取名为“OutputText”,如下:
这样做所产生的效果就是,开发人员在使用DSL定义一个单向状态流时,在每个“中间状态”的节点上会出现一个名为OutputText的字符串属性,以供开发人员设置需要在控制台中输出的字符串。很明显,基于这样的需求,我们要确保开发人员对每个“中间状态”都设置了OutputText属性,这样当该状态被触发时,才会有内容可以输出。
要实现这样的验证功能,需要自定义验证逻辑。在DSL Explorer下Validation的Uses Custom属性被设置为True的同时,还需要使用一些客户化代码。
在Visual Studio 2012解决方案资源管理器(Solution Explorer)中,找到Dsl工程下GeneratedCode目录下的DomainClasses.cs文件,该文件中包含了对DSL中所有领域类型的类的定义;同样,领域关系的类定义都在DomainRelationships.cs文件中。要实现对领域类型或领域关系的验证,只需在相应的类定义上应用ValidationStateAttribute特性,并在类中实现由ValidationMethod标记的方法即可。当然,我们不能直接修改DomainClasses.cs和DomainRelationships.cs文件,因为这些都是通过T4自动化产生的,在此我们需要使用partial关键字。
在Dsl工程下新建一个目录,比如CustomCode,在该目录下新建一个C#代码文件,然后在这个文件中使用以下代码来验证“中间状态”的OutputText属性是否已经设置:
using DslValidation = global::Microsoft.VisualStudio.Modeling.Validation;
[DslValidation::ValidationState(DslValidation::ValidationState.Enabled)]
partial class IntermediateState
{
[DslValidation::ValidationMethod(DslValidation::ValidationCategories.Save |
DslValidation.ValidationCategories.Menu)]
private void ValidateOutputText(DslValidation::ValidationContext context)
{
if (string.IsNullOrEmpty(this.OutputText))
{
context.LogError("OutputText property must be specified.",
"SFL001", this);
}
}
}
ValidationStateAttribute表示所修饰的类型是否需要启用客户化验证逻辑;在方法上应用ValidationMethodAttribute表示当前方法定义了客户化验证逻辑,同时还指定了验证方式:当使用右键菜单(Menu)时,或者当模型被保存(Save)时,都需要进行验证。验证方法接收一个ValidationContext类型的参数,一旦验证失败,则可以直接使用该参数的实例将验证结果反馈到Visual Studio 2012中。
本案例所使用的自定义验证逻辑都位于Dsl\CustomCode\CustomValidations.cs文件中,读者请自行下载本案例源代码参阅。当实现了客户化验证逻辑后,一旦模型验证失败,我们就能在Visual Studio 2012的错误列表(Error List)中获得错误信息:
连接行为的自定义
有些情况下,我们还需要对某个领域类型是否能够接受来自另一个领域类型的引用关联进行自定义,为了简化描述,在本文中将这种情形称为“连接行为的自定义”。假设:领域类型A可以通过领域关系R关联到领域类型B,但由于某种原因,比如B上有些属性未正确设置,在这些情况下,是不允许A通过R与B产生关联关系的,此时就需要实现R的关联行为的自定义。
在上文“对单向状态流DSL的分析”部分,我们已经设计并定义了一个DSL。根据目前的DSL定义,起始状态可以关联到中间状态,中间状态可以关联到非起始状态。由于中间状态本身又是非起始状态的一个子类,所以,这就造成了某个中间状态可以同时被起始状态和另一个中间状态关联的局面。然而单向状态流是不允许出现这种情况的,也就是当起始状态已经关联到了中间状态A后,A不能再接受来自其它中间状态的关联。所以,当开发人员试图创建中间状态B与A之间的关联时,DSL需要判断此时A是否已经被起始状态所关联,若是,则需阻止B与A之间的关联产生,也就是A不能接受来自B的关联。
为了实现这样的效果,我们需要在DSL Explorer中,找到中间状态关联非起始状态的连接定义,并在DSL Details窗口中,在连接的接受方,将Custom accept属性设置为True:
此时,通过Build | Transform All T4 Templates菜单,将整个解决方案中的T4模板进行转换,然后再通过Build | Rebuild Solution菜单对整个解决方案进行重编译。不出所料,编译失败:
当我们将Custom accept设置为True之后,就表示该连接的行为需要通过自定义的方式实现。因此,当通过T4转换产生C#代码的时候,就会在自动化产生的代码中留出自定义方法的占位代码。双击Error List中的错误,可以定位到调用这一方法的代码上。从报错的代码片段上我们可以看到类似如下的代码:
根据注释提示,很明显我们还需要创建一个新的方法来自定义连接行为,在这行注释中,也给出了方法的签名(signature),同样,使用部分类(partial class)的特性,实现这个方法即可:
public static partial class TransitionConnectionBuilder
{
private static bool CanAcceptIntermediateStateAndNonStartStateAsSourceAndTarget(IntermediateState sourceIntermediateState, NonStartState targetNonStartState)
{
if ((targetNonStartState is IntermediateState) &&
StartStateReferencesIntermediateState.GetLinkToStartState(targetNonStartState as IntermediateState) != null)
{
return false;
}
return true;
}
private static bool CanAcceptNonStartStateAsTarget(NonStartState candidate)
{
if ((candidate is IntermediateState) &&
StartStateReferencesIntermediateState.GetLinkToStartState(candidate as IntermediateState) != null)
{
return false;
}
return true;
}
}
上面的代码逻辑很明显:当被关联的非起始状态(targetNonStartState)为中间状态,并且存在起始状态对该中间状态的关联时,则拒绝接受来自另一个中间状态(sourceIntermediateState)的关联。
此时重新编译解决方案,编译通过,再次运行DSL,在单向状态流的设计器中,中间状态将无法再关联到另一个已被起始状态关联的中间状态了。
通过T4实现自动化代码生成
在实际应用中,我们可能不仅需要通过DSL来表达我们的领域概念和设计思想,还需要能够根据DSL来自动化产生一部分或者全部代码以减少开发工作量。正如本专题的第一部分介绍的那样,使用Visual Studio 2012 VMSDK所开发的DSL是一种外部DSL(External DSL),通过外部DSL产生代码的过程需要编译器或解释器的介入。与这种标准的代码生成过程不同的是,Visual Studio 2012 VMSDK为自动化代码生成提供了必要的工具和类库,DSL的开发者可以直接通过T4实现代码的自动化生成。
事实上,为某个特定的DSL模型编写T4模板其实意义并不大,我们更希望能够在今后使用DSL时,在实际解决方案中实现代码生成。然而,为了实现这样的目标,我们还是得从某个特定的DSL模型着手,为其编写一个代码生成的T4模板,然后再将这个模板通用化,并部署到客户机上。
在单向状态流DSL的开发界面上,直接按下F5键启动调试,此时会启动Visual Studio 2012 Experimental Instance,这在本专题第一部分的“调试”一节已经做过简要介绍。在Experimental Instance启动成功之后,我们即可开始开发T4模板。首先,向Debugging工程添加一个名为Test.stateflow的文件,双击打开这个文件,并在设计器中设计一个有效的单向状态流模型。例如:
然后,以同样的方式向Debugging工程添加一个Text Template文件,在此我们就将其命名为TestCS.tt,CS表示该T4模板主要是为了产生C#代码;当然你也可以根据实际需要再新建一个TestVB.tt,以满足Visual Basic的代码生成需求。在T4模板文件的开始部分,通过以下预处理指令来指定文本转换的类型以及所使用的DSL模型:
<#@ template inherits="Microsoft.VisualStudio.TextTemplating.VSHost.ModelingTextTransformation"
debug="false" hostspecific="false" language="C#" #>
<#@ StateFlowLanguage processor="StateFlowLanguageDirectiveProcessor"
requires="fileName='Test.stateflow'" #>
于是,在接下来的T4编辑过程中,就可以直接使用StateFlowModel属性来访问我们所建立的DSL模型了。此处StateFlowModel为指代DSL模型的领域类型的名称。
例如:如果我们需要获得模型中“起始状态”所关联的“中间状态”的名称,我们可以使用这样的表达式:
<#= this.StateFlowModel.StartState.IntermediateState.Name #>
在这里,我就不再将TestCS.tt文件的具体内容贴出了,还是请读者自行下载解决方案源代码进行阅读研究。在完成TestCS.tt的编写后,通过Run Custom Tool命令将模板转换成C#代码,我们会得到一系列的类。下图表示了这些类之间的关系:
接下来要做的就是,在完成DSL的部署以后,我们仍然希望能在实际的开发环境中,通过DSL直接产生代码。这就需要在部署DSL的同时,将我们开发的T4模板也一并发布。可以通过以下步骤完成这个过程:
- 在DslPackage工程下新建一个CustomCode的目录(目录名称随便),将已经调试通过的TestCS.tt文件添加到这个目录下,根据需要更改一下该文件的文件名,并将该文件的Build Action设置为Embedded Resource
- 将该文件中<#@ StateFlowLanguage #>指令的requires文件名改为一个宏名,以便接下来在代码中能够将其动态替换。此处我们用%MODELFILENAME%作为宏名:
<#@ StateFlowLanguage processor="StateFlowLanguageDirectiveProcessor" requires="fileName='%MODELFILENAME%'" #>
- 创建一个继承于TemplatedCodeGenerator的类,重写GenerateCode方法,在这个重写的方法中,首先从当前程序集的资源中读取T4模板代码,并将其中的%MODELFILENAME%宏替换为实际的模型文件名,再调用基类(TemplatedCodeGenerator类)的GenerateCode方法以产生程序代码。详细实现如下:
using Microsoft.VisualStudio.TextTemplating.VSHost;
using System.Diagnostics;
using System.IO;
using System.Reflection;
namespace MyCompany.StateFlowLanguage
{
[System.Runtime.InteropServices.Guid("14CE1B63-5030-4E9C-B671-B62EA776B5EF")]
public class StateFlowModelGenerator : TemplatedCodeGenerator
{
protected override byte[] GenerateCode(string inputFileName, string inputFileContent)
{
const string ModelFileNameMarker = "%MODELFILENAME%";
const string ResourceName = @"MyCompany.StateFlowLanguage.CustomCode.StateFlowModelCustomTool.tt";
// Load the text template from the embedded resource
string templateCode = null;
using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName))
{
Debug.Assert(stream != null, "Error - could not find the resource");
StreamReader reader = new StreamReader(stream);
templateCode = reader.ReadToEnd();
reader.Close();
}
Debug.Assert(templateCode.Contains(ModelFileNameMarker),
"Error - the template code does not contain the expected model file name marker");
// Substitute the real model file name into the template code
templateCode = templateCode.Replace(ModelFileNameMarker, inputFileName);
// Delegate the rest of the work to the base class.
// This will run the T4 transformation and return the
// result.
return base.GenerateCode(inputFileName, templateCode);
}
}
} - 使用部分类(partial class)的特性,向DslPackage\GeneratedCode\Package.cs文件中的Package类添加一个新的特性:ProvideCodeGeneratorAttribute,以指定Package所使用的源代码生成器,比如:
[ProvideCodeGenerator(typeof(StateFlowModelGenerator), "StateFlowModelGenerator",
这里说明一下,如果你需要同时支持C#和Visual Basic两种语言,则可再添加一个ProvideCodeGeneratorAttribute特性,所不同的是,你需要依照上面第三点的做法新建一个基于Visual Basic的Generator,然后在ProvideCodeGeneratorAttribute中的第一个参数传入这个新建的Generator的类型,并将ProjectSystem设置为ProvideCodeGeneratorAttribute.VisualBasicProjectGuid。
"Generates the state flow source code for StateFlowLanguage.",
true,
ProjectSystem=ProvideCodeGeneratorAttribute.CSharpProjectGuid)]
partial class StateFlowLanguagePackage
{
}
现在,让我们重新编译整个解决方案,并开始我们的DSL部署与使用的旅程吧。
部署DSL
部署DSL的过程非常简单:我们只需要执行由DslPackage工程产生的VSIX文件(Visual Studio扩展安装程序)即可。在DslPackage的编译输出目录,我们可以找到这个文件:
双击运行这个文件,就会出现标准的VSIX Installer界面:
直接单击Install按钮,将我们的DSL安装到Visual Studio 2012开发环境中。安装成功后,会给出提示信息:
接下来,让我们在实际项目中使用我们自己开发的单向状态流DSL。
使用DSL
重新启动Visual Studio,新建一个控制台解决方案,在新建的控制台项目上,单击鼠标右键,选择Add | New Item选项,在Add New Item中选择StateFlowLanguage:
双击新添加的StateFlowLanguage文件,会打开图形化设计器,通过鼠标拖拽的方式从工具栏向设计器添加一个开始状态,一个结束状态和多个中间状态,并设置好各个状态的名称与OutputText属性,以及模型的命名空间。保存模型或者使用右键菜单的Validate All选项来确保整个模型的设置是正确的。在完成了这些步骤之后,我们得到了下面的单向状态流的设计:
现在,我们让Visual Studio能够根据这个设计自动化产生代码。打开StateFlowLanguage1.stateflow文件的属性设置框,在Custom Tool属性上输入StateFlowModelGenerator后回车,可以立即看到在StateFlowLanguage1.stateflow节点下出现了一个C#源代码文件。双击打开这个C#源代码文件,可以看到产生的C#代码:
Custom Tool中输入的“StateFlowModelGenerator”,就是之前在ProvideCodeGeneratorAttribute中设置的第二个参数的值。
现在通过控制台程序调用这个单向状态流。修改控制台工程的Program类,在Main方法中加入:
new StateFlowMachine().Run();
编译并执行程序,可以看到,我们的程序向控制台依次输出了设置在三个中间状态上的文本信息:
进一步体验
有兴趣的朋友可以在设计器中尝试改变单向状态流的流向、增加或者删除中间节点等操作,来体验DSL给我们的日常开发工作带来的好处。每当模型被保存时,源代码都将自动重新生成。开发人员,甚至是不懂开发的领域专家,都可以很方便地通过调整模型的设置来改变软件的行为逻辑,而我们所要做的,就是开发这样一套能够解决特定领域问题的DSL。通过Visual Studio 2012 VMSDK所开发的DSL,不仅减小了开发人员与领域专家之间的交流成本,代码的自动化产生更是简化了软件的开发过程:这意味着更少的重复劳动、更少的成本投入以及更小的出错几率。
总结
《在Visual Studio 2012中使用VMSDK开发领域特定语言》专题文章至此就告一段落。在本专题的第一部分,对领域特定语言进行了简要介绍,并详细介绍了Visual Studio 2012和Visualization & Modeling SDK的集成开发环境;在第二部分中,我们通过一个单向状态流DSL的案例,了解了解决方案的创建、DSL分析、验证、开发、自动化代码生成、部署以及使用等内容,虽然没有对DSL开发中的各种属性设置进行详细介绍,但通过这些内容,读者应该能够了解到使用Visual Studio 2012 VMSDK开发DSL的基本过程和一些常用技术。在实际的软件开发项目中,如果项目规模较大,在成本和时间允许的前提下,我还是建议能够根据实际需要来定义一些DSL,虽然看上去前期投入较大,但它确实能在后续的开发过程中简化问题、降低成本、减少错误,以一种新的开发模式引领项目朝着良性的方向发展。
源代码下载
请通过【http://sdrv.ms/17uTLF9】下载本案例的源程序代码。