Встраивание и вызов из других языков
Rust: библиотека
[dependencies]
# `default-features = false` отключает фичу `cli`, а с ней clap, ariadne и ureq —
# библиотеке они не нужны. Замер на пустом проекте: 38 пакетов вместо 96. Фича
# включена по умолчанию только чтобы `cargo install aura-lang` давал бинарник
# `aura`.
aura-lang = { version = "0.4", default-features = false }
#![allow(unused)]
fn main() {
use aura_lang::facade::{eval_file, EvalOptions};
let opts = EvalOptions {
strict: true,
allow_read: vec!["config/".into()],
..Default::default()
};
let out = eval_file("config/app.aura".as_ref(), &opts)?;
let cfg: MyConfig = serde_json::from_value(out.json)?; // сразу в свои структуры
for w in &out.warnings {
log::warn!("{w}"); // Display: warning[W..]: ... at file:line:col
}
if let Some(lock) = out.updated_lockfile {
std::fs::write("config/aura.lock", lock)?; // писать или нет — решает хост
}
}
Права задаёт хост-приложение — конфиг не получает больше, чем вы разрешили.
Диагностики приходят структурированными (code, severity, file, line,
column, help) — рендерите в свой лог как удобно.
В async-контексте заворачивайте eval_file в spawn_blocking: он читает файлы и
вычисляет синхронно.
Чтение всего манифеста в свои типы
Когда манифест и есть конфигурация, читайте его целиком, сразу в свои
структуры - без JSON посередине и с тем же результатом, что печатает aura eval:
#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Device { board: Board, sensors: Vec<Sensor>, /* ... */ }
let device: Device = Script::builder()
.file("device.aura")
.allow_read("config") // файлы, которые он читает, рядом с ним
.env_values([("APP_ENV", "production")]) // ровно эти, и никакие другие
.load()?
.output()?;
}
env_values разрешает названные переменные с заданными значениями; env() берёт
их оттуда и никогда - из процесса, поэтому окружение самого сервиса не утечёт в
манифест, а тест может дать ему любое окружение, не трогая окружение процесса.
aura types --lang rust генерирует структуры для схем, объявленных в манифесте.
Каждый пример в репозитории читается так в тесте и сравнивается с выводом, на
котором зафиксирован CLI; cargo run --example read_device (а также
read_k8s_deploy, read_service_catalog) показывает это целиком.
Вызов скрипта: Script
eval_file возвращает JSON, а JSON не умеет хранить функции. Но pub def в Aura —
это значение, поэтому правила могут лежать в .aura-файле, а приложение —
вызывать их на каждый запрос, событие или порцию, меняя поведение правкой файла,
а не пересборкой. Для этого есть тип Script (D33).
#![allow(unused)]
fn main() {
use aura_lang::{Budget, Script};
#[derive(serde::Serialize)]
struct Event { device: String, temp_c: f64 }
#[derive(serde::Deserialize)]
struct Summary { total: u64, alerts: u64 }
let rules = Script::builder()
.file("telemetry.aura")
.budget(Budget::per_call().steps(5_000_000).memory_mb(32))
.load()?; // разбор, анализ, доказательство герметичности — один раз
let summarize = rules.function::<(&[Event],), Summary>("summarize")?; // имя, тип, арность — проверены сейчас
for chunk in events.chunks(10_000) {
let s = summarize.call((chunk,))?; // один вызов, один бюджет, ничего не остаётся в памяти
}
}
Что гарантируется — в том порядке, в каком решение ставит приоритеты:
- Герметичность по умолчанию. Без выданных прав
loadстатически доказывает, что скрипт не читает ни файлов, ни переменных, ни часов, и падает сE0505, если мог бы, — в том числе на ветке, которая никогда не выполнится.allow_envиallow_readвыдают ровно то же, что--allow-envи--allow-read, и только корневому модулю. - У каждого вызова бюджет — в шагах и байтах, но не во времени: иначе
результат зависел бы от машины. Убежавший вызов заканчивается с
E0325илиE0326, а не вешает хост. Значение по умолчанию конечно. - Память на вызов постоянна. Каждый вызов идёт в своём интерпретаторе и освобождает созданное, когда возвращается.
- Граница проверяется. Аргументы проходят через
serde; целое за пределамиi64или NaN — этоE0005, а не молча другой вход, а результат, который не подходит под ваш тип, —E0006с путём внутри значения. Script—Send + Sync. Один экземпляр обслуживает все потоки.
Замерено на одном ядре: вызов стоит около 1,2 мкс, а правило над событиями
обрабатывает около 0,9 млн событий в секунду при вызове на каждое событие и
1,3 млн — порциями (cargo bench --bench script). cargo run --example embedding
запускает пример выше целиком.
Перезагрузка правил без перезапуска сервиса
Правила меняются чаще, чем сервис, который их исполняет. Скрипт, загруженный из файла, можно загрузить заново без перезапуска (D36):
#![allow(unused)]
fn main() {
use std::time::Duration;
let rules = Script::builder()
.file("routing.aura")
.watch(Duration::from_secs(2), |result| match result {
Ok(v) => log::info!("правила: поколение {} ({})", v.generation, v.hash),
Err(e) => log::error!("правила не перезагружены, работают старые: {e}"),
})
.load()?;
rules.reload()?; // или перезагрузить сейчас, с watch или без него
}
- Перезагрузка - это загрузка. Она проходит все проверки первой загрузки с
теми же разрешениями: скрипт не станет менее герметичным, не дотянется до
функции хоста, которую вы не дали, и не подтянет пакет, не закреплённый в
aura.lock, - lock читается один раз, а перезагрузка разрешает импорты как--frozen. - Экспорт - это контракт. Каждый экспорт сохраняет вид и число аргументов,
иначе перезагрузка -
E0011, и продолжает работать старая версия. Новые экспорты разрешены. ПривязаннаяFunctionследует за своим именем. - Никогда не половина версии. Вызов заканчивается на той версии, на которой начался; старая версия освобождается, когда вернулся последний такой вызов.
- Решает содержимое.
touchничего не загружает. При слежении изменение загружается, только когда файлы не трогали в течение периода тишины - двух интервалов и не меньше секунды, или.settle(d), - поэтому недописанный файл не загрузится наполовину. Писатель, застрявший посреди записи дольше этого, всё же может быть пойман: выкладывайте новый файл и переименовывайте его поверх старого, как делают редакторы, а для медленного сетевого диска увеличьте.settle. - Без тихих отказов. Обработчик, который требует
watch, узнаёт о каждой автоматической перезагрузке - новая версия или ошибка, - а об одном и том же сломанном содержимом один раз. version()называет поколение и хэш всех файлов - для ваших логов.
cargo run --example checkout собирает всё вместе: правила риска интернет-магазина,
которым сервис даёт таблицу GeoIP, курсы валют и базу споров, перезагрузка по
правке - и отказ в правке, которая переименовывает то, что вызывает сервис, или
тянется к секрету, которого ей не давали.
Продвинутое: скрипт вызывает хост
Некоторым правилам нужны данные, которые есть только у приложения: в каком регионе адрес, включена ли функция для клиента. Вместо того чтобы вычислять все такие значения заранее, хост может дать скрипту именованные функции (D35). Это продвинутое использование: прочитайте гарантии ниже, прежде чем на него опираться.
import "host:geo" as geo
pub def route(ip)
region = geo.region(ip)
backend: region == null ? "global" : "#{region}-cluster"
end
#![allow(unused)]
fn main() {
let rules = Script::builder()
.file("routing.aura")
.pure("geo.region", move |ip: String| geoip.get(&ip).cloned()) // Option: None - это null
.effect("flags.enabled", move |flag: String, customer: String| {
flags.enabled(&flag, &customer) // Result: Err - это E0008
})
.load()?;
}
pure и effect - методы Rust API, а не слова языка. Часть имени до точки -
модуль, который импортирует скрипт.
pure- ваше обещание, и язык не может его проверить. Чистая функция должна давать один ответ на одни аргументы: таблица, поставляемая с бинарником, формула. Загрузка остаётся герметичной. Всё, что читает внешний мир - база, сервис флагов, часы, - этоeffect: его регистрация - разрешение, какallow_env, и загрузка перестаёт быть герметичной.- Только корневой модуль. Пакет, который импортирует скрипт, не может
вызывать функции хоста;
grant_to_imports("geo.region")открывает импортам одну функцию. - Проверка при загрузке. Непредоставленный модуль или функция -
E0007, вызов с неверным числом аргументов -E0009, в том числе в ветках, которые не выполнятся. Типы аргументов проверяются при вызове, тоже какE0009. - Бюджет. Вызов хоста стоит 100 шагов (
host_call_stepsменяет это). Время внутри вашей функции бюджету не видно: ограничивайте его сами. - Без обратного вызова. Функция хоста, вызвавшая скрипт, получает
E0010.
У aura eval хоста нет, поэтому импорт host: там останавливается с E0007;
aura check перечисляет, что нужно скрипту от приложения, которое его запустит.
cargo run --example host_functions запускает полный пример.
Любой язык: subprocess
CLI-контракт стабилен и является API: JSON в stdout, диагностики с кодами
E0xxx/W0xxx в stderr, exit-коды 0 (успех) / 1 (диагностики) /
2 (I/O, аргументы).
import json, subprocess
r = subprocess.run(["aura", "eval", "app.aura", "--frozen"],
capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(r.stderr)
config = json.loads(r.stdout)
Рекомендации для прода:
--frozen— зависимости строго поaura.lock;- права минимальные и явные (
--allow-read,--allow-env=ИМЕНА); --format yaml|toml, если потребителю удобнее.
Мобильные и браузерные приложения
Правильный паттерн — вычисление на сервере/CI:
configs.aura ──aura eval──▶ config.json ──▶ CDN / бандл
устройство читает готовый JSON штатным парсером платформы
Приложения — потребители результата Aura; валидация уже прошла в CI.
Почему обёртки, а не нативные реализации
Для YAML в каждом языке есть свой парсер, и возникает вопрос, почему у Aura не
так. Потому что YAML — формат данных: его порт это парсер, байты → данные.
Aura — вычисляемый язык (функции, импорты, схемы, capability, детерминизм): её
порт это интерпретатор, который обязан побайтово совпадать с этим. Иначе
один и тот же манифест даст разные значения в CI и в сервисе — то есть в
проде. Расхождение возникает не на экзотике: наивная реализация на
encoding/json в Go выдаёт 1 там, где Aura выдаёт 1.0, и
1000000000000000000 там, где 1e+18.
Поэтому реализация одна, а языки получают её через:
- Вычисление на этапе сборки — основной путь. Рантайм Aura внутри вашего
языка не нужен вовсе, а
aura typesвдобавок сгенерирует типы под ваш JSON. - Обёртки вокруг того же ядра — в роадмапе, по приоритету: WASM/npm
(закрывает Node, браузер и playground одним артефактом), затем
wazeroдля Go (чистый Go, без cgo) и wasmtime для Python; PyO3/UniFFI — по мере спроса.
Ядро уже собирается под wasm32-unknown-unknown; это проверяется в CI на
каждом коммите.