Что такое Urich
Каркас для бэкендов в стиле DDD и CQRS: приложение как кирпичики, модуль = bounded context, внутри — команды, запросы, handler'ы. На Python: объявил модуль и handler-классы — склеил одной цепочкой. Один endpoint — один класс и одна строка в модуле.
В Rust хотел то же самое. Плюс вынести общую часть в ядро и иметь одну реализацию слоя приложения — чтобы правки в ядре расходились и на Rust, и на Python.
urich-core (Rust) — не только протокол (scope/receive/send), роутинг и HTTP, но и слой приложения: Application, Container, Module, Handler, middlewares, discovery, outbox. Всё это в одном крейте: меняешь контракт или поведение — обновляются оба фасада.
urich-rs — конкретные фичи поверх ядра: DomainModule, Command/Query, CommandHandler/QueryHandler, RPC, discovery, events. Регистрация маршрутов и handler'ов уходит в core; в urich-rs остаётся только «как описать модуль на Расте».
Python — биндинги к тому же ядру (PyO3). Использует тот же Application:
add_command_route/add_query_route, одинset_external_callback(python_callable),run_from_env. Бэкенд на растовом core, без Starlette.
Итого: одно ядро с общим слоем приложения, две обёртки. Одна ментальная модель, одна кодовая база для движка.
В чём была боль (и что сделал)
Без прослойки один endpoint в Rust — это десятки строк: метод, путь, замыкание с lock/resolve/deserialize на каждый раз. Вынес рутину в ядро и обёртку. Ниже — было/стало и как устроено.
Было / Стало (один endpoint)
Было — на каждый endpoint ты пишешь что-то в таком духе:
// Роут вручную router.add("POST", "employees/commands/create_employee", route_id); // Handler — замыкание с полной рутиной let container = Arc::clone(&container); core.set_callback(Box::new(move |rid, body, _ctx| { let container = Arc::clone(&container); Box::pin(async move { let body: Value = serde_json::from_slice(body).map_err(|e| ...)?; let mut guard = container.lock().unwrap(); let handler = guard.resolve::<CreateEmployeeHandler>().map_err(...)?.clone(); drop(guard); let cmd: CreateEmployee = serde_json::from_value(body).map_err(...)?; handler.handle(cmd).await.map_err(...) }) })); // + то же для следующего endpoint, и ещё, и ещё...
Стало — в модуле одна строка, логика в типе и в handle:
// Модуль DomainModule::new("employees") .command_with_handler::<CreateEmployee, CreateEmployeeHandler>() // ... // Handler — только зависимость и handle struct CreateEmployeeHandler { db: Db } impl CommandHandler<CreateEmployee> for CreateEmployeeHandler { async fn handle(&self, cmd: CreateEmployee) -> Result<Value, CoreError> { create_employee(cmd, &self.db) // твоя логика } } // main — один раз фабрики app.with_container_mut(|c| { c.register_factory(|c| CreateEmployeeHandler { db: c.resolve::<Db>()?.clone() }); }); app.register(&mut employees_module())?;
Маршрут, lock, resolve, deserialize и вызов handle делает фреймворк.
Ядро: что там есть (псевдокод)
// Протокол: один контракт для HTTP / WebSocket / lifespan Scope = Http { method, path, headers } | WebSocket { ... } | Lifespan сервер: scope + receive → приложение → send(ответ) // Низкий уровень: роутер + один callback router: (method, path) → RouteId core.set_callback(fn(route_id, body, context) → Response) // Слой приложения (тот же крейт): таблица handler'ов + контейнер Application { core: App, handlers: Map<RouteId, (Value, Container) -> Future<Response>>, container: Arc<Mutex<Container>>, } install_callback() — вешает на core ОДИН closure: (route_id, body, ctx) → middlewares(ctx)?; value = from_slice(body); result = handlers[route_id](value, container).await; Ok(Response(result))
Python: тот же Application, но set_external_callback(one_callable) — один callback на все маршруты, по route_id диспетчеризует уже Python.
Обёртка: откуда берётся handler в таблице
Путь из типа: CreateEmployee → C::name() → "create_employee" → путь employees/commands/create_employee.
#[derive(Command)] struct CreateEmployee { employee_id: String, name: String, role: String } // путь = "{context}/commands/create_employee"
Кто строит замыкание для handlers[route_id]: при вызове .command_with_handler::<CreateEmployee, CreateEmployeeHandler>() обёртка создаёт одно замыкание и передаёт его в app.add_command(...):
// Внутри command_with_handler (urich-rs), упрощённо: handler = |body: Value, container: Arc<Mutex<Container>>| async move { let h = container.lock().unwrap().resolve::<CreateEmployeeHandler>()?.clone(); let cmd: CreateEmployee = serde_json::from_value(body)?; h.handle(cmd).await }; app.add_command("employees", "create_employee", schema, handler); // в ядре: handlers[route_id] = handler
Ты не пишешь это замыкание — его строит фреймворк по типам C и H.
Цепочка: регистрация и запрос
Регистрация (старт):
app.register(employees_module()) → module.register_into(app) → для каждой команды: app.add_command(ctx, name, schema, handler) handler уже построен в command_with_handler (lock, resolve, from_value, handle) → в core: route_id = core.add_command(...); handlers[route_id] = handler install_callback() → на core один closure: (rid, body, ctx) → handlers[rid](body, container)
Запрос:
POST /employees/commands/create_employee → router.match → route_id → core вызывает единственный callback(route_id, body, ctx) → callback: handlers[route_id](body, container) → внутри handler: lock → resolve CreateEmployeeHandler → from_value(body) → handle(cmd) → твой код: только handle(cmd)
Итог по слоям:
Где | Что |
|---|---|
urich-core | Роутер, протокол (scope/receive/send), Application, Container, Module, один callback → |
urich-rs | DomainModule, Command/Query, CommandHandler/QueryHandler; |
Python | Тот же Application, |
Как это выглядит в итоге?
Rust, модуль employees:
pub fn employees_module() -> DomainModule { DomainModule::new("employees") .command_with_handler::<CreateEmployee, CreateEmployeeHandler>() .query_with_handler::<GetEmployee, GetEmployeeHandler>() .query_with_handler::<ListEmployees, ListEmployeesHandler>() }
Типы команд/запросов и handler'ы (CreateEmployeeHandler { db: Db }, impl CommandHandler/QueryHandler) — отдельно. В main один раз регистрируешь фабрики. Никаких lock/resolve в модуле.
Python, тот же модуль:
employees_module = ( DomainModule("employees") .command(CreateEmployee, CreateEmployeeHandler) .query(GetEmployee, GetEmployeeHandler) .query(ListEmployees, ListEmployeesHandler) )
Идея одна: модуль как кирпичик, регистрация — несколько строк. В Расте без фреймворка на это ушла бы куча замыканий и lock'ов; здесь — те же концепты, что и на Python. Когда открываешь оба файла рядом, видно: один и тот же дизайн, разный синтаксис. Для меня это и была цель.
Что дальше
В задумках:
Схема из типа — генерация JSON Schema из Command/Query (schemars и т.п.), чтобы не дублировать описание.
Документация и примеры — как резать сервис на модули, регистрировать фабрики, RPC и middleware.
Макросы — при желании ещё сократить шаблон, не теряя явности.
Тесты и жизненный цикл — удобные точки для интеграционных тестов, при необходимости — события и outbox.
Главное — не усложнять, держать контракт модуля стабильным.
Ссылки
Urich: github.com/KashN9sh/urich (ветка multi)
Демо: github.com/KashN9sh/urichDemo (ветка multi) — Python в
services/, Rust вrust/, один бинарникdev:cargo run --bin devилиURICH_SERVICE=employees cargo run --bin devДокументация: kashn9sh.github.io/urich
Первая статья (Python, Starlette): Urich: DDD и CQRS для микросервисов на Starlette
Буду рад обратной связи и контрибьюциям — пиши в комментариях, в issues или в личку.
По состоянию ветки multi. WIP. Итог: одно ядро с общим слоем приложения (Application, Container, Module) для Rust и Python.
