依赖倒置原则(DIP)下:实际案例——数据访问层的依赖注入,IoC容器与DIP

上一讲我们聊了DIP的理论,说白了就是“高层模块不要依赖低层模块,两者都依赖抽象”。但理论归理论,落地才是真功夫。今天我就拿数据访问层这个最常见的场景,带大家看看DIP到底怎么用。

一个真实的数据访问层痛点

我记得几年前接手过一个遗留系统。那代码写得,啧啧……业务逻辑层直接new了一个MySQL连接对象。后来公司要切换到PostgreSQL,你猜怎么着?几乎每个业务类都得改。这就是典型的“高层依赖低层”的后果。

为什么会这样?因为业务层和数据库实现绑死了。我们来看看最原始的写法:

class OrderService {
private:
    MySQLDatabase db;  // 直接依赖具体实现
public:
    void createOrder(Order& order) {
        db.connect("localhost", "root", "pass");
        db.execute("INSERT INTO orders ...");
    }
};

这段代码的问题很明显——OrderService 直接依赖了 MySQLDatabase。哪天你想换成 PostgreSQLDatabase,就得改 OrderService 的代码。这违反了开闭原则,也违反了依赖倒置原则。

引入抽象接口

按照DIP的思路,我们应该让高层和低层都依赖抽象。先定义一个数据库访问接口:

class IDatabase {
public:
    virtual ~IDatabase() = default;
    virtual void connect(const std::string& host, 
                         const std::string& user, 
                         const std::string& pass) = 0;
    virtual void execute(const std::string& sql) = 0;
    virtual std::vector<Record> query(const std::string& sql) = 0;
};

然后让具体数据库实现这个接口:

class MySQLDatabase : public IDatabase {
public:
    void connect(const std::string& host, 
                 const std::string& user, 
                 const std::string& pass) override {
        // MySQL连接逻辑
    }
    void execute(const std::string& sql) override { /* ... */ }
    std::vector<Record> query(const std::string& sql) override { /* ... */ }
};

class PostgreSQLDatabase : public IDatabase {
    // 类似实现
};

现在业务层只依赖接口:

class OrderService {
private:
    IDatabase* db;  // 依赖抽象,不是具体实现
public:
    explicit OrderService(IDatabase* database) : db(database) {}
    
    void createOrder(Order& order) {
        db->connect("localhost", "root", "pass");
        db->execute("INSERT INTO orders ...");
    }
};

嗯,这里要注意——构造函数接收的是 IDatabase*,而不是具体类型。这就是依赖注入的雏形。

依赖注入的三种方式

我在项目中用过三种依赖注入方式,各有适用场景:

注入方式 实现方法 适用场景
构造函数注入 通过构造函数参数传入依赖 依赖在对象生命周期内不变
Setter注入 通过setter方法设置依赖 依赖可选或可替换
接口注入 通过接口方法传入依赖 需要动态切换依赖

我个人最常用的是构造函数注入。为什么?因为依赖一旦确定就不该变,构造函数注入能保证对象创建后依赖就绪,不会出现空指针问题。

// 构造函数注入
class OrderService {
private:
    std::unique_ptr<IDatabase> db_;
public:
    explicit OrderService(std::unique_ptr<IDatabase> db) 
        : db_(std::move(db)) {}
};

// Setter注入
class OrderService {
private:
    IDatabase* db_ = nullptr;
public:
    void setDatabase(IDatabase* db) { db_ = db; }
};

IoC容器登场

依赖注入解决了“依赖什么”,但没解决“谁来创建依赖”。如果每个业务类都手动new数据库对象,那还是耦合。这时候就需要IoC容器了。

IoC容器说白了就是一个“对象工厂+生命周期管理器”。它负责创建对象、管理依赖关系、控制生命周期。我曾在项目中写过一个轻量级容器,核心逻辑其实不复杂:

class IoCContainer {
private:
    // 存储类型信息到工厂函数的映射
    std::unordered_map<std::string, std::function<void*()>> factories_;
    
public:
    template<typename T>
    void registerType(const std::string& key, std::function<T*()> factory) {
        factories_[key] = [factory]() -> void* {
            return static_cast<void*>(factory());
        };
    }
    
    template<typename T>
    T* resolve(const std::string& key) {
        auto it = factories_.find(key);
        if (it != factories_.end()) {
            return static_cast<T*>(it->second());
        }
        return nullptr;
    }
};

使用起来就像这样:

IoCContainer container;

// 注册数据库实现
container.registerType<IDatabase>("mysql", []() -> IDatabase* {
    return new MySQLDatabase("localhost", 3306);
});

container.registerType<IDatabase>("postgres", []() -> IDatabase* {
    return new PostgreSQLDatabase("localhost", 5432);
});

// 注册业务服务
container.registerType<OrderService>("order", [&container]() -> OrderService* {
    auto db = container.resolve<IDatabase>("mysql");
    return new OrderService(db);
});

// 使用时
auto service = container.resolve<OrderService>("order");
service->createOrder(myOrder);

核心要点:IoC容器把“创建对象”和“使用对象”分开了。业务层只关心“我要用IDatabase”,不关心“IDatabase是怎么创建的”。这就是控制反转——创建对象的控制权从业务层转移到了容器。

DIP + IoC = 灵活的数据访问层

把DIP和IoC容器结合起来,数据访问层就变得非常灵活。我曾经在一个项目中,数据库从MySQL切换到TiDB,只改了一行配置代码——连重新编译都不需要。

来看一个完整的架构图:

基于DIP和IoC的数据访问层架构 业务层 (OrderService) 抽象接口 (IDatabase) MySQLDatabase PostgreSQLDatabase SQLiteDatabase IoC容器 (依赖注入 + 生命周期管理) 依赖抽象,不依赖具体 实现抽象接口 容器负责创建和注入

我的经验:刚开始用IoC容器时,容易陷入“万物皆容器”的误区。其实不是所有对象都需要容器管理。值对象、简单工具类直接new就行。我一般只把那些“可能变化”的依赖交给容器——比如数据库、缓存、消息队列这些基础设施。

避坑指南

我曾经在一个项目中踩过几个坑,分享出来给大家参考:

  • 过度抽象:不要为了DIP而DIP。如果某个依赖永远不会变(比如std::string),没必要抽象成接口。
  • 生命周期管理:IoC容器创建的对象,谁负责销毁?我建议用智能指针配合容器管理,避免内存泄漏。
  • 性能开销:每次从容器解析对象都有查找和创建开销。高频调用的对象可以考虑单例模式或对象池。
  • 循环依赖:A依赖B,B依赖A,容器会死循环。设计时尽量避免,或者用Setter注入打破循环。

特别注意:IoC容器不是银弹。小项目用容器反而增加复杂度。我一般建议:项目超过10个业务类,或者有3个以上数据源时,才考虑引入容器。

总结一下

DIP在数据访问层的落地,说白了就是三步:

  1. 定义抽象接口(IDatabase)
  2. 让高层依赖接口,不依赖具体实现
  3. 用IoC容器管理依赖的创建和注入

这样做的好处很明显:数据库切换只需改容器配置,业务代码纹丝不动。测试时也可以轻松注入Mock对象,单元测试好写多了。

嗯,DIP这部分就聊到这儿。记住一句话:面向接口编程,而不是面向实现编程。这是C++设计中最值钱的经验之一。


公众号:蓝海资料掘金营,微信deep3321