前阵子后台收到一条私信,写得很认真:
大佬你好,我 Java 后端写了五年,Spring 全家桶很熟。最近在学 Rust,最不习惯的不是语法,是依赖注入。没有 @Autowired、没有 @Service、没有容器扫描,我的 Service 和 Repository 到底怎么互相引用?总不能每个地方都 new 吧?每次 new 一个,测试还怎么 mock?
这个问题几乎是每个 Java 转 Rust 的人都会遇到的,包括当年的我。今天就把它讲透。
先说结论:依赖注入没消失,消失的是容器
先把依赖注入拆开看,它其实只有两件事:依赖抽象(面向接口编程)和 组装(谁把实现传给谁)。
Spring 用容器把这两件事自动化了:扫描注解、反射创建、按类型注入、管理生命周期。你只写 @Autowired,剩下全交给魔法。
Rust 没这个容器,但抽象和组装这两件事一样都没少,只是换成了编译期可见的代码。你不再写 @Autowired,改在入口处手动把依赖树组装一遍,往下传。这个过程有个名字,叫 组合根(composition root)。
很多人转过来第一反应是“这退化了”,恰恰相反。Spring 的注入是运行时魔法,依赖配错了要启动时才炸;Rust 的组装是编译期代码,依赖对不上编译器当场报错。用魔法换检查,这笔交易不亏。
Spring 的容器到底帮你干了啥
先回忆一下你过去依赖它的四件事,后面逐一对应:
- 扫描:启动时扫
@Service / @Repository,收集 bean
- 组装:按构造参数类型,自动找 bean 塞进去
- 生命周期:单例还是每次新建,容器管
- 可替换:测试时换一个 mock 实现,不碰业务代码
Rust 没有运行时反射,这四件事没法自动做。但每一件都有对应的显式做法,下面一个一个来。
替代一:组合根,main 里组装一次
这是 Rust 生态的主流做法,也是我的基本盘。所有依赖在 main(或一个 build_app() 函数)里组装,往下一层一层传。
// main.rs:组合根,依赖树只在这组装
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let pool = PgPool::connect(&cfg.database_url).await?;
let repo = UserRepoPg::new(pool.clone());
let mailer = MailerSmtp::new(cfg.smtp);
let svc = UserService::new(Arc::new(repo), Arc::new(mailer));
let app = Router::new()
.route("/users", get(handlers::list_users))
.route("/users", post(handlers::create_user))
.with_state(Arc::new(svc));
let listener = TcpListener::bind(&cfg.addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
看着是不是很像你没用 Spring 之前手写的 main?对,本质就是那套,只是 Rust 社区把它扶正成了标准做法,还给起了个名字。
好处是显式的:整棵依赖树在一个函数里,谁依赖谁一目了然,不用猜 bean 是从哪来的。 你担心的“每个地方都 new”,不会发生,业务代码里你拿到的都是组装好的实例,只有组合根这一个地方在做组装。
生命周期也是显式的:需要共享的依赖,用 Arc 包一层传下去,clone 一个 Arc 只是引用计数加一,成本几乎为零。这就是 Rust 版“单例”,不需要容器管。
替代二:trait 抽象,把 mock 变成编译期的事
Spring 测试时换 mock,靠的是接口加容器重配。Rust 里更直接:业务代码面向 trait 编程,测试传一个假实现进去。
// 面向 trait,不面向实现
trait UserRepo: Send + Sync {
async fn find(&self, id: i64) -> Result<User, Error>;
}
struct UserService {
repo: Arc<dyn UserRepo>,
mailer: Arc<dyn Mailer>,
}
测试时传假实现:
struct FakeRepo;
#[async_trait]
impl UserRepo for FakeRepo {
async fn find(&self, id: i64) -> Result<User, Error> {
Ok(User { id, name: "test".into() })
}
}
#[tokio::test]
async fn test_find_user() {
let svc = UserService::new(Arc::new(FakeRepo), Arc::new(FakeMailer));
let user = svc.get_user(1).await.unwrap();
assert_eq!(user.name, "test");
}
没有 mock 框架,没有运行时替换,假实现就是普通代码。你连 mockito 都不用装。
再补一个选择:如果不需要运行时多态,用泛型比 trait object 更好,编译期就确定实现,零开销:
struct UserService<R: UserRepo> {
repo: R,
}
什么时候用泛型、什么时候用 Arc<dyn Trait>,判断标准一句话:一个进程里只有一个实现,用泛型;配置驱动、插件化、需要运行时换,用 trait object。
替代三:框架自带的“注入”,axum 的 State
Web 框架层面,axum 有自己的注入机制:State 提取器。你在组合根把依赖塞进 Router,handler 里直接要:
async fn list_users(
State(svc): State<Arc<UserService>>,
) -> Result<Json<Vec<User>>, ApiError> {
let users = svc.list().await?;
Ok(Json(users))
}
这其实就是 Spring @Autowired 在 Rust 里的样子,只是不靠反射,靠类型。handler 要求 State<Arc<UserService>>,框架在请求进来时把它取出来递给你,类型对不上编译期就报错。
那 Rust 的 DI 框架呢?为什么不是主流
确实有人做了。shaku 是编译期 DI,用宏模拟 Spring 的组件模型,单例、瞬态都有;国内还有位老哥辞职四个月用 Rust 复刻了个 Spring Boot(predawn + rudi),启动服务一行代码,注解、扫描、注入全套都有。
但这些都不是主流,原因有三:
- 学习成本不值。宏魔法写起来像 Java,出了问题排查起来比 Java 还难,而手写组合根就那么几十行。
- 违背显式哲学。Rust 社区的核心价值观是“能编译期检查的绝不放运行时”,DI 容器的运行时解析恰恰是反的。
- 生态没长起来。shaku 的 axum 集成 2025 年初才跟上 axum 0.8,容器跟着框架版本追,手写组合根永远不用追。
我的建议是别碰,组合根加 trait 够你用 99% 的场景。 真到那 1%(比如你要做插件系统),先想清楚是不是设计问题,而不是缺个容器。
全局变量:最后的选项,也是最大的坑
从 Java 过来的人容易想:没有容器,我把 Service 做成全局单例不就行了?
Rust 里全局可变状态是编译器都不允许的,static mut 读写要 unsafe,这是有原因的,数据竞争是未定义行为。用 OnceLock 可以安全地初始化全局共享数据:
static DB: OnceLock<PgPool> = OnceLock::new();
但这是最后的选项,不是第一选择。全局变量的代价是测试时没法换:测试想用假数据库,全局池子还指着真的。宁可多传几层参数,也别图省事挂全局。
守住三条底线,离开容器也不慌
从 Spring 切过来,只要守住这三条,依赖这件事不会成为你的麻烦:
- 依赖走构造参数显式传。 别搞全局可变状态,别在业务代码里直接 new 依赖,谁创建谁负责。
- 面向 trait 编程。 Service 依赖接口不依赖实现,测试才能换假实现,这是可测试性的来源。
- 组合根只留一个。 main 或 build_app() 里组装一次,别在代码里到处组装,组装点越多越乱。
值得反复研读的项目
想知道 Rust 生态怎么处理依赖,看这几个:
| 项目 |
为什么值得看 |
| axum |
看 State / Extension 怎么做框架层的“注入”,不靠反射靠类型 |
| shaku |
模拟 Spring 组件模型的编译期 DI,看它为了像 Java 做了哪些妥协 |
| predawn + rudi |
中文社区有人用 Rust 复刻 Spring Boot,一行代码启动,看看代价是什么 |
| ripgrep |
经典单体,看它怎么在 main 里手动组装,极简范例 |
最后说两句
Java 老手转 Rust,最难受的从来不是语法,是把“容器帮我搞定”的思维换成“我自己组装”。这个转换期大概一两个项目就适应了,适应之后你会开始嫌弃 Spring 的启动日志里那堆 bean 初始化提示,太吵了。