#架构 #后端 #仓库 #六边形 #后端服务器 #xtc #会话

hextacy

包含各种实用工具的库,用于辅助使用六边形架构进行服务器开发

3个版本

0.1.12 2023年3月12日
0.1.11 2023年3月12日
0.1.1 2023年3月11日
0.1.0 2023年3月11日

#671HTTP服务器

每月 33 次下载

MIT 协议

110KB
2K SLoC

⬡ Hextacy ⬡

一个仓库,旨在通过提供可扩展的基础设施和CLI工具来快速启动使用 actix_web 的Web服务器开发,同时减少手动编写样板代码,并保持最佳实践。

此仓库使用的项目结构在很大程度上基于 六边形架构,也称为 端口和适配器 架构,它非常灵活且易于测试。您可以在此处阅读有关它的精彩文章 这里这里

架构

以下是在使用各种hextacy助手时预期的服务器架构,但为了理解为什么它以这种方式构建,您首先需要了解所有助手如何结合在一起以提供高效且灵活的架构。

后端服务器通常,如果不是总是,包含数据存储。 仓库 提供方法,通过这些方法应用程序的 适配器 可以与之交互,以获取对数据库 模型 的访问。

在这个架构中,仓库不包含实现细节。它只是一个接口,适配器利用它进行特定的实现以获取底层模型。因此,仓库方法必须始终接受一个完全通用的连接参数,该参数在适配器实现中被具体化。

当业务级服务需要访问数据库时,它们可以通过具有绑定到所需的任何仓库特质的仓库结构来获取(为了更好地了解这意味着什么,请查看下面的服务器示例或用户示例)。例如,认证服务可能需要访问用户和会话仓库。

在服务的定义中,其存储库必须受到服务所需任何存储库特征的约束。这意味着中间服务存储库也必须接受通用的连接参数。由于服务应该对存储库实现一无所知,这意味着使用该中间存储库来建立数据库连接的客户端也必须是通用的,因为服务无法预先知道它将使用哪个适配器。

可以通过将客户端从业务层移动到适配器层来缓解通用连接,但遗憾的是,我们就会失去执行数据库事务(没有噩梦般的API)的能力。业务层必须保留执行原子查询的能力。

到目前为止,我们有2个通用参数,客户端和连接,我们还有存储库,这些是我们服务的存储库可以利用来获取数据的接口,所以很好!

因为我们现在正在处理完全通用的类型,所以我们有一个完全解耦的架构(耶),但不幸的是,我们现在必须忍受每个中间存储库上Rust的神秘特质限制(啊)。幸运的是,我们可以利用Rust最优秀的特点——宏!

首先,让我们一步步通过查看一个简单的用户端点示例来了解为什么我们需要这些宏。请查看示例存储库中的服务器示例,以了解一切最终是如何设置的。

服务器

首先,我们必须定义我们将使用的数据

  • data.rs

    // We expect this in the query params
    // Validify creates a GetUsersPaginatedPayload in the background
    #[derive(Debug, Deserialize)]
    #[validify]
    #[serde(rename_all = "camelCase")]
    pub(super) struct GetUsersPaginated {
      #[validate(range(min = 1, max = 65_535))]
      pub page: Option<u16>,
      #[validate(range(min = 1, max = 65_535))]
      pub per_page: Option<u16>,
    }
    
    // It must derive Serialize and optionally new for convenience (provided by the
    // derive_new crate)
    #[derive(Debug, Serialize, new)]
    pub(super) struct UserResponse {
      users: Vec<User>,
    }
    
    impl Response for UserResponse {}
    

GetUsersPaginated进入,进行验证,UserReponse输出,很简单!我们通过处理器创建服务入口点

  • handler.rs

    use super::{contract::ServiceContract, data::GetUsersPaginatedPayload};
    
    pub(super) async fn get_paginated<S: ServiceContract>(
      data: web::Query<GetUsersPaginatedPayload>,
      service: web::Data<S>,
    ) -> Result<impl Responder, Error> {
        let query = GetUsersPaginated::validify(data.0)?;
        info!("Getting users");
        service.get_paginated(query)
    }
    

