跳到主要内容

项目 | Runico 设计文档

提示

实验性项目。有人感兴趣的话可以提一点建议。

背景

RPC 的问题

一般的 RPC 是面向过程的,因此它无法表示面向对象的逻辑,必须从面向对象还原为面向过程,这就损失了结构化的信息。

RMI 技术可以实现面向对象的远程调用,但是现有的 RMI 的跨语言难以实现。Java 的 RMI 方案目前比较成熟,但是这套机制严重依赖于 JVM,无法拓展到其他语言。

设计目标

设计一种类似于 RMI 的跨语言的方法调用机制,统一服务端和客户端的对象结构,使具体逻辑只需要在服务端实现一遍,避免重复工作,能够提供多种编程语言下的本地化的、一致的、良好的开发体验。

HATEOAS

HATEOAS(Hypermedia as the Engine of Application State) 是 RESTful 在发展后提出的一种范式。它的逻辑是“超媒体”导向,即注重机器可读的优先性

HATEOAS 的传统实现是在请求的资源结果上,附加其可能操作的链接。

如传统的 RESTful 的请求结果为:

comments: [
{
name: 'User 1',
comment: 'Hey, this post is terrible!'
},
{
name: 'User 2',
comment: 'I love this post, I read it daily ... visit my website now'
}
]

其 HATEOAS 的版本为:

comments: [
{
name: 'User 1',
comment: 'Hey, this post is terrible!',
links: [
{
rel: 'self',
href: 'http://blog.example.com/api/some-post/comments/1'
},
{
rel: 'update',
href: 'http://blog.example.com/api/some-post/comments/1/update'
}
]
},
{
name: 'User 2',
comment: 'I love this post, I read it daily ... visit my website now',
links: [
{
rel: 'self',
href: 'http://blog.example.com/api/some-post/comments/2'
}
]
}
]

可借鉴 HATEOAS 的这种设计来提供 RMI 式的接口。

版本

v1.0.0 alpha 5.

设计

对象和资源

对象分为实体型对象和数据型对象。类似 Friend Group 这一类具有对象方法的,可通过唯一标识获取的对象,属于实体型对象。类似 MessageChain 这一类可以完全序列化的对象,属于数据型对象。

实体型对象

对象用资源的形式表示和传输。资源包含一个对象的全部属性和全部方法。对象的类型对应资源的类别。

资源有一个在同一类别下唯一的 id,可用于获取资源。实体型对象在传输时可以仅用资源 id 表示,也可以以包含资源 id 和其他所有属性的序列化格式表示。

例如:

struct Friend {
id: i32,
nickname: String,
}

impl Friend {
fn send_message(&self, message: &MessageChain) {
unimplemented!();
}
}

let friend = Friend {
id: 12345678,
nickname: "QWQ".to_string(),
};

其对应的资源为:

{
_entity_: "Friend",
_id_: 12345678,
nickname: "QWQ",
_impl_: {
send_message: "/Friend/Friend/send_message"
}
}

Friend 这一类天然拥有 id 的对象,可以用其一个字段表示 id,此时假设该字段的值是唯一的。对于其他对象,使用附加的字段储存 id,只要保证能通过 id 获取资源即可。

资源的水化

资源在传输时,可以有两种形式:包含全部属性的完整形式,或者仅包含实体类型和 id 的简略形式。从简略形式得到完整形式的过程称为水化(hydrate)。

例如,简略形式的资源形如:

{
_entity_: "Friend",
_id_: 12345678
}

经过水化后,得到完整形式:

{
_entity_: "Friend",
_id_: 12345678,
nickname: "QWQ",
_impl_: {
send_message: "/Friend/Friend/send_message"
}
}

对于完整形式的资源,可进行再水化,达到更新信息的目的。

数据型对象

数据型对象对应资源。数据型对象完全以序列化后的形式传输。

数据型对象在序列化时,附加 _data 元数据字段,表示对象的类型。

{
_data_: "MessageChain",
parts: [{
_data_: "Plain",
text: "Hello, World!"
}, {
_data_: "Face",
id: 100
}]
}

实体类型和 Schema

资源的 Schema 由实体类型决定。

实体类型的属性资源
类型名称_entity_
唯一标识属性_id_
属性字段
对象方法_impl_ 的字段
静态方法包含在资源中

实体类型名称以 CamelCase 命名。序列化后的字段名均采用下划线分割命名。

当实际名称和对应的资源中的名称不同时,可以指定别名。对于字段名还可指定别名规则。

协议保留以单下划线开头并结尾的名称(sunder)。

远程方法 URI

与 RESTful 不同,URI 表示资源定位,而是表示远程方法的地址。

每个资源类别占有一个根目录下的同名地址。需要有以下的方法(假设类名为 <category>):

  • /<category>/get :根据资源 id 获取一个可用的资源。
  • /<category>/new (可选):新建一个资源。
  • /<category>/<method_name> :名为 <method_name> 的静态方法。
  • /<category>/<category>/<method_name> :名为 <method_name> 的对象方法。
  • /<method_name> :名为 <method_name> 的全局函数。

传输协议

接口工作于任何一种有连接的、有数据边界的、有应答的传输协议之上,比如:

  • HTTP 协议,并通过 session 实现有连接。
  • WebSocket 协议,并通过 sync_id 实现应答。
  • TCP 协议,并通过合适的应用层设计,实现数据边界和应答。

不要求传输协议实现 URI,URI 可以由后端实现进行解析。

