Заполнение текстовых шаблонов данными на основе модели. Реализация на .NET с использованием динамических функций в байт-коде (IL)

в 7:30, , рубрики: .net, C#, cil, generator, il, MSIL, parser, pattern, байт-код, генератор кода, многобукофф

Пролог

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

Сразу возник вопрос — как это реализовать? На ум приходили различные решения, начиная от задания в шаблоне неких константных значений, которые бы заменялись на данные модели, и заканчивая полноценными вьюхами Razor (сайт построен на MVC 5).

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

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

Если вас интересует только шаблонизатор, ссылочки ниже:

Исходные коды шаблонизатора (Genesis.Patternizer) и тестовой консоли в проекте на SourceForge: https://sourceforge.net/projects/open-genesis/?source=navbar
Или в архиве одним файлом: Patternizer.zip

Постановка задачи

Для начала определимся с синтаксисом. Лично мне по душе функция string.Format, широко используемая для форматирования простых значений. Воспользуемся ее синтаксисом для обозначения мест вставок значений в шаблон:

'{' <выражение> [ ':' <строка формата> ] [ '|' <строка по умолчанию> ] '}'

Примеры: {User.GetFIO()}, {User.Name|юзверь}, {User.Birthdate:dd.MM.yyyy}, {User.Score:0.00|ничегошеньки}.

Значение по умолчанию будет подставляться, если искомое значение нулевое (null) или отсутствует вовсе, т.е. если указанное свойство/поле/метод не найдено в модели. Для экранирования фигурных скобочек будем использовать двойные фигурные скобочки (как в функции string.Format), для экранирования символов в строке формата и значении по умолчанию — слеш.

А вот пример готового шаблона, который будет использоваться в тестовом примере:

Здравствуйте, {User.GetFIO()|юзверь}!

Вот фрагмент кода, который вы заказывали:

function PrintMyName()
{{
    Console.WriteLine("My name is {{0}}. I'm {{1}}.", "{UserName|юзверь}", {User.Age:0});
}}

Данное сообщение сформировано автоматически {Now:dd MMMM yyyy} в {Now:HH:mm:ss}

Изначально я предполагал, что шаблон будет поддерживать только публичные свойства модели, но в процессе разработки была добавлена поддержка полей, а также методов (с возможностью передачи аргументов типа строка, число, булевый тип и null) и обращений к массиву любой размерности. Т.е. следующее выражение также будет корректным шаблоном:

Неведомое нечто: {User.Account[0].GetSomeArrayMethod("a", true, 8.5, null)[5,8].Length:0000|NULL}

Решение

Парсер

Для начала надо понять, что делать с текстовым шаблоном. Конечно, можно при каждом вызове подстановки модели анализировать данные шаблона, искать и подставлять значения. Но это очень медленный способ. Гораздо эффективнее будет один раз разобрать шаблон на отдельные логические фрагменты (элементы шаблона) и в дальнейшем оперировать уже этими элементами. Существует три очевидных типа элемента: строковая константа (та часть шаблона, которая непосредственно идет в результат в неизменном виде), подстановка (то, что внутри фигурных скобок) и комментарий (данный элемент не реализован, но, полагаю, вы понимаете, о чем речь).

На основе этих рассуждений опишем базовый класс для элемента шаблона:

/// <summary>
/// элемент шаблона
/// </summary>
public abstract class PatternElement
{
    /// <summary>
    /// оценочная длина элемента шаблона
    /// </summary>
    public virtual int EstimatedLength { get { return 0; } }

    /// <summary>
    /// значение при пустом значении модели
    /// </summary>
    public abstract string GetNullValue();
}

Смысл свойства EstimatedLength и метода GetNullValue() будет раскрыт ниже.

Далее опишем конкретные реализации — строковую константу и подстановку (назовем ее «выражением»):

public class StringConstantElement : PatternElement
{
    public string Value { get; set; }

    public override int EstimatedLength { get { return Value == null ? 0 : Value.Length; } }

    public override string GetNullValue()
    {
        return Value;
    }
}

public class ExpressionElement : PatternElement
{
    public string Path { get; set; }

