Files
codestable__codestable/asset/programming-paradigm.md

675 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 编程通用范式:编排-计算分离与渐进结构化
> 本文档从具体语言实现中提炼出语言无关的编程范式。
> 这套范式适用于任何支持函数/模块/接口抽象的语言。
---
## 第一部分:核心模型
### 0. 基础约束:代码是写给人看的
所有规则的根基是一条认知事实:**人在单一时刻能处理的信息量是有限的**。代码的一切组织方式——函数拆分、文件划分、层次结构——最终都是为了让阅读者在任意时刻只需面对"刚好能理解"的信息量。
#### 单函数:一屏可读
一个函数应该在**一屏之内读完**,阅读者不需要滚动就能看到全貌。这不是审美偏好,而是认知约束——滚动意味着阅读者必须用记忆力去拼接上下文,记忆力是最不可靠的资源。
经验数字:
- **编排函数**:10-30 行最佳,每一行是一个动作名。超过 50 行几乎必然层级混乱。
- **计算函数**:不超过 50 行。超了说明它做的事情不止一件,应该继续拆。
- **硬上限**:单函数超过 100 行,无论什么理由,都是需要拆分的信号。
#### 单文件:一个文件一个角色
一个文件应该用**一句话说清它的职责**。说不清 → 文件该拆。
经验数字:
- **编排文件**(Workflow / Handler):50-150 行。它只做编排,超了说明业务逻辑混进来了。
- **计算文件**(领域服务 / 工具函数):100-300 行。一个内聚的计算主题。
- **超过 400 行**:几乎必然职责混合。不是"太长了",是"两件事混在一个文件里了"。
拆文件的判断方法:**数一下文件里有几组"不相关的函数"**。如果函数 A-D 互相调用但从不调用 E-G,而 E-G 互相调用也从不调用 A-D——它们就是两个文件。
#### 单次可见逻辑量
在任意一个抽象层级上,阅读者一次看到的**逻辑分支不应超过 7±2 个**(米勒定律)。
- 一个编排函数里,步骤超过 7 个 → 考虑分成"阶段",每个阶段是一个子编排
- 一个 if/switch 里,分支超过 5 个 → 考虑用注册发现或策略模式替代
- 一个模块的公开接口超过 7 个方法 → 考虑拆成更小的模块
- 一个函数同时操作超过 7 个概念(order、customer、payment...)→ 拆成两步
#### 信息密度的平衡
过疏和过密都有害:
- **过密**:一个函数里塞了加载、计算、持久化、异常处理、日志——阅读者被淹没
- **过疏**:每个操作都拆成单独的类和文件,3 行逻辑散落在 5 个文件里——阅读者被迫四处跳转拼接
**判断标准:阅读者理解一段逻辑需要同时打开几个文件?** 如果超过 3 个,说明拆得太碎了。所有规则(分层、拆文件、抽函数)都要在"单点信息过载"和"跨文件跳转"之间找平衡。
#### 文件内的信息布局
一个文件被打开后,阅读者在**前 10 秒**应该能回答:
1. **这个文件是干什么的**(文件名 + 顶部注释/类名)
2. **核心流程是什么**(编排函数在最前面,不要埋在 500 行之后)
3. **依赖了谁**(import / 构造参数一目了然)
具体做法:
- **编排函数/主函数放在文件最前面**,不要被辅助函数埋没
- **公开接口在前,私有实现在后**——阅读者先看"做什么",有兴趣再往下看"怎么做"
- **命名要承载意图**——好的命名让阅读者跳过实现。`compute_risk(order, customer)` 不需要看函数体就知道它干什么;`process(data)` 什么都没说
#### 核心判断标准
所有数字(50 行、400 行、7 个概念)都是启发式信号。真正的判断标准只有一个:
> **一个新加入团队的人,打开这个文件/函数,能否在 30 秒内理解它的意图?**
>
> 能 → 信息密度合适。
> 不能 → 要么太长(拆)、要么层级混乱(抽取)、要么命名不好(重命名)。
### 1. 三段式执行模型
任何有副作用的业务操作,都可以分解为三个阶段:
```
加载(Load)→ 计算(Compute)→ 持久化(Persist)
```
- **加载**:从外部世界(数据库、文件、网络)读取状态,装入内存结构
- **计算**:纯逻辑处理,不接触外部世界,输入决定输出
- **持久化**:把计算结果写回外部世界
这三个阶段的代码**不应混在同一个函数里**。混合是复杂性的第一来源——你无法单独测试计算逻辑,因为它和 IO 纠缠在一起。
**不同语言的实现方式不同,但分离原则不变**:
- 函数式语言天然做到了(纯函数 + IO Monad)
- 面向对象语言需要显式分层(Loader / Service / Persister)
- 脚本语言需要纪律约束(把 IO 调用集中到函数首尾)
### 2. 状态是显式的容器,不是隐式的环境
程序中流转的状态应该是一个**显式的数据结构**,在函数间作为参数传递,而不是藏在全局变量、线程本地存储、闭包捕获、或模块级 mutable state 中。
```
// 原则
f(state) → state'
// 而不是
f() // 偷偷读写某个外部的 state
```
这个显式的数据结构(我们称之为 **Context**)有三层内容:
| 层次 | 性质 | 示例 |
|------|------|------|
| 不可变输入 | 请求级标识,全程不变 | request_id, tenant_id, trace_id |
| 领域状态 | 核心业务数据,可变 | order, customer, account |
| 过程产物 | 节点间的中间结果 | risk_score, payment_result |
**Context 的边界判断**:一个值该不该进 Context,问一个问题——"这个值在不同请求之间会不一样吗?" 会 → 进 Context;不会 → 它是依赖,通过构造/初始化注入。
#### 框架提供的状态容器是合法的 Context
框架提供的响应式状态容器(Vue ref/reactive、React useState、Redux store)本质上就是 Context——它们是显式的、可追踪的、框架帮你管理的状态。**使用它们不违反本原则。**
不同技术栈的 Context 形态:
| 技术栈 | 状态容器(Context) | 编排层 | 计算层 |
|--------|---------------------|--------|--------|
| Vue | ref / reactive | methods / watch | computed / 纯函数 |
| React | useState / store | event handler / useEffect | 纯函数 / useMemo |
| 后端 Java | Context 对象 | Workflow 类 | 领域服务 |
| Rust | &mut Context | orchestrator 函数 | 纯函数 |
**违反本原则的不是"改 ref",而是绕过显式容器传递可变状态**:
- 模块级变量被多个组件/函数隐式共享
- 全局对象/单例被当作垃圾桶随处读写
- 隐式注入(provide/inject、ThreadLocal)传递可变状态而非只读值或修改函数
- 闭包捕获了外层的可变变量,且修改不可追踪
判断标准:**"这个状态的修改,是否可以从编排层的代码里一眼看出来?"** 可以 → 合法;不可以 → 隐式状态,需要招安。
### 3. 编排与计算分离
这是整套范式的核心原理。
**编排(Orchestration)**= 决定"做什么、以什么顺序、在什么条件下"。
**计算(Computation)**= 具体"怎么做"。
两者必须分处不同的代码单元(函数、模块、文件)。
```
// 编排层:只有流程骨架,每一行是一个动作名
function process_order(ctx):
validate(ctx)
check_risk(ctx)
charge(ctx)
ship(ctx)
// 计算层:具体逻辑,不知道自己在哪个流程里
function check_risk(order, customer) -> risk_score:
// 纯计算,无副作用,不依赖 context 容器
```
**关键约束:计算层不应接收 Context 容器。** 它只接收自己真正需要的参数。编排层负责从 Context 中取出参数、调用计算、把结果写回 Context。
精确表述:**计算层接收值、返回值,不接触状态容器。编排层负责从容器取值、调计算、把结果写回容器。**
```
// ✅ 计算函数:接收值,返回值,不碰容器
function compute_risk(order, customer) -> score:
score = 0
if customer.bindDuration < 30: score += 40
if order.amount > 10000: score += 30
return score
// ✅ 编排层:从容器取值 → 调计算 → 写回容器
function handle_submit():
score = compute_risk(order.value, customer.value) // 取值 + 调计算
risk_score.value = score // 写回容器
// ❌ 计算和编排混在一起
function compute_risk():
score = 0
if customer.value.bindDuration < 30: score += 40 // 直接读容器
risk_score.value = score // 直接写容器
api.charge(order.value.id) // 连持久化都混进来了
```
**只读派生不算"接触容器"。** 响应式框架中的派生计算(Vue computed、React useMemo)读取容器但不修改状态,数学上是 `f(state) → derived_value`,属于计算层而非编排层。
这条约束的意义:
- 计算函数的签名暴露了它的真实依赖,可读性极高
- 计算函数可以脱离 Context 独立测试
- Context 的变更不会波及计算层
#### 编排层是一个职责,不是一个固定的架构层
编排的本质是**"组装能力而不实现能力"**。这个职责可以由不同重量级的代码单元承担:
```
最轻 最重
│ │
调用点直接组合 提取组合模块 独立编排模块/类
(模板里写slot组合) (CopyableTip) (OrderWorkflow)
│ │
适合:1-2次使用 适合:3+次重复模式 适合:核心业务流程
不额外建文件 多一个小文件 独立文件,专职编排
```
三者做的事情完全一样——组装能力。区别只是**固化程度**。
后端倾向重量级编排(独立 Workflow 类),因为流程涉及事务边界、持久化时机、多入口调用,这些约束迫使编排逻辑必须集中管理。
前端组件组合通常没有这些约束——一个页面模板组合几个组件,不涉及事务、调用点通常就一个——所以调用点直接组合就够了。
**前端需要重量级编排的信号**:
- 多步表单/向导:步骤之间有依赖和条件跳转
- 复杂状态联动:改 A 要联动更新 B、C、D,不该散落在各个 watch 里
- 同一组合模式重复 3 次以上
选择哪个重量级,问三个问题:
1. **有没有事务/持久化/多入口等约束?** 有 → 独立编排模块
2. **这个组合模式重复出现 3 次以上了吗?** 是 → 提取组合模块
3. **都不是?** → 调用点直接组合,最简方案先行
### 4. 同层同级原则
基础约束(第 0 节)在代码层面的直接推论:**一个函数体内的每一行代码,应该处于同一个抽象层级**。
```
// ✅ 同一层级:都是"做什么"
validate(ctx)
check_risk(ctx)
charge(ctx)
ship(ctx)
// ❌ 层级混乱
validate(ctx)
score = 0
for rule in rules: // 突然掉到"怎么做"
score += rule.eval(order)
charge(ctx)
ship(ctx)
```
判断标准:**每一行能不能用一句自然语言概括为"做了什么"**——如果需要说"它遍历了一个列表然后累加了分数",那这段代码属于下一层。
---
## 第二部分:扩展模型
### 5. 按注册发现,不按分支选择
当系统需要支持多个可选功能(插件、特性开关、商业分级)时:
```
// ❌ 分支选择:调用点知道所有实现,改一个加一个都要改调用点
if feature == "A": do_a()
elif feature == "B": do_b()
elif feature == "C": do_c()
// ✅ 注册发现:调用点只知道契约,实现自己注册进来
for feature in registry.discover(FeatureContract):
if feature.enabled():
feature.apply(ctx)
```
核心转变:**"要不要做"的判断从调用点移到注册点**。调用点只剩遍历,没有分支。功能增减靠改注册表(配置文件、目录扫描、包管理),不靠改代码。
不同语言的注册机制不同,但原理一致:
- Java:ServiceLoader / Spring Bean
- Rust:trait object + inventory crate / 编译期 feature flag
- Python:entry_points / 插件目录扫描
- TypeScript:依赖注入容器 / 动态 import
- Go:init() 注册 + 全局 registry map
### 6. 契约 + 缺省行为
可选功能的契约(接口/trait/protocol)应该提供**有意义的缺省行为**,而不是强制所有实现者处理每一个方法。
```
trait OrderFeature:
fn apply(ctx) // 必须实现
fn enabled() -> bool: // 缺省:启用
return true
fn order() -> int: // 缺省:居中
return 100
```
这使得:
- 新增一个特性只需实现核心方法,其余走缺省
- 关闭特性时用缺省的空实现顶替(Null Object),调用代码不改
**Null Object 原则:缺席也是一种存在。** 当一个功能被裁剪时,不是"不调用它",而是"调用一个什么都不做的实现"。这让编排层永远保持一致的结构。
### 6.1 契约的稳定性设计
注册机制的稳定性完全取决于契约(接口/trait/protocol)的稳定性。接口的稳定性不靠"不变"来保证,靠**"变了也不破坏"**来保证。
**接口变化的三种情况及应对**:
| 变化类型 | 应对手段 | 风险 |
|----------|----------|------|
| 加新方法 | 给缺省实现,老注册者不受影响 | 低 |
| 改现有方法签名 | 接口定"厚"了,需要重新审视抽象 | 高 |
| 接口职责变了 | 版本化接口 + 适配器 | 高,但可控 |
**接口厚度的谱系**:
接口参数越具体,越容易因业务演化而变:
```
最厚(最容易变)
│ fn score(order, customer, rules) -> RiskScore // 参数多且具体
│ fn apply(ctx: &mut OrderCtx) // 只依赖一个 Context
│ fn handle(event: Event) -> Vec<Event> // 事件驱动,最松耦合
最薄(最不容易变)
```
**核心原则:接口的参数应该是"稳定的名词",不是"易变的结构"。**
**三个稳定性挡板**:
1. **新方法给缺省实现**——加方法永远不破坏现有注册者
2. **Context 设扩展区**——核心字段强类型,新增字段走扩展区(如 HashMap),等稳定后再提升为核心字段
3. **大版本变更用适配器**——老实现通过适配器在新接口上运行,新老共存
```
// Context 扩展区示例(伪代码)
struct OrderCtx:
order: Order // 核心字段,稳定
customer: Customer // 核心字段,稳定
extensions: Map<String, Any> // 扩展区,新字段先落在这里
fn get_ext<T>(key) -> Option<T>
fn set_ext<T>(key, val)
```
**选型指导**:
| 场景 | 推荐方案 |
|------|----------|
| 团队内部、变化可控 | 方法接口 + Context(含扩展区) |
| 跨团队/跨组织插件 | 版本化接口 + 适配器 |
| 高度动态、无法预知未来 | 事件/消息契约 |
**不要为了还没发生的变化提前设计兼容层**——大多数项目从"方法接口 + Context 扩展区"开始就够了。需要插件体系时再引入版本化接口或事件契约。
### 7. 编排层的 Workflow 拓扑
当业务流程超出线性 pipeline(需要分支、并行、汇合)时,拓扑需要升级:
| 复杂度 | 拓扑模型 | 适用场景 |
|--------|----------|----------|
| 顺序 | Pipeline(列表遍历) | 步骤固定、无条件分支 |
| 分支 | Router(路由表/条件边) | 有 if/else 但无并行 |
| 并行 | DAG(有向无环图) | 多条支线可同时执行 |
| 循环 | 状态机 | 审批打回、重试、长等待 |
**拓扑结构本身应该是数据,不是控制流。** 分支是图的属性,不是代码里的 if/else。这样拓扑可以被配置、可视化、动态修改。
但要注意:**当分支逻辑简单时(2-3 个 if),直接写代码比建图清晰**。不要为了架构正确性牺牲可读性——5 行 if/else 好过 200 行图引擎。
---
## 第三部分:参数设计
### 8. 参数打包的判断框架
函数参数是散着传还是打包成结构体/对象,不应由参数个数决定,而由以下维度判断:
**维度一:概念内聚性(最重要)**
这些参数是不是"一件事"的不同侧面?如果它们总是一起出现、一起变化(Data Clump),就该打包。
```
// 2 个参数但概念上是整体 → 该打包
fn ship(address: String, zip: String) // ❌
fn ship(address: Address) // ✅
// 5 个参数但各自独立 → 不用打包
fn transfer(from: Account, to: Account, amount: Money, reason: String, when: Instant) // ✅
```
**维度二:一致性约束**
参数之间有约束关系(from < to、lat/lng 配对)→ 必须封装,否则约束泄漏到所有调用者。
**维度三:抽象层级一致**
参数应在同一抽象层级。高层概念和低层细节并存时,细节该被封装进高层结构。
**维度四:变化频率**
同一个函数签名改过 3 次参数 → 这里需要一个结构体。
**数字参考(启发式)**:
- 1-3 个:散着传,除非概念上强内聚
- 4-5 个:检查有无 Data Clump
- 6+:几乎肯定有东西该打包
- 10+:函数职责可能过重
### 9. 值对象先行
从需求中提取类型时,**值对象(Value Object)比实体(Entity)更优先**。
- **实体**:有唯一标识、有生命周期、会被修改(Order, User, Account)
- **值对象**:无 ID、表达度量或描述、不可变(Money, Address, TimeRange, Email)
值对象是消除参数散乱的第一工具——它们把原本散着传的基本类型打包成有语义的整体,带上自己的校验规则和行为。
```
// 散着传:调用者必须自己确保 amount > 0 且 currency 合法
fn charge(amount: f64, currency: String)
// 值对象:Money 的构造函数保证了合法性
fn charge(amount: Money)
```
---
## 第四部分:编写规则
### 规则 1:值对象先行,实体随后
从需求中先识别值对象,再识别实体。值对象定义了系统的"度量衡",实体定义了系统的"角色"。
### 规则 2:最简 Workflow 先行
写一个最简编排把核心流程跑通,节点先用空实现。先达成结构共识,再填细节。
### 规则 3:逐个节点填充,每填一个跑一次测试
### 规则 4:同层同级,控制认知负荷
编排函数每一行保持同一抽象层级(见第 4 节)。所有长度和密度约束见基础约束(第 0 节),核心数字速查:
- **单函数**:编排 10-30 行(上限 50),计算不超过 50 行(上限 100)
- **单文件**:一个文件一个角色。编排文件 50-150 行,计算文件 100-300 行,超 400 行必拆
- **逻辑量**:单函数操作的独立概念不超过 7 个,分支不超过 5 个
- **密度平衡**:理解一段逻辑不应需要同时打开超过 3 个文件
- **终极判断**:新人打开这个文件,30 秒内能否理解意图?
### 规则 5:复制还是复用
#### 判断:给它起个名字
把"相同的部分"假装抽出来,试着给它起个名字:
```
第一步:能起具体名字吗?
├─ 需要 Generic / Common / Base / Shared 前缀 → 不是真概念 → 复制
└─ 有具体名字(CopyableTip、useAsync、Money)→ 进入第二步
第二步:不看调用点能理解它吗?
├─ 不能,要看谁在用才知道它干什么 → 意义依赖场景 → 复制
└─ 能,它自己就说清了自己是什么 → 是独立概念 → 复用
```
两步都过了才复用。任何一步没过就复制。
这条原则在不同层级上的表现:
| 层级 | 复制(各自演化) | 复用(同生共死) |
|------|-----------------|-----------------|
| 编排/workflow | 两个业务流程看起来像 → 复制,各走各的 | — |
| 固定结构组合 | — | CopyableTip = HoverTip + CopyButton,没有 if,改一个就该改全部 → 复用 |
| 领域计算 | — | "税怎么算"不因流程不同而不同 → 必须复用,单一真相源 |
#### 判断错了怎么办:识别信号,内联回去
预测不可能总对。关键不是判断准,而是**错了能快速纠正**。错误的复用会发出四个明确的代码信号:
**信号一:参数里出现了 boolean / enum 开关。** 某个调用点说"我要不一样的行为",你加了 `useIcon=true`、`feedbackMode="color"`。开关越多,说明调用点分化越严重——当初的"同一个概念"假设已经不成立。
**信号二:内部出现"只为某个调用点服务"的 if。** 打开共享模块,发现某个分支只有一个地方在走。它不再是通用模式,而是特殊需求的拼盘。
**信号三:改代码时脑子里在想"别影响别的调用点"。** 这是最直觉的信号——你已经在为错误的复用买单了。
**信号四:新人看不懂这个抽象。** 需要看调用点才能理解它是干什么的,说明它不是独立概念。
出现任何一个信号,执行**内联**:
```
1. 把共享模块的代码复制回每个调用点(出现重复代码是正确的中间状态)
2. 删掉共享模块
3. 让每个调用点独立演化
4. 观察是否有新的真实共性浮现——演化后的共性才是真共性
```
#### 默认策略:先复制,等待复用浮现
```
复制 → 发现是真共性 → 提取复用 容易,两份代码都是完整的
复用 → 发现不该复用 → 内联拆开 痛苦,N 个调用点已经依赖它
```
**错误的复用比错误的复制代价大得多。** 所以默认倾向复制——等到命名测试两步都通过、重复出现 3 次以上、而且没有出现上述四个信号时,再提取复用。
### 规则 6:新功能叠加,骨架不动
新功能以子流程、装饰器、Null Object 替换的方式叠加到现有 workflow 上。主 workflow 骨架尽量保持不变。功能上下 = 子流程的挂载或卸载。
在组件/模块层面同样适用:**新功能是组合出新组件,不是往老组件里加 prop/开关**。
#### 判断方法:细化还是组合?
遇到"要改老模块还是建新模块"时,问一个问题:
> **新功能和老模块是"同一个概念的细化",还是"两个概念的组合"?**
- **细化** → 改老模块。比如 tooltip 支持不同方向(top/bottom/left/right),方向是 tooltip 本身的属性。
- **组合** → 建编排层,把多个独立能力组装在一起。比如 tooltip + 复制、tooltip + 确认按钮。
#### 实例:HoverTip + 点击复制
需求:已有 HoverTip 组件(鼠标悬停显示提示),现在某个页面需要 tip 内容可以点击复制。
分析:
- **HoverTip**:鼠标悬停 → 显示文字 → 离开消失。核心是"被动信息展示"。
- **点击复制**:用户主动点击 → 写入剪贴板 → 反馈"已复制"。核心是"主动用户动作"。
一个是被动展示,一个是主动交互——**不是同一个概念,是两个独立的能力**。
```
❌ 在原组件里加 prop(瑞士军刀式膨胀)
<HoverTip content="xxx" :copyable="true" />
今天加 copyable,明天加 closable,后天加 hasAction,
半年后 15 个 prop、8 个 slot、300 行模板。
本质错误:把编排逻辑(哪个页面需要什么组合)塞进了计算层(组件本身)。
```
正确方向:**HoverTip 提供 slot,CopyButton 作为独立能力,组合发生在调用点**。
```
// CopyButton:纯"复制"能力,不知道自己会被用在哪里
component CopyButton(text):
copied = false
fn copy():
clipboard.write(text)
copied = true
after 1.5s: copied = false
render: button(onClick=copy, label=copied ? "已复制" : "复制")
```
最简方案——**调用点直接用 slot 组合**,不建新组件:
```
// 调用点就是编排层,决定 tip 里放什么
HoverTip:
slot content:
text("可复制内容")
CopyButton(text="可复制内容")
<span>悬停我</span>
```
HoverTip 和 CopyButton 各自不知道对方的存在,组合关系只在调用点体现。
#### 判断链:何时提取组合组件?
```
1. 老模块有 slot / 扩展点吗?
├─ 没有 → 先加 slot(这是"细化",支持自定义内容是它本身的能力)
└─ 有 → 在调用点直接用 slot 组合新能力
2. 这个组合模式重复出现了几次?
├─ 1-2 次 → 保持调用点组合,不建新模块
└─ 3+ 次 → 提取组合组件固化模式,消除重复
```
只有当"tip + copy"的组合出现 3 次以上,才值得提取 CopyableTip:
```
// CopyableTip:固化重复的组合模式
component CopyableTip(content):
render:
HoverTip:
slot content:
text(content)
CopyButton(text=content)
slot default:
<children/>
```
**先用最简单的方式(slot 组合),等重复逼你封装的时候再封装。** 这是规则 2(最简方案先行)在组件层面的体现。
通用原则:**出现 1 次是用法,出现 3 次是模式,模式才值得封装。**
---
## 第五部分:重构规则
### 规则 7:安全手法蚕食,不重写
操作顺序:**重命名 → 抽取函数 → 移到新模块 → 清理新模块内部**。
核心纪律:
- 抽取函数和移到新模块是**一个完整动作**,只做抽取不做搬移 = 做了一半
- **搬和改永远不在同一次提交**——搬的 commit 只移动代码不改逻辑,改的 commit 只优化不移动
- 每步之间运行测试,确保行为不变
安全手法按风险从低到高:
1. **重命名**:零风险,工具一键完成
2. **抽取函数**:极低风险,工具一键完成
3. **引入参数对象**:低风险
4. **移到新模块**:中低风险
5. **内联后重新抽取**:中风险,用于修正之前抽错的结构
### 规则 8:让结构浮现,再让结构裸露
从大函数中抽取小函数 → **结构浮现**(编排逻辑从细节中露出来)。
把小函数搬到独立模块 → **结构裸露**(编排模块只剩编排,打开就是流程图)。
最终目标:编排模块 20-30 行,一眼可读,每一行是一个动作名。所有实现细节在各自的独立模块里。
**浮现了但没裸露,等于没做完。**
---
## 第六部分:重构实操节奏
### 绞杀者模式
新结构从老代码外围长出来,逐步替换。整个过程老代码始终能跑、老功能始终不坏。
### 渐进阶段
| 阶段 | 动作 | 产出 |
|------|------|------|
| 0 | 画现状流程图(不动代码) | 节点清单 + 每个节点的读写依赖 |
| 1 | 抽取函数 | 主函数变成 workflow 雏形 |
| 2 | 识别隐式状态(全局变量、线程本地、闭包捕获) | 需要显式化的状态清单 |
| 3 | 引入 Context 结构体 | 所有状态传递显式化 |
| 4 | 分离加载/计算/持久化 | 纯计算函数可独立测试 |
| 5 | 搬到独立模块 | 老模块只剩编排 |
| 6 | 拆出 Loader / Workflow / Persister | 完整分层结构 |
每个阶段都可停下来,代码始终可运行。
### 关键技巧
- **特征测试做安全网**:录制真实输入输出,每次改完对比行为一致性
- **开关切流**:新老流程并存,小比例流量先走新流程,逐步放量
- **提交粒度越细越好**:每搬一个函数就提交一次
- **先别追求完美 Context**:重构中期先用最简单的结构,稳定后再优化封装
- **不值得重构的代码别动**:又烂又稳定、无人修改的代码没有重构 ROI
---
## 附录:范式速查
| 原则 | 一句话 |
|------|--------|
| 基础约束 | 函数一屏读完,文件一眼知主题,逻辑不用记就能跟上;理解一段逻辑不超过 3 个文件 |
| 三段式 | 加载、计算、持久化不混在一个函数里 |
| 显式容器 | 状态放在显式容器里,不藏在全局/闭包/魔法中;框架的响应式容器是合法 Context |
| 编排-计算分离 | 编排说"做什么",计算说"怎么做",分处不同模块 |
| 编排是职责不是架构层 | 调用点组合→提取组合模块→独立编排类,按复杂度和重复度选择重量级 |
| 计算层不碰容器 | 计算层接收值、返回值;编排层负责从容器取值和写回;只读派生不算"碰" |
| Context at the Edge | Context 只出现在编排层,计算层只接收真实依赖 |
| 同层同级 | 一个函数体内每一行同一抽象层级 |
| 注册发现 | 功能增减靠注册表,不靠 if/else |
| 缺省行为 | 契约提供有意义的缺省,缺席用空实现顶替 |
| 契约稳定性 | 新方法给缺省、新字段走扩展区、大版本用适配器 |
| 复制还是复用 | 能起具体名字且脱离调用点能理解→复用;否则复制。默认先复制 |
| 错误复用的纠正 | 出现 boolean 开关 / 专属 if / 怕影响别人 / 新人看不懂 → 内联回去 |
| 细化改原模块,组合建新模块 | 同一概念的细化→改;两个概念的组合→slot 组合;重复 3 次→提取组合模块 |
| 领域计算必须复用 | 业务规则单一真相源,复制了就是定时炸弹 |
| 值对象先行 | 先识别度量衡,再识别角色 |
| 浮现再裸露 | 抽取函数 + 搬到新模块是一个完整动作 |
| 搬和改分离 | 移动代码和修改逻辑永远不在同一次提交 |