refactor(common.storage): drop sync storage abstraction and example_usage

The async-only direction was already the only one used in production:
* create_storage never accepted mode=sync; build_storage_config always
  emitted mode=async; zero callers referenced StorageBackend / SyncData
  / S3StorageBackend.sync / LocalStorageBackend.sync anywhere.
* Drop the parallel sync base class, the sync concrete classes in
  backends/local.py and backends/s3.py, and the boto3 dependency.
* Drop example_usage.py (zero importers; demonstration code, not part
  of the public surface).
* Rename LocalAsyncStorageBackend -> LocalStorageBackend,
  S3AsyncStorageBackend -> S3StorageBackend to reflect the single
  remaining class per type.
* Tighten create_storage: any mode=... key now raises StorageConfigError
  with the new pointer (settings.storage_backend controls behavior).
* Cleanup call sites: schedule.application.service.build_object_store
  no longer passes mode=async to create_storage.
* Cosmetic touch-ups in backend/services/storage.py and
  common/config.py docstrings where they still said "boto3" instead of
  "S3 client".

Public API surface preserved: AsyncStorageBackend / ObjectMeta /
create_storage / build_storage_config / register_backend all keep
their names and call signatures. backend tests: 136 passed.
This commit is contained in:
tao.chen
2026-08-24 10:29:08 +08:00
parent 8fa6203303
commit 6050880c98
11 changed files with 81 additions and 541 deletions
+1 -1
View File
@@ -680,7 +680,7 @@ async def create_download_url_payload(
expires_in=timedelta(seconds=payload.expires_seconds),
)
# Public-host rewriting is now nginx's job (location /storage/). In the
# future the boto3 client should be built with the public endpoint so
# future the S3 client should be built with the public endpoint so
# generate_presigned_url returns a public URL directly.
return {
"data": {
-1
View File
@@ -7,7 +7,6 @@ dependencies = [
"greenlet>=3.0.0",
"apscheduler>=3.11.3",
"asyncmy==0.2.11",
"boto3>=1.34,<2",
"fastapi==0.116.1",
"pydantic-settings>=2.14.2",
"loguru>=0.7.2",
+2 -2
View File
@@ -139,11 +139,11 @@ class Settings(BaseSettings):
)
s3_access_key: str = Field(
default="modelplatform",
description="boto3 access key for S3-compatible storage.",
description="S3 access key for S3-compatible storage.",
)
s3_secret_key: str = Field(
default="modelplatformsecret",
description="boto3 secret key for S3-compatible storage.",
description="S3 secret key for S3-compatible storage.",
)
s3_workspace_bucket: str = Field(
default="workspace",
+8 -16
View File
@@ -1,24 +1,17 @@
"""统一存储层,同时支持同步和异步,通过 config["mode"] 切换
"""异步统一存储层。
上层暴露的公开 API
暴露的公开 API
from storage import create_storage, StorageBackend, AsyncStorageBackend, ObjectMeta
from storage.exceptions import StorageError, StorageNotFoundError, ...
from common.storage import create_storage, AsyncStorageBackend, ObjectMeta
from common.storage.exceptions import StorageError, StorageNotFoundError, ...
切换本地 / S3 通过 ``settings.storage_backend`` 控制,业务代码不感知差异:
用法:
# 同步(默认 mode="sync"
storage = create_storage({"type": "local", "base_dir": "./data"})
storage.put("a/b.txt", b"hello")
# 异步:加一个 mode 字段
storage = create_storage({"type": "local", "mode": "async", "base_dir": "./data"})
await storage.put("a/b.txt", b"hello")
切换本地/S3,或切换同步/异步,业务代码都不用改,只改配置:
storage = create_storage({"type": "s3", "mode": "async", "bucket": "my-bucket"})
storage = create_storage({"type": "s3", "bucket": "my-bucket"})
"""
from .base import AsyncStorageBackend, ObjectMeta, StorageBackend
from .base import AsyncStorageBackend, ObjectMeta
from .factory import (
PURPOSE_BUCKETS,
RCLONE_REMOTE_NAME,
@@ -38,7 +31,6 @@ __all__ = [
"USAGE_TYPE_TO_PURPOSE",
"AsyncStorageBackend",
"ObjectMeta",
"StorageBackend",
"actual_bucket_name",
"build_storage_config",
"build_storage_uri",
+11 -109
View File
@@ -1,21 +1,20 @@
"""本地文件系统存储后端。
"""本地文件系统异步存储后端。
- 同步实现 `LocalStorageBackend`标准库文件 I/O
- 异步实现 `LocalAsyncStorageBackend`aiofiles 做实际读写,
stat/exists/delete/mkdir/目录遍历这类轻量元数据操作用
asyncio.to_thread 包一层,避免阻塞事件循环
(只有创建异步实例时才需要装 aiofiles,同步实现零依赖)
`LocalStorageBackend`使用 aiofiles 做实际读写,
stat/exists/delete/mkdir/目录遍历这类轻量元数据操作用
``asyncio.to_thread`` 包一层,避免阻塞事件循环。
依赖:pip install aiofiles
"""
import asyncio
import os
import shutil
from collections.abc import AsyncIterator, Iterable
from collections.abc import AsyncIterator
from datetime import timedelta
from pathlib import Path
from typing import BinaryIO
from ..base import AsyncData, AsyncStorageBackend, ObjectMeta, StorageBackend, SyncData
from ..base import AsyncData, AsyncStorageBackend, ObjectMeta
from ..exceptions import StorageAlreadyExistsError, StorageNotFoundError
from ..registry import register_backend
@@ -33,106 +32,9 @@ def _meta(key: str, path: Path) -> ObjectMeta:
return ObjectMeta(key=key, size=st.st_size, last_modified=st.st_mtime)
# ==================== 同步实现 ====================
@register_backend("local", mode="sync")
class LocalStorageBackend(StorageBackend):
"""配置示例: {"type": "local", "mode": "sync", "base_dir": "/data/storage"}"""
def __init__(self, base_dir: str, **_ignored):
self.base_dir = Path(base_dir).resolve()
self.base_dir.mkdir(parents=True, exist_ok=True)
def _resolve(self, key: str) -> Path:
return _resolve(self.base_dir, key)
def put(
self,
key: str,
data: SyncData,
*,
overwrite: bool = True,
content_type: str | None = None,
metadata: dict | None = None,
) -> ObjectMeta:
path = self._resolve(key)
if path.exists() and not overwrite:
raise StorageAlreadyExistsError(f"key 已存在: {key}")
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(data, bytes):
path.write_bytes(data)
else:
with open(path, "wb") as f:
shutil.copyfileobj(data, f)
# local FS 没有对象级 metadatacontent_type / metadata 暂存忽略。
return _meta(key, path)
def get(self, key: str) -> bytes:
path = self._resolve(key)
if not path.is_file():
raise StorageNotFoundError(f"key 不存在: {key}")
return path.read_bytes()
def get_stream(self, key: str) -> BinaryIO:
path = self._resolve(key)
if not path.is_file():
raise StorageNotFoundError(f"key 不存在: {key}")
return open(path, "rb")
def delete(self, key: str) -> None:
try:
self._resolve(key).unlink()
except FileNotFoundError:
pass
def exists(self, key: str) -> bool:
return self._resolve(key).is_file()
def stat(self, key: str) -> ObjectMeta:
path = self._resolve(key)
if not path.is_file():
raise StorageNotFoundError(f"key 不存在: {key}")
return _meta(key, path)
def list(self, prefix: str = "") -> Iterable[ObjectMeta]:
search_root = self._resolve(prefix) if prefix else self.base_dir
if search_root.is_dir():
candidates = search_root.rglob("*")
else:
candidates = search_root.parent.glob(f"{search_root.name}*")
for path in candidates:
if path.is_file():
key = str(path.relative_to(self.base_dir)).replace(os.sep, "/")
yield _meta(key, path)
def get_url(self, key: str, *, expires_in: timedelta | None = None) -> str:
path = self._resolve(key)
if not path.is_file():
raise StorageNotFoundError(f"key 不存在: {key}")
return path.as_uri()
def copy(self, src_key: str, dst_key: str) -> ObjectMeta:
src_path = self._resolve(src_key)
if not src_path.is_file():
raise StorageNotFoundError(f"key 不存在: {src_key}")
dst_path = self._resolve(dst_key)
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
return _meta(dst_key, dst_path)
# ==================== 异步实现 ====================
@register_backend("local", mode="async")
class LocalAsyncStorageBackend(AsyncStorageBackend):
"""配置示例: {"type": "local", "mode": "async", "base_dir": "/data/storage"}
需要: pip install aiofiles
"""
@register_backend("local")
class LocalStorageBackend(AsyncStorageBackend):
"""配置示例: {"type": "local", "base_dir": "/data/storage"}"""
def __init__(self, base_dir: str, **_ignored):
self.base_dir = Path(base_dir).resolve()
+11 -160
View File
@@ -1,18 +1,15 @@
"""S3(及兼容协议)存储后端。
"""S3(及兼容协议)异步存储后端。
- 同步实现 `S3StorageBackend`boto3
- 异步实现 `S3AsyncStorageBackend`aioboto3
`S3StorageBackend`用 aioboto3 跑所有 S3 操作;
异常类型从 ``botocore.exceptions`` 拿(aioboto3 透传)。
两者只在各自 __init__ 里做 lazy import,互不强制依赖:
只用同步模式不需要装 aioboto3,只用异步模式不需要额外装 boto3
aioboto3 本身依赖 botocore,异常类型从它里面拿)。
依赖:pip install aioboto3
"""
from collections.abc import AsyncIterator, Iterable
from collections.abc import AsyncIterator
from datetime import timedelta
from typing import BinaryIO
from ..base import AsyncData, AsyncStorageBackend, ObjectMeta, StorageBackend, SyncData
from ..base import AsyncData, AsyncStorageBackend, ObjectMeta
from ..exceptions import (
StorageAlreadyExistsError,
StorageConnectionError,
@@ -30,162 +27,16 @@ def _meta_from_head(key: str, head: dict) -> ObjectMeta:
)
# ==================== 同步实现 ====================
@register_backend("s3", mode="sync")
class S3StorageBackend(StorageBackend):
@register_backend("s3")
class S3StorageBackend(AsyncStorageBackend):
"""配置示例:
{
"type": "s3", "mode": "sync",
"type": "s3",
"bucket": "my-bucket", "prefix": "app1/",
"region_name": "cn-north-1", "endpoint_url": "https://s3.example.com",
"aws_access_key_id": "...", "aws_secret_access_key": "...",
}
需要: pip install boto3
"""
def __init__(
self,
bucket: str,
prefix: str = "",
region_name: str | None = None,
endpoint_url: str | None = None,
aws_access_key_id: str | None = None,
aws_secret_access_key: str | None = None,
**_ignored,
):
try:
import boto3
from botocore.exceptions import BotoCoreError, ClientError
except ImportError as e:
raise ImportError("使用同步 S3 存储后端需要先安装 boto3: pip install boto3") from e
self._ClientError = ClientError
self._BotoCoreError = BotoCoreError
self.bucket = bucket
self.prefix = prefix.strip("/") + "/" if prefix.strip("/") else ""
try:
self.client = boto3.client(
"s3",
region_name=region_name,
endpoint_url=endpoint_url,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
)
except (BotoCoreError, ClientError) as e:
raise StorageConnectionError(f"初始化 S3 client 失败: {e}") from e
def _full_key(self, key: str) -> str:
return f"{self.prefix}{key.lstrip('/')}"
def put(self, key: str, data: SyncData, *, overwrite: bool = True) -> ObjectMeta:
full_key = self._full_key(key)
if not overwrite and self.exists(key):
raise StorageAlreadyExistsError(f"key 已存在: {key}")
body = data if isinstance(data, bytes) else data.read()
try:
self.client.put_object(Bucket=self.bucket, Key=full_key, Body=body)
except (self._ClientError, self._BotoCoreError) as e:
raise StorageConnectionError(f"上传失败 key={key}: {e}") from e
return self.stat(key)
def get(self, key: str) -> bytes:
full_key = self._full_key(key)
try:
resp = self.client.get_object(Bucket=self.bucket, Key=full_key)
return resp["Body"].read()
except self._ClientError as e:
if e.response.get("Error", {}).get("Code") in ("NoSuchKey", "404"):
raise StorageNotFoundError(f"key 不存在: {key}") from e
raise StorageConnectionError(f"读取失败 key={key}: {e}") from e
def get_stream(self, key: str) -> BinaryIO:
full_key = self._full_key(key)
try:
resp = self.client.get_object(Bucket=self.bucket, Key=full_key)
return resp["Body"]
except self._ClientError as e:
if e.response.get("Error", {}).get("Code") in ("NoSuchKey", "404"):
raise StorageNotFoundError(f"key 不存在: {key}") from e
raise StorageConnectionError(f"读取失败 key={key}: {e}") from e
def delete(self, key: str) -> None:
try:
self.client.delete_object(Bucket=self.bucket, Key=self._full_key(key))
except (self._ClientError, self._BotoCoreError) as e:
raise StorageConnectionError(f"删除失败 key={key}: {e}") from e
def exists(self, key: str) -> bool:
try:
self.client.head_object(Bucket=self.bucket, Key=self._full_key(key))
return True
except self._ClientError as e:
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey"):
return False
raise StorageConnectionError(f"检查 exists 失败 key={key}: {e}") from e
def stat(self, key: str) -> ObjectMeta:
try:
head = self.client.head_object(Bucket=self.bucket, Key=self._full_key(key))
except self._ClientError as e:
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey"):
raise StorageNotFoundError(f"key 不存在: {key}") from e
raise StorageConnectionError(f"获取元信息失败 key={key}: {e}") from e
return _meta_from_head(key, head)
def list(self, prefix: str = "") -> Iterable[ObjectMeta]:
full_prefix = self._full_key(prefix)
paginator = self.client.get_paginator("list_objects_v2")
try:
for page in paginator.paginate(Bucket=self.bucket, Prefix=full_prefix):
for obj in page.get("Contents", []):
key = obj["Key"][len(self.prefix):] if self.prefix else obj["Key"]
yield ObjectMeta(
key=key,
size=obj["Size"],
last_modified=obj["LastModified"].timestamp(),
etag=obj.get("ETag"),
)
except (self._ClientError, self._BotoCoreError) as e:
raise StorageConnectionError(f"列举对象失败 prefix={prefix}: {e}") from e
def get_url(self, key: str, *, expires_in: timedelta | None = None) -> str:
expires_seconds = int(expires_in.total_seconds()) if expires_in else 3600
try:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": self._full_key(key)},
ExpiresIn=expires_seconds,
)
except (self._ClientError, self._BotoCoreError) as e:
raise StorageConnectionError(f"生成预签名 URL 失败 key={key}: {e}") from e
def copy(self, src_key: str, dst_key: str) -> ObjectMeta:
try:
self.client.copy_object(
Bucket=self.bucket,
Key=self._full_key(dst_key),
CopySource={"Bucket": self.bucket, "Key": self._full_key(src_key)},
)
except self._ClientError as e:
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey"):
raise StorageNotFoundError(f"key 不存在: {src_key}") from e
raise StorageConnectionError(f"复制失败 {src_key} -> {dst_key}: {e}") from e
return self.stat(dst_key)
# ==================== 异步实现 ====================
@register_backend("s3", mode="async")
class S3AsyncStorageBackend(AsyncStorageBackend):
"""配置示例同上,把 "mode" 改成 "async" 即可。
需要: pip install aioboto3
每次操作默认通过 `async with session.client(...)` 拿一个短生命周期
client;用 `async with create_storage(...) as storage:` 可以复用同一个
client(见 __aenter__/__aexit__)。
@@ -226,7 +77,7 @@ class S3AsyncStorageBackend(AsyncStorageBackend):
def _client_cm(self):
return self._session.client("s3", **self._client_kwargs)
async def __aenter__(self) -> "S3AsyncStorageBackend":
async def __aenter__(self) -> "S3StorageBackend":
self._persistent_cm = self._client_cm()
self._persistent_client = await self._persistent_cm.__aenter__()
return self
@@ -418,4 +269,4 @@ class S3AsyncStorageBackend(AsyncStorageBackend):
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey"):
raise StorageNotFoundError(f"key 不存在: {src_key}") from e
raise StorageConnectionError(f"复制失败 {src_key} -> {dst_key}: {e}") from e
return await self.stat(dst_key)
return await self.stat(dst_key)
+7 -72
View File
@@ -1,21 +1,18 @@
"""同步 / 异步存储后端统一抽象接口。
"""统一异步存储后端抽象接口。
`StorageBackend` 是同步接口,`AsyncStorageBackend` 是异步接口
两者共用同一个 `ObjectMeta` 数据结构,方法签名尽量保持对称
(异步版本每个方法多一个 awaitget_stream/list 变成异步生成器),
这样业务代码从同步切到异步时心智负担最小。
所有异步存储后端(local / s3 / 未来新加的)继承 ``AsyncStorageBackend``
方法签名共用同一个 ``ObjectMeta`` 返回结构。
上层通过 `storage.create_storage(config)` 统一创建实例
用 `config["mode"]` 决定拿到的是同步实现还是异步实现
上层通过 ``storage.create_storage(config)`` 统一创建实例
不再提供同步抽象 —— 所有调用方都使用 ``async`` 接口
"""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Iterable
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import timedelta
from typing import BinaryIO, Union
from typing import Union
SyncData = Union[bytes, BinaryIO]
AsyncData = Union[bytes, "AsyncIterator[bytes]"]
@@ -30,68 +27,6 @@ class ObjectMeta:
extra: dict = field(default_factory=dict) # 后端特有的额外信息
class StorageBackend(ABC):
"""同步存储后端统一抽象基类。"""
@abstractmethod
def put(
self,
key: str,
data: SyncData,
*,
overwrite: bool = True,
content_type: str | None = None,
metadata: dict | None = None,
) -> ObjectMeta:
"""写入对象。overwrite=False 时 key 已存在应抛出 StorageAlreadyExistsError。
``content_type`` 和 ``metadata`` 是可选的(与异步 put 语义一致)。
"""
@abstractmethod
def get(self, key: str) -> bytes:
"""读取对象内容,不存在时抛出 StorageNotFoundError。"""
@abstractmethod
def get_stream(self, key: str) -> BinaryIO:
"""以流方式读取对象,适合大文件。"""
@abstractmethod
def delete(self, key: str) -> None:
"""删除对象。删除不存在的 key 不应报错(幂等)。"""
@abstractmethod
def exists(self, key: str) -> bool:
...
@abstractmethod
def stat(self, key: str) -> ObjectMeta:
"""不存在时抛出 StorageNotFoundError。"""
@abstractmethod
def list(self, prefix: str = "") -> Iterable[ObjectMeta]:
"""按前缀列出对象。"""
@abstractmethod
def get_url(self, key: str, *, expires_in: timedelta | None = None) -> str:
"""获取可访问 URL;本地存储返回 file://,S3 返回预签名 URL。"""
def copy(self, src_key: str, dst_key: str) -> ObjectMeta:
"""默认实现:读出来再写进去。后端可覆盖为更高效的原生实现。"""
data = self.get(src_key)
return self.put(dst_key, data)
def close(self) -> None:
"""释放后端持有的资源(连接池等)。不需要的后端可以不覆盖。"""
return
def __enter__(self) -> "StorageBackend":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
class AsyncStorageBackend(ABC):
"""异步存储后端统一抽象基类。"""
-115
View File
@@ -1,115 +0,0 @@
"""使用示例:同一套 create_storage(),靠 config["mode"] 切换同步/异步。"""
import asyncio
from common.storage import create_storage
from common.storage.exceptions import StorageNotFoundError
def sync_demo():
# mode 默认就是 "sync",可以不写
storage = create_storage({"type": "local", "base_dir": "./data_sync"})
storage.put("docs/hello.txt", b"hello world")
print(storage.get("docs/hello.txt"))
print(storage.exists("docs/hello.txt"))
print(list(storage.list("docs/")))
print(storage.get_url("docs/hello.txt"))
try:
storage.get("docs/not_exist.txt")
except StorageNotFoundError:
print("按预期抛出 StorageNotFoundError")
# 换成同步 S3,只改配置:
# storage = create_storage({"type": "s3", "bucket": "my-bucket"})
async def async_demo():
# 只加一个 "mode": "async",其余配置和参数不变
storage = create_storage({"type": "local", "mode": "async", "base_dir": "./data_async"})
await storage.put("docs/hello.txt", b"hello world")
print(await storage.get("docs/hello.txt"))
print(await storage.exists("docs/hello.txt"))
async for meta in storage.list("docs/"):
print(meta)
chunks = []
async for chunk in storage.get_stream("docs/hello.txt"):
chunks.append(chunk)
print(b"".join(chunks))
try:
await storage.get("docs/not_exist.txt")
except StorageNotFoundError:
print("按预期抛出 StorageNotFoundError")
# 并发写入,异步模式的典型优势场景
tasks = [storage.put(f"batch/{i}.txt", f"content-{i}".encode()) for i in range(10)]
await asyncio.gather(*tasks)
print("并发写入 10 个对象完成")
# 换成异步 S3,只改配置:
# storage = create_storage({"type": "s3", "mode": "async", "bucket": "my-bucket"})
# 高吞吐场景复用连接:
# async with create_storage({"type": "s3", "mode": "async", "bucket": "my-bucket"}) as s3:
# await s3.put("a.txt", b"1")
def extend_with_new_backend_demo():
"""演示独立扩展一种新的存储方式(同步+异步各一个),不用改现有代码。"""
import io
import time
from common.storage.base import ObjectMeta, StorageBackend
from common.storage.exceptions import StorageNotFoundError
from common.storage.registry import register_backend
@register_backend("memory", mode="sync")
class MemoryStorageBackend(StorageBackend):
def __init__(self, **_ignored):
self._store = {}
def put(self, key, data, *, overwrite=True):
body = data if isinstance(data, bytes) else data.read()
self._store[key] = body
return ObjectMeta(key=key, size=len(body), last_modified=time.time())
def get(self, key):
if key not in self._store:
raise StorageNotFoundError(key)
return self._store[key]
def get_stream(self, key):
return io.BytesIO(self.get(key))
def delete(self, key):
self._store.pop(key, None)
def exists(self, key):
return key in self._store
def stat(self, key):
if key not in self._store:
raise StorageNotFoundError(key)
return ObjectMeta(key=key, size=len(self._store[key]))
def list(self, prefix=""):
for key, body in self._store.items():
if key.startswith(prefix):
yield ObjectMeta(key=key, size=len(body))
def get_url(self, key, *, expires_in=None):
return f"memory://{key}"
mem_storage = create_storage({"type": "memory", "mode": "sync"})
mem_storage.put("a.txt", b"in-memory content")
print(mem_storage.get("a.txt"))
if __name__ == "__main__":
sync_demo()
asyncio.run(async_demo())
extend_with_new_backend_demo()
+23 -27
View File
@@ -1,58 +1,56 @@
"""统一入口:根据配置字典创建具体的存储后端实例。
"""统一入口:根据配置字典创建具体的异步存储后端实例。
配置里的 "mode" 字段决定拿到同步还是异步实现,默认 "sync"(向后兼容)
通过 ``settings.storage_backend`` 切换本地 / S3,业务代码不感知差异
# 同步(默认)
# 本地
storage = create_storage({"type": "local", "base_dir": "./data"})
storage.put("a.txt", b"hello")
# 异步:只需加一个 mode 字段,其余配置不变
storage = create_storage({"type": "local", "mode": "async", "base_dir": "./data"})
await storage.put("a.txt", b"hello")
# S3 同理
storage = create_storage({"type": "s3", "mode": "async", "bucket": "my-bucket"})
# S3
storage = create_storage({"type": "s3", "bucket": "my-bucket"})
await storage.put("a.txt", b"hello")
上层业务代码应该只从这里拿实例,不要直接 import 具体的 XxxStorageBackend /
XxxAsyncStorageBackend 类。
上层业务代码应该只从这里拿实例,不要直接 import 具体的 XxxStorageBackend 类。
"""
from pathlib import Path
from typing import Any, Union
from typing import Any
from .backends import local, s3 # noqa: F401 # 触发内置后端注册
from .base import AsyncStorageBackend, StorageBackend
from .base import AsyncStorageBackend
from .exceptions import StorageConfigError
from .registry import get_backend_class
AnyStorageBackend = Union[StorageBackend, AsyncStorageBackend]
def create_storage(config: dict[str, Any]) -> AnyStorageBackend:
"""根据配置创建存储后端。
def create_storage(config: dict[str, Any]) -> AsyncStorageBackend:
"""根据配置创建异步存储后端。
Args:
config: 必须包含 "type" 字段(如 "local" / "s3");
可选 "mode" 字段("sync" 默认 / "async");
其余字段作为 kwargs 传给对应后端的构造函数。
不能包含 "mode" ——同步接口已删除;如需切换行为请改 settings。
Returns:
mode="sync" 时返回 StorageBackend 实例(同步方法);
mode="async" 时返回 AsyncStorageBackend 实例(方法需要 await)。
``AsyncStorageBackend`` 实例(方法需要 await)。
"""
config = dict(config) # 不修改调用方传入的原字典
backend_type = config.pop("type", None)
mode = config.pop("mode", "sync")
if "mode" in config:
raise StorageConfigError(
"create_storage 不再接受 'mode' 字段;只支持异步后端。"
"如需切换本地 / S3,请改 settings.storage_backend。"
)
backend_type = config.pop("type", None)
if not backend_type:
raise StorageConfigError("配置缺少 'type' 字段,例如 'local''s3'")
backend_cls = get_backend_class(backend_type, mode)
backend_cls = get_backend_class(backend_type)
try:
return backend_cls(**config)
except TypeError as e:
raise StorageConfigError(
f"创建后端 (mode={mode}, type={backend_type}) 失败,参数不匹配: {e}"
f"创建后端 (type={backend_type}) 失败,参数不匹配: {e}"
) from e
@@ -125,7 +123,7 @@ def build_storage_config(bucket_name: str) -> dict[str, Any]:
bucket_name: 桶名,必须是 ``PURPOSE_BUCKETS`` 之一。
Returns:
直接喂给 ``create_storage(...)`` 的 dict。
直接喂给 ``create_storage(...)`` 的 dict。不含 ``mode`` 字段。
"""
# 延迟 import:避免 storage -> config -> storage 的循环依赖
from common.config import settings
@@ -138,14 +136,12 @@ def build_storage_config(bucket_name: str) -> dict[str, Any]:
if settings.storage_backend == "local":
return {
"type": "local",
"mode": "async",
"base_dir": str(Path(settings.local_storage_base_dir) / bucket_name),
}
if settings.storage_backend == "s3":
return {
"type": "s3",
"mode": "async",
"bucket": getattr(settings, f"s3_{bucket_name}_bucket"),
"endpoint_url": settings.s3_endpoint,
"aws_access_key_id": settings.s3_access_key,
+18 -36
View File
@@ -1,61 +1,43 @@
"""后端注册表,用 (mode, name) 作为 key 同时管理同步和异步实现。
"""后端注册表,按 name 索引每个后端的异步实现。
新增一种存储方式的同步或异步实现时,不需要改 factory.py
@register_backend("local", mode="sync")
class LocalStorageBackend(StorageBackend): ...
新增一种存储方式时,不需要改 factory.py:
@register_backend("local")
class LocalStorageBackend(AsyncStorageBackend): ...
@register_backend("local", mode="async")
class LocalAsyncStorageBackend(AsyncStorageBackend): ...
只要保证模块被 import 一次即可(backends/__init__.py 里统一 import)。
只要保证模块被 import 一次即可(``backends/__init__.py`` 里统一 import)。
"""
from typing import Union
from .base import AsyncStorageBackend, StorageBackend
from .base import AsyncStorageBackend
from .exceptions import StorageConfigError
BackendClass = Union[type[StorageBackend], type[AsyncStorageBackend]]
BackendClass = type[AsyncStorageBackend]
_REGISTRY: dict[tuple[str, str], BackendClass] = {}
VALID_MODES = ("sync", "async")
_REGISTRY: dict[str, BackendClass] = {}
def _check_mode(mode: str) -> None:
if mode not in VALID_MODES:
raise StorageConfigError(f"不支持的 mode: {mode!r},可选值: {VALID_MODES}")
def register_backend(name: str, mode: str = "sync"):
"""类装饰器:把一个后端类注册为 (mode, name) 对应的实现。"""
_check_mode(mode)
def register_backend(name: str):
"""类装饰器:把一个后端类注册为 ``name`` 对应的实现。"""
def _decorator(cls: BackendClass) -> BackendClass:
key = (mode, name)
if key in _REGISTRY and _REGISTRY[key] is not cls:
if name in _REGISTRY and _REGISTRY[name] is not cls:
raise StorageConfigError(
f"存储后端 (mode={mode}, type={name}) 已被注册为 {_REGISTRY[key]!r}"
f"存储后端 (type={name}) 已被注册为 {_REGISTRY[name]!r}"
)
_REGISTRY[key] = cls
_REGISTRY[name] = cls
return cls
return _decorator
def get_backend_class(name: str, mode: str = "sync") -> BackendClass:
_check_mode(mode)
key = (mode, name)
def get_backend_class(name: str) -> BackendClass:
try:
return _REGISTRY[key]
return _REGISTRY[name]
except KeyError:
available = ", ".join(
f"{m}:{n}" for (m, n) in sorted(_REGISTRY)
) or "(无)"
available = ", ".join(sorted(_REGISTRY)) or "(无)"
raise StorageConfigError(
f"未知的存储后端 (mode={mode}, type={name}),当前已注册: {available}"
f"未知的存储后端 (type={name}),当前已注册: {available}"
)
def registered_backends() -> dict[tuple[str, str], BackendClass]:
def registered_backends() -> dict[str, BackendClass]:
return dict(_REGISTRY)
@@ -210,7 +210,6 @@ def build_object_store(bucket_name: str | None = None) -> Any:
return create_storage(
{
"type": "local",
"mode": "async",
"base_dir": str(
Path(settings.local_storage_base_dir) / "version"
),
@@ -219,7 +218,6 @@ def build_object_store(bucket_name: str | None = None) -> Any:
return create_storage(
{
"type": "s3",
"mode": "async",
"bucket": bucket_name or settings.s3_version_bucket,
"endpoint_url": settings.s3_endpoint,
"aws_access_key_id": settings.s3_access_key,