Выражения в .NET-маршрутах: один язык для условий, значений и шаблонов, скомпилированный в IL

redb.Route

Маршрут интеграции наполовину состоит из мелких решений: пропустить сообщение или нет, куда его направить, что положить в заголовок, как назвать топик. В коде это лямбды. Много лямбд. Каждая тянет за собой e.In.Headers.TryGetValue, каст, проверку на null, и через полгода маршрут на двадцать шагов читается как обработчик исключений, а не как описание потока.

В Apache Camel для этого есть язык Simple: условие пишется строкой, ${header.amount} > 1000, и маршрут снова похож на маршрут. redb.Route приносит ту же идею в .NET, но устроена она иначе: одна грамматика для условий, значений и шаблонов, компиляция строки в делегат через деревья выражений, единое правило истинности и отказ при сборке маршрута, а не на первом сообщении. Ниже показываю, как это выглядит, что умеет язык, и где именно он отличается от Simple.

Зачем вообще строки, если есть лямбды

Лямбда точна, но она непрозрачна. Вот фильтр на лямбде:

.Filter(e =>
    e.In.Headers.TryGetValue("amount", out var a) && a is int amount && amount > 1000
    && e.In.Headers.TryGetValue("region", out var r) && r is string region && region == "eu")

А вот тот же фильтр строкой:

.Filter("header.amount > 1000 AND header.region == 'eu'")

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

Цена строки в большинстве фреймворков известна: интерпретация на каждом сообщении, разбор в рантайме, ошибки, которые всплывают в проде. Дальше про то, как эта цена снята.

Три позиции, один язык

Первое, что нужно знать про язык: смысл строки определяет позиция в DSL, а не её содержимое.

Позиция Пример Что происходит со строкой
Строка .SetHeader("note", "a>b") ничего, это литерал
Шаблон .SetHeaderExpression("key", "${header.region}-${header.priority}") текст как есть, внутри ${...} вычисление
Выражение .SetBody(Expr("upper(body)")) вся строка вычисляется
Условие .Filter("header.amount > 1000") выражение, результат читается как «да» или «нет»

Строка в строковой позиции никогда не разбирается. Rabbit.Queue("q").Password("p@ss(1)") это пароль, а не вызов функции, и фреймворк не пытается угадать. Угадывание по содержимому было бы удобно ровно до первого пароля с символом > внутри.

Внутри ${...}, в Expr() и в условии язык один и тот же. Это не оговорка в документации, это проверяется тестом: корпус из двухсот с лишним форм прогоняется через все три позиции, и колонки обязаны совпадать построчно. header.a > 10 в фильтре, в ${header.a > 10} и в Expr("header.a > 10") компилируется одним парсером в одно дерево.

Что умеет язык

Все примеры ниже сняты с живого прогона на одном обмене: header.a = 42, header.b = 3, header.s = "Hello World", header.price = 2.5, header.user это объект с полями Name = "Ann", Age = 30, Manager, в свойстве items лежит список из трёх строк.

Доступ к данным

header.a                    → 42
header.user.Name            → "Ann"
header.user.Manager.Name    → "Bob"
header.list.Count           → 3
header.list[1]              → 1
property.cfg.enabled        → true
property.items[0]           → "p"
body.Age                    → 30
exception.Message           → "boom"   (внутри ${} и в условии)

header, property и body ведут себя одинаково: свойства, публичные поля, вложенные объекты, коллекции, индексаторы, словари, регистр имени не важен. Если тело сообщения это ваш класс, body.Order.Total читается без каста.

Имена с точкой и дефисом решаются правилом «сначала литеральное имя»: ${header.Content-Type} находит заголовок Content-Type, потому что такой заголовок есть, и только если его нет, текст разбирается как выражение. Никакого индексатора с кавычками для этого не понадобилось.

Арифметика и инкременты

header.a + header.b         → 45
header.a / 4                → 10.5
(header.a + header.b) * 2   → 90
header.price * 2            → 5

Инкременты меняют значение прямо в обмене, и у заголовков, и у свойств:

