当前位置: 首页 > news >正文

[Python] Python中自带模块级的单例模式-不需要定义单例类

Python中的单例场景

  • 一般一些需要在模块中全局维护的变量(变量修改范围在模块内);简单方式是构建一个全局变量,然后不符合编码规范:1.线程安全与并发问题;2.测试隔离困难;3.缺乏多实例/多租户支持。
  • 一般做法用类(面向对象)的方式来实现,因为符合 Google Style 并遵循面向对象和高内聚低耦合的代码原则

场景:hermes-agent 工程中 skill_commands.py 就是使用全局变量来实现skill的读取, 下面是改进建议和指导原则

在分析agent/skill_commands.py文件中的全局变量设计时,我们需要将其分为**不可变常量(Immutable Constants)可变全局状态(Mutable Global State)**两类来讨论。

在这段代码中,以下两个是不可变常量(编译后的正则表达式):

# Patterns for sanitizing skill names into clean hyphen-separated slugs. _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")

而以下两个是可变全局状态(充当缓存):

_skill_commands: Dict[str, Dict[str, Any]] = {} _skill_commands_platform: Optional[str] = None

下面我将从代码原则、Google Python 风格指南、潜在问题以及替代方案等维度,详细解答您的问题。


一、 这种处理方式好吗?符合 Google Style 吗?

1. 对于不可变常量(如_SKILL_INVALID_CHARS):
  • 是好的处理方式,且完全符合 Google Python 风格指南。
  • 原因:预先编译正则表达式并将其声明为模块级常量,可以避免在每次调用函数时重复编译,从而提升性能。
2. 对于可变全局状态(如_skill_commands字典缓存):
  • 不是最好的处理方式,不完全符合 Google Python 风格指南。
  • Google Style Guide 的明确指出

    “Avoid global variables.”(避免使用全局变量)
    “While they are occasionally useful, global variables should be avoided. They can cause unexpected behavior during imports… Instead, use class variables or pass them as parameters.”(尽管全局变量偶尔有用,但应尽量避免。它们可能会在导入期间导致非预期行为……相反,应该使用类变量或将它们作为参数传递。)

  • 原因:Google 风格指南极力反对在模块级别使用**可变(Mutable)**的全局状态。这里的_skill_commands在运行时会被scan_skill_commands动态修改(使用了global关键字),这属于典型的可变全局状态。

二、 这种设计违反了哪些代码原则?(潜在的 Bug 和隐患)

在现代软件工程中,使用模块级可变全局变量主要存在以下几个严重隐患:

1. 线程安全与并发问题(Thread Safety)

当多个线程或异步任务同时调用scan_skill_commandsreload_skills时,它们会并发地修改同一个全局字典_skill_commands。由于 Python 字典的写操作不是天然线程安全的,这可能会导致:

  • 缓存数据损坏(Data Corruption)。
  • 竞争条件(Race Conditions),导致某个线程读取到不完整或正在被重写的缓存。
2. 测试隔离困难(Test Isolation)

全局变量会导致测试用例之间产生状态泄露(State Leakage)。

  • 如果测试 A 调用了scan_skill_commands写入了某些 Mock 数据,而测试 B 没有重置该全局变量,测试 B 就会受到测试 A 的干扰,产生令人头疼的“幽灵测试失败”(Flaky Tests)。
  • 注:正因为如此,该项目在tests/_isolate_plugin.py中不得不实现了一套“每个测试启动一个独立子进程”的强制隔离机制,这极大地增加了测试运行的开销,就是为了对付这些全局状态带来的污染。
3. 缺乏多实例/多租户支持(Multi-instance / Multi-tenancy)

如果同一个进程内需要运行两个具有不同配置、不同 Profile(例如用户 A 的 profile 和用户 B 的 profile)的AIAgent实例,由于它们共享同一个 Python 运行环境和模块全局变量,它们会共用同一个_skill_commands缓存,从而导致越权或配置冲突。


三、 为什么不使用“类”或其他方式?(历史原因与考量)

既然有上述缺点,为什么原作者还要用全局变量,而不写成类呢?通常有以下几个原因:

1. 历史遗留与 YAGNI(You Aren’t Gonna Need It)
  • 起点简单:Hermes CLI 最早可能只是一个简单的单进程、单线程命令行工具。在这种场景下,模块级的全局变量就是最快、最省事的“单例(Singleton)”实现方式。
  • 避免过度设计:在早期,引入一个SkillRegistry类并实例化它,可能显得代码过于臃肿。
2. 伪单例的便利性
  • Python 的模块(Module)导入机制本身就是一个天然的单例模式(同一个模块在进程中只会被执行和加载一次)。通过模块级变量,其他地方只需要简单的from agent.skill_commands import get_skill_commands就能直接获取到全局共享的缓存,不需要显式地传递 registry 对象实例。

