28个版本
0.7.0 | 2024年3月27日 |
---|---|
0.6.0 | 2023年10月15日 |
0.5.3 | 2023年3月24日 |
0.5.2 | 2022年12月30日 |
0.2.1 | 2022年3月13日 |
#249 in Rust模式
83 每月下载量
75KB
383 代码行
entrait
用于设计松散耦合Rust应用程序的proc宏。
entrait
用于从常规函数的定义生成一个实现过的特性。使用它产生的模式可以做到以下事情
- 零成本的松散耦合和反转控制
- 依赖图作为一个编译时概念
- 模拟库集成
- 干净、易读、无样板代码
简介
该宏看起来像这样
#[entrait(MyFunction)]
fn my_function<D>(deps: &D) {
}
它生成一个名为MyFunction
的新单方法特性,其方法签名来自原始函数。Entrait是一个纯追加宏:它永远不会改变您函数的语法。它生成的新语言项将出现在函数下方。
在第一个例子中,my_function
有一个名为deps
的单个参数,它是类型D
的泛型,表示注入到函数中的依赖项。依赖参数始终是第一个参数,这类似于生成特性方法的&self
参数。
要添加依赖项,我们只需引入一个特性界限,现在可以表达为impl Trait
。这可以通过查看一个函数调用另一个函数来演示
#[entrait(Foo)]
fn foo(deps: &impl Bar) {
println!("{}", deps.bar(42));
}
#[entrait(Bar)]
fn bar<D>(deps: &D, n: i32) -> String {
format!("You passed {n}")
}
多个依赖项
其他框架可能通过为每个依赖项提供一个值来表示多个依赖项,但entrait在同一个值中表示所有依赖项。当依赖参数是泛型时,其特性界限指定了函数内部可以调用的方法。
可以使用&(impl A + B)
语法来表示多个界限。
单值依赖设计意味着始终传递的是相同的引用。但引用的是什么,确切地说?这正是我们成功抽象出来的,也就是关键点。
运行时和实现
当我们想要编译一个可工作的应用程序时,我们需要将实际类型注入到各个entrait入口点。以下两点将很重要
- 图中的所有特质界限将隐式“冒泡”到入口点级别,因此我们最终使用的类型需要实现所有这些特质才能进行类型检查。
- 这些特质的实现需要做正确的事情:实际调用entrait函数,以便将依赖图转换为实际的调用图。
Entrait生成实现特质,用于链接所有内容的类型是Impl<T>
#[entrait(Foo)]
fn foo(deps: &impl Bar) -> i32 {
deps.bar()
}
#[entrait(Bar)]
fn bar(_deps: &impl std::any::Any) -> i32 {
42
}
let app = Impl::new(());
assert_eq!(42, app.foo());
🔬 检查生成的代码 🔬
链接发生在为Impl<T>
生成的impl块中,整个impl处于从原始依赖界限派生的where子句之下
impl<T: Sync> Foo for Impl<T> where Self: Bar {
fn foo(&self) -> i32 {
foo(self) // <---- calls your function
}
}
Impl
是泛型的,所以我们可以将其放入任何类型中。通常这将是代表运行应用程序的全局状态/配置的类型。但如果我们只能将依赖项视为特质,并且我们总是抽象出此类型,那么这个状态如何才能被访问呢?
具体依赖
到目前为止,我们只看到了基于泛型特质的依赖,但依赖也可以是具体类型
struct Config(i32);
#[entrait(UseTheConfig)]
fn use_the_config(config: &Config) -> i32 {
config.0
}
#[entrait(DoubleIt)]
fn double_it(deps: &impl UseTheConfig) -> i32 {
deps.use_the_config() * 2
}
assert_eq!(42, Impl::new(Config(21)).double_it());
use_the_config
的参数在第一个位置,因此它代表依赖。
我们将注意到两个有趣的事情
- 依赖于
UseTheConfig
的函数,无论是直接还是间接,现在只有一个有效的依赖类型:Impl<Config>
1。 - 在
use_the_config
内部,我们有&Config
引用而不是&Impl<Config>
。这意味着我们无法调用其他entrait函数,因为它们没有为Config
实现。
最后一点意味着具体依赖是尽头的终点,是依赖图中的叶子。
通常,具有具体依赖的函数应该保持小巧并避免复杂业务逻辑。它们理想地充当访问器,在具体应用程序状态上提供松散耦合的抽象层。
模块支持
为了减少生成的特质的数量,entrait可以用作mod
属性。当以这种方式使用时,宏将在模块作用域内直接查找非私有函数,将它们表示为特质的成员。此模式与独立函数模式几乎完全相同。
#[entrait(pub MyModule)]
mod my_module {
pub fn foo(deps: &impl super::SomeTrait) {}
pub fn bar(deps: &impl super::OtherTrait) {}
}
此示例生成一个包含foo
和bar
方法的MyModule
特质。
测试
使用Unimock
进行特质模拟
entrait的整个目的是提供控制反转,以便在单元测试函数体时可以使用替代依赖实现。虽然测试代码可以包含手动特质实现,但测试最方便的方法是使用模拟库,这可以通过更少的代码提供更多功能。
entrait与unimock配合使用效果最佳,因为这两个crate从一开始就是为彼此设计的。
Unimock导出单个模拟结构体,该结构体可以作为参数传递给所有接受通用deps
参数的函数(假设entrait在所有位置都使用了unimock支持)。要启用对entrait函数的模拟配置,请提供mock_api
选项,例如,如果特质名称为Trait
,则提供mock_api=TraitMock
。这对于特质模块也适用,只是它们已经有了要导出的模块。
通过将unimock
选项传递给entrait(#[entrait(Foo, unimock)]
)或开启unimock
功能(这使得所有entrait函数都可以模拟,即使是在上游crate中,只要提供了mock_api
)来启用Unimock对entrait的支持。
#[entrait(Foo, mock_api=FooMock)]
fn foo<D>(_: &D) -> i32 {
unimplemented!()
}
#[entrait(MyMod, mock_api=mock)]
mod my_mod {
pub fn bar<D>(_: &D) -> i32 {
unimplemented!()
}
}
fn my_func(deps: &(impl Foo + MyMod)) -> i32 {
deps.foo() + deps.bar()
}
let mocked_deps = Unimock::new((
FooMock.each_call(matching!()).returns(40),
my_mod::mock::bar.each_call(matching!()).returns(2),
));
assert_eq!(42, my_func(&mocked_deps));
与unimock的深度集成测试
Entrait与unimock支持取消模拟。这意味着测试环境可以是部分模拟的!
#[entrait(SayHello)]
fn say_hello(deps: &impl FetchPlanetName, planet_id: u32) -> Result<String, ()> {
Ok(format!("Hello {}!", deps.fetch_planet_name(planet_id)?))
}
#[entrait(FetchPlanetName)]
fn fetch_planet_name(deps: &impl FetchPlanet, planet_id: u32) -> Result<String, ()> {
let planet = deps.fetch_planet(planet_id)?;
Ok(planet.name)
}
pub struct Planet {
name: String
}
#[entrait(FetchPlanet, mock_api=FetchPlanetMock)]
fn fetch_planet(deps: &(), planet_id: u32) -> Result<Planet, ()> {
unimplemented!("This doc test has no access to a database :(")
}
let hello_string = say_hello(
&Unimock::new_partial(
FetchPlanetMock
.some_call(matching!(123456))
.returns(Ok(Planet {
name: "World".to_string(),
}))
),
123456,
).unwrap();
assert_eq!("Hello World!", hello_string);
此示例使用Unimock::new_partial
来创建一个模拟器,该模拟器基本上与Impl
相似,除了可以在任意、运行时配置的点短路调用图。示例代码通过三个层次(say_hello => fetch_planet_name => fetch_planet
)运行,并且只有最深层被模拟。
替代模拟:Mockall
如果您想使用更成熟的模拟crate,也支持mockall。请注意,mockall有一些限制。不支持多个特质边界,并且深度测试将无法工作。此外,mockall倾向于生成大量代码,通常比unimock多一个数量级。
通过使用mockall
entrait选项启用mockall。没有cargo功能可以隐式开启它,因为当通过另一个crate重导出时,mockall表现不佳。
#[entrait(Foo, mockall)]
fn foo<D>(_: &D) -> u32 {
unimplemented!()
}
fn my_func(deps: &impl Foo) -> u32 {
deps.foo()
}
fn main() {
let mut deps = MockFoo::new();
deps.expect_foo().returning(|| 42);
assert_eq!(42, my_func(&deps));
}
多crate架构
Rust应用程序开发中常用的一个常见技术是选择多crate架构。通常有两种主要的方法
- 调用图和crate依赖方向相同。
- 调用图和crate依赖方向相反。
第一种选项是库通常使用的选项:其函数只是调用,没有任何间接。
第二种选项可以称为依赖倒置原则的一种变体。这通常是一个理想的架构属性,本节将讨论如何使用entrait实现这一点。
主要目标是能够集中表达业务逻辑,并避免直接依赖于基础设施细节(洋葱架构)。本节中的所有示例都使用了一些特质和特质委派。
情况1:具体的叶级依赖
之前提到,当使用具体类型的依赖时,Impl<T>
中的T
,您的应用程序和依赖的类型必须匹配。但这只部分正确。实际上取决于哪些特质在哪些类型上实现。
pub struct Config {
foo: String,
}
#[entrait_export(pub GetFoo)]
fn get_foo(config: &Config) -> &str {
&config.foo
}
🔬 检查生成的代码 🔬
trait GetFoo {
fn get_foo(&self) -> &str;
}
impl<T: GetFoo> GetFoo for Impl<T> {
fn get_foo(&self) -> &str {
self.as_ref().get_foo()
}
}
impl GetFoo for Config {
fn get_foo(&self) -> &str {
get_foo(self)
}
}
实际上这里有一个特性 GetFoo
被实现了两次:一次是为 Impl<T>where T: GetFoo
,另一次是为 Config
。第一种实现是委派给另一个。
为了使它与任何下游应用程序类型一起工作,我们只需手动为该应用程序实现 GetFoo
。
struct App {
config: some_upstream_crate::Config,
}
impl some_upstream_crate::GetFoo for App {
fn get_foo(&self) -> &str {
self.config.get_foo()
}
}
情况 2:手写的特性作为叶子依赖项
在许多情况下,可以使用第一种情况中的具体类型 Config
。有时,一个简单的手写特性定义就能更好地完成任务。
#[entrait]
pub trait System {
fn current_time(&self) -> u128;
}
🔬 检查生成的代码 🔬
impl<T: System> System for Impl<T> {
fn current_time(&self) -> u128 {
self.as_ref().current_time()
}
}
在这种情况下,属性所做的只是生成特性的正确泛型实现:委派和模拟。
要与某些 App
一起使用,应用类型本身应该实现该特性。
情况 3:使用 动态分派 的手写特性作为叶子依赖项
有时可能希望有一个涉及动态分派的委派。Entrait 有一个 delegate_by =
选项,其中您可以传递一个替代特性作为委派策略的一部分。要启用动态分派,请使用 ref
#[entrait(delegate_by=ref)]
trait ReadConfig: 'static {
fn read_config(&self) -> &str;
}
🔬 检查生成的代码 🔬
impl<T: ::core::convert::AsRef<dyn ReadConfig> + 'static> ReadConfig for Impl<T> {
fn read_config(&self) -> &str {
self.as_ref().as_ref().read_config()
}
}
要与此一起使用某些 App
,它应该实现 AsRef<dyn ReadConfig>
特性。
情况 4:真正反转的 内部依赖 - 静态分派
到目前为止的所有情况都是 叶子依赖。叶子依赖是从 Impl<T>
层退出,使用涉及具体 T
的委派目标。这意味着无法继续使用 entrait 模式并在那些抽象之后扩展您的应用程序。
为了使您的抽象 可扩展 并使依赖 内部,我们必须将 T
泛型保留在 [Impl] 类型内部。为了使这可行,我们必须使用两个辅助特性
#[entrait(RepositoryImpl, delegate_by = DelegateRepository)]
pub trait Repository {
fn fetch(&self) -> i32;
}
🔬 检查生成的代码 🔬
pub trait RepositoryImpl<T> {
fn fetch(_impl: &Impl<T>) -> i32;
}
pub trait DelegateRepository<T> {
type Target: RepositoryImpl<T>;
}
impl<T: DelegateRepository<T>> Repository for Impl<T> {
fn fetch(&self) -> i32 {
<T as DelegateRepository<T>>::Target::fetch(self)
}
}
此语法引入了总共 三个 特性
Repository
:依赖,其余应用程序直接调用的。RepositoryImpl<T>
:委派目标,需要某些Target
类型实现的特性。DelegateRepository<T>
:委派选择器,它选择用于某些特定App
的特定Target
类型。
此设计使得可以将关注点分离到三个不同的存储库中,从最上游到最下游排序
- 核心逻辑: 依赖并调用
Repository
方法。 - 外部系统集成: 通过实现
RepositoryImpl<T>
提供一些仓库的实现。 - 可执行文件: 从存储库 2 中构建一个选择特定仓库实现的
App
。
所有从 Repository
到 RepositoryImpl
<T> 的委派都通过 DelegateRepository
<T> 特性进行。在 RepositoryImpl
<T> 中的方法签名是 静态 的,并且通过一个普通参数接收 &Impl
<T>。这使得我们可以在那些实现中继续使用 entrait 模式!
在 crate 2 中,我们必须为 RepositoryImpl
<T> 提供一个实现。这可以通过手动实现或使用 impl
块上的 [entrait] 属性来完成。
pub struct MyRepository;
#[entrait]
impl crate1::RepositoryImpl for MyRepository {
// this function has the now-familiar entrait-compatible signature:
fn fetch<D>(deps: &D) -> i32 {
unimplemented!()
}
}
🔬 检查生成的代码 🔬
impl MyRepository {
fn fetch<D>(deps: &D) -> i32 {
unimplemented!()
}
}
impl<T> crate1::RepositoryImpl<T> for MyRepository {
#[inline]
fn fetch(_impl: &Impl<T>) -> i32 {
Self::fetch(_impl)
}
}
Entrait 将把这个特性实现块分为两部分:一个包含原始代码的 固有 部分,以及一个执行委派的正确特性实现。
最后,我们只需要实现我们的 DelegateRepository
<T>。
// in crate3:
struct App;
impl crate1::DelegateRepository<Self> for App {
type Target = crate2::MyRepository;
}
fn main() { /* ... */ }
案例 5:真正倒置的内部依赖 - 动态委派
案例 4 的小变体:使用 delegate_by=ref
而不是自定义特性。这使得委派通过动态委派来实现。
实现语法几乎与案例 4 相同,只是 entrait 属性现在必须是 #[entrait
<(ref)>
#[entrait(RepositoryImpl, delegate_by=ref)]
pub trait Repository {
fn fetch(&self) -> i32;
}
pub struct MyRepository;
#[entrait(ref)]
impl RepositoryImpl for MyRepository {
fn fetch<D>(deps: &D) -> i32 {
unimplemented!()
}
}
现在应用程序必须实现 AsRef
<dyn RepositoryImpl<Self>。
选项和功能
特性可见性
默认情况下,entrait 生成的特性是模块私有的(没有可见性关键字)。要更改此设置,只需在特性名称之前放置一个可见性指定符即可。
use entrait::*;
#[entrait(pub Foo)] // <-- public trait
fn foo<D>(deps: &D) { // <-- private function
}
异步支持
零成本、静态委派的 async
默认情况下即可使用。
当需要动态委派时,例如与 delegate_by=ref
结合使用时,entrait 在 entrait 宏应用之后理解 #[async_trait
> 属性。Entrait 将根据需要重新应用该宏到各种生成的 impl 块。
异步 Send
性
类似于 async_trait
,entrait 默认情况下通过 futures 生成了一个 [Send] 绑定。要放弃 Send 绑定,请将 ?Send
作为宏参数传递。
#[entrait(ReturnRc, ?Send)]
async fn return_rc(_deps: impl Any) -> Rc<i32> {
Rc::new(42)
}
与其他 fn
定向宏和 no_deps
集成
一些宏用于转换函数的主体,或从头生成主体。例如,我们可以使用 feignhttp
来生成 HTTP 客户端。Entrait 将尽可能地与这些宏共存。由于 entrait
是一个高级宏,它不接触 fn 主体(甚至不尝试解析它们),因此 entrait 应该在之后处理,这意味着它应该放在 之前 低级宏。示例
#[entrait(FetchThing, no_deps)]
#[feignhttp::get("https://my.api.org/api/{param}")]
async fn fetch_thing(#[path] param: String) -> feignhttp::Result<String> {}
在这里,我们必须使用 no_deps
特性选项。这用于告诉 entrait,该函数没有以 deps
参数作为其第一个输入。相反,所有函数的输入都提升为生成的特质方法。
模拟的条件编译
通常,你只需为测试代码生成模拟实现,并跳过生产代码。一个值得注意的例外是构建库。当一个应用程序由几个包组成时,下游包可能希望模拟库中的功能。
Entrait 将此称为 导出,并且无条件地打开自动生成模拟实现的开关
#[entrait_export(pub Bar)]
fn bar(deps: &()) {}
或
#[entrait(pub Foo, export)]
fn foo(deps: &()) {}
你也可以通过执行 use entrait::entrait_export as entrait
来减少噪声。
特性概述
特性 | 意味着 | 描述 |
---|---|---|
unimock |
添加 [unimock] 依赖项,并打开所有特质的 Unimock 实现。 |
"哲学"
entrait
包是 entrait 模式 的核心,这是一种有见地但灵活且 Rusty 的方式来构建可测试的应用程序/业务逻辑。
要理解 entrait 模型以及如何用它实现依赖注入(DI),我们可以将其与更广泛使用的经典替代模式进行比较:面向对象的 DI。
在面向对象的 DI 中,每个命名的依赖项都是一个单独的对象实例。每个依赖项导出一组公共方法,并在内部指向一组私有依赖项。通过完全实例化这样一个相互连接的依赖项 对象图 来构建一个工作应用程序。
Entrait 被构建来解决这种设计固有的两个缺点
- 在 Rust 中,表示一个对象(即使是无环的)的图通常需要引用计数/堆分配。
- 每个 "依赖" 抽象通常包含很多不同的功能。例如,考虑基于 DDD 的应用程序,这些应用程序由
DomainServices
组成。通常,每个领域对象都有一个此类,其中包含许多方法。这导致依赖图中的节点总数较少,但可能的 调用图 数量要大得多。这种常见问题是实际依赖项——实际被调用的函数——被封装并隐藏在公共接口之外。为了在单元测试中构建有效的依赖项模拟,开发者必须阅读完整的函数体而不是查看签名。
entrait
通过以下方式解决这个问题
- 将依赖项表示为 特质 而不是类型,自动利用 Rust 的内置零成本抽象工具。
- 通过启用单功能特性和基于模块的特质,为用户提供精细和粗粒度依赖项选择。
- 始终在函数签名级别声明依赖项,接近调用点,而不是在模块级别。
限制
本节列出了 entrait 的已知限制
循环依赖图
循环依赖图在 entrait 中是不可能的。事实上,这并不是 entrait 自身的限制,而是 Rust 的特质求解器的限制。它无法证明一个类型实现了特质,如果它需要证明它来实现它以证明这一点。
虽然这是一个限制,但它并不一定是一个坏限制。有人可能会说,分层应用程序架构永远不会包含循环。如果你确实需要递归算法,你可以将此作为应用程序 entraited API 之外的实用函数来建模。
[^1]: 也就是说,从 [Box] 中出来!在 entrait 版本 0.7 及更高版本中,异步函数默认为零成本。
依赖项
~0.3–0.8MB
~19K SLoC