redb.Route
Что происходит с header.a > 10 между файлом маршрута и первым сообщением, как связывается bean:svc.Check(body), почему шаблоны никогда не выполняют код и что проверка пакета сообщает с номером строки и колонки.
Первая часть рассказала о языке выражений redb.Route в целом. Там была одна грамматика для условий, значений и шаблонов, компиляция в делегат вместо разбора на каждом сообщении, одно правило истинности и места, где выражения живут: шаблоны интеграции и адреса конечных точек. Эта часть заглядывает внутрь. Что именно происходит со строкой вроде header.a > 10. Как шаблон решает, где кончается подстановка. Как маршрут добирается до ваших сервисов и до окружения. Что язык отказывается делать, когда текст пришёл из данных, а не от разработчика. И что проверка пакета сообщает о файле маршрута ещё до развёртывания.
Сначала о том, откуда взяты результаты. Публичное руководство по языку, EXPRESSIONS.md, помечает свои таблицы примеров для исполнения, и тест ExpressionsGuideExamplesTests читает само руководство и прогоняет каждый помеченный пример, а их больше 130, на движке при каждом запуске тестов. Если движок меняется, либо руководство меняется вместе с ним, либо сборка падает. Поэтому каждый результат ниже, взятый из этих таблиц, это то, что движок возвращает, а не то, что он должен возвращать. Немногие результаты, которых нет в руководстве (песочница, свои корни, вывод проверки), получены прогоном на текущем коде, и я отмечаю это на месте.
Все примеры работают на одном обмене:
header.a = 42 header.b = 3 header.neg = -7 header.price = 2.5header.s = "Hello World" header.flag = true header.off = falseheader.Content-Type = "text/xml" header.empty = "" header.O'Brien = "obrien"header.user = { Name = "Ann", Age = 30, Active = true, Manager = { Name = "Bob", Age = 51 } }header.list = [5, 1, 9] header.when = 2026-08-28 10:30 UTCproperty.count = 7 property.items = ["p", "q", "r"] property.nums = [1, 2, 3, 4]property.cfg = { enabled = true, limit = 5 } property.counter = 10body = "payload" exception.Message = "boom"registry: beans "pricing" and "pricing-rules"; "limits.max", the number 100
1. От строки до делегата
Большинство интеграционных фреймворков считают строку выражения чем-то, что надо интерпретировать: на каждом сообщении проходят по тексту, ищут функции по имени, по дороге преобразуют значения. redb.Route считает её исходным кодом. Маршрут строится один раз, и выражения в нём один раз компилируются в делегаты вида Func. На каждом сообщении остаётся только вызвать делегат.
Вот путь, который проходит голое выражение.
-
Токенизатор. Текст режется на лексемы: имена, числа, строковые литералы, операторы, скобки. Пробелы между лексемами исчезают здесь же, поэтому
header.a>10иheader.a > 10это одно и то же выражение, а условие, перенесённое на другую строку в XML-атрибуте, читается правильно. -
Парсер. Парсер рекурсивного спуска строит синтаксическое дерево. Уровни приоритета привычные, от самого слабого к самому сильному: тернарный оператор,
??, логика, сравнение, сложение и вычитание, умножение и деление, унарные операторы и, наконец, первичные формы: имена, литералы, вызовы, скобки. Парсер берёт весь текст: лексема, оставшаяся после законченного выражения, это ошибка с позицией, а не то, что молча отбрасывается. -
Дерево выражений. Каждый узел синтаксического дерева переводится в
System.Linq.Expressions, те самые деревья выражений, которые строят LINQ-провайдеры и Entity Framework. Чтение заголовка становится обращением к словарю,upper(...)вызовом метода.ANDиORстановятсяAndAlsoиOrElseповерх приведения к истинности, а??становитсяCoalesce, поэтому их правая сторона выполняется, только когда левая не решила дело.XORнужны обе стороны, и он вычисляет обе. -
Компиляция.
Expression.Lambda<Func>(...).Compile()превращает дерево в IL. Получается обычный делегат .NET.
Это единственный движок. Каждое выражение-значение, условие и подстановка проходят через этот один путь, поэтому форма, которая работает в одной позиции, работает и в других, с тем же смыслом и теми же ошибками.
Короткое замыкание здесь не мелочь. Оно видно в исполняемых строках руководства, где справа стоит вызов бина, которого не существует:
Выражение
Результат
header.flag OR bean:nobody.Check()
True
header.off AND bean:nobody.Check()
False
header.a ?? bean:nobody.Check()
42
Ни одно из трёх не вызывает nobody. Проверка слева защищает дорогой или ненадёжный вызов справа, как в C#.
У шаблонов дорога немного другая. Сначала сканер находит в тексте дырки. Суффикс формата вроде ${header.price:F2} отделяется первым (раздел 2). Для подстановки, которая занимает всю строку, вроде ${header.a}, есть прямые короткие пути: заголовок, свойство, тело, одиночный вызов jpath/xpath и написания Camel ${jsonpath:...}, ${xpath:...} и ${file:...} компилируются сразу в доступ к значению, минуя парсер. Всё более сложное идёт через то же дерево, что и голое выражение. Смешанный шаблон, где вокруг подстановок есть текст, становится списком литеральных кусков и скомпилированных дырок, которые склеиваются во время выполнения.
Кэш. Скомпилированные делегаты хранятся в общем на процесс кэше, ключ в нём это текст выражения. Два маршрута с одинаковым условием делят одну компиляцию. Автоматического вытеснения у кэша нет; ExpressionResolver.ClearAllCaches() очищает его вручную.
Цифры скорости в этой статье не будет. Чистого замера, который не стыдно опубликовать, пока нет, а число с загруженной машины разработчика ничего не говорит. Зато можно точно сказать, что именно компилируется: всё. Нет интерпретируемого режима, в который можно откатиться, и нет «компилируемого варианта», который нужно включать. Разбор происходит, когда строится маршрут, и если текст не разбирается, маршрут не стартует: .Filter("header.a >") бросает ExpressionCompilationException на Start().
Голый текст: слово это слово, арифметика должна быть целой
Что должно значить application/json в атрибуте-выражении? Apache Camel читает текст без операторов как сам текст, и redb.Route тоже: hello, plain text и key=value это литералы, а имя вроде header.a или body это обращение к значению. Текст, в котором вне кавычек есть арифметический оператор (+ - * /), это арифметика, и она должна быть целой: разбираться до конца, и каждое голое слово в ней должно быть значением. Если нет, ошибка называет место:
1 + → ends where a value is expectedheader.a+b → 'b' at position 10 is not a valueapplication/json → 'application' at position 1 is not a value
Такой текст пишется в кавычках, 'application/json'. То же правило ловит даты: голое 2026-08-28 это арифметика, и она даёт 1990, поэтому дату берут в кавычки или читают из заголовка. Смысл правила в том, что опечатка никогда не превращается в правдоподобное значение. Недоразобранное выражение это ошибка при загрузке, а не неверное число в сообщении.
Инкременты пишут в обмен
В языке есть ++ и --, префиксные и постфиксные, для заголовков и свойств. Это не украшение: они меняют обмен.
Выражение
Возвращает
После
header.a++
42
header.a = 43
++header.a
43
header.a = 43
header.a--
42
header.a = 41
--header.a
41
header.a = 41
property.counter++
10
property.counter = 11
++property.counter
11
property.counter = 11
Префиксная форма возвращает новое значение, постфиксная старое, как в C#. Инкремент отсутствующего заголовка или свойства начинается с нуля. Значение, которое есть, но не является числом, это ошибка с его названием, а заголовок остаётся как был:
header.x++: the value 'abc' (String) is not a number.
Инкремент работает и внутри шаблона. В построителе маршрута:
.SetProperty("attempt", Constant(0)).LoopWhile("property.attempt < 3") .Log("attempt ${property.attempt++}") .To("direct://step").EndLoop()
LoopWhile повторяет тело, пока условие выполняется; инкремент в шаблоне лога продвигает счётчик, поэтому лог печатает attempt 0, attempt 1, attempt 2, а тело выполняется три раза. (Constant это помощник RouteBuilder; вне его пишите new ConstantExpression(0).)
Внутри инкремент компилируется в чтение, вычисление и запись обратно в In.Headers или Properties. Это простое «прочитал, изменил, записал», не атомарная операция. Это корректно, потому что один обмен в каждый момент обрабатывает один поток выполнения; для счётчиков, общих для нескольких потоков, этот инструмент не годится.
Литералы не зависят от локали сервера
Числовые литералы разбираются в инвариантной культуре. max(2.5, 1) означает одно и то же на сборочном сервере с en-US и на боевом сервере с ru-RU, где десятичный разделитель запятая. На выходе так же: число или дата, подставленные в шаблон, выглядят как 2.5 и ISO 8601 на любой машине. Слова для правила истинности (true/false, yes/no, on/off и остальные) только английские, по той же причине: локаль того, кто написал маршрут, никогда не меняет его смысл.
2. Типизированные шаблоны
В первой части мельком сказано, что подстановка на всю строку сохраняет тип. Это стоит разобрать подробнее, потому что так исчезает целый класс кода преобразований.
Шаблон
Результат
Тип
${header.a}
42
int
${header.a + 1}
43
число
${header.a > 10}
True
bool
${header.price}
2.5
число, выведено инвариантно
a=${header.a}, b=${header.b}, sum=${header.a + header.b}
a=42, b=3, sum=45
string
[${header.missing}]
[]
string
SetHeader("n", Expr("${header.a}")) кладёт в заголовок int 42, а не текст «42». Ключ Kafka, параметр SQL или заголовок, который следующий шаг читает через getHeader, получают число, а не строку, которую кому-то придётся разбирать заново. Как только вокруг подстановки появляется текст, шаблон становится строкой, как и положено шаблону с текстом. Отсутствующее значение выводится как пустота, поэтому [${header.missing}] даёт [], а не [null].
Суффикс формата
Когда число должно стать текстом заданного вида, подстановка принимает стандартный формат .NET после двоеточия:
Шаблон
Результат
${header.price:F2}
2.50
${header.a:D5}
00042
${header.price * 4:F1}
10.0
${value:F2} это format(value, 'F2') в инвариантной культуре: одна буква и до двух цифр (F2, N0, D8). Перед двоеточием стоит путь или вызов, поэтому пространство имён вроде env:X никогда не примут за формат, как и двоеточие тернарного оператора. Произвольный формат вроде yyyy-MM-dd пишется через format(...) или dateformat(...).
3. Где кончается подстановка: скобки, кавычки, экранирование
Шаблон выглядит простым, пока кто-нибудь не напишет фигурную скобку внутри строкового литерала. Правила короткие, и каждое исполняется тестами руководства:
Шаблон
Результат
${concat('a', '}', 'b')}
a}b
{${header.a}}
{42}
${}
${}
${header.O'Brien}
obrien
echo ${HOME} for ${header.s}
echo ${HOME} for Hello World
text ${body
ошибка: unclosed '${' at position 6 of "text ${body": the placeholder has no closing '}'.
Подстановка начинается с ${ и кончается на }, которая её уравновешивает. } внутри литерала в кавычках принадлежит литералу, поэтому ${concat('a', '}', 'b')} это одна подстановка. Простая { ничего не открывает, поэтому внешние скобки в {${header.a}} остаются текстом; это важно для JSON, записанного шаблоном. Пустая ${} подстановкой не считается и остаётся как написана. Кавычка внутри имени заголовка это часть имени, так что ${header.O'Brien} читает заголовок O'Brien без всякого экранирования. А вот кавычка, которая открывает строковый литерал, обязана закрыться:
header.s == 'abc → unterminated string literal starting at position 13 within the expression "header.s == 'abc".
Буквальный ${ пишется как ${. Он ничего не открывает и выводится как ${; это ровно то, что нужно команде оболочки или синтаксису другого шаблонизатора внутри шаблона маршрута.
Главная строка таблицы последняя. ${, который ничто не закрывает, это ошибка с указанием позиции, и возникает она там, где шаблон компилируется: при построении маршрута в C#, при загрузке файла маршрута и ещё раньше, в проверке пакета (раздел 9). Молча вывести это как текст движок не может.
Подстановка может содержать другую подстановку ровно в одном месте: как аргумент вызова bean: (раздел 5). Во всех остальных местах ${ внутри выражения это ошибка с указанием позиции.
4. Чтение и сравнение: случаи, на которых обычно спотыкаются
Имена с точками и дефисами
Имена заголовков в интеграции редко бывают идентификаторами C#: Content-Type, JMSCorrelationID, x.request.id. Язык разрешает их одним правилом: сначала буквальное имя. header.dotted.key это заголовок с именем dotted.key, если такой есть, иначе член key заголовка dotted. Внутри шаблона то же правило оставляет имя с дефисом читаемым: ${header.Content-Type} даёт text/xml.
В голом выражении дефис был бы минусом, и header.Content-Type там было бы вычитанием. Вместо того чтобы вычислять бессмысленную разность, движок останавливается с ошибкой, которая говорит, как надо написать:
'header.Content-Type' at position 1 reads as a subtraction: a name with a dash is written ${header.Content-Type}
Изобретать синтаксис индексатора в кавычках для этого не пришлось.
Объекты, коллекции, словари
Объект читается одинаково, где бы он ни лежал: в заголовке, в свойстве или в теле. Члены ищутся по имени без учёта регистра, публичные поля тоже. Словарь отвечает и на .key, и на ['key']. header.user.Manager.Name даёт Bob, header.list[1] даёт 1, property.cfg.enabled даёт True. У вложений входящего сообщения свой корень: attachments.size и attachments[0].fileName.
Доступ к членам по умолчанию безопасен к null: член отсутствующего значения это null, а не исключение, так что отдельный ?. писать не нужно. Исключение упавшего обмена это такой же корень, как остальные: голое exception.Message читает сообщение, так же как ${exception.Message}.
Методы самого значения
Метод объекта, лежащего в заголовке или в теле, можно вызвать в любой позиции, в условиях тоже:
Где
Текст
Результат
условие
header.s.length() > 10
True
шаблон
${header.s.toUpper()}
HELLO WORLD
значение
header.list.Count
3 (свойство, без скобок)
Аргумент метода сохраняет свои точки, поэтому header.list.contains(5.0) и header.names.contains('a.b') передают аргумент целиком. Если объекта нет (null), результат тоже null. Какие методы существуют, зависит от значения, а значение известно только с приходом сообщения, поэтому о неудачном вызове сообщается на сообщении, исключением ExpressionEvaluationException с названием вызова и позицией:
header.o.Boom() at position 1 threw InvalidOperationException: boom'Nope' is not a method of String at position 1 (in header.s.Nope()).
Метод, бросивший исключение, никогда не превращается в пустое значение, которое течёт дальше по маршруту.
Сравнение: точное, без учёта регистра, в диапазоне
Условие
Результат
header.s == 'hello world'
False
header.s =~ 'hello world'
True
header.a =~ '42'
True
header.a range '1..42'
True
header.price range '2.5..3'
True
header.a not range '1..10'
True
header.missing range '1..10'
False
== для двух строк точное, с учётом регистра. =~ это сравнение без учёта регистра из Apache Camel: две строки сравниваются без учёта регистра, порядково, без участия культуры; любая другая пара сравнивается как ==. Числа разных типов равны, когда равны их значения: 3 == 3.0 и 3 == '3' истинны, 3 == 5.0 ложно.
range 'from..to' истинно для числа между обоими концами включительно, как в Camel, а not range наоборот. Правая сторона это литерал в кавычках, который читается при компиляции выражения, поэтому неправильный диапазон ('1-10') или диапазон, где начало больше конца ('10..1'), это ошибка при загрузке и в проверке пакета, а не фильтр, который молча ничего не пропускает. Отсутствующее значение не входит ни в какой диапазон. Текст, который не является числом, это ошибка с его названием:
header.s range '1..10': the value 'Hello World' (String) is not a number.
range считается оператором только после операнда; в остальных местах это имя, так что header.range читает заголовок с именем range.
JSON и XML в теле
jpath следует RFC 9535 через JsonPath.Net: .name, ['name'], [0], срезы, [*], .., фильтры вроде [?(@.price < 10)] и стандартные функции. xpath читает XML. Внутри ${...} путь можно писать без кавычек:
${jpath($.order.id)} → 7id=${jpath($.order.id)} total=${jpath($.order.total)} → id=7 total=150.5${xpath(/order/id)} → 7${jsonpath:$.order.id} → 7${xpath:/order/id} → 7
Две последние строки это написания Apache Camel: ${jsonpath:path} и ${xpath:path} значат jpath('path') и xpath('path'). Каждое такое написание занимает свою ${...}, путь идёт до закрывающей скобки, а вокруг подстановки может стоять текст. Маршрут, перенесённый из Camel, продолжает работать.
В голом выражении и в условии путь берётся в кавычки, jpath('$.order.id'), потому что $ и / там операторы. Без кавычек выражение не компилируется, и опять же падает при построении маршрута, а не на сообщении.
Имена файлов: ${file:name}
Маршруту, который обрабатывает файлы, обычно нужно имя файла: чтобы отправить его в архив, назвать ответ, записать в лог. Язык файлов Camel здесь есть: ${file:key} занимает свою подстановку, ключ идёт до закрывающей скобки, а читает он заголовки, которые потребитель файлов кладёт в сообщение. Вокруг может стоять текст, как во второй строке таблицы. При redbFile.Name = "orders/a.xml" и redbFile.NameOnly = "a":
Шаблон
Результат
${file:name}
orders/a.xml
archive/${file:name.noext}.done
archive/a.done
Ключ
Заголовок
name, onlyname
redbFile.Name
name.noext, onlyname.noext
redbFile.NameOnly
ext, name.ext
redbFile.Extension, без точки (xml)
parent
redbFile.Parent
path, absolute.path
redbFile.AbsolutePath
relative.path
redbFile.RelativePath
length, size
redbFile.Length
modified
redbFile.LastModified
У сообщения, пришедшего не из файла, таких заголовков нет, и ключ даёт пустоту. Ключ, которого нет в таблице, это ошибка при построении маршрута, и в ней перечислены все ключи. Собственные параметры потребителей файлов (moveTo, preMove, moveFailed, doneFileName, idempotentKey и doneFileName у S3) принимают те же ключи через ту же таблицу и дают те же значения, прочитанные из обрабатываемого файла. Там ключ сравнивается без учёта регистра, как эти параметры работали всегда; в шаблоне он сравнивается точно, как в Camel.
5. За пределами сообщения: bean:, ref:, env:
bean:, вызов собственного кода
Рано или поздно маршруту нужно решение, которое язык выражать не должен: проверка лояльности, расчёт цены, правило, которое живёт в сервисе. Язык вызывает метод объекта, зарегистрированного в контексте маршрута, в любой позиции: в условии, в значении, в шаблоне.
В примерах реестр с двумя бинами, pricing и pricing-rules, у каждого Gross(int qty, double price), IsGold(string name), Badge(string name, int points) и Touch(string).
Выражение
Результат
bean:pricing.Gross(header.b, header.price)
7.5
${bean:pricing.Gross(${header.b}, ${header.price})}
7.5
bean:pricing-rules.Badge(header.user.Name, 7)
Ann:7
bean:pricing.Gross(header.b, 2) - 1
5
bean:pricing.Touch('x')
null
Условие
Результат
bean:pricing.IsGold(header.user.Name)
True
header.flag && bean:pricing.IsGold('Bob')
False
Шаблон
Результат
total ${bean:pricing.Gross(header.b, 2)} EUR
total 6 EUR
Вызов бина полноправный участник выражения: его результат можно сравнить, соединить через &&, использовать в арифметике. В файле маршрута это выглядит так:
Правила, все они видны в таблицах выше:
-
Имя это имя в реестре без
#, в нём могут быть дефисы (pricing-rules). -
Метод обязателен. Просто
bean:pricingэто ошибка.bean:pricing.Methodбез скобок вызывает метод без аргументов, а дефис после него это минус:bean:svc.Count-1вызываетCountи вычитает единицу. -
Аргументы это выражения языка. Внутри подстановки
${bean:...}аргумент можно обернуть в${...}, и это значит то же, что без обёртки; это форма из Camel, и она работает. В голом выражении аргументы пишутся без обёртки.${там делает весь текст шаблоном (он выводится как текст, а не вызывается), и проверка пакета предупреждает ровно об этой ошибке. -
Связывание идёт по имени метода (без учёта регистра) и числу аргументов. Параметр типа
IExchange,IMessageилиCancellationTokenподставляется связыванием и в счёт не идёт. Каждый аргумент приводится к типу своего параметра. -
Результат сохраняет тип. Метод
voidдаётnull, который читается как «нет» или пустота, как в Apache Camel. -
Поиск ленивый и свой у каждого контекста. Бин ищется при первом вычислении вызова в контексте маршрута, и найденный метод запоминается для этого контекста. Скомпилированный делегат общий через кэш по тексту, но каждый контекст находит свой бин, поэтому два контекста с разными объектами
pricingникогда не видят чужого. Отсутствующий бин или метод это ошибка с указанием вызова. -
Двоеточие касается обеих сторон.
bean:считается вызовом, только когда двоеточие примыкает и кbean, и к имени. В тернарном операторе, где ветка это переменная с именемbean, оставляйте пробелы:flag ? bean : other.
Только синхронно, и почему
Выражение вычисляется синхронно: шаблон выводится в строку прямо сейчас, значение считается прямо сейчас. Поэтому метод, возвращающий Task, Task, ValueTask или ValueTask, отклоняется с советом вызвать его шагом: в разметке или .Bean("#name", "...") в C#. То же правило действует в языке Simple из Apache Camel. Блокировка на задаче внутри выражения спрятала бы остановку потока туда, где её никто не ищет, поэтому язык такой возможности не даёт.
Method() в C#, и где ждут Task
Method(reference, spec) это типизированная форма того же вызова, аналог method() из Camel:
From("direct://orders") .Filter(Method("#loyalty", "IsGold(${header.customer})")) // условие .SetBody(Method("#pricing", "Gross(${header.qty}, 2.5)")) // значение, тип сохраняется .EndFilter() .Validate(Method("#rules", "IsAllowed(${header.customer})"), "not allowed");
Есть одно место, где асинхронный код уместен: условие. Условия в redb.Route это предикаты, которые движок ожидает (MatchesAsync), поэтому в Filter, When и Validate метод Method(), возвращающий Task, честно ожидается. В позиции значения вызов синхронный, как и функция bean:. Validate к тому же принимает любое выражение и читает его как условие, так же как Filter: Validate(Expr("header.a > 0")).
ref:, сам объект из реестра
Иногда маршруту нужен сам зарегистрированный объект, а не вызов его метода: лимит, объект настроек, значение для передачи в другой метод. ref:name это объект, зарегистрированный под именем name, как ${ref:name} в Camel:
Выражение
Результат
ref:limits.max
100
ref:limits.max - header.b
97
${ref:limits.max}
100
header.a < ref:limits.max (условие)
True
max ${ref:limits.max} (шаблон)
max 100
Имя это вся цепочка букв, цифр, _, . и - после двоеточия, так что ref:limits.max это объект, зарегистрированный как limits.max, а не член объекта limits. Перед минусом ставьте пробел: ref:limit-1 это имя limit-1, ref:limit - 1 это вычитание. Сам ref: ничего из объекта не читает. Подстановка на всю строку ${ref:name} сохраняет объект, так что заголовок, заданный из неё, содержит сам экземпляр; с текстом вокруг шаблон выводит его ToString(). Чтобы воспользоваться членом, передайте объект в метод: bean:fmt.Describe(ref:chat.settings). Отсутствующее имя это ошибка с его названием, поиск идёт один раз на контекст маршрута, а двоеточие должно касаться обеих сторон, как и у bean:.
env:, окружение процесса
env:NAME это переменная окружения ОС NAME, как ${env:NAME} в Camel: ${env:BILLING_TOKEN} в шаблоне, env:REGION == 'eu' в условии. Незаданная переменная это null и выводится пустотой. Читается она при каждом вычислении.
Для значения, которое может прийти из конфигурации или из окружения, есть второй механизм, и он не часть языка выражений: заполнители конфигурации {{BILLING_TOKEN}} и {{key:default}}. Они разрешаются до запуска маршрута в URI конечных точек, в enabled=, в значениях бинов и в value= у , и . Сначала читается IConfiguration хоста (окружение, appsettings.json, секреты, в порядке, заданном хостом), потом свойства контекста, и окружение ОС только тогда, когда у контекста нет IConfiguration. Ключ без значения и без значения по умолчанию это ошибка с его названием.
6. Ловушка сравнения и кто её ловит
Одна ошибка заслуживает отдельного раздела, потому что её в первый же день делает каждый, кто пришёл из Camel:
.Filter("${header.kind} == 'order'") // всегда истина.Filter("header.kind == 'order'") // что имелось в виду
Первая строка это шаблон. Он выводится в текст вроде refund == 'order', текст не пустой, а непустая строка это истина. В Simple такая форма обычный способ записать предикат; в redb.Route строка с ${...} это строка, а сравнение пишется целиком, либо голым, либо полностью внутри скобок. Язык здесь последователен, но последовательность не помогает тому, кто печатает по привычке. Поэтому проверка пакета предупреждает об этом прямо у атрибута, и для ==, и для операторов Camel =~ и range:
warning: (line,col): expr="${header.kind} == 'order'" compares OUTSIDE ${…}: a condition holding a placeholder is rendered to text first, and non-empty text is true whatever it says. Write the comparison without the braces (header.kind == 'order') or entirely inside them.
Так же она предупреждает о родственных формах: вызове bean: с аргументами ${...} вне подстановки и имени ref: вне ${...} в тексте, где есть подстановки (раздел 9).
7. Шаблоны это данные: песочница
В redb.Route есть второй вид шаблонов: шаблоны полезной нагрузки из пакета redb.Route.Templates, документы с {{ ... }}, которые выводят тело сообщения. Их текст часто приходит из конфигурации, базы данных или от партнёра, а не от разработчика. Данные не должны выполнять код.
Поэтому шаблон полезной нагрузки выводится внутри песочницы (ExpressionSandbox). В ней объекты показывают свои свойства и поля, встроенные функции работают, встроенные строковые помощники работают. expr('x') вычисляет x ровно так, как ${x} в шаблоне маршрута, но под песочницей шаблона. Вызов метода, который выполнил бы код, отклоняется, как и выходы за пределы сообщения: bean:, ref: и env:. Прогон на текущем коде, где у объекта тела метод Pwn() ставит флаг:
{{ body.Name }} → ann{{ expr('upper(body.Name)') }} → ANN{{ expr('body.Pwn()') }} → ExpressionSandboxViolationException: Method 'Pwn' on Probe cannot be called here: the expression runs in a sandbox (a template argument or expr()), where objects expose their members but never code. (флаг остался false: метод не выполнился){{ expr('bean:rules.IsGold(body.Name)') }} → error: Method 'IsGold' on TemplateGoldRules cannot be called here: ...{{ expr('ref:rules') }} → error: 'ref:rules' cannot be read here: the expression runs in a sandbox (a template argument or expr()), which reads the message and its values, not the registry or the process environment.{{ expr('env:PATH') }} → error: 'env:PATH' cannot be read here: ... (то же предложение)
Песочница действует на весь вывод: на аргументы шаблона и на каждый expr(...) в нём. Поэтому шаблон полезной нагрузки не может ни вызвать ваши сервисы, ни прочитать окружение процесса, кто бы его ни написал. Выражения маршрута, которые разработчик пишет в C# или в файле маршрута, это код, и в песочницу они не попадают; шаблоны полезной нагрузки это данные, и попадают.
8. Свои корни: резолверы префиксов
header., property., body и exception. это встроенные корни. Модуль может добавить свои, чтобы маршрут читал доменные данные так же естественно, как заголовок:
context.RegisterPrefixResolver("order.", (exchange, path) => orders.Read(exchange, path));
Прогон с резолвером, который возвращает 150.5m для total и true для vip:
Где
Текст
Результат
подстановка на всю строку
${order.total}
150.5, decimal
смешанный шаблон
total=${order.total}
total=150.5
условие
order.vip
True
условие
order.total > 100
True
Резолвер работает в любой позиции, а подстановка на всю строку сохраняет возвращённый им тип. Порядок поиска фиксирован: сначала встроенные корни, потом резолверы контекста маршрута, которому принадлежит обмен, потом общие на процесс (ExpressionResolver.RegisterPrefixResolver). Прогон это подтверждает: резолвер, зарегистрированный для header., не заслоняет настоящий заголовок. Резолвер, зарегистрированный на контексте маршрута, живёт и умирает вместе с этим контекстом, так что выгруженный модуль ничего не оставляет в процессе. Регистрация делается в коде, обычно при инициализации контекста модуля; элемента разметки для неё нет.
9. Проверка пакета: ошибки со строкой и колонкой до развёртывания
Компиляция на Start() уже означает, что сломанное выражение никогда не встретит живой трафик. Файл маршрута можно проверить ещё раньше. Проверка пакета (redb-route-xml check, а также pack, который выполняет те же проверки и собирает только чистый пакет) компилирует каждое выражение каждого файла маршрута, голое и каждую ${...}, ровно так, как его компилирует движок. Выражение, которое движок отверг бы, это ошибка у его атрибута, со строкой и колонкой, словами движка. Для каждого вызова bean: и имени ref: она проверяет, что их объявляет какой-нибудь пакета, а с ключом --bin загружает сборки модуля и сверяет каждый вызов bean: с типом объявленного бина.
Вот настоящий вывод проверки на тестовых пакетах (путь проекта сокращён):
error: routes/main.route.xml(5,14): expr= unclosed '${' at position 7 of "total ${header.amount": the placeholder has no closing '}'.error: routes/main.route.xml(6,25): expr= a bean: call names its method: bean:rules.Method(...) (position 1 within the expression).warning: routes/main.route.xml: '#pricing' is not declared by any of this package — either module code registers it at startup (fine), or it is a dangling reference.error: routes/main.route.xml(8,13): expr= bean: '#rules' has no public method 'Badge' taking 1 argument(s) (IExchange, IMessage and CancellationToken parameters are supplied by the binding and do not count).error: routes/main.route.xml(9,13): expr= bean:rules.CheckAsync returns Task`1: a bean: call in an expression is synchronous. Call an asynchronous method as a step: or .Bean("#rules", "CheckAsync(...)").warning: routes/main.route.xml(10,13): expr= holds a bean: call outside ${...} with ${...} arguments: the text is a template and renders instead of calling (in a condition it is always true). Write the arguments bare, bean:svc.Check(header.x), or the whole call as one placeholder, ${bean:svc.Check(${header.x})}.warning: (line,col): expr= holds a ref: name outside ${...} in a text with ${...}: the text is a template and renders ref:name as text instead of reading the object (in a condition it is always true). Write the whole expression bare, ref:limit > header.x, or as one placeholder, ${ref:limit > header.x}.error: (line,col): expr= Error compiling expression 'header.b == 3 garbage' using AST: unexpected 'garbage' at position 15 within the expression: the expression ends before it.warning: (line,col): expr= "header.b 3" reads as the text itself, not as an expression: '3' at position 10 does not continue it; in a condition the text is always true.
Каждое сообщение говорит, что не так и как написать правильно. Обратите внимание на разницу между ошибками и предупреждениями. Бин, который не объявлен ни одним пакета, это только предупреждение: код модуля вполне может зарегистрировать его при старте, проверка этого знать не может и называет обе возможности. Метод с неверным числом аргументов или метод, возвращающий задачу, это ошибка, потому что никакая ситуация во время выполнения его не исправит. Текст после законченного выражения это ошибка, и здесь, и при построении маршрута. header.b 3 это лишь предупреждение: по правилу для голого текста это законный литерал, но начинается он как обращение к значению, и проверка говорит, чем он окажется на самом деле.
Про редактор, чтобы быть точным. Расширение redb Route XML для VS Code проверяет разметку файла маршрута по схеме прямо при наборе: элементы, атрибуты, обязательные значения. Для схемы атрибуты-выражения это просто строки, поэтому ошибки выражений приходят от проверки пакета и от загрузки маршрута, а не подчёркиванием в редакторе. Следующий выпуск расширения добавляет подсказку имён бинов после bean: и ${bean:, имена берутся из объявлений самого файла и из context.xml модуля.
10. Где язык сильнее и почему
Каждый пункт ниже опирается на что-то, показанное выше.
-
Компиляция это единственный режим. Нет пути интерпретации, в который можно провалиться, и нет отдельного компилируемого диалекта со своими ограничениями. Любое правильное выражение, включая вложенные функции и обращения к реестру, становится IL при построении маршрута.
-
Ошибки появляются до трафика, причём дважды. Проверка пакета сообщает о сломанном выражении у атрибута, со строкой и колонкой, ещё до сборки пакета. Если что-то проскочило,
Start()бросит исключение до первого сообщения. Сломанное условие никогда не превращается в тихое «нет». -
Опечатка это ошибка, а не значение. Недоразобранная арифметика, текст после законченного выражения, незакрытая кавычка, имя с дефисом без скобок, неправильный диапазон, инкремент не-числа и метод, который бросает исключение или не существует, останавливаются с сообщением, которое называет место. Ни одно из них не даёт тихо правдоподобный результат.
-
Вызовы бинов сверяются с настоящими типами. С
--binпроверка убеждается, что метод существует, принимает столько аргументов и синхронный. Опечатка в имени метода это ошибка сборки, а не инцидент в бою. -
Значения сохраняют типы. Подстановка на всю строку возвращает
int,bool,decimal, объект из реестра, что бы ни вычислило выражение или резолвер. Следующие шаги получают данные, а не текст, который надо разбирать снова. -
Смысл не зависит от машины. Литералы разбираются, а значения выводятся в инвариантной культуре, суффиксы формата тоже;
=~сравнивает порядково; слова истинности только английские. -
Защитные проверки работают как в C#.
AND,ORи??вычисляются с коротким замыканием, поэтому проверка слева защищает вызов справа. -
Одно правило истинности. Условие, операнд
ANDиlogical()читают значение одинаково, во всей библиотеке. -
Инкременты меняют обмен.
property.attempt++это счётчик, а не вычисленное значение, которое кто-то должен записать обратно. -
Данные никогда не выполняют код. Шаблоны полезной нагрузки выводятся в песочнице, где вызовы методов, обращения к реестру и к окружению отклоняются понятным исключением.
-
Расширения ограничены своим контекстом. Резолвер префикса, зарегистрированный на контексте маршрута, уходит вместе с этим контекстом.
-
Документация не может разойтись с кодом. Каждый помеченный пример руководства выполняется при каждом запуске тестов. Изменения в том, как сканируются шаблоны, сверялись со снимком всех 956 различных шаблонов, найденных в дереве redb.Route, и каждая изменившаяся строка была просмотрена до того, как изменение попало в код.
11. Сравнение с Simple из Apache Camel
Сходство с Simple намеренное, и тот, кто пришёл из Camel, узнает почти каждую форму. Различия, форма за формой:
Camel Simple
redb.Route
${header.x}, ${exchangeProperty.x}, ${body}
${header.x}, ${property.x}, ${body}
${header.x} == 'a' как предикат
header.x == 'a'; ${...} вокруг операнда сначала выводит текст
${bean:name.method(${body})}, ${bean:name?method=m}
${bean:name.method(${body})} или голое bean:name.method(body); форма ?method= относится к URI конечной точки bean:
method(ref, "name")
Method("#ref", "name(args)")
${ref:x}
${ref:x} или ref:x; вся цепочка после двоеточия это имя
${header.x} =~ 'abc'
header.x =~ 'abc': две строки без учёта регистра, любая другая пара как ==
${header.n} range '100..199', not range
header.n range '100..199', not range; литерал проверяется при загрузке
${env:X}
${env:X} или env:X; {{X}} сначала читает конфигурацию
${jsonpath:$.x}, ${xpath:/x}
так же, или jpath('$.x') / xpath('/x')
${file:name}, ${file:name.noext}, ${file:size}
так же, поверх заголовков redbFile.*
${date:now:yyyyMMdd}
dateformat(now(), 'yyyyMMdd')
экранирование ${ и $${
${; $${ это знак доллара, за которым идёт подстановка
Что есть в Simple и чего сегодня нет в redb.Route, прямо: оператора ?., которому здесь нечего делать, потому что доступ к членам и так безопасен к null, и написания ${date:...}, работу которого делает dateformat(now(), ...).
12. Известные ограничения
Что
Сегодня
jpath($.x) / xpath(/x) голые и без кавычек
не компилируется; возьмите путь в кавычки или напишите внутри ${...}
round(2.5)
2: округление половины к чётному, как в .NET
обратная косая черта как текст прямо перед подстановкой (\${header.x})
способа записать нет
вызов метода у значения, который бросает исключение или не существует
ошибка на сообщении, а не при сборке: тип значения известен только тогда
Список короткий, потому что ошибки, которые обычно проходят молча (текст после выражения, имя с дефисом без скобок, инкремент не-числа, метод с исключением), здесь становятся ошибками с позицией. Строки выше это то, что осталось.
Что вынести
Первая часть доказывала, что строки выражений вообще стоит иметь. Эта показывает, что за ними стоит. Строку разбирают настоящий токенизатор и парсер в дерево выражений LINQ и компилируют один раз, причём AND, OR и ?? замыкаются коротко, как в C#. Недописанное выражение это ошибка с позицией, а не значение. Подстановка кончается ровно там, где уравновешиваются её скобки, а ${ записывает её буквально. Вызов бина связывается с настоящими типами и проверяется до развёртывания, а ref: и env: достают реестр и окружение так, как ждут пришедшие из Camel. Шаблоны с данными не могут ни выполнить код, ни прочитать окружение. Модуль может добавить свои корни, не оставляя следов в процессе. Каждый результат в руководстве выполняется тестом.
Само руководство, со всеми таблицами и правилами из этой статьи, это EXPRESSIONS.md в репозитории. Язык поставляется в составе redb.Route на NuGet.
Если было полезно, ⭐ на GitHub поможет другим это найти.
Другие мои статьи — redb.ru/articles, ещё — на Хабре.
Источник: habr.com