Files
MoviePilot/docs/rules/04-design-patterns.md
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

308 lines
11 KiB
Markdown
Raw Permalink 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.
# 04 — Design Patterns
This document defines the structural patterns used across this codebase. When implementing complex features, you are required to use these patterns rather than inventing new abstractions.
---
## 1. Module Pattern (Pluggable Backends)
**When to use:** Adding a new downloader, media server, message channel, storage backend, or any other capability that requires lifecycle management, configuration switches, priority ordering, or independent testing.
**Base class:** `_ModuleBase` in `app/modules/__init__.py`
**Specialized base classes:**
- `_DownloaderBase` — for download clients
- `_MediaServerBase` — for media servers (implied by existing patterns)
**Required methods every module must implement:**
```python
class ExampleModule(_ModuleBase, _DownloaderBase):
"""
展示下载器模块的注册、配置开关与生命周期接口。
"""
def init_module(self) -> None:
"""
初始化本模块管理的下载器服务。
"""
super().init_service(service_name=..., service_type=...)
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
声明启用本下载器模块所需的配置匹配条件。
:return: 配置项 DOWNLOADER 及其启用值 example
"""
return "DOWNLOADER", "example"
@staticmethod
def get_name() -> str:
"""
提供模块的展示名称。
:return: 示例下载器的名称
"""
return "Example"
@staticmethod
def get_type() -> ModuleType:
"""
声明模块所属的能力分类。
:return: 下载器模块分类
"""
return ModuleType.Downloader
@staticmethod
def get_subtype() -> DownloaderType:
"""
声明下载器的具体类型。
:return: 示例下载器对应的类型枚举
"""
return DownloaderType.Example
@staticmethod
def get_priority() -> int:
"""
提供模块调度的排序优先级。
:return: 示例优先级 1,数值越小越先调用
"""
return 1
def test(self) -> Optional[Tuple[bool, str]]:
"""
检查已配置下载器的连接状态。
:return: 检测结果与说明组成的二元组;未启用检测时返回 None
"""
...
def stop(self):
"""
结束模块生命周期;本示例没有需要释放的资源。
"""
pass
```
**Module directory convention:** `app/modules/<backend_name>/` containing at minimum `__init__.py` (the module class) and the implementation class.
**Module types** are defined in `app/schemas/types.py` as `ModuleType`, `DownloaderType`, `MediaServerType`, `MessageChannel`, `StorageSchema`, `OtherModulesType`. When adding a new category, update these enums.
---
## 2. Chain Orchestration Pattern
**When to use:** Adding a new business workflow that is shared across multiple entrypoints (API endpoint, CLI, agent, scheduler, webhook). Chains coordinate modules, helpers, databases, events, and caches.
**Base class:** `ChainBase` in `app/chain/base.py`
**Calling modules from a chain:**
```python
# 通过模块调度接口调用,避免链直接依赖具体模块实例
result = self.run_module("method_name", kwarg1=val1, kwarg2=val2)
result = await self.async_run_module("method_name", kwarg1=val1)
# 仅在枚举模块、查看实例或检查健康状态时直接使用 ModuleManager
```
**Chain-to-chain calls:** A chain may call another chain to reuse stable domain logic. Avoid introducing new circular dependencies between chains.
**File convention:** small workflows use `app/chain/<domain>.py`; a stable workflow with several independently owned responsibilities uses the same-named `app/chain/<domain>/` package. The package root lazily exposes only `<Domain>Chain`, `facade.py` preserves its public identity, and focused child modules use single-word responsibility names. The retired monolith and `source.py` copies must not coexist with the package.
---
## 3. Event / Observer Pattern
**When to use:** Triggering cross-cutting reactions (e.g., notifying the media server after a transfer completes, reloading a module after config changes, dispatching user messages to message channels).
**Core classes:** `EventManager` (singleton instance `eventmanager`) and `Event` in `app/runtime/events.py`.
**Registering a handler:**
```python
from app.runtime.events import eventmanager, Event
from app.schemas.types import EventType
@eventmanager.register(EventType.TransferComplete)
def on_transfer_complete(self, event: Event):
"""
接收整理完成事件并读取本次整理结果。
:param event: 事件对象,event_data 保存整理完成后的业务数据
"""
event_data = event.event_data
...
```
**Sending an event:**
```python
eventmanager.send_event(EventType.TransferComplete, data_dict)
```
**Event types** are defined as `EventType` and `ChainEventType` enums in `app/schemas/types.py`. Add new event types there when extending the event system.
---
## 4. Repository (Oper) Pattern
**When to use:** Implementing table-oriented persistence behind an Application-owned
Protocol. Never issue SQLAlchemy queries or construct Oper objects directly from
chain, module, endpoint, Agent, scheduler, or workflow code.
**Convention:** Each SQLAlchemy model in `app/db/models/` has a corresponding `<Model>Oper` class in `app/db/oper/<model>.py` — the two packages mirror each other file for file, so the module name carries the entity and the package carries the role.
```
app/db/models/subscribe.py → app/db/oper/subscribe.py (SubscribeOper)
app/db/models/systemconfig.py → app/db/oper/systemconfig.py (SystemConfigOper)
app/db/models/transferhistory.py → app/db/oper/transferhistory.py (TransferHistoryOper)
```
**Host usage:**
Host entrypoints depend on an Application service or Protocol. The concrete
`app/db/adapters/` implementation creates one operation-scoped Session, adapts the
Protocol with Oper objects, and gives commit/rollback ownership to the Application
command. Oper methods only query, stage, or flush in that caller-owned Session.
```python
from app.application.subscription.write import SubscribeWriter
def create_subscription(writer: SubscribeWriter, identity: dict, payload: dict):
"""
通过应用写入服务创建订阅。
:param writer: 已完成依赖注入的订阅写入服务
:param identity: 订阅媒体身份,用于确定订阅对象
:param payload: 本次提交的订阅配置
:return: 写入服务返回的创建结果,保留其成功或失败信息
"""
return writer.add(identity=identity, payload=payload)
```
The explicit Session/Oper composition belongs in `app/db/adapters/`, not at the
host call site:
```python
with SessionFactory() as session:
command = CreateSubscriptionCommand(
repository=SubscribeOper(session),
unit_of_work=SqlAlchemyUnitOfWork(session),
)
result = command.execute(identity=identity, payload=payload)
```
The no-Session `SubscribeOper()` facade is legacy plugin ABI only. It may remain
available through the curated SDK/Compat boundary, but new host code and examples
must not copy that form.
---
## 5. Config Reload Pattern
**When to use:** A chain, module, or helper holds a long-lived object that must be rebuilt when specific configuration keys change (e.g., a downloader client reconnects when its host/port changes).
**Mixin:** `ConfigReloadMixin` in `app/runtime/reload.py`
**How it works:**
1. Inherit `ConfigReloadMixin`.
2. Define a `CONFIG_WATCH` class attribute as a set of config key names.
3. Implement `on_config_changed()` — called automatically when any watched key changes.
4. Optionally implement `get_reload_name()` to provide a descriptive name for log messages.
```python
class MyChain(ChainBase, ConfigReloadMixin):
"""
在下载器配置变化后重新初始化模块。
:cvar CONFIG_WATCH: 触发重新初始化的下载器类型、地址和端口配置项
"""
CONFIG_WATCH = {"DOWNLOADER", "QB_HOST", "QB_PORT"}
def on_config_changed(self):
"""
在受监控的配置项变化后刷新模块实例。
"""
self.init_module()
```
`_ModuleBase` already inherits `ConfigReloadMixin` and calls `init_module()` from `on_config_changed()` by default. Modules typically only need to declare `CONFIG_WATCH`.
---
## 6. Singleton Pattern
**When to use:** Classes that must have exactly one instance shared application-wide (e.g., `EventManager`, `ModuleManager`, `PluginManager`).
**Implementation:** Inherit from `Singleton` in `app/foundation/singleton.py`.
```python
from app.foundation.singleton import Singleton
class MyManager(metaclass=Singleton):
"""
展示进程内共享同一管理器实例的声明方式。
"""
...
```
Do not introduce new singletons unless the class genuinely manages global shared state. Prefer dependency injection or parameter passing for everything else.
---
## 7. SystemConfig Pattern
**When to use:** Storing runtime business configuration that is user-editable, persistent across restarts, and not tied to a specific deployment environment.
**Enum:** `SystemConfigKey` in `app/schemas/types.py`
**Host service:** `SystemConfigService` and `get_configured_system_config()` in
`app/application/configuration.py`.
```python
from app.application.configuration import get_configured_system_config
from app.schemas.types import SystemConfigKey
configuration = get_configured_system_config()
value = configuration.get(SystemConfigKey.RssUrls)
configuration.set(SystemConfigKey.RssUrls, ["https://..."])
```
`SystemConfigOper` is the DB/composition implementation and legacy plugin-facing
facade; canonical host consumers do not construct it.
**Rule:** Never use raw string literals as SystemConfig keys. Always add a new entry to the `SystemConfigKey` enum first.
---
## 8. UserConfig Pattern
**When to use:** Per-user settings that must survive across sessions but differ by user.
**Host service:** `UserConfigurationService` and
`get_configured_user_configuration()` in
`app/application/security/userconfig.py`.
The concrete repository uses `UserConfigOper` behind the service boundary. Host
callers do not construct it.
---
## Anti-Patterns to Avoid
| Anti-Pattern | Correct Alternative |
|---|---|
| `module -> chain` coupling | Move orchestration into `chain` and shared logic into its owning canonical package |
| `module -> module` direct calls | Use `chain` to orchestrate cross-module workflows |
| Lower-level module importing a chain or manager | Register a callback/resolver from `app/startup/` or move orchestration to `chain` |
| Raw SQLAlchemy queries or Oper construction in host entrypoints | Define/use an Application Protocol or command; implement it in `app/db/adapters/` with an operation-scoped Session/UoW |
| Raw string keys for SystemConfig | Define and use a `SystemConfigKey` enum entry |
| HTTP requests via `requests` or `httpx` directly | Host code uses `RequestUtils` from `app/adapters/network/http.py`; plugins use `app.sdk.network` |
*Last Updated: 2026-08-27*