Что стоит за простым уведомлением: история разработки шаблонизатора на C#

Всем привет! Хочу рассказать о шаблонизаторе сообщений, который я разработал для нашей CRM, и о том, как он менялся по мере появления новых задач. Началось всё с напоминания о встрече. Имя клиента, дата, адрес. Несколько значений нужно вставить в текст и отправить сообщение. Если вы делали уведомления, задача наверняка знакома. На первый взгляд, обсуждать тут почти нечего.
Но значения ещё нужно откуда-то получить. У нас за простой строкой постепенно выросли каталог параметров, язык со связями и условиями, построение запросов и правила обработки пустых полей. Сначала механизм жил внутри CRM. Через несколько лет для колл-центра мы перенесли подготовку шаблонов в микросервис и добавили собственную генерацию SQL. CRM-версия при этом продолжила работать. Обе реализации используются параллельно.
Готовый шаблонизатор закрыл бы подстановку значений, но нам требовалось ещё описывать в настройках, откуда их получать, и строить по этим описаниям запросы к CRM. Поэтому основой решения стал небольшой язык полей и связей со своим построителем запросов. Подготовку текста мы реализовали рядом с ним. Готовый движок тоже можно было встроить в эту схему, однако каталог параметров и чтение связанных данных всё равно оставались нашей работой. Сразу уточню: один и тот же движок готовит текст и для SMS, и для email. Его можно использовать для push-уведомлений, сообщений в мессенджере или другого канала. Он получает данные и собирает строку; правила доставки применяются уже к результату.
Давайте пройдём этот путь на примере одной встречи. Знать нашу CRM не нужно. Названия сущностей и данные я обобщил, код местами сократил, но сохранил детали, от которых зависит поведение системы. По дороге сравним подход с готовыми движками. В конце вернёмся к практическому вопросу: что я выбрал бы для такой задачи сегодня.
Код, который можно посмотреть и запустить, лежит в DevelKit.MessageTemplates. Это самостоятельная библиотека на основе описанного механизма. Ссылки на реализацию дальше ведут к этой версии, а исторические особенности CRM я буду отмечать отдельно.
Начнём с сообщения, которое должен получить клиент
Вы договорились с менеджером о встрече на следующую неделю. Подтверждение пришло сразу, напоминание придёт накануне. Удобно. Теперь встречу перенесли в другой офис. Значит, и в сообщении должны измениться адрес и время, иначе автоматизация только добавит путаницы.
Для клиента результат выглядит просто:
Анна, здравствуйте! Ждём вас 15 сентября в 14:30. Адрес: улица Примерная, 10. С вами встретится Алексей.
Менеджеру приходится делать то же самое вручную: открыть карточки, найти контакты, скопировать адрес, проверить дату. И так для каждой встречи. Мы хотели снять с сотрудников эту работу, а клиентам помочь не забывать о договорённости.
Работа началась на фоне перехода с MS Dynamics CRM 2011 на CRM 2015. Мы перерабатывали систему и модель данных, поэтому прежние способы подготовки сообщений требовали пересмотра. Уже существовал шаблонизатор, разработанный внешней командой, но его параметры были жёстко связаны с кодом. Если в текст нужно было добавить новое значение, приходилось привлекать разработчика и выпускать обновление.
Встроенные email-шаблоны тоже не закрывали наш сценарий: хотелось управлять параметрами и получать данные по связям в обновлённой модели и иметь все это и для смс-сообщений. Основная задача собственного решения состояла именно в этом.
Требования были вполне конкретными. Сразу после создания встречи готовим подтверждение. Если меняются время, адрес или ответственный, предлагаем сотруднику отправить новые данные. Напоминание уходит накануне в 20:00 по времени клиента. Для встреч, созданных на сегодня или завтра, отдельного напоминания не планировали.
Есть ещё случай. Напоминание уже подготовлено, но отправка назначена на завтра. Сегодня встречу переносят. Старый текст теперь бесполезен: нужно заново получить данные, изменить сообщение и пересчитать время отправки.
Телефона может не оказаться. Текст может не поместиться в лимит. Сотрудник должен узнать о проблеме, но саму встречу всё равно нужно сохранить. Так у системы появилась первая отчётливая граница: подготовка уведомления может завершиться неудачно, не останавливая работу с клиентом.
Что можно было взять готовым в 2016–2017 годах
Свой парсер не был единственным вариантом. К началу нашей работы уже существовали DotLiquid, Handlebars.Net и RazorEngine. Для масштаба времени: DotLiquid 1.8.0 вышел в 2014 году, Handlebars.Net 1.3.0 в 2015-м, RazorEngine 3.7.7 в январе 2016-го. Это даты конкретных выпусков, не первых версий проектов. Их можно проверить в каталоге NuGet: DotLiquid, Handlebars.Net, RazorEngine.
Сейчас, оглядываясь на ту задачу, я бы сравнил варианты так:
Вариант | Что привлекало | Что оставалось решать |
|---|---|---|
DotLiquid | Язык Liquid, условия и циклы, управляемый доступ к объектам через Drops и разрешённые члены. Подходит для редактируемых текстов. | Нужно подготовить модель данных и освоить правила Liquid. Связи CRM движок сам не загрузит. Документация |
Handlebars.Net | Короткие выражения, повторно используемые фрагменты, расширение через вспомогательные функции. Удобен, если знаком синтаксис Handlebars. | Прикладные вычисления уходят в helpers. Для параметров из связанных записей всё равно нужен свой слой чтения. Проект |
RazorEngine | Знакомый разработчикам C#, Razor-шаблоны вне MVC, компиляция и повторное использование результата. Хорош для писем, которые поддерживает команда разработки. | Нужно учитывать компиляцию, кеш и окружение выполнения. Шаблон с C# требует доверия к автору и отдельного решения об изоляции. Документация |
Любой из этих движков мог собрать наше уведомление из готовых значений. Сложность начиналась раньше: определить нужные поля, пройти по связям и загрузить их без отдельного обработчика на каждый новый параметр. Вот эту часть пришлось бы написать в любом случае.
Scriban тоже уже зарождался в тот период. Но переносить на 2016 год его сегодняшние возможности было бы нечестно: в статье автора от ноября 2017-го описывается проект, начатый годом ранее и затем доведённый до более законченного состояния.
Почему не обойтись интерполяцией строки
Первый вариант легко представить на C#:
var message = $"Здравствуйте, {person.Name}! " +
$"Встреча {meeting.StartsAt:dd.MM.yyyy HH:mm}.";
Если таких сообщений два, все данные уже загружены и текст меняется вместе с приложением, этого может быть достаточно. Но в нашей задаче текст должен был редактироваться отдельно от программы.
Данные были разбросаны. Дата хранилась во встрече, имя в карточке клиента, телефон менеджера в карточке пользователя. С адресом ещё интереснее: для внешней встречи нужен введённый адрес, для внутренней требуется адрес филиала.
Можно собрать словарь Name, Date, Address и передать его готовому движку. Работает. Но для каждого нового элемента кто-то должен написать загрузку. Мы хотели описывать путь до значения в настройках, без очередного выпуска приложения.
Сначала нужно было научить движок понимать такие пути. А когда он заработал, появилась следующая задача: сделать так, чтобы с готовыми шаблонами было удобно работать людям.
Два уровня параметров
На техническом уровне простое поле записывалось так:
{{person:name}}
До двоеточия пишем сущность, после него поле. Если начинаем со встречи, до имени клиента нужно ещё добраться. Для этого служит вложенный параметр:
{{meeting:guest{{person:name}}}}
Это выражение читается от внешней части к внутренней: берём встречу, находим её гостя, затем читаем имя человека. Для даты можно сразу указать формат:
{{meeting:startsAt(dd.MM.yyyy HH:mm)}}
Когда движок уже был написан и на нём можно было собирать шаблоны, обнаружилась совсем другая проблема. Выражения работали, но занимали слишком много места в тексте. Для одного имени ещё терпимо. Добавьте время, телефон ответственного и несколько переходов по связям, и среди скобок становится трудно увидеть само сообщение.
Редактировать такой текст неудобно. Нужно поправить несколько слов, а взгляд всё время цепляется за названия сущностей и полей. При копировании легко потерять часть выражения. И автору сообщения приходится разбираться в устройстве CRM ради простой фразы о встрече.
Поэтому поверх движка мы добавили список именованных параметров. По сути, это небольшие готовые шаблоны с понятными русскими названиями: «Имя клиента», «Дата и время встречи», «Телефон ответственного», «Адрес встречи». За каждым названием скрывалось полное техническое выражение. В том числе длинное, с переходами по нескольким связям. По мере развития языка в таких определениях стало можно использовать и условия.