    public string FormatString { get; set; }

    public string DefaultValue { get; set; }

    public override int EstimatedLength { get { return Math.Max(20, DefaultValue == null ? 0 : DefaultValue.Length); } }

    public override string GetNullValue()
    {
        return DefaultValue;
    }
}

Также опишем интерфейс парсера IPatternParser, который принимает на входе текстовый шаблон, а выдает последовательность элементов:

public interface IPatternParser
{
    IEnumerable<PatternElement> Parse(string pattern);
}

Парсер на основе фигурных скобок так и назовем — BracePatternParser. Не имея большого опыта написания синтаксических анализаторов (а именно это по сути делает парсер), я не буду углубляться в его реализацию.

BracePatternParser.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Genesis.Patternizer
{
    /// <summary>
    /// парсер шаблона в фигурных скобочках
    /// </summary>
    public class BracePatternParser : IPatternParser
    {
        private object _lock = new object();

        private HashSet<char> PATH_TERMINATOR_CHARS;
        private HashSet<char> FORMAT_TERMINATOR_CHARS;
        private HashSet<char> PATTERN_TERMINATOR_CHARS;

        private string pattern;                 // шаблон
        private int length;                     // длина строки
        private int length_1;                   // длина строки минус один
        private int index;                      // текущая позиция в строке

        private StringBuilder constantBuilder;
        private StringBuilder expressionBuilder;

        /// <summary>
        /// конструктор
        /// </summary>
        public BracePatternParser()
        {
            PATH_TERMINATOR_CHARS = new HashSet<char>(":|}".ToCharArray());
            FORMAT_TERMINATOR_CHARS = new HashSet<char>("|}".ToCharArray());
            PATTERN_TERMINATOR_CHARS = new HashSet<char>("}".ToCharArray());
        }

        /// <summary>
        /// распарсить путь выражения
        /// </summary>
        /// <param name="chars"> символы-терминаторы </param>
        /// <returns></returns>
        private string ParsePatternPath(HashSet<char> chars)
        {
            // очищаем буфер выражения
            expressionBuilder.Clear();

            Stack<char> brackets = new Stack<char>();
            bool ignoreBrackets = false;

            for (index++; index < length; index++)
            {
                char c = pattern[index];
                if (c == '(')
                {
                    brackets.Push(c);
                    expressionBuilder.Append(c);
                }
                else if (c == ')')
                {
                    if (brackets.Peek() == '(')
                    {
                        brackets.Pop();
                    }
                    else
                    {
                        // недопустимый символ
                        ignoreBrackets = true;
                    }
                    expressionBuilder.Append(c);
                }
                else if (c == '[')
                {
                    brackets.Push(c);
                    expressionBuilder.Append(c);
                }
                else if (c == ']')
                {
                    if (brackets.Peek() == '[')
                    {
                        brackets.Pop();
                    }
                    else
                    {
                        // недопустимый символ
                        ignoreBrackets = true;
                    }
                    expressionBuilder.Append(c);
                }
                else if (chars.Contains(c) && (ignoreBrackets || brackets.Count == 0))
                {
                    // найден терминатор
                    break;
                }
                else
                {
                    expressionBuilder.Append(c);
                }
            }

            return expressionBuilder.Length == 0 ? null : expressionBuilder.ToString();
        }

        /// <summary>
        /// распарсить часть выражения шаблона
        /// </summary>
        /// <param name="chars"> символы-терминаторы </param>
        /// <returns></returns>
        private string ParsePatternPart(HashSet<char> chars)
        {
            // очищаем буфер выражения
            expressionBuilder.Clear();

            for (index++; index < length; index++)
            {
                char c = pattern[index];
                if (c == '\')
                {
                    // знак экранирования в шаблоне
                    if (index < length_1)
                    {
                        expressionBuilder.Append(pattern[++index]);
                    }
                }
                else if (chars.Contains(c))
                {
                    // найден терминатор
                    break;
                }
                else
                {
                    expressionBuilder.Append(c);
                }
            }

            return expressionBuilder.Length == 0 ? null : expressionBuilder.ToString();
        }

        /// <summary>
        /// распарсить выражение шаблона
        /// </summary>
        /// <returns></returns>
        private ExpressionElement ParsePattern()
        {
            string path = ParsePatternPath(PATH_TERMINATOR_CHARS);
            if (path == null)
            {
                // выражение отсутствует
                // пропускаем все до окончания шаблона (})
                for (; index < length; index++)
                {
                    char c = pattern[index];
                    if (c == '\')
                    {
                        index++;
                    }
                    else if (c == '}')
                    {
                        break;
                    }
                }
                return null;
            }
            else
            {
                ExpressionElement element = new ExpressionElement(path);

                // читаем дополнительную информацию
                if (index < length && pattern[index] == ':')
                {
                    // строка формата
                    element.FormatString = ParsePatternPart(FORMAT_TERMINATOR_CHARS);
                }

                if (index < length && pattern[index] == '|')
                {
                    // значение по умолчанию
                    element.DefaultValue = ParsePatternPart(PATTERN_TERMINATOR_CHARS);
                }

                return element;
            }
        }

        /// <summary>
        /// распарсить шаблон
        /// </summary>
        /// <param name="pattern"> шаблон </param>
        /// <returns></returns>
        public IEnumerable<PatternElement> Parse(string pattern)
        {
            lock (_lock)
            {
                if (pattern == null)
                {
                    // нулевой шаблон
                    yield break;
                }
                else if (string.IsNullOrWhiteSpace(pattern))
                {
                    yield return new StringConstantElement(pattern);
                    yield break;
                }

                // парсим шаблон
                this.pattern = pattern;

                // вспомогательные переменные
                length = pattern.Length;
                length_1 = length - 1;
                index = 0;

                // оптимизация
                constantBuilder = new StringBuilder();
                expressionBuilder = new StringBuilder();

                // основной цикл парсера
                for (; index < length; index++)
                {
                    char c = pattern[index];
                    if (c == '{')
                    {
                        if (index < length_1 && pattern[index + 1] == c)
                        {
                            // экранированный символ '{'
                            constantBuilder.Append(c);
                            index++;
                        }
                        else
                        {
                            // начало управляющей конструкции
                            if (constantBuilder.Length != 0)
                            {
                                yield return new StringConstantElement(constantBuilder.ToString());

                                // очищаем буфер
                                constantBuilder.Clear();
                            }

                            var patternElement = ParsePattern();
                            if (patternElement != null)
                            {
                                yield return patternElement;
                            }
                        }
                    }
                    else if (c == '}')
                    {
                        if (index < length_1 && pattern[index + 1] == c)
                        {
                            // экранированный символ '}'
                            constantBuilder.Append(c);
                            index++;
                        }
                        else
                        {
                            // конец управляющей конструкции в неположенном месте, ошибкой считать не будем
                            constantBuilder.Append(c);
                        }
                    }
                    else
                    {
                        constantBuilder.Append(c);
                    }
                }

                // хвост тела шаблона
                if (constantBuilder.Length != 0)
                {
                    yield return new StringConstantElement(constantBuilder.ToString());
                }

                // очищаем данные
                this.pattern = null;
                constantBuilder = null;
                expressionBuilder = null;
                index = length = length_1 = 0;
            }
        }
    }
}

Генератор построителя

Описанный выше парсер выполняет лишь часть общей задачи. Мало получить набор элементов шаблона, надо еще их обработать. Для этого опишем еще один интерфейс, представляющий главный по своей значимости компонент системы — IBuilderGenerator:

public interface IBuilderGenerator
{
    Func<object, string> GenerateBuilder(List<PatternElement> pattern, Type modelType);
}

Для достижения наибольшего быстродействия, на каждый новый тип модели (modelType) будем создавать новый построитель и записывать его в хеш. Сам построитель представляет из себя обычную функцию, принимающую на входе object (модель) и возвращающую строку — заполненный шаблон. Конкретная реализация данного интерфейса будет приведена ниже, а перед этим рассмотрим последний компонент системы, связывающий все воедино.

Шаблонизатор

Собственно шаблонизатор представляет из себя класс, связывающий шаблон, парсер и построитель. Его код также не представляет из себя ничего сверхинтересного.

Patternizator.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using BUILDER = System.Func<object, string>;

namespace Genesis.Patternizer
{
    /// <summary>
    /// мастер-шаблонизатор
    /// </summary>
    public class Patternizator
    {
        #region Declarations

        private PatternizatorOptions _options;  // опции шаблонизатора
        private string _pattern;                // шаблон

        private List<PatternElement> _elements; // элементы шаблона
        private Dictionary<Type, BUILDER> _builders;                // словарь построителей

        #endregion
        #region Properties

        /// <summary>
        /// шаблон
        /// </summary>
        public string Pattern
        {
            get { return _pattern; }
            set
            {
                _pattern = value;
                PreparePattern();
            }
        }

        #endregion

        #region Constructors

        /// <summary>
        /// конструктор
        /// </summary>
        public Patternizator()
        {
            _options = PatternizatorOptions.Default;
            _builders = new Dictionary<Type, BUILDER>();
        }

        /// <summary>
        /// конструктор
        /// </summary>
        /// <param name="pattern"> шаблон </param>
        public Patternizator(string pattern)
        {
            _options = PatternizatorOptions.Default;
            Pattern = pattern;
        }

        /// <summary>
        /// конструктор
        /// </summary>
        /// <param name="options"> опции шаблонизатора </param>
        public Patternizator(PatternizatorOptions options)
        {
            _options = options;
            _builders = new Dictionary<Type, BUILDER>();
        }

        /// <summary>
        /// конструктор
        /// </summary>
        /// <param name="pattern"> шаблон </param>
        /// <param name="options"> опции шаблонизатора </param>
        public Patternizator(string pattern, PatternizatorOptions options)
        {
            _options = options;
            Pattern = pattern;
        }

        #endregion

        #region Private methods

        /// <summary>
        /// подготовить шаблон
        /// </summary>
        private void PreparePattern()
        {
            // парсим шаблон
            _elements = _options.Parser.Parse(_pattern).ToList();

            // сбрасываем хеш построителей
            _builders = new Dictionary<Type, BUILDER>();

            // для наглядности можно раскомментировать и посмотреть распарсенный шаблон в виде списка строк
            //string template = string.Join(Environment.NewLine, _elements.Select(e => System.Text.RegularExpressions.Regex.Replace(e.ToString(), @"s+", " ").Trim()).ToArray());
        }

        #endregion
        #region Public methods

        /// <summary>
        /// генерировать сообщение
        /// </summary>
        /// <param name="model"> модель </param>
        /// <returns></returns>
        public string Generate(object model)
        {
            // получаем тип модели и ее ключ
            Type modelType = model == null ? null : model.GetType();
            Type modelTypeKey = modelType ?? typeof(DBNull);

            // ищем построитель данного типа модели
            BUILDER builder;
            if (!_builders.TryGetValue(modelTypeKey, out builder))
            {
                // построитель еще не создан
                builder = _options.BuilderGenerator.GenerateBuilder(_elements, modelType);
                _builders.Add(modelTypeKey, builder);
            }

            // запускаем генерацию
            return builder(model);
        }

        #endregion
    }
}

PatternizatorOptions.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Genesis.Patternizer
{
    /// <summary>
    /// опции шаблонизатора
    /// </summary>
    public class PatternizatorOptions
    {
        /// <summary>
        /// парсер шаблона
        /// </summary>
        public IPatternParser Parser { get; set; }

        /// <summary>
        /// генератор построителя
        /// </summary>
        public IBuilderGenerator BuilderGenerator { get; set; }

        #region Default

        private static PatternizatorOptions _default;

        /// <summary>
        /// настройки по умолчанию
        /// </summary>
        public static PatternizatorOptions Default
        {
            get
            {
                if (_default == null)
                {
                    _default = new PatternizatorOptions
                    {
                        Parser = new BracePatternParser(),
                        BuilderGenerator = new ReflectionBuilderGenerator(),
                    };
                }
                return _default;
            }
        }

        #endregion
    }
}

Опции (PatternizatorOptions) — это необязательный аргумент, в котором можно дать указание шаблонизатору использовать конкретную реализацию парсера или генератора построителя, например, если вы используете синтаксис шаблона, отличный от стандартного.

Пример использования шаблонизатора в стандартном исполнении:

// читаем шаблон
string pattern = GetPattern();

// готовим шаблонизатор
Patternizator patternizator = new Patternizator(pattern);

// создаем модель
User user = new User
{
    Surname = RandomElement(rnd, SURNAMES),
    Name = RandomElement(rnd, NAMES),
    Patronymic = RandomElement(rnd, PATRONYMICS),

    // дата рождения 1950 - 1990 гг
    Birthdate = new DateTime(1950, 1, 1).AddDays(rnd.NextDouble() * 40.0 * 365.25)
};
var model = new
{
    User = user,
    UserName = user.Name,
    Now = DateTime.Now,
};

// заполнение с использованием шаблонизатора
string text = patternizator.Generate(model);

В данном примере модель представляет из себя анонимный тип, но вас это не должно смущать. Даже при генерации элементов такого типа в цикле, построитель будет создан лишь один раз, при первом вызове метода Generate. Но к вопросу производительности вернемся в конце статьи, теперь же рассмотрим самое интересное, так сказать, гвоздь данной публикации.

Генератор построителя на основе байт-кода

Для начала произведем небольшой анализ. Как в теории можно решить данную задачу?
Напомню, у нас есть список элементов шаблона (константы и выражения) и тип модели. И надо получить функцию Func<object, string>, которая подставляет модель заданного типа в шаблон, получая на выходе строку.

Если с константами вопросов нет (просто кидаем их в StringBuilder), то с выражениями все сложнее.
Я вижу три возможных варианта, как получить значение выражения из модели:

  1. Через рефлексию
  2. Сгенерировать код на C#, скомпилировать и подключить к сборке
  3. Написать динамическую функцию (System.Reflection.Emit.DynamicMethod) с телом на байт-коде

Первый вариант явно страдает быстродействием, т.к. рефлексия всегда работает медленно. Мой вам совет — никогда не используйте рефлексию для операций, выполняющихся очень часто. Оптимальный вариант ее использования — это подготовка на этапе старта программы, т.е. что-то вида «пробежали по классам, нашли нужную информацию в атрибутах, построили какие-то связи (делегаты, события) и далее используем их, не обращаясь к рефлексии повторно». В общем, для данной задачи рефлексия явно не подходит.

Второй вариант очень хорош, изначально я хотел использовать именно его. Код функции построителя при этом выглядел бы примерно так (для шаблона, приведенного в начале):

public string Generate(object input)
{
    if (input == null)
    {
        // модель не указана
        return @"Здравствуйте, юзверь!

Вот фрагмент кода, который вы заказывали:

function PrintMyName()
{
    Console.WriteLine("My name is {0}. I'm {1}.", "юзверь", 0);
}

Данное сообщение сформировано автоматически  в ";
    }
    else {
        Model model = input as Model;
        StringBuilder sb = new StringBuilder();
        sb.Append("Здравствуйте, ");  // строковая константа
        if (model.User != null) {
                var m_GetFIO = model.User.GetFIO();
                if (m_GetFIO != null)
                {
                        sb.Append(m_GetFIO);
                } else 
                {
                        sb.Append("юзверь"); // значение по умолчанию
                }
        } else {
                sb.Append("юзверь"); // значение по умолчанию
        }
        sb.Append("!rnВот фрагмент кода, который вы заказывали:rnrn ...");  // строковая константа

        \ и т.д.

        return sb.ToString();
    }
}

Конечно, код будет достаточно длинным, но кто его увидит? В общем, этот вариант был бы оптимальным, если бы не анонимные типы. В коде выше мы не смогли бы объявить переменную модели Model model = input as <?>;, если бы тип модели не имел имени.

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

Динамические сборки, динамические функции и генератор байт-кода описаны в пространстве имен System.Reflection.Emit, и для их использования не надо подключать какие-либо дополнительные библиотеки.

Простейшая динамическая функция создается следующим образом:

// генерируем динамический метод
var genMethod = new DynamicMethod("<имя метода>", typeof(<тип результата>), new Type[] { typeof(<тип аргумента 1>), typeof(<тип аргумента 2>), ..., typeof(<тип аргумента N>) }, true);

// получаем генератор байт-кода (он же IL-генератор или CIL-генератор)
var cs = genMethod.GetILGenerator();

// генерируем тело метода
// ...

// конец метода
cs.Emit(OpCodes.Ret);

// конвертируем метод в делегат
return genMethod.CreateDelegate(typeof(<тип делегата>)) as <тип делегата>;

cs.Emit(OpCodes.Ret); — это операция записи команды в байт-коде. Кто не в курсе, байт-код — это что-то вроде ассемблера для языков семейства .NET.

Если вы собрались силами и дочитали статью до этого абзаца, то у вас должен возникнут вопрос, как же я сгенерирую байт-код, если не знаю его команд? Ответ достаточно прост. Для этого нам понадобится дополнительная программка (проект есть в архиве), код которой приведен под спойлером.

ILDasm

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;

namespace ILDasm
{
    class Program
    {
        #region Static

        static void Main(string[] args)
        {
            new Program().Run();
        }

        #endregion

        public void Run()
        {
            string basePath = AppDomain.CurrentDomain.BaseDirectory;
            string exeName = Path.Combine(basePath, AppDomain.CurrentDomain.FriendlyName.Replace(".vshost", ""));

            Process.Start(@"C:Program Files (x86)Microsoft SDKsWindowsv8.1AbinNETFX 4.5.1 Toolsx64ildasm.exe", string.Format(@"/item:ILDasm.TestClass::DoIt ""{0}"" /text /output:code.il", exeName));
        }
    }

    public class TestClass
    {
        public string DoIt(object value)
        {
            StringBuilder sb = new StringBuilder();
            return sb.ToString();
        }
    }
}

Смысл программы в том, что она запускает встроенный в студию дизассемблер ildasm и натравливает его на функцию DoIt класса TestClass. Байт-код тела этой функции помещается в файл code.il, который затем можно открыть и проанализировать. Привожу байт-код функции DoIt (лишнее убрано):

IL_0000:  newobj     instance void [mscorlib]System.Text.StringBuilder::.ctor()
IL_0005:  stloc.0
IL_0006:  ldloc.0
IL_0007:  callvirt   instance string [mscorlib]System.Object::ToString()
IL_000c:  ret

Вещество в черепной коробке с сочетании с методом проб и ошибок поможет сгенерировать код по аналогии, т.е. пишем тело функции DoIt наподобие того, что мы хотим получить в нашей сгенерированной функции, запускаем утилитку, смотрим код и воплощаем его в генераторе.

Общая информация о байт-коде

Все построено на стеке.
Если мы хотим выполнить операцию сложения a и b, надо поместить в стек значение переменной a, затем поместить в стек значение переменной b, затем вызывать команду сложения (add). При этом стек очищается от a и b, а на его вершину помещается результат сложения. Если после этого мы хотим умножить сумму на c, помещаем его значение в стек (помним, сейчас там уже есть сумма a+b) и вызываем операцию умножения (mul).

Итоговый байт-код:

IL_0000:  ldarg.1
IL_0001:  ldarg.2
IL_0002:  add
IL_0003:  ldarg.3
IL_0004:  mul

А вот как это выглядит в C#:

cs.Emit(OpCodes.Ldarg_1);
cs.Emit(OpCodes.Ldarg_2);
cs.Emit(OpCodes.Add);
cs.Emit(OpCodes.Ldarg_3);
cs.Emit(OpCodes.Mul);

Аналогично вызываются методы и конструкторы (помещаем в стек аргументы и вызываем метод/конструктор). При этом для нестатических методов, первым в стек необходимо положить экземпляр класса, метод которого мы вызываем.

Данная статья не имеет целью полное обучение генерации байт-кода, так что продолжим рассуждения про генератор.

Ядро генератора заключено в функции, реализующей его интерфейс (IBuilderGenerator):

GenerateBuilder

/// <summary>
/// сгенерировать функцию построителя
/// </summary>
/// <param name="pattern"> распарсеный шаблон </param>
/// <param name="modelType"> тип модели </param>
/// <returns></returns>
public virtual BUILDER GenerateBuilder(List<PatternElement> pattern, Type modelType)
{
    if (modelType == null)
    {
        // модель не задана, следовательно возвращаем константную строку
        StringBuilder sb = new StringBuilder();

        foreach (PatternElement item in pattern)
        {
            string nullValue = item.GetNullValue();
            if (nullValue != null)
            {
                sb.Append(nullValue);
            }
        }

        string value = sb.ToString();
        return (m) => value;
    }
    else
    {
        // создаем новый тип класса генератора
        string methodName = "Generate_" + Guid.NewGuid().ToString().Replace("-", "");

        // генерируем динамический метод
        var genMethod = new DynamicMethod(methodName, typeof(string), new Type[] { typeof(object) }, true);

        // получаем генератор кода
        var cs = genMethod.GetILGenerator();

        var sb = cs.DeclareLocal(typeof(StringBuilder));
        var m = cs.DeclareLocal(modelType);

        ReflectionBuilderGeneratorContext context = new ReflectionBuilderGeneratorContext
        {
            Generator = cs,
            ModelType = modelType,
            VarSB = sb,
            VarModel = m,
        };

        // распаковываем модель
        cs.Emit(OpCodes.Ldarg_0);
        cs.Emit(OpCodes.Isinst, modelType);
        cs.Emit(OpCodes.Stloc, m);

        // создаем StringBuilder с начальной емкостью размера шаблона
        cs.Emit(OpCodes.Ldc_I4, pattern.Sum(e => e.EstimatedLength));
        cs.Emit(OpCodes.Newobj, typeof(StringBuilder).GetConstructor(new Type[] { typeof(int) }));
        cs.Emit(OpCodes.Stloc, sb);

        foreach (PatternElement item in pattern)
        {
            MethodInfo processor;
            if (_dicProcessors.TryGetValue(item.GetType(), out processor))
            {
                // найден генератор
                processor.Invoke(processor.IsStatic ? null : this, new object[] { context, item });
            }
        }
        cs.Emit(OpCodes.Ldloc, sb);
        cs.Emit(OpCodes.Callvirt, typeof(object).GetMethod("ToString", Type.EmptyTypes));
        cs.Emit(OpCodes.Ret);

        return genMethod.CreateDelegate(typeof(BUILDER)) as BUILDER;
    }
}

Здесь нам как раз и пригодились метод GetNullValue() и свойство EstimatedLength элемента шаблона.

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

[PatternElement(typeof(StringConstantElement))]
protected virtual void GenerateStringConstantIL(ReflectionBuilderGeneratorContext context, StringConstantElement element)
{
    if (element.Value != null)
    {
        WriteSB_Constant(context, element.Value);
    }
}

/// <summary>
/// записать в StringBuilder строковое значение
/// </summary>
/// <param name="context"> контекст генерации </param>
/// <param name="value"> значение </param>
protected virtual void WriteSB_Constant(ReflectionBuilderGeneratorContext context, string value)
{
    if (value != null)
    {
        var cs = context.Generator;
        cs.Emit(OpCodes.Ldloc, context.VarSB);
        cs.Emit(OpCodes.Ldstr, value);
        cs.Emit(OpCodes.Callvirt, _dicStringBuilderAppend[typeof(string)]);
        cs.Emit(OpCodes.Pop);
    }
}

Код других методов приводить не стану, т.к. он очень громоздкий, но если у вас возникнут вопросы, постараюсь ответить на них отдельно.

Тест производительности

Т.к. у меня нет возможности сравнить свой шаблонизатор с каким-либо другим, я проведу сравнение с захардкоденным генератором шаблона на основе string.Replace().

Код функции теста

/// <summary>
/// запуск теста
/// </summary>
private void Run()
{
    // вспомогательные переменные
    string outputPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Result");
    if (!Directory.Exists(outputPath)) Directory.CreateDirectory(outputPath);
    Random rnd = new Random(0);
    Stopwatch sw = new Stopwatch();

    // читаем шаблон
    string pattern = GetPattern();

    // вспомогательные переменные
    string text;
    double patternTotal = 0;            // общее время для заполнения шаблонов (шаблонизатор)
    double patternInitialization;       // инициализация (шаблонизатор)
    double patternFirst = 0;            // первый шаблон (шаблонизатор)
    double manualTotal = 0;             // общее время для заполнения шаблонов (вручную)

    // готовим шаблонизатор
    sw.Restart();
    Patternizator patternizator = new Patternizator(pattern);
    sw.Stop();
    patternInitialization = sw.Elapsed.TotalMilliseconds;
    Console.WriteLine("Сборка {0} (v. {1})", patternizator.GetType().Assembly.GetName().Name, patternizator.GetType().Assembly.GetName().Version);

    // генерируем сообщения в цикле
    for (int i = 0; i < COUNT_PATTERNIZATOR; i++)
    {
        // создаем модель
        User user = new User
        {
            Surname = RandomElement(rnd, SURNAMES),
            Name = RandomElement(rnd, NAMES),
            Patronymic = RandomElement(rnd, PATRONYMICS),

            // дата рождения 1950 - 1990 гг
            Birthdate = new DateTime(1950, 1, 1).AddDays(rnd.NextDouble() * 40.0 * 365.25)
        };
        var model = new
        {
            User = user,
            UserName = user.Name,
            Now = DateTime.Now,
        };

        // заполнение с использованием шаблонизатора
        sw.Restart();
        text = patternizator.Generate(model);
        sw.Stop();
        patternTotal += sw.Elapsed.TotalMilliseconds;
        if (i == 0)
        {
            patternFirst = sw.Elapsed.TotalMilliseconds;
        }

        // заполнение через замену строку
        if (i < COUNT_MANUAL)
        {
            // ВНИМАНИЕ! Данный код сильно упрощен и не может быть использован в реальном приложении
            // Его цель - сравнение скорости замены строк через Replace и динамической функции
            sw.Restart();
            {
                StringBuilder sb = new StringBuilder(pattern);

                DateTime now = DateTime.Now;
                sb.Replace("{User.GetFIO()|юзверь}", model.User.GetFIO() ?? "юзверь");
                sb.Replace("{UserName|юзверь}", model.UserName ?? "юзверь");
                sb.Replace("{User.Age:0}", model.User.Age.ToString("0"));
                sb.Replace("{Now:dd MMMM yyyy}", now.ToString("dd MMMM yyyy"));
                sb.Replace("{Now:HH:mm:ss}", now.ToString("HH:mm:ss"));

                text = sb.ToString();
            }
            sw.Stop();
            manualTotal += sw.Elapsed.TotalMilliseconds;
        }
    }

    WriteHeader("Шаблонизатор");
    WriteElapsedTime("Инициализация шаблонизатора", patternInitialization);
    WriteElapsedTime("Заполнение первого шаблона", patternFirst);
    Console.WriteLine();
    WriteElapsedTime(string.Format("Общее время для заполнения {0} шаблонов", COUNT_PATTERNIZATOR), patternTotal);
    WriteElapsedTime("Среднее время заполнения шаблона", patternTotal / COUNT_PATTERNIZATOR);

    WriteHeader("Вручную (хардкод)");
    WriteElapsedTime(string.Format("Общее время для заполнения {0} шаблонов", COUNT_MANUAL), manualTotal);
    WriteElapsedTime("Среднее время заполнения шаблона", manualTotal / COUNT_MANUAL);

    Console.WriteLine();
    Console.WriteLine("Нажмите любую клавишу для продолжения...");
    Console.ReadKey();
}

Скриншот:
Заполнение текстовых шаблонов данными на основе модели. Реализация на .NET с использованием динамических функций в байт-коде (IL) - 1

Вместо заключения

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

Другие мои публикации

  1. Локализация проектов на .NET с интерпретатором функций

Автор: Doomer3D

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js