模組開發最佳實踐
本文檔提供了 ErisPulse 模組開發的最佳實踐建議。
模組設計
1. 單一職責原則
每個模組應該只負責一個核心功能:
# 好的設計:每個模組只負責一個功能
class WeatherModule(BaseModule):
"""天氣查詢模組"""
pass
class NewsModule(BaseModule):
"""新聞查詢模組"""
pass
# 不好的設計:一個模組負責多個不相關的功能
class UtilityModule(BaseModule):
"""包含天氣、新聞、笑話等多個功能"""
pass
2. 模組命名規範
[project]
name = "ErisPulse-ModuleName" # 使用 ErisPulse- 前綴
3. 清晰的配置管理
推薦使用宣告式配置(ConfigClass + BaseConfig),獲得類型安全、自動模板生成、WebUI 表單支援等能力:
from dataclasses import dataclass, field
from ErisPulse.Core.Bases import BaseConfig
@dataclass
class MyModuleConfig(BaseConfig):
api_url: str = field(default="https://api.example.com", metadata={
"description": {"i18n": "my_module.api_url", "default": "API 位址"},
})
timeout: int = field(default=30, metadata={
"description": {"i18n": "my_module.timeout", "default": "超時時間(秒)"},
})
cache_ttl: int = field(default=3600, metadata={
"description": {"i18n": "my_module.cache_ttl", "default": "快取存活時間(秒)"},
})
class MyModule(BaseModule):
ConfigClass = MyModuleConfig
async def do_something(self):
cfg = self.cfg # 類型安全,即時讀取
await self._fetch(cfg.api_url, timeout=cfg.timeout)
也可以繼續使用手動方式讀取和寫入配置儲存(見模組核心概念)。
宣告式翻譯鍵(v2.7.0+)
模組可以透過 I18nClass 集中宣告翻譯鍵,框架自動註冊到 i18n 系統,無需手動呼叫 i18n.register()。
from ErisPulse.Core.Bases import BaseI18n, I18nKey
class MyModule(BaseModule):
class I18nClass(BaseI18n):
# 帶占位符的業務翻譯鍵
welcome: I18nKey = I18nKey(
default="Welcome, {name}!",
zh_CN="歡迎你,{name}!",
zh_TW="歡迎你,{name}!",
en="Welcome, {name}!",
ja="ようこそ、{name}!",
ru="Добро пожаловать, {name}!",
)
# 配置欄位描述的翻譯
api_url: I18nKey = I18nKey(
default="API URL",
zh_CN="API 位址",
zh_TW="API 位址",
en="API URL",
ja="API URL",
ru="API URL",
)
詳細用法見 i18n 文檔。
異步編程
1. 使用異步庫
# 推薦使用 SDK 內建 HTTP 客戶端(異步,自動日誌和統計)
from ErisPulse.Core import client
class MyModule(BaseModule):
async def fetch_data(self, url):
resp = await client.get(url)
return await resp.json()
# 也可透過 sdk.client 使用(效果相同)
from ErisPulse import sdk
class MyModule(BaseModule):
async def fetch_data(self, url):
resp = await sdk.client.get(url)
return await resp.json()
# 不要使用 aiohttp 直接導入(不便於框架統一管理)
import aiohttp
class MyModule(BaseModule):
async def fetch_data(self, url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.json()
# 不要使用 requests(同步,會阻塞事件循環)
import requests
class MyModule(BaseModule):
def fetch_data(self, url):
return requests.get(url).json() # 會阻塞事件循環
2. 正確的異步操作
from ErisPulse.Core.Event import Event # event: Event 注解可獲得 IDE 补全
async def handle_command(self, event: Event):
# 需要等待結果的耗時操作:直接 await(生命周期明確)
result = await self._long_operation()
async def on_load(self, event: dict):
# 後台任務(輪詢/定時/fire-and-forget):用 self.spawn(),
# 模組卸載時框架在 on_unload 之後兜底取消,避免持有 self 導致泄漏
self.spawn(self._poll())
Note
後台任務推薦 self.spawn()(ErisPulse 2.8.0+)。2.8.3 起裸 asyncio.create_task
也會自動隱式歸屬模組(Task Factory 自動登記,卸載時兜底取消,不再泄漏 self 引用);
self.spawn() 仍是推薦寫法——支援非主循環線程調度回主循環、顯式 owner= 指定。
2.8.3 之前的版本裸任務不歸屬、會持有 self 引用導致模組實例無法被回收
(熱重載泄漏),必須用 self.spawn()。詳見 生命週期管理。
3. 資源管理
async def on_load(self, event):
# SDK 客戶端已自動管理連接池,無需手動建立 session
pass
async def on_unload(self, event):
# 如需自訂客戶端,記得清理資源
pass
事件處理
1. 使用 Event 包裝類
# 使用 Event 包裝類的便捷方法
@command("info")
async def info_command(event: Event):
user_id = event.get_user_id()
nickname = event.get_user_nickname()
await event.reply(f"你好,{nickname}!")
# 而非直接存取字典
@command("info")
async def info_command(event: Event):
user_id = event["user_id"] # 不夠清晰,容易出錯
2. 合理使用懶加載
# 低頻命令模組:宣告 activate_on 觸發器,首個匹配命令到達時自動激活(保持懶加載)
class CommandModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True, activate_on=[
{"command": {"name": "dice", "help": "擲一個骰子", "aliases": ["d"]}},
])
# 低頻監聽器模組:宣告事件觸發器,事件到達時自動激活
class ListenerModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True, activate_on=[
{"notice": "group_member_increase"},
])
# 高頻觸發(每條訊息都要處理)或啟動時就必須就緒的模組:立即加載
class HotListenerModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=False)
# 工具模組適合懶加載
class UtilityModule(BaseModule):
@staticmethod
def get_load_strategy():
return ModuleLoadStrategy(lazy_load=True)
activate_on的完整語法(事件三形式 / 命令簡寫與 dict 聲明 / help 回退鏈)見 懶加載模組系統。
3. 事件處理器註冊
async def on_load(self, event):
# 在 on_load 中註冊事件處理器
@command("hello")
async def hello_handler(event: Event):
await event.reply("你好!")
@message.on_group_message()
async def group_handler(event: Event):
self.logger.info("收到群訊息")
# 不需要手動註銷,框架會自動處理
工具模組:托管别人东西时要接住"卸载通知"
什么时候需要:你的模組替其他模組保管东西(定時回調、訂閱者、連接、快取條目……)。這些引用在對方模組卸載後如果一直不丟棄,對方實例就永遠無法被回收——這是工具模組最常見的記憶體泄漏來源。
from ErisPulse.Core.Bases import BaseModule
from ErisPulse.runtime import off_cleanup, on_cleanup
class MyToolModule(BaseModule):
def __init__(self):
self._entries = {} # {模組名: 托管的東西}
def register(self, entry):
owner = on_cleanup(self._drop) # ① 登記時掛入清理鏈,自動識別呼叫方
self._entries.setdefault(owner, []).append(entry)
def _drop(self, owner: str):
self._entries.pop(owner, None) # ② 對方卸載時框架自動呼叫:丟棄它的东西
async def on_unload(self, event):
off_cleanup(self._drop) # ③ 自己卸載前註銷鉤子
就這麼多,框架保證:
- 對方模組被卸載 / 禁用(或適配器關閉)時,
_drop("對方模組名")一定會被呼叫 - 呼叫方識別全自動:對方在
on_load裡直接調sdk.MyToolModule.register(...),或經sdk.module.call("MyToolModule", "register", ...)調用,都能正確識別是誰 - 不用操心時機——鉤子在框架清理鏈內觸發,早於泄漏診斷,不會誤報
不接入的後果:對方 purge 彻底卸載時實例無法回收(泄漏診斷報"不可回收");若對方自己也不在 on_unload 裡向你註銷,泄漏就是永久性的。
普通模組(不托管别人东西)不需要關心這個——框架資源(命令 / 處理器 / 路由 / 後台任務……)的卸載清理是全自動的。
觸發時機、呼叫方識別規則、超時與容錯等細節見 歸屬權系統 · 工具模組指南。
錯誤處理
1. 分類異常處理
from ErisPulse.Core.Bases.errors import ClientError
async def handle_event(self, event: Event):
try:
result = await self._process(event)
except ValueError as e:
# 預期的業務錯誤
self.logger.warning(f"業務警告: {e}")
await event.reply(f"參數錯誤: {e}")
except ClientError as e:
# 網路錯誤(sdk.client 的底層 aiohttp 異常已自動轉換)
self.logger.error(f"網路錯誤 {e.method} {e.url}: {e}")
await event.reply("網路請求失敗,請稍後重試")
except Exception as e:
# 未預期的錯誤
self.logger.error(f"未知錯誤: {e}", exc_info=True)
await event.reply("處理失敗,請聯絡管理員")
raise
2. 超時處理
# 推薦使用 SDK 內建客戶端(自帶超時和重試)
from ErisPulse.Core import client
from ErisPulse.Core.Bases.errors import ClientTimeoutError
async def fetch_with_timeout(self, url, timeout=30):
try:
resp = await client.get(url, timeout=timeout)
return await resp.json()
except ClientTimeoutError:
self.logger.warning(f"請求超時: {url}")
raise
存儲系統
1. 使用事務
# 使用事務確保資料一致性
async def update_user(self, user_id, data):
with self.sdk.storage.transaction():
self.sdk.storage.set(f"user:{user_id}:profile", data["profile"])
self.sdk.storage.set(f"user:{user_id}:settings", data["settings"])
# ❌ 不使用事務可能導致資料不一致
async def update_user(self, user_id, data):
self.sdk.storage.set(f"user:{user_id}:profile", data["profile"])
# 如果這裡出錯,上面的設定無法回滾
self.sdk.storage.set(f"user:{user_id}:settings", data["settings"])
2. 批量操作
# 使用批量操作提高效能
def cache_multiple_items(self, items):
self.sdk.storage.set_multi({
f"item:{k}": v for k, v in items.items()
})
# ❌ 多次呼叫效率低
def cache_multiple_items(self, items):
for k, v in items.items():
self.sdk.storage.set(f"item:{k}", v)
日誌記錄
1. 合理使用日誌等級
# DEBUG: 詳細的除錯資訊(僅開發時)
self.logger.debug(f"輸入參數: {params}")
# INFO: 正常運行資訊
self.logger.info("模組已加載")
self.logger.info(f"處理請求: {request_id}")
# WARNING: 警告資訊,不影響主要功能
self.logger.warning(f"配置項 {key} 未設定,使用預設值")
self.logger.warning("API 回應慢,可能需要優化")
# ERROR: 錯誤資訊
self.logger.error(f"API 請求失敗: {e}")
self.logger.error(f"處理事件失敗: {e}", exc_info=True)
# CRITICAL: 致命錯誤,需要立即處理
self.logger.critical("資料庫連線失敗,機器人無法正常運行")
2. 構造化日誌
# 使用構造化日誌,便於解析
self.logger.info(f"處理請求: request_id={request_id}, user_id={user_id}, duration={duration}ms")
# ❌ 使用非構造化日誌
self.logger.info(f"處理請求了,來自使用者 {user_id},用時 {duration} 毫秒")
性能優化
1. 使用快取
class MyModule(BaseModule):
def __init__(self):
self._cache = {}
self._cache_lock = asyncio.Lock()
async def get_data(self, key):
async with self._cache_lock:
if key in self._cache:
return self._cache[key]
# 從資料庫獲取
data = await self._fetch_from_db(key)
# 快取資料
self._cache[key] = data
return data
2. 避免阻塞操作
# 使用異步操作
async def process_message(self, event: Event):
# 異步處理
await self._async_process(event)
# ❌ 阻塞操作
async def process_message(self, event: Event):
# 同步操作,阻塞事件迴圈
result = self._sync_process(event)
安全性
1. 敏感資料保護
# 敏感資料儲存在配置中(宣告式 ConfigClass,secret 欄位不進入日誌/導出)
from dataclasses import dataclass, field
from ErisPulse.Core.Bases import BaseModule, BaseConfig
@dataclass
class MyModuleConfig(BaseConfig):
api_key: str = field(
default="",
metadata={"description": "API 密鑰", "secret": True},
)
class MyModule(BaseModule):
ConfigClass = MyModuleConfig
def check_api_key(self):
if not self.cfg.api_key or self.cfg.api_key == "YOUR_API_KEY_HERE":
raise ValueError("請在 config.toml 中設定有效的 API 密鑰")
# ❌ 敏感資料硬編碼
class MyModule(BaseModule):
API_KEY = "sk-1234567890" # 不要這樣做!
2. 輸入驗證
# 驗證使用者輸入
async def process_command(self, event: Event):
user_input = event.get_text()
# 驗證輸入長度
if len(user_input) > 1000:
await event.reply("輸入過長,請重新輸入")
return
# 驗證輸入格式
if not re.match(r'^[a-zA-Z0-9]+$', user_input):
await event.reply("輸入格式不正確")
return
測試
1. 單元測試
import pytest
from ErisPulse.Core.Bases import BaseModule
class TestMyModule:
def test_config_defaults(self):
"""測試配置預設值"""
config = MyModule.ConfigClass()
assert config.timeout == 30
2. 集成測試
@pytest.mark.asyncio
async def test_command_handling():
"""測試命令處理"""
module = MyModule()
await module.on_load({})
# 模擬命令事件
event = create_test_command_event("hello")
await module.handle_command(event)
部署
1. 版本管理
[project]
name = "ErisPulse-MyModule"
version = "1.0.0"
遵循語意化版本:
- MAJOR.MINOR.PATCH
- 主版本:不相容的 API 變更
- 次版本:向下相容的功能新增
- 修訂號:向下相容的問題修正
2. README 頭部
epsdk create 生成的 README 已內建 ErisPulse 頭部標識(Logo + 徽章行)。兩種推薦模式:
模式 A — 僅 ErisPulse Logo(預設):
<div align="center">
<img src="https://raw.githubusercontent.com/ErisPulse/ErisPulse/main/.github/assets/ErisPulseLogo.png" width="180" alt="MyModule" />
# MyModule
**一句話描述**
<p>
<a href="https://pypi.org/project/ErisPulse-MyModule/"><img src="https://img.shields.io/pypi/v/ErisPulse-MyModule?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
<a href="https://pypi.org/project/ErisPulse-MyModule/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
</p>
</div>
模式 B — 模組圖標 × ErisPulse Logo(有自訂圖標時):
<div align="center">
<img src=".github/assets/MyModuleIcon.svg" width="120" alt="MyModule" />
<span style="font-size:44px;color:#c8c8c8;margin:0 18px;vertical-align:middle;">×</span>
<img src="https://raw.githubusercontent.com/ErisPulse/ErisPulse/main/.github/assets/ErisPulseLogo.png" height="120" alt="ErisPulse" />
# MyModule
(徽章行同上)
</div>
可按需追加 GitHub Stars、Downloads 等徽章。Logo 也可下載到專案本地(.github/assets/ErisPulseLogo.png)改為相對路徑引用。