add: chongming-cache 统一缓存工具(diskcache / redis 双后端,dict-like 接口)
This commit is contained in:
@@ -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__"]
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
"""释放后端连接/句柄。"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user