產生 DSL 的程式碼

當您建立特定領域語言 (DSL) 時,如何讓它可執行。對於內部 DSL 來說,這是一個容易回答的問題,因為它們會嵌入到真正的語言中。外部 DSL 需要更多工作。這裡我舉一個簡單的 DSL 範例,並展示幾個從中產生程式碼的簡單方法。

2005 年 6 月 12 日



我最近寫了一篇文章,描述面向語言的程式設計和最近一堆我稱之為語言工作台的工具演進。在該篇文章中,我使用一個簡單的特定領域語言來說明我的觀點。儘管我在文章中討論了這個 DSL 的外觀,但我沒有談到如何透過產生程式碼來讓該語言可執行。了解這一點很有用,因為它可以幫助您了解語言工作台的抽象表示的性質,以及語言工作台的產生器如何運作。

因此,在本文中,我將採用該篇文章中的簡單範例,並展示一些我們可以產生程式碼的簡單方法。這將從一個簡單的單次傳遞方法,轉換到涉及建立抽象表示和使用範本來產生程式碼的方法。

在思考使用 DSL 的權衡時,您真的不需要了解產生是如何運作的。然而,當我深入探討語言工作台如何運作時,這將會很方便。

我將從自訂語言案例開始。為了喚起您的記憶,自訂語言看起來像

mapping SVCL dsl.ServiceCall
  4-18: CustomerName
  19-23: CustomerID
  24-27 : CallTypeCode
  28-35 : DateOfCallString

mapping  USGE dsl.Usage
  4-8 : CustomerID
  9-22: CustomerName
  30-30: Cycle
  31-36: ReadDate
  

為了讓自訂範例運作,我們需要將它轉換成等同於內部 DSL 範例。

public void Configure(Reader target) {
  target.AddStrategy(ConfigureServiceCall());
  target.AddStrategy(ConfigureUsage());
}
private ReaderStrategy ConfigureServiceCall() {
  ReaderStrategy result = new ReaderStrategy("SVCL", typeof (ServiceCall));
  result.AddFieldExtractor(4, 18, "CustomerName");
  result.AddFieldExtractor(19, 23, "CustomerID");
  result.AddFieldExtractor(24, 27, "CallTypeCode");
  result.AddFieldExtractor(28, 35, "DateOfCallString");
  return result;
}
private ReaderStrategy ConfigureUsage() {
  ReaderStrategy result = new ReaderStrategy("USGE", typeof (Usage));
  result.AddFieldExtractor(4, 8, "CustomerID");
  result.AddFieldExtractor(9, 22, "CustomerName");
  result.AddFieldExtractor(30, 30, "Cycle");
  result.AddFieldExtractor(31, 36, "ReadDate");
  return result;
}

讀取架構非常簡單,只有一個讀取類別,處理一個包含事件檔案的策略,而這些策略適用於檔案中可能出現的每一種事件。讀取器會讀取每一行,擷取事件代碼,並交由特定行的策略處理。組態的工作就是建立正確的策略並將它們傳送給讀取器。

為了將從外部組態檔案建立讀取器的程式碼封裝起來,該程式碼是一個獨立的讀取器建立器類別。我們會在進行的過程中探索許多執行此項操作的方法,因此您會看到各種讀取器建立器。第一個只是單純讀取自訂組態檔案並設定讀取器。

單次傳遞建構器

我透過告訴建立器要使用哪個組態檔案來建立一個建立器。然後我使用它來組態一個讀取器。

類別 ReaderBuilderTextSinglePass...

  public ReaderBuilderTextSinglePass(string filename) {
    _filename = filename;
  }
  private string _filename;
  public void Configure(Reader reader) {
    _reader = reader;
    using (TextReader input = File.OpenText(_filename)) {
      while ((_line = input.ReadLine()) != null)
        ProcessLine();
    }
  }
  private Reader _reader;
  private string _line = null;

