从DIP到依赖注入:依赖注入的三种方式与DI容器设计
依赖倒置原则(DIP)说起来高大上,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象。我在项目中见过太多人把DIP和依赖注入(DI)混为一谈,觉得它们是一回事。嗯,这里要澄清一下——DIP是设计原则,DI是实现手段。
说白了,DIP告诉你「要面向接口编程」,而DI告诉你「怎么把接口的实现塞进去」。今天我们就聊聊DI的三种主流实现方式,以及DI容器到底在解决什么问题。
为什么需要依赖注入?
先看一个反面教材。我接手过一个遗留系统,里面到处都是这样的代码:
class OrderService {
private:
MySqlDatabase db; // 直接依赖具体实现
EmailSender sender; // 又依赖一个具体类
public:
void process(Order& order) {
db.save(order);
sender.send(order);
}
};
这代码有什么问题?你想想看——OrderService 直接 new 了 MySqlDatabase 和 EmailSender。哪天要换数据库,得改 OrderService 的代码。哪天要换邮件服务,还得改它。这违反了开闭原则,也违反了DIP。
正确的做法是让 OrderService 依赖接口,而不是具体类。那问题来了:接口的实现对象从哪来?这就是依赖注入要解决的事。
依赖注入的三种方式
我个人习惯把DI分为三类:构造器注入、Setter注入、接口注入。下面逐一来看。
1. 构造器注入(Constructor Injection)
这是我最推荐的方式。通过构造函数把依赖传进去,对象一旦创建,依赖就不可变。
class OrderService {
private:
IDatabase& db;
IEmailSender& sender;
public:
// 依赖通过构造函数传入
OrderService(IDatabase& db, IEmailSender& sender)
: db(db), sender(sender) {}
void process(Order& order) {
db.save(order);
sender.send(order);
}
};
使用起来也很直观:
MySqlDatabase db;
SmtpEmailSender sender;
OrderService service(db, sender);
service.process(order);
优点:
- 依赖关系一目了然,看构造函数就知道这个类需要什么
- 对象创建后状态完整,不会出现「半初始化」状态
- 天然支持const成员和引用成员
我的经验: 在项目中,我尽量用构造器注入。尤其是那些核心业务类,依赖关系应该明确且不可变。我曾经在一个支付系统中,所有服务类都用了构造器注入,后来要替换支付网关,只需要改组装代码,业务逻辑一行没动。
2. Setter注入(Setter Injection)
通过setter方法设置依赖。这种方式给了你灵活性——可以先创建对象,再慢慢设置依赖。
class OrderService {
private:
IDatabase* db = nullptr;
IEmailSender* sender = nullptr;
public:
void setDatabase(IDatabase* db) { this->db = db; }
void setEmailSender(IEmailSender* sender) { this->sender = sender; }
void process(Order& order) {
if (!db || !sender) throw std::runtime_error("依赖未设置");
db->save(order);
sender->send(order);
}
};
注意: Setter注入有个坑——对象可能处于「未完全初始化」状态。我曾经见过一个bug,有人忘了调 setDatabase(),结果运行时直接崩溃。所以用Setter注入时,一定要做空指针检查。
什么时候用Setter注入?我个人觉得,当依赖是可选的,或者有默认实现时,Setter注入比较合适。比如日志组件,可以默认不输出,也可以后期注入一个文件日志。
3. 接口注入(Interface Injection)
这种方式在C++里用得不多,但了解一下没坏处。它通过一个专门的接口来注入依赖:
// 定义一个注入接口
class IDatabaseInjector {
public:
virtual void injectDatabase(IDatabase* db) = 0;
};
// 业务类实现注入接口
class OrderService : public IDatabaseInjector {
private:
IDatabase* db = nullptr;
public:
void injectDatabase(IDatabase* db) override {
this->db = db;
}
};
说实话,我在C++项目中很少用接口注入。它比Setter注入更繁琐,而且多了一层接口继承。Java的某些框架喜欢用这种方式,但C++社区更倾向于前两种。
三种方式对比
| 方式 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 构造器注入 | 依赖明确、对象完整、支持const | 构造函数参数可能过多 | 核心业务类、必需依赖 |
| Setter注入 | 灵活、支持可选依赖 | 可能未初始化、不支持const | 可选依赖、有默认值 |
| 接口注入 | 标准化注入方式 | 繁琐、侵入性强 | 框架设计、特殊场景 |
DI容器:把组装工作自动化
手动注入依赖,在小项目里还行。但项目大了以后,你会发现组装代码越来越恐怖。我曾经维护过一个模块,光main函数里就有30多个new和set调用,改一个依赖要牵连好几个地方。
这时候就需要DI容器了。DI容器说白了就是一个「对象工厂+生命周期管理器」。你告诉它「我要什么类型」,它自动帮你创建并注入依赖。
一个简单的DI容器核心逻辑:
class DIContainer {
private:
// 存储类型创建函数
std::unordered_map<std::type_index, std::function<void*()>> factories;
public:
template<typename T>
void registerType(std::function<T*()> factory) {
factories[std::type_index(typeid(T))] = factory;
}
template<typename T>
T* resolve() {
auto it = factories.find(std::type_index(typeid(T)));
if (it != factories.end()) {
return static_cast<T*>(it->second());
}
return nullptr;
}
};
当然,真实的DI容器要复杂得多。它需要处理:
- 生命周期管理:单例、每次新建、线程局部等
- 自动装配:通过反射或模板推导自动解析依赖
- 循环依赖检测:A依赖B,B又依赖A,这种情况要能检测出来
我的建议: 不要自己造DI容器轮子。C++社区有不错的DI库,比如Boost.DI、Fruit等。除非你是在学习原理,否则直接用现成的更靠谱。我曾经在一个项目中手写了DI容器,结果维护成本比手动注入还高——嗯,那是一次深刻的教训。
知识体系总览
下面这张图总结了从DIP到DI容器的完整脉络:
从图中可以看到,DIP是顶层原则,DI是具体实现,而DI容器则是把DI自动化的工具。这三层关系搞清楚了,你写出来的代码自然就灵活、可测试、易维护。
避坑指南: 我曾经在一个项目中过度使用DI容器,把所有对象的创建都丢给容器管理。结果调试时根本不知道对象是怎么组装出来的,出了问题排查半天。后来我定了个规矩:核心业务对象用构造器注入手动组装,工具类、配置类才交给DI容器。这个平衡点,你慢慢也会找到。
好了,今天的内容就到这里。记住:DIP是原则,DI是手段,DI容器是工具。别把手段当原则,也别把工具当银弹。写代码时多想想「这个依赖该不该暴露」「这个对象该由谁创建」,比盲目套用模式要实在得多。