![]() |
Технология Клиент-Сервер 2005'3 |
||||||
|
Если вы создаете язык предметной области (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, сообщая ему, какой конфигурационный файл использовать. Затем я использую его, чтобы сконфигурировать считыватель.
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 во фреймворк работает просто и быстро.
Рассмотрим слегка отличающийся способ сделать то же самое. Теперь я собираюсь создать двухпроходный процесс. Парсер разбирает конфигурационный файл и создает структуру данных. После этого отдельный генератор просматривает эту структуру данных и конфигурирует считыватель.
Эта структура данных представлена на рисунке 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 ООО "К-Пресс"