到目前为止,我们一直在展示一个简单的actix处理器,所以让我们进入好东西。

注意,在我们的处理器中有一个ServiceContract限制。服务通过合约定义其API。合约不过是我们与服务交互的特质(接口)。

  • contract.rs

    pub(super) trait ServiceContract {
      fn get_paginated(&self, data: GetUsersPaginated) -> Result<HttpResponse, Error>;
    }
    
    pub(super) trait RepositoryContract {
        fn get_paginated(
            &self,
            page: u16,
            per_page: u16,
            sort: Option<user::SortOptions>,
        ) -> Result<Vec<User>, Error>;
    }
    

这些合约定义了我们从服务中获得的行为及其将使用的基础设施。服务合约由服务结构实现。

  • service.rs

    pub(super) struct UserService<R>
    where
        R: RepositoryContract,
    {
        pub repository: R,
    }
    
    impl<R> ServiceContract for UserService<R>
    where
        R: RepositoryContract,
    {
        fn get_paginated(&self, data: GetUsersPaginated) -> Result<HttpResponse, Error> {
            let users = self.repository.get_paginated(
                data.page.unwrap_or(1_u16),
                data.per_page.unwrap_or(25),
                data.sort_by,
            )?;
    
            Ok(UserResponse::new(users)
                .to_response(StatusCode::OK)
                .finish())
        }
    }
    

服务有一个字段,必须实现合约。这个合约充当抽象层,使得我们现在不需要关心repository字段中有什么,只要它实现了RepositoryContract。这有助于在即将到来的存储库中设置通用限制,并使服务测试变得轻而易举!

现在我们必须定义我们的存储库,这时我们进入了Rust特质限制的神秘领域

  • adapter.rs

    use hextacy_derive::Repository;
    use hextacy::clients::db::{Client, DBConnect};
    use std::{marker::PhantomData, sync::Arc};
    
    #[derive(Debug, Clone, Repository)]
    #[postgres(Conn)]
    pub struct Repository<C, Conn, User>
    where
        C: DBConnect<Connection = Conn>,
        User: UserRepository<Conn>,
    {
        postgres: Client<C, Conn>,
        user: PhantomData<User>,
    }
    
    // This one's for convenience
    impl<C, Conn, User> Repository<C, Conn, User>
    where
        C: DBConnect<Connection = Conn>,
        User: UserRepository<Conn>
    {
        pub fn new(client: Arc<A>) -> Self {
            Self {
                client: Client::new(client),
                user: PhantomData
            }
        }
    }
    
    impl<C, Conn, User> RepositoryContract for Repository<C, Conn, User> 
    where
        Self: RepositoryAccess<Conn>,
        C: DBConnect<Connection = Conn>,
        User: UserRepository<Conn>
    {
      fn get_paginated(
          &self,
          page: u16,
          per_page: u16,
          sort: Option<user::SortOptions>,
      ) -> Result<Vec<user::User>, Error> {
          let mut conn = self.connect()?;
          User::get_paginated(&mut conn, page, per_page, sort).map_err(Error::new)
      }
    }
    

仅仅为了从数据库中获取用户就做了这么多工作。

Repository derive使用PgPoolConnection作为其连接类型实现RepositoryAccess特质,因为我们用PostgreSQL进行了注释。

RepositoryAccess是一个简单的特质,它是关于连接的通用类型,并为其实现者提供了一个connect()方法,用于建立到数据库的连接。在Repository derive中,这个通用连接被具体化为PgPoolConnection,这基本上意味着我们可以使用任何可以建立这种连接的客户端。Postgres客户端可以做到这一点(它只是连接池的包装器)。

属性 #[postgres(Conn)] 通知 derive 宏在实现中替换哪个通用连接参数,并且必须与结构体中的通用参数匹配。 RepositoryAccess 也可以手动实现。

DBConnect 是客户端用于建立实际连接的 trait。所有具体的客户端都以它们自己的方式实现它。它还由 Client 结构体实现。一个 Client 是一个具体客户端的包装器,简单地将其 connect() 调用委托给它。

