add: chongming-cache 统一缓存工具(diskcache / redis 双后端,dict-like 接口)

This commit is contained in:
2026-08-17 15:07:29 +08:00
commit ee1a370ee5
11 changed files with 905 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
from chongming_cache.cache import Cache
__version__ = "0.1.0"
__all__ = ["Cache", "CacheBackend", "DiskBackend", "RedisBackend", "__version__"]
+5
View File
@@ -0,0 +1,5 @@
from chongming_cache.backends.base import CacheBackend
from chongming_cache.backends.disk import DiskBackend
from chongming_cache.backends.redis_ import RedisBackend
__all__ = ["CacheBackend", "DiskBackend", "RedisBackend"]
+66
View File
@@ -0,0 +1,66 @@
"""缓存后端抽象接口:dict-like 映射 + TTL。"""
from abc import ABC, abstractmethod
from typing import Any, Iterator
class CacheBackend(ABC):
"""缓存后端统一接口。
语义约定(与 diskcache 对齐):
- ``__getitem__`` 未命中抛 ``KeyError``;``get(key, default)`` 未命中返回 default
- ``set(key, value, expire=None)`` 中 expire 为秒(int/float),None 表示永久
- ``touch`` 返回是否命中(未命中返回 False);``delete`` 未命中返回 False
"""
# ── 映射协议 ──────────────────────────────────────────────
@abstractmethod
def __contains__(self, key) -> bool:
"""key 是否存在且未过期。"""
@abstractmethod
def __getitem__(self, key) -> Any:
"""取值,未命中抛 KeyError。"""
@abstractmethod
def __setitem__(self, key, value) -> None:
"""写入(不设 TTL)。"""
@abstractmethod
def __delitem__(self, key) -> None:
"""删除,未命中抛 KeyError。"""
@abstractmethod
def __len__(self) -> int:
"""有效条目数。"""
@abstractmethod
def __iter__(self) -> Iterator:
"""迭代所有 key。"""
# ── 扩展接口 ──────────────────────────────────────────────
@abstractmethod
def get(self, key, default=None) -> Any:
"""取值,未命中返回 default。"""
@abstractmethod
def set(self, key, value, expire: float | None = None) -> None:
"""写入,可带 TTL(秒)。"""
@abstractmethod
def touch(self, key, expire: float | None = None) -> bool:
"""刷新 key 的 TTL,返回是否命中(未命中返回 False)。"""
@abstractmethod
def delete(self, key) -> bool:
"""删除,返回是否命中。"""
@abstractmethod
def clear(self) -> None:
"""清空本缓存实例可见的全部条目。"""
@abstractmethod
def close(self) -> None:
"""释放后端连接/句柄。"""
+60
View File
@@ -0,0 +1,60 @@
"""diskcache 后端:基于 SQLite 的本地持久化缓存,多进程安全。"""
from pathlib import Path
from typing import Any, Iterator
from diskcache import Cache as DiskCache
from chongming_cache.backends.base import CacheBackend
class DiskBackend(CacheBackend):
"""包装 diskcache.Cache,提供统一接口。
Args:
path: 缓存目录(SQLite 数据文件所在目录),父目录不存在时自动创建。
"""
def __init__(self, path: str | Path):
self._cache = DiskCache(path)
@property
def client(self) -> DiskCache:
"""底层 diskcache.Cache 实例(逃生口)。"""
return self._cache
def __contains__(self, key) -> bool:
return key in self._cache
def __getitem__(self, key) -> Any:
return self._cache[key]
def __setitem__(self, key, value) -> None:
self._cache[key] = value
def __delitem__(self, key) -> None:
del self._cache[key]
def __len__(self) -> int:
return len(self._cache)
def __iter__(self) -> Iterator:
return iter(self._cache)
def get(self, key, default=None) -> Any:
return self._cache.get(key, default)
def set(self, key, value, expire: float | None = None) -> None:
self._cache.set(key, value, expire=expire)
def touch(self, key, expire: float | None = None) -> bool:
return self._cache.touch(key, expire=expire)
def delete(self, key) -> bool:
return self._cache.delete(key)
def clear(self) -> None:
self._cache.clear()
def close(self) -> None:
self._cache.close()
+124
View File
@@ -0,0 +1,124 @@
"""redis 后端:跨进程/跨主机共享缓存。
redis-py 采用懒加载:仅在选择 redis 后端时才 import,
使用 diskcache 后端的打包产物不会引入 redis 依赖。
"""
import pickle
from typing import Any, Iterator
from chongming_cache.backends.base import CacheBackend
# SCAN 批大小与 clear() 管道批大小
_SCAN_COUNT = 1000
class RedisBackend(CacheBackend):
"""包装 redis-py 客户端,提供统一接口。
值使用 pickle 序列化(与 diskcache 默认序列化语义对齐,支持任意 Python 对象);
``prefix`` 作为 key 命名空间前缀,便于多实例共享同一 Redis 时互不干扰。
Args:
host: Redis 地址
port: Redis 端口
db: 数据库编号
password: 认证密码(无密码为 None)
prefix: key 前缀(命名空间),如 ``"chongming:"``
**kwargs: 其余参数透传 redis.Redis(如 socket_timeout)
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 6379,
db: int = 0,
password: str | None = None,
prefix: str = "",
**kwargs,
):
try:
import redis
except ImportError as e: # pragma: no cover
raise ImportError("使用 redis 后端需要安装 redis 包") from e
self._redis = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=False,
**kwargs,
)
self._prefix = prefix
@property
def client(self):
"""底层 redis.Redis 客户端(逃生口)。"""
return self._redis
def _key(self, key) -> bytes:
if isinstance(key, bytes):
return self._prefix.encode() + key
return self._prefix.encode() + str(key).encode()
def _unkey(self, raw: bytes) -> str:
"""去掉前缀并解码为 str。"""
return raw[len(self._prefix) :].decode()
def __contains__(self, key) -> bool:
return bool(self._redis.exists(self._key(key)))
def __getitem__(self, key) -> Any:
raw = self._redis.get(self._key(key))
if raw is None:
raise KeyError(key)
return pickle.loads(raw)
def __setitem__(self, key, value) -> None:
self._redis.set(self._key(key), pickle.dumps(value))
def __delitem__(self, key) -> None:
if not self._redis.delete(self._key(key)):
raise KeyError(key)
def __len__(self) -> int:
return sum(
1 for _ in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT)
)
def __iter__(self) -> Iterator:
for raw in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT):
yield self._unkey(raw)
def get(self, key, default=None) -> Any:
raw = self._redis.get(self._key(key))
if raw is None:
return default
return pickle.loads(raw)
def set(self, key, value, expire: float | None = None) -> None:
self._redis.set(self._key(key), pickle.dumps(value), ex=expire)
def touch(self, key, expire: float | None = None) -> bool:
# expire=None 时 redis-py 发 PERSIST(永久),与 diskcache.touch 语义一致
return bool(self._redis.expire(self._key(key), expire))
def delete(self, key) -> bool:
return bool(self._redis.delete(self._key(key)))
def clear(self) -> None:
"""仅清除本前缀下的 key(SCAN+管道批量 DEL,不 flushdb)。"""
pipe = self._redis.pipeline()
count = 0
for raw in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT):
pipe.delete(raw)
count += 1
if count >= _SCAN_COUNT:
pipe.execute()
count = 0
if count:
pipe.execute()
def close(self) -> None:
self._redis.close()
+121
View File
@@ -0,0 +1,121 @@
"""统一缓存门面:dict-like 接口,后端可切换 diskcache / redis。"""
from typing import Any, Iterator, Mapping
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
_KNOWN_BACKENDS = {"diskcache", "redis"}
class Cache:
"""统一缓存封装。
- 映射协议:``in`` / ``cache[key]`` / ``cache[key] = value`` / ``del`` / ``len`` / ``iter``
- 扩展接口:``get`` / ``set(key, value, expire=秒)`` / ``touch`` / ``delete`` / ``clear`` / ``close``
- 支持上下文管理器(退出时自动 close)
通过 ``backend`` 参数或 ``from_config`` 切换后端:
from chongming_cache import Cache
cache = Cache.from_config(config["cache"]) # 读取 [cache] 段
cache.set("k", {"a": 1}, expire=60)
if "k" in cache:
value = cache["k"]
"""
def __init__(self, backend: str = "diskcache", **params):
"""创建缓存实例。
Args:
backend: "diskcache" 或 "redis"
**params: 后端参数,见 ``from_config`` 说明
"""
self._backend = self._create_backend(backend, params)
@classmethod
def from_config(cls, config: Mapping) -> "Cache":
"""从配置字典创建缓存实例,读取形如主项目 config.toml 的 ``[cache]`` 段:
- ``backend``: "diskcache"(默认)或 "redis"
- ``cache_store_path``: diskcache 缓存目录(默认 "./cache_data")
- ``redis_host`` / ``redis_port`` / ``redis_db`` / ``redis_password`` / ``redis_prefix``:
redis 后端连接参数(仅 backend = "redis" 时生效)
所有键均可缺省,旧配置(只有 cache_store_path)直接兼容。
"""
cfg = dict(config)
return cls(cfg.pop("backend", "diskcache"), **cfg)
@staticmethod
def _create_backend(backend: str, params: Mapping) -> CacheBackend:
name = (backend or "diskcache").strip().lower()
if name not in _KNOWN_BACKENDS:
raise ValueError(f"不支持的缓存后端: {backend!r}(可选 diskcache / redis)")
if name == "diskcache":
return DiskBackend(params.get("cache_store_path", "./cache_data"))
return RedisBackend(
host=params.get("redis_host", "127.0.0.1"),
port=int(params.get("redis_port", 6379)),
db=int(params.get("redis_db", 0)),
password=params.get("redis_password") or None,
prefix=params.get("redis_prefix", "") or "",
)
@property
def backend(self) -> CacheBackend:
"""后端封装实例(DiskBackend / RedisBackend)。"""
return self._backend
@property
def client(self):
"""底层客户端(diskcache.Cache / redis.Redis),逃生口。"""
return self._backend.client
# ── 映射协议 ──────────────────────────────────────────────
def __contains__(self, key) -> bool:
return key in self._backend
def __getitem__(self, key) -> Any:
return self._backend[key]
def __setitem__(self, key, value) -> None:
self._backend[key] = value
def __delitem__(self, key) -> None:
del self._backend[key]
def __len__(self) -> int:
return len(self._backend)
def __iter__(self) -> Iterator:
return iter(self._backend)
# ── 扩展接口 ──────────────────────────────────────────────
def get(self, key, default=None) -> Any:
return self._backend.get(key, default)
def set(self, key, value, expire: float | None = None) -> None:
self._backend.set(key, value, expire)
def touch(self, key, expire: float | None = None) -> bool:
return self._backend.touch(key, expire)
def delete(self, key) -> bool:
return self._backend.delete(key)
def clear(self) -> None:
self._backend.clear()
def close(self) -> None:
self._backend.close()
def __enter__(self) -> "Cache":
return self
def __exit__(self, *exc) -> None:
self.close()