在Lua中操作Google protobuf格式数据

May 29, 2025 · View on GitHub

Build StatusCoverage Status

English | 中文


Urho3d集成说明:https://note.youdao.com/ynoteshare1/index.html?id=20d06649fab669371140256abd7a362b&type=note

Unreal SLua集成:https://github.com/zengdelang/slua-unreal-pb

Unreal UnLua集成:https://github.com/hxhb/unlua-pb

ToLua集成说明:链接

xlua集成:链接

QQ群:485016061 lua-protobuf1交流群

本项目提供在 Lua 全版本(5.1+、LuaJIT)下的protobuf 2/3 版本支持。提供了高级的消息编码/解码接口以及底层的protobuf wireformat 二进制数据操作接口。

使用高级接口,你需要通过 pb.load() 接口导入 protobuf 的消息描述文件(schema文件,.proto后缀名)的二进制版本(本质上是schema文件通过官方的 FileDescriptorSet 编码得到的二进制pb消息),导入的信息被存储在称为“state”的内存数据库中供消息编码/解码使用。你也可以通过pb模块提供的一系列接口来读取这个数据库里的内容。

要使用底层接口,你需要使用下面几个库提供的功能:

  • pb.slice:读取二进制的wireformat信息。
  • pb.buffer:写入二进制wireformat信息。
  • pb.conv:在Lua的数字类型和protobuf提供的一众数字数据类型之间转换。
  • pb.io:用于将pb模块用于工具当中使用:通过标准输入输出流读取写入二进制消息。

另外,为了得到schema文件的二进制版本(一般后缀名是.pb的文件),你需要官方protobuf项目提供的schema编译器二进制protoc.exe工具,如果在你的平台下获得这个工具太麻烦,或者你希望能有一个小体积的protobuf编译工具,你可以使用项目自带的另一个独立的纯 Lua库:protoc.lua文件。该文件提供了纯Lua实现的schema文件编译器,并集成了通过调用pb.load()载入编译结果的方便接口。但是要注意因为这个库是纯Lua实现的,它编译的速度会非常慢,如果你的schema文件太大或者编译的时候遇到了性能瓶颈。还是推荐你通过protoc.exe或者在开发时利用pb库自己写脚本将schema编译成.pb文件供生产环境使用。

安装

注意lua-prootbuf毕竟是个纯C的Lua库,而Lua库的编译安装是有门槛的。如果遇到了问题,建议询问有Lua的C模块使用经验的人,或者参阅《Lua程序设计》里的相关内容,预先学习相关知识。

另外,Lua的C模块是通用的,任何使用Lua的环境下都可以使用,然而XLua等C#环境通常有自己的一套规则,需要一些额外的集成操作。请确认自己对这些环境集成C模块足够了解,或者咨询足够了解的人获得帮助。这里只提供通用C模块的安装方法。

最简单的安装方法是使用Lua生态的包管理器luarocks进行安装(注意,这样依然需要你的电脑上装有C编译器,如果安装失败,你应该首先检查你的luarocks能否正常工作,即,能否正常安装其他Lua C模块):

luarocks install lua-protobuf

你也可以使用luarocks从源码安装(这样装的版本会更新一些):

git clone https://github.com/starwing/lua-protobuf
luarocks make rockspecs/lua-protobuf-scm-1.rockspec

如果你没有/不会配置luarocks,有一个Python写的方便脚本可以在你的电脑上安装luarocks——还是需要你有能正常运行的C编译器——当然,也需要你有Python。

pip install hererocks
git clone https://github.com/starwing/lua-protobuf
hererocks -j 2.0 -rlatest .
bin/luarocks make lua-protobuf/rockspecs/lua-protobuf-scm-1.rockspec CFLAGS="-fPIC -Wall -Wextra" LIBFLAGS="-shared"
cp protoc.lua pb.so ..

如果你想尝试Hard模式的话,自己手动编译也是可行的。

这是macOS的编译命令行:

gcc -O2 -shared -undefined dynamic_lookup pb.c -o pb.so

Linux的:

gcc -O2 -shared -fPIC pb.c -o pb.so

Windows的(注意那个Lua_BUILD_AS_DLL!在Windows上必须带这个预处理宏编译):