如您所见,客户端的 C 参数必须实现 DBConnect,该 trait 负责连接到数据库,其连接必须与 DBConnect 上的连接相同。这处理了我们如何连接到数据库的方式。

User 绑定只是将服务使用的存储库绑定到存储库,在这种情况下是 UserRepository。由于存储库方法必须接受一个连接(为了保持事务),它们不接收 &self。这是可以的,但现在编译器会抱怨我们有未使用的字段,因为我们实际上没有使用它们。如果我们删除这些字段,编译器会抱怨我们有未使用的特质约束,所以我们使用幻数来让编译器认为结构体拥有这些数据。

到目前为止,我们没有将任何实现细节与服务耦合。derive 宏为 postgres 客户端生成代码,但它只是在 RepositoryAccess 实现中将通用连接约束替换为具体约束。之所以称为 postgres,是因为 derive 宏在特定字段上操作,在手动实现的情况下,这可以命名为任何名称。

所以,基本上,服务只有对一些通用客户端、连接和存储库的调用。

这一事实是该架构的核心,也是它如此强大的原因。这不仅使得测试变得轻而易举,而且还允许我们随意更改适配器,而无需更改业务逻辑。它们是完全解耦的。

最后,我们在设置中具体化所有内容

  • setup.rs

    pub(crate) fn routes(pg: Arc<Postgres>, rd: Arc<Redis>, cfg: &mut web::ServiceConfig) {
      let service = UserService {
          repository: Repository::<Postgres, PgPoolConnection, PgUserAdapter>::new(pg.clone()),
      };
      let auth_guard = interceptor::AuthGuard::new(pg, rd, Role::User);
    
      cfg.app_data(Data::new(service));
    
      // Show all
      cfg.service(
          web::resource("/users")
              .route(web::get().to(handler::get_paginated::<
                  UserService<Repository<Postgres, PgPoolConnection, PgUserAdapter>>,
              >))
              .wrap(auth_guard),
      );
    }
    

我必须承认,特质约束看起来有点丑,但鉴于这是我们唯一具体化类型的地方,我们不必担心在适配器中做出更改时,服务的其余部分会损坏。

为了减少处理这么多泛型的不愉快,存在宏来帮助这个过程。如果我们使用 repository!contract! 宏,我们的 adapter.rs 文件将更容易阅读

  • adapter.rs

    /* ..imports.. */
    
    repository! {
        C => Connection : field_name;
    
        User => UserRepository<Connection>
    }
    
    contract! {
        C => Connection;
    
        RepositoryContract => Repository, RepositoryAccess;
        
        User => UserRepository<Connection>;
    
        fn get_paginated(
            &self,
            page: u16,
            per_page: u16,
            sort: Option<user::SortOptions>,
        ) -> Result<Vec<user::User>, Error> {
            let mut conn = self.connect()?;
            User::get_paginated(&mut conn, page, per_page, sort).map_err(Error::new)
        }
    }
    

看起来好多了!这将生成原始文件中的所有泛型代码。您可以在 hextacy::db 模块中了解更多关于宏如何工作。

事务

存储库总是接受连接作为其方法参数的原因是事务。由于业务级服务应该有能力在出现问题时回滚事务,我们必须以某种方式使它们的存储库支持事务。

我们通过在仓库中添加一个事务字段来实现这一点,这只是一个围绕一个RefCellOption<C>的简单操作,其中C是连接。我们使用引用细胞来获取对事务的可变访问,而不会用&mut self引用污染我们的API。

现在,这个引用细胞可以保存一个可以用于执行查询的开放连接。Atomic特质为任何仓库提供了启动、提交或回滚事务的接口。这是通过检查我们的引用细胞是否包含一个连接来实现的,如果包含,我们使用它,如果不包含,我们简单地指示我们的客户端建立一个新连接。更进一步,让我们让用户服务仓库原子化

