Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Встраивание и вызов из других языков

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.

Поэтому реализация одна, а языки получают её через:

  1. Вычисление на этапе сборки — основной путь. Рантайм Aura внутри вашего языка не нужен вовсе, а aura types вдобавок сгенерирует типы под ваш JSON.
  2. Обёртки вокруг того же ядра — в роадмапе, по приоритету: WASM/npm (закрывает Node, браузер и playground одним артефактом), затем wazero для Go (чистый Go, без cgo) и wasmtime для Python; PyO3/UniFFI — по мере спроса.

Ядро уже собирается под wasm32-unknown-unknown; это проверяется в CI на каждом коммите.