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;
}

リーダーフレームワークは非常にシンプルで、ファイルに現れる可能性のある各種類のイベントに対する戦略を持つ、イベントのファイルだけを処理するリーダークラスがあります。リーダーは各行を読み取り、イベントコードを抽出し、特定の行の戦略に渡します。設定の仕事は、適切な戦略を作成し、リーダーに送信することです。

外部設定ファイルからリーダーを作成するためのコードをカプセル化するために、そのコードは別のリーダービルダークラスです。ここでは、これを行うためのいくつかの方法を探るので、いくつかの種類のリーダービルダが表示されます。最初のものは、単にカスタム設定ファイルを読み取り、リーダーを設定します。

シングルパスビルダ

使用する設定ファイルを指定して、ビルダを作成します。そして、それを使用してリーダーを設定します。

class 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;

カスタム設定ファイルの行を処理するために、さまざまな正規表現で行をテストし、表示される行の種類に応じて反応します。空白とコメントは無視されます。

class 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);
  }

マッピング宣言が表示されたら、新しい戦略を作成します。

class 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;
  }

フィールド宣言が表示されたら、戦略に新しいフィールド抽出器を追加します。

class 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からフレームワークへのシングルパスの変換は迅速かつ簡単です。

ツーパスビルダ

それでは、少し異なる方法を見てみましょう。今度は、2パスプロセスを行います。パーサーは設定ファイルを読み取り、データ構造を生成します。次に、別のジェネレーターがこのデータ構造を見て、リーダーを設定します。

図1:言語の抽象表現のデータ構造。

図1はこのデータ構造を示しています。ご覧のとおり、これはマッピング言語の抽象構文を表しています。コンパイラの授業を覚えている人は、これを言語の抽象構文木として認識するでしょう。

このツリーを操作するクラスは2つあります。パーサーはテキスト入力を読み取り、ツリーを作成します。次に、ジェネレーターはツリーを読み取り、リーダーオブジェクトを設定します。

パーサーは、前に見たパーサーと非常によく似ています。基本的な制御フローは同じです。

class 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のルートを返すことです。

この意思決定は完全に同じです。

class 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のルートにマッピングオブジェクトを追加します。

class 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());
  }

同様に、フィールドが表示されると、フィールドオブジェクトを追加します。

class 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);
  }
}

ジェネレーターは अब इस संरचना को फ्रेमवर्क को कॉन्फ़िगर करने के लिए पढ़ता है. यह बहुत ही सरल वर्ग है.

class 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);
  }

2つのステージを分けることの利点は何でしょうか?それは少し複雑になります。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>

この形式を読み取るために必要なのは、新しいパーサーを作成することだけです。同じジェネレーターを使用できます。

class 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ツリーを読み取るだけで済みます。カスタムテキストパーサーとまったく同じオブジェクトを作成するため、同じジェネレーターは同じように機能します。(2段階プロセスのさらなる利点は、各段階を個別にテストできることです。)

このようなパーサーを手動で作成することは、この例のように単純な言語であれば問題ありませんが、より複雑な言語にはお勧めしません。言語の文法定義を受け取り、ASTの生成を支援するパーサージェネレーターツールが存在します。これらのツールを worthwhile にするために、この例よりもはるかに複雑になる必要はありません。使用方法を学ぶには少し努力が必要ですが、結果ははるかに扱いやすくなります。(本質的に、文法は言語を抽象表現に解析するのに役立つDSLです。)

ここではパーサージェネレーターについてはこれ以上説明しません。プロセスの解析部分は言語ワークベンチにとって重要ではないからです。言語ワークベンチでは、抽象表現は従来のプログラミングよりもはるかに中心的な役割を果たします。同じ抽象表現に対して複数の可読形式を持つことができるという考えとともに。

テンプレートを用いた生成

上記の例では、フレームワーククラスを生成するためにいくつかの手続き型コードを使用しました。これはこのケースでは非常によく機能します。ジェネレーターへの別のアプローチは、実際にはC#出力を生成することであり、これはフレームワークでコンパイルできます。これにより、設定ファイルをランタイムではなくコンパイル時にシステムに取り込むことができます。状況によっては、これは恩恵というよりは悩みの種になる可能性がありますが、ここでこのアプローチを探求する価値があります。言語ワークベンチで再び目にすることになるからです。

