4、轻量级DI容器:嵌入式DI容器设计、注册表模式、工厂模式结合DI
好,咱们进入第四章。前面几章我们把依赖注入的概念、手动注入的写法都过了一遍。你可能会想:每次手动new对象、传指针,代码量不小,而且耦合还是没完全解开。有没有一个东西,能帮我们统一管理这些依赖?
有的。这就是轻量级的DI容器。
我刚开始做嵌入式项目时,也觉得容器是Java、C#那些大系统才用的东西。后来在一个多传感器融合的项目里,模块间依赖关系越来越乱,手动初始化代码写了上千行,改一个接口要牵连七八个文件。嗯,那时候我才意识到——嵌入式也需要一个容器,只不过要足够轻。
4.1 嵌入式DI容器的设计原则
嵌入式环境资源有限,我们不能把Spring那套搬过来。我总结了几条核心原则:
- 静态注册为主:运行时反射在C语言里不现实,我们用编译期或启动时的静态注册。
- 零动态分配:容器内部不使用malloc,所有对象池在编译期确定大小。
- 接口与实现分离:容器只管理接口指针,具体实现由注册表决定。
- 可裁剪:用宏控制功能开关,不用到的特性不占ROM。
核心思路:DI容器本质上是一个“对象工厂 + 依赖关系表”。你告诉容器“我需要一个I2C驱动”,容器查表,返回给你注册好的实例。
4.2 注册表模式:容器的心脏
注册表模式,说白了就是一张全局的“接口-实现”映射表。每个接口类型对应一个创建函数指针,容器启动时把所有实现注册进去。
我在一个车载项目里用过这种设计。当时有3种不同的温度传感器,每种驱动都实现了同一个temp_sensor_if接口。通过注册表,业务代码根本不知道具体用的是哪款芯片。
4.2.1 注册表的数据结构
// 接口类型枚举
typedef enum {
IFACE_I2C_MASTER,
IFACE_SPI_MASTER,
IFACE_GPIO_CTRL,
IFACE_TIMER,
IFACE_COUNT // 总接口数
} iface_type_t;
// 注册表条目
typedef struct {
iface_type_t type;
void* (*creator)(void); // 创建实例的函数指针
void* instance; // 缓存单例实例
uint8_t flags; // 标志位:单例/多例、是否已初始化
} registry_entry_t;
// 注册表本身
static registry_entry_t g_registry[IFACE_COUNT];
我的习惯:把IFACE_COUNT放在枚举最后,这样数组大小自动跟着接口数量走,加新接口时不容易漏掉注册表条目。
4.2.2 注册与查找
// 注册一个实现
void di_register(iface_type_t type, void* (*creator)(void), uint8_t flags) {
g_registry[type].type = type;
g_registry[type].creator = creator;
g_registry[type].instance = NULL;
g_registry[type].flags = flags;
}
// 获取接口实例
void* di_resolve(iface_type_t type) {
registry_entry_t* entry = &g_registry[type];
if (entry->flags & FLAG_SINGLETON) {
// 单例模式:第一次创建,后续复用
if (entry->instance == NULL) {
entry->instance = entry->creator();
}
return entry->instance;
} else {
// 多例模式:每次都创建新实例
return entry->creator();
}
}
你看,代码量很少。但就是这个简单的表,把模块间的直接依赖彻底解开了。
4.3 工厂模式结合DI
注册表解决了“接口到实现”的映射。但有时候,一个接口可能有多个实现,或者实现需要额外参数。这时候工厂模式就派上用场了。
工厂模式在DI容器里扮演的角色是:封装对象的创建逻辑。容器不关心对象怎么造出来的,只关心工厂函数长什么样。
4.3.1 带参数的工厂
// 带参数的工厂函数类型
typedef void* (*param_creator_t)(void* param);
// 扩展的注册表条目
typedef struct {
iface_type_t type;
param_creator_t creator;
void* create_param; // 创建参数
void* instance;
uint8_t flags;
} ext_registry_entry_t;
// 示例:I2C设备工厂,参数是设备地址
void* i2c_device_factory(void* param) {
uint8_t addr = *(uint8_t*)param;
return i2c_device_create(addr);
}
// 注册时传入参数
uint8_t dev_addr = 0x50;
di_register_ext(IFACE_EEPROM, i2c_device_factory, &dev_addr, FLAG_SINGLETON);
注意:传入工厂的参数必须是全局或静态变量,不能是局部变量。否则注册完成后参数就失效了。我曾经在这个坑里栽过跟头,排查了半天才发现是栈上的参数被覆盖了。
4.3.2 工厂模式与策略模式组合
有时候,同一个接口在不同场景下需要不同的实现。比如日志模块:调试阶段用串口输出,量产阶段用Flash存储。工厂模式可以帮我们做运行时切换。
// 日志接口
typedef struct {
void (*log)(const char* msg);
} logger_iface_t;
// 两个实现
static logger_iface_t serial_logger = { serial_log };
static logger_iface_t flash_logger = { flash_log };
// 工厂函数:根据模式返回不同实现
void* logger_factory(void* param) {
uint8_t mode = *(uint8_t*)param;
if (mode == LOG_MODE_DEBUG) {
return &serial_logger;
} else {
return &flash_logger;
}
}
这样做的好处是:业务代码只依赖logger_iface_t接口,切换实现时一行代码都不用改。
4.4 整体架构:DI容器内部结构
下面这张图展示了DI容器的核心工作流程。我建议你仔细看看,理解数据是怎么流转的。
4.5 实战:构建一个微型DI容器
光说不练假把式。咱们动手写一个完整的微型DI容器,用在STM32或类似平台上。
4.5.1 容器头文件
// di_container.h
#ifndef DI_CONTAINER_H
#define DI_CONTAINER_H
#include <stdint.h>
// 接口ID定义
typedef enum {
DI_I2C_MASTER,
DI_SPI_MASTER,
DI_UART,
DI_GPIO,
DI_TIMER,
DI_LOG,
DI_MEMORY,
DI_SENSOR_TEMP,
DI_SENSOR_HUMI,
DI_MAX_INTERFACES
} di_iface_t;
// 容器初始化
void di_init(void);
// 注册接口实现
void di_register(di_iface_t iface, void* (*factory)(void));
// 带参数的注册
void di_register_param(di_iface_t iface, void* (*factory)(void*), void* param);
// 解析接口实例
void* di_resolve(di_iface_t iface);
// 重置容器(用于单元测试)
void di_reset(void);
#endif
4.5.2 容器实现
// di_container.c
#include "di_container.h"
#include <string.h>
#define FLAG_REGISTERED (1 << 0)
#define FLAG_SINGLETON (1 << 1)
#define FLAG_PARAM (1 << 2)
typedef struct {
di_iface_t iface;
void* (*factory)(void);
void* (*param_factory)(void*);
void* param;
void* instance;
uint8_t flags;
} container_entry_t;
static container_entry_t s_entries[DI_MAX_INTERFACES];
static uint8_t s_initialized = 0;
void di_init(void) {
memset(s_entries, 0, sizeof(s_entries));
s_initialized = 1;
}
void di_register(di_iface_t iface, void* (*factory)(void)) {
if (!s_initialized || iface >= DI_MAX_INTERFACES) return;
s_entries[iface].iface = iface;
s_entries[iface].factory = factory;
s_entries[iface].flags = FLAG_REGISTERED | FLAG_SINGLETON;
}
void di_register_param(di_iface_t iface, void* (*factory)(void*), void* param) {
if (!s_initialized || iface >= DI_MAX_INTERFACES) return;
s_entries[iface].iface = iface;
s_entries[iface].param_factory = factory;
s_entries[iface].param = param;
s_entries[iface].flags = FLAG_REGISTERED | FLAG_SINGLETON | FLAG_PARAM;
}
void* di_resolve(di_iface_t iface) {
if (!s_initialized || iface >= DI_MAX_INTERFACES) return NULL;
container_entry_t* entry = &s_entries[iface];
if (!(entry->flags & FLAG_REGISTERED)) {
return NULL; // 未注册
}
if (entry->instance == NULL) {
if (entry->flags & FLAG_PARAM) {
entry->instance = entry->param_factory(entry->param);
} else {
entry->instance = entry->factory();
}
}
return entry->instance;
}
void di_reset(void) {
for (int i = 0; i < DI_MAX_INTERFACES; i++) {
s_entries[i].instance = NULL;
}
}
使用建议:在单元测试中,每次测试前调用di_reset(),然后注册Mock实现。这样测试之间不会互相影响。我在一个传感器驱动测试套件里就是这么做的,覆盖率从60%提到了92%。
4.6 注册表模式 vs 工厂模式 vs 纯DI
这三种模式经常被放在一起比较。我整理了一张表,帮你快速区分:
| 模式 | 核心职责 | 适用场景 | 资源开销 |
|---|---|---|---|
| 注册表模式 | 维护接口-实现映射表 | 接口数量固定,实现单一 | 低(数组+指针) |
| 工厂模式 | 封装对象创建逻辑 | 创建过程复杂,需要参数 | 中(函数指针+参数) |
| 纯DI | 手动注入依赖 | 小型项目,接口少 | 最低(无额外开销) |
实际项目中,我通常把三者结合起来:注册表做容器骨架,工厂模式处理复杂对象的创建,纯DI用在性能敏感的路径上。
4.7 避坑指南
做DI容器时,有几个坑我踩过,你注意避开:
- 循环依赖:A依赖B,B又依赖A。容器解析时会死循环。解决办法:加一个解析深度计数器,超过阈值就报错。
- 线程安全:RTOS环境下,多个任务同时调用
di_resolve可能出问题。加一个简单的互斥锁,或者用临界区保护。 - 内存泄漏:多例模式下每次创建新对象,但没人负责销毁。我建议多例对象都实现一个
destroy函数,容器在重置时统一清理。 - 接口ID冲突:多人协作时,枚举值可能重复。用静态断言检查
DI_MAX_INTERFACES是否等于实际注册数。
我曾经...在一个项目里忘了处理循环依赖,结果系统启动时直接卡死在di_resolve里。用调试器一看,调用栈深到离谱。从那以后,我每个容器都加了深度检测,超过10层就触发断言。
4.8 小结
轻量级DI容器,说白了就是一张注册表加上几个工厂函数。它不复杂,但能帮你把模块间的耦合降到最低。嵌入式环境里,我们不需要Spring那样的庞然大物,一个几百行的C文件就足够了。
记住:容器的目的是让代码更容易测试、更容易替换、更容易维护。如果它反而让你的代码变复杂了,那说明你用错了。