本项目的工程实践总结。以 file_monitor 为案例,回答一个问题: 一个被多个模块共享的数据(config / logger / db / gap_store…),到底该参数化传导,还是全局化引用? 文末附术语表与自测清单,供复习时速查。
0. 结论速查(先背这一段)
判断顺序: ① 进程内会不会同时存在多个实例(含测试替身)?会 → 注入。 ② 谁能
new它、谁负责close它?是进程本身 → 可全局;是某条装配流程(组合根) → 注入给它下游。 ③ 它的状态变化是业务语义(业务要读结果、会中途替换/重建)还是自包含实现细节(业务只单向调用、从不读取)?前者 → 注入;后者 → 可全局。 ④ 使用形态是横切(任何无关函数都可能用到)还是沿数据流(A 产出 → B 消费)?横切 → 全局/隐式;沿数据流 → 参数化。 ⑤ 以上都居中,但嫌函数签名太脏?先试**"下沉到恰当的持有者"**,再考虑 contextvar,最后才考虑全局化。
一句话记忆:横切且"自包含"(进程期、使用方只单向调用、从不读写其内部状态)的依赖放心全局化;带业务状态、生命周期与替换需求的协作对象坚持注入。(注意:logger 内部有缓冲、会写文件,仍属"自包含"——它没有业务要读的状态,详见 §3.2。)
1. 判断的两个核心问题
1.1 生命周期所有权归谁?(最关键)
每个有状态的资源都有"被谁创建、被谁销毁"的问题:
| 资源 | 创建时机 | 销毁时机 | 谁配当它的 owner |
|---|---|---|---|
config |
进程启动时解析一次 | 进程退出,无需显式关闭 | 进程本身 |
logger |
进程启动时配置一次 | 进程退出(内部缓冲/文件流由 logging 框架收尾,业务从不中途关闭它) | 进程本身(业务只单向 emit,从不读其状态) |
db (DatabaseManager) |
由 app.py 在装配时创建 |
由 app.py 显式 close()(先于 logger 收尾) |
组合根 app |
gap_store (GapStore) |
由 app.py 在装配时创建 |
由 app.py 显式 close() |
组合根 app |
owner 是"进程本身" → 全局化没有风险(没人中途替换它、没人需要管它的销毁顺序)。 owner 是一条装配流程(组合根)→ 应注入,把创建/关闭的权力收归装配线统一管理,而不是散落成隐式全局状态。
1.2 它是"静态事实 / 自包含服务"还是"数据流中的参与者"?
先纠正一个常见误解:判据不是"对象内部有没有状态、会不会产生副作用"——logger 有内部缓冲、会写文件,db 也有连接状态,两者内部都在变。真正的分水岭是:这个状态变化有没有"业务读者"——业务逻辑要不要读取它来决策、要不要中途替换/重建它。
据此把共享依赖分成三类:
- 静态事实:存在即成立、不参与业务流转,且自身没有运行期状态。
config只是"环境",读取它不等于"消费某条数据"。→ 全局化毫无争议。 - 自包含横切服务:有内部状态、会写输出(logger 的 handler/缓冲/文件流),但状态是框架自管的实现细节——使用方只做单向调用(emit),从不读取其内部状态来决策,也不中途重建/关闭它。→ 全局化安全。
- 数据流参与者:对象状态本身承载业务语义——
db意味着"这批改动最终写进哪个库、成功没有",gap_store意味着"哪些路径还在待恢复"。业务方既写也读,失败模式、生命周期都要装配线管理。→ 参数化让读者看得见数据落到哪,符合"显式优于隐式"。
本项目的 db、gap_store 传给 BatchSyncer 就是第三类:BatchSyncer(db, gap_store=...) 读起来语义完整、自解释。logger 虽会写文件,但属于第二类(自包含),详见 §3.2。
2. 五种机制对比(含不常见的专有名词)
| 机制 | 本质 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 参数注入(本项目 db/gap_store) | 依赖从外部传入,显式可见 | 依赖方向清楚;可换替身;可多实例 | 层层透传时签名变脏 | 带生命周期/状态/替换需求的协作对象 |
| 模块级全局单例(本项目 config/logger) | 模块内 _CONFIG / get_config() 式的唯一实例 |
不污染签名;访问简单 | 隐式耦合;替换/测试麻烦;成为"看不见的依赖" | 进程期、横切、状态自包含(业务不读写其内部状态)的静态事实/服务 |
| contextvar / threading.local(隐式上下文) | 依赖藏在调用链"上下文"里,函数无需写参数 | 签名干净;可覆盖(利于测试);按上下文隔离并发 | 心智负担略高;隐式,需读者知晓其存在 | 请求/会话/任务作用域的横切数据(如 dry_run 标志、trace_id) |
| Service Locator(服务定位器) | 全局注册表,用时按名取 | 解耦"谁提供"与"谁使用" | 把依赖关系隐藏成魔法;测试要重置注册表 | 插件系统、按名发现服务 |
| DI 容器(组合根 + 容器组装) | 由框架/容器在组合根处统一装配 | 生命周期、作用域、多实例都由容器管理 | 小项目过度设计,出问题难排查 | 大型项目、插件化、延迟初始化 |
注:DI(依赖注入)与 Service Locator 常被混谈,一个把依赖主动传进来,一个让使用者自己去取,方向相反,本项目用前者(组合根手动装配),是 DI 最轻量的形态。
3. 本项目案例逐项复盘
3.1 config:为什么改成全局是对的?
- 装配后只读——没有任何代码在运行期写它。
- 横切:db、syncer、monitor、CLI 无关地都要读。
- 若坚持传参,每个函数签名都要写
cfg,而读f(x)的人并不需要知道它读配置——签名污染是净损失。
测试仍是安全的,因为全局值是一个可整体替换的只读字典:
# tests 中
config_mod._CONFIG = cfg # 整体换一份测试配置
关键点:全局单例的"可替换性"来自于只读+整体替换。如果测试需要"同一时刻两种 config 并存",全局就不可行了。
3.2 logger:为什么全局天经地义?
日志是典型的横切关注点(cross-cutting concern)——解析失败要打、同步失败要打、启动要打,与业务数据流正交。把 logger 写进每个函数签名会让整条调用链被迫携带一个与语义无关的参数。
Python 惯例即是模块级 logger = logging.getLogger(__name__),它不是"该不该全局"的问题,而是日志语义天然是进程级全局事件流。
不过要澄清:说 logger"适合全局",不等于它是只读静态对象——它内部有 handler、级别、缓冲,还会往文件写(副作用)。它和 db 的真正差别在于状态有没有业务读者:
| logger | db / gap_store | |
|---|---|---|
| 业务调用方向 | 只 emit(单向,业务从不"读"logger) | 写后还要读结果(写库成败、待恢复清单) |
| 内部状态 | 框架自管的实现细节,业务从不读取 | 状态即业务语义(连接、缓冲、缺口集合) |
| 中途重建/替换 | 业务不需要 | 需要(重连、dry_run 变体、测试 Fake) |
| 生命周期收尾 | 进程退出由 logging 框架统一收尾 | 装配线须显式 close,且有先后顺序 |
因为业务对 logger 只有"单向写"这一个入口,测试也不需要换掉 logger 单例——用 logging 框架自带的 handler 或 pytest 的 caplog 捕获输出即可断言。这正是它敢全局的底气:全局化的两大风险(隐式依赖、难以替换)在它身上都不存在。
3.3 db / gap_store:为什么坚持注入?
对照速查表逐条验证:
- 有生命周期:显式
open/close,且关闭有先后(db 收尾先于 logger)。 - 运行期状态被读写:连接、批量缓冲、失败重试计数。
- 高替换需求:测试里满是
FakeDb、FakeGapStore,还有dry_run变体——注入让你在构造点换替身,全局化则需 monkeypatch 全局还要担心测试间污染。 - 沿数据流走:
BatchSyncer(db, gap_store=...)自解释,读者直接看到"写进哪个库、登记进哪个台账"。
3.4 gap_store 的演进:从"可传可不传"到"必填注入"
本项目 gap_store 曾经历过一次重构:从 BatchSyncer(db, gap_store=None)(调用方可省)改为必填非空 BatchSyncer(db, gap_store)。这个案例说明两层道理:
- 它不是全局化的备选——它跟
db是同一性质的资源,所以照db的样板:组合根创建 + 注入。 - "可选"和"必填"是另一层决策:
gap_store因为要保证数据准确性而必须存在,所以从签名上就取消 None 通道,让"缺了它"在编译/调用层面直接报错,而不是靠运行期判空悄悄降级。
配套做法:从配置里删除
gap.enabled开关。当一个能力"必须启用"时,就不该再留一个"用户可能关掉"的开关——开关是给"可选能力"留的口子,必选能力保留开关只会制造"配置合法但功能缺席"的静默陷阱。
3.5 签名"太乱"的正确解法:下沉持有者,而不是提升为全局
你当初把 logger/config 全局化的理由是"每个地方都写函数签名太麻烦太杂乱"——这对横切依赖是成立的;但 gap_store 当年让你觉得麻烦,真正的解法是把它下沉:
#重构前(monitor 层处处带 gap_store,签名乱)
run_continuous(syncer, gap_store)
window_sync(db, syncer, gap_store)
recover_sync(syncer, gap_store)
monitor.xxx(... , gap_store) # 还要继续透传
# 重构后(gap_store 沉入 BatchSyncer,它本来就是台账的使用者)
app: gap_store = GapStore()
syncer = BatchSyncer(db, gap_store=gap_store) # 只在这一处出现
# monitor 各函数只拿 syncer,不再认识 gap_store
规律:一个依赖被多少层透传,取决于它"真正被谁消费";把依赖持有在真正的消费者(或其唯一入口)里,比把它全局化更干净,且保留了组合与替换能力。
4. 反模式与红线(踩到就说明设计错了)
- 上帝对象(God Object):把 db、config、logger、外部客户端全收进一个全局
App/Context,谁都.ctx.db。→ 换来了"无处不传参",代价是任何模块都隐式依赖一切,测试退化为"先造一个完整上帝"。 - 全局可变状态:全局字典运行期到处写(区别于 config 的"装配后只读")。→ 并发下谁弄脏了状态极难排查。
- 把开关当运行期全局变量翻转:用
set_dry_run(True)之类的全局函数控制流程(区别于 config 启动期只读、整体替换)。dry_run的干净做法是由组合根创建不同的对象变体(GapStore内部按dry_run决定_active),而不是运行期翻转一个全局。 - 为了测试方便而全局化业务核心对象:
db全局后测试确实能少传几个参数,但失去的是"并行跑两个隔离实例"和"每个用例换替身"的能力,得不偿失。 - 该必填的依赖留 None 通道 + 内部判空:静默降级,故障被吞掉。必填就必填,让类型系统/签名把约束说清楚。
5. 决策自测清单(复习用)
拿到一个共享数据,按顺序打勾:
- 进程内会同时存在多个吗(生产双目录跑、测试替身)?→ 是 ⇒ 注入(停止判断)
- 有显式
open/close或 flush 时序吗?→ 是 ⇒ 注入给组合根装配,注入方向跟随它的数据流 - 内部状态会被业务读取/替换,还是仅框架自管?→ 有业务读者 ⇒ 注入;自包含 ⇒ 可全局
- 使用是横切(与业务流向无关,任何函数都可能用)?→ 是 ⇒ 全局 / 隐式上下文
- 沿功能数据流单向传递(A 产出 → B 消费)?→ 是 ⇒ 参数化,且尽量下沉到真正持有它的对象
- 卡在中间、纯粹嫌签名脏?→ 先下沉持有者,再考虑 contextvar,别直接上全局
- 它是"必须启用"的能力吗?→ 是 ⇒ 从配置删开关、构造必填,不留 None 通道
6. 术语表
| 术语 | 一句话解释 | 本项目对应 |
|---|---|---|
| 依赖注入(DI) | 对象所需的依赖由外部主动传入,而不是自己 new 或自己去全局取 | BatchSyncer(db, gap_store=gap_store) |
| 组合根(Composition Root) | 程序入口处唯一负责"创建并装配所有依赖"的地方,越靠近入口越好 | app.py 的启动装配段 |
| 全局单例(Global Singleton) | 全进程仅一份、模块级持有的实例,任何模块可直接引用 | config._CONFIG、模块级 logger |
| 横切关注点(Cross-cutting Concern) | 与业务主线无关、却渗透所有模块的通用能力 | 日志、配置、计时 |
| 生命周期所有权(Ownership) | "谁负责创建/销毁/关闭某资源"的归属关系;决定该注入还是该全局 | db 归 app,logger 归进程 |
| 测试替身(Test Double) | 测试中顶替真实依赖的假对象一族,含 Fake / Stub / Mock | FakeDb、FakeGapStore |
| monkeypatch | 运行期临时替换某全局/对象的属性(如模块变量),用后还原 | 早期测全局 config 的方式 |
| contextvar | Python 提供的"上下文变量",随 async/线程调用链传播,可在子上下文覆盖 | (本项目未用,dry_run 靠对象变体) |
| Service Locator | 一个全局注册表,使用者按名字/类型主动"取"依赖(与 DI 方向相反) | 本项目未用 |
| DI 容器 | 帮你自动完成组合根工作的框架,管理生命周期与作用域 | 本项目未用(组合根手写足够) |
| 透传参数(Pass-through) | 函数本身不用某依赖,仅为传给它调用的下层而写进自己签名 | 重构前的 monitor 层传 gap_store |
| 签名污染 | 参数表被大量与函数本职无关的依赖填满,可读性下降 | 若坚持给每个函数传 config 的后果 |
| 上帝对象(God Object) | 一个全局对象持有过多职责/依赖,成为所有模块的公共入口 | 把所有共享资源打包进一个全局 Context |
| 只读事实(Read-only Fact) | 装配后不再变化、读取它无副作用的配置性数据 | config 装配后的字典 |
| 自包含状态(Self-contained State) | 对象有内部状态/副作用,但业务不读取、不替换,由对象或框架自管 | logger 的 handler、缓冲、写文件流 |