渲染后端开发指南¶
这份文档回答的不是“backend 抽象是什么”,而是更实际的问题:
- 如果现在要在仓库里接入一个 backend,最小落地步骤是什么
- 哪些地方已经抽象好了,哪些地方仍然带有
playwright假设 - 什么时候只需要实现
Backend,什么时候还要继续改插件启动与资源链路
如果你还没看协议本身,先读 自定义 Backend 指南。
当前状态
当前仓库正式支持的 backend 仍然只有 playwright。skia、pillow、htmlkit 等枚举值代表公开扩展点,不代表已经存在可用实现。
先下结论¶
把目标 backend 接进当前仓库,至少要完成四件事:
- 给
RenderBackend增加明确枚举值 - 提供一个可注册的 backend 实现,并声明真实 capability
- 让工厂层能构建它,并能给出可诊断的 availability 信息
- 审查插件入口、资源预热、兼容层与测试分层里是否仍有
playwright特判
只有前两步完成,代码才“能编起来”。
四步都完成,才算真正可维护。
flowchart LR
A["RenderBackend enum"] --> B["backend package import"]
B --> C["register_backend(...)"]
C --> D["build_backend(...)"]
D --> E["Render runtime/session"]
E --> F["render_* API"]
C --> G["BackendAvailability"]
E --> H["BackendCapability"]
H --> F
开发前判断:你在接哪一类 backend¶
先别急着建目录。先判断目标 backend 属于哪一类:
典型是浏览器或 WebView 语义后端,能处理 HTML、Markdown、template、元素截图。
适合声明:
RENDER_CONTEXTHTML_RENDERTEXT_RENDERMARKDOWN_RENDERTEMPLATE_RENDERTEMPLATE_HTML_RENDERHTML_ELEMENT_CAPTURE
典型是 Skia / Pillow 一类,仅负责位图绘制,不天然具备 DOM、CSS、模板页面上下文。
适合声明:
RASTER_RENDER
这会直接决定 capability 应该如何声明:
- 如果没有页面上下文,就不要声明
RENDER_CONTEXT - 如果不能稳定支持 HTML/CSS 语义,就不要声明
HTML_RENDER - 如果只做位图输出,应优先考虑
RASTER_RENDER
不要为了让上层 API “看起来都能用”而虚报能力。Render 会按 capability 暴露入口,声明错了,错误就会延后到运行期。
| 判断问题 | 更可能的路线 |
|---|---|
| 是否需要 CSS layout、字体回退、DOM 查询和元素截图 | HTML engine backend |
| 是否只需要把结构化数据画成图片 | Raster backend |
| 是否需要复用 template / filehost / resource resolver | 通常是 HTML engine backend |
| 是否可以不创建页面上下文 | 通常是 Raster backend |
推荐目录骨架¶
当前 Playwright 实现放在 nonebot_plugin_htmlrender/backend/playwright/。
目标 backend 不要求复制同样的文件数,但建议保留相同的职责切分:
nonebot_plugin_htmlrender/backend/<backend_name>/
├── __init__.py
├── render.py
├── config.py # optional
├── models.py # optional
├── operations.py # optional
└── runtime.py # optional
最低要求通常只需要一个 render.py,其中包含:
- backend 类本身
- runtime / session 创建与关闭逻辑
- availability checker
register_backend(...)
当渲染动作已经开始积累复杂度,再把操作拆到 operations.py。
不要在第一步就为“将来可能有很多文件”做目录体操。
最小落地步骤¶
交付检查清单
-
RenderBackend已有稳定枚举值 - backend 模块会被导入并完成注册
- availability checker 不产生重型副作用
-
capabilities只声明真实能力 - runtime/session 能被
Render层统一关闭 - 工厂层、生命周期、能力映射测试已覆盖
- 插件启动、资源解析、兼容层中的
playwright特判已审查
1. 增加 backend 枚举¶
位置:nonebot_plugin_htmlrender/consts.py
把目标 backend 放进 RenderBackend,让配置层、工厂层和状态查询能引用同一标识。
class RenderBackend(StrEnum):
SKIA = "skia"
PLAYWRIGHT = "playwright"
PILLOW = "pillow"
HTMLKIT = "htmlkit"
MY_BACKEND = "my_backend"
这里的值会直接暴露给 .env / nonebot.init(...),所以名称要稳定、可读、不要带实现细节。
2. 实现 backend 类¶
位置建议:nonebot_plugin_htmlrender/backend/<backend_name>/render.py
最小骨架如下:
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from nonebot_plugin_htmlrender.backend.base import (
BackendCapability,
RenderRuntime,
RenderSession,
)
from nonebot_plugin_htmlrender.backend.factory import (
BackendAvailability,
register_backend,
)
from nonebot_plugin_htmlrender.consts import RenderBackend
class MyBackend:
backend = RenderBackend.MY_BACKEND
capabilities = frozenset(
{
BackendCapability.RASTER_RENDER,
}
)
def startup_steps(self) -> tuple:
return ()
async def create_runtime(self) -> RenderRuntime:
handle = object()
async def _aclose() -> None:
return None
return RenderRuntime(
backend=self.backend,
handle=handle,
_aclose=_aclose,
)
async def create_session(
self,
runtime: RenderRuntime,
**kwargs: object,
) -> RenderSession:
del kwargs
session_handle = object()
async def _aclose() -> None:
return None
return RenderSession(
runtime=runtime,
handle=session_handle,
_aclose=_aclose,
)
def is_alive(self, session: RenderSession) -> bool:
return session.handle is not None
@asynccontextmanager
async def get_render_context(
self,
session: RenderSession,
**kwargs: object,
) -> AsyncIterator[object]:
del session, kwargs
yield object()
def is_my_backend_available() -> BackendAvailability:
return BackendAvailability(available=True)
register_backend(
RenderBackend.MY_BACKEND,
MyBackend,
availability_checker=is_my_backend_available,
)
这里真正关键的不是样板代码,而是三个约束:
RenderRuntime和RenderSession必须都能独立关闭is_alive()必须表达真实可复用性,而不是永远返回Truecapabilities只填已经落地的能力
为什么 runtime 和 session 要拆开
RenderRuntime 表达 backend 级资源,例如驱动进程、全局连接或共享上下文。RenderSession 表达可复用的渲染会话,例如浏览器实例、渲染 worker 或图形上下文。拆开之后,Render 层才能在 session 失效时重建会话,同时保留仍然可用的 runtime。
3. 决定是否实现 HTML family¶
如果 backend 要支持:
render_htmlrender_textrender_markdownrender_templaterender_template_htmlcapture_html_element
那么它还必须满足 SupportsHtmlRenderBackend 这组方法契约。
这通常意味着你不仅要能“出图”,还要定义:
- 单次渲染上下文是什么
- 如何处理页面生命周期
- 模板渲染与资源解析放在哪一层
- 元素级截图是否存在等价语义
如果这些语义并不成立,就不要勉强套 HTML_RENDER 路线。
| 用户 API | backend 需要提供的语义 |
|---|---|
render_html |
解析 HTML/CSS 并输出截图 |
render_text |
把文本排版为可截图内容 |
render_markdown |
Markdown -> HTML -> image 的完整链路 |
render_template |
模板变量、资源解析、页面渲染和截图 |
render_template_html |
只输出模板渲染后的 HTML |
capture_html_element |
打开页面并定位指定元素截图 |
4. 提供 availability checker¶
位置:和 backend 注册代码放在一起即可。
BackendAvailability 的目的不是做健康监控,而是在真正启动前尽早回答:
- 依赖包是否存在
- 本地二进制或系统库是否存在
- 配置组合是否自洽
- 当前平台是否支持
推荐把“静态可判定”的失败都放在这里,例如:
- 缺少第三方依赖
- 引擎只支持 Linux,但当前平台是 macOS
- 必填配置项为空
不要把 availability checker 写成“尝试真正启动一次浏览器/进程”。
那会把轻量状态查询变成有副作用的 warmup。
availability 的边界
availability checker 适合回答“当前环境是否具备启动条件”。真正启动、连接和探测应交给 startup_steps()、create_runtime() 或 probe_render()。
5. 接入导入路径¶
仅仅写完 register_backend(...) 还不够。
你必须确保对应模块会被导入,否则注册逻辑不会执行。
当前仓库的正式实现通过包导入触发注册。接入 backend 时至少要检查:
- backend 模块是否会在插件导入链路中被 import
registered_render_backends()是否能看到它list_render_backend_statuses()是否能报告它的状态
如果模块永远没有被导入,那么 build_backend(...) 最终只会报“backend 未注册”。
当前仓库里的非对称接缝¶
这里是接入 backend 时最容易踩的坑。
协议层已经抽象了,但插件层还没有完全去掉 playwright 假设。
| 位置 | 当前假设 | 目标 backend 需要判断 |
|---|---|---|
__init__.py |
启动时只为 playwright 预热 filehost |
目标 backend 是否也需要 startup bootstrap |
_bootstrap.py |
import 时只为 playwright 安装 filehost guard |
目标 backend 是否复用 filehost guard |
resources/ |
资源解析主要服务 HTML/template 渲染 | 目标 backend 是否需要复用资源解析 |
_compat.py / browser.py |
历史接口偏向 Playwright 语义 | 旧接口是否应支持目标 backend |
插件启动阶段的 playwright 特判¶
位置:
nonebot_plugin_htmlrender/__init__.pynonebot_plugin_htmlrender/_bootstrap.py
当前行为里至少有这些分支只对 RenderBackend.PLAYWRIGHT 生效:
- filehost request guard 安装
- filehost runtime 预热
- shutdown 时清理 Playwright 环境变量
这意味着:
- 如果目标 backend 不依赖这些逻辑,可以保持不动
- 如果它也需要 import-time bootstrap 或 shutdown cleanup,就要为它补一条对称路径
- 如果它复用 filehost,但不走 Playwright backend,就需要重新审视资源预热应该挂在哪一层
资源解析链路目前偏向 HTML family¶
位置:
docs/maintainers/architecture/filehost-resource-resolution.mdnonebot_plugin_htmlrender/resources/nonebot_plugin_htmlrender/backend/playwright/operations.py
现在的资源解析与 filehost 协作主要围绕 template / page render 展开。
如果目标 backend 不是页面语义后端,这一层可能完全不需要;如果是 HTML engine,则需要判断:
- 是否复用现有
resources/解析规则 - 是否仍然使用 filehost 作为远程本地资源桥接
- 是否要提供新的 resource config provider
不要把资源解析硬塞进 backend 基类。它目前仍然是具体实现选择。
兼容层默认仍然偏向 Playwright¶
位置:
nonebot_plugin_htmlrender/_compat.pynonebot_plugin_htmlrender/browser.py
旧接口和历史路径目前主要复用 Playwright 语义。
如果目标 backend 只是主路径实验,不需要动兼容层;但如果你打算让旧接口也可切到目标 backend,就要明确回答:
- 旧 API 的行为语义是否还能成立
- 返回类型、截图模型、模板行为是否一致
- 是否会引入新的歧义或破坏现有迁移说明
默认建议:先把目标 backend 当作 API 能力接入,不要第一步就改兼容层。
什么时候需要加配置模型¶
如果 backend 有独立配置,建议放在:
backend/<backend_name>/config.py
保持标准和 Playwright 一致:配置项应该描述运行方式,不要把调试临时开关做成长期公开协议。判断边界可以按下面两类处理:
- backend 有自己的连接模式
- backend 需要安装路径、缓存目录、系统库路径
- backend 的配置项数量已经明显超出
render_backend单个选择器
- 只有 1 个布尔开关
- 只是临时实验,没有稳定的公开配置面
- 参数只用于测试或调试,不是用户需要理解的运行方式
测试建议¶
接入 backend 时,至少补工厂层、生命周期、能力映射三类测试。
如果 backend 还支持 HTML family,再补渲染行为测试,验证 HTML / Markdown / template / element capture 的真实语义,而不是只测“返回了 bytes”。
测试组织建议延续现有分层:
tests/backend/<backend_name>/...tests/render/...用于 capability 映射与公共 API 行为
不要把所有测试都塞进 tests/render/,否则很快又会回到“公共层知道太多具体实现细节”的旧问题。
| 测试层 | 主要问题 | 建议位置 |
|---|---|---|
| 工厂层 | 是否注册、是否可用、错误是否可诊断 | tests/backend/... |
| 生命周期 | runtime/session 是否正确创建、复用、关闭 | tests/backend/<backend_name>/... |
| 能力映射 | capability 是否映射到正确公共 API | tests/render/... |
| 渲染行为 | 输出语义是否符合 backend 承诺 | tests/backend/<backend_name>/... |
推荐实施顺序¶
如果你准备真的接入一个 backend,推荐按这个顺序推进:
- 加
RenderBackend枚举值 - 实现最小 backend 类与 availability checker
- 打通注册与导入链路
- 先补工厂层与生命周期测试
- 再决定是否实现 HTML family 或 raster-only 路线
- 最后处理 bootstrap、资源解析、兼容层这类外围接缝
这个顺序的意义是先把“后端存在且可诊断”建立起来,再处理周边系统。
不要反过来先碰兼容层和文档入口,那样会在主实现尚未稳定时引入更多临时分支。
何时不该接入 backend¶
以下情况通常不值得单独做一个 backend:
- 只是给 Playwright 增加一项 launch / connect 选项
- 只是调整模板资源解析策略
- 只是新增一种截图参数组合
这些更像是现有 backend 的配置扩展,而不是新的 backend 边界。
只有当“运行时资源模型、渲染语义或依赖栈已经明显不同”时,才值得建立独立 backend。