四、 更好的替代方案是什么?

为了符合 Google Style 并遵循面向对象和高内聚低耦合的代码原则,可以采用以下几种更好的重构方案:

方案 A:使用类(面向对象与封装)—— 最推荐

将 Skill 缓存和管理逻辑封装进一个SkillCommandRegistry类中,将状态保存在实例属性中:

classSkillCommandRegistry:def__init__(self):self._skill_commands:dict[str,dict[str,Any]]={}self._skill_commands_platform:Optional[str]=Nonedef_resolve_platform(self)->Optional[str]:# 原来的 _resolve_skill_commands_platform 逻辑...defscan(self)->dict[str,dict[str,Any]]:# 扫描逻辑,将结果写入 self._skill_commands...defget_commands(self)->dict[str,dict[str,Any]]:current_platform=self._resolve_platform()ifnotself._skill_commandsorself._skill_commands_platform!=current_platform:self.scan()returnself._skill_commands# 进程内默认单例skill_registry=SkillCommandRegistry()

优点

  1. 测试友好:在单元测试中,每个测试可以简单地通过SkillCommandRegistry()创建全新的、完全隔离的实例,无需担心状态泄露。
  2. 多 Profile 支持:如果有多个 Profile,可以创建多个 registry 实例,互不干扰。
  3. 消除global关键字:符合干净代码(Clean Code)原则。
方案 B:使用标准库缓存装饰器functools.lru_cache

如果只是为了做缓存,Python 提供了优雅的内置装饰器。但因为本文件中的缓存依赖于_resolve_skill_commands_platform()的动态平台切换,单纯的lru_cache可能不够灵活,但也可以通过自定义缓存类(Cache Manager)来管理。


总结

  1. 不可变正则常量:是好的、符合 Google 规范的做法。
  2. 可变全局缓存_skill_commands不符合 Google 规范,在并发、测试、多 Profile 场景下存在隐患。
  3. 建议:在未来系统规模扩大、引入更多并发或需要支持更严格的多 Profile 隔离时,应该将这些全局状态重构为类实例(Class Instance Properties / Objects )上下文环境对象(Context Objects)

SkillCommandRegistry 为什么没有设计成单例

这是一个非常深刻且关键的设计问题!

简单来说:在运行时,它确实需要保持“单例的行为”;但在代码实现上,我们千万不要把它设计成“强行无法被创建第二个实例的‘严格单例’”。

在 Python 的世界里,有比传统设计模式(如 Java 中的严格单例)更好、更灵活的处理方式。以下为您深度剖析:


一、 Python 中最推荐的“天然单例”:模块级单例

Python 的模块导入机制本身就是天然的单例模式。当一个模块首次被导入时,Python 会执行它并将它缓存在sys.modules中,后续所有的import拿到的都是同一个模块对象。

所以,最符合 Pythonic 风格的单例实现方式是:在文件末尾实例化它,并直接导出该实例。

# agent/skill_commands.pyclassSkillCommandRegistry:def__init__(self):self._skill_commands={}...# 💡 在模块级别直接实例化一个全局共享的实例skill_registry=SkillCommandRegistry()

当其他模块需要使用时,不直接调用SkillCommandRegistry()重新创建,而是直接导入这个已经创建好的实例:

# 从其他文件导入这个“模块级单例”fromagent.skill_commandsimportskill_registry# 直接使用它,这保证了在整个运行期,大家都共用这同一个实例和它的缓存commands=skill_registry.get_commands()

二、 为什么不要做成“严格单例”(即强行限制只能实例化一次)?

在 Java 或 C++ 中,我们经常通过私有化构造函数或使用元类(Metaclass)来强行限制用户不能通过Registry()创建第二个实例。但在 Python 中,这种严格单例(Strict Singleton)通常是一个设计陷阱,原因有二:

1. 它是单元测试的“灾难”

如果SkillCommandRegistry是严格单例,那么在运行测试套件时,几十个测试用例会共享这唯一一个实例。

  • 测试 A 往里面写入了 Mock 的 Skill 缓存。
  • 测试 B 运行的时候,读到了测试 A 的数据,导致测试 B 莫名其妙地失败。

如果它是非严格单例(即普通的类),我们在单元测试中就可以非常轻松地为每一个测试用例创建一个全新的、干净的实例,彻底避免测试状态污染:

# tests/test_skill_commands.pydeftest_scan_skills():# 每次测试都创建一个全新的、隔离的 registry,测试结束自动销毁registry=SkillCommandRegistry()registry.scan()assertlen(registry.get_commands())>0
2. 它会锁死多 Profile(多实例)的扩展性

