Skip to content

Commit b71179c

Browse files
authored
Add plugin system hook function mechanism (#1142)
* Enhance plugin system with lifespan registry, setup hooks, and patching utilities * fix: remove registrar.py unused-import * Optimize implementations * Update function docs * update replace_middleware func docs * Recover deleted comments
1 parent 41a64bf commit b71179c

4 files changed

Lines changed: 237 additions & 46 deletions

File tree

backend/common/lifespan.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from collections.abc import Callable
2+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
3+
from typing import Any
4+
5+
from fastapi import FastAPI
6+
7+
LifespanFunc = Callable[[FastAPI], AbstractAsyncContextManager[dict[str, Any] | None]]
8+
9+
10+
class LifespanManager:
11+
"""FastAPI lifespan 管理器"""
12+
13+
def __init__(self) -> None:
14+
self._lifespans: list[LifespanFunc] = []
15+
16+
def register(self, func: LifespanFunc) -> LifespanFunc:
17+
"""
18+
注册 lifespan hook
19+
20+
:param func: lifespan hook
21+
:return:
22+
"""
23+
if func not in self._lifespans:
24+
self._lifespans.append(func)
25+
return func
26+
27+
def build(self) -> LifespanFunc:
28+
"""
29+
构建组合后的 lifespan hook
30+
31+
:return:
32+
"""
33+
34+
@asynccontextmanager
35+
async def combined_lifespan(app: FastAPI): # noqa: ANN202
36+
state: dict[str, Any] = {}
37+
async with AsyncExitStack() as exit_stack:
38+
for lifespan_fn in self._lifespans:
39+
result = await exit_stack.enter_async_context(lifespan_fn(app))
40+
if isinstance(result, dict):
41+
state.update(result)
42+
43+
for key, value in state.items():
44+
setattr(app.state, key, value)
45+
46+
yield state or None
47+
48+
return combined_lifespan
49+
50+
51+
# 创建 lifespan_manager 单例
52+
lifespan_manager = LifespanManager()

backend/core/registrar.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from backend import __version__
1919
from backend.common.cache.pubsub import cache_pubsub_manager
2020
from backend.common.exception.exception_handler import register_exception
21+
from backend.common.lifespan import lifespan_manager
2122
from backend.common.log import set_custom_logfile, setup_logging
2223
from backend.common.observability.otel import init_otel
2324
from backend.common.response.response_code import StandardResponseCode
@@ -30,14 +31,15 @@
3031
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
3132
from backend.middleware.opera_log_middleware import OperaLogMiddleware
3233
from backend.middleware.state_middleware import StateMiddleware
33-
from backend.plugin.core import build_final_router
34+
from backend.plugin.core import build_final_router, setup_plugins
3435
from backend.utils.demo_mode import demo_site
3536
from backend.utils.openapi import ensure_unique_route_names, simplify_operation_ids
3637
from backend.utils.serializers import MsgSpecJSONResponse
3738
from backend.utils.snowflake import snowflake
3839
from backend.utils.trace_id import OtelTraceIdPlugin
3940

4041

42+
@lifespan_manager.register
4143
@asynccontextmanager
4244
async def register_init(app: FastAPI) -> AsyncGenerator[None, None]:
4345
"""
@@ -84,7 +86,7 @@ def register_app() -> FastAPI:
8486
redoc_url=settings.FASTAPI_REDOC_URL,
8587
openapi_url=settings.FASTAPI_OPENAPI_URL,
8688
default_response_class=MsgSpecJSONResponse,
87-
lifespan=register_init,
89+
lifespan=lifespan_manager.build(),
8890
)
8991

9092
# 注册组件
@@ -96,6 +98,9 @@ def register_app() -> FastAPI:
9698
register_page(app)
9799
register_exception(app)
98100

101+
# 初始化插件
102+
setup_plugins(app)
103+
99104
if settings.GRAFANA_METRICS_ENABLE:
100105
register_metrics(app)
101106

backend/plugin/core.py

Lines changed: 153 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
import json
23
import os
34
import warnings
@@ -8,10 +9,11 @@
89
import anyio
910
import rtoml
1011

11-
from fastapi import APIRouter, Depends, Request
12+
from fastapi import APIRouter, Depends, FastAPI, Request
1213

1314
from backend.common.enums import DataBaseType, PluginLevelType, PrimaryKeyType, StatusType
1415
from backend.common.exception import errors
16+
from backend.common.lifespan import lifespan_manager
1517
from backend.common.log import log
1618
from backend.core.conf import settings
1719
from backend.core.path_conf import PLUGIN_DIR
@@ -132,53 +134,130 @@ def load_plugin_config(plugin: str) -> dict[str, Any]:
132134
return rtoml.load(f)
133135

134136

137+
def get_plugin_enable(plugin_info: str | None, default_status: int) -> str:
138+
"""
139+
解析插件启用状态
140+
141+
:param plugin_info: 插件缓存信息
142+
:param default_status: 默认状态值
143+
:return:
144+
"""
145+
if not plugin_info:
146+
return str(default_status)
147+
148+
try:
149+
return json.loads(plugin_info)['plugin']['enable']
150+
except Exception:
151+
return str(default_status)
152+
153+
154+
def get_enabled_plugins(plugins: tuple[str, ...] | None = None) -> set[str]:
155+
"""
156+
获取已启用的插件列表
157+
158+
:param plugins: 插件名称列表
159+
:return:
160+
"""
161+
plugin_names = plugins or get_plugins()
162+
enabled_plugins = set(plugin_names)
163+
164+
current_redis_client = RedisCli()
165+
run_await(current_redis_client.init)()
166+
167+
try:
168+
for plugin in plugin_names:
169+
plugin_info = run_await(current_redis_client.get)(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
170+
if get_plugin_enable(plugin_info, StatusType.enable.value) != str(StatusType.enable.value):
171+
enabled_plugins.discard(plugin)
172+
finally:
173+
run_await(current_redis_client.aclose)()
174+
175+
return enabled_plugins
176+
177+
178+
def register_plugin_lifespan_hook(plugin: str, module: Any) -> None:
179+
"""
180+
注册插件 lifespan hook
181+
182+
:param plugin: 插件名称
183+
:param module: 插件 hooks 模块
184+
:return:
185+
"""
186+
lifespan_hook = getattr(module, 'lifespan', None)
187+
if lifespan_hook is None:
188+
return
189+
190+
if not callable(lifespan_hook):
191+
log.warning(f'插件 {plugin} 的 lifespan 不是可调用对象,已跳过')
192+
return
193+
194+
lifespan_manager.register(lifespan_hook)
195+
log.info(f'插件 {plugin} lifespan hook 注册成功')
196+
197+
198+
def run_plugin_startup_hook(plugin: str, module: Any, app: FastAPI) -> None:
199+
"""
200+
执行插件 startup hook
201+
202+
:param plugin: 插件名称
203+
:param module: 插件 hooks 模块
204+
:param app: FastAPI 应用实例
205+
:return:
206+
"""
207+
setup_hook = getattr(module, 'setup', None)
208+
if setup_hook is None:
209+
return
210+
211+
if not callable(setup_hook):
212+
log.warning(f'插件 {plugin} 的 setup 不是可调用对象,已跳过')
213+
return
214+
215+
setup_result = setup_hook(app)
216+
if inspect.isawaitable(setup_result):
217+
run_await(lambda: setup_result)() # type: ignore
218+
log.info(f'插件 {plugin} startup hook 执行成功')
219+
220+
135221
def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
136222
"""解析插件配置"""
137223
extend_plugins = []
138224
app_plugins = []
139-
140225
plugins = get_plugins()
141226

142227
# 使用独立连接
143228
current_redis_client = RedisCli()
144229
run_await(current_redis_client.init)()
145230

146-
# 清理未知插件信息
147-
exclude_keys = [f'{settings.PLUGIN_REDIS_PREFIX}:{key}' for key in plugins]
148-
run_await(current_redis_client.delete_prefix)(
149-
settings.PLUGIN_REDIS_PREFIX,
150-
exclude=exclude_keys,
151-
)
152-
153-
for plugin in plugins:
154-
data = load_plugin_config(plugin)
155-
plugin_type = validate_plugin_config(plugin, data)
156-
157-
if plugin_type == PluginLevelType.extend:
158-
extend_plugins.append(data)
159-
else:
160-
app_plugins.append(data)
161-
162-
# 补充插件信息
163-
data['plugin']['name'] = plugin
164-
plugin_cache_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
165-
plugin_cache_info = run_await(current_redis_client.get)(plugin_cache_key)
166-
if plugin_cache_info:
167-
try:
168-
data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable']
169-
except Exception:
170-
data['plugin']['enable'] = str(StatusType.enable.value)
171-
else:
172-
data['plugin']['enable'] = str(StatusType.enable.value)
173-
174-
# 缓存最新插件信息
175-
run_await(current_redis_client.set)(plugin_cache_key, json.dumps(data, ensure_ascii=False))
176-
177-
# 重置插件变更状态
178-
run_await(current_redis_client.delete)(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
179-
180-
# 关闭连接
181-
run_await(current_redis_client.aclose)()
231+
try:
232+
# 清理未知插件信息
233+
exclude_keys = [f'{settings.PLUGIN_REDIS_PREFIX}:{key}' for key in plugins]
234+
run_await(current_redis_client.delete_prefix)(
235+
settings.PLUGIN_REDIS_PREFIX,
236+
exclude=exclude_keys,
237+
)
238+
239+
for plugin in plugins:
240+
plugin_config = load_plugin_config(plugin)
241+
plugin_type = validate_plugin_config(plugin, plugin_config)
242+
243+
if plugin_type == PluginLevelType.extend:
244+
extend_plugins.append(plugin_config)
245+
else:
246+
app_plugins.append(plugin_config)
247+
248+
# 补充插件信息
249+
plugin_config['plugin']['name'] = plugin
250+
plugin_cache_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
251+
plugin_cache_info = run_await(current_redis_client.get)(plugin_cache_key)
252+
plugin_config['plugin']['enable'] = get_plugin_enable(plugin_cache_info, StatusType.enable.value)
253+
254+
# 缓存最新插件信息
255+
run_await(current_redis_client.set)(plugin_cache_key, json.dumps(plugin_config, ensure_ascii=False))
256+
257+
# 重置插件变更状态
258+
run_await(current_redis_client.delete)(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
259+
finally:
260+
run_await(current_redis_client.aclose)()
182261

183262
return extend_plugins, app_plugins
184263

@@ -288,6 +367,41 @@ def build_final_router() -> APIRouter:
288367
return main_router
289368

290369

370+
def setup_plugins(app: FastAPI) -> None:
371+
"""
372+
注册并执行插件 hooks
373+
374+
:param app: FastAPI 应用实例
375+
:return:
376+
"""
377+
plugins = get_plugins()
378+
enabled_plugins = get_enabled_plugins(plugins)
379+
380+
for plugin in plugins:
381+
if plugin not in enabled_plugins:
382+
log.info(f'插件 {plugin} 未启用,已跳过 hooks 注册与执行')
383+
continue
384+
385+
module_path = f'backend.plugin.{plugin}.hooks'
386+
try:
387+
module = import_module_cached(module_path)
388+
except ModuleNotFoundError as e:
389+
if e.name == module_path:
390+
# 未定义 hooks.py
391+
continue
392+
log.warning(f'插件 {plugin} hooks 模块加载失败: {e}')
393+
continue
394+
except Exception as e:
395+
log.warning(f'插件 {plugin} hooks 模块加载失败: {e}')
396+
continue
397+
398+
try:
399+
register_plugin_lifespan_hook(plugin, module)
400+
run_plugin_startup_hook(plugin, module, app)
401+
except Exception as e:
402+
log.error(f'插件 {plugin} hooks 执行失败: {e}')
403+
404+
291405
class PluginStatusChecker:
292406
"""插件状态检查器"""
293407

@@ -312,10 +426,5 @@ async def __call__(self, request: Request) -> None:
312426
log.error('插件状态未初始化或丢失,需重启服务自动修复')
313427
raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员')
314428

315-
try:
316-
is_enabled = int(json.loads(plugin_info)['plugin']['enable'])
317-
except Exception:
318-
is_enabled = 0
319-
320-
if not is_enabled:
429+
if get_plugin_enable(plugin_info, StatusType.disable.value) != str(StatusType.enable.value):
321430
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')

backend/plugin/patching.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from fastapi import FastAPI
2+
from starlette.middleware import Middleware
3+
4+
5+
def replace_middleware(
6+
app: FastAPI,
7+
original_middleware_cls: type,
8+
replacement_middleware_cls: type,
9+
**replacement_kwargs,
10+
) -> None:
11+
"""
12+
替换中间件(应在插件的 startup hook 中调用)
13+
14+
:param app: FastAPI 应用实例
15+
:param original_middleware_cls: 原始中间件类
16+
:param replacement_middleware_cls: 替换后的中间件类
17+
:param replacement_kwargs: 传给替换后中间件的初始化参数
18+
:return:
19+
"""
20+
for index, middleware in enumerate(app.user_middleware):
21+
if middleware.cls is original_middleware_cls:
22+
app.user_middleware[index] = Middleware(replacement_middleware_cls, **replacement_kwargs)
23+
return
24+
25+
raise ValueError(f'{original_middleware_cls.__name__} not found in app.user_middleware')

0 commit comments

Comments
 (0)