use hextacy_derive::Repository;
use hextacy::db::{AtomicConnection, Transaction};
use hextacy::clients::db::{Client, DBConnect};
use std::{marker::PhantomData, sync::Arc};

#[derive(Debug, Clone, AcidRepository)]
#[postgres(Conn)]
pub struct Repository<C, Conn, User>
where
    C: DBConnect<Connection = Conn>,
    User: UserRepository<Conn>,
{
    pub postgres: Client<C, Conn>,
    // Type provided for convenience which is equivalent to RefCell<Option<Conn>>
    pub pg_tx: Transaction<Conn>,
    user: PhantomData<User>,
}

现在,我们不仅要建立连接并调用User::get_paginated,我们首先必须检查是否存在开放的连接

impl<C, Conn, User> RepositoryContract for Repository<C, Conn, User> 
where
    Self: AcidRepositoryAccess<Conn>,
    C: DBConnect<Connection = Conn>,
    User: UserRepository<Conn>
{
  fn get_paginated(
      &self,
      page: u16,
      per_page: u16,
      sort: Option<user::SortOptions>,
  ) -> Result<Vec<user::User>, Error> {
      let mut conn = self.connect()?;
      // Use atomic! to reduce this boilerplate
      match conn {
        hextacy::db::AtomicConnection::New(mut conn) => User::get_paginated(&mut conn, page, per_page, sort).map_err(Error::new),
        hextacy::db::AtomicConnection::Existing(mut conn) => User::get_paginated(conn.borrow_mut().as_mut().unwrap(), page, per_page, sort).map_err(Error::new),
      }
  }
}

为了减少匹配连接是否存在时的样板代码,可以利用atomic!宏来执行查询。它正是上述操作。

注意,Repository已更改为AcidRepository,而RepositoryAccess已更改为AcidRepositoryAccess。访问特质是相同的,区别在于原子版本返回一个AtomicConnection<C>,并要求仓库实现Atomic,这是AcidRepository在幕后完成的

use hextacy::db::{Atomic, DatabaseError, TransactionError};
use diesel::connection::AnsiTransactionManager;

impl</* ..bounds.. */> Atomic for Repository< /* ..bounds.. */, PgPoolConnection>
where /* ..bounds.. */ 
{
      fn start_transaction(&self) -> Result<(), DatabaseError> {
          let mut tx = self.transaction.borrow_mut();
          match *tx {
              Some(_) => Err(DatabaseError::Transaction(TransactionError::InProgress)),
              None => {
                  let mut conn = self.client.connect()?;
                  AnsiTransactionManager::begin_transaction(&mut *conn)?;
                  *tx = Some(conn);
                  Ok(())
              }
          }
      }

      fn rollback_transaction(&self) -> Result<(), DatabaseError> {
          let mut tx = self.transaction.borrow_mut();
          match tx.take() {
              Some(ref mut conn) => AnsiTransactionManager::rollback_transaction(&mut **conn)
                  .map_err(DatabaseError::from),
              None => Err(DatabaseError::Transaction(TransactionError::NonExisting).into()),
          }
      }

      fn commit_transaction(&self) -> Result<(), DatabaseError> {
          let mut tx = self.transaction.borrow_mut();
          match tx.take() {
              Some(ref mut conn) => {
                  AnsiTransactionManager::commit_transaction(&mut **conn)
                      .map_err(DatabaseError::from)
              }
              None => Err(DatabaseError::Transaction(TransactionError::NonExisting).into()),
          }
      }
  }

原子实现需要具有具体类型,因为它必须知道要使用哪个事务管理器来操作连接。

幸运的是,AcidRepository为我们完成了这项工作。还可以使用一个额外的快捷方式,即acid_repo!宏,它与repository的功能相同,除了添加事务字段和原子访问实现之外。

业务级服务现在可以利用这三种方法以它们认为合适的方式执行事务。为了减少与它们相关的样板代码,我们可以利用transaction!宏。

该宏接受一个必须返回结果的回调。在回调开始之前,将调用start_transaction,然后,根据结果,事务将提交或回滚。

