Rust的traits和依赖注入的更好实现 - jmmv


依赖注入是我最喜欢的用于开发高度可测试和模块化代码的设计模式之一。要应用此模式,您所要做的就是遵循两个简单的准则:

  1. 将对象构造与使用分开。实际上:停止在构造函数中创建对象并将这些对象作为输入参数。
  2. 使用接口而不是具体类型作为构造函数参数。通过这种方式,接收器对这些类型的实现保持不可知,因此可以提供不同的实现。

依赖注入是一个简单的概念:不需要花哨的框架。至于如何定义通用接口,技术取决于您选择的语言。在 C++ 中,您将定义纯抽象类;在 Java、Go 和 C# 中,您可使用interface接口;在 Rust 中,您将使用traits。

这篇文章是关于 Rust 的,所以让我们谈谈使用 trait 进行依赖注入,它们如何产生令人讨厌的副作用,以及我们能做些什么。

案例
当正确应用依赖注入模式时,库 crate 会暴露:

  1. 表示重量级对象的traits;
  2. 实现这些traits的对象集合;和
  3. 通过通用traits使用这些对象的函数。

然后,库的使用者实例化他们需要的特定对象,将它们连接在一起以创建依赖关系图,并将它们提供给库公开的通用业务逻辑函数。

我们首先定义一个 trait 来表示我们需要数据库连接公开的通用特性。通过这种方式,日志(业务)逻辑可以记录日志条目,并且不知道它正在与哪个特定数据库通信。
然后,我们添加一个函数来基于抽象连接对象初始化记录器:

#[async_trait]
/// Operations that an arbitrary database connection can perform.
pub trait Db {
    async fn put_log_entries(&self, es: Vec<LogEntry<'_, '_>>) -> Result<()>;
}

/// Initializes the logging subsystem to record entries in `db`.
pub fn init(db: Arc<dyn Db + Send + Sync + 'static>) {
    // ...
}

有了这个接口,消费者db_logger可以通过使用实现traits的替代对象来选择要连接的数据库Db——现在有两个实现:PostgresDb和SqliteDb.

在traits的使用消费者这边(比如 from src/main.rs):
当我们想与 PostgreSQL 交互时,我们可以这样做:

let db = Arc::from(db_logger::PostgresDb::connect_lazy(
    host, port, database, username, password));
db_logger::init(db);  // Doesn't care about which specific `db`.

想与 SQLite 交互时是这样的:

let db = Arc::from(db_logger::SqliteDb::connect(uri));
db_logger::init(db);  // Doesn't care about which specific `db`.

请注意,由于这种设计,不仅下游的消费者可以得到更好的服务:业务逻辑单元测试也可以使用这种抽象,以保持稳定和极快的速度。特别是,日志逻辑的单元测试注入了一个连接到内存中的SQLite数据库,这使它们变得超级快,并避免了由于错误配置或网络问题造成的松动。

听起来不错,对吗?嗯,确实如此,但请注意上面的Db traits在其put_log_entries()函数中如何引用LogEntry类型。这个类型,db_logger这边的用户是不应该知道的,但现在也必须倍写成公共的,因为Db是公共的。而这是一个很大的过渡性问题。

问题所在
在Rust中使用trait进行依赖注入的关键问题是,函数签名中引用的任何类型必须至少与函数本身一样可见。
这意味着如果一个trait是公开的(比如上面的Db trait),那么该trait的任何函数所引用的任何类型(比如上面的LogEntry结构),也必须是公开的。

过于广泛的可见性至少有两个原因是有问题的。

  • 破坏了封装性。库(crate)的用户不应该看到属于库内部的API。否则,他们很容易在实现细节上产生依赖性,这可能会破坏行为,并会使你作为维护者的生活在未来变得更加困难。
  • 死代码检测不充分。一旦一个类型被标记为公共的,编译器就不能声称它是未使用的,即使没有其他东西在板条箱内使用该类型。这是病毒性的:一个未使用的类型可能被未使用的函数所引用,而这些函数又可能引用其他未使用的类型,等等。链接时的优化可以使这个问题在运行时(几乎)不存在,但任何死代码在开发过程中都是一种责任,因为它妨碍了维护。

这个问题并不只存在于库框中。如果你按照建议将大部分代码放在一个私有库中,并让 src/main.rs 成为该库的一个简单门面,那么二进制crates也会受到影响。另外,如果有集成测试,任何cockate都会受到影响,因为这些测试只能与你的公共接口交互。

那么,我们能做些什么来保持我们的架构正常呢?

好的解决方案:newtype
解决可见性问题的想法是引入一个新的具体类型,将trait包装成其单一成员。然后,这个具体的类型被公开,而trait(以及它所有的依赖关系)可以保持私有。

对于我们的db_logger案例研究,我们所要做的就是引入一个新的类型,像这样。

#[derive(Clone)]
pub struct Connection(Arc<dyn Db + Send + Sync + 'static>);

请注意 Connection 是如何包装 Db trait的,但现在,该trait是struct的一个实现细节,不一定是公开的。还要注意这如何隐藏了Db实例表示的复杂性:Arc和所有trait的边界现在都隐藏在struct中,不会污染公共API。

有了这个,我们就可以用一些工厂方法来更新我们的Db具体实现。

/// Factory to connect to a PostgreSQL database.
pub fn connect_lazy(opts: ConnectionOptions) -> Connection {
    Connection(Arc::from(PostgresDb::connect_lazy(opts, None)))
}

/// Factory to connect to a SQLite database.
pub async fn connect(opts: ConnectionOptions) -> Result<Connection> {
    SqliteDb::connect(opts).await.map(|db| Connection(Arc::from(db)))
}

最后,我们的调用者代码可以实现其中一个,以轻松地设置记录器。

let conn = if (use_real_db) {
    postgres::connect_lazy(...)
} else {
    sqlite::connect(...)
};
db_logger::init(conn);


看吧。通过使用 newtype 习惯法将特质隐藏在一个结构中,特质和它所有的内部依赖类型可以重新成为私有的。而且,正如预期的那样,编译器现在可以发现未使用的代码。

更新(2022-04-23)。有些人通过其他渠道提出,使用静态调度可能是一个更好的方法,以避免上述解决方案带来的运行时开销。也许吧。我在写这篇文章或引用的代码时还没有想到这一点。探索这个途径会很有趣。