Язык выражений redb.Route, часть 2: от строки до IL, обращения к реестру и проверка маршрутов до запуска

redb.Routeredb.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. На каждом сообщении остаётся только вызвать делегат.

Вот путь, который проходит голое выражение.

  1. Токенизатор. Текст режется на лексемы: имена, числа, строковые литералы, операторы, скобки. Пробелы между лексемами исчезают здесь же, поэтому header.a>10 и header.a > 10 это одно и то же выражение, а условие, перенесённое на другую строку в XML-атрибуте, читается правильно.

  2. Парсер. Парсер рекурсивного спуска строит синтаксическое дерево. Уровни приоритета привычные, от самого слабого к самому сильному: тернарный оператор, ??, логика, сравнение, сложение и вычитание, умножение и деление, унарные операторы и, наконец, первичные формы: имена, литералы, вызовы, скобки. Парсер берёт весь текст: лексема, оставшаяся после законченного выражения, это ошибка с позицией, а не то, что молча отбрасывается.

  3. Дерево выражений. Каждый узел синтаксического дерева переводится в System.Linq.Expressions, те самые деревья выражений, которые строят LINQ-провайдеры и Entity Framework. Чтение заголовка становится обращением к словарю, upper(...) вызовом метода. AND и OR становятся AndAlso и OrElse поверх приведения к истинности, а ?? становится Coalesce, поэтому их правая сторона выполняется, только когда левая не решила дело. XOR нужны обе стороны, и он вычисляет обе.

  4. Компиляция. 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. Где язык сильнее и почему

Каждый пункт ниже опирается на что-то, показанное выше.

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

  2. Ошибки появляются до трафика, причём дважды. Проверка пакета сообщает о сломанном выражении у атрибута, со строкой и колонкой, ещё до сборки пакета. Если что-то проскочило, Start() бросит исключение до первого сообщения. Сломанное условие никогда не превращается в тихое «нет».

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

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

  5. Значения сохраняют типы. Подстановка на всю строку возвращает int, bool, decimal, объект из реестра, что бы ни вычислило выражение или резолвер. Следующие шаги получают данные, а не текст, который надо разбирать снова.

  6. Смысл не зависит от машины. Литералы разбираются, а значения выводятся в инвариантной культуре, суффиксы формата тоже; =~ сравнивает порядково; слова истинности только английские.

  7. Защитные проверки работают как в C#. AND, OR и ?? вычисляются с коротким замыканием, поэтому проверка слева защищает вызов справа.

  8. Одно правило истинности. Условие, операнд AND и logical() читают значение одинаково, во всей библиотеке.

  9. Инкременты меняют обмен. property.attempt++ это счётчик, а не вычисленное значение, которое кто-то должен записать обратно.

  10. Данные никогда не выполняют код. Шаблоны полезной нагрузки выводятся в песочнице, где вызовы методов, обращения к реестру и к окружению отклоняются понятным исключением.

  11. Расширения ограничены своим контекстом. Резолвер префикса, зарегистрированный на контексте маршрута, уходит вместе с этим контекстом.

  12. Документация не может разойтись с кодом. Каждый помеченный пример руководства выполняется при каждом запуске тестов. Изменения в том, как сканируются шаблоны, сверялись со снимком всех 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

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев