☰
ProtoBuf 转 EmmyLua 注解:为 Lua 代码补全类型提示
2026/10/15 12:41:45 网站建设 项目流程

最早开始做这个东西,是在一个用 Lua 写战斗逻辑的项目里。客户端和服务端之间用 ProtoBuf 定义协议,消息体一多,光靠记忆根本记不住字段名。手滑把bag_id写成bagId,IDE 不会报错,一跑起来等数据解析出来才发现拿了个 nil,只能打断点加日志再走一遍。后来我做了个小工具,把 .proto 文件转成 EmmyLua 注解风格的 API 提示文件,在写 Lua 的时候 IDE 就能弹字段补全、提示表结构、甚至顺着嵌套关系一路点下去。这篇文章把整个实现过程拆开讲,重点放在嵌套结构、map、enum 这些绕不开的场景上。

1. 为什么要把 ProtoBuf 翻译成 EmmyLua 提示文件

1.1 痛点还原

先说清楚当时的环境。客户端协议解析用的是一套通用的 protobuf 运行时,网络层把二进制数据解码之后,返回给 Lua 的是一个普通 table:

local msg = pb.decode("game.Player", raw_bytes)

这句执行完,msg在 IDE 眼里就是一个unknown。你写msg.的时候,编辑器完全不知道下面该提示什么。你只能打开 proto 文件翻字段,或者凭印象写。一旦字段重新命名、调整层级,代码里所有引用都不会被编译器发现,全部推到运行时才炸。

这种问题在纯面向对象的语言里几乎不存在,因为 C++、C# 有编译期生成代码和强类型检查。Lua 没有编译期,但好在有 EmmyLua 插件,它通过对注释的静态解析,能在编辑器里模拟出类型系统的效果。于是思路就变成了:能不能把 proto 的定义,翻译成 EmmyLua 能识别的.lua注释文件?答案是可以,而且要做得足够好,必须把嵌套消息、map、枚举这些语法都处理掉。

1.2 为什么我不满足于现成工具

在动手前,我把当时能找到的 proto 转 lua 提示方案都看了一遍。坦率说,能用,但都有明显短板。

一个是很多工具只做平铺处理。遇到一个多层嵌套的 message,它只会生成最外层的类,内部嵌套消息要么合并成下划线命名,要么直接丢字段。另一个问题是对 map 处理得不好。ProtoBuf 3 里的map<string, int32>在底层的 descriptor 里其实是一个特殊的 repeated message,很多生成器没做这一层判断,直接把_entry这种中间类型暴露出来,生成的文件里出现一堆XxxEntry,看着就头疼。还有的就干脆不支持 enum,字段只能标成number,写枚举常量时完全没提示。

其实这也正常,因为这些工具大多是为某个具体项目临时写的,只覆盖那一个项目用到的语法子集。而我想做的,是一个能覆盖常规游戏项目大多数协议写法的通用生成器。

1.3 实现思路雏形

我的想法很简单:把.proto文件当成“数据字典”,用一种可靠的方式把它解析成结构化数据,然后按 EmmyLua 的注解规则逐条映射,输出一个“影子 Lua 文件”。这个影子文件只包含类型声明和注释,不参与任何运行时逻辑。

核心路径可以概括为:

  1. 用 protoc 编译器把.proto解析成标准的 descriptor 二进制文件。
  2. 在 Python 里加载 descriptor,得到完整的消息结构树。
  3. 遍历每条 message 和 enum,递归处理嵌套结构。
  4. 把每个字段映射成 EmmyLua 的---@field注解。
  5. 输出.lua文件,并在 IDE 里把对应目录标记为 Lua 库路径。

这样,日常写代码时 IDE 就能根据注解提示字段名、字段类型,并且能跨文件识别所有自定义消息类型。

2. 技术方案选型:描述文件加递归生成

2.1 为什么不用正则硬解析

如果你也有过解析 proto 文件的冲动,大概率第一反应是写正则,把 message 块、花括号、字段声明一锅端。我在最早的原型里确实试过,用正则匹配一个中等规模的 proto 文件,表现得还行,但一旦牵涉到嵌套消息,问题就冒出来了。

