跳至正文
浏览章节

连线服务

一个插件如何够到另一个插件的行为:进程内按类型,跨进程按键、方法和 schema。

两种会合,一个想法

一个插件不许依赖另一个插件(ADR-0001)。它能做的是查一个服务:一个由它的拥有者 注册在某个字符串键下的活对象。

在进程内,会合是按类型的。拥有者和消费者导入同一个契约 crate,在同一个 TypeId 上 碰头——这次查找会向下转型到两边都点过名的那个具体类型:

pub fn service<T: Any + Send + Sync>(&self, key: &str) -> Option<Arc<T>>;

一个 TypeId 跨不过进程。所以跨过进程时,服务是靠工具契约本来就骑着的同一组三样东西 来会合的——一个字符串键、一个方法名,和一份 JSON schema(ADR-0031)。跨过一个进程边界, 契约必须取一种它的消费者读得懂的形式,而那种形式是 schema,不是类型。

唯一新增的那个 trait

#[async_trait]
pub trait WireService: Send + Sync {
    async fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
}

全部就这些,而这也是这个设计所铸出的唯一一个 trait。某个具体的服务意味着什么,住在 它自己的 api crate 里,绝不住在 sdk 里,因为内核不留任何功能名词。

一个服务不会说的方法会被拒绝,并附上它会说的那一组。ServiceError 带着话,没有任何 可以分支判断的东西:需要区分种类的消费者,谈的是某个 api crate 里的类型化 trait,而 不是这张脸。

跨不跨过去由拥有者决定

一份贡献同时携带同一个活对象的两张脸:

Contribution::Service {
    key: "acme.linter".into(),
    value: handle.clone(),   // what host.service::<T>(key) downcasts to
    wire: Some(adapter),     // what a process's service/call reaches
}

没有连线那张脸,这个服务对一个进程来说根本不存在。 注册它是一个有意的举动,而把 类型化的句柄变成连线那张脸的适配器是机械的——类型化的 trait 是源头,适配器是推导出来 的,两边谁也不用把契约手写第二遍。

一个外部服务——直到它的握手作答之前谁都不知道它存在——经由 HostApi::open_service 进入 注册表,那个方法从同一个对象造出两张脸。消费者用 host.service::<ServiceHandle>(key) 够到它,并按方法调用它;N 个外部服务就是 N 个 这样的东西,彼此的区别在于背后的进程,而绝不在类型。service_wire 和 open_service 默认都是用话拒绝,所以一个不保留任何服务的宿主会明说,而不是安静地失败。

从外面声明一个

一个外部插件在握手里声明它的服务:

{
  "services": {
    "acme.linter": {
      "methods": {
        "lint": { "type": "object", "properties": { "path": { "type": "string" } } }
      }
    }
  }
}

schema 是给写调用方的人看的;没有任何东西拿它去校验。方法名可就是宿主的事了:声明里 从未点过名的方法,在跨过去之前就被拒绝。

一个需要某个服务的插件在 requires 里说出来:

requires: ["service:acme.linter"]

一个缺失的依赖会带着一条通知停用这个插件。它绝不会把宿主搞崩。

一个方法,两个方向

{"jsonrpc":"2.0","id":7,"method":"service/call",
 "params":{"key":"acme.linter","method":"lint","params":{"path":"src/lib.rs"}}}

同一行两个方向都管用。宿主到进程,它驱动一个服务的外部实现。进程到宿主,它让一个外部 消费者去调任何人的服务——这条连接长出反向请求,正是为了这个。外部到外部要经由宿主转 一道:注册表就是路由器,这里没有进程到进程的管道。

两边都不去读 params。那是服务自己的契约,而桥是给它用的一根管子。一个没什么可说的 服务答 null,那也是一个回答;一个答不上来的服务则让这次调用失败。

于是两个外部插件可以在一个服务上配合,而任何人都不必写 Rust。而一个外部服务的 Rust 消费者,是对着它 api crate 里的 trait 编程的,根本看不出这个实现搬到了进程外面。

服务不携带任何内核权柄

一次服务调用是人亲手装上的那些插件之间的代码对代码。权限闸门不参与其中,而 schema 自己的措辞就说了:一次服务调用是插件自己的行为。关于服务的任何东西都放不宽一个工具 可以做什么。

宿主自己的那两扇门

桥注册了一个保留的服务 bingo.host,这样一个进程就能经由它够到任何服务的同一条通路 够到宿主(ADR-0033)。它的那些方法就是一个插件进程可以向宿主要的全部东西,一共两个。

ask {call, question} → {answer} 向人提出一个问题。

{"key":"bingo.host","method":"ask",
 "params":{"call":"<the callId from tool/call>","question":{ … }}}

call 就是这份授予的全部。桥本来就为进度和取消跟踪着运行中的调用,而正是那份活着的 状态授权了这个问题:一次已经结束的调用不在那儿供你提问,而另一条连接的调用也不归这个 调用方去够。两者都会被用话拒绝。这个问题就是 sdk 自己的 InteractionKind,骑的是进程 内工具提问时所骑的同一套提问机器;这里没有第二条提问路径。也没有为它新造任何东西。

notice {level, message} → {} 以插件自己的名义,在任何时候为人说一行。它是唯一 一扇不受任何东西限定范围的门:它花掉的只有一行。级别就是内核自己的那三种,不写的话就 是最安静的那种。

什么是一扇门,什么不是

这背后的分类值得说清楚,因为正是它让这个面保持很小。可见且可归属的东西——生成、发帖 ——以一个普通的、插件自有的服务跨过去。只观察的东西骑钩子的观察通路。而花掉东西的 ——钱、人的注意力,或者人的私密数据——以额度的形式跨过去:在一次跨越开始的地方铸出, 计量,跨越结束即作废。没有什么是随处可取的,也没有什么会续期。

一份授予是一次花费,绝不是一次许可。没有哪份授予是 Allow 形状的,而裁决那一层被这 一切完全不碰。将来的一项能力,进来的方式是 bingo.host 上多一个方法加上一个限定范围 的事实,各自背后有它自己的、点明范围与记账方式的决定——而只有当一扇门的限定事实在别处 都不存在时,才铸出这扇门。不是方法的东西,在这条线之外就不存在。