header.a++      → 42, заголовок стал 43
++header.a      → 43
property.counter--

Это удобно в циклах: .LoopWhile("property.attempt < 3") с ${property.attempt++} внутри читается как обычный код.

Сравнения и логика

header.a > 10                          → true
header.a>10                            → true   (пробелы не значимы)
header.s == 'Hello World'              → true
header.missing == null                 → true
header.a >= 42 AND header.b < 5        → true
header.a > 100 OR header.b < 5         → true
header.a > 10 XOR header.b > 10        → true
NOT header.flag                        → false
header.a > 10 && header.b < 5          → true   (&& и || тоже понимает)
(header.a > 10) AND (header.b < 5)     → true
header.s == 'a > b'                    → сравнение с текстом, оператор внутри кавычек не оператор

Пробелы вокруг операторов не несут смысла. header.a>10, header.a > 10, header.a\t>\t10 и перенос строки посреди условия в XML-конфигурации это одно выражение. На этом стоит остановиться, потому что в Simple ровно наоборот: документация Camel требует пробелы вокруг оператора, и без них условие не разбирается. Здесь такой оговорки нет вообще, и не потому что её забыли написать: настоящий лексер не знает, что такое «незначащий пробел», он просто не видит его между токенами.

Тернарник и ??

header.a > 10 ? 'big' : 'small'   → "big"
header.flag ? header.a : header.b → 42
header.missing ?? 'default'       → "default"

Строки

Двадцать три встроенные функции плюс десять методов в точечной записи. Обе формы работают:

upper(header.s)                         → "HELLO WORLD"
concat(header.s, '!')                   → "Hello World!"
substring(header.s, 6)                  → "World"
replace(header.s, 'World', 'Route')     → "Hello Route"
contains(header.s, 'World')             → true
startswith(header.s, 'Hello')           → true
length(header.s)                        → 11

header.s.toUpper()                      → "HELLO WORLD"
header.s.substring(0, 5)                → "Hello"
header.s.replace('o', '0')              → "Hell0 W0rld"
header.s.indexOf('W')                   → 6
header.s.Length                         → 11

Метод, которого нет в списке, ищется рефлексией на CLR-объекте. Так header.user.Manager.Name и header.list.Count работают без специальной поддержки, а ваш собственный класс в теле сообщения получает доступ ко всем своим публичным членам.

Коллекции, числа, даты

count(property.items)                       → 3
sum(property.nums)                          → 10
avg(property.nums)                          → 2.5
min(property.nums)                          → 1
max(header.a, header.b)                     → 42
abs(header.neg)                             → 7
round(2.567, 2)                             → 2.57

dateformat(header.when, 'yyyy-MM-dd')       → "2026-08-28"
dateadd(header.when, 1, 'day')              → 2026-08-29 10:30
dateformat(dateadd(header.when, 1, 'day'), 'yyyy-MM-dd')   → вложение работает

JSON и XML прямо из тела

${jpath($.order.id)}                → "7"
${xpath(/order/id)}                 → "7"
id=${jpath($.order.id)} total=${jpath($.order.total)}   → "id=7 total=150.5"

Пути компилируются один раз и кэшируются, как и всё остальное. В голом выражении путь берётся в кавычки, jpath('$.order.id'), потому что $ и / там операторы.

Шаблоны: подстановка, которая знает типы

Шаблон это текст с дырками. Вне дырок литерал, внутри тот же язык:

Hello, ${header.s}!                                   → "Hello, Hello World!"
a=${header.a}, b=${header.b}, sum=${header.a + header.b}  → "a=42, b=3, sum=45"
${property.cfg.limit > 3 ? 'over' : 'under'}          → "over"
${count(property.items)} items                        → "3 items"
[${header.missing}]                                   → "[]"

Одна деталь, которая экономит много кастов. Если вся строка это один placeholder, результат сохраняет CLR-тип:

.SetHeaderExpression("n", "${header.a}")        // в заголовке int 42, а не строка "42"
.SetHeaderExpression("big", "${header.a > 10}") // в заголовке bool
.SetHeaderExpression("total", "${header.amount * header.qty}")  // число