正则最难处理的是嵌套层级。message A里面套了message B,B 里面又套了message C,花括号的配对关系靠正则几乎没法稳定表达。哪怕用带递归的表达式,遇到注释、字符串、oneof、reserved段落,匹配结果随时会走样。更别提import多个文件之后,类型之间的跨文件引用关系,单纯靠文本扫描根本拿不到。

所以与其自己发明解析器,不如直接用官方编译器。protoc 本身就是解析.proto最好的实现,它能处理所有语法细节。只要让它输出一份 descriptor,我们就能站在编译器的肩膀上往下做。

2.2 descriptor_set_out 生成的中间产物

protoc 提供了一个特别好用的参数:--descriptor_set_out。它能把一组 proto 文件编译成一个二进制描述文件,里面包含了文件内所有消息、枚举、字段、依赖关系的结构化编码。这个文件体积不大,但信息密度极高,几乎等价于 proto 文件的抽象语法树。

我们后面在 Python 端直接用google.protobuf.descriptor_pb2.FileDescriptorSet反序列化这个文件,就能拿到FileDescriptorProto列表。每一个消息、每一个字段、每一个枚举,都被解析成了对象,能直接走属性访问,比用正则解析文本舒服太多。

这里有一个值得养成的习惯:不要让生成脚本死记.proto文件路径去单独解析文件,而是先统一生成描述文件,再在脚本里加载。因为描述文件天然处理了多文件import关系,生成逻辑只需要关心“消息长什么样”,而不用关心“消息是从哪个文件来的”。

2.3 生成器整体架构

整个生成器的架构我拆成四层:

  • 协议编译层:调用python -m grpc_tools.protoc,把.proto转为all.desc。
  • 数据加载层:解析all.desc,把FileDescriptorProto加入一个DescriptorPool,建立全量类型索引。
  • 类型映射层:把 proto 字段类型映射成 EmmyLua 可以识别的类型字符串。
  • 文本生成层:按照文件为单位输出.lua注释文件。

这种分层的好处是每一层都能独立测试。协议编译层出错可以单独排查命令参数;类型映射层出错可以单独调试映射函数;文本生成层出错则直接看输出文件,定位很快。

另外我还要强调一点:加载 descriptor 时要使用DescriptorPool,而不是自己维护 map。因为FieldDescriptor.message_type、enum_type这些属性需要 pool 来解析引用关系,自己手写一套全限定名查找不仅容易出错,遇到跨文件引用更是麻烦。

3. 核心细节:字段类型与嵌套结构的映射规则

3.1 基础类型映射表

这里先给出一张最基础的映射表。不同的 protobuf 运行时在 Lua 里实际返回的类型会有微小差异,但站在 EmmyLua 注解层,下面这张表是安全通用的:

Protobuf 类型EmmyLua 类型说明
double / floatnumber浮点统一弱化为 number
int32 / int64 / uint32 / uint64 / sint / fixed 系列integerLua 里的整型
boolboolean布尔值
stringstring字符串
bytesstringbytes 在 Lua 里通常也是 string
message消息的全限定类名如game.Item
repeated message消息全限定类名 +[]如game.Item[]
map<K, V>table<K, V>如table<string, game.Attribute>
enum枚举的全限定类名也可以降级为integer

这个表看起来简单,真正写进代码的时候要注意两点。

第一,repeated放在[]前缀还是后面。EmmyLua 支持game.Item[]这种数组语法,也支持table<integer, game.Item>。我用的是前者,因为它更贴近普通 Lua 数组的直觉,IDE 对player.items[1].name这类补全支持得也很好。

第二,map 的 key 类型不要写成string或者integer之外的东西。proto 的 map key 只允许整型和字符串,映射过去不会遇到意外。

3.2 message 字段和嵌套结构的生成策略

嵌套结构是整个生成器里最容易出错的部分,我单独讲。