数据包格式暂不做要求。具体实现时,需要确定一种可行的序列化格式,至少需要支持嵌套键值对。

方法调用约定

调用者约定

调用者按照 Schema 中规定的参数顺序,将实参映射到键值对,然后向方法对应的 URI 发送请求数据包,携带方法参数以及一系列元数据。只有 URI 与 session 是必须的元数据。实现不应依赖于其他的元数据。

方法参数全部使用命名调用,以序列化的键值对传递。对象方法调用时,用 _self_ 字段传递对象的资源表示。

[POST] /Friend/get
{
_self_: {
_entity_: "Friend",
_id_: 12345678
}
}

例如,实际方法调用为:

friend.send_message(msg!("Hello World!", Face::new(100)))

根据 Schema 的规定,send_message 方法有一个名为 message 的参数,那么就可以对应地进行序列化。

// [POST] /Friend/Friend/send_message
let message = msg!("Hello World!", Face::new(100));
let data = json!{
_self_: {
_entity_: "Friend",
_id_: friend.id
},
message: message.to_serialized()
};

被调用者约定

当远程方法被调用时,首先读取元数据中的 URI,确定要调用的方法。然后将参数反序列化得到实参,根据萃取规则,完成实参与形参的对应绑定。

按照绑定结果将参数值传递给方法实现,得到方法的返回值,在应答数据包中发送返回数据。

返回数据为专门的结构:

字段含义
status方法是否出现错误。0 为无错误,非零值为错误编号。
message仅当 status 非 0 时可用,表示错误信息。
data仅当 status 为 0 时可用。方法的返回值的序列化表示。

参数萃取

实体类型在定义时,可实现一系列萃取接口,允许从中取出一部分,或对参数进行包装。

例如,GroupMember 类型可实现 AsGroup 接口,以允许从中取出其引用的 Group 实体。

萃取规则为:

  1. 如果形参名与实参的名称和类型匹配,或者实参的 _self_ 与对象类型匹配,那么完成绑定。
  2. 如果形参类型与实参类型匹配,那么完成绑定。
  3. 如果实参类型实现了到形参类型的萃取接口,那么萃取后完成绑定。
  4. 如果第 2 条和第 3 条有多种可能的匹配方式,可以任意选取一种匹配方式,由具体实现决定。
  5. 按照以上规则从上到下匹配,如果全部完成后有未匹配的形参,匹配失败,直接返回错误信息。

实现

元编程

服务端通过元编程,从类定义中生成各个资源的 Schema。此处的元编程,要求编程语言拥有在编译时或运行时获取函数签名和类型定义,并进行编程性操作的能力(可借助侵入性的注解)。

Schema 中需要包括:元数据、各个字段的类型、各个静态方法的名称和 URI、各个对象方法的名称、各个方法的参数名称和顺序、可能的错误类型。

客户端从 Schema 中生成调用接口。

别名

别名是实际名称和序列化中的名称不一致的情况。别名发生时,在序列化和反序列化时以别名为准,原有的名称不再使用。

类型别名

一般来说,资源的 _entity_ 字段与类型名相同。如果在两个模块中存在两个名称相同的类型,可以指定别名。

属性别名

为了满足下划线分割命名的要求,可以为属性引入别名。允许通过全局性的配置项,为所有的属性和方法名采用统一的别名策略。

反向方法调用

允许服务端调用客户端暴露的方法,这可用于事件响应或回调。反向方法调用的调用约定与正向一致。

反向方法调用可以实现参数萃取,这需要客户端的实现有元编程支持。

实体的水化

客户端收到服务端发来的资源时,有两种情形:资源以资源 id 表示,或者以完整的序列化形式表示。后者可以用于直接构建完整可用的实体对象,而前者构建未水化的实体对象。

实体对象在未水化时不可用。客户端需要在使用该实体对象之前,隐式地完成对象的水化,即向服务端请求获取资源的接口,得到实体的数据。

服务端接收到客户端发来的资源时,必须进行再水化,更新其中的信息。

方法委托

资源的 _impl_ 字段中,包含的方法地址不一定属于此对象类型。可以将地址设为另一个方法的地址,以实现方法的委托调用。

借助于参数萃取的实现,不同签名的方法可以进行委托,例如:

fn Friend::send_message(&self, message: &Message);
fn send_friend_message(friend: &Friend, message: &Message);

参数萃取保证了 _self_ 绑定到 friend,因此可以把前者委托给后者。

方法禁用

当对象的一个方法在 Schema 中存在,但并不存在于资源的 _impl 字段中时,表明该方法处于禁用状态。禁用的含义是:调用该方法将必然引发一个错误。

用户尝试调用被禁用的方法时,调用者会引发一个错误,因为调用者无法找到此方法对应的接口地址。

错误处理

服务端在方法调用中发生的错误应当被捕获,并反映到方法的应答数据包中。

客户端收到应答数据包后,如果其中出现了错误,应当转化为编程语言的本地错误。如在 Python 中 raise 一个 Exception 的子类,在 rust 中得到一个 Result<_, _>

尚未解决的问题

数据型对象的辅助函数?

数据型对象需要有 Schema,但 Schema 中不会包含一些辅助函数,比如创建 MessageChain 的快捷方式。

这些代码可以在客户端编写,但这就与我们的减少重复工作的初衷不符了。

数据型对象的字段补全?

类似 FaceImage 这样的消息链段在构建时可以用一部分字段,自动生成其余的字段。

对于 Image,可以使之指向一个实体型对象来解决,而 Face 如何处理?

Loading...