テンプレートの背後にある考え方は、出力ファイルを最終的な形式で編集することですが、ジェネレーターがコードを挿入する場所を示す小さなマーカーが付いています。さまざまなサーバーページテクノロジー(PHP、JSP、ASP)は、テンプレートを使用してWebページに動的コンテンツを追加します。この場合、テンプレートを使用して、生成されたコンテンツをスケルトンのC#ファイルに追加します。

デモンストレーションのために、NVelocityを使用します。NVelocityは、一般的なJavaテンプレートエンジンであるVelocityの.NETポートです。Velocityが好きなのは、シンプルだからです。多くの人はJSPの代わりにVelocityを使用することを好みます。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;

テンプレートを作成する場合、通常は最初に単一のケースのハードコードされたクラスを作成し、そのクラスを動作させてデバッグし、次に(できるだけ徐々に)ハードコードされた要素をテンプレート化された要素に置き換えます。

これを2つの方法で示します。最初に、テンプレートを使用して、先ほど説明した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 Template Language)に精通しているとは限らないため、使用している要素について説明します。

最初の部分はパラメータへの参照です。VTLでは、$parameterNameまたは${parameterName}という構文を使用してパラメータを参照できます(後者は、パラメータ参照をスペースなしで他のテキストに直接実行する場合に最適です)。パラメータを取得したら、そのパラメータのメソッドを呼び出してプロパティに自由にアクセスできます。

パラメータにアクセスできるように設定するには、マッピングを実行するときにオブジェクトをエンジンのコンテキストに配置する必要があります。

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);
}

(MappingにプロパティTargetClassNameOnlyを定義していることに気付くでしょう。これは、ターゲットクラスの名前をdsl.ServiceCallではなくServiceCallとして返します。生成された設定コードでメソッドの分割を保持しているので便利です。ASTはほとんどの場合ダムデータ構造ですが、重複を避けるために便利な動作をそこに移動しない理由はありません。)

VTLの2番目の部分は、ループディレクティブ#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;
}

行の書式設定は少し乱れていますが、それ以外はオリジナルとほぼ同じです。

そのため、これは手動で記述したのと同じコードを生成しますが、これは通常、ジェネレーターで作業する方法ではありません。私たちが行ったのは、リフレクションを使用してクラスとフィールドを設定するランタイムインタープリターの構成を生成することです。それはランタイムインタープリターでやらなければならないことですが、コード生成を使用すると、コンパイル時の構成要素ですべてを行うことができます。

リフレクションを使用して作業を行う単一の戦略クラスを使用する代わりに、イベントの種類ごとに1つずつ、複数の戦略クラスを使用できます。これらの戦略は、クラスとメソッドを直接呼び出すことができます。このような戦略は次のようになります。

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;
  }
}

繰り返しますが、この1つのケースを最初に記述し、それを動作させてからテンプレートに変換することで、これを行いました。テンプレートは次のとおりです。

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;
  }
}

これは、サンプル用に2つのクラスを生成します。

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());
        }
}

結果のコードはより voluminous ですが、通常それはそれほど問題ではありません。これで、コンパイラにこのコードをチェックさせることができます。結局のところ、静的に型付けされた言語を使用している場合は、そのメリットを最大限に活用できます。多くの人は、少なくともVTLの操作に慣れれば、この種のコードは設定コードよりも理解しやすいと感じています。実行時に設定を変更することはできなくなるため、一部のシナリオには適していません。ただし、実行時に実行できるスクリプトを生成するために同様のテクニックを使用できない理由はありません。実際、言語指向プログラミングのlispスタイルは、実行時に実行されるlispコードを生成するジェネレーターを作成するようなものです。これは、lispのマクロ機能が輝くところです。

まとめ

これは非常に単純な例ですが、DSLからコードを生成する際のさまざまなアプローチを示しています。特に、生成を解析から分離するためにASTを生成することの価値、およびテンプレート言語を使用してASTからコードを生成する方法を理解することは役立ちます。これらの手法はどちらも、言語ワークベンチの使用で発生します。


主な改訂

2005年6月12日:初版