Rust Microservices - Project Structure
By Nikita Bishonen
Привет! Сегодня я делюсь своими мыслями об удобной мне структуре проекта при разработке микросервисов на расте. Это первый пост из возможной серии "Микросервисы на Расте". Если я смогу её закончить, то оформлю целую книгу, так что если вы заметили неточность или у вас есть своим мысли по теме поста - буду рад если вы поделитесь ими со мной. Начну этот пост с небольшой ликвидации безграмотности. Если вы уверены в своих знаниях системы пакетов и модулей в Rust, то смело прыгайте к сути.
Пакеты, крейты и модули в Раст
Если вы не знакомы с Раст, то рекомендую перед дальнейшим прочтением изучить первоисточник - главу Managing Growing Projects with Packages, Crates and Modules в официальной документации Раст. Далее я излагаю свой персказ, который может оказаться неточным или неполным.
Итак, в Раст проектах мы имеем:
- Пакет содержащий:
Cargo(.toml | .lock)
- Один или множество крейтов, каждый из которых имеет:
- Корень крейта -
src/(lib.rs | main.rs | bin/*.rs)
- Модули
- встроенные -
mod _name_ { _body }
- отдельными файлами -
mod _name_;
+src/(_name_.rs | _name_/mod.rs)
Получается такая матрёшка или сказка о Кощее Бессмертном - Игла (модуль) в яйце, яйцо (крейт) в курице, курица (пакет) в сундуке (проекте).
- встроенные -
- Корень крейта -
Давайте же разбираться какие есть правила доступа к модулям.
- Модули могут быть частными (скрытыми) и публичными.
- По-умолчанию модули частные.
- Сущности внутри модуля тоже могут быть частными и публичными.
- По-умолчанию сущности частные.
- А далее начинаются интересные взаимодействия и комбинации, которые предлагаю рассмотреть на примере кода.
mod parent {
mod children_private {
struct Private;
pub struct Public;
}
pub mod children_public {
struct Private;
pub struct Public;
mod grandchildren {
struct Private;
pub struct Public;
}
}
}
mod neighbour {
}
Визуально сама структура выглядит следующим образом:
На этом примере хочу показать вам возможные связи между модулями и сущностями (в данном примере мы ограничиваемся пустыми структурами). Разобьём это на несколько ситуацией, различать их мы будем по вызывающей стороне.
Что может родительский модуль?
fn test_visibility() {
// children_private::Private; // Fails due to privacy of struct
children_private::Public;
// children_public::Private; // Fails due to privacy of struct
children_public::Public;
// children_public::grandchildren::Private; // Fails due to privacy of module.
// children_public::grandchildren::Public; // Fails due to privacy of module.
}
Выше я привожу кусочек теста для родительского модуля, как мы видим, доступ имеется к обоим (частному и публичному) дочерним модулям, но только к публичным структурам. Доступа же к частным "внукам" и их публичным структурам не имеется. Визуально это выглядит следующим образом:
Что может дочерний модуль?
Для этого мы сделаем дополнение и добавим в родительский модуль две структуры, по аналогии с дочерними.
/// Test visibility of super model and another sub-models of module one level above.
fn test_visibility() {
super::Public;
super::Private;
// super::children_private::Private; // Fails due to privacy of struct
super::children_private::Public;
// self::grandchildren::Private; // Fails due to privacy of struct
self::grandchildren::Public;
}
Как мы можем видеть, щупальца нашего детятки раскинулись достаточно широко и лишь частные структуры соседних и дочерних модулей им недоступны. А вот частные структуры родительского модуля вполне в зоне досягаемости.
Что может правнук(чка)?
/// Test visibility of super model and another sub-models of module two levels above.
fn test_visibility() {
super::Private;
super::Public;
// super::super::children_private::Private; // Fails due to privacy of struct
super::super::children_private::Public;
super::super::Private;
super::super::Public;
}
Для правнука ситуация схожая, решил показать её для лучшего понимания многоуровневой работы этого механизма в Раст.
Что может сосед?
К сожалению я только на этом этапе заметил что все диаграммы до включают соседа в модуль родителя, на самом деле он конечно находится рядом, а не внутри родителя. Диаграмма ниже содержит корректное расположение этого модуля.
/// Test visibilit of sub-modules inside another module on the same level.
fn test_visibility() {
crate::parent::Public;
// crate::parent::Private; // Fails due to privacy of struct
// crate::parent::children_private::Private; // Fails due to privacy of module
// crate::parent::children_private::Public; // Fails due to privacy of module
// crate::parent::children_public::Private; // Fails due to privacy of struct
crate::parent::children_public::Public;
// crate::parent::children_public::grandchildren::Private; // Fails due to privacy of a module
// crate::parent::children_public::grandchildren::Public; // Fails due to privacy of a module
}
У соседа всё достаточно скучно - только публичные сущности и пути к ним являются доступными. При этом стоит заметить что модуль родителя не является публичным, при этом сосед всё равно его видит 👀
СОВМ
Или Структура на Основе Видимости Модулей. Идеей является то, что в Расте мы имеем уже определённые правила доступа (подробнее о которых я рассказал выше) к сущностям модулей в зависимости от их взаимоотношений (кем вызывающий модуль приходится вызываемому).
Я решил что и структура проекта должна быть изложена исходя из этих особенностей языка Раст, чтобы быть более идиоматичной. Идея новая и по мере её обкатывания, я буду обновлять этот пост.
Чтобы получить идиоматичную структуру проекта, мы должны располагать модули на основании того, кто и что должен видеть. Является ли эта часть кода чем-то используемым во всём сервисе? Выносим её как можно выше. Является ли другая часть кода специфичной? Выделяем её в отдельную ветвь или прячем поглубже. Суть в том что модули не должны быть декомпозированы согласно бизнес доменам (по-крайней мере это не должно быть основой деления). Ведь в случае микросервисной архитектуры это должны быть не модули, а другие сервисы (ну или другие пакеты раста для монолитных приложений). Таким образом мы стремимся к заветному золотому соотношению loose coupling and high cohesion.
TL;DR
Как же я вижу использование этого подхода в написании микросервисов? Предлагаю не тратить ещё больше вашего времени на чтение моих мыслей, а посмотреть на шаблон:
//! This is the base service crate. Better to make it a library, so functionality regarding how
//! exactly to run it will be stored separately inside one main.rs or different bins.
mod interfaces {
//!
mod graphql {
//!
}
mod http {
//!
}
mod grpc {
//!
}
}
mod core {
//!
mod api {
//!
}
mod config {
//!
}
}
mod resources {
//!
mod db {
//!
}
mod kafka {
//!
}
mod third_party_integration {
//!
}
}
Вы можете создать новый пакет, в котором будет два крейта:
- main отвечает лишь за запуск приложения
- lib хранит структуру и используется в main, tests (интеграционных) и в дополнительных "бинах" если потребуется
Реальный пример
В этом примере изменены некоторые названия, но это реальная структура одного из нескольких сервисов. Собственно их написание и послужило толчком к выведению некоторой общей теории относительно структурирования микросервисов на расте.
src
├── bin
│ └── events_processor.rs
├── core
│ ├── api.rs
│ ├── configuration.rs
│ └── mod.rs
├── interfaces
│ ├── graphql
│ │ ├── mod.rs
│ │ ├── mutations.rs
│ │ ├── queries.rs
│ │ └── subscriptions.rs
│ └── mod.rs
├── lib.rs
├── main.rs
└── resources
├── node_proxy.rs
├── indexer_proxy.rs
├── db.rs
├── mod.rs
├── reservoir_proxy.rs
└── real_time_data_ws.rs
Мой опыт
Могу сказать что эту структуру я нахожу удобной, поскольку она совмещает в себе преимущества старых добрых - трёхслойных архитектур и, применимых к микросервисам, гексогональных архитектур. Мы разделяем наше приложение на три ветви:
Ресурсы
, сюда попадает всё, что находится вне нашего сервиса и к чему мы являемся потребителями. Базы данных, другие сервисы, брокеры сообщений (частично). Всё где мы инициируем общение и где мы знаем о собеседнике больше, чем он о нас. (Раньше я называл эту частьclients
, но имя ресурсы мне сейчас кажется более подходящим). Основная задача ресурсов - справиться со спецификой взаимодействия с этими ресурсами. Здесь могут лежать реализации типажей кодирования и декодирования наших типов данных под определённую базу данных, также сюда пишем "прокси", которые трансформируют схему других сервисов в ту, которой оперируем мы внутри сервиса.Интерфейсы
, сюда складываем всё, что является входными точками для запросов к нашему сервису. HTTP/gRPC API, GraphQL, даже брокеры сообщений, если это часть обработки входящих запросов, всё идёт сюда. Также как и ресурсы, здесь мы стараемся отсечь всю специфику связанную с конкретным механизмом взаимодействия клиентов с нами, как с сервером (поэтому раньше я называл эту частьservers
). Например все десериализации запросов и сериализации ответов, должны быть реализованы тут. Дальше мы должны работать с "чистыми" типами.Ядро
, это самое "чистое" и "важное" место нашего сервиса. Здесь реализуется основная бизнес-логика. Здесь мы оперируем не пришедшим по сети JSON, а его статически типизированным и проверенным представлением. Из ядра мы можем вызывать ресурсы, но желательно ничего не знать об интерфейсах. Однако мы не должны иметь доступа к низкоуровневым концептам ресурсов. Только к публичному API.
Как же тут помогает расположение модулей? Мы видим что все три ветки являются соседями друг-другу, значит могут знать лишь публичные вещи, а частности являются безопасными для изменения (например смена базы данных или поддержка REST бок о бок с gRPC). Внутри же ветвей мы идём от общего к частному и также разделяем независимые части на ветви. graphql
состоит из независимых queries
, mutations
, subscriptions
, но общую схему составляет в корневом модуле:
//! graphql/mod.rs
pub async fn schema() -> Result<Schema<Query, Mutation, Subscription>> {
let configuration = Configuration::from_env()?;
let pool = database::connect(&configuration.database).await?;
let producer = kafka::build_producer(&configuration.kafka)?;
Ok(Schema::build(Query, Mutation, Subscription)
.enable_federation()
.enable_subscription_in_federation()
...
При этом каждый под-модуль будет вызывать публичные "чистые" методы из core/api.rs
. api.rs
же в моём примере содержит общие типы в корне (как частные, так и публичные), полный доступ к которым имеют его подмодули api::queries
и api::commands
, но частные типы и реализации самого модуля api
недоступны никому, кроме его подмодулей.
Дополнительные материалы
Если вам интересно "поиграть" с примером из данной статьи, то вот полный код проекта из главы про модули:
#![allow(clippy::all, dead_code, path_statements)]
fn main() {
println!("Hello, modules privacy!");
}
mod parent {
struct Private;
pub struct Public;
mod children_private {
struct Private;
pub struct Public;
}
pub mod children_public {
struct Private;
pub struct Public;
mod grandchildren {
struct Private;
pub struct Public;
/// Test visibility of super model and another sub-models of module two levels above.
fn test_visibility() {
super::Private;
super::Public;
// super::super::children_private::Private; // Fails due to privacy of struct
super::super::children_private::Public;
super::super::Private;
super::super::Public;
}
}
/// Test visibility of super model and another sub-models of module one level above.
fn test_visibility() {
super::Public;
super::Private;
// super::children_private::Private; // Fails due to privacy of struct
super::children_private::Public;
// self::grandchildren::Private; // Fails due to privacy of struct
self::grandchildren::Public;
}
}
/// Test visibility of sub-modules inside current module.
fn test_visibility() {
// children_private::Private; // Fails due to privacy of struct
children_private::Public;
// children_public::Private; // Fails due to privacy of struct
children_public::Public;
// children_public::grandchildren::Private; // Fails due to privacy of module.
// children_public::grandchildren::Public; // Fails due to privacy of module.
}
}
mod neighbour {
/// Test visibilit of sub-modules inside another module on the same level.
fn test_visibility() {
crate::parent::Public;
// crate::parent::Private; // Fails due to privacy of struct
// crate::parent::children_private::Private; // Fails due to privacy of module
// crate::parent::children_private::Public; // Fails due to privacy of module
// crate::parent::children_public::Private; // Fails due to privacy of struct
crate::parent::children_public::Public;
// crate::parent::children_public::grandchildren::Private; // Fails due to privacy of a module
// crate::parent::children_public::grandchildren::Public; // Fails due to privacy of a module
}
}