为了进一步说明,下面是一个仓库的样子

  • repository/user.rs
pub trait UserRepository<C> {
    fn get_paginated(
        conn: &mut C,
        page: u16,
        per_page: u16,
        sort_by: Option<SortOptions>,
    ) -> Result<Vec<User>, AdapterError>;
}

适配器仅实现了UserRepository特质,并使用其特定的ORM返回模型。这结束了架构部分(目前... :))。

hextacy

功能标志

  - full - Enables all the feature below

  - db - Enables mongo, diesel and redis
  - ws - Enable the WS session adapter and message broker

  - diesel - Enables the diesel postgres client and derive macros
  - mongo - Enables the mongodb client and derive macros
  - redis - Enables the redis client and cache access trait
  - email - Enables the SMTP client and lettre
  • db

    包含一组特质,用于在访问数据库的结构上实现,并与仓库交互。提供宏来轻松生成如示例中所示的数据仓库结构。

  • clients

    包含实现客户端特定行为的结构,例如连接到数据库、建立数据库、缓存、smtp和http服务器的连接池。在这里创建的所有连接通常通过Arcs在整个应用程序中共享。请参阅clients readme

  • logger

    logger模块利用了tracingenv_loggerlog4rs库来设置日志记录,无论是记录到stdout还是server.log文件,哪个更适合您的需求。

  • crypto

    包含加密工具,用于加密和签名数据以及生成令牌。

  • web

    包含用于HTTP和WebSocket的各种辅助工具和实用程序。

    • http

      其中最值得注意的是为HTTP设置的默认安全头中间件(如此处所述)以及响应特质,这是一个可以由任何需要转换为HTTP响应的struct实现的实用特质。还有一些cookie辅助工具。

    • ws

      包含WebSocket会话处理器的模块。

      发送给此处理器的每条消息都必须有一个顶级的"domain"字段。域名是完全任意的,用于告知WebSocket会话应广播哪种数据类型。

      域名在内部映射到数据类型。演员可以通过代理订阅他们感兴趣的具体数据类型,而WS会话演员在接收到相应客户端的任何消息时将发布它们。

      注册的数据类型通常是枚举,然后在接收演员的处理程序中进行匹配。枚举应始终无标签,以减少来自客户端套接字的不必要嵌套。

      使用基于actix框架的代理实现,这是一个基于Actor模型的非常酷的消息传递通信系统。

      有关更多信息和工作示例,请查看web::ws模块。

  • cache

    包含一个用于实现需要访问缓存的服务的cacher特质。每个服务都必须有其缓存域和标识符以实现缓存分离。《CacheAccess》和《CacheIdentifier》特质可用于此类目的。

XTC - 正在积极开发中

也称为CLI工具,提供了一种无缝生成和记录端点和中间件的方法。

在克隆存储库后设置CLI工具,请输入

cargo install --path xtc

从项目根目录。

可以使用xtc -h命令查看顶级命令列表。

最著名的命令是[g]enerate,它设置端点/中间件模板,以及[anal]yze,它扫描路由器和中间件目录,并构建包含端点信息的Json/Yaml文件。

Xtc仅适用于架构部分中描述的项目结构。

[g]enerate命令生成类似于在路由器中描述的端点结构。它可以生成route [r]middleware [mw]模板。还可以使用带有-c标志的合约,后跟要连接到端点的合约,用逗号分隔,例如。

xtc gen route <NAME> -c repository,cache

这将自动将合约连接到服务并设置基础设施模板。它还将pub(crate) mod <NAME>追加到路由器的mod.rs。它还接受一个-p参数,可以用来指定设置端点的目录。

函数 analyze 严重依赖于 syn 库。它分析 datahandlersetup 文件的语法,并提取必要的信息来文档化端点。

所有命令都接受 -v 标志,代表 '详细',如果为真,则将 xtc 的操作打印到 stdout。默认情况下,所有命令都静默运行。

待办事项

  • 使用 xtc init 初始化项目
  • 为宏添加 trybuild 测试

依赖项

~29–49MB
~1M SLoC