从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 了 MySqlDatabaseEmailSender。哪天要换数据库,得改 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容器:知识体系 DIP 依赖倒置原则 实现手段:依赖注入(DI) 构造器注入 Setter注入 接口注入 DI容器:自动组装与管理

从图中可以看到,DIP是顶层原则,DI是具体实现,而DI容器则是把DI自动化的工具。这三层关系搞清楚了,你写出来的代码自然就灵活、可测试、易维护。

避坑指南: 我曾经在一个项目中过度使用DI容器,把所有对象的创建都丢给容器管理。结果调试时根本不知道对象是怎么组装出来的,出了问题排查半天。后来我定了个规矩:核心业务对象用构造器注入手动组装,工具类、配置类才交给DI容器。这个平衡点,你慢慢也会找到。

好了,今天的内容就到这里。记住:DIP是原则,DI是手段,DI容器是工具。别把手段当原则,也别把工具当银弹。写代码时多想想「这个依赖该不该暴露」「这个对象该由谁创建」,比盲目套用模式要实在得多。

专注资料整理