modify: 清除diskcache依赖
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
|
||||
from chongming_cache.backends import CacheBackend, RedisBackend, SqliteBackend
|
||||
from chongming_cache.cache import Cache
|
||||
from chongming_cache.lock import DistributedLock
|
||||
|
||||
# 兼容旧名:原 diskcache 后端已由 SQLite 后端 (SqliteBackend) 直接替换
|
||||
DiskBackend = SqliteBackend
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
__all__ = [
|
||||
"Cache",
|
||||
"CacheBackend",
|
||||
"DiskBackend",
|
||||
"SqliteBackend",
|
||||
"RedisBackend",
|
||||
"DistributedLock",
|
||||
"__version__",
|
||||
]
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
from chongming_cache.backends.base import CacheBackend
|
||||
from chongming_cache.backends.disk import DiskBackend
|
||||
from chongming_cache.backends.redis_ import RedisBackend
|
||||
from chongming_cache.backends.sqlite_ import SqliteBackend
|
||||
|
||||
__all__ = ["CacheBackend", "DiskBackend", "RedisBackend"]
|
||||
__all__ = ["CacheBackend", "SqliteBackend", "RedisBackend"]
|
||||
@@ -1,8 +1,83 @@
|
||||
"""缓存后端抽象接口:dict-like 映射 + TTL。"""
|
||||
|
||||
import base64
|
||||
import pickle
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Iterator
|
||||
|
||||
try:
|
||||
import orjson
|
||||
|
||||
def _dumps(value: Any) -> bytes:
|
||||
# default:bytes 无法原生 JSON 化,包成 {__b64__: base64},读取时还原为 bytes
|
||||
def _default(obj: Any) -> Any:
|
||||
if isinstance(obj, bytes):
|
||||
return {_B64_MARKER: base64.b64encode(obj).decode("ascii")}
|
||||
raise TypeError(f"无法序列化为 JSON: {type(obj)!r}")
|
||||
|
||||
return orjson.dumps(value, default=_default)
|
||||
|
||||
def _loads(raw) -> Any:
|
||||
return orjson.loads(raw)
|
||||
|
||||
except ImportError: # pragma: no cover
|
||||
import json
|
||||
|
||||
def _dumps(value: Any) -> bytes:
|
||||
def _default(obj: Any) -> Any:
|
||||
if isinstance(obj, bytes):
|
||||
return {_B64_MARKER: base64.b64encode(obj).decode("ascii")}
|
||||
raise TypeError(f"无法序列化为 JSON: {type(obj)!r}")
|
||||
|
||||
return json.dumps(value, ensure_ascii=False, default=_default).encode("utf-8")
|
||||
|
||||
def _loads(raw) -> Any:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
# bytes 的 base64 包裹标记:让 bytes(如 RPC 缓存回复)在 JSON 下也能无损往返。
|
||||
_B64_MARKER = "__b64__"
|
||||
|
||||
# 所有后端统一以 JSON 持久化:跨后端(redis/本地 diskcache)语义一致,
|
||||
# 且与 Rust 进程插件(serde_json / json)的共享协议互通。
|
||||
|
||||
|
||||
def _restore_bytes(obj: Any) -> Any:
|
||||
"""递归还原被包裹的 bytes。"""
|
||||
if isinstance(obj, dict):
|
||||
if len(obj) == 1 and _B64_MARKER in obj:
|
||||
try:
|
||||
return base64.b64decode(obj[_B64_MARKER])
|
||||
except Exception:
|
||||
pass
|
||||
return {k: _restore_bytes(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_restore_bytes(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def encode_value(value: Any) -> bytes:
|
||||
"""统一序列化:JSON(orjson)。bytes 以 base64 包裹;datetime/set/自定义对象不支持。"""
|
||||
return _dumps(value)
|
||||
|
||||
|
||||
def decode_value(raw: Any) -> Any:
|
||||
"""统一反序列化。
|
||||
|
||||
- 字节值优先按 JSON 解析(orjson);bytes 从 base64 包裹还原;
|
||||
- 解析失败回退 pickle(兼容历史 pickled 数据,跨后端切换窗口);
|
||||
- 若后端已把对象透明还原(旧磁盘直存路径),非字节则原样返回。
|
||||
"""
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
return raw
|
||||
try:
|
||||
return _restore_bytes(_loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return pickle.loads(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
|
||||
class CacheBackend(ABC):
|
||||
"""缓存后端统一接口。
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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 set_nx(self, key, value, expire: float | None = None) -> bool:
|
||||
# diskcache.add 仅在 key 不存在时写入,返回是否成功(SET NX 语义)且进程安全
|
||||
return bool(self._cache.add(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()
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
redis-py 采用懒加载:仅在选择 redis 后端时才 import,
|
||||
使用 diskcache 后端的打包产物不会引入 redis 依赖。
|
||||
|
||||
值统一使用 JSON(orjson)序列化,与本地 diskcache 后端及 Rust 进程插件
|
||||
(serde_json)共享同一套跨语言协议;``prefix`` 作为 key 命名空间前缀。
|
||||
"""
|
||||
|
||||
import pickle
|
||||
import datetime
|
||||
from typing import Any, Iterator
|
||||
|
||||
from chongming_cache.backends.base import CacheBackend
|
||||
from chongming_cache.backends.base import CacheBackend, decode_value, encode_value
|
||||
|
||||
|
||||
# SCAN 批大小与 clear() 管道批大小
|
||||
_SCAN_COUNT = 1000
|
||||
@@ -16,8 +20,8 @@ _SCAN_COUNT = 1000
|
||||
class RedisBackend(CacheBackend):
|
||||
"""包装 redis-py 客户端,提供统一接口。
|
||||
|
||||
值使用 pickle 序列化(与 diskcache 默认序列化语义对齐,支持任意 Python 对象);
|
||||
``prefix`` 作为 key 命名空间前缀,便于多实例共享同一 Redis 时互不干扰。
|
||||
值统一使用 JSON(orjson)序列化;``prefix`` 作为 key 命名空间前缀,
|
||||
便于多实例共享同一 Redis 时互不干扰。
|
||||
|
||||
Args:
|
||||
host: Redis 地址
|
||||
@@ -66,6 +70,19 @@ class RedisBackend(CacheBackend):
|
||||
"""去掉前缀并解码为 str。"""
|
||||
return raw[len(self._prefix) :].decode()
|
||||
|
||||
@staticmethod
|
||||
def _expire_secs(expire):
|
||||
"""把秒(可能是 float)规范为 redis-py 可接受的取值。
|
||||
|
||||
redis-py 的 ``SET EX`` / ``EXPIRE`` 只接受 ``int`` 或 ``datetime.timedelta``,
|
||||
传入 float(如 DistributedLock 默认 ttl=30.0)会抛 ``DataError``。
|
||||
"""
|
||||
if isinstance(expire, datetime.timedelta):
|
||||
return expire
|
||||
if expire is not None:
|
||||
return int(expire)
|
||||
return None
|
||||
|
||||
def __contains__(self, key) -> bool:
|
||||
return bool(self._redis.exists(self._key(key)))
|
||||
|
||||
@@ -73,10 +90,10 @@ class RedisBackend(CacheBackend):
|
||||
raw = self._redis.get(self._key(key))
|
||||
if raw is None:
|
||||
raise KeyError(key)
|
||||
return pickle.loads(raw)
|
||||
return decode_value(raw)
|
||||
|
||||
def __setitem__(self, key, value) -> None:
|
||||
self._redis.set(self._key(key), pickle.dumps(value))
|
||||
self._redis.set(self._key(key), encode_value(value))
|
||||
|
||||
def __delitem__(self, key) -> None:
|
||||
if not self._redis.delete(self._key(key)):
|
||||
@@ -95,25 +112,27 @@ class RedisBackend(CacheBackend):
|
||||
raw = self._redis.get(self._key(key))
|
||||
if raw is None:
|
||||
return default
|
||||
return pickle.loads(raw)
|
||||
return decode_value(raw)
|
||||
|
||||
def set(self, key, value, expire: float | None = None) -> None:
|
||||
self._redis.set(self._key(key), pickle.dumps(value), ex=expire)
|
||||
self._redis.set(
|
||||
self._key(key), encode_value(value), ex=self._expire_secs(expire)
|
||||
)
|
||||
|
||||
def set_nx(self, key, value, expire: float | None = None) -> bool:
|
||||
# redis SET key val NX EX ttl:原子地仅在不存在时写入,用于分布式锁
|
||||
return bool(
|
||||
self._redis.set(
|
||||
self._key(key),
|
||||
pickle.dumps(value),
|
||||
ex=expire,
|
||||
encode_value(value),
|
||||
ex=self._expire_secs(expire),
|
||||
nx=True,
|
||||
)
|
||||
)
|
||||
|
||||
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))
|
||||
return bool(self._redis.expire(self._key(key), self._expire_secs(expire)))
|
||||
|
||||
def delete(self, key) -> bool:
|
||||
return bool(self._redis.delete(self._key(key)))
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""sqlite 后端:基于标准库 SQLite 的本地共享键值缓存(替代原 diskcache 后端)。
|
||||
|
||||
值以 JSON(orjson) 存入 TEXT 列,序列化协议与 redis 后端 / Rust 进程插件(serde_json)
|
||||
共享同一套 schema,因此 Rust 侧(sqlx)可直接读写同一份 .db 文件,实现真正的跨语言
|
||||
本地共享(无需 Redis)。
|
||||
|
||||
- 本地、多进程安全:WAL + busy_timeout + SQLite 行锁(等价于原 diskcache 的多进程语义);
|
||||
- TTL:``expire`` 列存到期 epoch 秒,读时惰性淘汰(对齐 diskcache/redis 的 get 语义)。
|
||||
|
||||
共享 schema(Ruby/Python 与 Rust 必须一致):
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL, -- JSON 文本
|
||||
expire REAL -- 到期 epoch 秒;NULL 表示永不过期
|
||||
);
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from chongming_cache.backends.base import CacheBackend, decode_value, encode_value
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL,
|
||||
expire REAL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class SqliteBackend(CacheBackend):
|
||||
"""包装 sqlite3,提供统一接口(与 Rust 侧共享同一份 schema)。
|
||||
|
||||
Args:
|
||||
path: SQLite 数据库文件路径(父目录不存在时自动创建)。
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
p = Path(path)
|
||||
if p.parent and str(p.parent) != ".":
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path = str(p)
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self._path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.execute(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
@property
|
||||
def client(self) -> sqlite3.Connection:
|
||||
"""底层 sqlite3.Connection 实例(逃生口)。"""
|
||||
return self._conn
|
||||
|
||||
@staticmethod
|
||||
def _expire_secs(expire) -> float | None:
|
||||
"""秒级 TTL -> 到期 epoch 时间戳;None 表示永不过期。"""
|
||||
if expire is None:
|
||||
return None
|
||||
return time.time() + float(expire)
|
||||
|
||||
def _live_value(self, k: str) -> str | None:
|
||||
"""取未过期的原始 JSON 文本;过期或缺失返回 None(过期时顺手删除)。"""
|
||||
row = self._conn.execute(
|
||||
"SELECT v, expire FROM cache WHERE k = ?", (k,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
v, expire = row
|
||||
if expire is not None and expire <= time.time():
|
||||
self._conn.execute("DELETE FROM cache WHERE k = ?", (k,))
|
||||
self._conn.commit()
|
||||
return None
|
||||
return v
|
||||
|
||||
def __contains__(self, key) -> bool:
|
||||
with self._lock:
|
||||
return self._live_value(str(key)) is not None
|
||||
|
||||
def __getitem__(self, key) -> Any:
|
||||
with self._lock:
|
||||
raw = self._live_value(str(key))
|
||||
if raw is None:
|
||||
raise KeyError(key)
|
||||
return decode_value(raw.encode("utf-8"))
|
||||
|
||||
def __setitem__(self, key, value) -> None:
|
||||
with self._lock:
|
||||
self._set(str(key), value, None)
|
||||
|
||||
def __delitem__(self, key) -> None:
|
||||
with self._lock:
|
||||
if not self.delete(key):
|
||||
raise KeyError(key)
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
self._conn.execute(
|
||||
"DELETE FROM cache WHERE expire IS NOT NULL AND expire <= ?", (now,)
|
||||
)
|
||||
self._conn.commit()
|
||||
return self._conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
self._conn.execute(
|
||||
"DELETE FROM cache WHERE expire IS NOT NULL AND expire <= ?", (now,)
|
||||
)
|
||||
self._conn.commit()
|
||||
rows = self._conn.execute("SELECT k FROM cache").fetchall()
|
||||
return iter(row[0] for row in rows)
|
||||
|
||||
def get(self, key, default=None) -> Any:
|
||||
with self._lock:
|
||||
raw = self._live_value(str(key))
|
||||
if raw is None:
|
||||
return default
|
||||
return decode_value(raw.encode("utf-8"))
|
||||
|
||||
def _set(self, k: str, value: Any, expire) -> None:
|
||||
v = encode_value(value).decode("utf-8")
|
||||
self._conn.execute(
|
||||
"INSERT INTO cache(k, v, expire) VALUES(?, ?, ?) "
|
||||
"ON CONFLICT(k) DO UPDATE SET v=excluded.v, expire=excluded.expire",
|
||||
(k, v, expire),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def set(self, key, value, expire: float | None = None) -> None:
|
||||
with self._lock:
|
||||
self._set(str(key), value, self._expire_secs(expire))
|
||||
|
||||
def set_nx(self, key, value, expire: float | None = None) -> bool:
|
||||
with self._lock:
|
||||
k = str(key)
|
||||
v = encode_value(value).decode("utf-8")
|
||||
cur = self._conn.execute(
|
||||
"INSERT OR IGNORE INTO cache(k, v, expire) VALUES(?, ?, ?)",
|
||||
(k, v, self._expire_secs(expire)),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cur.rowcount == 1
|
||||
|
||||
def touch(self, key, expire: float | None = None) -> bool:
|
||||
with self._lock:
|
||||
k = str(key)
|
||||
if self._live_value(k) is None:
|
||||
return False
|
||||
self._conn.execute(
|
||||
"UPDATE cache SET expire=? WHERE k=?", (self._expire_secs(expire), k)
|
||||
)
|
||||
self._conn.commit()
|
||||
return True
|
||||
|
||||
def delete(self, key) -> bool:
|
||||
with self._lock:
|
||||
cur = self._conn.execute("DELETE FROM cache WHERE k = ?", (str(key),))
|
||||
self._conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute("DELETE FROM cache")
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,10 +1,14 @@
|
||||
"""统一缓存门面:dict-like 接口,后端可切换 diskcache / redis。"""
|
||||
"""统一缓存门面:dict-like 接口,后端可切换 sqlite(本地) / redis(共享)。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Mapping
|
||||
|
||||
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
|
||||
from chongming_cache.backends import CacheBackend, RedisBackend, SqliteBackend
|
||||
|
||||
_KNOWN_BACKENDS = {"diskcache", "redis"}
|
||||
_KNOWN_BACKENDS = {"sqlite", "redis"}
|
||||
# 旧后端名 diskcache → sqlite(新本地后端直接替换了原 diskcache 实现)
|
||||
_BACKEND_ALIASES = {"diskcache": "sqlite"}
|
||||
_DEFAULT_PATH = "./cache_data/cache.db"
|
||||
|
||||
|
||||
class Cache:
|
||||
@@ -24,11 +28,11 @@ class Cache:
|
||||
value = cache["k"]
|
||||
"""
|
||||
|
||||
def __init__(self, backend: str = "diskcache", **params):
|
||||
def __init__(self, backend: str = "sqlite", **params):
|
||||
"""创建缓存实例。
|
||||
|
||||
Args:
|
||||
backend: "diskcache" 或 "redis"
|
||||
backend: "sqlite" 或 "redis"(旧名 "diskcache" 兼容映射到 sqlite)
|
||||
**params: 后端参数,见 ``from_config`` 说明
|
||||
"""
|
||||
self._backend = self._create_backend(backend, params)
|
||||
@@ -37,24 +41,29 @@ class Cache:
|
||||
def from_config(cls, config: Mapping) -> "Cache":
|
||||
"""从配置字典创建缓存实例,读取形如主项目 config.toml 的 ``[cache]`` 段:
|
||||
|
||||
- ``backend``: "diskcache"(默认)或 "redis"
|
||||
- ``cache_store_path``: diskcache 缓存目录(默认 "./cache_data")
|
||||
- ``backend``: "sqlite"(默认,本地共享)或 "redis"(跨主机)
|
||||
- ``path`` / ``cache_store_path``: sqlite 数据库文件路径(默认 "./cache_data/cache.db")
|
||||
- ``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)
|
||||
return cls(cfg.pop("backend", "sqlite"), **cfg)
|
||||
|
||||
@staticmethod
|
||||
def _create_backend(backend: str, params: Mapping) -> CacheBackend:
|
||||
name = (backend or "diskcache").strip().lower()
|
||||
name = _BACKEND_ALIASES.get((backend or "sqlite").strip().lower(), (backend or "sqlite").strip().lower())
|
||||
if name not in _KNOWN_BACKENDS:
|
||||
raise ValueError(f"不支持的缓存后端: {backend!r}(可选 diskcache / redis)")
|
||||
raise ValueError(f"不支持的缓存后端: {backend!r}(可选 sqlite / redis)")
|
||||
|
||||
if name == "diskcache":
|
||||
return DiskBackend(params.get("cache_store_path", "./cache_data"))
|
||||
if name == "sqlite":
|
||||
path = params.get("path") or params.get("cache_store_path") or _DEFAULT_PATH
|
||||
p = Path(path)
|
||||
# 兼容旧 diskcache 配置:路径若指向目录则追加默认 db 文件名
|
||||
if path.endswith(("/", "\\")) or p.is_dir():
|
||||
p = p / "cache.db"
|
||||
return SqliteBackend(p)
|
||||
|
||||
return RedisBackend(
|
||||
host=params.get("redis_host", "127.0.0.1"),
|
||||
@@ -66,12 +75,12 @@ class Cache:
|
||||
|
||||
@property
|
||||
def backend(self) -> CacheBackend:
|
||||
"""后端封装实例(DiskBackend / RedisBackend)。"""
|
||||
"""后端封装实例(SqliteBackend / RedisBackend)。"""
|
||||
return self._backend
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""底层客户端(diskcache.Cache / redis.Redis),逃生口。"""
|
||||
"""底层客户端(sqlite3.Connection / redis.Redis),逃生口。"""
|
||||
return self._backend.client
|
||||
|
||||
# ── 映射协议 ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user