在Visual Studio 2012中使用VMSDK開發領域特定語言(二)

dax.net發表於2013-08-06

本文為《在Visual Studio 2012中使用VMSDK開發領域特定語言》專題文章的第二部分,在這部分內容中,將以實際應用為例,介紹開發DSL的主要步驟,包括設計、定製、除錯、釋出以及使用等。

案例:一個單向狀態流DSL的設計和開發

假設我們需要設計一個單向狀態流DSL,這個單向狀態流有著三種不同的狀態節點:起始節點、中間節點和結束節點。整個DSL需要滿足以下的條件(或具有以下功能):

  • 為了簡單起見,狀態的轉換是無條件的(也就是不存在分支、迴圈等,轉換流是一個狀態接一個狀態的連結串列形式,這也是“單向”一詞的含義)
  • 起始狀態只能銜接到中間狀態;中間狀態可以銜接到另一箇中間狀態或者結束狀態;結束狀態只能被中間狀態銜接
  • 起始狀態不能被任何狀態銜接
  • 中間狀態只能被起始狀態或者另一箇中間狀態銜接
  • 在DSL中,有且僅有一個起始和結束狀態,有至少一箇中間狀態
  • 為了簡單起見,當中間狀態被訪問(觸發)時,僅需向控制檯輸出設定於該狀態上的文字,無需任何其它操作

下圖更直觀地表述了上面的描述,這也是該DSL開發完成後在Visual Studio 2012中使用的效果:

p1

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模型與這些狀態之間的關係可以用下圖表示:

p2

再看這三種狀態之間的轉換關係:起始狀態只可以轉換到中間狀態,也就是說它不能轉換到結束狀態或者自己本身;中間狀態可以轉換到另一箇中間狀態,或者結束狀態,但不能轉換到起始狀態;作為一個轉換的接受物件,它又只能接受來自起始狀態或者另一箇中間狀態的轉換;而對於結束狀態而言,它只能接受來自某個中間狀態的轉換。

根據上面的分析,我們可以很容易地理清三種狀態之間的領域關係。首先是起始狀態和中間狀態之間的關係:起始狀態僅能關聯(注:上面也提到過,這種關聯關係其實是轉換關係)到一箇中間狀態;而中間狀態則可以被一個起始狀態所關聯,“可以”一詞的意思是,中間狀態不一定非要被起始狀態所關聯,它還可以被關聯到另一箇中間狀態。因此,我們可以用下面的DSL設計圖來表示起始狀態和中間狀態之間的關係:

p3

其次,中間狀態可以關聯到另一箇中間狀態或結束狀態。所以我們需要在兩個中間狀態之間,以及中間狀態與結束狀態之間建立“轉換”關係。但這裡會產生一個問題:由於中間狀態可以轉換到另一箇中間狀態,也可以轉換到結束狀態,因此,在建立的兩個領域關係上,“中間狀態”和“結束狀態”一端的重複數只能是0..1。然而這樣也造成了在實際的DSL應用中,一箇中間狀態可以同時關聯到另一箇中間狀態和結束狀態的局面。

為了解決這個問題,我們需要引入領域型別的“繼承”關係:將中間狀態和結束狀態抽象成“非起始狀態”,而中間狀態和結束狀態都是“非起始狀態”的子型別。比如:

p4

於是,我們只需要設定中間狀態與非起始狀態之間的關係即可,在這個關係的“非起始狀態”一端,重複數為1..1,也就是說,中間狀態必須關聯到一個非起始狀態。由於中間狀態和結束狀態都是非起始狀態的子類,因此,在實際的DSL應用中,當某個中間狀態“轉換”到另一箇中間狀態時,該中間狀態則不能再“轉換”到其它的中間狀態或者結束狀態。這就解決了上面的問題。

再進一步分析結束狀態與中間狀態之間的關係。根據單向狀態流DSL的定義,一個結束狀態必須由某個中間狀態轉換而來,因此,在中間狀態與結束狀態之間的關聯上,還需要確保結束狀態必須有一箇中間狀態與之關聯。由於在上面的分析中,我們將中間狀態和結束狀態都歸類於非起始狀態,所以,我們還需要擴充中間狀態和非起始狀態之間的關係,指定當所關聯的非起始狀態為結束狀態時,在中間狀態一端的重複數是1..1的,也就是確保結束狀態必須有一個關聯的中間狀態。

這種對關聯的擴充同樣也是通過領域關係的繼承實現的:首先使用Reference Relationship建立中間狀態與結束狀態之間的關係,然後設定該關係的Base Class屬性,將其設定為上面我們所設定的中間狀態與非起始狀態之間的關係型別。在完成了這部分設定之後,我們得到了類似下面的設計:

p5

到目前為止,我們已經分析了單向狀態流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即可。如下圖所示:

p6

該圖中的設定說明,當使用者使用右鍵選單或者當使用者試圖儲存時,需要對整個模型進行驗證。不僅如此,還需要執行一些自定義的驗證邏輯。接下來,就讓我們一起看看如何使用Visual Studio 2012 VMSDK來自定義DSL的驗證邏輯。

