进程外插件
进程外插件是宿主启动的子进程,经本机 gRPC 按协议 xihan.plugin.v1 与宿主通信。这一页讲协议的组成、握手与生命周期、插件声明的操作如何成为宿主的代理操作并经完整管道执行、事件投递、回调、失败降级,然后用 Go SDK 写一个能跑的插件,最后讲远程 Provider 与配置节。读完你能写一个任意语言的插件并接进 .NET 宿主。前置知识是隔离级别与清单。
为什么需要
进程内扩展与宿主同生共死:一个未处理异常让整个应用退出,依赖冲突在加载时才暴露,而且只能用 .NET 写。把插件放进独立进程后,崩溃只影响插件本身,宿主按退避重启它;插件带自己的依赖;只要有 protobuf、gRPC 与 JSON 的语言都能实现。框架把这套约定写成规范 RFC-0017,.NET 宿主 Plugins.OutOfProcess 与 Go SDK 是它的两个实现,一致性套件保证不同语言的插件行为一致。
协议 xihan.plugin.v1 概述
协议定义在规范仓库的 plugin/plugin.proto,分两个服务:
| 服务 | 方法 | 用途 |
|---|---|---|
Plugin(宿主调插件) | Handshake | 协商协议版本、核对原语层大版本与清单,交换操作声明(契约与 JSON Schema)、订阅与消息目录 |
Configure / Start / Stop | 生命周期;Configure 下发清单声明的配置节与绑定 | |
Health / WatchHealth | 健康轮询与推送 | |
Invoke / InvokeStream | 调用插件声明的操作;.NET 宿主暂不发布流式操作 | |
HandleEvent | 投递插件订阅的消息 | |
Host(插件调宿主) | Invoke / Publish | 回调:以插件身份调用宿主操作、发布消息 |
业务载荷是 JSON,上下文按 RFC-0003 平铺为元数据(traceparent、截止时间、只含已登记 xihan.* 键的 Baggage)。.NET 侧的协议常量(环境变量名、元数据键、PLUGINS_* 错误码)在 Plugins.Protocol 包里,应用通常不直接引用。
握手与生命周期
宿主只继承必要的环境变量(PATH、用户与临时目录、区域与时区、DOTNET_*、GO* 等),再追加下表的变量启动进程,工作目录是插件目录:
| 环境变量 | 含义 |
|---|---|
XIHAN_PLUGIN_PROTOCOL_VERSIONS | 宿主支持的协议版本,逗号分隔、升序,当前为 1 |
XIHAN_PLUGIN_PRIMITIVES | 宿主的原语层大版本 |
XIHAN_PLUGIN_SESSION | 本次启动的会话令牌,回调时放在元数据 xihan-plugin-session |
XIHAN_PLUGIN_HOST_ADDRESS | 宿主回调端点 <network> <address>(Windows 命名管道,其他系统 Unix 域套接字) |
XIHAN_PLUGIN_INSTANCE | 实例 ID |
XIHAN_PLUGIN_LOCALE | 宿主默认区域 |
插件启动 gRPC 服务端后向 stdout 写一行握手行 xihan-plugin/1 <network> <address>(network 为 unix、tcp 或 npipe,tcp 只能是回环地址),宿主连上后调用 Handshake:取双方都支持的最高协议版本(没有交集时插件以 gRPC FailedPrecondition 拒绝),核对原语层大版本,比对清单,交换声明。stdin 在插件停止前保持打开,插件必须在 stdin 关闭时自行退出;stderr 是插件日志,宿主逐行转成来源 plugins.<清单 ID> 的日志,以 { 开头的 JSON 行按结构化日志处理。
生命周期状态与原语层的 LifecycleState 一致:
| 状态 | 进入时机 |
|---|---|
Declared | 宿主读到清单 |
Resolved | 握手成功,声明与清单一致 |
Configured | Configure 成功 |
Ready | Start 成功;之后按健康推送或轮询更新 |
Degraded | 插件自报 Degraded,或任一健康项为 Degraded / Unhealthy |
Draining | 宿主已发 Stop,等待插件完成在途调用 |
Stopped | 正常停止 |
Failed | 握手、配置、启动失败,进程异常退出,健康自报 Failed,停止超时被终止 |
时限与重启的缺省值:
| 环节 | 缺省 | 配置键(XiHan:Plugins:OutOfProcess) |
|---|---|---|
握手(读到握手行并完成 Handshake) | 10 秒,超时 PLUGINS_TIMEOUT_001 | HandshakeTimeoutSeconds |
Configure 与 Start 各自 | 30 秒 | StartTimeoutSeconds |
| 健康 | 先订阅 WatchHealth 推送,断开后每 10 秒一次 Health;连续 3 次失败视为失效 | HealthIntervalSeconds、HealthFailures |
| 崩溃后重启 | 1 秒起翻倍、上限 60 秒的退避 | — |
| 重启上限 | 10 分钟窗口内 5 次,超过后保持 Failed,直到 UnloadAsync + LoadAsync | RestartLimit、RestartWindowSeconds |
| 停止宽限 | 取 XiHan:Kernel:StopTimeoutSeconds(缺省 30 秒) | StopGraceSeconds |
停止时宿主发 Stop(带宽限秒数与原因 shutdown / unload / restart),在宽限内等进程退出;超时先发终止信号(关闭 stdin,Unix 上同时发 SIGTERM),5 秒内仍未退出才终止整个进程树,两种结局都记为 Failed(PLUGINS_TIMEOUT_001)。宿主停止时按与登记相反的顺序停止插件。重启后是新进程、新会话令牌、重新握手,声明必须与首次一致,否则 PLUGINS_VALIDATION_004。插件失败只让应用进入 Degraded,宿主不退出。
最小示例
用 framework-sdk-go 写一个回声插件。SDK 的 plugin.Serve 包掉协议的全部机械部分:读环境变量、监听、写握手行、实现服务端、生命周期与健康推送、事件投递、宿主回调客户端和 stderr 结构化日志。
第一步,写清单 xihan.manifest.json。不写 contributes.pipeline:只有 Wasm 插件能贡献中间件,SDK 读到非空即报错不启动,宿主也以 PLUGINS_VALIDATION_001 拒绝。
{
"$schema": "https://spec.xihanfun.com/manifest/v1.json",
"id": "xihan.contoso.echo",
"package": "contoso/echo",
"version": "1.0.0",
"primitives": "1",
"stability": "experimental",
"kind": "plugin",
"isolation": "out-of-process",
"requires": [ { "id": "kernel.plugins", "version": "1" } ],
"contributes": { "operations": [ "echoes.create" ] },
"activation": [ "onStartup" ],
"entry": "echo"
}第二步,写入口。用 operation.Add 登记操作,用 plugin.LoadCatalog 加载消息目录(目录 Localization/ 下一种区域一个 JSON 文件,至少要有 en-US.json),交给 plugin.Serve:
package main
import (
"context"
"embed"
"log"
"github.com/xihanfun/framework-sdk-go/invocation"
"github.com/xihanfun/framework-sdk-go/operation"
"github.com/xihanfun/framework-sdk-go/plugin"
"github.com/xihanfun/framework-sdk-go/primitives"
)
// 握手时原样发送,所以构建产物目录里的 xihan.manifest.json 必须是同一份
//
//go:embed xihan.manifest.json
var manifest []byte
//go:embed Localization
var localization embed.FS
type CreateRequest struct {
Text string `json:"text"`
}
type CreateResponse struct {
Text string `json:"text"`
Tenant string `json:"tenant,omitempty"`
}
// 回声文本并附上下文里的租户;租户、主体、关联 ID 都来自宿主传来的 Baggage
func create(ctx *invocation.Context, request CreateRequest) primitives.Result[CreateResponse] {
return primitives.Ok(CreateResponse{Text: request.Text, Tenant: ctx.Tenant()})
}
func main() {
catalog, err := plugin.LoadCatalog("echo", localization, "Localization")
if err != nil {
log.Fatal(err)
}
registry := operation.NewRegistry()
contract := operation.Contract{ID: "echoes.create", Stability: primitives.Experimental, Expose: []string{operation.ExposeHTTP}}
if err := operation.Add(registry, contract, operation.HandlerFunc[CreateRequest, CreateResponse](create), nil, ""); err != nil {
log.Fatal(err)
}
// 日志只能走 stderr:stdout 留给握手行
if err := plugin.Serve(context.Background(), plugin.Options{Manifest: manifest, Operations: registry, Catalogs: []plugin.Catalog{catalog}}); err != nil {
log.Fatal(err)
}
}第三步,构建并把清单放进产物目录,再用规范仓库的黑盒运行器验证协议一致性(一致性套件与门禁):
go build -o bin/echo .
cp xihan.manifest.json bin/
go install github.com/xihanfun/framework-spec/conformance/cmd/xihan-conformance@latest
xihan-conformance plugin --dir bin --sample 'echoes.create={"text":"hi"}'第四步,在宿主的 XiHan:Plugins:OutOfProcess:Directories 里指向 bin(宿主装配见隔离级别与清单)。启动后 POST /echoes 带 {"text":"hi"} 得到带租户的回声,/health/ready 里出现 plugins.xihan.contoso.echo。
更完整的写法见 SDK 的 examples/echo(仓库里的 samples/EchoGoPlugin 就是它在 .NET 宿主里的端到端运行):订阅、生命周期回调与健康项经 plugin.Options 的 Subscriptions、Hooks、Catalogs 接入;Hooks.Configure 里用 configuration.Decode(&settings) 读清单声明的配置节;操作里用 plugin.HostOf(ctx) 取回调客户端;业务失败返回 primitives.Fail,文案只带消息键。SDK 不提供流式操作,不覆盖 Wasm 载体。用 .NET 写进程外插件不引用任何 XiHan 包,只需 protobuf 与 gRPC 从 plugin.proto 生成桩,见 samples/EchoPlugin。
代理操作经完整管道
握手声明的每个操作在宿主的 OperationRegistry 里登记为代理操作,归属是插件清单 ID,契约(stability、expose、权限码或策略)与 Schema 取自声明。代理操作和本地操作契约没有区别:协议适配器按 expose 发布,调用经宿主完整管道,Authorize 阶段按契约判定,Handle 阶段把 JSON 载荷与上下文发给插件。
上下文平铺为元数据:traceparent / tracestate、截止时间(同时设为 gRPC 调用的截止时间)、只含已登记 xihan.* 键的 Baggage;Items 与服务不传播。插件返回的业务失败原样成为调用结果,宿主用握手时拿到的消息目录翻译消息键(全球化和本地化);传输层失败按下表映射:
| 插件侧 | 宿主错误码 |
|---|---|
| 插件未就绪、正在停止、进程已退出或不可达;进行中的调用被插件停机切断 | PLUGINS_UNAVAILABLE_001 |
截止时间到(宿主的 DeadlineMiddleware 按截止令牌一律改报超时,见弹性与限流) | KERNEL_TIMEOUT_001 |
| 截止时间到达之前调用方取消 | KERNEL_CANCELLED_001 |
插件未实现该操作(gRPC Unimplemented) | KERNEL_NOTFOUND_001 |
载荷或帧序不合法(gRPC InvalidArgument) | KERNEL_VALIDATION_001 |
| 其他 gRPC 失败 | PLUGINS_UNEXPECTED_001 |
事件投递
握手里的每个订阅让宿主按它的 mode 向消息代理订阅该主题:空或 competing 是竞争消费(订阅了主题的宿主实例里只有一个把消息交给插件),broadcast 是广播(每个在线的宿主实例都交给自己的插件,见发布与订阅);mode 不合法或同一(主题,类型)以两种方式各声明一次是 PLUGINS_VALIDATION_004。消息到达时派发器开新作用域、经完整管道,在 Handle 阶段把信封(CloudEvents 属性平铺,attempt 从 1 起)发给插件;订阅写了消息类型时只投递类型相同的消息。投递至少一次,插件处理器必须幂等,可用 Baggage 里的 xihan.idempotency-key 或信封 id 去重;插件返回失败或不可用时由代理按自己的重试与死信策略处理,宿主不缓冲消息。宿主要装配一个消息代理,Standard 组合已带内存默认实现。
回调:Host.Invoke 与 Host.Publish
插件可以反过来调用宿主:
| 回调 | RPC | 权限(须在清单声明且已授予) | 语义 |
|---|---|---|---|
| 调用操作 | Host.Invoke | capability:kernel.pipeline | 以新的调用经宿主完整管道执行操作,未登记的操作为 KERNEL_NOTFOUND_001 |
| 发布消息 | Host.Publish | capability:messaging.queue | 按主题分组批量经宿主消息发布器发布,逐项返回结果;宿主没有发布器时每项为 PLUGINS_UNAVAILABLE_001 |
回调必须带 xihan-plugin-session 元数据,令牌缺失或不匹配为 gRPC Unauthenticated(PLUGINS_DENIED_002);令牌只对本次进程有效,宿主端点只接受本机连接。权限未声明或未授予为 gRPC PermissionDenied,尾部元数据 xihan-error-code: PLUGINS_DENIED_002。回调以插件身份执行(主体 ID 是清单 ID、认证方案 Plugin),元数据里的 xihan.principal 被忽略,xihan.tenant 等已登记的 Baggage 照常采用;Authorize 阶段按目标操作的契约判定,所以插件主体还要在授权与权限里被授予相应权限码。
失败降级
插件故障的结果是降级而不是退出:每个插件是一个健康条目 plugins.<清单 ID>,Failed 或 Degraded 只把应用压成 Degraded,/health/ready 仍按宿主自己的就绪规则应答(生命周期与健康)。
| 故障 | 插件状态 | 调用方看到 |
|---|---|---|
| 进程异常退出 | Failed(PLUGINS_UNEXPECTED_002),按退避重启 | 在途调用 PLUGINS_UNAVAILABLE_001 |
| 调用超过截止时间 | 不变 | KERNEL_TIMEOUT_001 |
| 健康探测连续失败 | 进程被终止,按崩溃处理 | 重启前 PLUGINS_UNAVAILABLE_001 |
| 重启次数超过上限 | 保持 Failed | PLUGINS_UNAVAILABLE_001 |
OutOfProcessPluginHost.Describe(清单 ID) 返回诊断快照 OutOfProcessPluginStatus:状态、进程 ID、协议版本、重启次数、是否懒激活。
远程 Provider
子系统能力也可以由进程外插件提供(规范 RFC-0019):宿主侧的 Caching.Remote 适配器声明 caching.distributed@1,插件只实现规范规定的操作集 cache-entries.*(单条与批量的读、写、不存在才写、删),适配器把它接成 ICacheStore、共享缓存、分布式锁与幂等中间件,和 Redis Provider 一样参与装配。Go 写的 samples/CacheGoPlugin 是现成实现:
builder.AddXiHan(xihan => xihan
.UseStandardProfile()
.UseFeature<OutOfProcessPluginsFeature>()
.UseFeature<RemoteCachingFeature>());{
"XiHan": {
"Plugins": { "OutOfProcess": { "Directories": [ "plugins/cache-go" ] } },
"Caching": { "Remote": { "TimeoutMilliseconds": 5000 } }
}
}适配器每次调用带超时,插件不可达映射为统一的不可用错误;「适配器 + 插件」整体通过与 Redis 相同的缓存一致性套件。用别的语言写 Provider 都走这条路,见写一个 Provider。
配置节
配置节 XiHan:Plugins:OutOfProcess(OutOfProcessPluginOptions.ConfigurationSection):
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
Directories | 字符串数组 | 空 | 启动时装载的插件目录,一个目录一个插件;相对路径相对当前目录 |
Grants:<清单 ID> | 字符串数组 | 空授予 | 授予该插件的权限;清单声明的权限必须全部授予才能加载 |
HandshakeTimeoutSeconds | 正数 | 10 | 读到握手行并完成 Handshake 的时限 |
StartTimeoutSeconds | 正数 | 30 | Configure 与 Start 各自的时限 |
StopGraceSeconds | 正数 | 取 XiHan:Kernel:StopTimeoutSeconds | 停止宽限 |
HealthIntervalSeconds | 正数 | 10 | 健康推送断开后的轮询间隔 |
HealthFailures | 正整数 | 3 | 连续多少次健康轮询失败视为进程失效 |
RestartLimit | 非负整数 | 5 | RestartWindowSeconds 内最多自动重启多少次 |
RestartWindowSeconds | 正数 | 600 | 统计重启次数的时间窗 |
WorkDirectory | 字符串 | 临时目录下的 xihan-plugins | 插件工作目录的根,每个插件一个子目录 |
取值不合类型与范围时,OutOfProcessPluginOptions.FromConfiguration 以带配置键的 ArgumentException 拒绝启动。插件自己的配置节由插件清单 contributes.config.section 决定,宿主在 Configure 时把它序列化为 JSON 对象下发。
常见问题
插件进程起来了,宿主却报握手超时
宿主要在 HandshakeTimeoutSeconds 内读到 stdout 的握手行并完成 Handshake。常见原因:日志写到了 stdout(只能走 stderr)、tcp 地址不是回环地址、握手发回的清单与目录里的文件不一致。xihan-conformance plugin --dir <目录> 的 handshake 用例能直接定位。
插件崩溃后多久恢复,一直崩会怎样
从 1 秒起翻倍退避重启,上限 60 秒;RestartWindowSeconds 内超过 RestartLimit 次就保持 Failed,调用方一直得到 PLUGINS_UNAVAILABLE_001,直到 UnloadAsync 再 LoadAsync。
插件的操作能不能像本地操作一样出现在 OpenAPI 与 MCP 里
能。配置阶段发现的插件,其操作在协议适配器发布路由与文档时已经在登记表里(规范化接口文档、MCP)。只有运行期 LoadAsync 的插件例外:路由已经发布完,它的操作只对 IOperationInvoker 可见。
能用 gRPC 流式操作吗
协议定义了 InvokeStream,但 .NET 宿主暂不发布流式操作,Go SDK 也不提供。需要流式结果时拆成多次一元调用或经消息投递。
相关包与下一步
- 包参考:Plugins.OutOfProcess、Plugins.Protocol、Caching.Remote、Conformance
- 相关章节:隔离级别与清单、操作契约、发布与订阅、授权与权限、一致性套件与门禁
- 规范:RFC-0017《进程外插件协议》、RFC-0019《远程 Provider》;Go SDK framework-sdk-go
- 下一步:Wasm 插件讲沙箱、ABI 与插件贡献中间件