cl /O2 /LD /Fepb.dll /I Lua53\include /DLUA_BUILD_AS_DLL pb.c Lua53\lib\lua53.lib

样例

local pb = require "pb"
local protoc = require "protoc"

-- 直接载入schema (这么写只是方便, 生产环境推荐使用 protoc.new() 接口)
assert(protoc:load [[
   message Phone {
      optional string name        = 1;
      optional int64  phonenumber = 2;
   }
   message Person {
      optional string name     = 1;
      optional int32  age      = 2;
      optional string address  = 3;
      repeated Phone  contacts = 4;
   } ]])

-- lua 表数据
local data = {
   name = "ilse",
   age  = 18,
   contacts = {
      { name = "alice", phonenumber = 12312341234 },
      { name = "bob",   phonenumber = 45645674567 }
   }
}

-- 将Lua表编码为二进制数据
local bytes = assert(pb.encode("Person", data))
print(pb.tohex(bytes))

-- 再解码回Lua表
local data2 = assert(pb.decode("Person", bytes))
print(require "serpent".block(data2))

使用案例

零境交错

接口文档

请注意接口文档有些是.有些是:.代表这是静态函数,直接调用即可,:代表这是一个方法,需要在一个对象上调用。

protoc 模块

接口返回描述
protoc.new()编译器对象创建一个新的编译器对象
protoc.reload()true重新载入谷歌标准的schema信息(编译需要用到)
p:parse(string[, filename])table将文本schema信息转换成 DescriptorProto 消息的Lua表
p:compile(string[, filename])string将文本schema信息转换成二进制.pb文件数据
p:load(string[, filename])true将文本schema信息转换后,调用pb.load()载入内存数据库
p.loadedtable一个包含了所有已载入的 DescriptorProto 表的缓存表
p.unknown_import详情见下处理schema里import语句找不到文件的回调
p.unknown_type详情见下处理schema里未知类型的回调
p.include_importsbool编译结果中包含所有import的文件

要编译一个文本的schema信息,首先,生成一个编译器实例。一个编译器实例会记住你用它编译的每一个scehma文件,从而能够正确处理schema之间的import关系:

local p = protoc.new()

生成了编译器实例之后,可以给编译器实例设置一些选项或者回调:

-- 设置回调……
p.unknown_import = function(self, module_name) ... end
p.unknown_type   = function(self, type_name) ... end
-- ……和选项
p.include_imports = true

unknown_importunknown_type可以被设置成多种类型的值:如果被设置成true,则所有不存在的模块或者消息类型会自动生成一个默认值(空模块/空消息)而不会报错。pb.load()会自动处理空消息的合并,因此这样载入信息也不会出错。如果设置一个字符串值,那么这个字符串是一个Lua的正则表达式,满足正则表达式的模块或者消息类型会被设置成默认值,而不满足的则会报错:

p.unknown_type = "Foo.*"

上面的选项意味着所有Foo包里的消息会被当作好像已经载入了,即使没找到也不会报错。

如果这些回调被设置成一个函数,这个函数会在找不到模块/消息的时候被调用。调用的时候会传入找不到的模块/消息的名字,你需要返回一个DescriptorProto数据表(对模块而言),或者一个类型的名字和这个类型的实际分类,比如说message或者enum,如下所示:

function p:unknown_import(name)
  -- 如果找不到 "foo.proto" 文件而调用了这个函数,那就自己手动载入 "my_foo.proto" 文件并返回信息
  return p:parsefile("my_"..name)
end

function p:unknown_type(name)
  -- 如果找不到 "Type", 那就把它当 "MyType" 消息编译好了,注意前面那个“.”,那是包名。
  return ".My"..name, "message"
end

设置好这些选项或者回调以后,使用load()compile()parse()来按照你的需求得到想要的结果。这些函数都需要直接传入scehma的文本内容作为参数,可以可选地多传入当前schema对应的文件名,用于方便schema之间的import指令找到对应的文件,但是除非设置了include_imports,否则import指令即使找到了对应的文件也不会编译/加载对应文件,只是会检查类型引入是否正确。即使没有设置include_imports,也可以手动按照拓扑顺序依次load对应文件从而加载所有schema。

pb 模块

pb模块提供了编码/解码信息的高级接口、内存schema数据库的载入和读取接口,以及其他的一些方便的工具函数。