正如 Hermes 项目中的规则所说,Hermes 强烈支持Profiles(多实例支持)

  • 用户可以运行hermes -p dev(开发 Profile,指向~/.hermes/profiles/dev目录,有它专属的 skills 目录)。
  • 也可以同时在另一个终端运行hermes -p prod(生产 Profile,指向~/.hermes/profiles/prod)。

如果在未来,Hermes Gateway 升级为多租户架构(在一个 Python 进程中同时服务多个不同的 Profile / 用户),如果写成严格单例,整个进程就只能有一个 Registry,导致不同 Profile 之间的 Skills 发生越权混淆。
如果是普通的类,我们可以为每个 Profile 创建一个独立的实例,完美隔离。


三、 完美的架构设计:将 Registry 绑定在 Context(上下文)中

最优雅的架构设计,既能保证运行时是单例,又能保证测试和多 Profile 的隔离,那就是:将 Registry 的生命周期与AIAgentHermesCLI实例进行绑定。

也就是说,不要使用任何全局的单例,而是让AIAgent持有它:

# run_agent.py (伪代码)classAIAgent:def__init__(self,profile_name="default",...):# 每一个 Agent 实例在初始化时,创建自己专属的 Registry# 这对于这个 Agent 来说就是自然单例,但不同 Agent 之间是隔离的self.skills_registry=SkillCommandRegistry(profile_name=profile_name)

这样设计:

  1. 天然单例:在一个AIAgent的生命周期内,它只拥有一个skills_registry实例,行为和单例完全一致。
  2. 多实例隔离:两个不同的AIAgent(例如不同的用户或 Profile)拥有各自的skills_registry,互不干扰,数据非常安全。
  3. 极易测试:测试时只需要实例化一个AIAgent,测试结束直接销毁,没有任何全局残留状态。

总结

  • 需要单例的行为(保证缓存只被加载一次,全局共享)。
  • 不需要严格的单例限制(强行拦截__new__)。
  • 最佳实践
    • 初期/中期:在模块中声明class SkillCommandRegistry,并在文件底部实例化skill_registry = SkillCommandRegistry()。外部通过from ... import skill_registry共享该模块级单例。
    • 后期(更优):将SkillCommandRegistry的实例作为AIAgent或控制器的成员变量(属性),由上下文去管理它的生命周期。
http://www.jsqmd.com/news/881537/

相关文章:

  • 新手学java多态的感受
  • HTTPS静态资源403/404根因排查:从Nginx配置到SELinux权限
  • 别再为乱码头疼了!Linux离线安装LibreOffice 7.5完整指南:从RPM包到完美中文显示
  • 告别卡顿!用Sunshine在Linux上搭建远程开发环境(保姆级教程,含显卡欺骗器选购)
  • 保姆级教程:用Rufus制作Proxmox VE 8.1启动盘,一次点亮你的旧服务器
  • 2026年比较好的洗衣机碳刷/南通风扇碳刷/跑步机碳刷/汽车起动机碳刷厂家哪家好 - 行业平台推荐
  • 数字图像处理-7-图像的梯度锐化算法
  • 诗心撷珍 | 李白诗行里,那些被忽略的星辰与旷野
  • 量子核方法在工业音频异常检测中的实践与性能突破
  • ZS315Q Type-C转DP1.4带PD100w方案,边投屏边充电,告别接口焦虑
  • SQL like 与 正则 区别
  • 2026年比较好的丽水本地获客渠道实力公司推荐 - 品牌宣传支持者
  • 南宁口碑好的旧改企业哪家靠谱
  • 安全稀疏矩阵乘法:基于二叉树递归传播的MPC算法优化详解
  • 二、大模型节点配置以及结束节点配置
  • 异常断电导致存储崩溃:Linux IO栈级数据恢复实战
  • 阿拉伯语多模态机器学习:从数据构建到模型融合的工程实践
  • AscendSiPBoost信号处理加速库架构与实战
  • 什么是ERC-8183
  • 安全多方计算在隐私保护AI推理中的应用:FHE与混淆电路协议对比
  • 【论文阅读】VLAW: Iterative Co-Improvement of Vision-Language-Action Policy and World Model
  • List<T>泛型列表
  • 如何让政策数据在三个端保持同步?政策快报的实践方案
  • c++ csv?_?C++处理csv文件格式的fstream与字符串分割方法详解.txt
  • 2026年免费照片去水印软件App推荐,一看就会的保姆级详细教程
  • Infineon XC16x中断处理机制解析与优化实践
  • 神经网络原理 第九章:自组织映射
  • VR+机器学习:跨语言阅读障碍识别的新范式
  • leetcode 61. 旋转链表 中等
  • 测试前端代码!