joerl — это библиотека модели акторов для Rust, вдохновленная Erlang и названная в честь Джо Армстронга, создателя Erlang. Если вам когда-либо приходилось строить конкурентные системы на Erlang/OTP и вы думали: «Эх, был бы здесь хоть намек на систему типов», — то вот она, ваша прелесть. Я начинал этот проект просто потренироваться в расте немного, но меня затянуло и я довел код более-менее до ума. Сам я на расте писать буду вряд ли, если кто-то ближе к телу захочет попробовать — буду признателен.

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

Еще одна нафиг библиотека?

  • Для эрлангистов: Та же терминология, те же концепции. Плавный переход, надеюсь, очень плавный; можно почти без кода запустить кластер. EPMD в комплекте, прозрачная адресация акторов, короче, distributed как мы привыкли;

  • Production-ready: Встроенная телеметрия, мониторинг здоровья системы, распределенный обмен сообщениями — все, что нужно для боевого применения;

  • Тщательно протестирована: Обширное property-based тестирование гарантирует корректность. Не верьте на слово — проверьте сами (но продакшена она пока не видала);

  • Производительность: Нормальная производительность, вроде. Раст же.

Простой пример: Актор-счетчик

Начнем с простейшего примера — актора-счетчика. Ничего не делает, но дает представление о внешнем виде кода:

use joerl::{Actor, ActorContext, ActorSystem, Message};
use async_trait::async_trait;

// Определяем актор
struct Counter {
    count: i32,
}

#[async_trait]
impl Actor for Counter {
    async fn started(&mut self, ctx: &mut ActorContext) {
        println!("Счетчик запущен с pid {}", ctx.pid());
    }

    async fn handle_message(&mut self, msg: Message, ctx: &mut ActorContext) {
        if let Some(cmd) = msg.downcast_ref::<&str>() {
            match *cmd {
                "increment" => {
                    self.count += 1;
                    println!("[{}] Счет: {}", ctx.pid(), self.count);
                },
                "get" => {
                    println!("[{}] Текущий счет: {}", ctx.pid(), self.count);
                },
                "stop" => {
                    ctx.stop(joerl::ExitReason::Normal);
                },
                _ => {}
            }
        }
    }

    async fn stopped(&mut self, reason: &joerl::ExitReason, ctx: &mut ActorContext) {
        println!("[{}] Счетчик остановлен: {}", ctx.pid(), reason);
    }
}

#[tokio::main]
async fn main() {
    let system = ActorSystem::new();
    let counter = system.spawn(Counter { count: 0 });
    
    counter.send(Box::new("increment")).await.unwrap();
    counter.send(Box::new("increment")).await.unwrap();
    counter.send(Box::new("get")).await.unwrap();
    counter.send(Box::new("stop")).await.unwrap();
    
    // Дадим сообщениям время обработаться
    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}

Сообщения принципиально сделаны type-erased — и позволяют отправлять что угодно; я не считаю типы — полезной херней.

Телеметрия и наблюдаемость

Одна из сильных сторон joerl — встроенный мониторинг для production. Не нужно гадать, что пошло не так в три часа ночи. Включаем фичу telemetry:

[dependencies]
joerl = { version = "0.5", features = ["telemetry"] }
metrics-exporter-prometheus = "0.15"

Добавляем телеметрию в приложение:

use joerl::telemetry;
use metrics_exporter_prometheus::PrometheusBuilder;

#[tokio::main]
async fn main() -> Result<(), Box> {
    // Запускаем Prometheus exporter
    PrometheusBuilder::new()
        .with_http_listener(([127, 0, 0, 1], 9090))
        .install()?;
    
    telemetry::init();
    
    let system = ActorSystem::new();
    // ... ваши акторы
    
    Ok(())
}

Теперь открываем http://localhost:9090/metrics и видим:

▸ Жизненный цикл акторов: создание, остановка, паники
Пропускная способность: сообщений отправлено/обработано в секунду
Глубина mailbox: индикаторы backpressure
Перезапуски супервизоров: статистика восстановления после сбоев
▸ Готовая интеграция с Grafana/Prometheus
▸ Поддержка OpenTelemetry для распределенного трейсинга

Прозрачный distribution

joerl обеспечивает location-transparent messaging: тот же API для локальных и удаленных акторов. Никакой разницы, работаете вы на одной машине или в кластере из десятков нод. Эх, почти, как в эрланге!

Запускаем два узла:

# Терминал 1: Запускаем EPMD сервер
cargo run --example epmd_server

# Терминал 2: Запускаем узел A
cargo run --example distributed_cluster -- node_a 5001

# Терминал 3: Запускаем узел B
cargo run --example distributed_cluster -- node_b 5002

Узлы автоматически обнаруживают друг друга через EPMD (Erlang Port Mapper Daemon). Как в Erlang, только лучше — потому что на Rust (шутка, конечно, вообще не лучше).

Пример кода:

use joerl::ActorSystem;