為了處理自訂組態檔案的一行,我使用各種正規表示式測試該行,並根據我看到的行類型做出反應。空白和註解會被忽略。

類別 ReaderBuilderTextSinglePass...

  private void ProcessLine() {
    if (isBlank()) return;
    if (isComment()) return;
    else if (isNewMapping()) makeNewStrategy();
    else makeFieldExtract();
  }
  private bool isBlank() {
    Regex blankRE = new Regex(@"^\s*$");
    return blankRE.IsMatch(_line);
  }
  private bool isComment() {
    return _line[0] == '#';
  }
  private bool isNewMapping() {
    Regex blankRE = new Regex(@"\s*mapping");
    return blankRE.IsMatch(_line);
  }

當我看到對應宣告時,我會建立一個新的策略。

類別 ReaderBuilderTextSinglePass...

  private void makeNewStrategy() {
    string[] tokens = _line.Split(whitespace());
    _currentStrategy = new ReaderStrategy(tokens[1].Trim(whitespace()),
                                          Type.GetType(tokens[2]));
    _reader.AddStrategy(_currentStrategy);
  }
  private char[] whitespace() {
    char[] result = {' ', '\t'};
    return result;
  }

當我看到欄位宣告時,我會將一個新的欄位萃取器新增到策略中。

類別 ReaderBuilderTextSinglePass...

  private void makeFieldExtract() {
    string[] tokens1 = _line.Split(':');
    string targetProperty = tokens1[1].Trim(' ');
    string[] tokens2 = tokens1[0].Trim(whitespace()).Split('-');
    int begin = Int32.Parse(tokens2[0]);
    int end = Int32.Parse(tokens2[1]);
    _currentStrategy.AddFieldExtractor(begin, end, targetProperty);
  }

這當然不是我寫過最漂亮的剖析器,但它很簡單,而且可以完成工作。我實際上所做的是剖析組態檔案,並在進行的過程中組態讀取器。對於像這樣一個簡單的範例,從自訂 DSL 到架構的單次轉換快速又容易。

兩次傳遞建構器

現在讓我們來看一下一個稍微不同的執行方式。我現在要執行的是一個兩次處理程序。剖析器會讀取組態檔案並產生一個資料結構。然後,一個獨立的產生器會查看這個資料結構來組態讀取器。

圖 1:語言抽象表示的資料結構。

圖 1 顯示這個資料結構。正如您所見,它表示對應語言的抽象語法。記得編譯器類別的人會將此識別為語言的抽象語法樹。

兩個類別會處理這個樹狀結構。剖析器會讀取文字輸入並建立樹狀結構。然後,產生器會讀取樹狀結構並組態讀取器物件。

剖析器與我們之前看到的剖析器非常相似。基本控制流程相同。

類別 ReaderBuilderTextSinglePass...

  public ReaderBuilderTextSinglePass(string filename) {
    _filename = filename;
  }
  private string _filename;
  public void Configure(Reader reader) {
    _reader = reader;
    using (TextReader input = File.OpenText(_filename)) {
      while ((_line = input.ReadLine()) != null)
        ProcessLine();
    }
  }
  private Reader _reader;
  private string _line = null;

這個啟動程式碼中唯一的變更,是傳回 AST 的根節點,而不是讀取器。

這個決策制定完全相同。

類別 BuilderParserText...

  private void ProcessLine() {
    if (isBlank()) return;
    if (isComment()) return;
    else if (isNewMapping()) makeMapping();
    else makeField();
  }
  private bool isBlank() {
    Regex blankRE = new Regex(@"^\s*$");
    return blankRE.IsMatch(_line);
  }
  private bool isComment() {
    return _line[0] == '#';
  }
  private bool isNewMapping() {
    Regex blankRE = new Regex(@"\s*mapping");
    return blankRE.IsMatch(_line);
  }
  private char[] whitespace() {
    char[] result = {' ', '\t'};
    return result;
  }

變更會發生在剖析器剖析出廣泛的代碼標記之後的動作中。在這種情況下,當剖析器看到對應行時,會將對應物件新增到 AST 的根節點。

