第三部 · 架构与工程经验第 6 章
架构:模块化与插件化¶
本章要点
大型工业软件的架构问题,最终都会落到两个具体决定上:模块之间的依赖箭头朝哪边,扩展点以什么形状固定下来。依赖方向决定一个改动会波及多少代码,扩展点决定第三方能不能在不改主程序的前提下加功能。data-workbench 用五层模块、一张依赖矩阵和一套版本化的插件接口回答了这两个问题,代价是接口一旦发布就很难改、分层会带来一层转发代码。本章按分层、边界、类放置、插件、接口、文档对象与撤销、事件解耦的顺序展开,最后列出四种最常见的反模式。判断架构好坏的标准只有一条:把界面层删掉,剩下的代码还能不能运行。
6.1 功能多带来的四个后果¶
一个 Qt 界面程序长到十万行以后,开发者会同时感受到四种变慢,它们的根源不同,解法也不同。把这些症状分开看,才能判断该改目录、改依赖还是改接口。
编译变慢是最先被注意到的。C++ 的头文件包含是传递的,一个界面类的头文件里包含 QMainWindow、包含文档对象、包含图表控件,所有 include 它的源文件都要把这些头文件重新解析一遍。从公开文档看,data-workbench 的界面层 DAGui 有 400 多个源文件,如果这些文件之间互相包含头文件而不是用前向声明,改一个私有成员就会触发大半个仓库重编。大型工程普遍用两种手段对抗这个问题:把实现细节藏进 PIMPL,让头文件只留下指针和接口;把模块切成独立 target,让编译单元的上限由依赖图决定,而不是由目录深度决定。
耦合变深是第二层。功能一多,模块之间开始互相“借用”一点东西:图表模块想用数据模块的某张表,数据模块想用界面模块的某个进度对话框,界面模块又反过来需要图表的导出接口。每一次借用单独看都有理由,合起来就形成环。环在 C++ 里不会立刻报错,只在链接顺序、初始化顺序、卸载顺序上出问题,症状是随机的崩溃和随机的“第一次打开正常、第二次打开失败”。
测试变难是第三层。没有模块边界的程序,只有启动整个应用才能验证一个功能:测排序要打开主窗口、导入数据、点菜单,测试用例写成了操作手册。这种测试跑得慢、失败定位难、覆盖率上不去,久而久之没人维护。反过来,如果数据层能在没有 QApplication 的情况下构造、能在单元测试里直接建一个 DataFrame 并调用处理函数,测试成本会低一个数量级。
新人上手慢是第四层,也是最容易被低估的一层。新人问“加一个图表类型要改哪些文件”,如果答案是“先看主窗口的槽函数,再找图表操作类,再改图元注册表”,说明结构没有把流程表达出来;如果答案是“在 DAFigure 加一个图元类,注册到工厂,在 DAGui/Chart/Setting 加一个属性面板”,说明分工已经写进了目录。前者需要老员工带三个月,后者需要三天。
这四种变慢有一个共同的放大器:团队规模。一个人写代码时,所有结构都能装在脑子里,耦合再深也能改;五个人并行开发时,没有边界的目录每天都会产生合并冲突,没有测试的模块每次改动都在赌。架构的价值在这里体现:它把“依赖于某个人记得”变成“依赖于代码和构建系统的约束”。代价是前期要多做设计、多写接口、多拆 target,功能交付看起来慢了一拍。项目越大,这笔投入回收得越快;只做三个月的工具,不值得为它设计五层架构。
6.2 分层:应用层、接口层、界面层、功能层、基础层¶
分层是模块化最直接的形式:把代码按“离用户多远”排成上下几层,规定依赖只能从上往下。data-workbench 分了五层,这个数字不是拍脑袋定的,每一层对应一种变化原因。
基础层放不依赖业务的知识:字符串与文件工具、XML 序列化、日志、Python 绑定、纯头文件的数据结构。它们的共同点是可以在任何项目里复用,变化频率最低。功能层放领域能力:数据容器、图表容器、工作流引擎、图形视图框架。它们知道“数据”“图表”“节点”这些概念,但不知道菜单和按钮。界面层把功能层组装成可操作的界面。接口层定义插件和主程序之间的契约。应用层是入口,把上面四层装配成可执行程序。
这种分法的直接好处是变化被隔离。界面改版只动界面层,功能层可以原样保留;换一个图表库只动图表模块,数据层不受影响;加一个插件只动接口层和应用层,功能层不需要知道插件的存在。表 6-1 把五层的职责、允许依赖和变化频率放在一起,对照它就能判断新代码该落在哪一行。
| 层 | 职责 | 允许依赖 | 典型模块 | 变化频率 |
|---|---|---|---|---|
| L5 应用层 | 程序入口、接口实现、模块装配 | 接口层、功能层 | APP |
随产品功能变化 |
| L4 接口层 | 插件契约、核心接口、Agent 框架 | 界面层、功能层、基础层 | DAInterface、DAPluginSupport、DAAgent |
慢,接口变更代价高 |
| L3 界面层 | 控件、面板、视图、交互 | 功能层、基础层 | DAGui |
随交互需求变化 |
| L2 功能层 | 数据、图表、工作流、图形视图 | 基础层 | DAData、DAFigure、DAPyWorkFlow、DAGraphicsView |
随领域能力变化 |
| L1 基础层 | 工具、序列化、日志、Python 绑定、基础类型 | 只依赖 Qt 与第三方 | DAShared、DAUtils、DAMessageHandler、DAPyBindQt |
最慢,一旦改动影响全局 |
flowchart TB
L5["应用层<br/>APP 主程序"] --> L4["接口层<br/>DAInterface / 插件 / Agent"]
L4 --> L3["界面层<br/>DAGui"]
L3 --> L2["功能层<br/>数据 / 图表 / 工作流 / 图元"]
L2 --> L1["基础层<br/>工具 / 共享 / 日志 / 绑定"]
分层不是越细越好。三层(界面、业务、基础)能覆盖大多数中型项目,五层适合有插件生态、有脚本绑定、有多个可独立发布的模块的项目。再往上加到七层八层,层与层之间的转发代码会超过实际逻辑,改一个字段要穿过四个 getXxx(),这时层数已经从约束变成了负担。判断层数是否合适的办法是看每一层有没有独立的交付物:能单独编译、单独安装、单独被外部依赖的层才有资格存在。
还有两种常见的分层错误。一种是把分层写成目录,构建系统里却仍然是一个 target 全量编译,层与层之间没有任何编译期约束,方向靠自觉,三个月就乱。另一种是把分层做成“每层一个库”但没有接口,上层直接调用下层的具体类,功能层改了函数签名,界面层全部编译失败。前者需要把 target 拆开,后者需要在上层和下层之间引入稳定的抽象,前者是构建问题,后者是设计问题,不能互相替代。
6.3 模块边界:职责、依赖方向、依赖矩阵¶
模块边界由三样东西定义:这个模块负责什么,它能依赖谁,别的模块怎么使用它。前两样可以写进文档,第三样必须由接口和构建系统强制。只写文档的边界,在第一次赶进度时就会破防。
依赖方向要具体到箭头。data-workbench 的公开规则写得很直白:上层可以依赖下层,下层不能依赖上层,同层模块尽量不直接依赖,由上层整合。换成检查动作就是:DAGui 里出现 #include "DAUtils/..." 合法,DAUtils 里出现 #include "DAGui/..." 非法;DAData 依赖 DAPyBindQt 合法,DAPyBindQt 依赖 DAData 非法。这条规则之所以有效,是因为它同时写进了 CMake 的链接关系,方向错了链接阶段就报错,不依赖评审者的记忆力。
flowchart TB
APP["APP 应用层"] -->|允许| IFACE["接口层"]
IFACE -->|允许| GUI["界面层"]
GUI -->|允许| FUNC["功能层"]
FUNC -->|允许| BASE["基础层"]
BASE -.->|禁止| GUI
BASE -.->|禁止| APP
依赖矩阵是把这个规则记录下来的工具。表的行是模块,列是它可以直接依赖的对象,单元格里写 PUBLIC 还是 PRIVATE。PUBLIC 会传递给消费者,PRIVATE 只影响自己。data-workbench 的公开文档里有一张完整的矩阵,下面摘出关键部分,它同时说明了每个模块在 CMake 里应该链接谁。
| 模块 | 层 | 允许直接依赖 | 禁止依赖 |
|---|---|---|---|
DAShared |
L1 | 仅 Qt Core | 所有项目内模块 |
DAUtils |
L1 | Qt Core、Gui、Widgets、Xml | 界面层、功能层、接口层、应用层 |
DAMessageHandler |
L1 | Qt 组件、DAUtils(PRIVATE)、spdlog |
除基础层以外的模块 |
DAPyBindQt |
L1 | Qt Core、pybind11、DAUtils(PRIVATE) |
所有业务模块 |
DAData |
L2 | Qt 组件、DAPyBindQt、DAPyScripts、DAUtils(PRIVATE) |
界面层、接口层、应用层 |
DAFigure |
L2 | Qt 组件、Qwt、DAUtils、DAWidgets |
界面层、接口层、应用层 |
DAPyWorkFlow |
L2 | DAUtils、DAGraphicsView、DAPyBindQt |
界面层、接口层、应用层 |
DAGraphicsView |
L2 | Qt 组件、DAUtils(PRIVATE) |
界面层、接口层、应用层 |
DAGui |
L3 | 所有 L1、L2 模块、SARibbon、ADS、Qwt | 接口层、应用层 |
DAInterface |
L4 | DAGui(PUBLIC,传递给消费者) |
应用层 |
DAPluginSupport |
L4 | DAInterface、DAPyWorkFlow(PUBLIC) |
应用层 |
DAAgent |
L4 | DAInterface、DAData、DAPyBindQt、DAPyScripts |
界面层各模块(刻意不依赖 GUI) |
APP |
L5 | DAPluginSupport、DAPyWorkFlow、全部第三方库 |
无 |
这张矩阵最有价值的地方,是它能检查出三类问题。单元格里出现下层模块依赖上层模块,是方向错误;同一层的两个模块互相依赖,是环的前兆,应该把公共部分下沉或引入协调者;某个模块的依赖列表突然变长,说明它的职责在膨胀,需要拆。矩阵不需要每周更新,但每次加新模块、加新依赖时要改,改的时候顺手检查这三类问题,成本只有几分钟。
拿 data-workbench 的真实结构验证一下这套检查。DAInterface 依赖 DAGui 是 PUBLIC 的,意思是任何链接 DAInterface 的插件自动获得整个界面层的能力。这条依赖违反了“接口层不应该依赖界面层”的常见说法,公开文档也承认这是为了让插件能访问完整 UI 而做的取舍。它的代价是接口层无法脱离界面层编译,好处是插件开发者只用一套头文件。矩阵的价值在于让这类依赖显式存在、被评审、被记录,而不是悄悄发生。
6.4 判断一个类该放哪:三条放置规则¶
依赖矩阵解决模块之间的关系,类放在哪个模块则要具体到每个新类。data-workbench 的公开文档给了一张“创建新类之前必须回答的问题”清单,可以归纳成三条规则。
第一条,按职责放置,看这个类因为什么原因变化。图表要加一种新的坐标轴,变化的是图表能力,放 DAFigure;主窗口要加一个菜单项,变化的是产品功能,放 APP;日志格式要改,变化的是基础能力,放 DAMessageHandler。如果一个类同时因为界面和算法变化,说明它承担了两个职责,先拆开再放置。
第二条,按依赖方向放置,看它会不会迫使下层依赖上层。一个类放在某层,就意味着这层要引入它的依赖。把“进度对话框”放进数据模块,数据模块就被迫依赖界面层;把“DataFrame 类型转换”放进工作流模块,基础绑定层想复用它时就要反向依赖。判断方法是问一句:不依赖当前模块的模块,会不会想用这个类?会,就往下沉。
第三条,按通用性放置。纯工具和基础类型进基础层或共享层;领域能力进功能层;界面组件进界面层;只有一处使用、且强绑定当前业务的东西留在原地,不要为了“将来可能复用”提前抽象。data-workbench 的公开文档里有一组真实的历史迁移记录,正好是这三条规则的反面教材与修正:
| 类 | 原位置 | 现位置 | 违反的规则 |
|---|---|---|---|
DAPyGILGuard、DAPyGILRelease、DAPySafePyObjectHolder |
DAPyWorkFlow |
DAPyBindQt |
通用 GIL 工具被放在业务模块,其他 Python 模块无法复用 |
DAParameterDescriptor |
DAPyWorkFlow |
DAGui/Workflow/NodeSetting |
参数描述符属于界面表单体系,不在工作流引擎 |
DAPyPainterProxy |
DAPyWorkFlow |
仍在评估是否迁移 DAPyBindQt |
QPainter 与 Python 的桥接无工作流逻辑 |
这三行记录比任何设计原则都更有说服力,因为它们说明“放错”是常态,放错的代价也是可修复的。第一行里,GIL 守卫是典型的通用工具,工作流模块用得上,Python 绑定层也用得上,放在工作流模块里,绑定层就只能复制一份或者反向依赖;迁到 DAPyBindQt 之后,两边都能用,依赖方向也顺了。第三行更有意思:DAPyPainterProxy 目前只有一个使用点,所以暂时不迁,这体现了第三条规则的另一半,通用性要由第二个真实使用场景证明,不能靠想象。
规则之外还有一条工程判断:看这个类能不能在没有主程序的情况下被测试。一个类如果必须启动 QApplication、必须创建主窗口、必须加载插件才能实例化,它大概率属于界面层或应用层;如果它能用几行测试代码构造出来,它就适合放在功能层或基础层。这个测试和“能否复用于其他模块”高度相关,但更容易执行。
6.5 插件化:什么该做成插件,什么不该¶
插件化(plugin architecture)解决的是“功能增长速度快于主程序发布速度”的问题。工业软件里,这个矛盾通常出现在三个地方:行业规则随客户变化、数据格式随设备变化、后处理算法随研究进展变化。这三类功能适合做成插件,因为它们的变化节奏和主程序不同,绑在一起发布会让主程序永远在发版。
判断标准可以归纳成四条:功能是否可选,是否由第三方维护,是否需要独立版本节奏,是否能在运行时动态加载。四条全中的功能,做成插件收益最大;只中一两条的,先做成模块,接口稳定之后再插件化。data-workbench 的公开插件清单包括 DataAnalysis(数据分析)、DASystemNodes(内置工作流节点)、DAAgentTools(Agent 工具)和 plugin-template(插件模板生成器),它们分别对应可选分析功能、可扩展节点、可替换的 AI 工具三类扩展点。
反过来,有四类东西不该做成插件。基础类型和工具库不该,因为所有插件都依赖它们,放进插件目录会让每个插件各带一份;核心文档模型不该,因为插件一旦能替换文档模型,序列化格式就无法保证一致;界面框架不该,因为插件需要稳定的主窗口和停靠体系才能挂载;性能热路径上的能力不该,因为跨 DLL 调用和虚函数分发的开销在百万级数据上会被放大。
插件化的代价集中在四个地方。启动变慢,宿主要在启动时扫描目录、加载 DLL、调用初始化,插件越多开机越慢,需要懒加载和按需加载;调试变难,插件里的崩溃要跨进程或跨模块定位,栈回溯经常缺符号;ABI 绑定,插件和宿主必须用同一套编译器和 Qt 版本,升级宿主就等于要求插件重编;接口维护,接口一旦发布,加方法容易、改方法难,删方法基本不可能。这四条合起来决定了插件化的第一条纪律:扩展点要少,接口要窄,版本要能拒绝。
data-workbench 的做法是双模式:需要高性能或深度集成的用 C++ 动态库插件,轻量的工作流节点用 Python 的 entry_points 注册。公开文档把这两种模式的取舍写得很清楚:C++ 插件适合需要和宿主共享数据结构的场景,Python 插件适合算法变化快、发布到 PyPI 就能用的场景。这种“两种插件”的结构比强制所有人都写 C++ 更现实,代价是宿主需要同时维护两套发现机制和两套生命周期。
插件不是越多越好。一个宿主有五十个插件时,启动时间、依赖冲突、界面菜单的拥挤程度都会成为新问题。成熟的做法是把插件分成“随宿主发布的核心插件”和“用户可选的扩展插件”,前者保证开箱可用,后者由用户按需安装。这条边界不写在代码里,但要在产品层面想清楚,否则插件化会从解耦手段变成部署难题。
6.6 接口设计:窄接口、版本化、上下文对象¶
插件接口是宿主和未知代码之间唯一稳定的接触面,它的设计质量决定插件生态能走多远。data-workbench 的接口层拆得很细:DACoreInterface 是核心入口,DAUIInterface 聚合界面能力,下面再分 DAActionsInterface、DACommandInterface、DADockingAreaInterface、DARibbonAreaInterface、DAStatusBarInterface、DADataManagerInterface,另外还有独立的 DAProjectInterface。这种拆法对应接口隔离原则(ISP):插件只依赖它真正用到的那一组方法,不用为了拿一个动作管理器而链接整个 UI。
窄接口的好处是稳定。一个只有五个方法的接口,两年后要改的概率远低于一个有五十个方法的“全能接口”;方法少了,实现类也少,宿主改内部结构时不必担心破坏插件。窄接口的代价是宿主需要组合多个接口才能完成一件事,插件开发者要先搞清楚该用哪一个。host 提供的便利入口 DAInterfaceHelper 就是为这个代价准备的:它把所有接口的访问收在一处,但接口本身仍然保持窄。
版本化要写进接口的身份里。Qt 的插件机制用 IID 区分接口,Q_DECLARE_INTERFACE 和 Q_PLUGIN_METADATA 把 IID 编进 DLL 的元数据,宿主加载时先比对 IID,对不上就拒绝加载。data-workbench 的节点插件用 DAABSTRACTNODEPLUGIN_IID 作为 IID,接口变更时换 IID,比在运行时判断版本号更早、更安全。插件元数据文件里还会写插件名、描述、版本和依赖,这些字段和 IID 一起构成插件的身份。
// 接口与 IID(结构示意,非完整实现)
class DAAbstractPlugin {
public:
virtual ~DAAbstractPlugin() = default;
// 宿主注入的上下文对象,插件通过它访问宿主能力
virtual bool initialize() = 0;
virtual void retranslate() = 0;
DACoreInterface* core() const { return m_core; }
virtual QString getName() const = 0;
virtual QString getDescription() const = 0;
virtual QString getVersion() const = 0;
protected:
DACoreInterface* m_core { nullptr };
};
// 接口变更时换 IID,宿主会拒绝旧插件而不是运行时崩溃
#define DAABSTRACTNODEPLUGIN_IID "com.daworkbench.DAAbstractNodePlugin/1.0"
Q_DECLARE_INTERFACE(DA::DAAbstractNodePlugin, DAABSTRACTNODEPLUGIN_IID)
上下文对象是接口设计里最容易被忽略的一条。插件需要访问文档、命令栈、日志、数据管理器,最省事的做法是把 QMainWindow* 直接传给插件,插件想点什么就调什么。这样做的后果是插件和主窗口的类结构焊死,主窗口换布局、换 Ribbon、换停靠框架,所有插件一起编译失败。data-workbench 的插件基类只持有一个 DACoreInterface*,插件通过它拿数据管理器、动作接口、停靠接口,主窗口的具体类型不出现在接口里。这条约定让界面重构不会波及插件,代价是插件开发者要多写一层 core()->getUIInterface()->... 的调用。
接口还要规定生命周期。公开文档把插件生命周期画成状态机:Unloaded 到 Active 再到 Unloading,失败进 Error,语言切换时调用 retranslate()。插件必须在 initialize() 里完成资源申请和信号连接,在卸载时反向释放;宿主负责在启动时按依赖顺序初始化、在退出时逆序卸载。生命周期不写清楚,插件在 initialize() 里弹模态对话框、在析构里等网络请求,都会让宿主退出时挂死。
6.7 文档-对象模型与命令撤销¶
界面之下要有一份权威的文档对象模型(document object model),否则数据会散落在各个控件的成员变量里,保存、撤销、复制、脚本访问都要各自实现一遍。工业软件的文档模型通常是一棵对象树:根节点持有文档元信息,子节点是领域对象,每个对象有稳定的 ID、属性表、序列化方法和变更信号。data-workbench 的工程文件 .dapro 就是这套模型的持久化形式,它是一个 ZIP 包,里面分 system.xml、workflow.xml、charts.xml、data-manager.xml、数据目录、图表数据目录和 Agent 会话目录,各模块按依赖顺序保存和加载。
有了文档模型,撤销才有落点。Qt 提供的 QUndoStack 是一个命令栈:每次用户操作 push 一个 QUndoCommand,命令的 redo() 执行修改,undo() 回滚修改,栈负责顺序、合并、宏和脏标记。data-workbench 在公开文档里对应了三处实现:数据管理器的 DADataManager 持有 QUndoStack,图形场景的 DAGraphicsScene 带撤销栈,接口层的 DACommandInterface 用 QUndoGroup 管理多个栈,让 Ctrl+Z 作用于当前获得焦点的那一个。
sequenceDiagram
participant U as 用户
participant V as 视图
participant C as 命令
participant D as 文档模型
participant S as QUndoStack
U->>V: 拖动图元
V->>V: 收集起止位置
V->>C: 构造移动命令
Note over C: 构造只记录参数<br/>不修改数据
V->>S: push(command)
S->>C: redo()
C->>D: 应用新位置
D-->>V: 数据变更信号
Note over S: cleanChanged<br/>驱动标题脏标记
U->>S: Ctrl+Z
S->>C: undo()
C->>D: 恢复旧位置
D-->>V: 数据变更信号
这套结构里有两个细节决定撤销的正确性。命令的构造函数不能修改模型,所有修改都放在 redo() 里,因为 QUndoStack::push() 会立刻调用一次 redo(),宏命令里的每条子命令也会被依次执行,构造函数里改数据就会改两次。连续操作要能合并,拖动一次滑块会产生几百个位置变化,如果每个都进栈,撤销一次只回退一个像素;给命令实现 id() 和 mergeWith(),同类型的相邻命令就能合并成一条记录,撤销粒度由这两个函数决定。
classDiagram
class QUndoStack {
+push(QUndoCommand*)
+undo()
+redo()
+setClean()
+cleanChanged(bool)
}
class QUndoCommand {
<<abstract>>
+undo()
+redo()
+mergeWith(QUndoCommand*) bool
+id() int
}
class MoveCommand {
-Item* mItem
-QPointF mDelta
+undo()
+redo()
+mergeWith(QUndoCommand*) bool
+id() int
}
class Document {
+undoStack() QUndoStack*
+addItem(Item*)
}
class View {
+refresh()
}
QUndoCommand <|-- MoveCommand
QUndoStack o-- QUndoCommand
Document *-- QUndoStack
Document ..> View : 数据变更信号
// 文档对象与撤销栈的结构示意(非完整实现)
class DADocument : public QObject {
Q_OBJECT
public:
Item* createItem(const QString& type);
void removeItem(Item* item);
QUndoStack* undoStack() { return &mUndoStack; }
signals:
void itemAdded(Item* item);
void itemRemoved(Item* item);
void modifiedChanged(bool modified);
private:
QList<Item*> mItems;
QUndoStack mUndoStack; // 所有修改的唯一入口
};
// 宿主侧:多文档、多面板用 QUndoGroup 切换当前栈
void DACommandInterface::setActiveStack(QUndoStack* stack)
{
mUndoGroup.setActiveStack(stack); // Ctrl+Z 作用于焦点面板
}
撤销子系统最容易出的问题是“漏路径”。菜单操作走了命令,右键菜单直接改数据,脚本接口又走了一条单独的路径,属性面板的编辑信号直接写模型,结果是撤销栈里只记录了部分修改,撤销之后界面和数据的其他部分对不上。解决办法不是到处补 push,而是把“修改文档必须经过命令”做成不可绕过的入口:文档对象的 setter 设为私有,只允许命令类访问;脚本绑定暴露的命令接口和菜单命令调用同一个工厂。data-workbench 的表格域在公开基准里统计过不同撤销实现的成本,单元格编辑走值记录,百万行表上应用和撤销都在 1 ms 以内;排序走置换记录,命令本身只驻留 8 MB 索引。这些数字说明撤销粒度设计可以兼顾正确性和性能,前提是修改路径收敛。
6.8 事件总线与模块解耦¶
模块之间除了依赖关系,还需要通信。Qt 的信号槽是默认答案:类型安全、连接关系可查、跨线程有明确规则。data-workbench 的模块间通信主要走信号槽,工作流执行时由 DAPySignalManager 把节点输出传播到下游,主程序里由 DAAppController 把停靠窗口的信号和接口层的信号链连接起来。这些连接都写在明确的类里,读代码时能顺着 connect 找到数据的来源和去向。
事件总线(event bus)是另一种选择:所有模块把事件发到一个中心,中心再分发给订阅者,发送方不需要知道接收方是谁。它在两种场景下有优势:一对多广播,比如主题切换、语言切换、工程关闭;跨模块通知,比如日志、状态栏消息、崩溃恢复。data-workbench 的语言切换用 retranslate() 逐个通知插件,日志用消息处理器分发到 UI 队列,本质上都是受控的广播。
事件总线的风险在于它会把依赖关系藏起来。信号槽的 connect 写在某个类里,依赖是显式的;事件总线里发送方只发一个字符串,接收方在别处订阅,半年后没人知道这个事件有几个消费者,改事件参数等于盲改。更隐蔽的问题是“事件转调用”:有的模块把事件总线当函数调用用,发一个事件然后期待对方立刻处理并返回结果,这既破坏了单向数据流,也让执行顺序依赖订阅顺序。判断该不该用事件总线,可以问三个问题:发送方是否真的不该知道接收方,是否允许多个接收方,事件丢失是否可接受。三个都是“是”,用总线;有一个“否”,用信号槽或直接接口调用。
模块解耦的目标,是让每一层的依赖都指向比自己变化更慢的一侧。界面依赖功能层的接口,功能层依赖基础层的工具,插件依赖接口层的契约。事件总线如果能让依赖指向事件定义而不是具体模块,它是解耦工具;如果只是把 connect 换成字符串,它只是把耦合藏得更深。
6.9 反模式:上帝类、循环依赖、UI 里写业务、隐式全局状态¶
架构原则讲了再多,项目还是会被四种反模式拖垮。它们的共同点是“短期看起来最省事”,代价在半年后集中出现。
上帝类是主窗口或“核心管理器”承担了所有职责:菜单响应、文件读写、数据校验、插件加载、界面刷新全在一个类里。症状是文件超过三千行、成员变量超过一百个、修改任何功能都要打开同一个文件。检测方法很简单:看这个类的头文件包含了多少个模块的头文件,超过十个基本可以确认。代价是所有人都在同一个文件上改,合并冲突天天发生,测试无法构造,重构无从下手。拆法是把职责按“变化原因”分出去,先抽控制器,再抽服务类,主窗口只留装配。
循环依赖是两个模块互相包含对方的头文件,或者两个类互相持有指针。它在编译期可能只是慢,在运行期会变成初始化顺序问题和卸载顺序问题。检测方法是把模块依赖画成图,看有没有环;C++ 里更实用的办法是看头文件包含:A 的头文件包含 B,B 的头文件包含 A,即使能编译,也说明边界错了。破环的手段是引入接口或事件:把 B 需要的能力抽成接口放到下层,A 实现接口,B 依赖接口,依赖变成单向。
界面里写业务是最常见的一种。按钮的槽函数里直接读文件、算数据、更新三个面板,功能能跑,但这段逻辑无法测试、无法复用、无法被脚本调用。判断标准是这段代码能不能在没有界面的情况下运行:能,把它移到功能层;不能,说明它和界面状态绑得太紧,先拆出参数和数据。data-workbench 的公开规范把这条写成了硬规则:业务类放在功能层,界面层只做交互和展示,设置面板通过统一的属性表单绑定参数,不在面板里写算法。
隐式全局状态是单例、静态变量、全局配置对象被到处读写,任何模块都可以改,改了之后没人知道谁受影响。它的症状是“重启就好”“换个操作顺序就崩”“测试单独跑通过、一起跑失败”。检测方法是搜全局变量的写入点,超过三个写入点就要警惕。治理手段是收敛可变状态:配置只读快照、单例只暴露查询接口、真正的全局状态(当前工程、当前选中数据)放到文档模型里,由信号通知变化。
| 反模式 | 典型症状 | 检测方法 | 代价 |
|---|---|---|---|
| 上帝类 | 主窗口三千行、改功能都要动同一个文件 | 头文件包含的模块数、成员变量数 | 合并冲突、无法测试、重构困难 |
| 循环依赖 | 初始化顺序敏感、卸载偶发崩溃 | 模块依赖图有环、头文件互相包含 | 编译慢、链接顺序脆弱、无法单独发布 |
| UI 里写业务 | 槽函数里读文件、算数据、连改多个面板 | 这段逻辑能否脱离界面运行 | 无法复用、无法测试、脚本接口要重写一遍 |
| 隐式全局状态 | 重启就好、换操作顺序就崩 | 全局变量写入点是否超过三个 | 故障难复现、模块间隐性耦合 |
这张表可以直接拿去做代码评审的检查项。反模式之所以难治,是因为它们单看每一处都有理由:主窗口统一管理确实省事,单例确实方便,界面里写业务确实少写一层转发。架构评审要做的是把这些“省事”的代价提前说出来,让团队在知情的前提下选择,而不是等半年后被迫重构。
架构判断可以收成一句话:把界面层整个删掉,剩下的代码还能不能表达这个领域的模型和流程。能,说明分层成立、边界清楚、撤销和序列化有落点;不能,说明业务已经长进了界面类,任何一次交互改版都会变成产品重构。下一章讨论怎么用文档、测试、静态分析和格式化把这些结构约束固定成日常流程,让架构不只是纸上的图。