proto 文件里可以定义嵌套消息,比如:

message Guild { message Member { string name = 1; int32 rank = 2; } repeated Member members = 1; }

在descriptor_pool里,Member的全限定名是Guild.Member,它对应的Descriptor通过Guild.nested_types访问。生成时我把它当成独立类型单独生成一个---@class Guild.Member块,字段类型直接用全限定名引用。

来看最终的生成效果:

---@meta ---@enum game.NickColor game_NickColor = { NORMAL = 0, GOLD = 1, } ---@class game.Guild ---@field guild_id integer ---@field guild_name string ---@field members game.Guild.Member[] local game_Guild = {} ---@class game.Guild.Member ---@field name string ---@field rank integer local game_Member = {}

这里有两个关键点。

  • 类名使用全限定名game.Guild.Member,这样即使项目里有多个同名Member消息,只要所属的 package 或父消息不同,也不会在命名空间上打架。
  • 本地锚点变量用了game_Guild这种下划线写法,而不是Guild.Member。因为 Lua 语法里变量名不能带点。EmmyLua 解析---@class时,会把这个变量当作类型锚点,起什么名字不会影响类型补全。

嵌套层数深也不怕,因为生成逻辑是递归的。每个Descriptor都可能有nested_types,遍历时只要把子消息也加入输出队列,深度再深也能稳定生成。

3.3 循环引用和 Map 的检测

嵌套结构里最隐蔽的问题有两个:循环引用和 map 展开。

先说循环引用。比如message A里有一个B b字段,message B里又有一个A a字段,这就是耦合类型。因为要生成的.lua文件只是纯注释,不涉及真正的运行时赋值,所以不存在“必须先定义后使用”的约束。IDE 在索引阶段会等全项目扫描完再解析类型,我不需要调整生成顺序。只要最终文件里存在---@class A和---@class B的声明,IDE 就能正确识别互相引用。

再说 map。在 proto 的 descriptor 里,map<string, int32>并不会直接给你一个 map 类型,而是被编译器展开成一个带map_entry选项的嵌套消息,字段名通常是key和value。如果没有识别map_entry,看到的就是一个普通的 repeated message,生成结果会变成{ key = ..., value = ... }的结构,跟真实用法完全对不上。

正确做法是在判断字段类型时,先看它是不是 message,如果是就检查它的GetOptions().map_entry。只有标记为 true,才按 map 处理,并分别取 key 字段和 value 字段的类型,拼出table<K, V>。

3.4 oneof 和 optional 的处理

现在的 proto3 里optional字段和oneof字段也经常出现。在注解层,它们不影响类型映射,字段名照常生成即可。唯一可能造成的困惑是oneof在运行时同一时刻只会有其中一个字段有值,但这属于业务约束,IDE 注释层面没法表达互斥关系。所以我的策略是全部生成字段,保持提示完整。

3.5 关于用 json_name 还是原始字段名

这是一个容易踩坑的小细节。proto 字段一般写成下划线风格,比如player_level。如果 Lua 侧的协议库是按原始字段名解码,那---@field player_level integer是对的。但如果你的链路走的是 JSON 转换,解码后的表里可能已经是playerLevel这种驼峰命名,这时生成器就要改用FieldDescriptor.json_name。

我建议不要自动猜,而是给生成器提供一个命令行参数,比如--field-style original或--field-style json,把这个决定权留给使用方。项目里用了哪种解码方式,就传哪种参数,省得生成后才发现提示和实际字段名对不上。

4. 实操:完整脚本与接入 IDE

4.1 环境准备

这个生成器只依赖 Python 和两个库,安装成本很低:

pip install protobuf grpcio-tools

项目结构我习惯这样维护:

proto/ # 所有 .proto 文件 stubs/ # 生成出来的 EmmyLua 提示文件 gen_stubs.py # 生成器脚本

grpcio-tools里自带了一个可以在命令行直接调用的 protoc,这样就不用在机器上额外安装 C++ 版 protoc 了。调用方式是:

