阅读前的须知
本文不是 Rust 类型系统论文,只是我在 AsterDrive 和 ShortLinker 里反复写trait、impl Trait、dyn Trait之后,总结出来的一点工程生存经验。
看完之后如果你还是觉得 Rust 很烦,那正常,它确实烦。
“你是不是也写过那种
match backend { local => ..., s3 => ..., redis => ... },然后越写越像在给未来的自己挖坟?”
——AptS:1548
Rust 里讲“多态”,绕不开 trait、impl Trait、dyn Trait。这三个词看着像一家人,实际脾气完全不一样。
你本来只是想让 Local、S3、Redis、Memory 这些实现共用一套接口,结果编译器冷不丁给你来一句:
the trait cannot be made into an object或者:
if and else have incompatible types好,开始怀疑人生。
本文就拿两个项目说人话:AsterDrive 里的存储驱动怎么用 trait / dyn Trait 把 Local、S3、Remote 统一起来;ShortLinker 里的缓存插件怎么自注册,然后按配置把 memory、redis、bloom 摇出来。
目标只有一个:别再看到 trait、impl Trait、dyn Trait 就原地开摆。
一、trait:先把规矩写出来
trait 是一份行为契约。别一上来想 Local 怎么写、S3 怎么写,先问一句:“你想当存储驱动?那你至少得会什么?”AsterDrive 里的 StorageDriver 可以简化成这样:
#[async_trait]
pub trait StorageDriver: Send + Sync {
async fn put(&self, path: &str, data: &[u8]) -> Result<String>;
async fn get(&self, path: &str) -> Result<Vec<u8>>;
async fn delete(&self, path: &str) -> Result<()>;
async fn exists(&self, path: &str) -> Result<bool>;
}这段代码不关心具体实现,只规定:谁想被当成存储驱动,谁就得实现这些方法。于是本地存储可以 impl StorageDriver for LocalDriver,S3 可以 impl StorageDriver for S3Driver,Remote 也可以 impl StorageDriver for RemoteDriver。这就是 trait 最基础的价值:把“能力”从“具体类型”里拆出来。
如果不用 trait,业务逻辑就很容易变成这样:
match driver_type {
DriverType::Local => local_put(...),
DriverType::S3 => s3_put(...),
DriverType::Remote => remote_put(...),
}一开始看起来没问题,后面新增一个后端,改一堆 match;某个分支忘了补,线上行为开始抽象;不支持的能力随手 panic!("unsupported"),然后某天一个边缘请求真走到了那里。好,你成功给自己找了一堆麻烦,到时候不支持还得一堆 match 调用,一不小心手抖成 unsupported!,运行直接炸掉。
所以 trait 不是为了显得高级,而是为了把边界讲清楚:业务逻辑只关心“你能不能存”,不关心“你到底是本地磁盘还是 S3”。
二、impl Trait:编译器知道是谁,只是你懒得写全名
impl Trait 经常被误解成 dyn Trait 的简化版,不是。它更像:“这里有一个具体类型,它实现了这个 trait;编译器知道它是谁,但我不想把类型名写出来。”
比如 AsterDrive 的路由函数里会有这种写法:
pub fn routes() -> impl HttpServiceFactory {
web::scope("/api/v1/files")
.service(list_files)
.service(upload_file)
}返回值并不是“随便哪个 HttpServiceFactory 都行”,它是某一个确定类型,只是那个类型太长,写出来跟咒语一样,所以用 impl HttpServiceFactory 盖住。
参数位置也常见:
fn storage_driver_error(message: impl Into<String>) -> AsterError {
AsterError::storage_driver_error(message.into())
}它基本等价于:
fn storage_driver_error<T: Into<String>>(message: T) -> AsterError {
AsterError::storage_driver_error(message.into())
}也就是说,参数里的 impl Trait 很多时候就是匿名泛型。它适合“调用时类型已经确定,只是我不想写泛型参数”的场景。
但返回值里的 impl Trait 有个很重要的限制:它只能隐藏一种具体类型。这样通常不行:
fn create_driver(kind: DriverType) -> impl StorageDriver {
match kind {
DriverType::Local => LocalDriver::new(),
DriverType::S3 => S3Driver::new(),
}
}LocalDriver 和 S3Driver 都实现了 StorageDriver,不代表它们是同一个类型。impl Trait 的意思是“我返回某一个具体类型,只是不告诉你”,不是“我今天心情好,想返回谁就返回谁”。如果你确实要在运行时根据配置返回不同实现,那就别硬拗 impl Trait 了,该 dyn Trait 上场。
三、dyn Trait:运行时才知道是谁
dyn Trait 才是运行期多态。它的意思是:“我不关心你具体是什么类型,只要你实现了这个 trait,我就按这套接口调用你。”
AsterDrive 测试存储策略连接时,逻辑可以简化成这样:
let driver: Box<dyn StorageDriver> = match driver_type {
DriverType::Local => Box::new(LocalDriver::new(&policy)?),
DriverType::Remote => Box::new(RemoteDriver::new(&policy, &remote_node)?),
DriverType::S3 => Box::new(S3Driver::new(&policy)?),
};
probe_storage_driver(driver.as_ref()).await?;这里每个分支的具体类型不同,用 Box<dyn StorageDriver> 之后,它们被统一成“一个实现了 StorageDriver 的东西”。后面的函数就可以写成:
async fn probe_storage_driver(driver: &dyn StorageDriver) -> Result<()> {
driver.put("_test", b"ok").await?;
driver.delete("_test").await?;
Ok(())
}几个常用形态也顺手分一下:&dyn Trait 是借用,不拥有;Box<dyn Trait> 是拥有一个 trait object,通常放堆上;Arc<dyn Trait> 是共享所有权,适合服务端状态、异步任务、全局组件。
所以服务端代码里很常见:
pub cache: Arc<dyn CacheBackend>,
pub mail_sender: Arc<dyn MailSender>,这不是为了写得高级,而是因为运行时确实可能换实现:开发环境用 Memory,生产环境用 Redis,测试环境用 Noop,邮件发送可以是真 SMTP,也可以是内存假发送器。业务逻辑不应该到处认识这些具体实现,不然你改一处配置,半个项目都得跟着补分支,迟早把自己绕晕。
四、dyn Trait 的代价:不是不能用,是别装不知道
dyn Trait 靠动态派发工作。一个 &dyn Trait 通常是胖指针:一个指向真实数据,一个指向 vtable,也就是方法表。调用方法时,它会通过 vtable 找到真正的方法,所以它比静态派发多一点运行期开销。
但在 Web 服务里,别一看到“运行期开销”就开始紧张。你一个请求查数据库、打 Redis、读文件、走网络,结果盯着一次动态派发说性能不行,这就有点像房子漏水,你先去擦门牌号。真正需要注意的是另一个问题:一旦变成 dyn Trait,你就丢掉了具体类型信息。
比如你手上只有:
Arc<dyn StorageDriver>那你只知道它是一个存储驱动,不知道它到底是不是 S3。如果后面突然要用 S3 才有的 multipart upload,就麻烦了。
AsterDrive 的 DriverRegistry 没有一开始就把所有驱动全抹成 Arc<dyn StorageDriver>,而是内部保留了枚举:
enum DriverEntry {
Local(Arc<LocalDriver>),
Remote(Arc<RemoteDriver>),
S3(Arc<S3Driver>),
}普通使用时返回基础能力:
fn as_storage_driver(&self) -> Arc<dyn StorageDriver>需要 multipart 时单独取:
fn as_multipart_driver(&self) -> Option<Arc<dyn MultipartStorageDriver>>这就是很实际的工程折中:对外暴露抽象,内部保留必要的具体类型。 如果你的实现种类固定,而且确实有不同分支的特殊能力,enum 往往比强行 dyn 更舒服。别为了抽象而抽象,抽象过头,最后加一个功能要绕三层 trait object,再写两个 downcast,自己看了都想重开。
五、可选能力:不要把 trait 写成万能插座
第一次设计 trait,很容易写成这样:
trait StorageDriver {
async fn put(...);
async fn get(...);
async fn delete(...);
async fn presigned_url(...);
async fn multipart_upload(...);
async fn list_paths(...);
async fn native_thumbnail(...);
}看起来很全,实际上很危险。因为不是所有驱动都支持这些能力:Local 没有 presigned URL,S3 没有本地绝对路径,Remote 有一部分能力但不一定全有,某些对象存储有原生缩略图,某些没有。你把这些全塞进一个 trait,后面就会到处出现:
Err("unsupported")或者更糟:
panic!("unsupported")好,你成功给自己埋了雷。调用方每次都得先 match driver_type,一不小心漏个分支,到时候不是编译器救你,是线上日志提醒你。
AsterDrive 的做法是把核心能力和可选能力拆开。核心 StorageDriver 只放所有驱动都该有的基础操作,额外能力拆成扩展 trait:
trait PresignedStorageDriver {
async fn presigned_url(...);
}
trait ListStorageDriver {
async fn list_paths(...);
}
trait StreamUploadDriver {
async fn put_reader(...);
}然后在 StorageDriver 里提供能力查询:
fn as_presigned(&self) -> Option<&dyn PresignedStorageDriver> {
None
}
fn as_list(&self) -> Option<&dyn ListStorageDriver> {
None
}支持的驱动覆盖:
impl StorageDriver for S3Driver {
fn as_presigned(&self) -> Option<&dyn PresignedStorageDriver> {
Some(self)
}
}这个设计比“所有方法都塞进去,不支持就 unsupported”干净很多。调用方看到 Option,就知道这能力可能不存在;不存在是类型设计的一部分,不是运行时突然炸出来的惊喜。
六、ShortLinker:插件自己报名,系统按名字点人
AsterDrive 更像“根据策略选择驱动”,ShortLinker 的缓存系统更像插件系统。它有几层缓存能力:ExistenceFilter 判断短码是否可能存在,比如 Bloom Filter;ObjectCache 缓存短链对象,比如 Memory / Redis;NegativeCache 记录确认不存在的 key,防止扫短码把数据库打爆;CompositeCacheTrait 把几层缓存组合成统一入口。
ObjectCache 可以简化成这样:
#[async_trait]
pub trait ObjectCache: Send + Sync {
async fn get(&self, key: &str) -> CacheResult;
async fn insert(&self, key: &str, value: ShortLink, ttl_secs: Option<u64>);
async fn remove(&self, key: &str);
async fn invalidate_all(&self);
}Memory 实现它:
impl ObjectCache for MokaCacheWrapper {
async fn get(&self, key: &str) -> CacheResult {
// 从 moka 里取
}
}Redis 也实现它:
impl ObjectCache for RedisObjectCache {
async fn get(&self, key: &str) -> CacheResult {
// 从 redis 里取
}
}普通 trait 到这里就结束了。ShortLinker 更进一步:它让插件自己注册到全局 registry。注册表大概长这样:
static OBJECT_CACHE_REGISTRY:
Lazy<RwLock<HashMap<String, ObjectCacheConstructor>>> =
Lazy::new(|| RwLock::new(HashMap::new()));构造器类型是:
pub type BoxedObjectCacheFuture =
Pin<Box<dyn Future<Output = Result<Box<dyn ObjectCache>>> + Send>>;
pub type ObjectCacheConstructor =
Arc<dyn Fn() -> BoxedObjectCacheFuture + Send + Sync>;看起来很吓人,翻译一下:“注册表里放一个函数。调用它,会异步创建一个 Box<dyn ObjectCache>。”
插件通过宏注册:
declare_object_cache_plugin!("redis", RedisObjectCache);
declare_object_cache_plugin!("memory", MokaCacheWrapper);宏里面用了 #[ctor::ctor],启动时把构造器塞进注册表:
#[ctor::ctor]
fn __register_cache_plugin() {
register_object_cache_plugin(
$name,
Arc::new(|| {
Box::pin(async {
let cache = <$ty>::new().await?;
Ok(Box::new(cache) as Box<dyn ObjectCache>)
})
}),
);
}之后创建缓存时,就不需要硬编码:
if cache_type == "redis" {
RedisObjectCache::new()
} else if cache_type == "memory" {
MokaCacheWrapper::new()
}而是按配置取插件:
let object_cache_name = &config.cache.cache_type;
let ctor = get_object_cache_plugin(object_cache_name)?;
let object_cache = ctor().await?;最后组合:
pub struct CompositeCache {
filter_plugin: Arc<dyn ExistenceFilter>,
object_cache: Arc<dyn ObjectCache>,
negative_cache: Arc<dyn NegativeCache>,
}这就是 dyn Trait 很适合的场景:运行时按名字选择实现,业务逻辑只面向能力写。 以后加一个新的对象缓存,只要它实现 ObjectCache,再注册一个名字,核心逻辑不用每个地方都加 match cache_type。这才是插件系统该有的样子。
七、对象安全:不是所有 trait 都能 dyn
烦人的来了,不是所有 trait 都能写成 dyn Trait。能变成 trait object 的 trait,需要满足对象安全。不用一开始背完整规则,先记几个常见坑。
第一个坑是泛型方法。比如:
trait CacheBackend {
async fn get<T>(&self, key: &str) -> Option<T>;
}这个对 dyn CacheBackend 很不友好,因为 vtable 需要固定的方法表。泛型方法等于“对不同 T 生成不同版本”,运行期 trait object 没法这么玩。
AsterDrive 的缓存抽象就避开了这个坑。核心 trait 只暴露 bytes 接口:
#[async_trait]
pub trait CacheBackend: Send + Sync {
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>;
async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>);
}泛型序列化能力单独放到扩展 trait:
pub trait CacheExt {
fn get<T: DeserializeOwned + Send>(
&self,
key: &str,
) -> impl Future<Output = Option<T>> + Send;
}
impl CacheExt for dyn CacheBackend {
async fn get<T: DeserializeOwned + Send>(&self, key: &str) -> Option<T> {
let bytes = self.get_bytes(key).await?;
serde_json::from_slice(&bytes).ok()
}
}核心 trait 保持 object-safe,Arc<dyn CacheBackend> 就能到处传;泛型便利方法也有,只是不塞进核心 trait 里添堵。
第二个坑是返回 Self。比如:
trait CloneLike {
fn clone_like(&self) -> Self;
}如果你拿到的是 dyn CloneLike,那 Self 到底多大?是 LocalDriver,还是 S3Driver?编译器不知道。所以这种方法通常不能直接出现在 object-safe trait 里。
第三个坑是 async trait object。Rust 的 async trait 和 dyn Trait 放一起时,历史包袱不少,很多项目会用 async_trait,把 async 方法包装成 boxed future,让 trait object 能用。ShortLinker 的插件构造器里也能看到类似味道:
Pin<Box<dyn Future<Output = Result<Box<dyn ObjectCache>>> + Send>>这不是为了炫技,它只是要表达:“我要把一个异步构造过程,塞进一个可以运行时调用的注册表。”异步、闭包、trait object、插件注册表叠一起,类型长成这样不奇怪。奇怪的是你指望它长得像 JavaScript。
八、到底怎么选?
按问题选。
用 trait,是在定义能力,比如 StorageDriver、ObjectCache、MetricsRecorder。这时候先别想具体实现,先把边界定清楚。
用 impl Trait,是编译期能确定类型,只是不想写类型名,比如:
fn normalize_ids(ids: impl Iterator<Item = i64>) -> Vec<i64> { ... }
fn error(message: impl Into<String>) -> AsterError { ... }
fn routes() -> impl HttpServiceFactory { ... }这类代码简单、快、类型名不吓人,但它不适合“根据配置返回不同实现”。
用 dyn Trait,是运行时才决定具体实现,比如:
Arc<dyn CacheBackend>
Arc<dyn ObjectCache>
Box<dyn StorageDriver>
&dyn StorageDriver它适合插件系统、配置选择实现、测试替换 mock、AppState 里只暴露抽象能力、多种具体类型要塞进同一个位置。代价是动态派发、对象安全限制,以及丢掉具体类型信息。
用 enum,是实现种类固定,而且需要保留具体类型,比如:
enum DriverEntry {
Local(Arc<LocalDriver>),
Remote(Arc<RemoteDriver>),
S3(Arc<S3Driver>),
}别觉得 enum 不够抽象。有时候它比一堆 dyn 加 downcast 清爽多了,而且编译器还能检查分支有没有漏。
九、总结
把这几个词放一起看,其实没那么玄:trait 定义能力;impl Trait 表示编译期确定类型,只是隐藏具体名字;dyn Trait 表示运行时才知道具体实现,通过 trait object 调用;enum 适合实现集合固定、还要保留具体类型的场景;扩展 trait 用来把可选能力拆出去,别让核心 trait 变成万能插座;注册表则让插件自己报名,系统按名字点人。
AsterDrive 的存储系统用 StorageDriver 统一 Local、S3、Remote,但内部仍然保留 DriverEntry 处理 multipart 这种特殊能力。ShortLinker 的缓存系统用 ObjectCache、ExistenceFilter 和注册表,把 Memory、Redis、Bloom 这些实现变成运行期可选插件。这俩项目说明了一件事:Rust 不是不让你多态,它只是逼你把“到底什么时候决定具体类型”说清楚。
编译期能决定,就用泛型或 impl Trait;运行期才决定,就用 dyn Trait;实现种类固定,还要保留特殊能力,就老老实实用 enum。别一看到抽象就上 trait object,也别一看到性能就拒绝 dyn。你真正要避免的是那种最糟糕的中间态:
看起来抽象了,实际上每个调用点还在
match;
看起来兼容了,实际上不支持就panic;
看起来扩展了,实际上新增一个实现要改半个项目。
那不叫多态,那叫给未来的自己挖坟。
完工。

发表回复