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模式

Download history 58/week @ 2024-04-03 7/week @ 2024-04-10 13/week @ 2024-04-17 6/week @ 2024-04-24 2/week @ 2024-05-22 2/week @ 2024-05-29 7/week @ 2024-06-05 6/week @ 2024-06-12 2/week @ 2024-06-19 8/week @ 2024-06-26 67/week @ 2024-07-03 7/week @ 2024-07-10

83 每月下载量

MIT 许可证

75KB
383 代码行

entrait

crates.io docs.rs CI

用于设计松散耦合Rust应用程序的proc宏。

entrait用于从常规函数的定义生成一个实现过的特性。使用它产生的模式可以做到以下事情

  • 零成本的松散耦合和反转控制
  • 依赖图作为一个编译时概念
  • 模拟库集成
  • 干净、易读、无样板代码

产生的模式被称为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) {}
}

此示例生成一个包含foobar方法的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架构。通常有两种主要的方法

  1. 调用图和crate依赖方向相同。
  2. 调用图和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 类型。

此设计使得可以将关注点分离到三个不同的存储库中,从最上游到最下游排序

  1. 核心逻辑: 依赖并调用 Repository 方法。
  2. 外部系统集成: 通过实现 RepositoryImpl<T> 提供一些仓库的实现。
  3. 可执行文件: 从存储库 2 中构建一个选择特定仓库实现的 App

所有从 RepositoryRepositoryImpl<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