類別 BuilderParserText...

  private void makeMapping() {
    _currentMapping = new ReaderConfiguration.Mapping();
    _result.Mappings.Add(_currentMapping);
    string[] tokens = _line.Split(whitespace());
    _currentMapping.Code = tokens[1].Trim(whitespace());
    _currentMapping.TargetClassName = tokens[2].Trim(whitespace());
  }

同樣地,當它看到欄位時,它會加入欄位物件。

類別 BuilderParserText...

  private void makeField() {
    ReaderConfiguration.Field f = new ReaderConfiguration.Field();
    string[] tokens1 = _line.Split(':');
    f.FieldName = tokens1[1].Trim(' ');
    string[] tokens2 = tokens1[0].Trim(whitespace()).Split('-');
    f.Start = Int32.Parse(tokens2[0]);
    f.End = Int32.Parse(tokens2[1]);
    _currentMapping.Fields.Add(f);
  }
}

產生器現在會讀取這個結構來設定架構。它是一個非常簡單的類別。

類別 BuilderGenerator...

  public void Configure(Reader result, ReaderConfiguration configuration) {
    foreach (ReaderConfiguration.Mapping mapping in configuration.Mappings)
      makeStrategy(result, mapping);
  }
  private void makeStrategy(Reader result, ReaderConfiguration.Mapping mapping) {
    ReaderStrategy strategy = new ReaderStrategy(mapping.Code, mapping.TargetClass);
    result.AddStrategy(strategy);
    foreach(ReaderConfiguration.Field field in mapping.Fields) 
      strategy.AddFieldExtractor(field.Start, field.End, field.FieldName);
  }

將這兩個階段分開有什麼好處?它確實讓我們增加了一點複雜性 - 我們必須加入 AST 類別。如果我們只讀取和寫入單一格式,那麼 AST 是否值得花費力氣是有爭議的 - 至少對於這個簡單的案例來說是如此。AST 的真正優點在於當我們想要讀取或寫入多種格式時。

讓我們允許我們的 DSL 以 XML 具體語法以及自訂語法撰寫。再次,為了讓您不必在文件中到處尋找,以下是 XML 版本。

<ReaderConfiguration>
  <Mapping Code = "SVCL" TargetClass = "dsl.ServiceCall">
    <Field name = "CustomerName" start = "4" end = "18"/>
    <Field name = "CustomerID" start = "19" end = "23"/>
    <Field name = "CallTypeCode" start = "24" end = "27"/>
    <Field name = "DateOfCallString" start = "28" end = "35"/>
  </Mapping>
  <Mapping Code = "USGE" TargetClass = "dsl.Usage">
    <Field name = "CustomerID" start = "4" end = "8"/>
    <Field name = "CustomerName" start = "9" end = "22"/>
    <Field name = "Cycle" start = "30" end = "30"/>
    <Field name = "ReadDate" start = "31" end = "36"/>
  </Mapping>
</ReaderConfiguration>

要讀取這個格式,我們所要做的就是撰寫一個新的剖析器 - 我們可以使用相同的產生器。

類別 BuilderParserXml...

  ReaderConfiguration _result = new ReaderConfiguration();
  string _filename;
  public BuilderParserXml()
  {
  }
  public BuilderParserXml(string filename) {
    _filename = filename;
  }
  public void run() {
    XPathDocument doc = new XPathDocument(File.OpenText(_filename));
    XPathNavigator nav = doc.CreateNavigator();
    XPathNodeIterator it = nav.Select("//Mapping");
    while (it.MoveNext()) ProcessMappingNode(it.Current);
  }
  public ReaderConfiguration ReaderConfiguration {
    get { return _result; }
  }
  private void ProcessMappingNode(XPathNavigator nav) {
    ReaderConfiguration.Mapping currentMapping = new ReaderConfiguration.Mapping();
    _result.Mappings.Add(currentMapping);
    currentMapping.Code = nav.GetAttribute("Code", "");
    currentMapping.TargetClassName = nav.GetAttribute("TargetClass", "");
    XPathNodeIterator it = nav.SelectChildren("Field", "");
    while(it.MoveNext()) currentMapping.Fields.Add(ProcessFieldNode(it.Current));
  }
  private ReaderConfiguration.Field ProcessFieldNode(XPathNavigator nav) {
    ReaderConfiguration.Field result = new ReaderConfiguration.Field();
    result.FieldName = nav.GetAttribute("name", "");
    result.Start = Convert.ToInt16(nav.GetAttribute("start", ""));
    result.End = Convert.ToInt16(nav.GetAttribute("end", ""));
    return result;
  }