下面的表格里给出了pb模块里所有的函数,注意表格中的中“返回”一栏中的一些特殊返回值,这些返回值有一些约定俗成的含义:

  • type:这个返回值代表返回的是一个代表类型的字符串。".Foo"代表没有包名的proto文件里的Foo消息,而"foo.Foo"代表写了package foo;包名的proto文件里的Foo消息。

  • data:一个字符串,或者pb.Slice对象或者pb.Buffer对象,反正是个能表示二进制数据的东西

  • iterator:返回一个能在for in语句里使用的迭代器对象,比如说:

    for name in pb.types() do
      print(name)
    end
    

注意:只有pb.load()通过返回值返回是否出错,你要用assert(pb.load(...))去调用这个函数!其他的函数会直接扔一个错误异常,不需要你对返回值调用assert()函数。

接口返回描述
pb.clear()None清除所有类型
pb.clear(type)None清除特定类型
pb.load(data)boolean,integer将一个二进制schema信息载入内存数据库
pb.encode(type, table)string将table按照type消息类型进行编码
pb.encode(type, table, b)buffer同上,但是编码进额外提供的buffer对象里并返回
pb.decode(type, data)table将二进制data按照type消息类型解码为一个表
pb.decode(type, data, table)table同上,但是解码到你提供的表里
pb.pack(type, ...)string编码展开后的消息(后续参数按number顺序提供)
pb.unpack(data, fmt, ...)values...解码展开后的消息(同上)
pb.types()iterator遍历内存数据库里所有的消息类型,返回具体信息
pb.type(type)详情见下返回内存数据库特定消息类型的具体信息
pb.fields(type)iterator遍历特定消息里所有的域,返回具体信息
pb.field(type, string)详情见下返回特定消息里特定域的具体信息
pb.field(type, number)详情见下返回特定消息里特定域的具体信息
pb.typefmt(type)string得到 protobuf 数据类型名对应的 pack/unpack 的格式字符串
pb.enum(type, string)number提供特定枚举里的名字,返回枚举数字
pb.enum(type, number)string提供特定枚举里的数字,返回枚举名字
`pb.defaults(type[, tablenil])`table
pb.hook(type[, function])function获得或设置特定消息类型的解码钩子
pb.encode_hook(type[, function])function获得或设置特定消息类型的编码钩子
pb.option(string)string设置编码或解码的具体选项
pb.state()pb.State返回当前的内存数据库
pb.state(newstate | nil)pb.State设置或删除当前的内存数据库,返回旧的内存数据库
pb.tohex(string)string将string(通常是二进制数据)编码成16进制串用于显示,通常用于调试的目的。

内存数据库载入 Schema 信息

pb.load() 接受一个二进制的schema数据,并将其载入到内存数据库中。如果载入成功则返回true,否则返回false,无论成功与否,都会返回读取的二进制数据的字节数。如果载入失败,你可以检查在这个字节位置周围是否有数据错误的情况,比如被NUL字符截断等等的问题。

二进制流中是什么样的schema,就会载入什么样的schema。通常只能载入一个文件。如果需要同时载入多个文件(比如包括import后的文件,或者多个不相干文件),可以通过在使用protoc.exe或者protoc.lua编译二进制schema的时候编译多个文件,或者使用include_imports在二进制数据中包含多个文件的内容实现。注意根据protobuf的特性,直接将多个schema二进制数据连接在一起载入也是可行的。

类型映射

Protobuf 类型Lua 类型
double, floatnumber
int32, uint32, fixed32, sfixed32, sint32numberinteger (Lua 5.3+)
int64, uint64, fixed64, sfixed64, sint64number"#" 打头的 stringinteger (Lua 5.3+)
boolboolean
string, bytesstring
messagetable
enumstringnumber

内存数据库信息获取

可以使用pb.type()pb.types()pb.field()pb.fields()系列函数获取内存数据库内的消息类型信息。

内存数据库存储了可以编码/解码的所有的消息类型信息,如果内存数据库中无法查询到对应信息,则编码/解码可能失败。使用限定后的消息类型名字就可以获取对应的类型信息。比如foo 包里的Foo消息的限定消息类型名字".foo.Foo",如果没有包名,则直接在消息名前面加".",比如".Foo"就是没有包名的Foo消息的限定名称。

