Продолжается подписка на наши издания! Вы не забыли подписаться?

Генерируем код для DSL

Автор: Мартин Фаулер
Опубликовано: 26.02.2006

Если вы создаете язык предметной области (Domain Specific Language, DSL), как вы собираетесь сделать его исполняемым? В случае внутренних DSL ответить на этот вопрос несложно, поскольку они встроены в реальные языки. С внешними DSL придется труднее. В этой статье я рассмотрю простой пример DSL и покажу несколько простых способов сгенерировать по нему код.

Недавно я написал статью, описывающую языкоориентированное программирование и эволюцию набора средств, которые я назвал языковым инструментарием. В этой статье я использую простой 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;
}

Фреймворк для чтения очень прост, есть только класс-читатель, который обрабатывает файл событий, имеющий стратегии для каждого вида событий, которые могут встретиться в файле. Этот класс читает каждую строку, извлекает код события, и передает управление стратегии, предназначенной для этой конкретной строки. Задача конфигурирования – создать правильные стратегии и передать их классу-читателю.

Чтобы инкапсулировать код создания классов-считывателей внешнего конфигурационного файла, я вынес этот код в отдельный класс. По ходу статьи рассматривается ряд способов сделать это, так что вы увидите несколько вариантов классов, создающих считыватели. Первый из них просто читает конфигурационный файл и задает считыватель.

Однопроходный Builder

Я создаю builder, сообщая ему, какой конфигурационный файл использовать. Затем я использую его, чтобы сконфигурировать считыватель.

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

Если встречается декларация "mapping", я создаю новую стратегию.

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 во фреймворк работает просто и быстро.

Двухпроходный Builder

Рассмотрим слегка отличающийся способ сделать то же самое. Теперь я собираюсь создать двухпроходный процесс. Парсер разбирает конфигурационный файл и создает структуру данных. После этого отдельный генератор просматривает эту структуру данных и конфигурирует считыватель.


Рисунок 1. Структура данных для абстрактного представления языка.

Эта структура данных представлена на рисунке 1. Как вы видите, она представляет абстрактный синтаксис языка отображения. Люди, которые когда-то занимались созданием компиляторов, опознают в ней Абстрактное Синтаксическое Дерево (Abstract Syntax Tree, AST) языка.

Этим деревом манипулируют два класса. Парсер считывает текст и создает дерево. Затем генератор читает дерево и конфигурирует объект-считыватель.

Парсер очень похож на используемый выше.

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

Изменения начинаются, когда парсер разбирает отдельные токены. При этом парсер добавляет объекты "Mapping" к корню 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.

<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) используют шаблоны для размещения динамического контента на Web-страницах. Мы в данном случае будем использовать шаблоны, чтобы разместить сгенерированный контент в скелете C#-файла.

Для демонстрации я буду использовать NVelocity. NVelocity – это .NET-порт Velocity, популярного движка шаблонов для Java. Мне нравится Velocity своей простотой – многие используют Velocity вместо JSP. NVelocity все еще находится в разработке, в процессе использования я обнаружил, что документация по ней очень скудна. К счастью, язык шаблонов (Velocity Template Language, VTL) остался тем же, что и в Java-версии, что позволяет использовать старую документацию.

Работа с NVelocity может оказаться непростым делом. Вот класс, создающий экземпляр движка Velocity, который я буду использовать для создания нужных мне файлов.

class VelocityBuilder...
  public VelocityBuilder(string templateDir, string configDir, string rgetDir) 
  {
    engine = new VelocityEngine();  
    this.configDir = configDir;
    this.targetDir = targetDir;
    engine.SetProperty(
      RuntimeConstants_Fields.FILE_RESOURCE_LOADER_PATH, mplateDir);
    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 p.TargetClassName));
  #foreach($f in $map.Fields)
  result.AddFieldExtractor($f.Start, $f.End, "$f.FieldName");
  #end
  return result;
}
#end

Поскольку я не предполагаю, что вы знакомы с VTL, объясню значение используемых элементов.

Первый из них – ссылки на параметры. В 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. Оно возвращает имя целевого класса как "ServiceCall" вместо "dsl.ServiceCall", что полезно, так как я использую это имя для формироания имени метода в генерируемом коде. Хотя AST – это в основном тупая структура данных, нет причин отказываться от введения в нее полезного поведения, чтобы избежать дублирования).

Следующий элемент VTL – директива цикла #foreach ($item in $collection), используемая для перебора объектов mapping и полей.

Результирующий сгенерированный код выглядит так.

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

Форматирование строк слегка нарушено, но все остальное весьма близко к оригиналу.

Таким образом, сгенерирован тот же код, что до этого был написан вручную – но это не то, для чего следует использовать генератор. Мы сгенерировали конфигурацию runtime-интерпретатора, использующего Reflection, чтобы заполнить классы и поля. Это то, что нужно для runtime-интерпретатора, но при использовании кодогенерации все это можно сделать с помощью конструкций времени компиляции.

Вместо использования одного класса стратегии, использующего рефлексию, я могу использовать несколько классов стратегии, по одному для каждого вида событий. Эти стратегии смогут напрямую вызывать классы и методы. Стратегия может выглядеть так:

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

Чтобы связать эти классы со считывателем, нужно сгенерировать builder, знающий о только что сгенерированных классах. Вот шаблон для этого:

  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.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.

Copyright © 1994-2016 ООО "К-Пресс"