1个不稳定版本
0.1.0 | 2023年3月11日 |
---|
#560 在 #后端
每月21次 下载
用于 hextacy
50KB
458 行
⬡ 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
进来,经过验证,UserResponse
出来,很简单!我们使用处理器为服务创建入口点
-
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
特质,因为我们用postgres做了注释。
RepositoryAccess
是一个简单的特质,它是通用的连接,为其实施者提供了一个connect()
方法来建立数据库连接。在Repository
derive中,这个通用连接被具体化为PgPoolConnection
,这基本上意味着我们可以使用任何可以建立这种连接的客户端。Postgres
客户端可以做到这一点(它只是连接池的一个包装器)。
#[postgres(Conn)]
属性告诉 derive 宏在实现中要替换哪个泛型连接参数,并且必须与结构体中的泛型相匹配。 RepositoryAccess
也可以手动实现。
DBConnect
是客户端用来建立实际连接的 trait。所有具体客户端都以它们自己的方式实现它。它也由 Client
结构体实现。一个 Client
是一个具体客户端的包装,并且简单地将 connect()
调用委托给它。
正如您所看到的,客户端的 C
参数必须实现 DBConnect
,它负责连接到数据库,并且其连接必须与 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
模块中了解更多关于宏如何工作的信息。
事务
存储库总是在它们的方法中接受连接的原因是事务。由于业务级服务应该有能力在事情出错时回滚事务,我们必须以某种方式使它们的存储库能够支持事务。
我们通过在存储库中添加一个事务字段来实现这一点,这只是一个围绕 RefCell
的 Option<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 共享。
-
logger
logger
模块使用了tracing、env_logger和log4rs这些crate来配置日志输出到标准输出或者server.log
文件,具体取决于你的需求。 -
crypto
包含加密工具,用于加密和签名数据以及生成令牌。
-
web
包含HTTP和websockets的各种辅助工具和实用程序。
-
http
其中最值得注意的是HTTP的默认安全头中间件(为每个请求设置所有推荐的安全头,详情请见这里)和Response特质,这是一个实用特质,可以被任何需要转换为HTTP响应的结构体实现。还有一些cookie辅助工具。
-
ws
包含一个WebSocket会话处理器的模块。
发送给这个处理器的每个消息都必须有一个顶层
"domain"
字段。域名是完全任意的,用于告诉ws会话广播哪种数据类型。域名在内部映射到数据类型。演员可以通过代理订阅他们感兴趣的具体数据类型,当ws会话演员从各自的客户端接收任何消息时,它们会相应地发布这些消息。
注册的数据类型通常是枚举,然后在接收者的处理程序中进行匹配。枚举应该始终是无标签的,以减少客户端套接字的不必要嵌套。
使用基于actix框架的代理实现,这是一个基于Actor模型的非常酷的消息传递系统。
请查看
web::ws
模块以获取更多信息和工作示例。
-
-
cache
包含一个cacher特质,可以用于需要访问缓存的服务的实现。每个服务都必须有它的缓存域名和用于缓存分离的标识符。《code>CacheAccess和
CacheIdentifier
特质可以用于此类目的。
关于中间件的说明
结构类似于上述端点。如果你对Actix的中间件工作原理感兴趣,可以阅读这篇不错的博客文章。通过用中间件包装资源,我们可以在请求实际到达处理器之前访问它。这使得我们可以向请求中添加任何数据,以便由指定的处理器使用。本质上,我们必须为中间件实现Transform
特质,并为实际业务逻辑实现Service
特质。
如果您查看 auth
中间件,会发现我们的 Transform
实现,特别是 new_transform
函数,返回一个包含结果的 future,该结果包含 AuthMiddleware
或一个 InitError
,它是一个单例类型。如果您查看 Actix 的 wrap
函数的签名,您会看到我们可以传递给它任何实现了 Transform
的东西。这意味着,例如,当我们想要将我们的 AuthGuardMiddleware
包装在一个资源上时,我们必须传递实例化的 AuthGuard
结构体,因为它是实现 Transform
的。如果您更仔细地查看 wrap
中发生的事情,您会看到它内部触发 new_transform
,这意味着实例化的 AuthGuard
转换为 AuthGuardMiddleware
,执行所有业务。
结构与端点的结构完全相同,只是 interceptor.rs 除外,该文件包含我们的 Transform
和 Service
实现。中间件的主要功能位于 Service
实现的 call
函数中。
配置文件
我们在服务器的 src
目录中的 config.rs
文件中将所有处理程序连接在一起。只有一个端点时,它看起来可能如下所示
pub(super) fn init(cfg: &mut ServiceConfig) {
let pg = Arc::new(Postgres::new());
users::setup::routes(pg, cfg);
}
然后我们将此函数传递给我们的服务器设置。
HttpServer::new(move || {
App::new()
.configure(config::init)
.wrap(Logger::default())
})
.bind_openssl(addr, builder)?
.run()
.await
有关 openssl 设置的更多信息,请参阅 openssl/README.md
辅助模块包含服务器中可用的各种辅助函数。
存储目录概述
存储 crate 是特定于项目的,因此它与其余部分完全分离。它包含 3 个主要模块
-
存储库
包含与应用程序模型交互的接口。它们的唯一目的是描述与数据库的交互性质,它们完全不了解实现。此模块旨在尽可能通用,可在服务逻辑的任何地方使用。
-
适配器
包含存储库接口的客户特定实现。适配器根据其基础存储库指定的行为进行适配。将实现与行为分离,使任何使用存储库的其他模块与适配器中位于适配器中的客户特定代码解耦。
-
模型
应用程序模型所在的位置。
存储适配器可以利用从客户端模块建立的连接。
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 crate。它分析data
、handler
和setup
文件的语法,并提取文档端点所需的信息。
所有命令都接受-v
标志,代表“详细”模式,如果为真,则会将xtc正在做什么打印到stdout。默认情况下,所有命令都是静默运行的。
待办事项
- 使用
xtc init
初始化项目
依赖项
~1.5MB
~35K SLoC