XML 剖析器比較容易撰寫,因為工具會為我們處理所有文字整理,我們所要做的就是讀取產生的 XML 樹狀結構。它建立的物件與自訂文字剖析器完全相同,所以相同的產生器會以相同的方式運作。(兩步驟程式的另一個優點是我們也可以獨立測試每個步驟。)

對於像這樣簡單的語言,手動撰寫像這樣的剖析器是可以的,但我不會建議您對更複雜的語言這麼做。剖析器產生器工具可以採用語言的語法定義,並協助您產生 AST。您不必比這個範例複雜太多,就能讓這些工具發揮作用。雖然學習如何使用它們需要花費一些力氣,但結果會容易處理許多。(基本上,語法是協助您將語言剖析成抽象表示的 DSL。)

我不會在此進一步討論剖析器產生器,因為剖析程序的部分對於語言工作台來說並不重要。在語言工作台中,抽象表示所扮演的角色遠比在傳統程式設計中更為核心 - 此外還有您能針對相同的抽象表示擁有多種人類可讀形式的想法。

使用範本產生

在上述範例中,我們使用一些程序碼來產生架構類別,這在這個案例中運作得很好。產生器的另一種方法是實際產生 C# 輸出,然後可以與架構一起編譯。這允許設定檔在編譯時而不是執行時帶入系統。根據情況,這可能是一種禍害而不是一種好處,但在此探討這種方法是有價值的 - 再次是因為我們會在語言工作台中再次看到它。

範本背後的想法是以最終格式編輯您的輸出檔案,但使用小標記來指出您希望產生器插入程式碼的位置。各種伺服器頁面技術 (PHP、JSP、ASP) 使用範本將動態內容加入網頁。在這個案例中,我們會使用範本來將產生的內容加入一個 C# 骨架檔案。

為了示範,我將使用 NVelocity。NVelocity 是熱門 Java 編排引擎 Velocity 的 .NET 移植。我喜歡 Velocity,因為它很簡單,許多人喜歡使用 Velocity 取代 JSP。NVelocity 仍在開發中,我在使用時發現它的文件非常有限。幸運的是,範本語言 (VTL) 與 Java 版本相同,而且那裡的說明文件可用。

執行 NVelocity 可能會有些棘手。這裡有一個 Velocity 建構器類別,它會建立 Velocity 引擎的執行個體,我可以使用它來建構我需要的檔案。

class VelocityBuilder...

  public VelocityBuilder(string templateDir, string configDir, string targetDir) {
    engine = new VelocityEngine();  
    this.configDir = configDir;
    this.targetDir = targetDir;
    engine.SetProperty(RuntimeConstants_Fields.FILE_RESOURCE_LOADER_PATH, templateDir);
    engine.Init();
    config = new BuilderParserText(configDir + "ReaderConfig.txt").Run();
  }
  VelocityEngine engine;
  string configDir;
  string targetDir;
  ReaderConfiguration config;

當我在進行編排時,我通常會先為單一案例撰寫一個硬式編碼類別,讓該類別運作並進行除錯,然後(盡可能逐漸地)使用編排元素取代硬式編碼元素。

