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 |
Описание |
|---|---|---|
|
|
Создать новый процесс |
|
|
Отправить сообщение |
|
|
Синхронный RPC |
|
|
Асинхронное сообщение |
|
|
Двунаправленная связь |
|
|
Однонаправленный монитор |
|
|
Обработка сбоев |
|
|
Сигнал выхода |
|
|
Дерево супервизоров |
Пример: Конвертация эрланговского 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(&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 — тоже есть.
В общем, оно как-то работает, как-то проверено и как-то задокументировано. Что еще нужно?
Комментарии (19)

vkni
08.12.2025 20:49Сообщения принципиально сделаны
type-erased— и позволяют отправлять что угодно; я не считаю типы — полезной херней.Если я правильно помню, там проблема в том, что сообщения могут быть пересланы дальше, так? Соответственно, типизация должна быть как-то ослаблена. А вот «классы типов» там не подойдут? То есть, актор
Апринимает сообщения, реализующие traitAtrait, а акторБэ —Бtrait. Хотя всё равно, хочется иметь переконфигурируемую сеть, но, кажется, всё не так уж и плохо получается?
А есть ещё gradual typing.
Dhwtj
08.12.2025 20:49Микросервисы пересылают самодокументированный JSON, так и тут должно быть

vkni
08.12.2025 20:49Слова "самодокументированный JSON" - это совершенно другой уровень обобщения, нежели типы/классы типов/системы типов.

cupraer Автор
08.12.2025 20:49Кому должно? Как я сериализую в джейсон — пид, например? Ну, помимо того, что далеко не все микросервисы оперируют джейсоном, который удобен только для очень тривиальных доменов.

cpud47
08.12.2025 20:49Там проблема хуже. Набор сообщений, который автор готов обрабатывать динамически меняется со временем. При том, это буквально базовый функционал. Допустим актор A послал сообещние-запрос в автор B. Тогда после этого актор A умеет обрабатывать одно сообщение BReply — как только он его получит, он потеряет возможность обрабатывать сообщения BReply.
Разумеется, правда, все эти проблемы проявляются только в авторах с нетривиальным поток управления. Когда авторы задаются через трейты — проблему обходят просто заставляя пользователя ручками делать конечный автомат и перечислять все функции, которые автор может вызвать...

cupraer Автор
08.12.2025 20:49Где «там»?
Акторы у меня задаются через трейты и от сообщений требуется реализация трейта сериализации. И это всё никак не отменяет, что я смотрю на мир шире своей песочницы и хочу уметь подружить в одном кластере ноды на эрланге и ноды на расте, причем нативно.

cpud47
08.12.2025 20:49Там — в типизации акторных систем.
Я понял, что акторы у Вас задаются трейтами. Но как уже сказал, это весьма неприятный способ задания акторов. Сравните с тем же send/receive в эрланге — там акторы это код.

cupraer Автор
08.12.2025 20:49А, ну да. Но, насколько могу судить, я прошел по тонкой косе между двух волн: в раст тащить прям динамический подход эрланга — это перебор, но и приколачивать гвоздями типы сообщений, как это делает
actix— не вариант.Я попытался оставаться пока возможно в парадигме раста, но для сообщений — это суицид. Поэтому вот так.

cupraer Автор
08.12.2025 20:49receiveиз эрланга более-менее адекватно в раст перенести не получится, но ведь и в самом эрланге сегодня им пользуется только сам Вирдинг :)А
gen_serverиgen_statemя затащил.

cupraer Автор
08.12.2025 20:49В принципе, в вакууме никакой проблемы нет.
actix, который, насколько я понял, является «стандартной» реализацией акторной модели в расте, естественно, строго типизирует все сообщения.Но я приложил некоторое количество усилий к тому, чтобы кластер мог быть гетерогенным. На данный момент, мой
epmd— обслуживает, конечно, только мои же ноды, но даже из названия понятно, что вместо этой заглушки я хочу в результате использовать родной эрланговский. У меня банально не хватило сил и времени по уму разрулить анбоксинг, но код полностью к этому готов.Если бы я завязался на хоть какую-то типизацию — прозрачно воткнуть три ноды на расте в отипишный кластер не удалось бы никогда.

davaeron
08.12.2025 20:49Что еще нужно?
Registry из Elixir.Хотя, его можно и свой написать.@cupraer библиотека выглядит здóрово.

cupraer Автор
08.12.2025 20:49А расскажите, что именно вас заставило упомянуть (пусть даже потом вычеркнув)
Registry? В каком-то смысле у меня уже есть некое подобие, можно и написать свойRegistryи использовать его вместо костылей, которые там сейчас.

AnthonyMikh
08.12.2025 20:49Зачем нужно было публиковать статью про свою библиотеку во второй раз, не добавив при этом ничего полезного?

gBear
08.12.2025 20:49Отлично получилось, в целом. Осталось реализовать ETF (ну и ещё "койчего") и можно цепляться как external node к.
Dhwtj
Удивительно, правда?
Вы вещаете из глубокого технологического колодца и ваше бубнение непонятно здешней публике. А адаптироваться вы не хотите.
Демонстрации по бизнес ориентированным задачам. Тут только техно демки, proof of concept для тех кто и без того хорошо разбирается, то есть для ерланг программистов. А таковых тут нет
vkni
Да пошла эта публика, не понимающая технологий, на три весёлых буквы.
Нет, не удивительно. Но есть вещи, к которым не надо адаптироваться.
А вообще, путь адаптаций, он может очень далеко завести...
cupraer Автор
Именно. Я даже думал взять ник «Randle McMurphy» но это было бы слишком претенциозно.
У хабра в последние 15 лет есть очень опасная тенденция: здесь слишком поощряется серость. Чем ты усредненнее, тем лучше. Особенно, если ты не высовываешься, делаешь вид, что вежлив и даже чуточку интеллигентен, и если ты умеешь восемь раз в одном комментарии обосновать, что белое белее черного, а черное — чернее белого.
Я ненавижу за такой позорный тип поведения советскую интеллигенцию и борюсь с ним по мере сил в свободное от написания текстов время. За любой голос против — тебя сожрёт не ужасное КГБ, а твои же соседи, под самыми благовидными предлогами.
cupraer Автор
Назовите хоть одну причину, по которой мне имело бы смысл адаптироваться.
Как однажды сказал Наум Коржавин на форуме славистов: «Я пишу не для славистов, я пишу для нормальных людей». Я публикую тексты здесь с единственной целью: сделать их подоступнее в поиске. Продавать правильную архитектуру людям, которые лучше горутин в своей жизни ничего не видели — мне неинтересно. Я не миссионер, не евангелист, не популяризатор и не коммивояжер.