通过调用pb.type(),你可以获得下面的信息:

  • name:即限定的消息类型名称(如".package.TypeName"
  • basename:即去除了包名的消息类型名称(如"TypeName"
  • type:"map" | "enum" | "message",消息的实际类型——MapEntry类型,或者枚举,或者消息。

pb.types()返回了一个迭代器,就好像对内存数据库里存储的每个类型调用 pb.type()一样:

-- 打印出 MyType 消息的详细信息
print(pb.type "MyType")

-- 列出内存数据库里存储的所有消息类型的信息
for name, basename, type in pb.types() do
  print(name, basename, type)
end

pb.field() 返回了一个特定的消息里的一个域的详细信息:

  • name: 域名
  • number: schema中该域的对应数字(序号)
  • type: 域类型
  • default value: 域的默认值,没有的话是nil
  • "packed"|"repeated"| "optional":域的标签,注意并不支持required,会被当作optional
  • oneof_name:域所属的oneof块的名字,可选
  • , oneof_index:域所属的oneof块的索引,可选

pb.fields() 会返回一个好像对消息类型里的每个域调用pb.field()一样的迭代器对象:

-- 打印 MyType 消息类型里 the_first_field 域的详细信息
print(pb.field("MyType", "the_first_field"))

-- 遍历 MyType 消息类型里所有的域,注意你并不需要写全所有的详细信息(后面的信息会被忽略掉)
for name, number, type in pb.fields "MyType" do
  print(name, number, type) -- 只需要打印这三个
end

pb.enum() 转换枚举的名字和值:

protoc:load [[
enum Color { Red = 1; Green = 2; Blue = 3 }
]]
print(pb.enum("Color", "Red")) --> 1
print(pb.enum("Color", 2)) --> "Green"

其实枚举本身就是一种特殊的“消息类型”,在内存数据库里,枚举和消息其实没什么区别,因此使用pb.field()也能做到枚举的名字和值之间的转换,pb.enum()这个名字更多的只是语义上的区别,另外因为只返回一个值,可能会相对快一些。

默认值

你可以调用pb.defaults() 函数得到对应一个消息类型的一张Lua表,这张表存储了该消息类型所有域的默认值。

pb.defaults()函数的第一个参数是指定的消息类型名称,如果有可选的第二个参数,那么该参数会被指定为该类型的默认值。

其实通过pb.decode("Type")本身就能得到一张填充了默认值的Lua表。而pb.defaults不同的是,他提供的表会被内存数据库记住,如果你设置了use_default_metatable这个选项,那么这个默认值表就会成为对应类型被解码时被自动设置的元表—也就是说,可以支持解码一个空表,但是你能通过元表取得所有域的默认值。

另外,你可以传递"*array"或者"*map"作为特殊的类型名给pb.defaults()函数,效果是给解码出来的map/repeated设置元表。这个特性和use_default_metatable无关,如果不想设置元表了,只要删掉对应元表就可以禁用这个特性了。

示例如下:

   check_load [[
      message TestDefault {
         optional int32 defaulted_int = 10 [ default = 777 ];
         optional bool defaulted_bool = 11 [ default = true ];
         optional string defaulted_str = 12 [ default = "foo" ];
         optional float defaulted_num = 13 [ default = 0.125 ];
      } ]]
   print(require "serpent".block(pb.defaults "TestDefault"))
-- output:
-- {
--   defaulted_bool = true,
--   defaulted_int = 777,
--   defaulted_num = 0.125,
--   defaulted_str = "foo"
-- } --[[table: 0x7f8c1e52b050]]

钩子

如果通过pb.option "enable_hooks"启用了钩子功能,那么你可以通过pb.hook()函数为指定的消息类型设置一个解码钩子。一个钩子是一个会在该消息类型所有的域都被读取完毕之后调用的一个函数。你可以在这个时候对这个已经读取完毕的消息表做任何事。比如设置上一节提到的元表。

pb.hook()的第一个参数是指定的消息类型名称,第二个参数就是钩子函数了。如果第二个参数是nil,则这个类型的钩子函数会被清除;任何情况下,pb.hook()函数都会返回之前设置过的钩子函数(或者nil)。

钩子函数只有一个参数,即当前已经处理完的消息表。如果你同时还需要消息类型名称,那么可以使用以下工具函数:

local function make_hook(name, func)
  return pb.hook(name, function(t)
    return func(name, t)
  end)
end

你可以通过 pb.option “enable_enchooks” 启用编码钩子功能。编码钩子会在编码消息中任何 message 或者 enum 类型的成员之前调用。只有一个参数即该成员本体。如果你有返回任何值,那么这个值就会被作为这个 message 或者 enum 被编码。返回 nil (或者不返回值)则待编码的值不变。

编码钩子通过 pb.encode_hook() 函数设置,该函数和 pb.hook() 类似,但是用来设置编码钩子。

选项

你可以通过调用pb.option()函数设置选项来改变编码/解码时的行为。

目前支持的选项如下:

选项描述
enum_as_name解码枚举的时候,设置值为枚举名 (默认)
enum_as_value解码枚举的时候,设置值为枚举值数字
int64_as_number如果值的大小小于uint32允许的最大值,则存储整数,否则存储Lua浮点数类型(\le Lua 5.2,可能会导致不精确)或者64位整数类型(\ge Lua 5.3,这个版本开始才支持64位整数类型) (默认)
int64_as_string同上,但返回一个前缀"#"的字符串以避免精度损失
int64_as_hexstring同上,但返回一个16进制的字符串
auto_default_values对于 proto3,采取 use_default_values 的设置;对于其他 protobuf 格式,则采取 no_default_values 的设置 (默认)
no_default_values忽略默认值设置
use_default_values将默认值表复制到解码目标表中来
use_default_metatable将默认值表作为解码目标表的元表使用
enable_hookspb.decode 启用钩子功能
disable_hookspb.decode 禁用钩子功能 (默认)
enable_enchookspb.encode启用钩子功能
disable_enchookspb.encode 禁用钩子功能 (默认)
encode_default_values默认值也参与编码
no_encode_default_values默认值不参与编码 (默认)
decode_default_array配合no_default_values选项,对于数组,将空值解码为空表
no_decode_default_array配合no_default_values选项,对于数组,将空值解码为nil (默认)
encode_order保证对相同的schema和data,pb.encode编码出的结果一致(按照field number的顺序进行编码)。如果message中空field特别多,可能会导致效率下降。
no_encode_order不保证对相同输入,pb.encode编码出的结果一致。(默认)
decode_default_message将空子消息解析成默认值表
no_decode_default_message将空子消息解析成 nil (default)

注意int64_as_stringint64_as_hexstring 返回的字符串会带一个 '#' 字符前缀,因为Lua会自动把数字表示的字符串当作数字使用,从而导致精度损失。带一个前缀会让Lua认为这个字符串并不是数字,从而避免了Lua的自动转换。

本模块中所有接受数字参数的函数都支持使用带'#'前缀的字符串用于表示数字,无论是否开启了相关的选项都是如此。如果需要表格中提供的数字,也同样支持使用前缀字符串指定。

多内存数据库

pb 模块支持同时存在多个内存数据库,但是你每次只能使用其中的一个。内存数据库仅仅存储所有的类型。默认值表、选项等等不受影响。你可以通过pb.state()函数来获得/设置内存数据库。

如果要新建一个内存数据库,调用 pb.state(nil) 函数清除当前内存数据库(会在返回值中返回),如果在调用pb.load()函数载入消息的类型的时候发现当前没有内存数据库,那么载入器会自动创建一个新的内存数据库。从而支持多个内存数据库同时存在。你可以同样使用pb.state() 函数来切换这多个内存数据库。

下面的示例能让你在独立的内存数据库中完成某些操作:

local old = pb.state(nil) -- 清空当前内存数据库
-- 如果要使用 protoc.lua, 这里还需要调用 protoc.reload() 函数
assert(pb.load(...)) -- 载入新的消息类型信息
-- 开始编码/解码 ...
pb.state(old) -- 恢复旧的内存数据库(并丢弃刚才新建的那个)

需要注意的是 protoc.Lua 模块会注册一些Google标准消息类型到内存数据库中,因此一定要记得在创建新的内存数据库之后,调用 proto.reload() 函数恢复这些信息。

pb.io 模块

pb.io 模块从文件或者 stdin/stdout中读取或者写入二进制数据。提供这个模块的目的是在Windows下,Lua没有二进制读写标准输入输出的能力。然而要实现一个官方的protoc插件则必须能够读写二进制的标准输入输出流。因为官方的protoc找到插件以后会用插件启动新进程,然后把读取编译好的proto文件的内容用二进制的FileDescriptorSet消息的格式发给新进程的stdin。所以提供了这个插件,才可以用纯Lua写官方的插件。

pb.io.read(filename) 负责从提供的文件名指定的文件里读取二进制数据,如果不提供文件名,那么就直接从 stdin 读取。

pb.io.write()pb.io.dump() 和Lua标准库里的 io.write() 是一样的,只是会写二进制数据。前者写stdout,而后者写到第一个参数提供的文件名所指定的文件中。

这些函数执行成功的时候都会返回true,执行失败的时候会返回 nil, errmsg,所以调用的时候记得用assert()包住以捕获错误。

接口返回描述
io.read()stringstdin读取所有二进制数据
io.read(string)string从文件中读取所有二进制数据
io.write(...)true将二进制数据写入 stdout
io.dump(string, ...)stringwrite binary data to file name

pb.conv 模块

pb.conv 能够在Lua和protobuf提供的各种数字类型之间进行转换。如果你要使用底层接口,那么这个模块就会很有用。

Encode FunctionDecode Function
conv.encode_int32()conv.decode_int32()
conv.encode_uint32()conv.decode_uint32()
conv.encode_sint32()conv.decode_sint32()
conv.encode_sint64()conv.decode_sint64()
conv.encode_float()conv.decode_float()
conv.encode_double()conv.decode_double()

pb.slice 模块

“Slice”是一种类似于“视图”的对象,它代表某个二进制数据的一部分。使用slice.new()可以创建一个slice视图,它会自动关联你传给new函数的那个对象,并且在它之上获取一个指针用于读取二进制的底层wireformat信息。

slice对象最重要的方法是slice:unpack(),它的第一个参数是一个格式字符串,每个格式字符代表需要解码的一个类型。具体的格式字符下面会用表格的形式给出,这些格式字符也可以使用pb.typefmt()函数从protobuf的基础类型的名字转换而来。请注意,pb.buffer模块的重要方法buffer:pack()使用的是同一套格式字符:

格式字符描述
v基础类型,变长的整数类型,1到10个字节(varint
d基础类型,4 字节数字类型
q基础类型,8 字节数字类型
s基础类型,带长度数据通常是 string, bytes 或者 message 类型的数据
cfmt之后额外接受一个数字参数 count,直接读取接下来的 count 个字节
b布尔类型:bool
f4 字节浮点数类型:float
F8 字节浮点数类型:double
ivarint表示的32位有符号整数:int32
jvarint表示的zig-zad 编码的有符号32位整数:sint32
uvarint表示的32位无符号整数:uint32
x4 字节的无符号32位整数:fixed32
y4 字节的有符号32位整数:sfixed32
Ivarint表示的64位有符号整数:int64
Jvarint表示的zig-zad 编码的有符号64位整数:sint64
Uvarint表示的64位无符号整数:uint64
X4 字节的无符号32位整数:fixed32
Y4 字节的有符号32位整数:sfixed32

slice对象内部会维护“当前读到哪儿”的位置信息。每当使用unpack读取的时候,会自动指向下一个待读取的偏移,可以使用#slice 方法得知“还剩下多少字节的数据没有读”。下面的表给出了操控“当前位置”的“格式字符”——注意,这些格式字符只能用在unpack函数里,pack是不给用的:

格式字符描述
@返回1开始的当前读取位置偏移,以当前视图开始为1
*fmt参数后提供一个额外参数,直接设置偏移为这个参数的值
+fmt参数后提供一个额外参数,设置偏移为加上这个参数以后的值

下面是一个“将一个 varint 类型的值读取两次”的例子:

local v1, v2 = s:unpack("v*v", 1)
-- v: 读取一个 varint
-- *: 接受下一个参数(这里是1),将其设置为当前位置:现在读取位置回到一开始了
-- v: 现在,把上一个 varint 再读一遍

除了读取位置以外,slice还支持“进入”和“退出”视图,也就是视图栈的功能。比如说,你的协议里是一个消息A,套一个消息B,再套一个消息C,你用slice:new()可以得到整个消息A的视图,那么在发现消息B出现的时候,你可以通过s:enter()先读取一个带长度数据(这个数据读取后被跳过),然后将视图缩小到这个带长度数据内部:也就是消息B的内容。同理可以处理消息C。当处理完之后,调用s:leave()可以回到原视图了,注意在读取带长度数据的时候这个数据就被跳过了,这时回到原视图读取位置正好是带长度数据之后,就可以继续处理后续的消息了。

s:enter()还支持接受两个参数i和j直接给出偏移量位置。注意这种情况下,s:leave()就不会修改读取位置了——因为并没有读取操作。

下面是一个使用底层接口读取一个消息的示例:

local s = slice.new("<data here>")
local tag = s:unpack "v"
if tag%8 == 2 then -- tag 是 string/bytes 类型?这可能就是个子消息
  s:enter() -- 读取这个带长度数据,进入数据本身
  -- 现在可以对这个带长度数据做任何事儿了:比如说,读取一堆的fixed32数据
  local t = {}
  while #s > 0 do
    t[#t+1] = s:unpack "d"
  end
  s:leave() -- 搞定了?回到上级视图继续读取接下来的数据
end

以下是pb.slice模块里的所有接口:

接口返回描述
slice.new(data[,i[,j]])Slice object创建一个新的 slice 对象
s:delete()nones:reset()相同,重置并释放slice对象引用的内存
tostring(s)string返回slice的字符串表示信息
#snumber得到当前视图还未读取的字节数
s:result([i[, j]])String得到当前视图的二进制数据
s:reset([data[,i[,j]]])self将slice对象重置绑定另一个数据源
s:level()number返回当前视图栈的深度
s:level(number)p, i, j返回第n层视图栈的信息(读取位置、视图偏移)
s:enter()self读取一个带长度数据,并将其视图推入视图栈
s:enter(i[, j])self将[i,j]字节范围的数据推入视图栈
s:leave([number])self, n离开n层的视图栈(默认离开一层),返回当前视图栈深度
s:unpack(fmt, ...)values...利用fmt和额外参数,读取当前视图内的信息

pb.buffer 模块

Buffer模块本质上其实就是一个内存缓存,类似Java的“StringBuilder”的东西。他是一个构建wireformat数据的底层接口。使用buffer:pack()可以向缓存里新增数据,使用buffer:result()可以得到编码后的二进制数据结构。或者使用buffer:tohex()获取人类可读的16进制编码字符串。

buffer.pack() 使用和 slice.unpack()相同的格式字符,请参见pb.slice的文档获取详细的格式字符的表格。除此以外,pack还支持 '()'格式字符,用于支持编码嵌套的带长度数据——也就是嵌套消息的结构。括号格式字符是可以嵌套的。下面是一个例子:

b:pack("(vvv)", 1, 2, 3) -- 获得一个编码了3个varint的带长度数据

buffer.pack() 也支持 '#' 格式字符:该格式字符会额外读取一个长度参数oldlen,并将bufferoldlen开始到结束的所有字节数据重新编码为一个带长度数据:

b:pack("#", 5) -- 将b里从第五个字节开始的所有数据编码为一个带长度数据类型数据

这个功能可以用来更方便地编码长度不清楚的嵌套子消息:先读取一个当前长度,然后直接将子消息编码进buffer,一旦编码结束,就编码一个"#"格式,这样之前编码的所有信息就自动成为了一个带长度数据——即合法的子消息类型数据。

下面是 pb.buffer 模块里所有的接口:

接口返回描述
buffer.new([...])Buffer object创建一个新的buffer对象,额外参数会传递给b:reset(...)函数
b:delete()noneb:reset(),释放buffer使用的内存
tostring(b)string返回buffer的字符串表示信息
#bnumber返回buffer中已经完成编码的字节数
b:reset()self清空buffer中的所有数据
b:reset([...])self清空buffer,并将其数据设置为所有的参数,如同io.write()一样处理其参数
b:tohex([i[, j]])string返回可选范围(默认是全部)的数据的16进制表示
b:result([i[,j]])string返回编码后二进制数据。允许只返回一部分。默认返回全部
b:pack(fmt, ...)self利用fmt和额外参数,将参数里提供的数据编码到buffer中