#[tokio::main]
async fn main() {
    // Создаем распределенную систему
    let system = ActorSystem::new_distributed(
        "mynode@localhost",
        5000,
        "127.0.0.1:4369"  // адрес EPMD
    ).await.unwrap();
    
    // Создаем актор — работает точно так же, как локально
    let actor = system.spawn(MyActor::new());
    
    // Отправляем сообщение — работает для локальных И удаленных акторов
    actor.send(Box::new("hello")).await.unwrap();
    
    // Подключаемся к другому узлу
    system.connect_to_node("othernode@localhost").await.unwrap();
    
    // Получаем pid удаленного актора и прозрачно отправляем сообщения
    // ... тот же API, никаких изменений в коде!
}

▸ Единый API: spawn(), send(), link() работают идентично
Автоматическое обнаружение: EPMD обрабатывает регистрацию узлов
Двунаправленные связи: Полная семантика соединений в стиле Erlang
Сериализация: Trait-based сериализация сообщений с глобальным реестром

Ну и мониторинг всякий, линкинг и прочий дуинг работают тоже прозрачно.

Для разработчиков на Erlang/OTP

Если вы знаете Erlang, вы уже знаете joerl. Смотрите:

Erlang

joerl

Описание

spawn/1

system.spawn(actor)

Создать новый процесс

Pid ! Msg

actor.send(msg).await

Отправить сообщение

gen_server:call/2

server.call(request).await

Синхронный RPC

gen_server:cast/2

server.cast(msg).await

Асинхронное сообщение

link/1

system.link(pid1, pid2)

Двунаправленная связь

monitor/2

actor.monitor(from)

Однонаправленный монитор

process_flag(trap_exit, true)

ctx.trap_exit(true)

Обработка сбоев

{'EXIT', Pid, Reason}

Signal::Exit { from, reason }

Сигнал выхода

supervisor:start_link/2

spawn_supervisor(&system, spec)

Дерево супервизоров

Пример: Конвертация эрланговского gen_server в joerl:

Erlang:

-module(counter).
-behaviour(gen_server).

init([]) -> {ok, 0}.

handle_call(get, _From, State) ->
    {reply, State, State};
handle_call({add, N}, _From, State) ->
    {reply, State + N, State + N}.

handle_cast(increment, State) ->
    {noreply, State + 1}.

joerl:

use joerl::gen_server::{GenServer, GenServerContext};

struct Counter;

#[async_trait]
impl GenServer for Counter {
    type State = i32;
    type Call = CounterCall;
    type Cast = CounterCast;
    type CallReply = i32;

    async fn init(&amp;mut self, _ctx: &mut GenServerContext<'_, Self>) -> Self::State {
        0
    }

    async fn handle_call(
        &mut self,
        call: Self::Call,
        state: &mut Self::State,
        _ctx: &mut GenServerContext<'_, Self>,
    ) -> Self::CallReply {
        match call {
            CounterCall::Get => *state,
            CounterCall::Add(n) => {
                *state += n;
                *state
            }
        }
    }

    async fn handle_cast(
        &mut self,
        cast: Self::Cast,
        state: &mut Self::State,
        _ctx: &mut GenServerContext<'_, Self>,
    ) {
        match cast {
            CounterCast::Increment =>; *state += 1,
        }
    }
}

Код почти не изменился: акторы, супервизоры, связи, мониторы, появилась type safety и производительность Rust (не уверен, что это плюс, впрочем).

Property-Based тестирование: доказательство корректности

joerl использует обширное property-based тестирование с QuickCheck для проверки корректности. Вместо написания отдельных тестовых случаев определяются свойства, которые должны выполняться для всех входных данных, затем генерируются сотни случайных тестов.

Пример property-теста:

use quickcheck_macros::quickcheck;

/// Свойство: Сериализация Pid должна быть без потерь
#[quickcheck]
fn prop_pid_serialization_roundtrip(pid: Pid) -> bool {
    let serialized = serde_json::to_string(&pid).unwrap();
    let deserialized: Pid = serde_json::from_str(&serialized).unwrap();
    pid == deserialized
}

QuickCheck генерирует случайные значения Pid и проверяет, что свойство выполняется для всех них.

Запуск property-тестов:

# Запустить все property-тесты
cargo test --tests proptest

# Запустить 1000 случайных случаев на свойство
QUICKCHECK_TESTS=1000 cargo test --test proptest_pid

# Запустить конкретный тест
cargo test --test proptest_serialization prop_message_roundtrip

Property-based тестирование — в 2025 году для меня — не прихоть, а доказательство того, что код работает не только на примерах из README, но и в реальной жизни со всеми ее сюрпризами.

Примеры валяются в директории examples, сравнение с actix — тоже есть.

В общем, оно как-то работает, как-то проверено и как-то задокументировано. Что еще нужно?

Комментарии (1)


  1. Dhwtj
    08.12.2025 20:49

    карму мне скоро выкрутят в минус

    Удивительно, правда?

    Вы вещаете из глубокого технологического колодца и ваше бубнение непонятно здешней публике. А адаптироваться вы не хотите.