第3章 模块边界定义原则
在BLE模块中,依赖注入边界的选择直接决定系统是否易于扩展、测试与维护。本章围绕单一职责、接口隔离、依赖倒置三项原则,结合BLE实际场景分析其应用方式,并给出边界粒度的选型权衡。
3.1 单一职责原则(SRP)在BLE边界中的映射
每个模块只应有一个改变的理由。在BLE系统中,典型的职责拆分包括:
- 广播管理:负责广播参数的配置与启停,不涉及连接控制。
- 连接管理:处理连接建立、参数更新、断开事件,不关心广播内容。
- GATT服务实现:提供服务注册、特征读写回调,与底层HCI解耦。
- 安全配对:单独封装SM(安全管理)协议逻辑。
若一个模块同时处理广播参数和连接参数,则广播协议变更会影响连接逻辑,违反SRP。建议按协议层+功能域交叉划分边界。
3.2 接口隔离原则(ISP)——最小依赖接口
客户端不应依赖它不需要的接口。在DI边界设计时,应针对不同消费者(广播模块、连接模块、GATT服务器)提供粒度精细的接口:
| 消费者 | 需要的方法 | 不应暴露的方法 |
|---|---|---|
| 广播模块 | startAdvertising, stopAdvertising, setAdvertisementData | connect, disconnect, discoverServices |
| 连接模块 | connect, disconnect, updateConnectionParams | startAdvertising, writeCharacteristic |
| GATT服务器 | registerService, handleRead, handleWrite | advertise, scan |
通过将IBleRadio拆分为IAdvertiser、IConnector、IGattServer三个接口,各模块只依赖自己需要的API,降低变更影响面。
// 反例:臃肿的单一接口
struct IBleController {
virtual void startAdvertising() = 0;
virtual void stopAdvertising() = 0;
virtual void connect(const Address& addr) = 0;
virtual void disconnect() = 0;
virtual void registerService(GattService& svc) = 0;
// ……更多方法
};
// 正例:按职责拆分的接口
struct IAdvertiser {
virtual void start() = 0;
virtual void stop() = 0;
};
struct IConnector {
virtual void connect(const Address& addr) = 0;
virtual void disconnect() = 0;
};
struct IGattServer {
virtual void registerService(GattService& svc) = 0;
virtual void onRead(const Request& req) = 0;
};
3.3 依赖倒置原则(DIP)——抽象边界
高层模块不应依赖低层模块,二者都应依赖抽象。在BLE DI边界中,应用层(如心率计Profile)应依赖抽象的GATT服务接口,而非具体的芯片驱动。底层驱动实现该接口,并通过DI容器注入。
依赖倒置使BLE协议栈可轻松切换芯片(如从Nordic nRF52832切换到Dialog DA14695),只需提供新的驱动实现类,应用层代码不变。
// 抽象接口定义在高层模块
class HeartRateServiceInterface {
public:
virtual void updateHeartRate(uint8_t value) = 0;
virtual ~HeartRateServiceInterface() = default;
};
// 具体实现位于底层芯片适配层
class Nrf52HeartRateService : public HeartRateServiceInterface {
// 使用nRF SDK的API实现
};
// DI容器注册
// di_container.register<HeartRateServiceInterface, Nrf52HeartRateService>();
3.4 边界粒度选择
过粗的边界(如单个“BleManager”模块)会导致内部耦合爆炸;过细的边界(每个函数一个模块)则引入大量接口与组装成本。BLE场景下推荐两种粒度:
- 按协议层粒度:HCI、L2CAP、GATT、GAP各为一模块。优点是与标准分层对齐,适合需要深度定制协议的团队。缺点是上层业务常需跨层调用,产生“横切”依赖。
- 按功能域粒度:广播、扫描、连接、配对、数据传输。优点是与业务场景自然对应,上层应用直接感知。缺点是需要额外协调层处理跨域事件(如连接成功后的广播停止)。
| 粒度类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 协议层粒度 | 技术边界清晰,分层测试容易 | 业务代码需跨层拼装 | BLE协议栈研发、芯片SDK开发 |
| 功能域粒度 | 面向业务,API直观 | 可能出现职责重叠(如扫描与广播共用射频) | 终端产品应用层开发 |
风险点:若在同一个系统中混用两种粒度,会造成边界混乱,DI容器需要同时处理多层抽象,增加配置复杂度。建议整个项目统一选择一种粒度,并在架构文档中显式约定。
3.5 依赖关系与数据流
下图展示应用层通过抽象接口(DIP)与具体驱动解耦,DI容器负责组装依赖关系。数据流方向为:应用层 → DI容器 → 具体驱动实现 → 硬件寄存器。
3.6 落地步骤与验收标准
- 职责识别:列出BLE系统中所有功能点,按SRP分组,每组形成一个模块候选。
- 接口裁剪:为每个模块定义最小接口集,确保ISP满足——使用接口隔离工具类或C++纯虚类。
- 抽象提取:将模块接口提升至独立头文件(抽象层),具体实现仅在底层编译单元包含。
- DI容器引入:选择轻量级DI框架(如Boost.DI或自建工厂),将抽象-具体绑定注册。示例配置:
// 使用工厂函数实现简单DI绑定
auto createHeartRateService = [](const BoardConfig& cfg) -> std::shared_ptr<HeartRateServiceInterface> {
if (cfg.chip == ChipType::NRF52) {
return std::make_shared<Nrf52HeartRateService>(cfg.spi);
} else if (cfg.chip == ChipType::DA14695) {
return std::make_shared<Da14695HeartRateService>(cfg.i2c);
}
throw std::runtime_error("unsupported chip");
};
- 验收标准:
- 新增芯片实现时,仅需添加新的实现类并在DI容器注册,应用层代码0修改(DIP验证)。
- 修改广播模块时不需重新编译连接模块(SRP验证)。
- 连接模块引用的接口只包含connect/disconnect/updateParams,不存在startAdvertising方法(ISP验证)。
边界粒度的最终验证:在单元测试中,可用Mock对象替换任一模块,测试其他模块是否依然能正常构建与运行。若频繁出现“我不得不Mock掉整个BleStack才能测试GATT服务”,则说明边界粒度过粗,需要进一步拆分。