Смешанный шаблон, где есть текст вне дырок, отдаёт строку, как и положено шаблону.

Условие: любое выражение плюс одно правило истинности

Фильтру, When, LoopWhile и Validate нужен bool. Они получают его от любого выражения по одному правилу, которое действует во всей библиотеке:

Значение Истинность
bool как есть
число != 0
строка булево слово true/false, 1/0, yes/no, on/off, иначе непустая значит истина
null ложь
любой объект истина

Поэтому работают и голые ссылки, и шаблоны, и явное приведение:

.Filter("header.flag")                  // bool в заголовке
.Filter("property.cfg.enabled")         // значение из словаря
.Filter("${header.vip}")                // шаблон как условие
.Filter("logical(header.count)")        // явное приведение тем же правилом
.Filter("header.user.Age > 18 AND header.user.Active")

Правило одно на всё: и граница DSL, и операнды AND/OR/NOT, и функция logical() считают одинаково. Звучит очевидно, но в этой библиотеке до недавнего времени правил было три, и Filter("${header.zero}") пропускал сообщение там, где Filter("logical(header.zero)") его резал. Теперь ноль это ложь, где бы он ни встретился.

Что происходит со строкой на самом деле

Здесь главное отличие от интерпретируемых языков.

Строка проходит токенизатор и парсер и превращается в AST. AST компилируется в System.Linq.Expressions, дерево выражений .NET, и дальше Expression.Compile() отдаёт делегат Func<IExchange, object?>, то есть настоящий IL. Разбор происходит один раз, при сборке маршрута. На каждом сообщении вызывается уже скомпилированный делегат, без токенов, без строк, без рефлексии по имени функции.

Скомпилированные делегаты лежат в ConcurrentDictionary с изоляцией по контексту, так что два маршрута с одинаковым текстом условия делят одну компиляцию, а разные контексты не мешают друг другу.

Из этого вытекает второе свойство: ошибка в условии это ошибка сборки. .Filter("header.amount >") бросает ExpressionCompilationException на Start(), до того как первое сообщение вошло в маршрут. Условие никогда не «молчит» и не превращается в постоянное «нет» на живом трафике.

Третье свойство: условие хранится как предикат, а не как делегат. Filter, When, Loop и Validate принимают IPredicate, кладут его в определение и на каждом сообщении ждут MatchesAsync. Предикат, который ходит в базу или в сервис, не блокирует поток. Делегат Func<IExchange, bool>, если вы всё-таки написали лямбду, заворачивается в предикат на входе, а не наоборот.

Как это меняет код маршрута

Обычный маршрут заказов с ветвлением, обогащением и динамическим адресатом:

From("direct://orders")
    .Filter("header.amount > 1000 AND header.region == 'eu'")
    .SetHeaderExpression("routeKey", "${header.region}-${header.priority}")
    .SetHeaderExpression("total", "${header.amount * header.qty}")
    .SetBody(Expr("upper(body)"))
    .Choice()
        .When("header.user.Age >= 18").To("direct://adult")
        .When("${header.vip}").To("direct://vip")
        .Otherwise().To("direct://default")
    .EndChoice()
    .LoopWhile("property.attempt < 3")
        .SetPropertyExpression("attempt", "${property.attempt + 1}")
        .To("http://flaky-service")
    .EndLoop()
    .Validate("header.amount > 0", "amount must be positive")
    .ToD("kafka://orders-${header.region}");

Здесь нет ни одного TryGetValue, ни одного каста, ни одной проверки на null. Каждая строка читается как утверждение о потоке. И всё это скомпилировано: header.amount > 1000 стал делегатом до первого сообщения, ${header.region}-${header.priority} стал конкатенацией двух вызовов, upper(body) стал вызовом метода.

Когда нужна композиция или что-то за пределами языка, предикаты собираются объектами:

.Filter(new HeaderExpression("amount").isGreaterThan(1000)
        .and(new HeaderExpression("vip").isEqualTo(true)))
