多系统协作的十条流
当程序增长到多个子系统——每个子系统有自己的生命周期、自己的线程、自己的状态——十条流最终成型。启动流变成了「编排」,执行流变成了「跨系统调用」,状态流出现了「同步」,反馈流拥有了「跨系统回退」的能力。这是十条流最完整的形态。
一、从多层到多系统 多层系统的每一层内部通常是「被动的」——业务层被入口层调用,基础设施层被业务层调用。但当程序足够复杂时,某些部分需要自己运行 ——它们有独立的线程、独立的生命周期、独立的状态。
1 2 3 4 5 6 7 8 9 10 11 12 args_app/ ├── main.c ← 入口层:解析命令行,组装系统 ├── cli/ ← 入口子系统:CLI 解析 │ ├── cli_parser.h/c ├── format/ ← 业务子系统:格式化 │ ├── formatter.h/c ├── utils/log/ ← 全局工具框架化子系统:日志(有自己的线程) │ ├── log_system.h/c ├── file_io/ ← 接口化子系统:文件 IO(无状态) │ ├── file_ops.h/c └── search/ ← 业务子系统:搜索 ├── searcher.h/c
这里的关键变化:log 子系统是一个框架化子系统 ——它有自己的线程,自己控制写入时机,有自己的启动和关闭流程。它不是被动地被调用,而是主动地运行。
但 log 有一个特殊身份:logger 是全局工具 。它和多层阶段的 utils/log 是同一个东西——横切关注点,不属于任何业务,谁都可以用(入口层记参数、业务层记进度、file_io 记失败)。当它升级成框架化子系统(有自己的线程、生命周期)后,仍然放在 utils 里 :全局工具框架化后就是 utils 里的框架化子系统,与其他业务子系统分开。
1 2 3 4 5 6 7 8 9 框架化子系统(log): → 自己拥有控制流(独立线程) → 有自己的生命周期(start → running → stop) → 被上层初始化,但运行时自主 接口化子系统(file_io): → 被调用时才执行 → 无状态,无生命周期 → 上层完全控制
两种子系统形态——框架化和接口化——让十条流在多系统级出现了前所未有的复杂性。
二、启动流:子系统的编排(启动流在这里才真正形成)
前四个阶段(单函数→单文件→多文件→多层)启动流都尚未形成 ——都只有 main 一个入口,没有框架。多系统阶段出现了框架化子系统 (有生命周期、要 start/stop),启动流在这里才真正形成:按依赖顺序编排子系统的启动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 graph TD L["log_system.start()"] -->|"无依赖"| L_OK["✅ 日志就绪"] F["file_ops.init()"] -->|"无依赖"| F_OK["✅ 文件IO就绪"] S["searcher.init(log, file_ops)"] -->|"依赖 log + file_ops"| S_OK["✅ 搜索器就绪"] C["cli.start(searcher, formatter)"] -->|"依赖 searcher"| C_OK["✅ CLI就绪"] L_OK --> S F_OK --> S S_OK --> C style L fill:#4caf50,color:#fff style F fill:#4caf50,color:#fff style S fill:#ff9f4a,color:#fff style C fill:#4a9eff,color:#fff
多层阶段没有启动流(基础设施层是接口,无需初始化;启动流要等能力升级成独立系统、被框架化才出现)。多系统的启动流变成了:按依赖顺序编排子系统的启动 。
1 2 3 4 5 6 7 启动编排: 1. log_system.start() ← 日志先启动(其他子系统要用它) 2. file_ops.init() ← 文件 IO 初始化 3. formatter.init() ← 格式化器初始化 4. searcher.init(log, file_ops) ← 搜索器初始化(依赖日志 + 文件 IO) 5. cli.start(searcher, formatter) ← CLI 启动(依赖搜索器 + 格式化器) 6. 进入 CLI 主循环
启动流的关键约束:依赖决定顺序 。searcher 依赖 log 和 file_ops,所以它们必须先启动。
1 2 3 4 5 6 启动依赖图: log_system ← 无依赖,最先启动 file_ops ← 无依赖,可与 log 并行 formatter ← 无依赖 searcher ← 依赖 log + file_ops cli ← 依赖 searcher + formatter
多系统启动流的核心变化:启动变成了「编排问题」——需要分析依赖图、确定拓扑排序、处理并行启动。
启动失败的处理也变复杂了:
1 2 3 log_system.start() 失败 → 其他子系统都不能启动(因为它们都要用日志) → 整个程序终止
1 2 3 formatter.init() 失败 → searcher 可以启动(搜索不需要格式化) → cli 降级:禁用格式化相关命令
启动流的错误处理:哪些子系统启动失败是致命的,哪些可以降级?
三、执行流:跨子系统的调用 执行流现在跨越了子系统的边界:
1 2 3 4 5 6 cli: 解析用户命令 "search file.bin AABB" → searcher: search("file.bin", "AABB") → file_ops: read("file.bin") → 返回字节数组 → searcher: 在字节数组中搜索 "AABB" → 找到偏移 42 → log: log_info("found at offset 42") → cli: 输出 "found at offset 42"
执行流跨子系统时的关键观察:每个子系统只知道自己的直接调用者,不知道调用者的调用者 。
1 2 searcher 知道的:有人调了我 search() searcher 不知道的:是 cli 调了我,还是测试程序调了我
多系统执行流的核心变化:子系统之间是「接口调用」,不关心对方是谁。 这就是接口化的价值——子系统可以被任何调用者使用。
四、数据流:子系统间的数据交换 数据在子系统之间流动:
1 2 3 4 5 6 7 8 9 cli: 字符串 "search file.bin AABB" ↓ parse searcher: Request{path="file.bin", pattern="AABB"} ↓ 调用 file_ops file_ops: ByteBuffer{data=[...]} ↓ 返回给 searcher searcher: Result{offset=42, found=true} ↓ 返回给 cli cli: 字符串 "found at offset 42"
数据流跨子系统的关键观察:每个子系统有自己的数据类型,子系统间通过接口定义的类型传递数据 。
1 2 3 4 cli 的数据类型:字符串(用户输入) searcher 的数据类型:Request, Result(结构化查询) file_ops 的数据类型:ByteBuffer(字节数组) log 的数据类型:LogRecord(日志条目)
多系统数据流的核心变化:数据类型在子系统边界处做转换。 每个子系统「看到」的数据形态不同。
五、状态流:子系统的独立状态 多系统级的状态流出现了独立的状态域 :
1 2 3 4 5 6 7 8 9 10 11 log_system 的状态: INIT → RUNNING → STOPPED (有自己的状态机,自己控制转换) searcher 的状态: IDLE → SEARCHING → IDLE (被 cli 驱动,无独立线程) cli 的状态: WAITING_INPUT → PROCESSING → WAITING_INPUT (在主循环中轮转)
关键区别:框架化子系统有独立的状态机 (log_system 的状态变化不被外部驱动),接口化子系统没有独立状态 (searcher 的状态由调用者驱动)。
子系统间的状态同步问题:
1 2 3 4 5 6 7 cli 进入 PROCESSING 状态 → 调用 searcher.search() → searcher 进入 SEARCHING 状态 → searcher 调用 log.log_info("searching...") → log 自己在 RUNNING 状态,接受日志请求 → searcher 返回结果 → cli 输出结果,回到 WAITING_INPUT
如果 log_system 此时正在 STOPPING(正在关闭):
1 2 3 4 searcher 调用 log.log_info() → log 正在关闭,拒绝写入 → searcher 收到错误 → searcher 决定:跳过日志?等待?终止?
多系统状态流的核心变化:子系统有独立的状态机,状态同步成为新问题。
六、用户流程:仍是一类多条,CLI 是唯一入口 多系统 CLI 阶段,用户流程仍是一类多条 ——这类流程共有的模式是:输入命令 → 等待 → 看到结果(验证)→ 输入下一条命令,如此循环。每一条具体命令(list、search、format……)只是这一类的一个实例,无需逐一赘述。 用户流程被完全隔离在入口子系统:
1 2 3 4 5 6 7 用户流程这一类(CLI 子系统以上): 输入命令 → 等待 → 看到结果(验证:符合预期)→ 输入下一条命令 → …… 循环往复,每条命令一个实例 执行流这一类(CLI 子系统以下): cli 解析命令 → 调用对应子系统 → 子系统执行 → 结果返回 → cli 格式化输出 用户看到结果,进入下一个实例
CLI 子系统 = 用户流程和执行流的唯一分界线。 所有子系统中,只有 CLI 能感知用户的存在,用户不关心 searcher、file_ops 怎么执行。
1 2 3 用户 ↕ ← CLI 子系统(唯一的交界面) log | searcher | file_ops | formatter ← 全部对用户不可见
多系统用户流程的核心特征:用户流程被完全隔离在入口子系统中,其他所有子系统都是纯执行流。即使到了多系统,CLI 的用户流程仍是一类多条——真正多步操作、逐步验证的完整用户流程是 GUI 的线(见「从输入到交互」系列)。
七、业务流:子系统分工 多系统的业务流变成了:每个子系统负责业务流中的一个环节 。
1 2 3 4 5 6 7 8 业务流(完整的搜索操作): 1. 接收用户命令 → cli 2. 解析搜索参数 → cli 3. 读取目标文件 → file_ops 4. 在文件中搜索模式 → searcher 5. 记录操作日志 → log_system 6. 格式化搜索结果 → formatter(可选) 7. 输出给用户 → cli
业务流和子系统的对应关系:
1 2 3 4 5 cli: 步骤 1, 2, 7(入口 + 输出) file_ops: 步骤 3(基础设施) searcher: 步骤 4(业务核心) log_system: 步骤 5(横切关注点) formatter: 步骤 6(可选增强)
多系统业务流的核心变化:业务流被子系统切分了。 每个子系统只负责业务流中的一个环节——修改任何一个子系统都不会影响其他环节。
八、功能流:仍然在函数内部 从单函数到多系统,功能流始终是单个函数内部的逻辑步骤。它不随组织规模变化,是所有流中最稳定的。
功能流是唯一不随组织规模变化的流——它始终是函数级别的。
九、控制流:框架化 vs 接口化 1 2 3 4 5 6 7 8 9 10 11 12 graph LR subgraph "框架化子系统 (log_system)" A["有自己的线程"] --> B["自己控制执行时机"] B --> C["上层只控制 start/stop"] end subgraph "接口化子系统 (file_ops)" D["无状态"] --> E["被调用时才执行"] E --> F["上层完全控制"] end style A fill:#4caf50,color:#fff style D fill:#4a9eff,color:#fff
多系统级的控制流最终成型:
1 2 3 4 5 6 7 8 9 框架化子系统(log_system): → 自己拥有控制流(独立线程,自己决定什么时候写日志) → 上层只能「初始化」和「关闭」,不能控制其内部行为 → 类封装最合适:初始化时创建对象,运行时对象自主 接口化子系统(file_ops): → 被调用时才执行,没有自己的控制流 → 上层完全控制:什么时候调用、传什么参数、结果怎么用 → 函数导出最合适:无需初始化,直接调用
控制流的分配:
1 2 3 4 5 main: 全局控制流(组装、启动、关闭) cli: 用户交互控制流(解析命令、驱动业务) log_system: 自有控制流(独立线程,自主写入) searcher: 被驱动(被 cli 调用时才执行) file_ops: 被驱动(被 searcher 调用时才执行)
多系统控制流的核心变化:控制流开始在子系统之间分配。框架化子系统有独立控制流,接口化子系统没有。
这就是「库的两种形态」的完整体现:
1 2 3 4 5 框架化库 = 有状态 + 有生命周期 + 有自己的控制流 → 使用类封装,初始化时创建,运行时自主 接口化库 = 无状态 + 无生命周期 + 无自己的控制流 → 使用函数导出,无需初始化,直接调用
十、错误流:跨子系统的错误传播 多系统级的错误流变得复杂:
1 2 3 4 5 6 7 file_ops: read() 失败 → 返回错误给 searcher searcher: 检查错误 → 决定:尝试备用文件?返回空结果? → 返回错误给 cli cli: 检查错误 → 决定:打印错误信息给用户?重试?
错误传播路径:
1 file_ops → searcher → cli → 用户
但还有另一条路径:
1 2 3 searcher: 检查错误 → 调用 log.log_error("file read failed") ← 错误日志 → 返回错误给 cli
错误流的两条路径 :
1 2 主路径:file_ops → searcher → cli → 用户(逐层上报) 旁路: searcher → log_system(记录错误日志,不影响主流程)
多系统错误流的核心变化:错误传播有了分支——主路径向上层报告,旁路向日志系统记录。
十一、反馈流:跨系统回退 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 sequenceDiagram participant U as 用户 participant CLI as cli子系统 participant S as searcher子系统 participant F as file_ops子系统 U->>CLI: 输入搜索命令 CLI->>S: 调用search() S->>F: 读取文件 F-->>S: 文件不存在! S-->>CLI: 返回错误 CLI->>U: 显示错误, 询问新路径 U->>CLI: 输入新路径 CLI->>S: 重新调用search() S->>F: 读取文件 F-->>S: 返回数据 S-->>CLI: 返回结果 CLI-->>U: 显示结果
多系统级的反馈流拥有了最完整的能力:
1 2 3 4 cli 发现 searcher 返回错误 → 回退到「让用户重新输入命令」 → 用户输入新命令 → 重新执行搜索流程
更复杂的场景:
1 2 3 4 5 searcher 发现 file_ops 读文件失败 → 不是终止,而是询问用户:「文件不存在,是否指定其他路径?」 → 用户提供新路径 → searcher 重新调用 file_ops → 搜索继续
反馈流的回退层次:
1 2 3 4 函数级回退:跳过当前操作,继续下一个(最弱) 文件级回退:返回错误码给调用者,调用者决定下一步 多层回退:上层接住下层错误,从合适步骤重来 多系统回退:跨子系统回退,甚至可以询问用户重新输入(最强)
多系统反馈流的核心突破:回退可以跨越子系统边界,甚至可以回到用户。 这是反馈流最完整的形态。
十二、十条流在多系统级的全景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 多系统级的十条流 ┌──────────────────────────────────────────────────────────────────┐ │ 启动流 按依赖顺序编排子系统启动(拓扑排序) │ │ 执行流 跨子系统的接口调用链 │ │ 数据流 子系统间通过接口类型传递数据 │ │ 状态流 子系统有独立状态机,状态同步成为新问题 │ │ 用户流程 一类多条;CLI = 唯一分界线 │ │ 业务流 业务流被子系统切分,每个子系统负责一个环节 │ │ 功能流 仍在函数内部(不随规模变化) │ │ 控制流 框架化子系统有独立控制流,接口化子系统被驱动 │ │ 错误流 主路径向上层报告,旁路向日志记录 │ │ 反馈流 跨子系统回退,甚至可以回到用户(最强形态) │ └──────────────────────────────────────────────────────────────────┘
十三、十条流的演化总结 从单函数到多系统,十条流的演化轨迹:
1 2 3 4 5 6 7 8 9 10 11 单函数 单文件 多文件 多层 多系统 启动流 未形成 未形成 未形成 未形成 编排(有框架才有) 执行流 单树 调用图 跨文件 跨层调用链 跨系统调用 数据流 输入输出 转换链 指针传递 类型转换 接口类型转换 状态流 局部 局部+跨调用 三种状态 层内隔离 独立状态机 用户流程 一类多条 一类多条 一类多条 一类多条+分界 一类多条+唯一入口 业务流 单函数 多函数协作 文件=职责 业务/技术分离 子系统=环节 功能流 函数内 不变 不变 不变 不变 控制流 数据决定 调用者间接 封装边界 按层分配 框架化/接口化 错误流 内部处理 单向传播 跨文件传播 逐层上报 主路径+旁路 反馈流 不存在 萌芽 线性回退 首次回退 跨系统回退
两条规律贯穿始终:
功能流是唯一不变的流 ——从单函数到多系统,它始终是函数内部的逻辑步骤。所有其他流都在随规模变化,只有它不变。
反馈流从无到有,逐级生长 ——函数级不存在,文件级萌芽,多文件级线性回退,多层级首次真正回退,多系统级跨系统回退。反馈流的复杂度和组织规模正相关。
这十条流是观察任何软件的通用工具。 无论你拿到的是一个函数、一个文件、一个多层系统还是一个分布式系统,都可以用这十个问题去观察它的行为:启动时谁先?运行时谁调谁?数据经过谁?状态怎么变?用户怎么做?业务做什么?内部逻辑怎么跑?控制权在谁?出错怎么办?发现问题后回退到哪?
每回答一个「看不清楚」的问题,就是对系统理解的一个缺口——补上它,理解就更完整一层。