为 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 到框架的单遍转换既快速又容易。

两遍构建器

现在让我们看看另一种稍微不同的方法。我现在要做的是一个两遍过程。解析器读取配置文件并生成一个数据结构。然后,一个单独的生成器查看此数据结构来配置阅读器。

图 1:语言抽象表示的数据结构。

图 1 显示了此数据结构。如您所见,它表示我们映射语言的抽象语法。那些记得编译器课程的人会认识到这是一种语言的抽象语法树。

两个类操作此树。解析器读取文本输入并创建树。然后,生成器读取树并配置阅读器对象。

解析器与我们之前看到的解析器非常相似。基本控制流是相同的。

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

将这两个阶段分开有什么好处?它确实给我们带来了一些复杂性——我们必须添加 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 树。它创建与自定义文本解析器完全相同的对象,因此相同的生成器将以相同的方式工作。(两步过程的另一个优点是我们也可以独立测试每个步骤。)

对于像这样的简单语言,手动编写这样的解析器是可以的,但我不会建议将其用于更复杂的语言。解析器生成工具存在,它们可以接受语言的语法定义,并帮助您生成 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);
}

(您会注意到我在 Mapping 上定义了一个属性 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 日:首次出版。