.Filter(myAsyncPredicate)   // IPredicate с настоящим MatchesAsync
.Filter(e => e.In.getHeader<int>("amount") > 1000)   // лямбда никуда не делась

Восемнадцать готовых предикатов, от isBetween до regex и In, и все они комбинируются через and, or, not.

Где ещё живут выражения: EIP и адреса эндпоинтов

Фильтр это самый заметный потребитель языка, но не главный по объёму. Тот же движок стоит за каждым паттерном, которому нужно вычислить что-то по сообщению, и за адресами консюмеров и продюсеров.

Паттерны EIP

Паттерн Что вычисляется Пример
Content-Based Router ветка .When("header.type == 'vip'")
Message Filter пропуск .Filter("header.amount > 1000")
Splitter коллекция для разбиения .Split(Expr("body.Items"))
Routing Slip список адресов .RoutingSlip("${header.steps}")
Dynamic Router / ToD адрес по сообщению .ToD("kafka://orders-${header.region}")
Loop условие или счётчик .LoopWhile("property.attempt < 3"), .LoopExpression("${header.count}")
Validate условие .Validate("header.amount > 0", "amount must be positive")
Message Translator тело, заголовок, свойство .SetBodyExpression("${upper(body)}"), .TransformExpression("concat(body, '!')")
Delayer задержка в миллисекундах .DelayExpression("${header.backoff}")
Throttler лимит в период, по сообщению .ThrottleExpression("header.tier == 'gold' ? 100 : 10", TimeSpan.FromSeconds(1))
Log текст записи .Log("Processing outbox row ${header.id}")

Условие, значение и шаблон компилируются одним парсером, поэтому header.amount > 1000 в фильтре и ${header.amount > 1000} в логе это одно и то же дерево, а не два разных разбора с разными правилами.

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

.Log(LogLevel.Information)
    .Message("order ${header.orderId}: ${header.amount * header.qty} total")
    .Message("${count(property.items)} items, first is ${property.items[0]}")
    .Header("correlationId")
    .Property("attempt")
    .ShowRouteId()
.EndLog()

В журнал уходит одна запись вида [rId:orders] [p:attempt=2] [h:correlationId=…], дальше два сообщения, каждое на своей строке, и каждый ${...} в них вычислен тем же движком. .Log() без уровня открывает тот же скоуп на Information.

