简介:面向Unity游戏开发者的Excel数据解析工具,利用Protocol Buffers将xls文件转换为Unity可直接使用的C#类,解决策划配置与代码之间数据同步效率低的问题,适合中高级开发者阅读与二次改造。工具涉及Python脚本、protobuf序列化、protobuf-net,并包含C++底层模块;搭建Python与protobuf环境后,可通过修改xls内容调整角色属性、物品配置等数据,避免改动源码。包体为zip压缩包,共34个文件,以C++源码(12个cpp与12个h)为主,还有Python转换脚本、Go文件、示例xls表、SConstruct构建说明及README文档,整体仅375KB,轻量且便于快速上手。目前已有661人学习/下载。读者可从中获取完整的项目目录结构、Python转换脚本、可复用的C++组件以及应用框架代码,便于理解protobuf数据管线的完整落地流程,减少手写解析逻辑与重复维护成本。
1. 用 protobuf 解析 xls 工具:策划表到二进制配置的最后一公里
做客户端或服务端的人大概率都干过这么一件事:策划在 Excel 里维护数值和关卡表,程序拿到手先看一遍 Sheet 名,再写一坨逐行读取的代码把单元格塞进结构体。表一多、字段一变,这套流程就成了每天的暗坑:列对不上、类型读错、漏配一个默认值,线上就多了个隐藏 Bug。protobuf 解析 xls 工具解决的就是这个老问题——把 xls 当数据源,经过一层 schema 描述和解析规则,直接产出 protobuf 二进制配置,让策划表与代码结构体彻底解耦。适合手里有大量 Excel 配置、又在用 protobuf 做运行时数据的团队,也适合想自己搭一套配置导出管线的从业者。它的价值在于:不买商业表格中间件,不依赖具体引擎,一套工具就能把编辑端和运行端之间的格式鸿沟填平。
2. 为什么是 protobuf + xls:数据模型映射与字段规则
2.1 xls 表格到 protobuf message 的映射关系
要设计这个工具,第一件事不是写代码,而是把「Excel 里的一个区域」翻译成「protobuf 里的一个 message」。我一般会约定:一个 Sheet 对应一个 message,Sheet 的第一行是字段名,第二行是类型声明,从第三行开始才是真正的数据。protobuf 的字段名要求是小写字母加下划线,而策划的列名往往是中文或大写驼峰,所以需要一张显式的列名映射表,而不是在运行时靠猜。
另一个关键点是 protobuf 的 tag(字段编号)。手写 .proto 时 tag 是手工维护的,但表格驱动的工具里 tag 必须稳定,否则新增一列导致后续列全部错位,老客户端加载新配置直接解析失败。常见做法是把列序号的 1 倍或 2 倍作为 tag 基数,保证中间插入新列也不会挤占已有 tag。更稳妥的方式是维护一份字段元数据表,把列名、tag、类型三者锁定,生成 .proto 时按这张表来写。
实际落地时会发现,策划表的列类型比 protobuf 的基础类型要丰富得多。字符串、整数、浮点、布尔都好映射,但 xls 里常见的「1,2,3」逗号分隔列表、JSON 嵌套、引用另一个 Sheet 的 ID,这些都需要靠 repeated 字段或 bytes 字段兜底。我倾向于把约定做简单:基础类型用 protobuf 原生类型,嵌套结构用 JSON 字符串塞进 bytes,程序侧再反序列化一次。这样工具代码量小,且策划改表时不容易把结构写崩。
2.2 字段类型与默认值的判定规则
xls 单元格本身是弱类型的,你读到的可能是数字、字符串、日期或者空。工具里必须有一个确定性的类型判定顺序:先看第二行的类型声明,再看单元格的实际值,最后按缺省规则兜底。类型声明里的合法值一般是 int32、int64、float、double、bool、string、bytes、enum 名,或者带 [] 的 repeated 写法。第二行缺失时,才用单元格值的 Python 类型去推断,这种推断只做兜底,不能作为主路径,否则一个全数字的字符串列会被误判成整数。
默认值这个问题最容易被忽略。protobuf 的默认值规则是:没设置就返回类型零值,int 是 0、string 是空串、bool 是 false、enum 是第一个枚举值。但策划表里的「空单元格」到底该当 0 还是当「缺配」?我建议用 optional 字段来区分:optional int32 的空单元格不写进二进制,程序读出来还是默认值,但你无法区分「没配」和「配了 0」。如果业务需要区分,就再加一列 is_set 标记,或者把空单元格统一写成 0 并让策划明白这个约定。这块最好在工具里打一条 warning 日志,列出所有空单元格的坐标,方便回溯。
还有枚举类型,xls 里常常写的是「金、木、水、火、土」这类中文,或者「TypeA / TypeB」这种字符串。protobuf 的 enum 只认数字,所以工具里要维护一张枚举名到数字的映射表。我一般把这张表放在一个独立的 enum_sheet 里,生成 .proto 时一并输出 enum 定义,解析时先查表再赋值。遇到查不到的值不要静默跳过,直接报错并把坐标打到日志里,让策划第一时间知道哪一行哪个名字写错了。
3. 核心解析流程:从 xls 单元格到 protobuf 二进制
3.1 先做 xls 读取与数据清洗
工具的第一步是把 xls 读进来并清洗成行列表结构。旧版 .xls 格式要用 xlrd,新版 .xlsx 用 openpyxl。为了兼容,我一般用 openpyxl 读 .xlsx,用 xlrd 处理老 .xls,统一封装成一个 load_sheet 函数,返回 List[List[CellValue]]。这里的 CellValue 只保留四种:字符串、数字、布尔、None。日期单元格必须在这一步转成字符串,因为 protobuf 里没有日期类型,转成时间戳字符串或整数秒由业务自定义。
# xls_reader.py from openpyxl import load_workbook import xlrd def load_sheet(filepath, sheet_name): """读取 xls/xlsx 的指定 sheet,返回二维 list,空单元格为 None""" if filepath.endswith(".xlsx"): wb = load_workbook(filepath, data_only=True) ws = wb[sheet_name] rows = [] for row in ws.iter_rows(values_only=True): rows.append([clean_cell(v) for v in row]) return rows elif filepath.endswith(".xls"): wb = xlrd.open_workbook(filepath) ws = wb.sheet_by_name(sheet_name) rows = [] for i in range(ws.nrows): rows.append([clean_cell(ws.cell_value(i, j)) for j in range(ws.ncols)]) return rows else: raise ValueError(f"unsupported file type: {filepath}") def clean_cell(value): """把日期转字符串,把 bool 保留,其余按原样返回""" if hasattr(value, "strftime"): return value.strftime("%Y-%m-%d %H:%M:%S") return value这里有几个参数值得说明。openpyxl 的data_only=True会取公式计算后的缓存值,如果表是从外部系统导出的,缓存值可能不存在,就会拿到 None。碰到这种情况我会额外跑一次 LibreOffice 批量转值,或者要求策划表不要写公式,只存纯值。xlrd 读取 .xls 时cell_value返回的类型是 number、text、date、boolean 等,clean_cell 里只用strftime判断日期足够,不需要再看类型码。nrows和ncols可能包含大量空白行列,清洗阶段不要急着裁剪,等字段映射时按需要跳过。
清洗后还要做一步「列对齐」。Excel 里合并单元格会导致某一行少几个值,或者行尾有多余空列。常见做法是让 row 的长度统一到表头长度,多出来的截掉,少的补 None。这个逻辑不放在读取函数里,放在后面构建 message 时,因为不同 sheet 的表头长度不同,读取函数保持原样返回,由调用方决定怎么截。
3.2 用 descriptor 动态构建 message 并序列化
有了行列结构,接下来是核心:不写死 .proto,而是直接用 protobuf 的 descriptor API 在内存里构建 message 结构,再逐行填充并序列化成 bytes。这样工具可以从 xls 的表头实时生成 schema,新增字段时不用重新编译代码。下面是构建 descriptor 的示例,代码里用google.protobuf.descriptor_pb2生成 FileDescriptorProto,再用message_factory把它变成可用的 message 类。
# build_message.py from google.protobuf import descriptor_pb2, descriptor_pool, message_factory def create_message_class(message_name, fields): """fields: list of (field_name, tag, field_type, is_repeated)""" file_proto = descriptor_pb2.FileDescriptorProto() file_proto.name = f"{message_name}.proto" file_proto.package = "config" msg = file_proto.message_type.add() msg.name = message_name for field_name, tag, field_type, is_repeated in fields: f = msg.field.add() f.name = field_name f.number = tag f.type = field_type f.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED if is_repeated else descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL pool = descriptor_pool.DescriptorPool() pool.Add(file_proto) return message_factory.GetMessageClass(pool.FindMessageTypeByName(f"config.{message_name}"))这段的关键参数是tag和field_type。field_type是FieldDescriptorProto.Type里的枚举值,比如 TYPE_INT32 = 5、TYPE_STRING = 9。注意不能用字符串直接塞,必须转成枚举数值,否则 pool.Add 会抛异常。is_repeated对应 label,repeated 的字段在 xls 里通常表现为逗号分隔,解析时需要 split 后再逐个 add。
填充数据时另一段逻辑:把清洗过的行数据按字段元数据逐个转换,再 setattr 到 message 实例上,最后SerializeToString()。repeated 字段用extend或循环add,普通字段用setattr。这里最容易出错的是 bool 和数字的字符串表示,Excel 里可能把 1 写成 "1" 或者 "是/否",所以转换函数要集中处理,不允许散落在业务代码里。
# fill_message.py def fill_message(msg_cls, row, field_meta): """field_meta: dict, key=列名, value=(protobuf字段名, 类型转换函数)""" msg = msg_cls() for col_name, (pb_name, converter) in field_meta.items(): raw = row.get(col_name) if raw is None: continue value = converter(raw) field_descriptor = msg.DESCRIPTOR.fields_by_name[pb_name] if field_descriptor.label == field_descriptor.LABEL_REPEATED: items = value if isinstance(value, list) else [value] getattr(msg, pb_name).extend(items) else: setattr(msg, pb_name, value) return msg.SerializeToString()converter 是类型转换函数字典,比如{"int:int": lambda v: int(float(v))}。为什么数字要经 float 转 int?因为 Excel 单元格读出来的数字都是 float,直接int("100")没问题,但int(100.0)会报错,所以统一先转 float 再转 int 更稳。字符串列要把数字转回字符串,否则setattr一个 int 到 string 字段会校验失败。空单元格直接 continue,让 protobuf 走默认值,但前面提到的日志警告也在这一步补上,记录哪些行列是空的。
4. 做成可落地的转换工具:命令行参数与批量导出
4.1 命令行交互设计
解析逻辑写完,不能停留在脚本阶段,要把它收拢成一个可以进 CI 或交付给策划的工具。我用 argparse 设计命令行,核心参数是输入文件、输出目录、schema 输出路径、以及是否覆盖已有输出。这样打包成 exe 后,策划双击或者程序在 Jenkins 里呼叫都不用改代码。
python xls2pb.py \ --input ./configs/attr.xlsx \ --output ./pb/attr.pb \ --schema ./proto/attr.proto \ --sheet AttrConfig \ --start-row 3 \ --overwrite参数含义:--sheet指定读取哪个 Sheet,--start-row 3表示前两行是字段名和类型声明,从第三行开始是数据。--schema是选填,填了会把生成的 .proto 文件写到指定路径,方便程序侧对照查看。--overwrite控制目标文件已存在时是否直接覆盖,不填则报错退出,避免误操作把线上配置覆盖了。
命令行之外的另一个内部参数是编码方式。老 xls 里的中文列名在生成 .proto 时不能直接用,必须转成拼音或者英文字段名。我在元数据表里维护一个列中文名 -> 字段名的字典,命令行只传文件名,字段名映射从单独的 mapping.json 里读。这样做的好处是策划改列名不会破坏程序代码,因为字段名只认 mapping 里的英文标识。
4.2 多 sheet 与跨表格引用的处理
实际项目里一个 xls 文件通常有十几个 sheet,每个 sheet 对应一到多个 message。批量导出时我习惯加一个--sheet-list参数,格式是「Sheet名:Message名:起始行」的分号列表,缺省情况下自动遍历所有非隐藏 sheet。遍历顺序要和生成 .proto 的字段顺序一致,否则 enum 前置声明的依赖关系会断。
跨表格引用这块,最常见的场景是 A sheet 里填了 B sheet 的 ID,策划肉眼能看懂,但运行时要查出具体内容。工具里不直接解析引用关系,而是把 ID 原样导出,由业务代码在加载时做二次索引。另一个场景是多个 xls 之间存在主外键约束,比如道具表和图标表。我处理的方式是先全部导出成独立 pb,再用一个 Python 脚本做校验,检查所有引用 ID 是否存在,不存在就汇总输出一个引用缺失清单。
多 sheet 导出时的输出命名也要注意,我用{sheet_name}_{message_name}.pb的组合,而不是只写 message 名。因为不同文件里可能有同名 message,只按 message 名存盘会互相覆盖。文件头要不要加版本号?我建议在二进制前面塞一个固定长度的 header,写入 schema 的 hash 值,加载时先比对 hash 再反序列化,防的是策划改了表但程序没更新配置的经典事故。
5. 避坑清单:类型翻车、空行与枚举映射问题
5.1 数字日期傻傻分不清:单元格类型推断翻车
现象:某列是「2024-01-15」格式的文本,导出后变成一坨 45000 左右的数字,程序里读出个莫名其妙的日期。原因:Excel 把日期存成了序列号,openpyxl 用data_only=True读出来的是 datetime 对象,而 xlrd 老版本读出来的是 float。我在 clean_cell 里已经用strftime判断了一次,但没覆盖全——如果单元格本身是文本格式的日期,openpyxl 返回字符串,直接通过;如果是日期格式,返回 datetime,会被我的hasattr(value, "strftime")拦住。真正翻车的是 xlrd 的 date 单元格,cell_value返回 float,strftime判断失效。解决:读 xlrd 时用cell_type判断是否为 date 类型,而不是依赖值本身。
5.2 空单元格与合并单元格导致的列错位
现象:某一行数据集体左移一格,某个字段的值串到前一列去了。原因:策划在表里做了纵向合并单元格,openpyxl 读出来只有合并区域的第一个单元格有值,其余全为 None。如果合并的是表头下方的一组数据行,解析时默认「None 就是空值」,后续列全部错位。解决:读取后先做一次「向下填充」预处理,把合并单元格的 None 用上方第一个非 None 值填充。这个逻辑只在表头和数据行都出现合并时启用,通过命令行--fill-merged控制,默认关,防止普通空单元格被误填。
5.3 枚举与默认值:为 0 与缺失的真区别
现象:枚举字段有些行没填,导出后程序侧读出第一个 enum 值(通常下标 0 是 UNKNOWN),日志里全是 UNKNOWN,线上表现像数据丢失。原因:空单元格被 continue 跳过,protobuf 对未设置的 enum 字段返回声明的第一个枚举值。如果第一个枚举不是 UNKNOWN 而是正常业务值,问题更隐蔽。解决:生成 .proto 时强制每个 enum 的第一个元素命名为 UNKNOWN = 0,并加一条规则「所有枚举字段必须有显式填值,缺失直接报错」,不靠默认值兜底。这样设计后,枚举字段的空格就是编译期错误,不会再静默进到运行时。
另一个枚举坑:策划填了中文名「金」而映射表里只有「Gold」,查不到映射直接抛 KeyError。我把它从报错改成收集后统一汇总,一个 sheet 里所有无法映射的枚举值列成清单,一次性打印,避免一条报错一条修的低效循环。
5.4 repeated 字段的 split 边界
现象:一个列表列填了「1, 2, 3」正常,但填了「1,2,」或者「」时,split 后多出一个空字符串元素,转为 int 时直接抛异常。原因:Python 的split(",")对末尾逗号不敏感,会返回空串。解决:转换函数里先strip再过滤空串,然后才做 map。写代码时容易漏掉的是「全角逗号和半角逗号混用」,策划从文档直接粘贴时经常带全角逗号,split 前先替换成全角再切分,这个稳一点。
6. 进阶:schema 版本兼容与增量导出技巧
工具能稳定导出只是第一步,运行一段时间后你会遇到一个新的需求:配置文件要能平滑升级。策划加了一列,客户端旧版本没有这个字段,线上包没发全量更新前,新旧 PB 要能共存。protobuf 的可变长线格式给了这个能力,但前提是你的 tag 没有乱动。我之前吃过一次亏,某个开发者图省事,把新字段的 tag 直接按列号生成,列一插入,旧包解析直接崩。后来我锁了一条铁律:tag 只增不减,新列一律追加在表尾,已删除的列 tag 永不复用。
增量导出的另一个技巧是只处理变更过的 xls。工具里加一个--hash-db参数,记录每个 xls 文件的 MD5,文件没变就直接跳过,变了才重新解析并更新 hash。这一步把上百个表的全量导出时间从几十分钟降到十几秒,策划改一个表就只生成一个表对应的 pb,CI 的产物也更小。
验证环节不能省。我习惯导出完成后自动跑一个「回读」:用同一个 descriptor 把 pb 反序列化,把每条记录的字段值和原 xls 行做个 diff,不一致就列出坐标。这条校验相当于给工具本身做回归测试,因为解析代码出 Bug 的成本远高于多跑一遍校验的时间。回读脚本不复杂的核心就是ParseFromString后逐字段比对,注意 float 要设精度阈值,不能直接==。
最后说说给策划用的习惯。我要求每张表必须有一个version列,导出时把版本号通过 optional 字段写进 message,程序启动时读出来比对,不一致就打印「配置版本落后」的告警日志。这不算 protobuf 的新能力,但和 tag 管理搭配起来,基本杜绝了线上「配置和程序不匹配」那一类问题。工具跑多了会发现,真正贵的不是写解析代码,而是把字段映射的变更流程管住。希望这套思路和踩过的坑能帮到你——配置管线的稳定性,比多数人想象中更值得投入。
本文还有配套的精品资源,点击获取