В каталоге видны готовые параметры встречи и интереса. Каждому соответствует своё определение.

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

Автор сообщения выбирает название из списка и нажимает «Вставить».
Связь между понятным названием и внутренним выражением можно представить так. Имена сущностей и полей в примере условные:
Параметр в каталоге | Техническое выражение |
|---|---|
Дата и время встречи |
|
Имя клиента |
|
Телефон ответственного |
|
В библиотеке этот первый этап можно посмотреть в TemplateAliases.cs.
При обработке сообщения первым этапом раскрывались эти именованные параметры. Короткая запись заменялась определением из каталога, и только потом полученный текст поступал в основной парсер. Движок снова видел привычные ему сущности, поля, связи и форматы.
Например, внутренний ключ {{meeting:date}} мог обозначать параметр с русским названием «Дата и время встречи». Тогда начало обработки выглядело так:
До раскрытия:
Ждём вас {{meeting:date}}.
После раскрытия:
Ждём вас {{meeting:startsAt(dd.MM.yyyy HH:mm)}}.
Это иллюстрация внутренней подстановки. На скриншотах исторического редактора сами именованные вставки записаны по-русски, например «{{Встреча: Имя ответственного}}». Ключ {{meeting:date}} выше упрощён для иллюстрации двух этапов обработки. После разбора и загрузки данных вместо выражения появлялась уже конкретная дата встречи.
Оставалось упростить создание самих параметров. Для этого мы сделали небольшой редактор с автодополнением. Он подсказывал поля, которые есть у клиента, интереса или встречи, и помогал составлять техническое выражение. Не приходилось держать все имена в памяти или каждый раз искать их отдельно в описании CRM.

Редактор предлагает сущности по введённой части имени. Рядом с техническим именем показано русское название.

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

