多系统协作的十条流

当程序增长到多个子系统——每个子系统有自己的生命周期、自己的线程、自己的状态——十条流最终成型。启动流变成了「编排」,执行流变成了「跨系统调用」,状态流出现了「同步」,反馈流拥有了「跨系统回退」的能力。这是十条流最完整的形态。


一、从多层到多系统

多层系统的每一层内部通常是「被动的」——业务层被入口层调用,基础设施层被业务层调用。但当程序足够复杂时,某些部分需要自己运行——它们有独立的线程、独立的生命周期、独立的状态。

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 依赖 logfile_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
              单函数  单文件  多文件  多层  多系统
启动流 未形成 未形成 未形成 未形成 编排(有框架才有)
执行流 单树 调用图 跨文件 跨层调用链 跨系统调用
数据流 输入输出 转换链 指针传递 类型转换 接口类型转换
状态流 局部 局部+跨调用 三种状态 层内隔离 独立状态机
用户流程 一类多条 一类多条 一类多条 一类多条+分界 一类多条+唯一入口
业务流 单函数 多函数协作 文件=职责 业务/技术分离 子系统=环节
功能流 函数内 不变 不变 不变 不变
控制流 数据决定 调用者间接 封装边界 按层分配 框架化/接口化
错误流 内部处理 单向传播 跨文件传播 逐层上报 主路径+旁路
反馈流 不存在 萌芽 线性回退 首次回退 跨系统回退

两条规律贯穿始终:

  1. 功能流是唯一不变的流——从单函数到多系统,它始终是函数内部的逻辑步骤。所有其他流都在随规模变化,只有它不变。

  2. 反馈流从无到有,逐级生长——函数级不存在,文件级萌芽,多文件级线性回退,多层级首次真正回退,多系统级跨系统回退。反馈流的复杂度和组织规模正相关。

这十条流是观察任何软件的通用工具。 无论你拿到的是一个函数、一个文件、一个多层系统还是一个分布式系统,都可以用这十个问题去观察它的行为:启动时谁先?运行时谁调谁?数据经过谁?状态怎么变?用户怎么做?业务做什么?内部逻辑怎么跑?控制权在谁?出错怎么办?发现问题后回退到哪?

每回答一个「看不清楚」的问题,就是对系统理解的一个缺口——补上它,理解就更完整一层。