Адреса эндпоинтов: `$

Опция продюсера, в которой стоит ${...}, резолвится на каждом сообщении. Компилируется один раз при сборке эндпоинта, дальше вызывается делегат. Это работает и в строке URI, и во флюентном конструкторе, потому что конструктор строит тот же URI.

Kafka: ключ партиционирования из заголовка.

.To("kafka://orders?key=${header.tripId}")

// то же флюентно
.To(Kafka.Topic("orders").Key("${header.customerId}").Build())

SQL: параметры запроса из тела и заголовков, сам запрос статичен и параметризован.

From("direct://save")
    .To("sql:INSERT INTO audit(message, status) VALUES(@message, @status)"
        + "?dataSource=#main"
        + "&param.message=${body}"
        + "&param.status=${header.mode}");

HTTP: путь, хост и порт из сообщения.

.To("https://api.example.com/orders/${header.orderId}?method=PUT")
.To("https://${header.targetHost}:${header.targetPort}/api/${header.resource}?method=POST")

RabbitMQ, Firebase, файлы:

.To(Rabbit.Queue("orders").RoutingKey("${header.region}-key").Build())
.To("fstore://users?documentId=${header.userId}&merge=true")
.To("fbstorage://media-bucket?objectName=${header.fileName}&contentType=image/png")
.To(File.Write("out").FileName("${header.orderId}.json").Build())

Под капотом это одна точка: EndpointOptions.ResolveOption, и ей пользуются четырнадцать продюсеров, от Kafka и RabbitMQ до SMTP, MQTT, Redis, gRPC и Telegram. Плюс типизированные опции DynamicValue<T> в тринадцати наборах опций, где значение нужно не строкой, а числом или булевым: имя файла, документ в Firestore, ключ в S3, параметры SQL.

Две границы, о которых стоит знать. Первая: строка без ${} в опции это литерал, password=p@ss>1 никто не разбирает. Вторая: у консюмера, который сам порождает сообщения, в момент опроса обмена ещё нет, поэтому ${header...} не к чему применить. Например, param.* у SQL-консюмера резолвятся без сообщения, там работают только константы. Это не ограничение движка, а свойство момента: заголовок появляется вместе с сообщением.

Сравнение с Simple из Apache Camel

Simple это удачный язык, и сходство здесь намеренное: пришедший из Camel узнает header., body, ${...}, тернарник, contains. Различия структурные, и они стоят того, чтобы назвать их прямо.

Где стоит оператор. В Simple левая часть сравнения обязана быть в ${}, а оператор стоит снаружи: ${header.bar} > 100. Это правило из документации, не мой пересказ. В redb.Route выражение целиком либо голое, header.bar > 100, либо целиком внутри ${}. Форма ${header.bar} > 100 тоже допустима, но означает ровно то, что написано: шаблон, который рендерится в текст "42 > 100", и фильтр прочитает эту непустую строку как истину. Это не дефект, а следствие того, что строка есть строка, а выражение есть выражение. Хотите сравнение, пишите его целиком.

Пробелы. Simple требует пробелы вокруг операторов. redb.Route не требует. Для условия, которое приехало из XML-атрибута с переносом строки, это разница между «работает» и «не разбирается».

Компиляция. Simple интерпретируется в рантайме. Compiled-вариант, csimple, существует, но требует либо maven-плагина с генерацией исходников на сборке, либо jOOR для компиляции при старте (который, по документации, не работает с fat-jar в Spring Boot), заставляет писать подсказки типов вроде bodyAs(String), не поддерживает вложенные функции и null-safe оператор, а с Camel 4.19 помечен deprecated. В redb.Route компиляция в IL это единственный режим, ей не нужны плагины и подсказки типов, и вложенные функции в ней работают.

Один язык или два. В Simple значение и условие это разные конструкции: ${...} для подстановки, ${...} OP value для предиката. В redb.Route грамматика одна, а позиция в DSL решает, как прочитать результат. Отсюда меньше правил, которые нужно помнить, и меньше мест, где документация расходится с поведением.

Fail-fast. Битое выражение в redb.Route не стартует маршрут. Это не заслуга языка, а следствие компиляции при сборке: чтобы получить делегат, строку нужно разобрать целиком, и делать это в момент Start() естественно.

Чего в Simple есть, а здесь нет: null-safe оператор ?. и часть операторов Simple вроде range и =~. Первый заменяется ?? и тем, что несуществующий член даёт null, а не исключение; вторые не понадобились ни в одном из тридцати с лишним коннекторов.

Как это проверяется

Про язык выражений легко рассказывать и трудно гарантировать: правка в одном месте тихо сдвигает поведение в другом. Поэтому кроме обычных тестов у языка есть сетка характеризации. Корпус из двухсот с лишним форм, от header.a > 10 до dGVzdC1zZWNyZXQ== и black and white, прогоняется через все три позиции и сравнивается со снятым слепком. Любое изменение поведения печатается построчно: что было, что стало. Слепок обновляется только после того, как каждый сдвиг объяснён и записан в changelog.

Именно эта сетка вскрыла, что парсер читал десятичные литералы текущей культурой машины: max(2.5, 1) компилировался на en-US и падал на ru-RU. Теперь литералы разбираются инвариантно и закреплены тестом под тремя культурами. Семантика исходника не может зависеть от локали сервера.

Что взять с собой

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

Один язык на три позиции, одно правило истинности, паритет header/property/body по всем членам объекта, предикаты, которые ждут асинхронно. Если пришли из Camel, синтаксис знаком, а оговорок про пробелы и compiled-режим помнить не нужно.

Язык поставляется внутри redb.Route на NuGet, полный справочник форм с проверенными результатами лежит в репозитории рядом с кодом.

Если было полезно, ⭐ на GitHub поможет другим это найти.


Другие мои статьи — redb.ru/articles, ещё — на Хабре.