# 自己开发一个插件
从零写一个能被平台调用、能授权、能计费的插件。全程不需要改平台一行代码。
想直接看能跑的代码:[`examples/echo-plugin`](../examples/echo-plugin) 是一个
约 130 行的完整插件,依赖全部来自 crates.io,`cargo run` 就起。
## 0. 先理解一件事:平台不认识你的插件
平台侧只有**能力 id**和 **JSON 载荷**两个概念,不生成也不依赖你的类型。
契约细节见[插件契约](./plugin-contract.md),这里只讲怎么把插件做出来。
## 1. 实现一个 gRPC 服务
一个 `ExtensionPlugin` 服务,只有一个方法:
```protobuf
service ExtensionPlugin {
rpc Call(GenericRequest) returns (GenericResponse);
}
```
`operation` 决定走哪个分发臂,`payload.value` 是 JSON 字节,
`metadata` 里的上下文**必须由你自己还原**——平台是进程外调用你。
## 2. 声明你能做什么
启动后向平台注册。用 `flare-plugin-host` 而不是手写 gRPC 调用:
```bash
cargo add flare-plugin-host
```
```rust,no_run
use flare_plugin_host::{PluginDeclaration, PluginHost, SeatModel};
# async fn demo() -> Result<(), flare_plugin_host::HostError> {
let declaration = PluginDeclaration {
tenant_id: "0".into(),
plugin_id: "vendorx-invoice".into(),
capability_id: "vendorx.invoice.create".into(),
grpc_authority: "10.0.0.7:9000".into(), // 平台按这个地址回调你
plugin_version: env!("CARGO_PKG_VERSION").into(),
api_version: "1".into(),
manifest_sha256: String::new(),
declared_operations: vec![
"vendorx.invoice.create".into(),
"vendorx.invoice.void".into(),
],
labels: Default::default(),
seat_model: SeatModel::Tenant,
};
PluginHost::connect("http://capability:50110")
.await?
.announce(&declaration)
.await?;
# Ok(())
# }
```
`grpc_authority` 是**平台能连到**的地址,不是你监听地址的字面量 ——
容器里这两者常常不同,通告一个平台连不到的地址,症状会表现成健康检查失败
而不是配置错误。
### 为什么用库而不是自己调
不是省代码量,是**容易少调**。漏了 `declared_operations` 不会报错,只会让你的
插件退化成 `unverified`——声明边界从此对它失效。做成必须填满字段的结构体,
漏填在编译期就暴露。
### declared_operations 决定你承接什么
**注册一次就够**:平台的注册粒度是 `(租户, 插件)`,你的进程只有一条记录。
这条清单必须与你 `Call` 里实际处理的 operation 一致:
- 声明有、实现没有 → 请求打过来,你回 `UNKNOWN_OPERATION`,看着像插件坏了
- 实现有、声明没有 → 平台直接拒绝,你的进程收不到,看着像权限问题
两个方向的排查路线完全不同,所以**加一条测试双向比对**这两份清单。
示例里有一个可以直接抄的版本。
## 3. 选择计费单位:这是产品决策
`seat_model` 决定你的插件怎么授权,平台不替你决定:
| `Tenant` | **租户装了就全员可用**,租户开关即安装状态 | 绝大多数插件 |
| `PerUser` | 还需逐人授权 | 有边际成本的(AI 按 token 烧钱)、需合规隔离的(导出只给特定角色) |
选 `Tenant` 意味着:管理员安装后,组织里**从未被单独授权过**的用户也能直接用。
选错的代价是真金白银——按租户卖一个按 token 烧钱的能力,用量越大亏越多。
所以这个字段没有默认值,必须显式写。
## 4. 授权是 fail-closed 的
平台在把请求交给你之前会先过授权。**拒绝就是拒绝,不会降级放行**:
```text
global 开关关闭 → 拒绝
租户开关 = false → 拒绝(装过但停用)
租户开关缺失(tenant 模型)→ 拒绝(从未安装)
用户无授权(per_user 模型)→ 拒绝
```
注意最后两条的区别:`tenant` 模型下**开关缺失即拒**,因为「安装」这个动作
必须有效力;而 `per_user` 沿用旧语义。
与之相反,**可用性类失败会降级**:你的插件超时或不可达时,平台不会因此拒绝
整条链路,而是按健康状态把你的实例排到候选末尾。两类失败方向相反,别混淆。
## 5. 优雅停机
```rust,no_run
# async fn demo(host: &mut flare_plugin_host::PluginHost)
# -> Result<(), flare_plugin_host::HostError> {
host.withdraw("0", "vendorx-invoice").await?;
# Ok(())
# }
```
不调也不会坏——健康检查最终会把你摘掉。但那要等一个检查周期,期间的调用会打到
正在退出的进程上。注销粒度是 `(租户, 插件)`,与注册粒度一致。
## 6. 自有存储
插件有自己的库表时,在清单里声明归属与卸载策略:
```json
"storage": {
"prefix": "plugin_vendorx_",
"onUninstall": "archive",
"migrationsPath": "migrations/"
}
```
`onUninstall` **没有默认值**,必须显式写:默认保留会让卸载留下无人认领的表,
默认删除会让一次误操作毁掉用户数据。两种默认都不可接受。
## 7. 完整流程回顾
```text
你的进程启动
→ 起 gRPC 服务(先起服务再声明,否则平台可能在你还没监听时就来探活)
→ announce():声明 operations + seat_model + 版本
→ 平台把你记进路由簿,标记 verified
管理员安装(tenant 模型)或逐人授权(per_user 模型)
→ 调用方发起请求
→ 平台:授权校验 → 声明边界校验 → 路由到你 → ExtensionPlugin.Call
→ 你:还原上下文 → JSON 转你的类型 → 业务 → 转回 JSON
```
## 8. 常见坑(都有明确症状)
| 所有 op 报「找不到上下文」 | 没把 `metadata` 还原进请求上下文 |
| 某个 op 报 `UNKNOWN_OPERATION` | 声明了但没实现 |
| 请求根本到不了插件,报权限错 | 实现了但没声明 |
| 装了却全员不可用 | `seat_model` 没声明,退化成按人授权 |
| 注册被当场拒绝 | `capability_id` 不在自己的 `declared_operations` 里 |
| 健康检查一直失败 | 通告的地址平台连不到,或声明了特殊探活协议而平台侧没有对应探针 |
| 用 prost 解 payload 报错 | `Any.value` 是 JSON 字节,不是 protobuf 编码 |
这些症状都是**刻意设计成可区分的**——每一条都指向唯一的原因,不需要猜。