Sproto 是一个用 C 编写的高效序列化库,主要是想用来做 Lua 绑定。类似 Google 的 protocol buffers,但是速度更快。其设计得非常简单。只支持 Lua 支持的几种数据类型,其可以很容易的绑定到其他动态语言,或者直接在 C 中使用。
简介
其项目开源到 github.com/cloudwu/sproto
其主要包含一些提供给 Lua 使用的 API,一个语法解析模块(parser) sprotoparser
,还有一个 RPC API,加上 C 库。
解析器
local parser = require "sprotoparser" |
parser.parse
把一个 sproto 协议框架解析为一个二进制字符串
在解析的时候需要用到这个。可以用它来产生二进制字符串。框架文本和解析器在程序运行的时候并不需要
Lua API
我们先看看看它提供给 Lua 使用的 API。
local sproto = require "sproto" |
- sproto.parse(schema) 通过一个 文本字符串 的框架生成一个 sproto 对象。
- sproto.new(spbin) 通过一个 二进制的字符串(parser 生成) 生成一个 sproto 对象。
- sprotocore.newproto(spbin) 通过一个 二进制的字符串(parser 生成) 生成一个 C sproto 对象。
- sproto.sharenew(spbin) 从一个 sproto C 对象(sprotocore.newproto()生成)共享一个 sproto 对象。
- sproto:exist_type(typename) 检查 sproto 对象中是否存在此类型。
- sproto:encode(typename, luatable) 把一个 Lua 表以 typename 编码到二进制字符串内。
- sproto:decode(typename, blob [,sz]) 以typename来解码一个
sproto:encode()
产生的二进制字符串。如果 blob 是一个 lightuserdata (C 指针),sz 是必须的。 - sproto:pencode(typename, luatable) 类似
sproto:encode
,但是会压缩结果。 - sproto:pdecode(typename, blob [,sz]) 类似
sproto.decode
,但是会先解压缩对象。 - sproto:default(typename, type) 以类型名的默认值来建立一个表。类型可以是 nil, REQUEST, RESPONSE。
RPC API
这些 API 是对 core API 的封装。
sproto:host([packagename])
以 packagename 建立一个宿主对象 host 来传输 RPC 消息。
host:dispatch(blob [,sz])
以 host 对象内的(packagename)来解压并解码(sproto:pdecode
)二进制字符串。
如果 .type 存在,这是一个 有.type REQUEST
消息,返回REQUEST, protoname, message, responser, .ud。responser是一个用来编码 响应消息的函数。 当.session不存在时,responser将会是 nil。
如果 .type 不存在,这是一个给 .session 的 RESPONSE
消息。返回 REPONSE, .session, message, .ud。
host:attach(sprotoobj)
建立一个以 sprotoobj 来压缩和编码请求消息的函数 function(protoname, message, session, ud)
。
如果不想使用主机对象,可以用下面的 API 来编码和解码 RPC 消息。
sproto:request_encode(protoname, tbl)
以protoname 来编码一个请求消息。
sproto:response_encode(protoname, tbl)
以protoname 来编码一个响应消息。
sproto:request_decode(protoname, blob [,sz])
解码一个请求消息。
sproto:response_decode(protoname, blob [,sz]
解码一个响应消息
数据类型
- string : string
- binary : binary string (字符串的子类型)
- integer : 整型,最大整型是有符号 64 位的。 可以是一个不动点的特定精度的数字。
- boolean : true or false
在类型前面添加一个 *
来表示一个数组。
可以指定一个主索引,数组将会被编码成一个无序的 map。
用户定义的类型可以是任何非保留的名字,也支持嵌套类型。
没有双精度或者实数类型。作者认为,这些类型非常少使用。如果果真需要的话,可以用字符串来序列化双精度数。如果需要十进制数,可以指定固定的精度。
枚举类型并不十分实用。我们在 Lua 定义一个 enum 表来实现。
协议定义
sproto 是一个协议封装库。所以我们要定义我们自己的协议格式(schema)。
sproto 消息是强类型的,而且不是自描述的。所以必须用一个特殊的语言来定义我们自己的消息结构。
然后调用 sprotoparser 来把 协议格式 解析为二进制字符串,这样 sproto 库就可以使用它。
可以离线解析,然后保存这些字符串,或者可以在程序运行的时候解析。
一个协议框架可能会像这样:
# 注释 |
一个框架可以是 被 sproto 框架语言自描述的:
.type { |
Wire protocol
每个整数以小端(little endian)格式序列化。
sproto 消息必须是一个用户定义类型结构,每个结构编码成三个部分。header, field, data(头部,字段,数据)。标签(tag)和 小的整数 或 布尔值 会被编码到 field 部分,其他的都在 data 部分。
所有的字段必须以升序编码(通过 标签 tag,从 0 开始)。当有字段是 nil的时候(lua 中的默认值),不要在消息中进行编码。 字段的标签因此可能是不连续的。
头部(header)是一个 16bit 整数。就是字段数两。
字段部分的所有字段都是一个 16bit 整数(n)。如果 n 为 0,表示这个字段的数据编码在数据部分;
如果 n 是不为 0 的偶数,字段的值是 n/2-1,tag(标签)会增加 1;
如果 n 是奇数,表示标签是不连续的,我们应该把当前标签 增加 (n+1)/2。
数组总是被编码到数据部分,4 bytes 来表示大小,接下来的字节就是内容。(len-value)二元组。查看 例子 2 来了解 结构数组; 例子 3/4 展示整数数组; 例子 5 是布尔数组。
对于一个整型数组,一个额外的字节(4 or 8)来表示这个值是 32bit 还是 64bit。
查看下面的例子。
注意:如果 标签没有在 框架内声明,解码器为了协议版本的兼容,会忽略那些字段。
.Person { |
例子 1
person { name = "Alice" , age = 13, marital = false } |
例子 2
person { |
例子 3
data { |
例子 4
data { |
例子 5:
data { |
例子 6:
data { |
0 Packing
算法类似 Cap’n proto,但是不特别对待 0x00。
在打包的格式中,消息会被填充到 8。每个标签背后的都是 8 字节的倍数。
标签字节的位对应了未打包字的字节数,最不重要的位对应第一个字节。
每个为 0 的位表示对应的字节是 0。而非 0 的字节被打包到 标签后面。
比如:
unpacked (hex): 08 00 00 00 03 00 02 00 19 00 00 00 aa 01 00 00 |
0xff 标签会被特别对待。一个数字 N 会跟在 0xff 标签后面,表示 (N+1)*8 字节应该被直接复制。
字节可能包含也可能不包含 0 值。因为这个规则,最行的空间浪费就是每 2 KB 输入只打包了 2 字节数据。
例如:
unpacked (hex): 8a (x 30 bytes) |
C API
struct sproto * sproto_create(const void * proto, size_t sz); |
以一个被 sprotoparser 编码的 框架字符串来建立一个 sproto 对象。
void sproto_release(struct sproto *); |
释放 sproto object:
int sproto_prototag(struct sproto *, const char * name); |
在一个协议的 标签和名字间转换,并查询对象的类型。
struct sproto_type * sproto_type(struct sproto *, const char * typename); |
从一个 sproto 对象查询类型对象。
struct sproto_arg { |
以一个用户定义的回调函数编码和解码 sproto 消息。查看 lsproto.c 的实现来看更多的信息。
int sproto_pack(const void * src, int srcsz, void * buffer, int bufsz); |
以 0 packing 算法来打包和解包消息。
总结
在 TCP 连接上,我们发送和读取的的数据,都是连续的字节流。我们无法知道我应该读取的内容到底是什么,内容到底是什么,是由我们自己定义的协议所确定的。
而在基本的套接字编程示例中,我们都是调用系统的 read(int fd, void * buffer, ssize_t sz)
来将从文件描述符上将内存缓冲区的数据,读到我们自己的缓冲区内。
对此,在 skynet 的使用示例中,其把每个消息的前两个字节定义为 消息的长度,后面跟上真正的消息内容。
然后在我们以我们指定的协议进行解码。协议内容总是会包含一个协议头部:
.package { |
跟上真正的协议内容,然后以 0-packing
方式打包。
?type 的值,表明了我们定义的协议中类型的标签值?
消息类型与请求类型
在云风的博客上提到:
对于 request/response 的 RPC 方案,除了消息本身打包外,还有两个重要的信息需要传输。它们分别是请求的类型以及请求的 session 。
不要把请求的类型和消息的类型混为一谈。因为不同的请求可以用相同的消息类型,所以在 sproto 中,需要对 rpc 请求额外编码。你也不一定为每个请求额外设计一个消息类型,可以直接在定义 rpc 协议时内联写上请求(以及回应)的消息结构。通常,我们用数字作为消息类型的标识,当然你也可以使用字符串。在用类 json 的无 schema 的协议中使用字符串多一些,但在 sproto 这种带 schema 的协议中,使用数字会更高效。同样,session 作为一条消息的唯一标识,你也可以用数字或字符串。而生成唯一数字 session 更容易,编码也更高效。
所以,每当我们发送一次远程请求,需要传输的数据就有三项:请求的类型、一个请求方自己保证唯一的 session id 以及请求的数据内容。
服务方收到请求后,应根据请求的类型对请求的数据内容解码,并根据类型分发给相应的处理器。同时应该把 session id 记录下来。等处理器处理完毕后,根据类型去打包回应的消息,并附加上 session id ,发送回客户端。
注意:回应是不需要传输消息类型的。这是因为 session id 就唯一标识了这是对哪一条请求的回应。而 session id 是客户端保证唯一的,它在产生 session id 时,就保存了这个 session 对应的请求的类型,所以也就有能力对回应消息解码。
btw ,如果只是单向推送消息(也就是 publish/subscribe 模式),直接省略 session 就可以了,也不需要回应。
在上面一节中,我们说道 .package
就是一个我们定义的消息类型,而其中的 type
字段,定义了我们的请求类型。
对于每个包,都以这个 package 开头,后面接上 (padding)消息体。最后连在一起,用 sproto 自带的 0-pack 方式压缩。
我们可以这样理解:
消息类型 .package
定义了我们消息包含的内容。
而 .type
定义了我们消息内容是怎么表示的。
client.lua 使用示例
我们先来看一下一般性的代码:
-- 加载 socket, proto, sproto 库 |
首先,我们先要定义我们的协议,然后通过 parser 来解析成为一个二进制字符串,最后,调用 sproto.new
来建立一个 sproto 对象。
协议定义
这是通过 parser.parse
来解析一个我们用 schema 语言定义的框架,然后生成的字符串保存在 表中进行了返回。
其中对于 c2s
的协议,我们定义了一个 消息类型 .package
,四个请求(协议)类型。
而对于 s2c
的协议,我们只定义了一个请求(协议)类型。
proto.c2s = sprotoparser.parse [[ |
对象建立
我们先来看看第一个调用:
local host = sproto.new(proto.s2c):host "package" |
这个调用实际上就是:
local sobj = sproto.new(proto.s2c) |
我们先看看第一步 sproto.new
的定义:
local weak_mt = { __mode = "kv" } |
其实是调用 注册出的的 core.newproto API,来建立了一个 sproto 对象。返回值就是 一个表 ,此表中的 __cobj
引用了 这个建立的 对象。这个表的元表已经被设置为 sproto_mt
sobj = { |
接下来我们调用的sobj:host
,在 sobj 表内并不存在方法 host
,所以其转而去寻找去 __index
事件的元方法,这是一个表,就是 sproto,其实其调用的就是下面的这个方法。
function sproto:host( packagename ) |
会根据我们给定的 packagename 消息类型来建立一个表对象 obj,这个表内的 __proto
事件就指向了我们的 sproto表,然后__package
事件引用了 packagename 在 建立的 sproto 对象中的位置。host对象的元表被设置成了 host_mt
,其中具有 dispatch, attach
两个方法。所以当 host,不存在对应方法时会调用元表中的方法。
最终我们可以得到一个表,也可以说是一个对象。host,
host = { |
消息分发器
实际上,我们对一个 sproto 对象调用 :host
方法,就是为它绑定一个有两个方法 dispatch, attach
的元表。这样当访问这两个方法的时候就会直接访问我们绑定的方法。
host:attach
我们来看一下 attach
方法:
function host:attach(sp) |
这个函数会返回一个函数:
function (name, args, session, ud) ... end |
其会根据 name
(协议类型/请求类型)来把 代表内容的 args, session 打包。
host:dispatch
我们先来看一下 dispatch
方法:
function host:dispatch(...) |
消息发送
在调用 local request = host:attach(sproto.new(proto.c2s))
后,建立了一个消息封装函数request。
函数 :
local function send_request(name, args) |
会将 会话 ID,协议名,参数传递给 消息封装函数。之后,函数:
local function send_package(fd, pack) |
会将打包好的消息,进行大端封装后发送到套接字去。
消息接收
服务端使用了 snax.gateserver
的实例 gate
来实现连接管理,当收到一个消息时,如果有 agent,就会将消息转发到 agent 去:
-- services/gate.lua |
我们的 agent 服务在启动时即注册了 client 类型的消息:
-- examples/agent.lua |
其会使用 host:dispatch
来解压消息,然后注册了自己的消息回调函数。
我们注意到,在服务端中,建立消息消息分发器的方式同客户端似乎都不一样:
-- examples/agent.lua |
其是通过 sprotoloader.load(1):host "package"
来建立的。我们有理由去猜测,这个其实应该等价与:
sproto.new(proto.c2s):host "package" |
因为其处理的,是从客户端到服务端的消息。
sprotoloader
如果想要在程序中,各个服务中共享同样的消息类型和协议类型,为每个服务都单独的保存这些协议信息似乎是非常浪费的。所以就有了把共享的协议由一个服务来提供的想法。
其先启动了一个全局唯一的协议加载服务:
skynet.uniqueservice("protoloader") |
skynet.start(function() |
把 客户端到服务端的消息类型保存为索引 1。
这样当我们通过 sprotoloader.load(1)
,就得出了这个索引对应的对象指针,在通过 sproto.sharenew()
来把这个对象给返回给调用者。
-- lualib/sprotoloader.lua |
-- lualib/sproto.lua |
这个函数其实是 sproto.new
返回的值一样,不过其是直接传过去的对象,而不是二进制的字符串。
如此,我们的消息处理流程就完美了。