ShiErFeng/shierfeng-fastapi-backend/common/annotation/cache_annotation.py
2026-07-24 09:43:10 +08:00

503 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hashlib
import json
from collections.abc import Awaitable, Callable, Sequence
from datetime import datetime
from functools import wraps
from typing import Any, TypeVar
from fastapi import Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse, ORJSONResponse, Response, StreamingResponse, UJSONResponse
from redis import asyncio as aioredis
from typing_extensions import ParamSpec
from common.constant import HttpStatusConstant
from common.context import RequestContext
from common.enums import HttpMethod, RedisInitKeyConfig
from exceptions.exception import LoginException
from utils.api_annotation_util import ApiAnnotationUtil
from utils.api_response_header_util import ApiResponseHeaderUtil
from utils.log_util import logger
P = ParamSpec('P')
R = TypeVar('R')
class ApiCacheManager:
"""
接口缓存键与命名空间管理工具类
"""
@classmethod
async def clear_namespace(cls, redis: aioredis.Redis, namespace: str) -> int:
"""
清理指定命名空间下的接口缓存
:param redis: Redis连接对象
:param namespace: 缓存命名空间
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_pattern(namespace))
@classmethod
async def clear_all(cls, redis: aioredis.Redis) -> int:
"""
清理所有接口缓存
:param redis: Redis连接对象
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_pattern('*'))
@classmethod
async def clear_namespaces(cls, redis: aioredis.Redis, namespaces: list[str] | tuple[str, ...] | set[str]) -> int:
"""
批量清理多个命名空间下的接口缓存
:param redis: Redis连接对象
:param namespaces: 需要清理的缓存命名空间列表
:return: 删除的缓存数量
"""
deleted_count = 0
for namespace in set(namespaces):
deleted_count += await cls.clear_namespace(redis, namespace)
return deleted_count
@classmethod
async def clear_namespace_prefix(cls, redis: aioredis.Redis, namespace_prefix: str) -> int:
"""
按命名空间前缀清理接口缓存
:param redis: Redis连接对象
:param namespace_prefix: 缓存命名空间前缀
:return: 删除的缓存数量
"""
return await cls._clear_by_pattern(redis, cls.build_namespace_prefix_pattern(namespace_prefix))
@classmethod
async def clear_namespace_prefixes(
cls,
redis: aioredis.Redis,
namespace_prefixes: list[str] | tuple[str, ...] | set[str],
) -> int:
"""
批量按命名空间前缀清理接口缓存
:param redis: Redis连接对象
:param namespace_prefixes: 需要清理的缓存命名空间前缀列表
:return: 删除的缓存数量
"""
deleted_count = 0
for namespace_prefix in set(namespace_prefixes):
deleted_count += await cls.clear_namespace_prefix(redis, namespace_prefix)
return deleted_count
@classmethod
def build_namespace_pattern(cls, namespace: str) -> str:
"""
生成命名空间扫描表达式
:param namespace: 缓存命名空间
:return: 缓存键扫描匹配模式
"""
return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace}:*'
@classmethod
def build_namespace_prefix_pattern(cls, namespace_prefix: str) -> str:
"""
生成命名空间前缀扫描表达式
:param namespace_prefix: 缓存命名空间前缀
:return: 缓存键扫描匹配模式
"""
return f'{RedisInitKeyConfig.API_CACHE.key}:{namespace_prefix}*'
@classmethod
async def _clear_by_pattern(cls, redis: aioredis.Redis, pattern: str) -> int:
"""
根据扫描表达式清理匹配的接口缓存
:param redis: Redis连接对象
:param pattern: 缓存键扫描匹配模式
:return: 删除的缓存数量
"""
cache_keys = [key async for key in redis.scan_iter(match=pattern)]
if not cache_keys:
return 0
return await redis.delete(*cache_keys)
class _ApiCacheSupport:
"""
接口缓存装饰器共用工具基类
"""
def _load_json_content(self, response_body: str) -> Any | None:
"""
解析JSON响应体内容
:param response_body: JSON字符串响应体
:return: 解析后的Python对象解析失败时返回None
"""
try:
return json.loads(response_body)
except json.JSONDecodeError:
return None
def _extract_json_response_content(self, result: Any) -> Any | None:
"""
提取响应中的JSON内容
:param result: 原始接口返回结果
:return: 解析后的JSON内容无法提取时返回None
"""
if result is None or isinstance(result, StreamingResponse):
return None
if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)):
return self._load_json_content(result.body.decode('utf-8'))
if isinstance(result, Response):
return None
return jsonable_encoder(result)
def _match_response_codes(self, response_content: Any, response_codes: set[int]) -> bool:
"""
判断响应内容中的业务响应码是否匹配
:param response_content: JSON响应内容
:param response_codes: 允许的业务响应码集合
:return: 是否匹配
"""
if not isinstance(response_content, dict):
return True
response_code = response_content.get('code')
if response_code is None:
return True
return response_code in response_codes
def _get_matched_response_content(self, result: Any, response_codes: set[int]) -> Any | None:
"""
提取并校验满足业务响应码要求的JSON响应内容
:param result: 原始接口返回结果
:param response_codes: 允许的业务响应码集合
:return: 匹配成功的JSON响应内容不匹配时返回None
"""
response_content = self._extract_json_response_content(result)
if response_content is None or not self._match_response_codes(response_content, response_codes):
return None
return response_content
class ApiCache(_ApiCacheSupport):
"""
接口缓存装饰器仅用于幂等且返回JSON的接口
"""
def __init__(
self,
namespace: str,
expire_seconds: int = 10,
vary_by_user: bool = True,
methods: Sequence[HttpMethod] | None = None,
cache_response_codes: set[int] | None = None,
) -> None:
"""
初始化接口缓存装饰器
:param namespace: 缓存命名空间,用于区分不同接口类型
:param expire_seconds: 缓存过期时间,单位秒
:param vary_by_user: 是否按当前登录用户隔离缓存
:param methods: 允许启用缓存的HttpMethod枚举列表为None时默认仅缓存GET请求
:param cache_response_codes: 允许缓存的业务响应码为None时默认仅缓存成功响应
"""
self.namespace = namespace
self.expire_seconds = expire_seconds
self.vary_by_user = vary_by_user
self.methods = ApiAnnotationUtil.normalize_http_methods(methods, default_methods=(HttpMethod.GET,))
self.cache_response_codes = (
cache_response_codes if cache_response_codes is not None else {HttpStatusConstant.SUCCESS}
)
def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""
为目标异步接口函数增加接口缓存能力
:param func: 需要被缓存的异步接口函数
:return: 包装后的异步接口函数
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
request, redis = ApiAnnotationUtil.resolve_request_redis(
func, '当前应用未初始化Redis连接跳过接口缓存', *args, **kwargs
)
if request is None or redis is None:
return await func(*args, **kwargs)
if not self._is_request_method_allowed(request):
return await func(*args, **kwargs)
cache_key = await self._build_cache_key(request)
cached_response = await redis.get(cache_key)
if cached_response:
return self._build_cached_response(cached_response) # type: ignore[return-value]
result = await func(*args, **kwargs)
await self._cache_response(redis, cache_key, result)
ApiResponseHeaderUtil.merge_headers(request, {'X-Api-Cache': 'MISS'})
return result
return wrapper
def _is_request_method_allowed(self, request: Request) -> bool:
"""
判断当前请求方法是否允许启用接口缓存
:param request: 当前请求对象
:return: 是否允许缓存
"""
return request.method.upper() in self.methods
async def _build_cache_key(self, request: Request) -> str:
"""
根据当前请求生成稳定缓存键
:param request: 当前请求对象
:return: 接口缓存键
"""
request_body = await request.body()
user_scope = self._get_user_scope(request) if self.vary_by_user else ''
key_material = {
'method': request.method,
'path': request.url.path,
'path_params': dict(sorted(request.path_params.items())),
'query_params': sorted(request.query_params.multi_items()),
'body_digest': hashlib.sha256(request_body).hexdigest() if request_body else '',
'user_scope': user_scope,
}
key_digest = hashlib.sha256(
json.dumps(
key_material,
ensure_ascii=False,
sort_keys=True,
separators=(',', ':'),
default=str,
).encode('utf-8')
).hexdigest()
return f'{RedisInitKeyConfig.API_CACHE.key}:{self.namespace}:{key_digest}'
def _get_user_scope(self, request: Request) -> str:
"""
获取用户隔离维度
优先使用当前登录用户ID未登录时回退到Authorization摘要。
:param request: 当前请求对象
:return: 用户隔离字符串
"""
try:
current_user = RequestContext.get_current_user()
return str(current_user.user.user_id)
except LoginException:
authorization = request.headers.get('Authorization', '')
return hashlib.sha256(authorization.encode('utf-8')).hexdigest() if authorization else ''
def _serialize_response(self, result: Any) -> str | None:
"""
将响应对象序列化为可存入缓存的字符串
:param result: 原始接口返回结果
:return: 序列化后的缓存内容不可缓存时返回None
"""
response_payload = self._extract_response_payload(result)
if response_payload is None:
return None
return json.dumps(response_payload, ensure_ascii=False)
def _extract_response_payload(self, result: Any) -> dict[str, Any] | None:
"""
提取可缓存的响应载荷
:param result: 原始接口返回结果
:return: 可缓存的响应载荷不可缓存时返回None
"""
if isinstance(result, StreamingResponse):
return None
if isinstance(result, (JSONResponse, ORJSONResponse, UJSONResponse)):
# 命中缓存时无法安全复放后台任务,因此带有后台任务的响应不参与缓存
if getattr(result, 'background', None) is not None:
return None
content = self._get_matched_response_content(result, self.cache_response_codes)
if content is None:
return None
return {
'content': content,
'status_code': result.status_code,
'media_type': result.media_type,
'headers': self._filter_response_headers(result.headers),
}
if isinstance(result, Response):
return None
json_content = self._get_matched_response_content(result, self.cache_response_codes)
if json_content is None:
return None
return {
'content': json_content,
'status_code': HttpStatusConstant.SUCCESS,
'media_type': 'application/json',
'headers': {},
}
def _build_cached_response(self, cached_response: str) -> JSONResponse:
"""
根据缓存内容重建JSON响应对象
:param cached_response: 缓存中读取到的响应字符串
:return: 重建后的JSON响应对象
"""
cached_payload = json.loads(cached_response)
cached_content = self._refresh_response_time(cached_payload['content'])
response = JSONResponse(
status_code=cached_payload['status_code'],
content=jsonable_encoder(cached_content),
headers=cached_payload.get('headers'),
media_type=cached_payload.get('media_type'),
)
response.headers['X-Api-Cache'] = 'HIT'
return response
async def _cache_response(self, redis: aioredis.Redis, cache_key: str, result: Any) -> None:
"""
将接口响应写入接口缓存
:param redis: Redis连接对象
:param cache_key: 接口缓存键
:param result: 原始接口返回结果
:return: None
"""
serialized_response = self._serialize_response(result)
if serialized_response is None:
return
await redis.set(cache_key, serialized_response, ex=self.expire_seconds)
logger.debug(f'接口缓存写入成功: {cache_key}')
def _filter_response_headers(self, headers: dict[str, str]) -> dict[str, str]:
"""
过滤不适合直接回放的响应头
:param headers: 原始响应头
:return: 过滤后的响应头
"""
excluded_headers = {'content-length', 'content-type', 'set-cookie'}
return {key: value for key, value in headers.items() if key.lower() not in excluded_headers}
def _refresh_response_time(self, response_content: Any) -> Any:
"""
刷新项目统一响应体中的time字段
:param response_content: JSON响应内容
:return: 刷新time后的响应内容
"""
if not isinstance(response_content, dict):
return response_content
if {'code', 'msg', 'success', 'time'}.issubset(response_content):
refreshed_content = response_content.copy()
refreshed_content['time'] = datetime.now()
return refreshed_content
return response_content
class ApiCacheEvict(_ApiCacheSupport):
"""
接口缓存失效装饰器,用于在写操作成功后统一清理相关缓存
"""
def __init__(
self,
namespaces: Sequence[str] | None = None,
namespace_prefixes: Sequence[str] | None = None,
evict_response_codes: set[int] | None = None,
) -> None:
"""
初始化接口缓存失效装饰器
:param namespaces: 需要失效的缓存命名空间列表
:param namespace_prefixes: 需要失效的缓存命名空间前缀列表
:param evict_response_codes: 允许触发失效的业务响应码,默认为仅成功响应触发
"""
self.namespaces = tuple(dict.fromkeys(namespaces or ()))
self.namespace_prefixes = tuple(dict.fromkeys(namespace_prefixes or ()))
if not self.namespaces and not self.namespace_prefixes:
raise ValueError('ApiCacheEvict至少需要指定namespaces或namespace_prefixes')
self.evict_response_codes = (
evict_response_codes if evict_response_codes is not None else {HttpStatusConstant.SUCCESS}
)
def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""
为目标异步接口函数增加缓存失效能力
:param func: 需要在成功后触发缓存失效的异步接口函数
:return: 包装后的异步接口函数
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
result = await func(*args, **kwargs)
_, redis = ApiAnnotationUtil.resolve_request_redis(
func, '当前应用未初始化Redis连接跳过接口缓存失效', *args, **kwargs
)
if redis is None:
return result
if self._should_evict(result):
await self._evict_cache(redis)
return result
return wrapper
async def _evict_cache(self, redis: aioredis.Redis) -> None:
"""
根据配置统一执行接口缓存失效
:param redis: Redis连接对象
:return: None
"""
if self.namespaces:
await ApiCacheManager.clear_namespaces(redis, self.namespaces)
if self.namespace_prefixes:
await ApiCacheManager.clear_namespace_prefixes(redis, self.namespace_prefixes)
def _should_evict(self, result: Any) -> bool:
"""
判断当前响应是否应触发缓存失效
:param result: 原始接口返回结果
:return: 是否触发缓存失效
"""
return self._get_matched_response_content(result, self.evict_response_codes) is not None