依赖管理设计参考:参数传导 or 全局引用?

ai问答 / 依赖管理设计参考

返回目录

本项目的工程实践总结。以 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) 的人并不需要知道它读配置——签名污染是净损失。

测试仍是安全的,因为全局值是一个可整体替换的只读字典:

python
# 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:为什么坚持注入?

对照速查表逐条验证:

  1. 有生命周期:显式 open/close,且关闭有先后(db 收尾先于 logger)。
  2. 运行期状态被读写:连接、批量缓冲、失败重试计数。
  3. 高替换需求:测试里满是 FakeDb、FakeGapStore,还有 dry_run 变体——注入让你在构造点换替身,全局化则需 monkeypatch 全局还要担心测试间污染。
  4. 沿数据流走: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 当年让你觉得麻烦,真正的解法是把它下沉:

python
#重构前(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. 反模式与红线(踩到就说明设计错了)

  1. 上帝对象(God Object):把 db、config、logger、外部客户端全收进一个全局 App/Context,谁都 .ctx.db。→ 换来了"无处不传参",代价是任何模块都隐式依赖一切,测试退化为"先造一个完整上帝"。
  2. 全局可变状态:全局字典运行期到处写(区别于 config 的"装配后只读")。→ 并发下谁弄脏了状态极难排查。
  3. 把开关当运行期全局变量翻转:用 set_dry_run(True) 之类的全局函数控制流程(区别于 config 启动期只读、整体替换)。dry_run 的干净做法是由组合根创建不同的对象变体(GapStore 内部按 dry_run 决定 _active),而不是运行期翻转一个全局。
  4. 为了测试方便而全局化业务核心对象:db 全局后测试确实能少传几个参数,但失去的是"并行跑两个隔离实例"和"每个用例换替身"的能力,得不偿失。
  5. 该必填的依赖留 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、缓冲、写文件流