Excel配表转protobuf:从xls到二进制的解析工具与避坑指南

发布时间:2026/10/15 9:02:31
Excel配表转protobuf:从xls到二进制的解析工具与避坑指南 简介面向游戏开发者的xls转Unity C#数据结构生成工具核心利用Protocol Buffers序列化方案将Excel表格数据转换为Unity可用的cs文件解决游戏配置频繁调整与代码维护繁琐问题适合有Python和C#基础、希望在Unity中快速迭代数值与配置的开发者。资源共34个文件主体为C源码12个cpp与12个h、Python入口脚本、Go辅助文件及示例xls表格SConstruct与Markdown文档用于构建和环境说明压缩包仅375KB结构精简便于快速上手。已有660人学习/下载。读者可拿到完整的protobuf解析xls工具源码、示例表格和配置指引并可了解protobuf与protobuf-net在Python及.NET场景下的协同用法后续只需修改xls表格即可更新游戏数据简化数据管理流程提升迭代效率。1. protobuf解析xls工具Excel 配表为什么非得绕一道二进制运营同学扔过来一张 2000 行的道具配置表后端接口定义的是 protobuf 消息体两边怎么对齐直接让运营去改二进制不现实让后端手工把 xls 转成 pb 又容易漏字段、改错类型。所谓「protobuf 解析 xls 工具」解决的就是这个中间层问题把 Excel 里可读、可维护的行列结构解析成 protobuf 消息体能直接消费的数据反过来也能把 pb 二进制 dump 成 xls 方便人工排查。它不是某个特定框架而是一套「表头即字段名、单元格即字段值、类型映射即 .proto 定义」的转换思路。这套东西适合谁只要是拿 Excel 当配置入口的场景都逃不掉——游戏数值配表、运营活动参数、数据接入模板、离线任务入参。纯 Python 或纯后端服务里用 openpyxl 读取 protobuf 库序列化是最短路径但如果你要在 Java 或 Go 服务里做同样的事选型逻辑和坑的位置又不一样了。我做过几个这类转换组件最大的体感是难点从来不在读 Excel而在「类型推断」和「空值语义」这两个点不做对工具就是给自己埋雷。2. 为什么是 protobuf数据交换的三种载体怎么选2.1 JSON、CSV 与 protobuf 的边界划分先聊选型。很多人第一反应是「既然 Excel 能转 CSV那直接让后端读 CSV 不就行了」。对于一次性导入CSV 确实能用但它有三个硬伤没有类型系统数字、字符串全靠下游自己猜没有嵌套结构一行拍平一个对象遇到子表、重复组就只能靠外键拼没有 schema 约束某天运营多删一列服务端不一定报错数据悄悄就歪了。JSON 比 CSV 好一点有嵌套有类型但 JSON 的 schema 是隐式的接口演进之后老数据和新代码之间容易出现「字段没了」「类型变了」这类兼容性问题。而且 JSON 在序列化、传输、落盘上的体积和解析成本在批量场景下是实打实的开销。protobuf 的核心价值是三件事第一字段有编号有类型有规则optional / repeated / oneof这是显式 schema第二二进制编码紧凑批量传输和存储都省第三生成代码后强类型访问字段名拼错了编译期就报错。代价是二进制不可读、调试麻烦、生产环境改动字段要管理版本。我的习惯是数据只在本系统内部流转、且链路较长写入、回读、校验、落库时用 protobuf要对外提供接口、对方技术栈不确定时用 JSON纯存档、纯给人看的数据才考虑 CSV。protobuf 解析 xls 工具解决的是前者——内部配置链路而不是对外开放的 API 协议。2.2 一张表对应一个 message表头到字段的映射规则选定 protobuf 之后先要确定映射规则。最常见、最可靠的做法是「一个 sheet 对应一个 message一行数据对应一个 message 实例表头对应 proto 字段名」。字段名必须满足 proto 命名规范小写字母、数字、下划线不能以数字开头不能有中文。所以 xls 表头要先做一次清洗运营写的「角色 ID」要转成 role_id「掉落物品」转成 drop_item。清洗规则我一般固定成三步去空格和全角符号、中文转拼音或英文、驼峰转下划线。这里有个容易翻车的点如果同一张表里出现「角色ID」和「角色 ID」两个表头清洗后都变成 role_idproto 定义就冲突了。必须在清洗后做唯一性校验重复的直接报错而不是静默覆盖。字段类型映射是第二个关键决策。Excel 单元格本身只有字符串、数字、日期、布尔几种形态要给每个表头指定 proto 类型有两个路线显式指定在 xls 里加一行「类型声明」或在单独的映射配置里写明 role_id 是 int32、drop_item 是 repeated string隐式推断读前 N 行数据根据单元格内容猜类型。我强烈建议用显式指定。隐式推断看起来省事但数据量一大就出问题某列前 100 行全是数字第 101 行出现一个「待定」推断逻辑就会崩或者把整列降级成 string。这个坑一旦踩到线上数据就是混的。显式写法也不复杂在表头下面加一行 meta 行约定好这行不参与数据解析。2.3 工具链选型Python 生态为什么是默认选项具体实现上Python 生态最顺原因有三openpyxl 对 xlsx 的单元格类型、合并单元格、公式的处理比较成熟protobuf 的 Python 运行时虽然性能一般但做配置解析这种低频任务完全够用pandas 可以兜底处理脏数据哪怕你没打算用它做核心逻辑。如果你在 Java 或 Go 服务里做也有对应方案Java 用 POI 读 Excelprotobuf 用官方 Java 库Go 用 excelize 读 xlsxprotobuf 用 google.golang.org/protobuf。核心逻辑一样只不过 POI 的内存占用和 excelize 的单元格类型处理各有各的脾气。我的建议是如果这个转换是离线的、跑在调度里的用 Python 单独做个任务别塞进主服务如果必须在服务内同步做再考虑宿主语言方案。3. 落地路线从 xls 到 pb 的两种可靠实现路径3.1 路径 Axls 转 JSON 中间态再进 protobuf第一种路径是「两步走」先用脚本把 xls 解析成 JSON再用 protobuf 的 JSON 互转能力把 JSON 塞进 message。好处是每一步都能单独调试xls 转换出错了看 JSON 就能定位不用直接对着二进制抓瞎。第一步的脚本长这样#!/usr/bin/env python3 # xls_to_json.py import json from openpyxl import load_workbook def sheet_to_json(path, sheet_name, meta_row1): wb load_workbook(path, data_onlyTrue, read_onlyTrue) ws wb[sheet_name] rows ws.iter_rows(values_onlyTrue) # 第 0 行是表头字段名原始值第 1 行是类型声明 headers [clean_header(c) if c is not None else fcol_{i} for i, c in enumerate(next(rows))] types [str(c).strip().lower() if c is not None else string for c in next(rows)] # 从第 2 行开始才是数据 data [] for row in rows: if all(c is None or str(c).strip() for c in row): continue # 跳过全空行 item {} for i, (h, t, v) in enumerate(zip(headers, types, row)): if v is None: continue # 空值不写字段交给 proto3 默认值兜底 item[h] cast_value(v, t) data.append(item) wb.close() return data def clean_header(h): # 去空格、全角转半角、转小写、空格换下划线 import unicodedata h unicodedata.normalize(NFKC, str(h)).strip() h h.replace( , _) return h.lower() def cast_value(v, t): if t in (int, int32, int64, uint32, uint64): return int(float(v)) # Excel 数字可能是 1.0 这种浮点形态 if t in (float, double): return float(v) if t bool: if isinstance(v, str): return v.lower() in (true, 1, yes) return bool(v) return str(v) if __name__ __main__: data sheet_to_json(config.xlsx, Sheet1) print(json.dumps(data, ensure_asciiFalse, indent2))这个脚本里有三个点要说明。data_onlyTrue很关键它拿的是单元格缓存值而不是公式如果配表里有公式列没开这个参数你读到的就是公式字符串而不是计算结果。read_onlyTrue是流式读取2000 行的表无所谓但到了 5 万行以上普通模式内存会涨得很难看。cast_value里先把值转 float 再转 int是因为 Excel 里数字 1 在 openpyxl 读出来可能是1.0直接int(1.0)会抛异常绕一下最稳。第二步就简单了把 JSON 喂给 protobuf 的解析器或者干脆在代码里逐字段 set。用 Python 的google.protobuf.json_format最省# json_to_pb.py from google.protobuf import json_format import config_pb2 def json_to_proto(json_str, message_class): msg message_class() json_format.Parse(json_str, msg, ignore_unknown_fieldsFalse) return msg if __name__ __main__: with open(config.json, r, encodingutf-8) as f: json_str f.read() msg json_to_proto(json_str, config_pb2.Config) with open(config.pb, wb) as f: f.write(msg.SerializeToString())ignore_unknown_fieldsFalse是故意开着的JSON 里出现 proto 没定义的字段直接报错防止表头加了列但 .proto 没同步更新时数据被静默丢弃。生产环境建议保持这个默认值开发期想省事再临时改 True。JSON 到 message 不是万能的proto3 的optional字段在 JSON 里没有值时会有坑后面避坑章节展开。3.2 路径 B直接 xls 到 message跳过 JSON第二种路径是不经过 JSON直接遍历 Excel 行逐字段 set 到 message 里。看起来多写几行但省掉了一次中间序列化和一次 JSON Parse还有两个额外好处可以在赋值时做更精确的字段存在性判断而不是依赖 JSON 的默认值逻辑嵌套的 repeated 消息结构在代码里看得更清楚。# xls_to_pb.py from openpyxl import load_workbook import config_pb2 def row_to_config(headers, types, values): cfg config_pb2.Config() for h, t, v in zip(headers, types, values): if v is None or str(v).strip() : continue if t repeated_string: # 单元格内用分号分隔多个值a;b;c for part in str(v).split(;): cfg.drop_item.append(part.strip()) elif t int32: cfg.__getattribute__(h).value int(float(v)) elif t bool: cfg.__getattribute__(h).value str(v).lower() in (true, 1, yes) else: setattr(cfg, h, cast_by_type(t, v)) return cfg def build_message_from_xls(path, sheet_name): wb load_workbook(path, data_onlyTrue, read_onlyTrue) ws wb[sheet_name] rows ws.iter_rows(values_onlyTrue) headers [clean_header(c) for c in next(rows)] types [str(c).strip().lower() for c in next(rows)] result config_pb2.ConfigList() for row in rows: if all(c is None or str(c).strip() for c in row): continue result.items.append(row_to_config(headers, types, row)) wb.close() return result注意这里手动做了两件 json_format 做不到的事repeated 字段不在 .proto 里声明成repeated string drop_item而是在 Excel 单元格里用分号分隔代码里 split 后逐个 append空字符串和 None 统一跳过保证 proto3 的默认值语义不被显式赋值破坏。路径 B 写起来更啰嗦但出错时可以精确到「第 5 行第 3 列赋值失败」比 JSON 报错信息直观得多。3.3 两种路径的选择判断看你的表结构复杂度如果表结构是「一列一个标量字段」路径 A 就够了少写代码、中间态可读。如果出现「一列其实是多个值」或者「一张表里嵌套了子结构」路径 B 更合适因为 repeated 和嵌套消息的赋值逻辑必须手写才能控制细节。还有第三种情况xls 里还带合并单元格。openpyxl 读合并单元格时只有左上角有值其余是 None。这经常导致「看起来有值解析出来丢了一半」。处理办法是读的时候自己建一个 merged 映射# 合并单元格预处理 def expand_merged_cells(ws): merged_map {} for m in ws.merged_cells.ranges: base_val ws.cell(m.min_row, m.min_col).value for row in range(m.min_row, m.max_row 1): for col in range(m.min_col, m.max_col 1): merged_map[(row, col)] base_val return merged_map读单元格时先查 merged_map有值就用合并后的值没有再直接读。这个处理一定要放在迭代所有行之前否则你会出现「第 3 行有值、第 4 行没值」这种玄学问题。4. 最小可复现工程一个完整的 xls 到 pb 转换 Demo4.1 .proto 定义字段编号和类型设计先给一个最小可用的 .proto 定义。拿一个最常见场景举例道具配置表包含 ID、名称、类型、掉落列表、是否上架、价格。syntax proto3; package config; message Item { uint32 id 1; string name 2; uint32 item_type 3; repeated string drop_scripts 4; // 可能掉落多个脚本 bool listed 5; double price 6; } message ItemList { repeated Item items 1; }字段编号有几个习惯要养成。第一1 到 15 的编号只占 1 个字节频繁出现的字段、嵌套层级深的字段优先用小编号第二字段一旦上线就不要改编号改了就是破坏兼容性新老版本同时存在时会解析错位第三repeated字段在 proto3 里默认是 packed 编码数字类型的多个值会被压成一个连续字节块这没问题但意味着你不能指望drop_scripts用标量字段的访问方式。4.2 直接可跑的解析脚本带参数、带校验、带详细日志把上一章的代码汇总成一个命令行工具支持指定输入 xlsx、sheet 名、输出 pb 路径#!/usr/bin/env python3 # xls_parser.py import argparse import sys import traceback from openpyxl import load_workbook import config_pb2 def clean_header(h): import unicodedata h unicodedata.normalize(NFKC, str(h)).strip() h h.replace( , _) return h.lower() def expand_merged_cells(ws): merged_map {} for m in ws.merged_cells.ranges: base_val ws.cell(m.min_row, m.min_col).value for row in range(m.min_row, m.max_row 1): for col in range(m.min_col, m.max_col 1): merged_map[(row, col)] base_val return merged_map def parse_sheet(path, sheet_name): wb load_workbook(path, data_onlyTrue, read_onlyTrue) ws wb[sheet_name] merged expand_merged_cells(ws) if not ws.read_only else {} rows ws.iter_rows(values_onlyTrue) headers [clean_header(c) if c is not None else fcol_{i} for i, c in enumerate(next(rows))] types [str(c).strip().lower() if c is not None else string for c in next(rows)] # 校验表头唯一性重复的直接报错 if len(set(headers)) ! len(headers): dup [h for h in headers if headers.count(h) 1] raise ValueError(f表头重复: {dup}) result config_pb2.ItemList() row_no 2 # 数据从第 3 行开始行号用于日志 for row in rows: row_no 1 if all(c is None or str(c).strip() for c in row): continue item config_pb2.Item() try: for i, (h, t, v) in enumerate(zip(headers, types, row)): if v is None: continue value merged.get((row_no, i 1), v) if merged else v if t repeated_string: for part in str(value).split(;): item.drop_scripts.append(part.strip()) elif t uint32: item.__getattribute__(h).value int(float(value)) elif t bool: item.__getattribute__(h).value str(value).lower() in (true, 1, yes) elif t double: item.__getattribute__(h).value float(value) else: item.__getattribute__(h).value str(value) result.items.append(item) except Exception as e: sys.stderr.write(f第 {row_no} 行解析失败: {e}\n) traceback.print_exc() wb.close() raise wb.close() return result def main(): parser argparse.ArgumentParser(descriptionxls 转 protobuf) parser.add_argument(-i, --input, requiredTrue, help输入 xlsx 路径) parser.add_argument(-s, --sheet, defaultSheet1, helpsheet 名) parser.add_argument(-o, --output, requiredTrue, help输出 pb 路径) args parser.parse_args() try: msg parse_sheet(args.input, args.sheet) with open(args.output, wb) as f: f.write(msg.SerializeToString()) print(f成功解析 {len(msg.items)} 条配置 - {args.output}) except Exception: sys.exit(1) if __name__ __main__: main()运行方式python xls_parser.py -i item_config.xlsx -s Sheet1 -o item_config.pb这段工程代码有几个值得抄的点。第一行号row_no从表头行下面开始数报错时能精确告诉运营「第几行有问题」这是排障效率的关键第二合并单元格的处理放在read_only模式下会有兼容问题所以我在代码里判断了ws.read_only生产环境如果你确定没有合并单元格可以关掉这块逻辑第三每个字段都用__getattribute__(h).value的方式赋值绕开了 Python 关键字冲突的问题。4.3 反向验证把 pb 读回 xls 做一致性检查解析完不算完一定要做回读验证。这个步骤很多人偷懒跳过但恰恰是它能把「转换有问题」和「业务逻辑有问题」区分开。反向逻辑很简单遍历 message 的字段把值写进一个新的 xlsx# pb_to_xls.py from openpyxl import Workbook import config_pb2 def pb_to_xls(path, msg): wb Workbook() ws wb.active # 写表头 headers [id, name, item_type, drop_scripts, listed, price] ws.append(headers) for item in msg.items: row [ item.id, item.name, item.item_type, ;.join(item.drop_scripts), # repeated 转回分号分隔 item.listed, item.price, ] ws.append(row) wb.save(path) if __name__ __main__: msg config_pb2.ItemList() with open(item_config.pb, rb) as f: msg.ParseFromString(f.read()) pb_to_xls(item_config_check.xlsx, msg)回读后做两层对比第一层逐单元格比对把原始 xlsx 和回读 xlsx 转成 dictkey 是「行号-列号」value 是单元格值两边不一致的直接输出差异第二层是抽样人工看重点看类型有没有被降级数字变成字符串、布尔变成 0/1。这一步跑通了工具才敢交给运营用。5. protobuf 解析 xls 的避坑指南5 个我踩过的坑5.1 坑一数字精度丢失double 转 int64 翻车现象ID 列是 18 位数字xls 解析出来最后两位变成 00或者变成 123456789012345670 这种「看似合理但不对」的值。原因Excel 本身对超过 15 位的数字精度有限制openpyxl 读出来就已经丢了精度再加上int(float(v))这种转换走了一遍浮点等于再丢一次。解决从源头限制——在配表规范里要求 ID 列长度不超过 15 位数字如果实在需要长整型让运营把 ID 单元格设置成文本格式代码里读到的就是字符串int(v)直接转不经过 float。另外代码里遇到「单元格是文本格式但内容是纯数字」的情况openpyxl 读出来是 strint(v)不要慌int(123)是合法的。5.2 坑二日期单元格读出来是数字而不是日期字符串现象表里「上架时间」列明明写的是 2024-05-01解析出来变成 45167 或者 45167.5没有任何报错。原因openpyxl 默认读日期单元格拿到的可能是 Excel 的序列号data_onlyTrue只影响公式不影响日期格式。日期本身在 xlsx 内部就是数字按「天数 小数」存。解决在 meta 行类型声明里加一个date类型处理时用openpyxl.utils.datetime.from_excel(value)转成 Python datetime再格式化成字符串如果 sheet 里的日期列不是统一格式让运营规范成「YYYY-MM-DD」文本这是最省心的方案。我一般会在文档里强制要求所有日期列一律文本格式。5.3 坑三空值被显式赋值破坏 proto3 默认值语义现象xls 里「价格」列留空解析出来的 message 里price字段不是 0而是被设置成了一个空字符串或某个奇怪的值下游判断if item.price 0时行为错乱。原因代码里没判断 None直接把空单元格的None传给了float()或str()在某些分支下产生了非默认值的赋值。解决统一在赋值入口判断两个条件——v is None和str(v).strip() 两者都直接continue不写入字段。proto3 没有显式赋过值的字段会保持默认值0、空字符串、空列表这样下游的判空逻辑才可靠。这是转换工具最容易忽略但影响最大的坑。5.4 坑四表头多了前缀或空格清洗后和 .proto 字段对不上现象xls 表头写的是「item_id」和「item id」清洗规则不同导致一个映射到item_id另一个映射成item_id结果后者在赋值时报AttributeError: Assignment not allowed。原因清洗规则没统一或者表头里藏了不可见字符全角空格、NBSP。解决在做表头归一化时除了去空格还要把全角空格\u3000和\xa0显式替换掉最后统一unicodedata.normalize(NFKC, ...)。另外在清洗后做一次set(headers) - set(proto_fields)的差集检查多出的字段直接打 warning少掉的字段直接报错不静默。5.5 坑五read_only 模式下 expand_merged_cells 不可用现象同一套脚本小文件能跑大文件换成read_onlyTrue后合并单元格的值全部变成 None而且不报错。原因read_onlyTrue时ws.merged_cells.ranges拿不到完整数据或者拿到的是未展开的坐标代码里按坐标取值得到 None。解决两个方案。一是对带合并单元格的 sheet 禁用read_onlyTrue文件大会慢一点但结果正确二是先加载到内存再预处理合并映射不用流式读取。我更推荐前者因为配表场景的「大」通常也就几万行普通模式完全扛得住。6. 进阶玩法用 meta 行做字段级校验让 xls 自己成为 schema如果只是「能解析」工具的价值就打折了。往上走一步把 meta 行从「类型声明」扩展成「字段规则声明」让 xls 本身变成一个可校验的 schema解析的过程同时就是校验的过程。规则我一般这样设计meta 行支持int32,int64,uint32,double,bool,string,repeated_string,date, 后面可以跟规则后缀用竖线分隔。比如id | name | drop_scripts | listed uint32|required | string|len50 | repeated_string|max_items10 | bool解析脚本里对每个字段执行规则检查失败的收集到错误列表最后一次性报出来而不是遇到第一个错误就中止。这样运营改配置时能一次性看到「第 5 行 ID 超范围、第 12 行 name 超过 50 字、第 30 行 drop_scripts 超过 10 个」而不是改一次报一次。这个进阶设计里有一个细节值得说规则校验要在「转 protobuf 之前」做而不是之后。因为 proto3 本身对字段值范围几乎没有运行时校验uint32 传入一个负数序列化不报错但下游解析出来就是巨大的数等于埋雷。规则校验放在解析脚本里配合上面的行号定位能直接告诉运营「你的负数写在哪个单元格」。再进一步可以把生成的ItemList反序列化后塞进一个模拟的消费函数跑一遍业务校验——比如价格不能为负、id 不能重复、引用项必须存在。这一步如果在转换工具里做了下游服务就少一层防御逻辑线上问题的排查范围也会小很多。我做这套工具的教训是别指望着一个脚本解决所有表的转换问题。每类配置表单独定义 .proto单独维护 meta 行宁可重复 30 行代码也不要搞一个「万能解析器」到处套。万能解析器的表头清洗、类型推断、空值处理每多一个分支就是多一排雷。希望这些踩过的坑能帮你少折腾几个晚上。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询