Microsserviços em Rust - Estrutura do Projeto

Por Nikita Bishonen7 minutos de leitura



Olá! Hoje partilho os meus pensamentos sobre uma estrutura de projeto conveniente para desenvolver microsserviços em Rust. Este é o primeiro post de uma potencial série “Microsserviços em Rust”. Se conseguir terminá-la, compilarei tudo num livro completo, por isso se notares alguma imprecisão ou tiveres os teus próprios pensamentos sobre o tema - ficaria feliz se os partilhasse comigo.

Começarei este post com uma breve introdução. Se estás confiante no teu conhecimento do sistema de pacotes e módulos do Rust, podes saltar diretamente para o conceito central.

Pacotes, Crates e Módulos em Rust

Se não estás familiarizado com Rust, recomendo estudar a fonte original - o capítulo Managing Growing Projects with Packages, Crates and Module na documentação oficial do Rust. O que se segue é a minha interpretação, que pode ser imprecisa ou incompleta.

Portanto, em projetos Rust temos:

Isto cria uma matrioska ou o conto de Koschei, o Imortal - a agulha (módulo) num ovo, o ovo (crate) numa galinha, a galinha (pacote) num baú (projeto).

Pacotes em Rust

Vamos examinar as regras de acesso aos módulos.

  1. Os módulos podem ser privados (ocultos) e públicos.
    1. Por defeito, os módulos são privados.
  2. As entidades dentro de um módulo também podem ser privadas e públicas.
    1. Por defeito, as entidades são privadas.
  3. E então começam interações e combinações interessantes, que proponho examinar com exemplos de código.
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 {
}

Visualmente, a estrutura parece-se com isto:

Estrutura

Com este exemplo, quero mostrar as possíveis conexões entre módulos e entidades (neste exemplo, limitamo-nos a structs vazios).

Vamos dividir isto em várias situações, que distinguiremos pelo lado que faz a chamada.

O Que Pode um Módulo Pai Aceder?

fn test_visibility() {
	// children_private::Private; // Falha devido à privacidade da struct
	children_private::Public;
	// children_public::Private; // Falha devido à privacidade da struct
	children_public::Public;
	// children_public::grandchildren::Private; // Falha devido à privacidade do módulo.
	// children_public::grandchildren::Public; // Falha devido à privacidade do módulo.
}

Acima, mostro um excerto de teste para o módulo pai. Como podemos ver, o acesso está disponível a ambos os módulos filhos (privado e público), mas apenas às structs públicas. O acesso aos “netos” privados e às suas structs públicas não está disponível. Visualmente, isto parece-se com:

Pai

O Que Pode um Módulo Filho Aceder?

Para isso, faremos uma adição e adicionaremos duas structs ao módulo pai, análogas aos filhos.

/// Testar visibilidade do modelo super e de outros sub-modelos do módulo um nível acima.
fn test_visibility() {
	super::Public;
	super::Private;
	// super::children_private::Private; // Falha devido à privacidade da struct
	super::children_private::Public;
	// self::grandchildren::Private; // Falha devido à privacidade da struct
    self::grandchildren::Public;
}

Filho

Como podemos ver, os tentáculos do nosso filho alcançam bastante longe, e apenas as structs privadas de módulos irmãos e filhos são inacessíveis. No entanto, as structs privadas do módulo pai estão bem ao alcance.

O Que Pode um Bisneto Aceder?

/// Testar visibilidade do modelo super e de outros sub-modelos do módulo dois níveis acima.
fn test_visibility() {
	super::Private;
	super::Public;
	// super::super::children_private::Private; // Falha devido à privacidade da struct
	super::super::children_private::Public;
	super::super::Private;
	super::super::Public;
}

Bisneto

Para o bisneto, a situação é semelhante. Decidi mostrá-lo para uma melhor compreensão de como este mecanismo multinível funciona em Rust.

O Que Pode um Vizinho Aceder?

Infelizmente, só reparei nesta fase que todos os diagramas até agora incluíam o vizinho dentro do módulo pai. Na realidade, está localizado ao lado, não dentro do pai. O diagrama abaixo contém a colocação correta deste módulo.

/// Testar visibilidade de sub-módulos dentro de outro módulo no mesmo nível.
fn test_visibility() {
	crate::parent::Public;
	// crate::parent::Private; // Falha devido à privacidade da struct
	// crate::parent::children_private::Private; // Falha devido à privacidade do módulo
	// crate::parent::children_private::Public; // Falha devido à privacidade do módulo
	// crate::parent::children_public::Private; // Falha devido à privacidade da struct
	crate::parent::children_public::Public;
	// crate::parent::children_public::grandchildren::Private; // Falha devido à privacidade de um módulo
	// crate::parent::children_public::grandchildren::Public; // Falha devido à privacidade de um módulo
}

Vizinho

A situação do vizinho é bastante aborrecida - apenas entidades públicas e caminhos para elas são acessíveis. Note que o módulo pai não é público, ainda assim o vizinho consegue vê-lo 👀

SOVMS

Ou Estrutura Baseada na Visibilidade dos Módulos. A ideia é que em Rust, já temos regras de acesso definidas (que descrevi em detalhe acima) às entidades dos módulos dependendo das suas relações (que relação o módulo que faz a chamada tem com o módulo chamado).

Decidi que a estrutura do projeto também deveria ser organizada com base nestas características da linguagem Rust para ser mais idiomática. A ideia é nova, e à medida que a testar, atualizarei este post.

Para obter uma estrutura de projeto idiomática, devemos colocar os módulos com base em quem e o que os deve ver. Esta parte do código é usada em todo o serviço? Mova-a o mais alto possível. Outra parte do código é específica? Isole-a num ramo separado ou esconda-a mais profundamente.

