XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

进程外插件 ​

进程外插件是宿主启动的子进程,经本机 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握手成功,声明与清单一致
ConfiguredConfigure 成功
ReadyStart 成功;之后按健康推送或轮询更新
Degraded插件自报 Degraded,或任一健康项为 Degraded / Unhealthy
Draining宿主已发 Stop,等待插件完成在途调用
Stopped正常停止
Failed握手、配置、启动失败,进程异常退出,健康自报 Failed,停止超时被终止

时限与重启的缺省值:

环节缺省配置键(XiHan:Plugins:OutOfProcess)
握手(读到握手行并完成 Handshake)10 秒,超时 PLUGINS_TIMEOUT_001HandshakeTimeoutSeconds
Configure 与 Start 各自30 秒StartTimeoutSeconds
健康先订阅 WatchHealth 推送,断开后每 10 秒一次 Health;连续 3 次失败视为失效HealthIntervalSeconds、HealthFailures
崩溃后重启1 秒起翻倍、上限 60 秒的退避—
重启上限10 分钟窗口内 5 次,超过后保持 Failed,直到 UnloadAsync + LoadAsyncRestartLimit、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 拒绝。

json
{
  "$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:

go
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)
	}
}

第三步,构建并把清单放进产物目录,再用规范仓库的黑盒运行器验证协议一致性(一致性套件与门禁):

bash
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.Invokecapability:kernel.pipeline以新的调用经宿主完整管道执行操作,未登记的操作为 KERNEL_NOTFOUND_001
发布消息Host.Publishcapability: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
重启次数超过上限保持 FailedPLUGINS_UNAVAILABLE_001

OutOfProcessPluginHost.Describe(清单 ID) 返回诊断快照 OutOfProcessPluginStatus:状态、进程 ID、协议版本、重启次数、是否懒激活。

远程 Provider ​

子系统能力也可以由进程外插件提供(规范 RFC-0019):宿主侧的 Caching.Remote 适配器声明 caching.distributed@1,插件只实现规范规定的操作集 cache-entries.*(单条与批量的读、写、不存在才写、删),适配器把它接成 ICacheStore、共享缓存、分布式锁与幂等中间件,和 Redis Provider 一样参与装配。Go 写的 samples/CacheGoPlugin 是现成实现:

csharp
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature<OutOfProcessPluginsFeature>()
    .UseFeature<RemoteCachingFeature>());
json
{
  "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正数30Configure 与 Start 各自的时限
StopGraceSeconds正数取 XiHan:Kernel:StopTimeoutSeconds停止宽限
HealthIntervalSeconds正数10健康推送断开后的轮询间隔
HealthFailures正整数3连续多少次健康轮询失败视为进程失效
RestartLimit非负整数5RestartWindowSeconds 内最多自动重启多少次
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 也不提供。需要流式结果时拆成多次一元调用或经消息投递。

相关包与下一步 ​

Released under The MIT License