我將以兩種方式顯示這個。首先,我將使用編排來產生我們先前建立的 C# 組態程式碼。這通常不是你會執行的做法,但它讓我得以在熟悉的東西上示範編排。組態程式碼看起來像這樣。

public void Configure(Reader target) {
  target.AddStrategy(ConfigureServiceCall());
  target.AddStrategy(ConfigureUsage());
}
private ReaderStrategy ConfigureServiceCall() {
  ReaderStrategy result = new ReaderStrategy("SVCL", typeof (ServiceCall));
  result.AddFieldExtractor(4, 18, "CustomerName");
  result.AddFieldExtractor(19, 23, "CustomerID");
  result.AddFieldExtractor(24, 27, "CallTypeCode");
  result.AddFieldExtractor(28, 35, "DateOfCallString");
  return result;
}
private ReaderStrategy ConfigureUsage() {
  ReaderStrategy result = new ReaderStrategy("USGE", typeof (Usage));
  result.AddFieldExtractor(4, 8, "CustomerID");
  result.AddFieldExtractor(9, 22, "CustomerName");
  result.AddFieldExtractor(30, 30, "Cycle");
  result.AddFieldExtractor(31, 36, "ReadDate");
  return result;
}

編排版本的程式碼看起來像這樣。

public void Configure(Reader target) {
  #foreach( $map in ${config.Mappings})
  target.AddStrategy(Configure${map.TargetClassNameOnly}());
  #end
}
#foreach( $map in $config.Mappings)
private ReaderStrategy Configure${map.TargetClassNameOnly}() {
  ReaderStrategy result = new ReaderStrategy("$map.Code", typeof ($map.TargetClassName));
  #foreach( $f in $map.Fields)
  result.AddFieldExtractor($f.Start, $f.End, "$f.FieldName");
  #end
  return result;
}
#end

由於我不會假設你熟悉 VTL(Velocity 範本語言),因此我將說明我使用的元素。

第一個部分是參數參考。你可以使用語法 $parameterName${parameterName} 來在 VTL 中參考參數(後者在直接針對其他文字執行參數參考且沒有空格時最佳)。取得參數後,你可以自由呼叫方法和存取該參數的屬性。

若要設定參數可存取的項目,你需要在執行對應時將物件放入引擎的內容中。

private void GenerateParameterized() {
  VelocityContext context = new VelocityContext();
  context.Put("config", this.config);
  using (TextWriter target = File.CreateText(targetDir + "ReflectiveTemplateBuilder.cs"))
    engine.MergeTemplate("ReflectiveTemplateBuilder.cs.vm", context, target);
}

(你會注意到我在對應上定義了一個屬性 TargetClassNameOnly。這會將目標類別的名稱傳回為 ServiceCall,而不是 dsl.ServiceCall,這很有用,因為我保留了在產生的組態程式碼中中斷方法。儘管 AST 主要是一個愚蠢的資料結構,但沒有理由不將有用的行為移到其中以避免重複。)

第二個 VTL 部分是迴圈指令 #foreach ($item in $collection)。這允許我迴圈瀏覽對應和欄位。

產生的結果程式碼看起來像這樣。

public void Configure(Reader target) {
        target.AddStrategy(ConfigureServiceCall());
        target.AddStrategy(ConfigureUsage());
      }
    private ReaderStrategy ConfigureServiceCall() {
  ReaderStrategy result = new ReaderStrategy("SVCL", typeof (dsl.ServiceCall));
        result.AddFieldExtractor(4, 18, "CustomerName");
        result.AddFieldExtractor(19, 23, "CustomerID");
        result.AddFieldExtractor(24, 27, "CallTypeCode");
        result.AddFieldExtractor(28, 35, "DateOfCallString");
        return result;
}
    private ReaderStrategy ConfigureUsage() {
  ReaderStrategy result = new ReaderStrategy("USGE", typeof (dsl.Usage));
        result.AddFieldExtractor(4, 8, "CustomerID");
        result.AddFieldExtractor(9, 22, "CustomerName");
        result.AddFieldExtractor(30, 30, "Cycle");
        result.AddFieldExtractor(31, 36, "ReadDate");
        return result;
}

