Tip
实验性项目。有人感兴趣的话可以提一点建议。
背景
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
实体。
萃取规则为:
- 如果形参名与实参的名称和类型匹配,或者实参的
_self_
与对象类型匹配,那么完成绑定。 - 如果形参类型与实参类型匹配,那么完成绑定。
- 如果实参类型实现了到形参类型的萃取接口,那么萃取后完成绑定。
- 如果第 2 条和第 3 条有多种可能的匹配方式,可以任意选取一种匹配方式,由具体实现决定。
- 按照以上规则从上到下匹配,如果全部完成后有未匹配的形参,匹配失败,直接返回错误信息。
实现
元编程
服务端通过元编程,从类定义中生成各个资源的 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
的快捷方式。
这些代码可以在客户端编写,但这就与我们的减少重复工作的初衷不符了。
数据型对象的字段补全?
类似 Face
和 Image
这样的消息链段在构建时可以用一部分字段,自动生成其余的字段。
对于 Image
,可以使之指向一个实体型对象来解决,而 Face
如何处理?