Python实现单例装饰器:支持持久序列化

Python实现单例装饰器:支持持久序列化

标签:#Python #设计模式 #单例模式 #序列化 #编程实战 日期:2026-06-15 摘要:本文介绍一个支持持久化的单例装饰器 single,核心特点在于:兼容三种装饰语法、支持 JSON 文件恢复实例、巧用 object.__setattr__ 绕过 Pydantic 字段限制、用 jsonpickle 的 class_mapping 避免无限解码循环。


前言

之前写过一篇 # Python 单例模式的几种实现方式:朴素才是王道,提到过装饰器可以实现单例模式。

这次更进一步,带来一个支持持久化的单例装饰器

  • 免参数装饰 @single:实例自动获得 save() 方法
  • 带文件名装饰 @single('file.json'):启动时从文件恢复,调用 save() 默认保存到该文件
  • Pydantic 兼容:即使 BaseModel 禁止动态添加字段也能工作

一、核心代码

python 复制代码
import os
import types
from functools import wraps
from typing import TypeVar
import jsonpickle
from pydantic import BaseModel


T = TypeVar('T')

def single(cls_or_filename: T|str = None, *,filename:str=None) -> T:
    """单例装饰器:
        - @single / @single() :仅单例,实例获得 save(filename) 方法
        - @single('path/file.json') / @single(filename='path/file.json') :
        指定持久化文件,首次创建时若文件存在则从中恢复;实例获得 save() 方法
        """
    if isinstance(cls_or_filename, type):
        # @single 直接装饰类
        fname = cls_or_filename.__qualname__ + '.json'  # 默认文件名是类名 + .json
        return _decorate(cls_or_filename, fname)  # type: ignore[return-value]
    else:
        fname = cls_or_filename if cls_or_filename is not None else filename
        # 注意:不能直接'return _decorate(cls, fname)',因为此时还没有传入cls
        def decorator(cls: T):
            return _decorate(cls, fname)
        return decorator

def _decorate(cls: T, fname: str) -> T:
    _instance: T = None  # 单例实例
    # 用于 jsonpickle 解码时的类映射,避免递归调用 wrapper
    class_fullname = cls.__module__ + '.' + cls.__qualname__
    class_mapping = {class_fullname: cls}
    @wraps(cls)
    def wrapper(*args, **kwargs) -> T:
        nonlocal _instance
        if _instance is None:
            if fname is not None and os.path.exists(fname):
                with open(fname, 'r', encoding='utf-8') as f:
                    _instance = jsonpickle.decode(f.read(), classes=class_mapping)
            else:
                _instance = cls(*args, **kwargs)
            # 动态添加 save 方法
            if not hasattr(_instance, 'save'):
                def save(self, filename: str = None):
                    save_path = filename or fname
                    if save_path is None:
                        raise ValueError(
                            "No filename specified. Either provide a filename to save() "
                            "or use the decorator with a filename."
                        )
                    with open(save_path, 'w', encoding='utf-8') as f:
                        f.write(jsonpickle.encode(self))
                # 为了防止pydantic禁止动态添加字段,这里使用object.__setattr__强制添加
                object.__setattr__(_instance, 'save', types.MethodType(save, _instance))
        return _instance  # type: ignore[return-value]
    return wrapper

二、三个实现细节

1. 支持三种装饰语法

python 复制代码
@single                    # 无参数,默认文件名是 类名.json
@single()                 # 同上
@single('b.json')         # 指定文件名
@single(filename='b.json') # 关键字参数指定文件名

实现原理是根据第一个参数的类型判断

python 复制代码
if isinstance(cls_or_filename, type):
    # @single 直接装饰类,此时第一个参数就是类本身
    return _decorate(cls_or_filename, fname)
else:
    # @single('file.json') 两段式调用,先返回一个 decorator,再注入 cls
    def decorator(cls: T):
        return _decorate(cls, fname)
    return decorator

关键 :第二段 @single('b.json') 返回的是 decorator(cls) 而不是直接 decorate(cls, fname),因为此时 cls 还没有传入。


2. jsonpickle 需要 class_mapping

python 复制代码
class_fullname = cls.__module__ + '.' + cls.__qualname__
class_mapping = {class_fullname: cls}
_instance = jsonpickle.decode(f.read(), classes=class_mapping)

如果不传 classes=class_mapping,jsonpickle 解码时会遇到问题:

  • 编码时保存的是类的全限定名(如 __main__.B
  • 解码时 jsonpickle 不知道这个全限定名对应哪个类
  • 没有正确的类映射,就无法反序列化

class_mapping 把类的全限定名映射回原始类,确保解码正确。


3. object.setattr 绕过 Pydantic 限制

python 复制代码
object.__setattr__(_instance, 'save', types.MethodType(save, _instance))

问题 :Pydantic 的 BaseModel 默认禁止动态添加字段,直接 _instance.save = ... 会触发验证错误。

解决 :通过 object.__setattr__ 绕过 Pydantic 的 __setattr__ 拦截,直接写到实例的 __dict__ 里。同样的技巧也适用于其他禁止动态属性的库。


三、使用示例

基本用法

python 复制代码
@single
class A:
    def __init__(self, x: int = 0):
        self.x = x

a = A(5)
print(type(a))    # <class '__main__.A'>,类型依然是 A
print(a.x)        # 5
a.save()          # 保存到 A.json

指定持久化文件

python 复制代码
@single('b.json')
class B:
    def __init__(self, y: str = ''):
        self.y = y

b = B('hello')
b.save()  # 保存到 b.json

配合 Pydantic 使用

python 复制代码
@single
class Task(BaseModel):
    id: str = 'id'
    label: str = 'label'
    children: list['Task'] = []

t = Task()
print(t.id)     # id
print(t.label)  # label
t.save()        # 正常保存,不会被 Pydantic 拦截

四、总结

📌 要点回顾

细节 说明
三种语法 无参数 / @single('f.json') / @single(filename='f.json')
两段式返回 有文件名时必须返回 decorator(cls),因为 cls 还没传进来
class_mapping jsonpickle 解码时需要映射类的全限定名,避免无法反序列化
object.setattr 绕过 Pydantic 等禁止动态添加字段的限制

💡 适用场景

  • 配置管理器:启动时恢复配置,修改后自动持久化
  • 缓存服务:重启后缓存不丢失
  • 轻量级状态管理:不需要数据库

本文为本人原创,首发于掘金。 如果你有任何问题或想法,欢迎在评论区交流!

相关推荐
winfredzhang1 小时前
用 wxPython + 通义千问 VL 打造一款“批量人物图像识别“桌面应用
python·sqlite·wxpython·qwen 3.7max·分析照片
codeaideaai1 小时前
使用UV创建python项目
python·fastapi·uv
yongche_shi2 小时前
ragas官方文档中文版(十七)
python·ai·ragas·事实正确性
闵孚龙2 小时前
Tensor:PyTorch 世界里的一切都是张量
人工智能·pytorch·python
Sam09272 小时前
Java 转 AI Agent 开发:Java 和 Python 的区别与快速学习指南
java·人工智能·python·ai
在放️2 小时前
Python 爬虫 · 模拟浏览器跳转 - 防盗链处理
爬虫·python
骑士雄师2 小时前
21.2 mcp-server-chart 图表化作用
python
满怀冰雪2 小时前
01_LangChain是什么_带你理解LLM应用框架
python·langchain
稷下元歌2 小时前
python核心基础,这关于基于Moveltg加 Ros2实战Python编程基础实课
开发语言·python