Rust Microservices - Project Structure

By Nikita Bishonen

Привет! Сегодня я делюсь своими мыслями об удобной мне структуре проекта при разработке микросервисов на расте. Это первый пост из возможной серии "Микросервисы на Расте". Если я смогу её закончить, то оформлю целую книгу, так что если вы заметили неточность или у вас есть своим мысли по теме поста - буду рад если вы поделитесь ими со мной. Начну этот пост с небольшой ликвидации безграмотности. Если вы уверены в своих знаниях системы пакетов и модулей в Rust, то смело прыгайте к сути.

Пакеты, крейты и модули в Раст

Если вы не знакомы с Раст, то рекомендую перед дальнейшим прочтением изучить первоисточник - главу Managing Growing Projects with Packages, Crates and Modules в официальной документации Раст. Далее я излагаю свой персказ, который может оказаться неточным или неполным.

Итак, в Раст проектах мы имеем:

Пакеты в Раст

Давайте же разбираться какие есть правила доступа к модулям.

  1. Модули могут быть частными (скрытыми) и публичными.
    1. По-умолчанию модули частные.
  2. Сущности внутри модуля тоже могут быть частными и публичными.
    1. По-умолчанию сущности частные.
  3. А далее начинаются интересные взаимодействия и комбинации, которые предлагаю рассмотреть на примере кода.
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 {
        //!
    }
}

Вы можете создать новый пакет, в котором будет два крейта:

Реальный пример

В этом примере изменены некоторые названия, но это реальная структура одного из нескольких сервисов. Собственно их написание и послужило толчком к выведению некоторой общей теории относительно структурирования микросервисов на расте.

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

Мой опыт

Могу сказать что эту структуру я нахожу удобной, поскольку она совмещает в себе преимущества старых добрых - трёхслойных архитектур и, применимых к микросервисам, гексогональных архитектур. Мы разделяем наше приложение на три ветви:

  1. Ресурсы, сюда попадает всё, что находится вне нашего сервиса и к чему мы являемся потребителями. Базы данных, другие сервисы, брокеры сообщений (частично). Всё где мы инициируем общение и где мы знаем о собеседнике больше, чем он о нас. (Раньше я называл эту часть clients, но имя ресурсы мне сейчас кажется более подходящим). Основная задача ресурсов - справиться со спецификой взаимодействия с этими ресурсами. Здесь могут лежать реализации типажей кодирования и декодирования наших типов данных под определённую базу данных, также сюда пишем "прокси", которые трансформируют схему других сервисов в ту, которой оперируем мы внутри сервиса.
  2. Интерфейсы, сюда складываем всё, что является входными точками для запросов к нашему сервису. HTTP/gRPC API, GraphQL, даже брокеры сообщений, если это часть обработки входящих запросов, всё идёт сюда. Также как и ресурсы, здесь мы стараемся отсечь всю специфику связанную с конкретным механизмом взаимодействия клиентов с нами, как с сервером (поэтому раньше я называл эту часть servers). Например все десериализации запросов и сериализации ответов, должны быть реализованы тут. Дальше мы должны работать с "чистыми" типами.
  3. Ядро, это самое "чистое" и "важное" место нашего сервиса. Здесь реализуется основная бизнес-логика. Здесь мы оперируем не пришедшим по сети 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
    }
}