列格式有點混亂,但除此之外,它與原始程式碼非常接近。

因此,這會產生我們手寫的相同程式碼,但這通常不是使用產生器的做法。我們所做的是產生執行時期解釋器的組態,它使用反射來填入類別和欄位。這是執行時期解釋器必須執行的動作,但當您使用程式碼產生時,您可以使用編譯時期建構來執行所有動作。

我可以用多個策略類別來取代使用單一策略類別,每個類別對應一種事件。這些策略接著可以直接呼叫類別和方法。此類策略可能如下所示。

public class InlineStrategy : IReaderStrategy  {
  public string Code {
    get { return "SVCL"; }
  }
  public object Process(string line)  {
    ServiceCall result = new ServiceCall();
    result.CustomerName = line.Substring(4,15);
    result.CustomerID = line.Substring(19,5);
    result.CallTypeCode = line.Substring(24,4);
    result.DateOfCallString = line.Substring(28,8);
    return result;
  }
}

我再次先撰寫此案例,讓它運作,然後將它轉換為範本。以下是範本。

public class $map.MapperClassName : IReaderStrategy
{
  public string Code {
    get { return "$map.Code"; }
  }

  public object Process(string line)  {
    $map.TargetClassName result = new ${map.TargetClassName}();
    #foreach( $f in $map.Fields)
    result.$f.FieldName = line.Substring($f.Start, $f.Length);
    #end
    return result;
  }
}

這會為我們的範例產生兩個類別。

public class MapSVCL : IReaderStrategy
{
  public string Code {
    get { return "SVCL"; }
  }

  public object Process(string line)  {
    dsl.ServiceCall result = new dsl.ServiceCall();
          result.CustomerName = line.Substring(4, 15);
          result.CustomerID = line.Substring(19, 5);
          result.CallTypeCode = line.Substring(24, 4);
          result.DateOfCallString = line.Substring(28, 8);
          return result;
  }
}
public class MapUSGE : IReaderStrategy
{
  public string Code {
    get { return "USGE"; }
  }

  public object Process(string line)  {
    dsl.Usage result = new dsl.Usage();
          result.CustomerID = line.Substring(4, 5);
          result.CustomerName = line.Substring(9, 14);
          result.Cycle = line.Substring(30, 1);
          result.ReadDate = line.Substring(31, 6);
          return result;
  }
}

若要將這些類別連結到讀取器,我們需要產生一個知道我們剛剛產生的類別的建構器。以下是該範本

public class ReaderBuilderInline  {
  public void Configure(Reader target) {
    #foreach( $map in $config.Mappings)
    target.AddStrategy(new ${map.MapperClassName}());
    #end
  }
}

它會產生

public class ReaderBuilderInline  {
  public void Configure(Reader target) {
          target.AddStrategy(new MapSVCL());
          target.AddStrategy(new MapUSGE());
        }
}

產生的程式碼量較大,但通常影響不大。您現在可以讓編譯器檢查此程式碼,畢竟如果您使用的是靜態型別語言,您也可以善用它的優點。人們通常覺得此類程式碼比組態程式碼容易遵循,至少在他們習慣處理 VTL 之後。它會阻止您在執行時期修改組態,因此這並不適用於某些情況。不過,沒有理由不能使用類似技術來產生可以在執行時期執行的指令碼。事實上,以 lisp 風格為主的語言導向程式設計更類似於此,您撰寫一個產生器,產生在執行時期執行的 lisp 程式碼。這正是 lisp 的巨集功能發揮作用的地方。

結語

這是一個非常簡單的範例,但它確實說明了從 DSL 產生程式碼的各種方法。特別是了解在產生 AST 以分離產生與剖析,以及如何使用範本語言從 AST 產生程式碼的價值。這兩種技術都會出現在使用語言工作台中。


重大修訂

2005 年 6 月 12 日:首次出版。