O ponto é que os módulos não devem ser decompostos de acordo com os domínios de negócio (pelo menos isto não deve ser a base da divisão). Afinal, na arquitetura de microsserviços, estes devem ser serviços separados (ou pacotes Rust separados para aplicações monolíticas). Desta forma, aspiramos à cobiçada proporção áurea de fraco acoplamento e alta coesão.

TL;DR

Como vejo a utilização desta abordagem na escrita de microsserviços? Deixe-me não perder mais do seu tempo a ler os meus pensamentos e mostrar-lhe um modelo:

//! Este é o crate base do serviço. Melhor torná-lo uma biblioteca, para que a funcionalidade
//! sobre como exatamente executá-lo seja armazenada separadamente dentro de um main.rs ou diferentes 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 {
        //!
    }
}

Podes criar um novo pacote com dois crates:

Exemplo Real

Neste exemplo, alguns nomes foram alterados, mas esta é uma estrutura real de um de vários serviços. Escrevê-los na verdade levou-me a derivar alguma teoria geral sobre a estruturação de microsserviços em Rust.

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

A Minha Experiência

Posso dizer que acho esta estrutura conveniente porque combina as vantagens das antigas arquiteturas de três camadas e das arquiteturas hexagonais aplicáveis a microsserviços. Dividimos a nossa aplicação em três ramos:

  1. Resources - tudo o que está fora do nosso serviço e onde somos consumidores vai aqui. Bases de dados, outros serviços, message brokers (parcialmente). Tudo onde iniciamos a comunicação e onde sabemos mais sobre o interlocutor do que eles sabem sobre nós. (Anteriormente chamava esta parte clients, mas o nome resources parece mais apropriado agora). A tarefa principal dos resources é lidar com as especificidades da interação com estes recursos. Aqui podemos ter implementações de traits para codificar e descodificar os nossos tipos de dados para uma base de dados específica, e aqui escrevemos “proxies” que transformam esquemas de outros serviços no que operamos dentro do serviço.
  2. Interfaces - tudo o que serve como pontos de entrada para pedidos ao nosso serviço vai aqui. API HTTP/gRPC, GraphQL, até message brokers se fizerem parte do processamento de pedidos recebidos - tudo vai aqui. Tal como os resources, aqui tentamos isolar todas as especificidades relacionadas com o mecanismo particular de interação cliente-servidor (é por isso que anteriormente chamava esta parte servers). Por exemplo, todas as deserializações de pedidos e serializações de respostas devem ser implementadas aqui. Depois disso, devemos trabalhar com tipos “limpos”.
  3. Core - este é o lugar “mais limpo” e “mais importante” no nosso serviço. A lógica de negócio principal é implementada aqui. Aqui operamos não com JSON que veio pela rede, mas com a sua representação estaticamente tipada e verificada. A partir do core, podemos chamar resources, mas idealmente não devemos saber nada sobre interfaces. No entanto, não devemos ter acesso a conceitos de baixo nível dos resources. Apenas à API pública.

Como é que a colocação de módulos ajuda aqui? Vemos que todos os três ramos são vizinhos entre si, o que significa que só podem conhecer coisas públicas, e as especificidades são seguras para mudar (por exemplo, mudar a base de dados ou suportar REST junto com gRPC).

Dentro dos ramos, vamos do geral para o específico e também separamos partes independentes em ramos.

graphql consiste em queries, mutations, subscriptions independentes, mas o esquema comum está no módulo raiz:

//! 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()
...

Cada sub-módulo chamará métodos públicos “limpos” de core/api.rs.

api.rs no meu exemplo contém tipos comuns na raiz (tanto privados como públicos), aos quais os seus sub-módulos api::queries e api::commands têm acesso total, mas tipos privados e implementações do próprio módulo api são inacessíveis a qualquer pessoa exceto aos seus sub-módulos.

Materiais Adicionais

Se estás interessado em “brincar” com o exemplo deste artigo, aqui está o código completo do projeto do capítulo de módulos:

#![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;

            /// Testar visibilidade do modelo super e de outros sub-modelos do módulo dois níveis acima.
            fn test_visibility() {
                super::Private;
                super::Public;
                // super::super::children_private::Private; // Falha devido à privacidade da struct
                super::super::children_private::Public;
                super::super::Private;
                super::super::Public;
            }
        }

        /// Testar visibilidade do modelo super e de outros sub-modelos do módulo um nível acima.
        fn test_visibility() {
            super::Public;
            super::Private;
            // super::children_private::Private; // Falha devido à privacidade da struct
            super::children_private::Public;
            // self::grandchildren::Private; // Falha devido à privacidade da struct
            self::grandchildren::Public;
        }
    }

    /// Testar visibilidade de sub-módulos dentro do módulo atual.
    fn test_visibility() {
        // children_private::Private; // Falha devido à privacidade da struct
        children_private::Public;
        // children_public::Private; // Falha devido à privacidade da struct
        children_public::Public;
        // children_public::grandchildren::Private; // Falha devido à privacidade do módulo.
        // children_public::grandchildren::Public; // Falha devido à privacidade do módulo.
    }
}

mod neighbour {
    /// Testar visibilidade de sub-módulos dentro de outro módulo no mesmo nível.
    fn test_visibility() {
        crate::parent::Public;
        // crate::parent::Private; // Falha devido à privacidade da struct
        // crate::parent::children_private::Private; // Falha devido à privacidade do módulo
        // crate::parent::children_private::Public; // Falha devido à privacidade do módulo
        // crate::parent::children_public::Private; // Falha devido à privacidade da struct
        crate::parent::children_public::Public;
        // crate::parent::children_public::grandchildren::Private; // Falha devido à privacidade de um módulo
        // crate::parent::children_public::grandchildren::Public; // Falha devido à privacidade de um módulo
    }
}