首页/开发/代码质量与评审速查/单例滥用与测试困境
设计原则与模式62 条

单例滥用与测试困境

单例把依赖藏进全局访问点:调用方看不出依赖、测试无法替换、并行测试互相污染;替代方案是显式注入共享实例(容器作用域单例),让依赖出现在签名里。

单例为什么容易变成负债

单例模式保证某个类型在进程内只有一个实例,并提供全局访问点。问题不在唯一性本身,而在全局访问点:依赖被藏进方法体内部,调用方签名上看不出它需要什么,测试也无法替换。唯一实例往往是合理的需求(配置、连接池、注册表),不合理的是用静态方法把它取出来。

问题与替代

隐藏依赖依赖不出现在构造签名或参数里,阅读者需要翻实现
无法替换测试桩与模拟对象插不进去,只能改全局状态
测试污染实例被跨用例复用,执行顺序不同结果不同
推荐替代用依赖注入容器注册单实例,再注入到使用方

典型使用场景

  • 应用配置:确实只需一份,但应通过构造参数传入而不是在方法里取值
  • 数据库连接池:实例唯一合理,交由容器或框架管理生命周期
  • 全局注册表与插件表:启动时装配完成,之后只读访问
  • 缓存客户端:需要共享实例时用容器单例,避免各处自行 new 造成连接数翻倍
  • 日志器:语言内置日志模块已提供全局实例,不必再做一层包装

从取单例改为注入

从取单例改为注入
# 改造前:依赖藏在方法体里,签名看不出任何信息
class OrderService:
    def create(self, order):
        cfg = Config.get_instance()   # 全局访问点

# 改造后:依赖出现在构造签名,容器保证只有一份实例
class OrderService:
    def __init__(self, cfg: Config):   # 谁提供由容器决定
        self.cfg = cfg

症状与对应改法

观察到的症状根因改法
单测必须重置全局对象实例被跨用例复用改为注入,每次用例传独立实例
并行测试互相干扰可变全局状态把状态移入实例或改为不可变
改一处要全局回归依赖图不可见让依赖出现在签名,用静态分析审视调用
两个模块各自初始化没有统一生命周期管理交给容器统一注册与释放
启动顺序敏感、偶发空指针静态初始化时机不可控改为显式装配,在入口处完成初始化
过渡手法:把单例藏到接口后面,调用方只依赖接口,实现由容器装配。这样既保留进程内唯一的语义,又让测试可以注入替身,不必一次改完所有调用点。
单例持有可变状态时风险最高:任何一处写入都会影响所有使用者,跨测试用例污染只表现为偶发失败。若必须共享,优先让实例本身不可变,或明确写出并发访问约束。

自查清单

  • 构造签名是否完整表达了这个类需要的协作对象
  • 测试能否在不触碰全局状态的前提下替换依赖
  • 实例的生命周期由谁负责创建与释放,是否有明确归属
  • 如果明天要开两份不同配置的实例,现有代码会不会崩

易错点

  • 为了「方便到处能取」而引入单例:依赖图不可见,任何改动都可能波及全库,问题定位只能靠搜索符号名
  • 单例持有可变状态:跨测试用例与跨线程共享,失败表现为偶发且不可复现,排查成本极高
  • 用单例做懒加载:首次访问时初始化,启动顺序稍有变化就出现空引用或半初始化对象
  • 把单例当全局变量用并允许运行时替换:并发环境下读到的是哪一份实例取决于时序

数据来源与参考

  • Google Testing Blog(Singleton 与可测试性讨论);Michael Feathers 全局状态相关论述
​‌‌​​‌​​​‌‌​‌‌‌‌​‌‌​​​‌‌​‌‌‌​​‌‌​​‌​‌‌‌​​‌‌‌​​​‌​‌‌​‌​​‌​‌‌‌​​​‌​‌‌​‌​​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌‌‌‌​​‌​‌‌‌​​‌‌​​​‌‌​‌‌​‌‌‌‌​‌‌​‌‌​‌