Готовое определение: от встречи через поле ownerid к имени пользователя. Автору сообщения доступна короткая русская запись.
Так на практике разделились обязанности. Маркетолог работает с текстом и выбирает готовые вставки. Администратор пополняет каталог и настраивает пути к данным. Разработчик поддерживает сам механизм; добавление параметра из уже доступных сущностей и полей не требует выпуска новой версии приложения.
Допустим, телефон теперь хранится иначе. Администратор исправляет определение в каталоге. Автор продолжает выбирать «Телефон ответственного», а тексты сообщений не приходится обходить по одному. Второй уровень сделал длинные выражения пригодными для повседневной работы и собрал их определения в одном месте.
У раскрытия есть конкретная граница: вставленные определения не обрабатываются повторно как имена из каталога. Поэтому цепочку ссылок из одного именованного параметра на другой здесь построить нельзя. Вложенность сущностей внутри технического выражения при этом остаётся доступной: с ней уже работает основной парсер.
От поиска подстрок к дереву
Для простых параметров регулярное выражение выглядит естественным началом. В ранней реализации мы действительно использовали Regex: он извлекал имя сущности, поле, формат и вложенный фрагмент, а границы дополнительно уточнялись обработкой скобок.
Но вложенность меняет задачу. Возьмём город офиса ответственного:
{{meeting:owner{{person:office{{office:city}}}}}}
Найти крайние скобки уже недостаточно. Важно сохранить весь путь: встреча → ответственный → офис → город. Позже он превратится в запрос. Набор подстрок для этого неудобен, а дерево подходит хорошо.
Так появился рекурсивный парсер. Читая параметр, он встречает вложенную конструкцию и запускает для неё тот же разбор. Получается дерево:
meeting:owner
└── person:office
└── office:city
Объект TextTemplate хранит разобранный текст. Каждый узел представлен классом TextTemplateParameter:
var template = TextTemplateParser.Parse(
"{{meeting:guest{{person:name}}}}");
var guest = template.Parameters[0];
// Внешний узел описывает связь встречи с гостем.
Console.WriteLine(guest.EntityName); // meeting
Console.WriteLine(guest.FieldName); // guest
var name = guest.SubParameters![0];
// Вложенный узел описывает значение, которое попадёт в текст.
Console.WriteLine(name.EntityName); // person
Console.WriteLine(name.FieldName); // name
У каждого узла сохраняются начало и длина в исходной строке. По этим координатам форматтер копирует обычный текст между параметрами. Цепочка глобальных Replace ему не нужна, и уже вставленное значение не попадёт под следующую замену.
Парсеру разрешено ошибиться в предположении. Он может начать читать условие и обнаружить, что перед ним обычное поле. Тогда позицию нужно вернуть назад, иначе следующая попытка получит обрезанный фрагмент. Для этого достаточно небольшого вспомогательного метода:
private bool ParseWithRevert(Func<bool> parse)
{
var start = position;
if (parse())
return true;
// Незавершённая конструкция не должна поглощать часть текста.
position = start;
return false;
}
Переменная position хранит текущую позицию; в исходном коде она называлась _i. С таким возвратом удобно пробовать отдельные части грамматики: имя, формат, условие, вложенный параметр.
У исторического разбора есть особенность: нераспознанный фрагмент обычно остаётся обычным текстом. Например, {{broken}} не содержит разделителя сущности и поля и может дойти до результата без замены. Проверка уже построенного дерева такой фрагмент не обнаружит. Если редактор должен обязательно подсвечивать все ошибки синтаксиса, для этого нужна отдельная проверка исходной строки.
Почему парсер написан вручную
Небольшой язык можно описать грамматикой и поручить разбор генератору. Например, ANTLR по такому описанию создаёт парсер и дерево разбора. Затем приложение обходит дерево и выполняет свою работу. Для нашего синтаксиса это тоже был возможный путь. Как устроен ANTLR.
Если оценивать выбранное решение по устройству кода, ручной разбор здесь объясняется размером задачи. Несколько конструкций: имя поля, формат, переход по связи и условие. Не было арифметических выражений с приоритетами, объявлений функций или других возможностей полноценного языка программирования. Отдельные правила достаточно прямо укладывались в методы ParseName, ParseField, ParseFormat и ParseIf.
Кроме того, реализация росла постепенно. Сначала работал разбор через регулярное выражение, затем появился рекурсивный парсер. Он сразу создавал нужные нам узлы с координатами в исходном тексте. Эти координаты использовались при сборке сообщения, а вложенность переходила в план чтения данных. Код можно было пройти отладчиком от открывающих скобок до готового параметра.
Генератор тоже умеет работать с позициями и вложенностью. Преимущество ручного варианта в данном случае скромнее: не требовалось подключать генерацию и среду выполнения ANTLR, а также связывать его дерево с нашей моделью параметров. Для нескольких правил такой объём собственной реализации был обозримым. Это объяснение технического компромисса; отдельного исторического сравнения прототипов на ANTLR и ручном парсере у меня нет.
Удобно было и явно задать поведение незавершённых конструкций. Не удалось прочитать параметр целиком, возвращаем позицию и оставляем фрагмент текстом. В сгенерированном парсере это тоже можно реализовать, но нужную нам политику восстановления всё равно пришлось бы описать. Автоматическая генерация не выбирает её за разработчика.
Обратная сторона проявляется при развитии языка. Каждое новое правило нужно согласовать с остальными. Сообщения об ошибках, восстановление после неверного ввода и пограничные случаи становятся нашей ответственностью. Мягкий разбор, удобный для сохранения текста, уже недостаточен для редактора, который должен точно подсветить опечатку.
Поэтому ручной парсер я бы оставлял, пока грамматика мала и меняется редко. При появлении сложных выражений, явных приоритетов операторов и развитой диагностики заново рассмотрел бы генератор. А если собственный язык больше не нужен, сначала проверил бы возможность использовать готовый шаблонизатор. Размер задачи здесь важнее желания написать всё самостоятельно.
Как устроен разбор изнутри
В TextTemplateParser.cs в основе лежит рекурсивный спуск: каждому правилу языка соответствует метод, который читает свою часть строки и вызывает методы для вложенных правил. Отдельного лексера, заранее нарезающего текст на токены, здесь нет. Имена, разделители и литералы читаются прямо из исходной строки.
Проследим один путь. ParseParameter узнаёт {{, читает имя сущности, двоеточие и передаёт управление ParseField. Тот сначала пробует условие. Если это обычное поле, читает его имя и проверяет продолжение: вложенный параметр или формат в круглых скобках. Вложенный параметр снова приводит нас в ParseParameter. Так несколько небольших методов разбирают цепочку произвольной структуры без отдельного кода для «связи второго уровня» или «связи третьего уровня».
Здесь пригодились несколько приёмов проектирования. Backtracking, то есть возврат к сохранённой позиции, уже виден в ParseWithRevert. Правило либо распознано целиком, либо возвращает false, и курсор откатывается. Это похоже на маленькую транзакцию над позицией чтения. Исключения этот метод не перехватывает: например, переполнение числа не превращается автоматически в неудачную попытку.
Следующий приём можно связать с Composite, или «Компоновщиком». Простое поле и составная конструкция представлены узлами одного дерева TextTemplateParameter, а вложенные части лежат в SubParameters. Отдельную иерархию классов для каждой конструкции мы не строили; разновидность узла задаётся его типом. У поля может быть продолжение по связи, у условия хранятся три дочерние части.
Наконец, форматтер выполняет роль интерпретатора: читает это дерево, получает значения, выбирает ветвь и выводит результат. Это описание распределения обязанностей, а не буквальная реализация всех классов паттерна Interpreter из учебника. Парсер ничего не знает о соединении с CRM или SQL. То же дерево отдельно читает построитель запроса. Поэтому изменение источника данных не требует заново придумывать синтаксис.
Грамматика целиком
Примеры помогают начать, но плохо отвечают на вопрос «а что ещё здесь разрешено?». Для этого удобна EBNF, расширенная форма Бэкуса — Наура. Ниже используется запись с ::=, |, ?, * и +, как в описании нотации W3C: альтернативы разделены вертикальной чертой, вопросительный знак означает необязательную часть, звёздочка допускает любое число повторений, плюс требует хотя бы одно. Текст в кавычках читается буквально.
Это грамматика распознаваемого параметра, восстановленная по методам парсера. Правило обработки остального текста приведено сразу после неё.
Parameter ::= "{{" S Name S ":" S Field S "}}"
Field ::= If | Name S (Parameter | Format)?
If ::= "if" S "[[" S Condition S "?" S Field S ";" S Field S "]]"
Condition ::= Field S Operator S Value S
Operator ::= "!=" | "<=" | ">=" | "=" | ">" | "<"
Value ::= String | Integer | Boolean | Null
Boolean ::= "true" | "false"
Null ::= "null"
Name ::= NameChar+
Integer ::= Digit+
String ::= "'" (StringChar | "''")* "'"
Format ::= "(" FormatChar+ ")"
S ::= (#x20 | #x9 | #xD | #xA)*
Лексические классы здесь определяются теми же проверками, что и в C#: NameChar допускает символы, для которых верно char.IsLetter или char.IsNumber, а также подчёркивание; Digit проверяется через char.IsDigit. StringChar означает любой символ, кроме одинарной кавычки, FormatChar любой символ, кроме круглых скобок. Коды в правиле S обозначают пробел, табуляцию и два символа перевода строки. Это именно лексика парсера; допустимость имени в схеме данных проверяется отдельно.
Весь шаблон сканируется слева направо. Если в текущей позиции удалось прочитать Parameter, парсер сохраняет узел и продолжает после него. Если нет, продвигается дальше в поиске следующего параметра. Обычный текст хранится в исходной строке, а не в отдельных узлах дерева. Поэтому EBNF сама по себе не описывает мягкое восстановление: даже внутри незавершённой внешней конструкции сканирование может найти корректный вложенный параметр.
Есть ещё несколько деталей, которые грамматика делает заметнее:
Имя может начинаться с цифры. Ключевые слова
if,true,falseиnullчувствительны к регистру.У одного поля выбирается либо переход по связи, либо формат. Формат конечного поля внутри вложенного параметра при этом разрешён. Пустые скобки
()и скобки внутри формата не поддерживаются.Число преобразуется через
int.Parse. Минуса, дробной части и экспоненты в языке нет; даже последовательность допустимых цифр должна успешно преобразоваться вInt32.Строка может быть пустой и допускает удвоенную одинарную кавычку. Но исторический код сохраняет содержимое между внешними кавычками без обратного преобразования:
'O''Brien'даёт значение с двумя кавычками внутри. Это особенность реализации, которую нельзя незаметно исправить при переносе.Слева от сравнения и в обеих ветвях вызывается одно правило
Field. Парсер поэтому допускает вложенные условия. При этом синтаксическая допустимость ещё не гарантирует смысл: форматтер рекурсивно исполняет условия в ветвях, а левую часть сравнения передаёт функции получения значения. Вложенныйifв левой части сам по себе там не вычисляется.
При выборе альтернатив важен и порядок. Сначала проверяется If, затем обычное имя; двухсимвольные операторы читаются раньше односимвольных. Иначе <= можно было бы принять за < и оставить лишний знак равенства. В нынешней выделенной библиотеке также есть предел глубины разбора 64. Это защитное ограничение современной реализации, а не правило исходного языка.
Насколько просто добавить новое правило
Для небольших расширений место изменения обычно видно сразу. Допустим, хочется разрешить <> как вторую запись «не равно». Это пример возможного изменения, сейчас такой записи в языке нет.
В словарь Operators достаточно добавить соответствие существующему значению:
// Новая запись использует уже реализованное сравнение.
{ "<>", TextTemplateParameterConditionOperator.NotEqual },
А в ParseIfConditionOperator добавить её в список распознаваемых строк:
// Сначала читаем длинные варианты, затем одиночные знаки.
var parsed = Read(new[] { "!=", "<>", "<=", ">=", "=", ">", "<" });
Дерево, построитель запроса и форматтер для такого синонима менять не требуется. Проверить нужно обе записи неравенства, соседний оператор < и неправильные последовательности знаков. Заодно обновить грамматику, чтобы описание языка не отстало от кода.
С новым видом значения работы больше. Для дробных чисел мало добавить метод чтения: нужно договориться о разделителе, типе числа и сравнении со значениями, которые возвращает источник данных. А цикл по коллекции затронет уже форму дерева, загрузку нескольких записей и сборку текста. Такая возможность выходит за рамки локального расширения парсера.
Удобство этой реализации в том, что небольшие правила разнесены по понятным методам. Цена расширения всё равно зависит от смысла конструкции. Ещё один способ записать готовую операцию добавляется быстро. Новый способ получать или обрабатывать данные требует пройти весь путь до результата.
Форматирование и условия
Парсер описывает, что находится в шаблоне. Форматтер отвечает за то, как превратить значения в текст. Чтобы проверить его работу, подключение к CRM не требуется:
var template = TextTemplateParser.Parse(
"Дата встречи: {{meeting:startsAt(dd.MM.yyyy)}}");
// Вместо загрузки данных передаём форматтеру готовую дату.
var result = TextTemplateFormatter.Format(
template, parameter => new DateTime(2026, 9, 15));
Console.WriteLine(result); // Дата встречи: 15.09.2026

В карточке параметра отдельно задаются исходное поле scheduledstart и формат dd.MM.yyyy. Для времени можно использовать то же поле с форматом HH:mm.
Второй аргумент возвращает значение для узла. Здесь это готовая дата. В приложении функция найдёт значение среди загруженных данных. Формат применяется средствами .NET, поэтому эту часть можно проверить вообще без базы.
В первой версии движка if не было. Шаблоны уже работали, а язык мы расширяли по мере появления задач, которые не удавалось выразить существующими средствами. Одной из них стал выбор источника адреса встречи. Во встрече есть адрес. Только хранится он не всегда в одном месте: для внешней встречи нужен указанный для неё адрес, для внутренней адрес офиса. Клиенту эта разница неинтересна. Он должен получить понятное сообщение о том, куда прийти.
Можно завести два почти одинаковых шаблона. Но тогда каждую правку приветствия, времени или контактного телефона придётся повторять. Можно перенести выбор адреса в C#, однако при изменении правила снова понадобится разработчик. Мы хотели оставить это решение в настройке параметра.
Так появился if. Администратор описывает, откуда брать адрес при каждом варианте встречи. Маркетолог выбирает в редакторе знакомый параметр «Адрес встречи» и пишет вокруг него текст. Изучать вложенные скобки ему для этого не нужно.

В описании записано правило выбора адреса, а в расширенном формате находится выражение if с двумя путями к данным.
На условных именах полей настройка выглядит так:
{{meeting:if[[isExternal = true ? address{{address:line}} ; office{{office:address}}]]}}
Читаем по порядку. Проверяем признак внешней встречи. Если он установлен, идём по связи address и берём строку адреса. В противном случае переходим к офису и читаем его адрес. После ? и ; записаны выражения полей, включая пути по связям. Произвольную строку или код C# в качестве ветви здесь написать нельзя.
Для проверки форматтера загрузку обеих связанных записей можно заменить готовыми значениями:
var template = TextTemplateParser.Parse(
"Место встречи: {{meeting:if[[isExternal = true ? " +
"address{{address:line}} ; office{{office:address}}]]}}");
// В приложении значения приходят из загруженных записей.
// Здесь разные имена конечных полей позволяют упростить пример.
var result = TextTemplateFormatter.Format(template, parameter =>
parameter.FieldName switch
{
"isExternal" => true,
"line" => "улица Примерная, 10",
"address" => "Офис на Центральной, 5",
_ => null
});
// При isExternal = false получится адрес офиса.
Console.WriteLine(result); // Место встречи: улица Примерная, 10
Этот пример использует современную запись C# для компактности. Само правило выбора относится к языку шаблонов. В дереве у оператора три части: проверяемое поле, ветвь при истинном условии и ветвь при ложном. Ветви могут содержать переходы по связям и следующие условия.
Здесь if выбирает значение одного параметра. Он не решает, пора ли отправлять уведомление, кому оно разрешено и какой канал использовать. Эти решения остаются в окружающем процессе. А построитель запроса заранее собирает поля проверки и обеих ветвей, чтобы форматтеру не пришлось обращаться к базе во время выбора адреса.
Язык оставался небольшим. Он поддерживал сравнения, строки в одинарных кавычках, неотрицательные целые числа Int32, логические значения и null. Циклов и вызовов методов в нём не было. Это помогало держать основную задачу в фокусе: выбрать нужные данные для сообщения и представить их в тексте.
Как дерево превращается в запрос к CRM
Вернёмся к уведомлению и добавим телефон менеджера:
Ваш менеджер — {{meeting:owner{{person:name}}}}.
Телефон: {{meeting:owner{{person:phone}}}}.
Оба параметра используют одну связь. Если загрузить имя и телефон независимо, мы повторим одинаковую работу. Построитель запросов обходил дерево, объединял нужные колонки и переиспользовал подходящие связи.
Запрос описывался средствами CRM SDK. QueryExpression задавал общую структуру, ColumnSet перечислял поля, а LinkEntity описывал переход к связанной записи. Дальше вызывался IOrganizationService.RetrieveMultiple. SQL на этом этапе мы сами не формировали.
Посмотрим на стандартную схему CRM. Встречи хранятся в appointment, пользователи в systemuser. Для примера считаем, что встречей владеет пользователь:
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
static EntityCollection ReadMeetingOwner(
IOrganizationService service, Guid meetingId)
{
var query = new QueryExpression("appointment")
{
ColumnSet = new ColumnSet("scheduledstart"),
TopCount = 1
};
// Выбираем конкретную встречу по её идентификатору.
query.Criteria.AddCondition(
"activityid", ConditionOperator.Equal, meetingId);
var owner = query.AddLink(
"systemuser", "ownerid", "systemuserid",
JoinOperator.LeftOuter);
// Одна связь позволяет получить сразу имя и телефон.
owner.EntityAlias = "owner";
owner.Columns = new ColumnSet("fullname", "address1_telephone1");
return service.RetrieveMultiple(query);
}
Имена meeting и person из предыдущих примеров были условными обозначениями, а здесь используются имена SDK-схемы. Различие полезно учитывать, если переносить пример в свою CRM.
LeftOuter сохраняет встречу в результате, даже если подходящего пользователя нет. Его поля будут пустыми. А вот несколько подходящих записей требуют отдельного решения: кого именно считать нужным участником? Первая попавшаяся строка не всегда даёт правильный ответ.
В исходном построителе параметры группировались по корневой сущности. Для каждой группы находилась целевая запись и создавался подзапрос. Поэтому не стоит понимать эту архитектуру как обещание одного запроса на любой текст: шаблон мог обращаться к нескольким корням.
В открытой библиотеке построение общего плана находится в TextTemplateQueryBuilder.cs. Преобразование этого плана в настоящие типы CRM SDK вынесено в CrmQueryTranslator.cs.
Как прочитанное поле возвращается на своё место
Предположим, имя ответственного встречается в сообщении дважды. Прочитать колонку достаточно один раз, но в тексте нужно сохранить оба вхождения.
Для этого рядом с запросами хранилось соответствие между узлом дерева и колонкой результата. В реализации оно называлось TemplateMapping. Условно:
Узел текста | Колонка результата |
|---|---|
Первое вхождение имени |
|
Второе вхождение имени |
|
Телефон ответственного |
|
Форматтер идёт по тексту, а функция получения значения использует это соответствие. Разбор, загрузка данных и форматирование остаются отдельными этапами.
Связанные поля CRM возвращает внутри AliasedValue. Обёртку нужно раскрыть. У OptionSetValue нас интересует числовое значение, у Money денежная величина:
static object? Unwrap(object? value) => value switch
{
// Значение связанного поля может само оказаться обёрткой SDK.
AliasedValue alias => Unwrap(alias.Value),
OptionSetValue option => option.Value,
Money money => money.Value,
_ => value
};
Это сокращённый пример подготовки значений. Форматтеру удобнее работать с датами, числами и строками, чем знать обо всех типах транспортного слоя. При этом ссылка на запись ещё не равна её отображаемому имени: если в тексте нужно имя, его следует явно запросить по связи.
Когда одной связи недостаточно
В CRM встреча относится к активностям, а её участники могут храниться через промежуточные записи activityparty. Чтобы найти клиента, недостаточно просто перейти к любому человеку, связанному со встречей. Нужно учесть роль участника, а иногда и тип записи, на которую он ссылается.
Можно представить обычную таблицу с тремя колонками: встреча, участник, роль. Один человек организует встречу, другой приглашён. Оба перехода заканчиваются в карточках людей. Только имена в сообщении нужны разные.
Поэтому построитель должен учитывать всю цепочку и фильтры связи. Объединять два перехода только потому, что они заканчиваются в таблице людей, было бы ошибкой: так можно подставить имя организатора вместо имени клиента.
С условиями есть ещё одна тонкость. Чтобы выбрать адрес внешней встречи или офиса, форматтеру сначала нужно получить значение isExternal. Построитель заранее включал в план поля обеих ветвей. Это позволяло собрать данные до форматирования, но означало, что условие в тексте само по себе не ограничивает чтение данных. Права и допустимые поля определяются приложением и CRM.
Что происходит, когда карточка заполнена не полностью
До сих пор нам везло. У клиента было имя, у встречи дата, у менеджера контакты. Теперь уберём одно поле и посмотрим на сообщение ещё раз.
Возьмём обращение из имени и отчества:
Здравствуйте, {{person:firstname}} {{person:middlename}}!
Если отчества нет, хочется получить «Здравствуйте, Анна!». В историческом форматтере для этого удалялись пробельные символы непосредственно перед пустым параметром.
Но пустой параметр может оказаться первым в строке. В буфере ещё ничего нет. Попытка прочитать последний символ обращается по индексу -1 и заканчивается исключением. Поэтому перед удалением пробелов появилась проверка:
// Удаляем пробелы слева от отсутствующего значения.
// Буфер может быть пустым, если это первый параметр сообщения.
while (buffer.Length > 0 &&
char.IsWhiteSpace(buffer[buffer.Length - 1]))
{
buffer.Remove(buffer.Length - 1, 1);
}
У такого поведения есть границы. Удаляются все пробельные символы слева, включая перевод строки. Пробелы справа и соседняя пунктуация сохраняются. Поэтому шаблон {{person:name}} ждём вас при отсутствии имени даст строку с начальным пробелом. А запятую после отсутствующего имени этот код не уберёт.
Русскую пунктуацию этот код не исправляет. Если имя необязательно, обращение нужно продумать и без него. Здесь особенно помогает предпросмотр: сначала на заполненной карточке, затем на карточке с пустыми полями.
В данных также встречалось служебное значение unknown в полях имени. Для программы это обычная строка, но в обращении к клиенту она неуместна. Мы преобразовывали её в отсутствие значения до форматирования:
if (value is string text &&
string.Equals(text, "unknown", StringComparison.OrdinalIgnoreCase) &&
(fieldName == "firstname" ||
fieldName == "middlename" ||
fieldName == "lastname"))
{
// Только в полях имени это служебное обозначение отсутствующих данных.
value = null;
}
Здесь правило намеренно привязано к определённым полям. Слово unknown в другом контексте может быть корректным содержимым. Приложение понимает смысл данных и нормализует их, а форматтер получает обычный null и применяет единое правило вывода.
Ограничения длины и стоимость SMS
Адрес хорошо показывает ещё одно различие: длина шаблона почти ничего не говорит о длине готового текста.
Ждём вас по адресу: {{meeting:address{{address:line}}}}
Для одного клиента адрес короткий, для другого в нём есть корпус, этаж и пояснение, где находится вход. При сохранении шаблона реальные значения ещё неизвестны.
Здесь длина была важна именно из-за SMS. Длинное сообщение может разбиваться на несколько частей, хотя телефон показывает их одним текстом. При посегментной тарификации оплачивается каждая часть. Несколько лишних слов могут увеличить стоимость каждой отправки, а на большой рассылке разница уже заметна.
Для обычного кириллического текста типичный предел составляет 70 символов в одиночном SMS и 67 на часть составного сообщения. Часть места занимает служебный заголовок склейки. Для кириллических SMS в нашем процессе был установлен лимит 268 символов: при таком разбиении это четыре части по 67. Достаточно выйти на 269 символов, и потребуется пятая. Это пример расчёта для обычной кириллицы; фактическое число частей зависит от кодировки, символов и правил провайдера, а сумма оплаты от тарифа. Механизм сегментации и оплаты за части описан, например, в документации Twilio.
Такое ограничение позволяло контролировать длину уведомления и расходы на отправку. Проверок было две. В редакторе каждый параметр условно считался за два символа. Перед отправкой проверялся уже текст с настоящим именем, адресом и датой. Именно вторая проверка показывала, поместилось ли конкретное сообщение в принятый лимит.
Для предварительного подсчёта можно было использовать тот же форматтер, возвращая короткий заменитель вместо каждого значения:
var template = TextTemplateParser.Parse(
"Адрес: {{meeting:address{{address:line}}}}");
// Условная оценка для редактора: каждый выводимый параметр занимает два символа.
var estimated = TextTemplateFormatter.Format(template, _ => "..");
var fitsEditorLimit = estimated.Length <= 268;
Предварительная оценка помогает при редактировании, но не гарантирует длину готового сообщения. Окончательное решение принимает обработчик SMS после подстановки. Позже проверку длины сделали управляемой при получении текста: тот же шаблонизатор использовался другими потребителями. Переносить SMS-лимит в общий парсер означало бы ограничить им и письма, и любые будущие каналы. Для рабочего редактора я бы также показывал число частей и оценку стоимости по тарифу провайдера.
Эта же граница помогает разбирать внешне похожие проблемы. Например, клиент может получить два одинаковых сообщения при совершенно корректной работе форматтера: если для одного события выбраны два шаблона с одинаковым текстом. Такой случай встречался и у нас. Исправлять нужно выбор шаблонов, а не функцию подстановки. Полезно сохранять связь сообщения с шаблоном и событием, чтобы эту разницу было видно при разборе.
Транслитерация для снижения стоимости SMS
У бизнеса был ещё один способ сократить расходы: отправлять часть уведомлений транслитом. В GSM-7 помещается больше текста: обычно 160 позиций в одиночном SMS и 153 в каждой части составного. Поэтому сообщение латиницей могло обойтись дешевле, даже если после преобразования букв в нём становилось больше. Например, «ш» превращается в sh. Экономия появлялась за счёт кодировки и числа оплачиваемых частей, а не сокращения самой строки.
Но читать транслит менее удобно. Поэтому мы не включали его для всех сообщений автоматически. В самом SMS-шаблоне появился отдельный признак «Использовать транслитерацию». Администратор выбирал его для конкретного шаблона, а автор продолжал писать обычный русский текст и использовать привычные параметры.
Порядок обработки здесь принципиален. Сначала раскрываем параметры и подставляем настоящие имя, дату и адрес. Затем, если включена настройка, преобразуем всё полученное сообщение. И только после этого проверяем длину. Если переводить в транслит исходный шаблон, подставленное позже имя клиента останется кириллицей и ожидаемой экономии может не получиться.
Для преобразования мы использовали готовую библиотеку NickBuhro.Translit, на тот момент версии 1.1.3. Она реализует кириллическо-латинскую транслитерацию по ГОСТ 7.79-2000, системе Б. В SMS-обработчике это занимало несколько строк. Ниже сокращённый фрагмент с упрощёнными именами переменных:
// К этому моменту параметры уже заменены данными клиента и встречи.
if (useTransliteration && !string.IsNullOrEmpty(message))
message = NickBuhro.Translit.Transliteration.CyrillicToLatin(message);
// Проверяем именно тот текст, который получился после преобразования.
var lengthCheck = ValidateLength(message);
В нашем валидаторе были два лимита: 268 символов для кириллицы и 612 для текста, проходившего проверку допустимых GSM-символов. Это четыре части по 67 или 153 соответственно. Выбор лимита зависел от содержимого результата, а не только от галочки в шаблоне: транслитерация сама по себе не гарантирует, что каждый оставшийся символ подходит для GSM-7. При предварительной проверке в редакторе преобразование тоже выполнялось, но вместо реальных значений параметров по-прежнему использовались короткие заменители.
Здесь у исторической реализации есть упрощение. Она проверяла набор символов и сравнивала string.Length с лимитом. Некоторые символы расширенной таблицы GSM-7, например фигурные скобки и знак евро, занимают две позиции. Поэтому такой подсчёт нельзя считать точным калькулятором стоимости. Сегодня для оценки частей я бы учитывал кодировку и вес каждого символа по правилам провайдера. Именно готовый текст после всех преобразований должен поступать на эту проверку. Подробнее о кодировках и длине SMS.
Подпись библиотеки для работы внутри CRM
С готовой библиотекой обнаружилась небольшая инфраструктурная деталь. Наш код выполнялся внутри Dynamics CRM 2015 на .NET Framework. Сборки плагинов имели строгое имя, strong name, и используемые ими зависимости тоже должны были быть подписаны. Это требование к этой цепочке сборок, а не ко всем программам, которые обращаются к CRM. Под подписью здесь имеется в виду strong name сборки .NET.
У выбранной версии NickBuhro.Translit такой подписи не было. Для этого мы подключили Brutal.Dev.StrongNameSigner версии 2.1.3. Утилита позволяет подписать готовую стороннюю сборку без пересборки её исходников. Оставалось сделать этот шаг частью обычной сборки проекта, чтобы никто не вспоминал о нём вручную после восстановления NuGet-пакетов.
В .csproj я добавил вызов консольной утилиты в BeforeBuild. Так выглядел этот участок с теми версиями пакетов:
<Target Name="BeforeBuild">
<!-- Подписываем библиотеку до компиляции использующего её проекта. -->
<Exec ContinueOnError="false"
Command=""..\packages\Brutal.Dev.StrongNameSigner.2.1.3\build\StrongNameSigner.Console.exe" -in "..\packages\NickBuhro.Translit.1.1.3"" />
</Target>
Утилита получала каталог пакета с библиотекой. Если подпись завершалась ошибкой, ContinueOnError="false" останавливал сборку. В результате подготовка зависимости перестала быть отдельной ручной операцией. Это фрагмент нашей тогдашней сборки с packages.config и общим каталогом packages.
Для пользователя вся эта работа выглядела как одна настройка в карточке SMS-шаблона. За ней оказались преобразование готового текста, другой лимит длины и дополнительный шаг сборки. Сам язык шаблонов расширять ради транслитерации не потребовалось: она осталась в обработчике SMS.
Часовые пояса и типы значений
Запись HH:mm определяет внешний вид времени. Она не сообщает, в каком часовом поясе находится клиент.
Если встреча хранится как момент времени, сначала нужно привести его к нужной зоне и только потом форматировать. Если исходное значение уже локальное, нужно понимать, для какой именно зоны. По одному DateTime без такого контекста восстановить правильный ответ нельзя.
В CRM-реализации для этого загружалась дополнительная информация о времени клиента, в том числе по связанным записям. Преобразование выполнялось при подготовке значения к подстановке. Поэтому запрос должен был учитывать не только поле, видимое в шаблоне, но и данные, необходимые для его корректного представления.
С числами похожая история. В условии count = 3 литерал имел тип Int32. Если источник возвращал Int64 со значением 3, историческое сравнение через Equals давало false:
object fromSource = 3L; // Int64
object fromTemplate = 3; // Int32
Console.WriteLine(fromSource.Equals(fromTemplate)); // False
На экране одинаковые тройки. Для Equals значения разные. Значит, на границе чтения данных нужно согласовать типы. Можно научить движок приводить числа автоматически, но тогда часть старых условий начнёт работать иначе. Это уже изменение языка, а не косметическая правка.
Один шаблонизатор для разных каналов
Когда к SMS добавился email, разбор текста и получение данных остались общими. В 2019 году мы вынесли эту логику в общий слой, а подготовку SMS и email оставили в отдельных обработчиках. Оба использовали один шаблонизатор.
На его входе находятся текст с параметрами и исходные записи. На выходе получается строка. Движок не выбирает способ доставки, не считает стоимость SMS и не знает, какой сервис отправит push. Поэтому его можно использовать и для других каналов, подключив соответствующий обработчик. Это возможность архитектуры; в этой истории речь идёт о реализованных SMS и email.
Общий механизм не требует одинакового текста везде. Для SMS можно написать короткое напоминание, для письма подготовить подробности, для push отдельно собрать заголовок и сообщение. Параметры и правила получения значений при этом переиспользуются. Затем каждый канал применяет свои проверки: SMS учитывает кодировку, число частей и стоимость, email требует корректной обработки HTML, push зависит от формата и ограничений выбранного провайдера.
Данные на этом этапе по-прежнему читались через CRM. Собственная генерация SQL понадобилась позже, при оптимизации работы колл-центра.
Почему колл-центр изменил требования к получению данных
Следующая крупная перемена произошла, когда с шаблонами стал работать колл-центр. Вместо подготовки одного уведомления интерфейсу требовался список шаблонов с подставленными данными клиента или интереса.
Один шаблон готовится быстро. Теперь их двадцать. Для каждого повторяется загрузка данных через общий API. Схематично вызовы выглядели так:
var templates = await GetTemplates();
foreach (var template in templates)
{
// Каждый элемент требует отдельного обращения за обогащением текста.
template.Text = await RenderViaCrmApi(template.Id, clientId);
}
Это псевдокод: имена методов условные. Существенно то, что за строкой RenderViaCrmApi находится внешнее обращение. Даже быстрый парсер не устраняет стоимость повторных загрузок и прохождения через общий API.
В 2022 году мы столкнулись с таймаутами при подготовке списка шаблонов. Пришлось пересмотреть и поиск, и загрузку данных для подстановки. В 2024 году мы перенесли эту работу для колл-центра в микросервис, сохранив CRM-реализацию для прежних сценариев.
Узкое место оказалось в пути получения данных. Можно вынести обработку в отдельный процесс и сохранить все прежние обращения. Но их стоимость от этого не исчезнет. Поэтому перенос затронул и сам способ чтения.
Генерация SQL в микросервисе
В CRM запрос выполнялся через SDK. В микросервисе появился QueryExpressionSqlQueryGenerator, который строил SQL для чтения необходимых полей. При этом идея дерева связей осталась полезной: оно уже описывало корневую запись, колонки и переходы.
Что делает система | CRM-реализация | Микросервис |
|---|---|---|
Разбирает текст | Строит дерево параметров | Использует ту же основную механику |
Описывает данные | Типы запросов CRM SDK | Локальные модели запроса |
Читает значения |
| Генерирует и выполняет SQL |
Готовит результат | Разворачивает значения SDK | Использует данные и метаданные полей |
Собирает сообщение | Форматтер | Форматтер |
Здесь мы решили не усложнять себе жизнь. Взяли привычные названия и поля из CRM SDK и описали облегчённые версии QueryExpression, LinkEntity и других нужных классов. Сохранили семантику работы с запросами, чтобы код построения дерева оставался узнаваемым. Меняли только те участки, которые нельзя было перенести без изменений, прежде всего выполнение запроса и подготовку результата.
Это помогало поддерживать две реализации. CRM-версия продолжает работать наравне с микросервисом: в одной запрос выполняет SDK, в другой наш SQL-генератор. Когда исправляешь обход связей или добавляешь возможность языка, проще сопоставить одинаково устроенный код и учесть изменение в обеих версиях. Чем меньше ненужных различий, тем меньше работы при таком сопровождении.
Для встречи с ответственным результат такого преобразования можно показать следующим SQL. Таблицы и колонки здесь обобщены:
SELECT TOP 1
[m].[startsAt],
[p].[name] AS [owner.name],
[p].[phone] AS [owner.phone]
FROM [dbo].[meeting] AS [m]
LEFT OUTER JOIN [dbo].[person] AS [p]
ON [m].[ownerid] = [p].[personid]
WHERE [m].[meetingid] = @meetingId;
Значение идентификатора передаётся параметром. В сервисной реализации генератор возвращал FormattableString и описание выбранных полей, а слой доступа преобразовывал аргументы в параметры команды. Для этого существенен именно контракт слоя выполнения: если превратить такую строку в обычный текст раньше времени, механизм параметризации сам собой не сохранится.
Выбранные поля по-прежнему связывались с узлами шаблона. Форматтеру не требовалось знать, каким способом они загружены. Но у нового способа чтения появилась дополнительная обязанность: вернуть значения с прежним смыслом и типами.
Защита от SQL-инъекций
После появления собственного SQL возникает закономерный вопрос. Шаблон редактирует человек. Он может вставить кавычку, точку с запятой, фрагмент запроса. Что помешает этому тексту превратиться в команду для базы?
Здесь нужно различать три вещи: обычный текст сообщения, имена сущностей и полей, значения условий запроса. У каждой свой путь через систему. Защита строилась на этом разделении.
Обычный текст не становился частью запроса
Допустим, в сообщении появилась такая строка:
Здравствуйте, {{person:name}}! Проверочная строка: ' OR 1=1 --
Построитель видит параметр person:name и добавляет нужную колонку. Текст приветствия и всё после параметра ему для запроса не нужны. Эти фрагменты остаются в исходной строке и копируются форматтером уже при сборке сообщения.
То же относится к прочитанным значениям. Если в имени клиента окажутся кавычки или даже запись, похожая на SQL, форматтер выведет её как значение. Он не запускает повторный разбор вставленного текста и не отправляет его на выполнение в базу. Сообщение получится странным, но в этом пути строка не станет SQL-командой.
Это объясняет, почему мы не вырезали из текста слова SELECT или DROP. Они могут встречаться в обычной переписке. Важно, куда попадёт строка и кто будет её интерпретировать.
Имена полей проходили через грамматику
Имя таблицы нельзя передать параметром вместо SQL-идентификатора. Его приходится включать в структуру команды. Поэтому здесь нужна другая граница.
Исторический парсер разрешал в именах только буквы, числовые символы и подчёркивание. Правило выглядело так:
private bool ParseName(out string? name)
{
return ParseChars(
out name,
c => char.IsLetter(c) || c == '_' || char.IsNumber(c),
true);
}
Кавычка, закрывающая квадратная скобка, пробел или точка с запятой в такое имя не входят. Например, запись {{person:name]; SELECT 1--}} не разбирается как корректный параметр поля. Её SQL-хвост не попадает в имя колонки.
Однако парсер у нас в этом плане достаточно мягкий: нераспознанная конструкция может остаться обычным текстом. Это не означает, что редактор показал ошибку или что шаблон вообще нельзя сохранить. Это означает, что из такого фрагмента не получилось поле для построителя запроса. Форматы дат и строковые литералы условий тоже не становятся именами колонок: форматтер использует их при подготовке текста. Условие if вычисляется после чтения данных, а не копируется в SQL как готовый WHERE.
Дальше построитель создавал связи и служебные псевдонимы, а генератор выбирал JOIN, AND и другие операторы из поддерживаемых вариантов. Свободного поля «вставьте сюда SQL» в языке не было.
У этой защиты есть ограничния. Старый генератор сам не проверял каждое переданное имя: он рассчитывал на план, собранный штатным путём, и обрамлял идентификаторы квадратными скобками. Одни скобки не защищают произвольную строку с символом ]. Поэтому этот генератор нельзя считать безопасным универсальным API для внешнего QueryExpression, полученного в обход парсера и построителя.
Значения передавались отдельно от команды
Внутри CRM мы создавали объекты QueryExpression и передавали значения через API SDK. Собственного SQL-текста с подставленными значениями там не было. После перехода в микросервис эту границу пришлось сохранить уже в своём слое доступа.
Генератор складывал значения условий в отдельный список. В тексте оставались позиции {0}, {1} и далее:
private static string AddParameter(List<object> parameters, object value)
{
parameters.Add(value);
// Возвращаем позицию аргумента, не его строковое представление.
return $"{{{parameters.Count - 1}}}";
}
Результат создавался через FormattableStringFactory.Create. У такого объекта есть и формат команды, и отдельный массив аргументов. Но одного типа FormattableString недостаточно. Всё решается в момент подготовки SQL-команды.
В исходном слое доступа формат заполнялся именами параметров, а значения добавлялись в коллекцию SqlCommand.Parameters. Ниже сокращённый вариант этого механизма. GenerateParameterNames создаёт служебные имена @p_0, @p_1 и далее:
var values = query.GetArguments();
var names = GenerateParameterNames(values.Length);
// В SQL подставляются только имена параметров, созданные нашим кодом.
command.CommandText = FormattableStringFactory
.Create(query.Format, names)
.ToString();
for (var i = 0; i < values.Length; i++)
{
var parameter = command.CreateParameter();
parameter.ParameterName = names[i];
parameter.SqlDbType = GetSqlTypeByValue(values[i]);
parameter.Value = values[i];
command.Parameters.Add(parameter);
}
Например, для строкового значения ' OR 1=1 -- команда содержит WHERE [code] = @p_0, а вся подозрительная строка лежит в значении @p_0. Это иллюстрация работы слоя параметров; выбор корневой записи в нашем сценарии обычно использовал идентификатор Guid. База сравнивает поле с переданным значением, а не добавляет из него новое условие.
Если вместо этого вызвать query.ToString() с исходными аргументами и результат присвоить CommandText, разделение потеряется. Поэтому важно проследить путь до Parameters.Add, а не остановиться на красивой интерполированной строке. Параметризованные запросы и отдельная проверка динамических идентификаторов соответствуют подходам, описанным в рекомендациях OWASP.
Корректный SQL ещё не означает разрешённое чтение
Имя salary синтаксически ничем не хуже name. Запрос может быть защищён от инъекции и всё равно прочитать поле, которое автору сообщения видеть не положено. Это уже вопрос доступа.
В библиотеке DevelKit.MessageTemplates эти проверки выражены явно: TemplateSchema задаёт разрешённые сущности, поля и связи, а SQL-генератор дополнительно проверяет синтаксис идентификаторов. Это более поздняя реализация. Приписывать такой же список разрешений старому валидатору было бы неверно: тот проверял заполненность узлов дерева. Права пользователя CRM и права сервисного подключения также требуют отдельной настройки.
Наконец, SQL injection и вставка HTML требуют разных мер. Кавычки в имени не должны менять запрос. HTML-разметка в том же имени не должна неожиданно менять письмо. Первую задачу решает описанный путь построения команды; для второй приложение должно кодировать значение в соответствии с местом вставки. Подготовленный текст не становится безопасным для любого дальнейшего использования только потому, что мы безопасно прочитали данные из базы.
Что SQL не приносит с собой автоматически
Когда CRM возвращает поле, она знает его тип и правила представления. После прямого запроса мы получаем строки и колонки. Чтобы сохранить поведение шаблонов, нужны ещё метаданные: описание того, что означает каждое поле.
В сервисном исполнителе данные запроса и метаданные загружались параллельно. Сокращённый фрагмент этого этапа:
var dataTask = facade.ExecuteQuery(query, cancellationToken);
var metadataTask = facade.SearchMetadataAsync(metadata, cancellationToken);
// Обе операции нужны для подготовки значений к форматированию.
await Task.WhenAll(dataTask, metadataTask);
var data = dataTask.Result.Tables[0];
var fieldMetadata = metadataTask.Result;
После этого обработчик учитывал тип поля: восстанавливал специальные значения, оформлял связанные колонки как AliasedValue, подготавливал даты. Иными словами, перенос чтения потребовал воспроизвести часть работы, которую раньше обеспечивала CRM-инфраструктура.
Описания полей кешировались. Отдельно кешировался каталог параметров. Путать их не стоит: каталог раскрывает короткую запись в выражение, а метаданные объясняют тип прочитанного поля.
У кеша каталога использовался срок жизни 30 минут и блокировка обновления. Когда срок истекал, один запрос загружал новую версию, а остальные ожидали результат. Так одновременные обращения не вызывали одинаковое обновление каждый по отдельности.
Прямое чтение также требует отдельно определить права доступа и допустимые данные: правила пользовательского контекста CRM не переносятся автоматически вместе с текстом запроса. Это часть ответственности сервисного слоя наряду с корректной обработкой типов.
Сопоставимых замеров для красивой цифры ускорения у меня нет. Для колл-центра поиск и подготовка шаблонов теперь выполняются в микросервисе, который сам формирует SQL для чтения данных. В CRM сохраняется свой путь выполнения через SDK. Количество запросов на весь список зависит от конкретного пути выполнения.
Собираем полный сценарий уведомления
Теперь соединим части. Приложению нужно сохранить параметры, создать шаблон, позднее найти его по коду и подготовить сообщение для конкретного клиента. Одного вызова форматтера для этого мало: кто-то должен управлять каталогом, а кто-то выполнить запрос к данным.
В сквозном примере samples/NotificationFlow эти обязанности разделены между тремя небольшими классами. NotificationCatalog сохраняет и читает определения параметров и тексты шаблонов в SQL Server. NotificationService связывает найденный шаблон с клиентом. SqlNotificationDataProvider выполняет запрос, который построил движок. Каталог и сервис относятся к приложению, их устройство библиотека не навязывает.
Сначала администратор добавляет параметры. Автор сообщения использует их в тексте и сохраняет шаблон под понятным кодом:
await catalog.AddParameterAsync(
"{{Клиент:Имя}}", "{{person:name}}");
await catalog.AddParameterAsync(
"{{Встреча:Дата}}", "{{person:meetingAt(dd.MM.yyyy HH:mm)}}");
await catalog.AddTemplateAsync("meeting-reminder",
"Здравствуйте, {{Клиент:Имя}}! Ждём вас {{Встреча:Дата}}.");
Эти методы выполняют параметризованные INSERT. Текст и определения действительно сохраняются в таблицах, а не остаются локальными переменными рядом с вызовом движка. Для краткости у клиента в этом примере хранится дата одной встречи. В более полной модели до встречи нужно пройти по связи, как мы делали выше.
Когда наступает время подготовить уведомление, прикладной код знает только код шаблона и идентификатор клиента:
var message = await notifications.RenderAsync("meeting-reminder", 42);
Внутри NotificationService происходит поиск шаблона и загрузка параметров. Если шаблон не найден, сервис сообщает об этом отдельно:
var text = await catalog.FindTemplateAsync(templateCode, cancellationToken)
?? throw new KeyNotFoundException($"Template not found: {templateCode}");
var parameters = await catalog.LoadParametersAsync(cancellationToken);
return await engine.RenderAsync(text,
new Dictionary<string, object> { ["person"] = personId }, parameters,
cancellationToken: cancellationToken);
Движок раскрывает русские вставки, разбирает технические выражения и собирает запрос к person с фильтром по id. Затем вызывает поставщик данных. Именно здесь используется SqlServerQueryGenerator:
var sql = SqlServerQueryGenerator.Generate(query, schema);
await using var command = sql.CreateCommand(connection);
command.Transaction = transaction;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
return null;
var row = new Dictionary<string, object?>(StringComparer.Ordinal);
for (var i = 0; i < reader.FieldCount; i++)
row.Add(reader.GetName(i), reader.IsDBNull(i) ? null : reader.GetValue(i));
return row;
Генератор возвращает SQL и значения параметров. CreateCommand превращает их в команду с отдельными DbParameter. Провайдер выполняет её и возвращает словарь колонок. Названия колонок сохраняются вместе с псевдонимами, чтобы движок смог сопоставить прочитанные значения с узлами шаблона. После этого форматтер собирает строку.
В демонстрационной базе есть два клиента. Один и тот же шаблон для идентификаторов 42 и 43 даёт разные результаты:
Здравствуйте, Анна! Ждём вас 15.09.2026 14:30.
Здравствуйте, Борис! Ждём вас 16.09.2026 10:00.
Это уже полный путь от сохранённой настройки до текста из данных конкретного клиента. В примере он проверяется на настоящем SQL Server. Для повторных запусков таблицы создаются в уникальной схеме внутри транзакции, которая в конце откатывается. Доставки сообщения здесь нет: готовая строка дальше поступит обработчику выбранного канала.
Что стоит проверить в похожем движке
Если вы решаете близкую задачу, начать можно с небольшого набора сценариев вокруг итогового сообщения. Разобрать простое поле. Пройти по двум связям. Вывести обе ветви условия на разных данных. Подставить пустое первое значение. Повторить одно поле в двух местах. Получить дату для клиента в другой временной зоне.
Каждый слой можно проверить отдельно. Парсеру даём строку и проверяем дерево. У построителя смотрим колонки, связи и фильтры. Форматтеру подсовываем готовые значения, а у адаптера проверяем типы и отсутствие записи. Поднимать всю CRM ради пустого имени не требуется.
Например, проверка пустого первого параметра занимает несколько строк:
var template = TextTemplateParser.Parse("{{person:name}} ждём вас");
var result = TextTemplateFormatter.Format(template, _ => null);
// Пробел справа сохраняется согласно текущему правилу форматтера.
if (result != " ждём вас")
throw new Exception("Изменилось поведение пустого параметра в начале текста.");
Здесь зафиксирован наблюдаемый результат. Если мы позднее захотим убирать начальный пробел, это будет осознанное изменение правила с новым ожидаемым результатом, а не побочный эффект исправления исключения.
Также полезно явно записать границы языка: одна запись вместо коллекции, ограниченный набор литералов, правила пустых значений, отсутствие автоматического HTML-кодирования. Тогда автор шаблона и разработчик интеграции понимают, на что могут рассчитывать.
Посмотреть такие проверки целиком можно в tests: отдельно для ядра и SQL, отдельно для CRM SDK. А ограничения собраны в документации совместимости.
Что получилось в итоге
Мы хотели отправлять напоминания о встречах. Получился механизм, в котором параметр описывает и значение, и путь к нему. Один язык используется в двух реализациях: внутри CRM и в микросервисе. Сохранённые названия, поля и семантика моделей помогают сопровождать их параллельно, хотя способы получения данных различаются.
Когда понадобилось вынести подготовку уведомлений в микросервис, мы сохранили язык шаблонов и работу форматтера. Изменили получение и подготовку данных: в микросервисе для этого использовали SQL и метаданные полей. Каталог помогает автору текста. Парсер сохраняет структуру. Построитель собирает поля и связи. Адаптер возвращает значения. Форматтер пишет сообщение. У каждой части есть работа, которую можно объяснить и проверить отдельно.
Если хотите попробовать механизм на своих данных, начните с примера генерации SQL и подготовки сообщения или примера CRM-адаптера. Оба запускаются на демонстрационных данных. Общий порядок сборки описан в README.
Сегодня я бы начал с проверки готовых решений: сможет ли администратор добавить нужные данные в шаблон, не обращаясь к разработчику?
Что выбрать сейчас
Сегодня я бы проверял не только возможности редактора. Важно, кто будет добавлять новые параметры, где хранятся данные и сколько системы придётся установить ради подготовки текста. Маркетологу нужны понятные вставки. Администратору нужен способ связать их с полями уже доступной модели. Разработчик подключает источник и задаёт границы доступа, но не должен возвращаться к каждому новому параметру.
Среди готовых продуктов есть несколько близких подходов. Ниже речь об их современных возможностях, а не о том, что было доступно в CRM 2015.
Dynamics 365 Customer Insights — Journeys ближе всего к нашему разделению параметров и текста. Можно настроить источник значения, понятную метку и значение по умолчанию, а затем добавить вставку в общий список для авторов email и SMS. При выборе данных доступны переходы по связям Dataverse, форматы дат и часовые пояса. Однако каталог ведёт себя иначе: изменения не распространяются автоматически на все ранее созданные сообщения. Это платный продукт с 30-дневным пробным периодом. Получить описанный механизм как отдельную библиотеку для своего микросервиса нельзя: он работает внутри Customer Insights и Dataverse. Я бы рассматривал его прежде всего там, где эта платформа уже используется. Персонализация и общий список вставок, лицензирование и пробный период.
Odoo близок по работе с моделью. Шаблон связан с записью, а динамические вставки позволяют выбирать поля и переходить к связанным моделям. Такие вставки есть и в SMS-шаблонах. Community Edition бесплатен, открыт под LGPLv3 и допускает собственное размещение. Но механизм шаблонов опирается на модели и окружение Odoo. Это не готовый независимый компонент, который можно подключить к существующему C#-сервису. Если компания уже работает в Odoo, его настройки стоит проверить первыми. Устанавливать всю платформу только ради подстановки текста я бы не стал. Бесплатность Community также не означает бесплатность Enterprise, всех дополнений и отправки SMS. Динамические вставки, SMS-шаблоны, редакции Odoo.
Customer.io позволяет использовать в сообщениях объекты, связанные с получателем, и атрибуты связей. Для части сценариев редактор предлагает выбор атрибутов без написания Liquid. Это ближе к работе с предметной моделью, чем простой словарь переменных. Однако нужные объекты и связи должны быть представлены в Customer.io: произвольную модель нашей CRM он сам не подхватит. Для платформы сообщений предлагаются платные планы и 14-дневный пробный период, а не постоянная бесплатная редакция. В этом сравнении это внешний сервис с собственной моделью данных, не автономный движок для установки в приложение. Объекты в шаблонах, планы и пробный период, тарифы.
Novu можно подключить к своему приложению как отдельную инфраструктуру уведомлений, без перехода на другую CRM. У него есть редакторы и несколько каналов, а дополнительные данные можно получать через HTTP. Доступен бесплатный облачный план с ограничениями и Community-редакция для собственного размещения. Открытое ядро использует MIT, корпоративные части имеют отдельные коммерческие условия. При этом возможности Community и облака различаются: наличие функции в общей документации ещё не гарантирует её наличие в бесплатной установке. Novu независим от конкретной CRM, но для описанного сценария остаётся отдельным сервисом со своим окружением. Установить клиентский SDK недостаточно, чтобы получить весь механизм локально. Каталог параметров с выбором полей и связей нашей модели всё равно нужно продумать при интеграции. Получение данных по HTTP, редакции, исходники и лицензии, тарифы.
Knock тоже можно использовать с собственным приложением. Он даёт редактор шаблонов и умеет получать дополнительные данные через настраиваемый HTTP-запрос. Есть постоянный бесплатный тариф Developer с ограничением объёма. Но это бесплатное использование сервиса Knock, а не независимая бесплатная библиотека с теми же возможностями. Наличие SDK не переносит его серверный движок внутрь нашего приложения. Если нужный API уже существует, администратору может хватить настройки запроса. Если ради нового поля приходится дописывать API, требование о работе без разработчика пока не выполнено. Доставка через внешних провайдеров оплачивается отдельно от тарифа Knock. Получение данных, шаблоны, бесплатный план и условия оплаты.
Для системы на Dataverse или Odoo я бы сначала проверил встроенную персонализацию. Если нужен отдельный сервис управления уведомлениями, сравнил бы Novu, Knock и Customer.io с учётом размещения и подготовки данных. Но среди этих пяти вариантов я не нашёл готового бесплатного встраиваемого компонента, который одновременно даёт наш каталог, настройку путей по связям и редактор для авторов сообщений вне собственной платформы.
Если нужна именно библиотека внутри существующего приложения, готовый текстовый движок может взять на себя форматирование. Каталог, редактор и получение данных останутся отдельной частью решения. Проверка выбора простая: администратор добавляет параметр из связанной записи, маркетолог использует его в SMS и письме, система показывает готовый результат. В пределах уже подключённой модели этот путь должен проходить без изменения кода.
Как сделать движок независимым от модели CRM
Следующим шагом я вижу обобщение механизма работы с данными. Общие классы уже вынесены в библиотеке, но в устройстве запросов элементы Dynamics CRM: сущности, поля, связи, QueryExpression и LinkEntity. Для существующих двух реализаций это удобно. Чтобы подключать собственные модели и другие источники, эту реализацию следует пересмотреть.
Идея простая: шаблон описывает, какие значения ему нужны, а способ их получения выбирает адаптер. Для реляционной базы он строит SQL. Для объектов приложения проходит по свойствам. Для внешнего сервиса обращается к API. Парсеру и форматтеру при этом не требуется знать, где хранятся данные.
Часть возможностей уже есть: форматтер принимает функцию получения значения, а метаданные и поставщик данных задаются интерфейсами. Но поставщик внутри TemplateEngine пока получает план, ориентированный на таблицы и соединения. Дальнейшая работа могла бы состоять в том, чтобы выделить независимое описание нужных данных, а преобразование в запросы CRM, SQL или обращения к другим источникам оставить отдельным реализациям.
Тогда приложение сможет использовать собственные модели, не подгоняя их под структуру CRM. Для нового источника разработчик один раз реализует подключение и правила доступа. После этого администратор сможет настраивать параметры в пределах доступной модели, а автор сообщения продолжит выбирать понятные названия из каталога.
У источников разные возможности: где-то данные можно получить одним запросом, где-то понадобятся несколько обращений и кеш. Особенности получения данных будут обрабатываться в адаптерах. Язык шаблонов и привычная работа с параметрами при этом сохранятся.
Был бы вам полезен такой инструмент, или вы уже решили эту задачу иначе? Если нашли подходящее готовое решение, поделитесь в комментариях.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.