根據本文一開始的應用場景設定,當某個中間狀態被觸發時,需要將設定在該狀態上的文字輸出到控制檯。所以,在設計DSL的時候,我們需要在“中間狀態”這種領域型別上定義一個字串屬性,取名為“OutputText”,如下:

p7

這樣做所產生的效果就是,開發人員在使用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)中獲得錯誤資訊:

p8

連線行為的自定義

有些情況下,我們還需要對某個領域型別是否能夠接受來自另一個領域型別的引用關聯進行自定義,為了簡化描述,在本文中將這種情形稱為“連線行為的自定義”。假設:領域型別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:

p9

此時,通過Build | Transform All T4 Templates選單,將整個解決方案中的T4模板進行轉換,然後再通過Build | Rebuild Solution選單對整個解決方案進行重編譯。不出所料,編譯失敗:

p10

當我們將Custom accept設定為True之後,就表示該連線的行為需要通過自定義的方式實現。因此,當通過T4轉換產生C#程式碼的時候,就會在自動化產生的程式碼中留出自定義方法的佔位程式碼。雙擊Error List中的錯誤,可以定位到呼叫這一方法的程式碼上。從報錯的程式碼片段上我們可以看到類似如下的程式碼:

p11

根據註釋提示,很明顯我們還需要建立一個新的方法來自定義連線行為,在這行註釋中,也給出了方法的簽名(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的檔案,雙擊開啟這個檔案,並在設計器中設計一個有效的單向狀態流模型。例如:

p12

然後,以同樣的方式向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#程式碼,我們會得到一系列的類。下圖表示了這些類之間的關係:

p13

接下來要做的就是,在完成DSL的部署以後,我們仍然希望能在實際的開發環境中,通過DSL直接產生程式碼。這就需要在部署DSL的同時,將我們開發的T4模板也一併釋出。可以通過以下步驟完成這個過程:

  • 在DslPackage工程下新建一個CustomCode的目錄(目錄名稱隨便),將已經除錯通過的TestCS.tt檔案新增到這個目錄下,根據需要更改一下該檔案的檔名,並將該檔案的Build Action設定為Embedded Resource 
    p14
  • 將該檔案中<#@ 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",
        "Generates the state flow source code for StateFlowLanguage.",
        true,
        ProjectSystem=ProvideCodeGeneratorAttribute.CSharpProjectGuid)]
    partial class StateFlowLanguagePackage
    {
    }
    這裡說明一下,如果你需要同時支援C#和Visual Basic兩種語言,則可再新增一個ProvideCodeGeneratorAttribute特性,所不同的是,你需要依照上面第三點的做法新建一個基於Visual Basic的Generator,然後在ProvideCodeGeneratorAttribute中的第一個引數傳入這個新建的Generator的型別,並將ProjectSystem設定為ProvideCodeGeneratorAttribute.VisualBasicProjectGuid。

現在,讓我們重新編譯整個解決方案,並開始我們的DSL部署與使用的旅程吧。

部署DSL

部署DSL的過程非常簡單:我們只需要執行由DslPackage工程產生的VSIX檔案(Visual Studio擴充套件安裝程式)即可。在DslPackage的編譯輸出目錄,我們可以找到這個檔案:

p15

雙擊執行這個檔案,就會出現標準的VSIX Installer介面:

p16

直接單擊Install按鈕,將我們的DSL安裝到Visual Studio 2012開發環境中。安裝成功後,會給出提示資訊:

p17

接下來,讓我們在實際專案中使用我們自己開發的單向狀態流DSL。

使用DSL

重新啟動Visual Studio,新建一個控制檯解決方案,在新建的控制檯專案上,單擊滑鼠右鍵,選擇Add | New Item選項,在Add New Item中選擇StateFlowLanguage:

p18

雙擊新新增的StateFlowLanguage檔案,會開啟圖形化設計器,通過滑鼠拖拽的方式從工具欄向設計器新增一個開始狀態,一個結束狀態和多箇中間狀態,並設定好各個狀態的名稱與OutputText屬性,以及模型的名稱空間。儲存模型或者使用右鍵選單的Validate All選項來確保整個模型的設定是正確的。在完成了這些步驟之後,我們得到了下面的單向狀態流的設計:

p19

現在,我們讓Visual Studio能夠根據這個設計自動化產生程式碼。開啟StateFlowLanguage1.stateflow檔案的屬性設定框,在Custom Tool屬性上輸入StateFlowModelGenerator後回車,可以立即看到在StateFlowLanguage1.stateflow節點下出現了一個C#原始碼檔案。雙擊開啟這個C#原始碼檔案,可以看到產生的C#程式碼:

p20

Custom Tool中輸入的“StateFlowModelGenerator”,就是之前在ProvideCodeGeneratorAttribute中設定的第二個引數的值。

現在通過控制檯程式呼叫這個單向狀態流。修改控制檯工程的Program類,在Main方法中加入:

new StateFlowMachine().Run();

編譯並執行程式,可以看到,我們的程式向控制檯依次輸出了設定在三個中間狀態上的文字資訊:

p21

進一步體驗

有興趣的朋友可以在設計器中嘗試改變單向狀態流的流向、增加或者刪除中間節點等操作,來體驗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】下載本案例的源程式程式碼。

相關文章