python -m grpc_tools.protoc -I proto --descriptor_set_out=stubs/all.desc proto/*.proto

注意,多个 proto 文件之间如果有 import,-I参数必须指定到它们共同的根目录,否则 protoc 会找不到依赖。

4.2 生成器核心代码

我贴一个可运行的完整脚本,这个版本覆盖了 message、嵌套消息、enum、repeated、map 和基本标量类型。

import glob import subprocess import sys from pathlib import Path from google.protobuf import descriptor_pb2 from google.protobuf import descriptor_pool from google.protobuf.descriptor import FieldDescriptor TYPE_MAP = { FieldDescriptor.TYPE_DOUBLE: "number", FieldDescriptor.TYPE_FLOAT: "number", FieldDescriptor.TYPE_INT64: "integer", FieldDescriptor.TYPE_UINT64: "integer", FieldDescriptor.TYPE_INT32: "integer", FieldDescriptor.TYPE_FIXED64: "integer", FieldDescriptor.TYPE_FIXED32: "integer", FieldDescriptor.TYPE_BOOL: "boolean", FieldDescriptor.TYPE_STRING: "string", FieldDescriptor.TYPE_BYTES: "string", FieldDescriptor.TYPE_UINT32: "integer", FieldDescriptor.TYPE_ENUM: None, # 需要走 enum_type 逻辑 FieldDescriptor.TYPE_SFIXED32: "integer", FieldDescriptor.TYPE_SFIXED64: "integer", FieldDescriptor.TYPE_SINT32: "integer", FieldDescriptor.TYPE_SINT64: "integer", FieldDescriptor.TYPE_MESSAGE: None, # 需要走 message_type 逻辑 } def emmy_field_type(field: FieldDescriptor) -> str: base = TYPE_MAP.get(field.type) if field.type == FieldDescriptor.TYPE_MESSAGE: entry_type = field.message_type if entry_type.GetOptions().map_entry: key_field = entry_type.fields_by_name["key"] value_field = entry_type.fields_by_name["value"] key_t = emmy_field_type(key_field) val_t = emmy_field_type(value_field) return f"table<{key_t}, {val_t}>" base = field.message_type.full_name elif field.type == FieldDescriptor.TYPE_ENUM: base = field.enum_type.full_name if base is None: return "any" if field.label == FieldDescriptor.LABEL_REPEATED and field.type == FieldDescriptor.TYPE_MESSAGE: entry_type = field.message_type if not entry_type.GetOptions().map_entry: return f"{base}[]" if field.label == FieldDescriptor.LABEL_REPEATED and field.type != FieldDescriptor.TYPE_MESSAGE: if base != "any": return f"{base}[]" return base def gen_enum_block(enum_desc) -> list: cls = enum_desc.full_name anchor = cls.replace(".", "_") lines = [f"---@enum {cls}", f"local {anchor} = {{"] for value in enum_desc.values: lines.append(f" {value.name} = {value.number},") lines.extend(["}", ""]) return lines def gen_message_block(msg_desc) -> list: cls = msg_desc.full_name anchor = cls.replace(".", "_") lines = [f"---@class {cls}"] for field in msg_desc.fields: lines.append(f"---@field {field.name} {emmy_field_type(field)}") lines.append(f"local {anchor} = {{}}") lines.append("") return lines def collect_types(package_prefix: str, type_container, outputs): # 先收集 enum,再收集 message for enum in type_container.enum_types: outputs.append(gen_enum_block(enum)) for msg in type_container.message_types: collect_types(package_prefix, msg, outputs) outputs.append(gen_message_block(msg)) for nested in msg.nested_types: pass # 已经在上面的递归里收集过 def main(): proto_dir = Path("proto") stub_dir = Path("stubs") stub_dir.mkdir(exist_ok=True) proto_files = sorted(glob.glob(str(proto_dir / "*.proto"))) if not proto_files: print("no proto files found") return desc_path = str(stub_dir / "all.desc") subprocess.run( [ sys.executable, "-m", "grpc_tools.protoc", "-I", str(proto_dir), f"--descriptor_set_out={desc_path}", ] + proto_files, check=True, ) pool = descriptor_pool.DescriptorPool() file_set = descriptor_pb2.FileDescriptorSet() file_set.ParseFromString(Path(desc_path).read_bytes()) for file_descriptor in file_set.file: pool.Add(file_descriptor) for file_descriptor in file_set.file: lines = [ "---@meta", f"--- 该文件由 gen_stubs.py 自动生成,请勿手工修改", "", ] collect_types(file_descriptor.package, file_descriptor, lines) out_path = stub_dir / f"{Path(file_descriptor.name).stem}.lua" out_path.write_text("\n".join(lines), encoding="utf-8") print(f"generated: {out_path}") total_types = 0 for fd in file_set.file: total_types += len(fd.message_type) + len(fd.enum_type) print(f"total top-level types: {total_types}") if __name__ == "__main__": main()

简单说一下这段代码里两个比较容易理解错的地方。

collect_types里的package_prefix实际上没怎么用,因为类型全限定名已经由 descriptor 里的full_name直接给出,不需要自己拼。保留这个参数只是为了标记函数语义,写的时候别重复拼包名,否则会生成game.game.Player。

emmy_field_type对repeated和 map 的判断分得很细。前缀判断field.type == FieldDescriptor.TYPE_MESSAGE是必需的,因为 map 在 descriptor 层面也是 message,必须优先判断是否 map_entry,否则会走到[]分支,生成出错误的table<int32, int32>[]这类类型。

4.3 验证生成结果

用一个简单的 proto 文件做验证:

syntax = "proto3"; package game; enum NickColor { NORMAL = 0; GOLD = 1; PURPLE = 2; } message Player { string name = 1; int32 level = 2; repeated Item items = 3; map<string, Attribute> attrs = 4; NickColor color = 5; } message Item { int32 item_id = 1; string item_name = 2; } message Attribute { int32 value = 1; int32 level = 2; }

运行生成器后,stubs/game.lua长这样:

---@meta --- 该文件由 gen_stubs.py 自动生成,请勿手工修改 ---@enum game.NickColor local game_NickColor = { NORMAL = 0, GOLD = 1, PURPLE = 2, } ---@class game.Player ---@field name string ---@field level integer ---@field items game.Item[] ---@field attrs table<string, game.Attribute> ---@field color game.NickColor local game_Player = {} ---@class game.Item ---@field item_id integer ---@field item_name string local game_Item = {} ---@class game.Attribute ---@field value integer ---@field level integer local game_Attribute = {}

在 IDE 里新建一个 Lua 文件,写这样一段代码:

---@param p game.Player local function print_player(p) p. end

当光标停在p.后面时,IDE 会弹出name、level、items、attrs、color。继续写p.items[1].,会继续提示Item的字段。补全深度完全跟着 proto 定义走,这就是我们想要的效果。

4.4 把 stub 目录接入 IDE

生成完的stubs目录,建议不要和业务 Lua 代码混在一起,专门作为一个提示库目录。在 JetBrains 系的 IDE 里,可以右键stubs目录,选择标记为源码根目录或 Lua 库目录;在 VS Code 配合 EmmyLua 插件,则在设置里把该目录加入 Lua workspace library。

另外一个重要细节是 stub 文件里都写了---@meta标记。这个标记意味着该文件只是纯类型定义,不会被当作真实运行文件。这样就算整个业务运行时误扫到了这个目录,也不会因为空表声明导致任何副作用。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象可能原因处理方式
生成的类名出现Entry后缀map 字段被当成普通 repeated message检查是否判断了GetOptions().map_entry
枚举字段没有提示枚举项字段映射成了integer而不是 enum 类型让 generator 输出 enum 的---@enum声明并引用全限定名
嵌套消息找不到类型本地锚点变量名带了点,比如local Outer.Inner = {}锚点变量用下划线替代点号
字段提示的是驼峰,但实际代码用下划线访问不小心用了json_name而没有配置开关在生成器里加--field-style参数显式控制
stub 文件被业务代码误加载文件没有任何标记文件开头加---@meta
多个 proto 文件同名的顶层消息互相覆盖类名没有带 package 前缀生成full_name而不是name
all.desc生成失败报 import 找不到-I参数根目录不对让 protoc 的-I指向所有 proto 的共同根目录

5.2 我怎么排查生成文件对不上的问题

我最常用的一招是:生成完成后,不要急着去 IDE 里试,先直接看.lua文件里的---@field类型。如果某个字段的类型和你预期不一样,立刻回看 descriptor 里这个字段的原始信息。

调试时可以临时写一小段打印脚本,把某个 message 里所有字段的type、label、type_name、json_name都打出来。重点看type_name是不是带前导点,比如.game.Item。带到生成代码里时,要确保拿的是full_name而不是type_name,因为type_name的自定义类型是带点的,直接用会拼出错误类名。

另一个好习惯是,只在开发期保留all.desc文件,正式提交到 git 时可以排除,因为它属于中间产物,每次生成都会重建。但如果团队里有别人的机器装不了 grpc_tools,那也可以把 desc 文件提交到仓库里作为兜底,生成脚本运行时检查文件是否存在,再决定是否需要重新编译。

5.3 在真实项目里怎么平稳落地

我第一次接入时不敢一次性让所有协议都走这个生成器,只挑了一个常用协议文件跑,跑通后再逐步扩大范围。原因很简单,如果某个消息嵌套特别深、涉及很多oneof和import,第一版生成出问题也容易定位。

同时,为了避免生成文件在团队合并时产生无谓冲突,我建议把stubs目录也加入生成流程而不是手工维护。任何人改了 proto,重新跑一遍脚本就能同步更新提示文件。CI 上甚至可以加一条检查命令,判断当前 git 工作区里有没有 proto 文件和 stub 文件的差异,避免有人改了 proto 但忘提交 stub。

还有一个常见业务场景:消息在客户端解码后,常用一个通用的decode函数返回。这个函数本身没有类型信息,为了让 IDE 正确推导,可以给项目里这个函数加上---@return注解,或者在上层业务里显式声明参数类型。比如:

---@param bytes string ---@return game.Player local function decode_player(bytes) return pb.decode("game.Player", bytes) end

这样从decode_player拿到的对象,后续所有字段访问都能有补全。这一步虽然不是生成器本身的功能,但它是整条链路生效的关键一环,不然生成了 stub 文件,IDE 也不知道你的变量其实就是game.Player。

6. 生成器还能怎么扩展

做到这里,这个工具已经能覆盖我项目里的日常协议了,但它的边界也很清楚。

如果你需要更细的自动补全,可以给 enum 字段的注解再加上一个可选的---@cast提示,或者把字段类型从枚举全限定名改成integer但额外生成常量集合,具体看团队习惯。

如果你要生成的协议文件特别多,可以改成 protoc 插件模式。也就是写一个protoc-gen-lua_type的可执行文件,让 protoc 把CodeGeneratorRequest通过 stdin 传给你,你直接从 request 里读FileDescriptorProto,然后向 stdout 返回CodeGeneratorResponse。这样生成逻辑可以和 protoc 无缝集成到构建系统里,改动一行配置就完成整个工程的重建。

如果你还希望 IDE 里能看到字段的注释说明,可以从.proto的SourceCodeInfo里提取字段上方注释文本,一并写入---@field那一行的后置说明里。这个功能会明显提升伙伴们的使用体验,但实现起来要额外处理多行注释的格式化,属于锦上添花的项目。

我个人在实际项目里用到后来最大的体会是:这类生成工具的难点从来不在代码本身,而在于把它嵌进团队的工作流。一次性生成的脚本很容易写,难的是保证每次 proto 改动之后,stub 文件都能准时而有效地更新,并且所有人都愿意用起来。当你做到这一步,就会发现之前那些在 Lua 里手搓协议带来的低级 bug,几乎被消灭得干干净净。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询