依赖注入是我在开发高可测试性和模块化代码时最喜欢的设计模式之一。要应用这个模式,你需要做的就是遵循两个简单的准则:
将对象的构造与使用分开。在实践中,停止在构造函数中创建对象,而是将这些对象作为输入参数。
使用接口而不是具体类型作为构造函数的参数。通过这种方式,接收方没有接收接口的具体类型,因此可以提供不同的实现。
依赖注入确实是可测试性的关键,但它也是一个很好的设计原则,因为它保持了系统各部分松散耦合。最好的一点是依赖注入是一个简单的概念,不需要花哨的框架。
至于如何定义通用接口,技术上取决于你选择的语言。在c++中,可以定义纯抽象类;在Java, Go和c#中,可以定义接口;在Rust中,使用Trait。
这篇文章是关于Rust的,所以让我们来谈谈使用Trait来实现依赖注入,它们会产生怎样的副作用,以及我们能做些什么。
一个例子
当依赖注入模式被正确应用时,一个库crate就会体现出:
Trait代表一个重量级对象;
实现这些Trait的对象集合;
通过泛型Trait,函数使用这些对象;
然后,库的使用者实例化他们需要的特定对象,将它们连接在一起以创建依赖关系图,并将它们提供给库公开的通用业务逻辑函数。
明白了吗?是的,我想没有。让我们来看一个具体的例子,这样我们就可以为下面的解释提供参考代码。为此,我将使用我最近发布的db_logger crate 的代码。
db_logger提供了一个日志facade实现,它将日志消息记录到数据库中。存储消息的数据库取决于你,PostgreSQL或者SQLite(默认),这个选择是通过依赖注入实现的。
你可能会认为,对于选择数据库后端,Cargo特性已经足够了,但是对于这种配置来说,Cargo特性不是一个很好的工具。Cargo特性非常适合于控制构建的依赖关系(db_logger确实为此提供了postgres和sqlite特性),但作为用户,仍然必须通过代码选择与哪个数据库通信。配置每个后端数据库需要不同的设置,你甚至可能希望在运行时选择一个后端数据库。
为了支持运行时配置,我们首先定义一个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的使用者可以通过使用实现Db特征的对象来选择要连接的数据库——现在有两个对象:PostgresDb和SqliteDb。
在客户端(比如,从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 trait 是如何在其put_log_entries()函数中引用LogEntry类型的。这种类型,db_logger的用户不需要知道,但是现在也必须是public的,因为Db是public的。这是一个很大的传递问题。
问题
在Rust中使用trait进行依赖注入的关键问题是,函数签名中引用的任何类型必须至少和函数本身一样可见。这意味着,如果Trait是public的(如上面的Db特征),那么任何特征函数(如上面的LogEntry结构)引用的任何类型也必须是public的。
过于广泛的可见性是有问题的,至少有两个原因:
破坏封装,库(crate)的用户不应该看到库内部的api。否则,他们很容易依赖于实现细节。
未使用代码检测不足,一旦一个类型被标记为public,编译器就不能声明该类型未被使用,即使crate中没有其他东西使用该类型。未使用的类型可能被未使用的函数引用,而函数又可能引用其他未使用的类型,等等。链接时优化可以在运行时(几乎)消除这一问题,但任何死代码在开发期间都是一个不利因素,因为它会妨碍维护。
这个问题并不只存在于lib的crate中,二进制crate也会受到影响。另外,如果有集成测试,任何crate都可能受到影响,因为这些测试只能与你的公共接口交互。
那么,我们能做些什么来保持我们的架构正常呢?
糟糕的解决方案:“无所不能的函数”
第一个解决方案是尝试将有问题的特性隐藏在我称之为“无所不能的函数”(因为没有更好的名称)后面。
这是我去年在几个项目中第一次做的,在那些项目中,我曾经有一个serve_rest_api()公共函数,它获取数据库连接,然后启动它支持的REST服务器。为了隐藏这些特征,我将这个函数重命名为serve_rest_api_internal(),然后添加了一个新的serve_rest_api()函数,该函数接受配置参数来决定实例化哪个对象,从而包含了src/main.rs中先前存在的大部分逻辑。
不用说,这很难看,因为我们失去了可组合性,而且这看起来很糟糕,因为我们把主程序的责任硬塞到库中——所有这些都是为了解决一些可见性问题。从API设计的角度来看,这不是一个好的权衡。
更糟糕的是,这种方法不适用于db_logger。你可以想象公开了init_postgres()和init_sqlite()公共函数(同样,这不利于可组合性),它们在其中创建数据库对象。我尝试过这么做,但我在集成测试中遇到了一些噩梦般的问题,因为我必须跨异步运行时边界处理生命周期和异步任务(Drop being sync是……麻烦的)。
这个麻烦的结果是,我最终不得不放弃这个“解决方案”,花了几个令人挠头的早晨来寻找替代方案——这是一件好事,因为从设计的角度来看,这些“做所有事情的功能”真的很糟糕。
好的解决方案:newtype
我不知道为什么我花了这么长时间才得出使用新类型来隐藏Trati的结论。我想我太沉迷于让上述特定的解决方案发挥作用了,这阻止了我看到另一种方法。回想起来,这听起来微不足道,但事实就是这样。
解决可见性问题的想法是引入一个新的具体类型,它将Trait包装为单个成员。然后,这个具体类型是public的,trait(以及它的所有依赖项)可以保持私有。
对于我们的db_logger案例研究,我们所要做的就是引入一个新的类型,就像这样:
#[derive(Clone)]
pub struct Connection(Arc<dyn Db + Send + Sync + 'static>);
注意Connection是如何包装Db Trait的,但是现在,Trait是Struct的实现细节,不必是public的。还要注意这是如何隐藏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 将trait隐藏到一个struct中,trait及其所有内部依赖类型可以再次变为私有。而且,正如预期的那样,编译器现在可以找出未使用的代码。
本文翻译自:
https://jmmv.dev/2022/04/rust-traits-and-dependency-injection.html