Serial-Studio 规格驱动开发实战:2.1 万行 God-class 按职责拆分 TU 的有序任务清单与验证配方
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
本文以 Serial-Studio 规格库中的任务清单 tasks.md 为主体,完整拆解该项目如何把三个合计 2.1 万余行的"上帝文件"(ProjectModel / ProjectEditor / ProjectHandler)拆分为按职责划分的编译单元(TU)。读完本文,你将掌握一套可复用的 C++ 大文件物理拆分方法:如何把"纯移动"定义成可 grep 验证的纪律、如何用"未限定名查找"技巧实现零调用点修改的跨 TU 共享、以及"每个阶段结束都是合法构建状态"的分阶段验收体系。
背景:四阶段规格驱动流程中的第三阶段
Serial-Studio 对非平凡改动采用 spec -> plan -> tasks -> implement 的四阶段规格驱动工作流,每阶段产出物需人工批准后才能进入下一阶段(详见 spec-driven.md)。本任务清单是该流程的第三阶段(Phase 3 of 4),定位是"有序检查清单"(the ordered checklist):
- 第一阶段 spec.md 回答 WHAT 与 WHY:问题、目标、编号需求、验收标准(AC1–AC6)与 CANNOT-MOVE 不变量;
- 第二阶段 plan.md 回答 HOW:受影响文件、验证配方、风险与回退策略;
- 第三阶段即本文主角 tasks.md:把 plan 拆成小、有序、可独立验证的任务单元,供
/ss-implement自上而下执行并保持复选框状态更新; - 门禁规则:人工在 tasks.md 上标记
approved之前,不得开始/ss-implement。
问题本身的规模,由 spec 记录如下:
| God 文件 | 原行数 | 职责簇 |
|---|---|---|
ProjectModel.cpp | 8,297 行 | 持久化、加载+遗留迁移、source CRUD、实体 CRUD、workspace 合成、folder CRUD、表格/寄存器、选中态、磁盘监视、自动保存、图表调用、状态/锁 |
ProjectEditor.cpp | 6,677 行 | 外部接线、树模型、MQTT 表单、实体表单、提交处理器、多选、选中镜像、摘要 |
ProjectHandler.cpp | 6,534 行 | 若干命令族(file/snapshot、entities、parser/painter/dry-run、batch)+ 约 60 个文件级 static 辅助 |
核心思路是:先做零行为变化的物理 TU 拆分(S1–S3,把函数体逐行搬到按职责命名的兄弟.cpp),之后再单独做协作者对象抽取(S4–S5)。类本身是 QML 与 API 层绑定的公共门面(facade),问题在文件而不在接口——最便宜、零风险的第一步是让每次改动只重编一个小 TU,而不是整个 God 文件。
任务清单的三条约定(Conventions)
tasks.md 开头用三条约定约束整个执行过程,这是该清单最值得借鉴的部分:
- 一个任务 = 一个聚焦、可评审的 diff(在本文语境下即"一个新 TU 或一次 CMake 编辑");
- Verify 是本单元通过后才继续的标准:plan 中的逐阶段 grep 配方,加上
python scripts/code-verify.py --check <files>(该脚本见 code-verify.py); - Deps 列出必须先落地的任务 ID;任务按"树在任何阶段之后都是合法早晨状态"(valid morning state)排序——S1、S2、S3 相互独立,按风险顺序 S1 -> S2 -> S3 执行。
执行状态:S1–S3 完成,偏差如实留痕
文档的 Run status(2026-07-06 夜间自主运行)记录了三个有教育意义的偏差(deviation),体现了"记录实际发生的事而非计划"的留痕纪律:
- S1/S2 共享头精简:
sanitizeFolderTree、serializeFolders(ProjectModel 侧)与buildFolderTree、accumulateFolderEnabled(ProjectEditor 侧)本计划提升到共享头ProjectModelShared.h/ProjectEditorShared.h,但审计发现每个只有单一调用者,于是降级为所属 TU 内的文件级static,而非入共享头——跨 TU 才值得升格; - S2b
CustomModel.h(T2.11)当次未执行:class CustomModel留在ProjectEditor.h,S5c 随之保持阻塞(S5c 的复选框最终勾选,说明该项在后续运行中补齐); - S3 的
ProjectApiSupport最终是纯头文件:14 个跨族辅助函数以inline自由函数形式落在ProjectApiSupport.h(无.cpp);register*Commands构建器与全部 65 处registerCommand调用留在残余ProjectHandler.cpp的registerCommands()中,只有命令体移入各命令族 TU。
S4、S5 在当次运行中仅停留为规格(spec-only),其验收标准记录在清单中、待后续运行实现。
S1:ProjectModel.cpp 的七族 TU 拆分(T1.1–T1.9)
S1 把 8,297 行的ProjectModel.cpp拆为 7 个新.cpp+ 1 个共享头 + 残余门面 TU。任务粒度、移动范围(原文件行号)与验证要点如下:
| 任务 | 新文件 | 移动内容(原文件行号) | 关键验证 |
|---|---|---|---|
| T1.1 | ProjectModelShared.h | 6 个跨 TU 辅助:folderExists、folderIsSelfOrDescendant、sanitizeFolderTree、serializeFolders(:67-154)、nextDuplicateTitle(:159-194)、seedDefaultFrameParser(:1503-1516),提升为namespace DataModel中的 inline/模板自由函数 | grep "\btr("零命中;每个辅助全仓库唯一;模板保留template、非模板加inline;code-verify --check |
| T1.2 | ProjectModelPersistence.cpp | :1779-2027(askSave..serializeToJson)+ :7639-7868(autoSave..finalizeProjectSave) | 定义对称性;include 闭包超集;纯移动 diff;watcher 重挂(watchProjectFile())完好 |
| T1.3 | ProjectModelLoading.cpp | :2334-3176(加载 + transform 扫描 statics :2715-2900 + 遗留迁移)+ :758-905(seed/dedup/migrate statics +remapWaterfallYAxisId) | static 闭包;纯移动 diff |
| T1.4 | ProjectModelSources.cpp | :1517-1778(source CRUD/settings)+ :4849-4982(frame-parser setters) | 定义对称性;纯移动 diff |
| T1.5 | ProjectModelCrud.cpp | :3366-3591、:3595-4029(含detail::RefAnchor)、:4036-4824(含detail::ThreeAxisLayout+populateThreeAxisDatasets:275-317)、:7877-8055、:8063-8297 | detail 命名空间 ODR:ThreeAxisLayout+RefAnchor只存在于本 TU |
| T1.6 | ProjectModelWorkspaces.cpp | :5055-5224、:5824-6030、:6781-7095、:7122-7480 + statics :196-273、:322-349(tally/append/collect/push/buildAutoRefsForGroup) | 按函数而非 banner 分区(6781-7480 的自动 workspace/隐藏组机制归此族而非 Folders) |
| T1.7 | ProjectModelFolders.cpp | :6032-6779(三块 folder CRUD + 提示框)+ :7097-7121(sanitize*Folders) | 定义对称性;纯移动 diff |
| T1.8 | ProjectModelTables.cpp | :5231-5451、:5461-5497、:5591-5822(表格/寄存器 + 提示框 + CSV 导入导出) | 定义对称性;纯移动 diff |
| T1.9 | app/CMakeLists.txt | 7 个新.cpp加入SOURCES(约 :308),ProjectModelShared.h加入HEADERS(约 :439),各恰好一次 | 每个新文件仅列一次;S1 全配方(步骤 1–7)通过;残余文件保留 ctor/单例、status/lock、getters(:725-1512)、setupExternalConnections、newJsonFile等 |
依赖关系上,T1.2–T1.8 均依赖 T1.1(共享头先行),T1.9 收尾并依赖 T1.1–T1.8。T1.2 还留痕了一条CANNOT-MOVE #4 的偏差处理:autoSave()/syncRuntime()体按计划本应留在门面.cpp(它们涉及m_runtimeDirty竞态区),实际却随持久化族搬到了ProjectModelPersistence.cpp;文档说明这是行为中性的(成员分派与 TU 无关,ctor 的 QTimer 接线两种写法都能解析),且 #4 的本意——把它们排除在 S4 的AutoSaveController协作者之外——仍然成立。
S2:ProjectEditor.cpp 拆九族 + ItemIds 头 + S2b CustomModel(T2.1–T2.12)
S2 额外抽出两个头文件,处理"私有枚举块"和"类定义"的搬家:
| 任务 | 新文件 | 内容与要点 |
|---|---|---|
| T2.1 | ProjectEditorItemIds.h | 原样移动ProjectEditor.cpp:51-273的私有 typedef-enum 块(TopLevelItem、ProjectItem、kDatasetView_*、kGroupView_*…)。非Q_OBJECT,无需 HEADERS 条目;验证要求每个使用k<View>_*的文件都 include 它 |
| T2.2 | ProjectEditorShared.h | 计划提升 4 个 inline 辅助(folderDisplayPath、buildFolderTree、accumulateFolderEnabled、busTypeIcon);实际偏差:只交付 2 个(folderDisplayPath、busTypeIcon),另两个因单调用者降级为文件级 static |
| T2.3 | ProjectEditorWiring.cpp | 移动 wire*(:281-760)。wireProjectModelRebuilds()的 connect 拓扑与QueuedConnection(:285-289)形状逐字保留(CANNOT-MOVE #8) |
| T2.4 | ProjectEditorTree.cpp | 移动 :1310-2021、:2419-2537、:5717-5782 |
| T2.5 | ProjectEditorMqtt.cpp | 移动 :1191-1214、:2023-2418,#ifdef BUILD_COMMERCIAL区域逐字保留,每 TU 的 open/close 必须配平 |
| T2.6 | ProjectEditorForms.cpp | 移动 :2538-3023、:3205-3443、:3443-4005、:5497-5640 |
| T2.7 | ProjectEditorCommit.cpp | 移动 :3024-3204、:4116-4749、:5641-5715(onDataset*/onGroup* 提交处理器)。标题编辑不变量(就地更新、禁止逐键击变更模型)必须保持(CANNOT-MOVE #9:这些处理器只有配合 ItemIds 头才可移动) |
| T2.8 | ProjectEditorMultiSelect.cpp | 移动 :4757-5065 |
| T2.9 | ProjectEditorSelection.cpp | 移动 :4088-4114、:5073-5495。m_selected*镜像变量仍声明在门面(CANNOT-MOVE #6);PM<->PE 循环回调拓扑不变 |
| T2.10 | ProjectEditorSummaries.cpp | 移动 :5784-6560、:6567-6677 |
| T2.11 (S2b) | CustomModel.h | 把class CustomModel(ProjectEditor.h:635-680)搬到独立头(含Q_OBJECT,需入 HEADERS 供 automoc),仅ProjectEditor.cpp与FrameParserModel.cpp两个使用点加 include;roleNames()不得变化。S5c 的前置条件 |
| T2.12 | app/CMakeLists.txt | 新.cpp入SOURCES(约 :309);CustomModel.h入HEADERS(约 :440);四个 Q_ENUM(CurrentView/EditorWidget/CustomRoles/ItemKind)必须留在ProjectEditor.h(CANNOT-MOVE #2) |
从当前仓库源码结构可以看到这套拆分的落地形态:core/Ui/ProjectEditor/ 目录下存在EditorWiring.cpp、EditorTree.cpp、EditorMqtt.cpp、EditorForms.cpp、EditorCommit.cpp、EditorMultiSelect.cpp、EditorSelection.cpp、EditorSummaries.cpp等按职责命名的族文件以及 ProjectEditorItemIds.h,与 S2 任务表一一对应;而残余的 ProjectEditor.cpp 从规格文档记录的 6,677 行缩减到当前 1,394 行,正好落在 plan 预估的"残余约 1,300 行(ctor、accessors :765-1290、generateComboBoxModels、transform-editor 胶水)"的量级。
S3:ProjectHandler.cpp 的"14 个跨族 static"与 STOP-RULE(T3.1–T3.6)
S3 是全清单风险最高的一站:ProjectHandler.cpp有约 60 个文件级 static,是 static 密度最高的文件。清单的策略是先审计出确凿跨族的 14 个,其余按族聚合,并用一条硬性 STOP-RULE 限制爆炸半径:
STOP-RULE:任何调用点横跨两个命令族、且不在 14 个名单内的 static —— 该族整体不拆。允许部分拆分(partial split),但要记录。
| 任务 | 新文件 | 内容 |
|---|---|---|
| T3.1 | ProjectApiSupport.h(/.cpp) | 14 个已验证的跨族 static 移入namespace API::Handlers并去掉static:attachProjectEpoch、captureProjectEpoch、appendStaleProjectWarning、appendUnknownFieldsWarning、buildDatasetObject、datasetOptionsBitflag、summarizeProjectJson、summarizeCurrentProject、takeParam(33 处调用点)、makeScriptEngine、detectLanguageMismatch、frameParserCompileHint、applySimpleAlarmFields、appendDatasetWidgetTypes。验证:每个辅助只定义一次;33 处takeParam站点零调用点修改即可解析 |
| T3.2 | ProjectHandlerFile.cpp | registerFile* + file/snapshot/validate/template + 专属 statics(:3683-3860) |
| T3.3 | ProjectHandlerEntities.cpp | group/dataset/action/outputWidget + applyDataset*Fields(:4773-4930)+ 成员辅助(:157-162) |
| T3.4 | ProjectHandlerParser.cpp | parser/painter/dry-run + 引擎 statics(:5847-6450) |
| T3.5 | ProjectHandlerBatch.cpp | batch + list/resolver/move |
| T3.6 | app/CMakeLists.txt | 新.cpp入SOURCES(约 :256),各恰好一次;新增 + 残余的registry.registerCommand计数之和等于原值;registerCommands()留在残余 TU |
每族任务的验证配方都包含 static 闭包 + STOP-RULE +BUILD_COMMERCIAL配平 +registerCommand计数贡献记录。从当前源码结构看,core/Api/API/Handlers/ProjectHandler.cpp 仍作为保留注册表的残余 TU 存在,与"注册入口不动、命令体按族外迁"的设计一致。
S4 / S5:协作者抽取的验收标准(状态归属 + 信号策略)
S4–S5 是"文件拆完之后、在不变门面背后抽真实协作者对象"的设计阶段。清单对每个子阶段都写明了拥有的状态(Owned state)与验收标准(AC),并规定统一的信号策略:协作者发出窄信号,门面 ctor 把它连到既有 NOTIFY 名上——QML 零修改。
S4(ProjectModel 侧,均依赖 S1):
| 子阶段 | 协作者 | 拥有的状态 / 验收标准要点 |
|---|---|---|
| T4a | ProjectFileGuard | m_fileWatcher、m_diskCheckPending、m_diskPromptActive、m_diskFileHash(约 150 行)。AC:写/加载/新建后watchProjectFile()重挂不变量完好;磁盘变化仍经同一门面信号上报;QML 不动 |
| T4b | AutoSaveController | m_autoSaveTimer、m_autoSaveSuspended(约 90 行)。AC:autoSave()/syncRuntime()/m_runtimeDirty留在门面(CANNOT-MOVE #4);自动保存节奏与挂起/恢复行为不变;不引入新竞态。依赖 T4a |
| T4c | WorkspaceSynthesizer | 无状态(纯函数)。AC:合成的 workspace 与当前输出字节级一致;自动重生成触发顺序(ctor 围栏,CANNOT-MOVE #3)不变 |
| T4d | LegacyMigrations | 无状态(自由函数)。AC:每条遗留文件路径产出相同迁移后 JSON;迁移顺序保持 |
| T4e | ProjectUiStateStore | UI 状态簇(约 350 行,:1165-1512)。AC:所有 getter/setter 重发同一组 NOTIFY 信号;QML 绑定不变 |
| T4f | ProjectSerializer/Loader(尾部) | 约 1,100 行的 serialize/load 对。AC:先解决ProjectDocument聚合体 vsfriend class的开放问题;序列化输出与加载结果字节级一致;watcher 重挂完好。依赖 T4a–T4e |
S5(ProjectEditor 侧,均依赖 S2),每个 controller 都要重申"标题编辑不变量"(就地更新条目,绝不逐键击变更模型):
| 子阶段 | 协作者 | 范围 / 验收标准要点 |
|---|---|---|
| T5a | ComboBoxCatalog | 约 250 行;combobox 模型内容一致;恢复竞态守卫(if (count <= 0) return)保留 |
| T5b | ProjectTreeController | 约 2,100 行——树 + 选中 + 展开必须一起搬;groupsChanged -> buildTreeModel保持QueuedConnection逐字不变(CANNOT-MOVE #8) |
| T5c | per-entityFormControllers | 约 3,000 行;每个接收CustomModel*、构建行、处理on*ItemChanged提交。前置条件为 S2b 的CustomModel.h(依赖 T2.11) |
| T5d | MultiSelectionController | 约 320 行;批量删除/复制/移动行为一致;选中镜像(CANNOT-MOVE #6)完好 |
这些"拥有的状态 + AC"式写法,使得每个协作者抽取在实现前就可被评审:状态边界先于代码存在,行为等价以"字节级一致/信号名一致/连接类型不变"这类可检查的判据表述,而非"看起来没坏"。
验证纪律:grep 配方、code-verify 与 Definition of Done
该清单最硬核的部分是"不依赖编译器也能验证纯移动"的配方(S1 七步,S2/S3 在其上叠加增量检查):
- 定义对称:
grep -cE "DataModel::ProjectModel::"(及 editor/handler 等价形式)旧值 = 各新 TU 之和;每个头声明成员恰好在一个族文件中定义; - static 闭包:每个
^static/^template辅助的调用点在同 TU 或共享头内可解析,只有已审计的共享头辅助可以跨 TU; - include 闭包:每个新 TU 的 include 是原文件的超集;
- 纯移动 diff:所有移动片段的拼接 == 所有删除片段的拼接(被升格辅助上
static->inline除外); - detail 命名空间 ODR:
ThreeAxisLayout+RefAnchor留在唯一的 TU(Crud),跨 TU 无重名类型; grep "\btr(" ProjectModelShared.h-> 零命中(共享头不得拖入翻译上下文);- CMake 恰好一次:每个新文件在
SOURCES/HEADERS中只出现一次。
S2 追加:(a) 每个k<View>_*使用文件 include ItemIds 头;(b)BUILD_COMMERCIAL每 TU open/close 配平;(c) 恰好一个class CustomModel定义、roleNames()不变。S3 追加:registerCommand计数总和守恒 + STOP-RULE 遵守记录。
Definition of Done 汇总为 8 条全功能门禁:
- spec.md 中每个验收标准在 S1–S5 全部落地后勾选(AC6 在 S4/S5 落地前保持开放);
python scripts/code-verify.py --check对全部改动文件干净(无新增错误);qt-cpp-review跑过 C++ diff,发现项已处理或留痕;- 热路径未触碰——
--benchmark-hotpath为正确性无需重跑; - 维护者运行既有 project-editor / API-handler 的
pytest套件(清单在 plan.md 中列出)——一旦失败即说明某个函数体被改动而非被移动; python scripts/sanitize-commit.py(见 sanitize-commit.py)跑过,工作树无 lint 债务;- diff 是"所要求之事且仅此而已"——纯移动、无范围蔓延、不碰外来文件;
spec.md状态仅在 S1–S5 全部完成后置done,中间运行保持in-progress(当前 spec.md 的 frontmatter 即为status: done,作者 Alex Spataru)。
这条"测试失败 = 体被改而非被移"的判读规则,是整套纪律的闭环:因为行为按构造不变,回归测试没有新增用例,唯一的信号源就是"动了不该动的字节"。
当前仓库中的落点与后续演化
从当前仓库结构看,这次拆分已成为后续重构的地基,且项目目录在 0002 之后经历了整体搬迁(任务清单中的app/src/...现对应core/...):
- core/Pipeline/DataModel/Project/ 目录汇聚了数据模型侧的按职责族文件,如
ProjectPersistence.cpp(770 行)、ProjectLoader.cpp(1,410 行)、ProjectWorkspaces.cpp(1,314 行)、ProjectEntities.cpp(1,375 行)、ProjectFolders.cpp(1,147 行)、ProjectSources.cpp、ProjectTables.cpp、ProjectBulkOps.cpp等;从命名与行数分布可以推断它们对应并进一步演化了 S1 的族划分(Persistence/Loading/Workspaces/Crud/Folders/Tables/Bulk); - 残余门面 core/Pipeline/DataModel/ProjectModel.cpp 现为 1,292 行,对照 spec 记录的原始 8,297 行,验证了"每次改动只重编一个小 TU"的目标;
- 仓库还沉淀了支撑该纪律的工具链:scripts/tu-cutter.py 是一个确定性 TU 切分器,按清单(manifest)逐块移动,除非解析出的块能逐行重构原文件(非空行、条件指令除外)否则拒绝切分,并对每个产出文件校验花括号与
#if配平——正是 tasks.md 中"纯移动 + 配平"配方的自动化形态。
结语:把"重构安全性"变成可检查的断言
0002 这份任务清单给出的核心方法论可以概括为三句话:
- 先把风险降为零再谈收益:物理 TU 拆分通过 moc 中性(成员定义可放在任意 TU,
Q_OBJECT元对象在不动的头文件里)与未限定名查找(把跨 TU 共享的static升格为共享头中的inline自由函数,33 处takeParam调用点零修改)两条语言级性质,变成"grep 对称 + 纯移动 diff"即可验证的操作; - 每个完成的前缀都是合法状态:S1/S2/S3 相互独立、按风险排序,任一阶段被打断,树上留下的都是可构建、可测试的中间态;
- 偏差是文档的一部分:单调用者降级为 static、header-only 交付、计划外搬移的
autoSave(),全部以 Deviation 字段留痕并解释为何仍满足不变量——这让清单本身就是可审计的执行记录。
对任何持有数千行.cpp的 Qt/C++ 项目,这套"规格 -> 计划 -> 有序任务 -> 逐阶段验证"的清单式拆法,以及配套的 CANNOT-MOVE 不变量与 STOP-RULE,都提供了一个不依赖大规模测试基础设施也能保证"移动即安全"的工程模板。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考