简介:Excel转Lua工具包是一套面向游戏开发与配置管理场景的实用转换方案,能把结构化的Excel表格批量导出为Lua脚本,减少手动整理数据的重复劳动,尤其适合数据驱动且以Lua为主要脚本语言的中小型项目。压缩包内共4个文件,包含Python处理源码、bat一键运行脚本、示例Excel表格以及Word格式的使用说明文档,整体仅159KB,体积小巧,便于分发和部署。已有686人学习,对需要将角色属性、物品参数、地图信息等配置数据快速接入Lua逻辑的开发者有直接参考价值,也可减少手写Lua表的出错概率。借助示例表格与说明文档,可以完整走通从导入工作表、选择转换范围到生成Lua代码的流程;Python源码支持按项目实际调整字段映射与数据类型转换规则,配合批处理脚本可一键完成转换,便于后续维护与迭代。
1. Excel转Lua工具:策划改表,程序不再盯到后半夜
做过游戏客户端或服务端的人,多少都被“配表”这件事折磨过:策划在Excel里调了一个晚上的数值,你拿到新表发现某个ID少了个逗号,或者name变成了1.0,更常见的是手动复制粘贴时漏了一行。Excel转Lua工具要解决的就是这个场景:把Excel这种人类友好的二维表格,按约定规则自动转成Lua table文件,让代码直接require加载。它适合所有用Lua做热更或业务逻辑的项目,也适合需要把外部数据导入游戏的运营后台。工具本身不需要多复杂,核心是把“表头+类型+数据”三行结构稳定地映射成Lua语法,难的是各种边界情况。这篇文章我会按自己实际做过的方案,从原理讲到踩坑,最后给你一套能直接放到构建流程里的落地路径。
2. Excel转Lua的核心原理:单元格到 table 的结构映射
2.1 先看清目标:一份可被 game 直接加载的 Lua 数据文件
做转换前,先得知道目标长什么样。常见的Lua配表有两种写法:数组模式是一组有序记录,字典模式用唯一ID做键。以《道具表》为例,Excel里大概有id、name、attack、price这几列。数组模式导出后是:
local data = { { id = 1, name = "木剑", attack = 10, price = 100 }, { id = 2, name = "铁剑", attack = 20, price = 300 }, } return data字典模式导出后是:
local data = { [1] = { id = 1, name = "木剑", attack = 10, price = 100 }, [2] = { id = 2, name = "铁剑", attack = 20, price = 300 }, } return data两种格式在Lua里都是合法table,差别只在查找方式。数组模式适合遍历列表,比如掉落池、商店随机商品;字典模式适合用ID直接定位,data[itemId]一次取到,不需要遍历。我在大多数项目里会默认导出字典模式,因为策划配表几乎必有唯一主键,但如果是关卡怪物列表这种有顺序要求的数据,就得保留数组模式。工具应该同时支持两种,由配置项决定,不能写死。
需要说明的是,Lua table在5.2之前的版本里对浮点键和整数键是严格区分的,[1]和[1.0]是两个不同的键。很多导出工具在这里翻过车,后面避坑章节会专门讲。另外,不管哪种模式,我们导出的都是一个返回table的Lua chunk,项目里用require("config.items")把它加载进来。这样配表变更时,只需要重新生成文件并触发热更,不用重新编译游戏逻辑,这是Lua方案相比C#/C++最有价值的地方。
2.2 表头、类型行与数据行的约定:解析前的第一件事
无论用哪种模式,Excel的单元格都是二维矩阵,我们得约定前几行是什么意思。最常见做法是三行结构:第一行是字段名,第二行是类型提示,第三行可以写中文注释,从第四行开始是数据。类型提示用int、float、string、bool这些简单的名字,工具按这个名字来做转换,不依赖乱猜。例如:
| id | name | attack | price |
|---|---|---|---|
| int | string | int | int |
| 唯一ID | 名称 | 攻击力 | 价格 |
| 1 | 木剑 | 10 | 100 |
也有项目会用“第一行字段名,第二行示例数值,然后让工具自动推断类型”的方案。自动推断看起来省事,实际很容易翻车:Excel里100和"100"在用户眼里一样,在单元格底层类型上可能一个是数字一个是文本,自动推断会把int识别成string,或者把带小数点的字符串识别成float。我自己的经验是宁可让策划多写一行类型,也不要在导出时靠猜。类型行本身也充当了文档,策划打开表就能看到每个字段应该填什么。如果不想占用第二行,可以把类型信息放到独立的json配置里,这会在第3章写到。
关于表头的额外约定:字段名必须是合法的Lua标识符。比如“道具名称”“attack”都能用,但“item-name”带横杠就不行,因为Lua会把-当成减号。遇到这种列名,工具要么报错,要么做一次名字清洗,把非法字符替换成下划线。清洗容易造成列名冲突,所以我在项目里的规则是:表头只允许字母、数字、下划线,且不能以数字开头,其余情况在导出时警告并跳过。策划一开始觉得麻烦,但养成习惯后,配表和代码里访问字段都用同一套名字,反而少了很多沟通成本。
2.3 最小实现:用 openpyxl 读取 Excel 并序列化成 Lua
有了约定,实现就非常直接。我常用Python的openpyxl库,因为它只读取文件,不依赖本机安装Excel,在Linux构建机上也能跑。下面是最小可用的转换脚本,只处理一个sheet:
import openpyxl def serialize(val, type_hint): """把Excel单元格值转成Lua字面量,type_hint来自类型行""" if val is None: return "nil" t = type_hint.strip().lower() if t == "int": return str(int(val)) elif t == "float": return str(float(val)) elif t == "bool": v = val if isinstance(val, bool) else str(val).strip().lower() return "true" if v in (True, "true", "1") else "false" else: # string 和其他类型一律按字符串处理 text = str(val).strip() text = text.replace("\\", "\\\\").replace('"', '\\"') return f'"{text}"' def sheet_to_lua(filepath, sheet_name): wb = openpyxl.load_workbook(filepath, data_only=True) ws = wb[sheet_name] rows = list(ws.iter_rows(values_only=True)) if len(rows) < 3: raise ValueError(f"{sheet_name} 至少需要表头、类型、数据三行") headers = rows[0] types = rows[1] out = ["local data = {"] for row in rows[2:]: if all(c is None for c in row): continue # 跳过完全空行 fields = [] for i, h in enumerate(headers): if h is None: continue key = str(h).strip() val = row[i] if i < len(row) else None hint = types[i].strip() if i < len(types) and types[i] else "string" fields.append(f"{key}={serialize(val, hint)}") out.append(" { " + ", ".join(fields) + " },") out.append("}") out.append("return data") return "\n".join(out) if __name__ == "__main__": print(sheet_to_lua("config.xlsx", "Items"))这段代码里有几个地方值得解释。load_workbook(filepath, data_only=True)中的data_only=True表示读取单元格上缓存的计算结果而非公式字符串,后面第5章会专门讲它的坑。iter_rows(values_only=True)把每一行变成元组,比遍历单元格对象快得多。类型行里如果某个单元格留空,代码会退回string,等于默认所有字段都是文本,这个策略在表结构临时多出一列时能保住导出不崩,只是类型可能不对,需要日志去提醒。
参数方面的建议:如果Excel文件很大,比如几万行配表,iter_rows仍然是一口气读进内存,实际可接受;真要省内存可以改用ws.iter_rows(min_row=4)然后在循环里处理,但代码复杂度会上升。对绝大多数游戏配表,几万行数据导出的耗时在几十毫秒到几百毫秒,没必要优化。输出端注意字符串里如果有双引号和反斜杠,必须转义,否则Lua文件一加载就报语法错误。上面serialize里的replace就是干这个的。如果表头里包含空格,也需要strip掉,否则生成的字段名会带着空格,Lua里写成item id = 1直接语法错误。我在debug时经常遇到这种“看哪都没问题,一加载就崩”的情况,最后发现就是表头尾部的空格。
3. 配置驱动导出:字段名、类型、主键与默认值怎么弹
3.1 为什么不在脚本里写死列号:配置驱动才扛得住表结构调整
第2章的最小脚本虽然能跑,但处理器里写死了“前两行是表头、类型,从第二行开始数据”。如果说表上加了一个新列、或者某列被策划移动到别的位置,你不需要改代码,因为它是按表头名字映射的,这已经比写死列号进步了。但还有几个问题它没解决:某些列(比如备注列)不想导出;某个字段类型行里没写,需要外部指定默认值;还有同一个Excel里不同的sheet可能需要不同的导出模式。如果这些规则全写在Python代码里,每次表结构变化都要改代码、走发布流程,那就违背了工具的初衷。
所以我会再加一层配置,把“Excel的结构”和“导出的规则”分开。常见的做法是一份json配置文件,里面声明源文件、sheet名、表头行、类型行、主键字段、导出模式、字段白名单等。策划或工具维护者改配置就行,不用碰代码。配置本身也进了版本库,谁改的、为什么改成这样都能追溯。这种做法在团队里推行时阻力最小,因为Python不是人人会,但json是个人都能改明白。
另一个被忽略的原因是:自动推断类型在遇到“策划手动改格式”时会失灵。比如某列本来全是数字,策划突然在某个单元格填了“普通攻击/暴击”这种文字,工具如果靠第一行数据推断类型,就会把整列推断成string,导致游戏里本来做数值加减的代码收到字符串,运算全乱。配置里手动指定类型后,工具遇到类型不匹配可以直接报错提醒策划,而不是默默改变整列类型。所以配置驱动不只是工程上的解耦,更是数据质量的一道防线。
3.2 一套可复用的导出配置:支持字段类型和默认值
下面是我常用的一份配置模板:
{ "tables": [ { "source": "config/items.xlsx", "sheet": "Items", "header_row": 1, "type_row": 2, "data_start_row": 4, "key_field": "id", "key_mode": "dict", "output": "lua/items.lua", "fields": { "id": { "type": "int", "comment": "唯一ID" }, "name": { "type": "string", "comment": "名称" }, "attack": { "type": "int", "default": 0 }, "price": { "type": "int", "default": 0 }, "备注": { "type": "skip" } } } ] }header_row和type_row从1开始计数,data_start_row同样是Excel的行号。fields里可以对每个字段单独覆盖类型、设置默认值,还可以把不需要的列标记为"type": "skip",这样即使Excel里放着策划的中间计算列,也不会进到Lua里污染数据。key_mode支持dict和array两种:dict模式用key_field指定的列值作为外层表的键,array模式则直接输出顺序表。output指定输出文件的相对路径,方便把多个sheet导到不同目录。
在代码里读这份配置的逻辑很简单,但有一个关键点:配置中显式声明的字段优先于Excel表头。也就是说,如果表头叫attack,配置里也写了attack,那么以配置里的类型和默认值为准。这个优先级要写清楚,否则策划改了个列名,配置还按旧名字找,导出就会悄悄丢掉这一列。我的做法是:先读配置的fields,再读Excel表头,用表头去匹配配置;表头里出现但配置里没有的字段,如果配置里没开strict模式,就按类型行推断;如果开了strict,直接报错,避免漏字段。
默认值的处理是另一个容易忽略的点。当单元格为空时,如果配置里写了default,就用默认值序列化;如果没写,按类型给一个合理的空值:int给0,float给0.0,string给空字符串,bool给false。这样导出的Lua结构是稳定的,不会因为某个单元格空着就让字段消失,导致其他代码里取属性时得到nil。但注意,这个默认值只对“空单元格”生效,如果单元格里是公式但计算结果为0,那它就是0,不是空值。
3.3 序列化细节:转义、空值、浮点与字典模式
配置驱动之后,序列化函数需要再处理几个边界。第一个是字符串转义,除了双引号和反斜杠,换行符也要转成\n,否则Lua源码里会真的换行,破坏table结构。第二个是空值:Lua里nil不能作为table字段的值,写了等于没写。所以如果一个单元格是空,最安全的做法是跳过这个字段,而不是输出field=nil,否则{ id=1, desc=nil }里的desc字段等于不存在,后续代码用t.desc判断时会得到nil而不是预期值。如果你想保留“这个字段存在但为空”,可以约定导出空字符串。
第三个是浮点精度。Excel里的数字很多是浮点存储,比如2.3可能存成2.2999999999999998。直接str(float(val))在Python里会输出这个长尾巴。处理办法是:int类型用round()后再格式化;float类型保留小数点后最多6位,或直接输出原始float,让Lua自己处理。我在代码里通常写:
if t == "int": return str(int(round(float(val)))) elif t == "float": return f"{float(val):.6f}".rstrip("0").rstrip(".")这样100.0会变成100而不是100.000000。但注意,如果你期望输出浮点类型,100和100.0在Lua里数值相同,用math.type看都是integer还是float的区别。对数值计算影响不大,但用..字符串拼接时会有差异,所以尽量统一。
字典模式在序列化时,主键索引不能走key=value的写法,而要写成[1] = {...}。Lua的table键如果是整数,不加方括号会按字符串键处理,这是个很容易被忽略的语法点。生成字典模式时,主键的值经过serialize后直接放在方括号里,例如[1]或["hero"](如果主键是字符串)。另外要检查主键是否唯一,配置项key_field对应的值如果重复,导出时要有显式报错,否则后面的记录会覆盖前面的,数据就静默丢了。这一条我们项目里吃过亏,所以后来在工具里加了一个简单的set()去重检查,一旦发现重复就记录行号和值,把错误信息打出来,宁可不让构建通过,也不带错数据上线。
4. 落地为命令行工具:批量导出与构建流程集成
4.1 命令行参数设计:输入目录、输出目录、配置文件和日志
光有转换函数还不够,得把它封装成一个能天天跑的命令行工具。我用Python的argparse设计了一套参数,保证新手拿到手能直接用,老手也能在CI里无交互调用:
python excel2lua.py -c config.json -i ./excel -o ./lua --strict --log warnings对应的参数解析代码是这样的:
import argparse def parse_args(): p = argparse.ArgumentParser(description="Excel转Lua配置导出工具") p.add_argument("-c", "--config", required=True, help="导出配置文件路径(json)") p.add_argument("-i", "--input", default="./excel", help="Excel文件目录,默认./excel") p.add_argument("-o", "--output", default="./lua", help="Lua输出目录,默认./lua") p.add_argument("--strict", action="store_true", help="严格模式:表头与配置不匹配时报错") p.add_argument("--log", default="info", choices=["debug", "info", "warning", "error"], help="日志级别") return p.parse_args()-c指定全局配置,-i和-o让工具可以脱离固定目录运行,这样就能在Jenkins或GitLab CI里把它作为一步构建任务。--strict开关我强烈建议在正式发布和CI里打开,它能把“策划加了个列,配置没跟上”这类问题暴露成构建失败,而不是导出后游戏运行到一半才发现字段丢了。日志级别用Python标准库的logging即可,注意输出要带上文件名:sheet名:行号,排错时一眼定位。比如日志写成config/items.xlsx:Items:行7: 字段attack的数据不是合法int,策划自己就能去改表,不用再绕道问程序。
4.2 批量遍历 Excel:过滤临时文件与平滑读取
实际使用中要处理的Excel往往是一整个目录,可能几十个文件。用pathlib.Path.glob("*.xlsx")就能拿到所有文件,但有几个坑必须提前处理。第一个是Excel打开时产生的临时文件,比如~$items.xlsx,这种文件不能被openpyxl正常读取,glob模式却会匹配到,所以要显式跳过以~$开头的文件。第二个是.xls和.xlsx混用:openpyxl只支持xlsx,老项目的.xls要用xlrd或先批量转格式;如果你接手的是老表,最好写个预处理脚本统一转成xlsx,别在转换器里同时又依赖两个库。第三个是读取失败时的处理策略:一个文件坏了不应该中断整个任务,但必须记下错误并在最后汇总,否则几十个文件导出了,你没注意某个文件没生成,线上数据就缺了。
我一般这样写批量处理主循环:
from pathlib import Path import logging, traceback def batch_convert(cfg, input_dir, output_dir, strict): failures = [] for xlsx_path in sorted(Path(input_dir).glob("*.xlsx")): if xlsx_path.name.startswith("~$"): continue try: convert_one_file(cfg, xlsx_path, output_dir, strict) logging.info(f"成功: {xlsx_path.name}") except Exception as e: failures.append(f"{xlsx_path.name}: {e}") logging.error(f"失败: {xlsx_path.name}\n{traceback.format_exc()}") if failures: raise SystemExit("\n".join(failures))convert_one_file里按配置里的tables逐条处理,每个table找到对应的sheet。注意glob默认不递归子目录,如果Excel分布在子目录,需要改成rglob("*.xlsx")。这里没有用多线程,因为openpyxl读取本身是I/O密集,但文件不大时线程收益不明显,反而让日志和错误信息串台。只有当单个Excel超过10MB、有几十个文件时,可以用concurrent.futures.ThreadPoolExecutor,并给每个线程独立的错误收集器。
另外,读取时建议用read_only=True模式。openpyxl.load_workbook(path, read_only=True, data_only=True)在批量场景下能显著降低内存占用,因为普通模式会把整个工作簿的样式、公式、单元格对象都读进来。但只读模式有一个副作用:它不能修改文件,也不能正确读取合并单元格的所有信息,如果项目需要用到合并单元格的解析,就得退回普通模式。我只在纯导出场景用只读模式,一旦脚本里要写回Excel,就老老实实普通模式。
4.3 接入构建流程:原子替换与增量导出
命令行工具能跑了,就该把它接到项目构建里。游戏项目常见做法是在资源打包或热更生成阶段,Excel转Lua作为前置步骤。比如Jenkins流水线里加一步python tools/excel2lua/excel2lua.py -c config.json,或者在Makefile里加一个data目标。这时有两个实操细节最重要:原子替换和增量导出。
原子替换是指不要直接写最终Lua文件。因为构建进程可能在导出过程中读到半个文件,造成缓存错误。正确做法是先写到临时文件,比如temp_items.lua.tmp,全部写完后再os.replace()覆盖到items.lua。os.replace在Windows和Linux上都是原子操作,不会出现半截文件。代码很简单:
import os def write_atomic(path, content): tmp_path = path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as f: f.write(content) os.replace(tmp_path, path)增量导出是指只有Excel变更时才重新导出,减少构建时间。判断依据可以是文件修改时间mtime,也可以是文件的MD5。配置表通常不大,我直接用MD5,防止文件被改回旧内容后mtime骗人。在配置里加一个cache_dir存哈希值,如果哈希没变就跳过。这一招在动辄几百个配置表的项目里很管用,能把导表时间从几分钟压到几十秒。增量导出后还要和Lua文件的mtime做比较,如果Excel变了但Lua还比Excel新,说明导出被跳过了,要打印警告。
关于日志,建议把每次导出成功、跳过的文件名都打印出来,保存到构建日志里。这样出了问题回查时,能知道某个Lua文件是哪个版本的Excel生成的。我在生产项目里遇到过一次诡异问题:玩家线上数据异常,查到最后是某个配表在发布后又被手动改过,而构建机没有拉取最新提交,导致旧数据上线。加了MD5增量和日志后,这种问题就再没出现过。
5. Excel转Lua避坑指南:5个让你翻车的常见问题
5.1 乱码和 BOM:中文变一堆问号
现象:生成的Lua文件里中文全部变成???,或者用编辑器打开是乱码,但游戏运行时又能读。
原因:一个是源文件本身不是UTF-8。xlsx内部字符串就是UTF-8,openpyxl读出来没问题;但如果你用的是CSV,Windows下默认编码是GBK,直接读就会乱。另一个是Lua文件的编码问题和BOM标记。Lua解释器一般按UTF-8解析源码,但Windows记事本保存时会带BOM,有的Lua版本不支持BOM会报第一个字符错误。
解决:统一用UTF-8无BOM写文件,encoding="utf-8"在Python里默认就是无BOM。如果源工程里Lua加载器要求带BOM(罕见),用utf-8-sig写。检查乱码时,先看file命令的输出或者用十六进制编辑器看文件头;不要用记事本另存。另外如果你的Excel里粘贴了从网页复制的内容,里面可能包含不可见字符,在类型行是string时会被原样导出,建议在转换时对字符串做一次strip()。我处理过一个案例:策划从网页复制了一段物品描述,里面带有一个不换行的空格,导出后游戏里显示名字正常,但和另一个字符串比较时永远不相等,查了半天才看到是编码问题。所以字符串清洗不能只做首尾空格,最好把常见的不可见字符统一替换掉。
5.2 数字 100 变成 100.0:类型推断的锅
现象:Excel里单元格明明是整数100,导到Lua里变成了100.0;主键id变成1.0,字典模式生成的[1.0]在Lua里 lookup 时和[1]不匹配。
原因:Excel中数字默认以双精度浮点存储。openpyxl读取时,如果单元格格式是常规,整数会返回int;但一旦策划把单元格格式设成“数值”,openpyxl会返回float。类型行写的是int,新手转换时直接输出int(val)还好,但如果是自动推断,就会把这种float推断成浮点。
解决:int类型转换时严格一点,int(round(float(val)))。这里先转float再round,是为了处理像1.0和2.0000000001这种;浮点误差在Excel里很常见,比如0.3存成0.299999999999998。如果你用的是自动推断,那么建议彻底停用,强制要求类型行。对于主键列,即使类型行忘了写,工具内部也应该总是按int处理,因为主键不可能是浮点。另外,导出的Lua文件里如果出现[1.0],Lua实际是浮点键,和整数键[1]是两条索引,查表会查不到,这是游戏运行时的隐形bug,而且不容易复现。
我还见过一种更隐蔽的情况:主键数字超过2的53次方(约900亿),比如用时间戳做ID,Excel把它存成科学计数法,openpyxl读出来已经是float,精度丢了。如果项目里确有大整数ID,建议类型行写string,让策划把ID列格式设为文本,否则再完美的转换也没法还原已经丢失的精度。
5.3 公式单元格导出为 nil:data_only 的真相
现象:某一列是公式,比如=VLOOKUP(...)或=A2*2,导出后这一列全是nil,或者都是0。
原因:openpyxl的data_only=True读取的是Excel文件里缓存的公式计算结果。如果这个Excel从未被Excel或LibreOffice打开计算过,缓存就不存在,读取结果是None。很多情况下,策划在Excel里写了公式,最后一步是“另存为”,文件里确实有缓存值;但如果这个文件是程序批量生成、或者用脚本修改后再保存的,缓存可能丢失。
解决:排查时先拿Excel打开这个文件,随便改个单元格再保存,让Excel计算一次;如果工具导出正常,说明就是缓存问题。生产环境不能依赖人手操作,有两种做法:一是要求策划交付时用Excel的“另存为”或“保存”确保公式被计算;二是在构建机上安装LibreOffice,用命令行无头模式打开文件并重新保存,让缓存生成。注意不要用data_only=False读取公式文本,然后自己解析,Excel公式语法复杂,不值得。另外,如果公式的值是字符串且包含了双引号,序列化时依然要做转义,这个容易忘。
这里还牵扯到Excel加载项的问题。有些策划电脑装了第三方加载项,公式里用到自定义函数,导致公式在本机能算出结果,但在构建机上没有加载项,LibreOffice重算时这些单元格会报错或变成空值。处理办法是:比对外部公式列时单独配置"type": "skip",或者要求策划把公式列改成纯手填。工具本身读的是文件缓存,不经过加载项,所以“Excel加载项被禁用”这种问题不会直接影响导出,但会影响Excel在打开时是否自动重算并更新缓存。
5.4 合并单元格导致表头串位:前向填充
现象:Excel的表头是合并单元格,比如“属性”合并了两列,下面分别是“攻击”和“防御”。转换后,“攻击”这一列的字段名变成了“属性”,或者变成了None。
原因:合并单元格时,只有左上角单元格有值,其他单元格返回None。如果代码直接按row[0]取表头,遇到合并区域就会得到None,于是这列被跳过;或者后面的列没有对应表头,导致数据错位。
解决:在读取表头时做前向填充。让当前列的值等于最近一个非空表头,代码如下:
headers = [] last = None for raw in rows[0]: if raw is not None: last = raw headers.append(last)这样合并单元格下方的第一个非空值会继续沿用合并标题,但注意这不是真实列名。更稳妥的做法是要求策划不要合并表头,或者只在最后一行表头上写真正的字段名。如果你用的是多行表头,比如第一行是大类、第二行是字段名,那就应在配置里指定header_row=2,并把第一行当作注释导出去。合并单元格的坑在于数据区域一般不合并,但示例格式的合并会让很多人以为列名丢了,排查时先看表头行前向填充后的值。
除了表头,数据区域里的合并单元格更麻烦。比如“备注”列只有一行值,但下面几行视觉上是合并的,导出的数据里只有第一行有值,其余行都是空。如果字段类型是string,按默认值会输出空字符串,这在语义上可能能接受;如果是int,输出0会让人误以为是真实数值。我一般在转换前扫描数据区域,如果有合并单元格,直接打一条警告,提示策划把数据补全,而不是靠工具去推断。
5.5 Excel 文件被占用和临时文件:进程锁的干扰
现象:在Windows上运行工具,提示PermissionError,打不开某个xlsx;或者批处理时遍历到了~$xxx.xlsx,报未知目标。
原因:Excel或WPS打开着这个文件,Windows文件系统会锁定它,openpyxl无法读取。另外,Excel在编辑时会在同目录创建以~$开头的临时文件,这些文件不是合法的xlsx格式。
解决:改代码跳过~$文件,前面已经提过。对于被锁定的文件,有两种处理:一是让使用者关闭Excel后重新跑,这在本地没问题,但在CI里会遇到有人不关闭就触发构建;二是用openpyxl的只读模式(read_only=True,再配合data_only=True)有时可以绕过锁定,但这依赖文件是否允许多读。如果项目里大量用Excel的VBA或VB操作,要特别注意COM对象没有释放导致进程残留。比如用win32com.client打开Excel后如果忘了Workbook.Close(),后台会留一个Excel.exe进程,把文件锁住。而openpyxl不创建Excel进程,从这个角度讲比桌面自动化方案干净。最后还是建议:在CI机器上验收时加一个前置检查,扫描所有~$文件和锁定状态,提前失败并提示哪个用户打开了文件。
我还遇到过一种情况:工具读取文件时,杀毒软件或云同步工具(比如OneDrive、坚果云)正在后台同步同一份文件,导致读取到的内容只有一半。这种问题很难从代码上完全规避,最好的办法是把Excel目录排除在同步盘之外,或者在构建脚本里先复制一份到临时目录再转换。复制虽然多一步I/O,但能杜绝很多莫名其妙的半截文件错误。
6. 进阶技巧:带注释导出、增量构建与校验闭环
6.1 把批注塞进Lua注释,让配置可读
导出的Lua文件如果只有数据,另一个同事接手时很难看懂每个字段的含义。可以在Excel的第三行(注释行)读取中文说明,生成时作为注释输出到每行前面。比如:
-- id=1: 木剑 [1] = { id = 1, name = "木剑", attack = 10 },实现就是在拼接字段时,从rows[2]里取当前记录的id对应的注释,加一个--前缀。注意注释里不能有换行,如果有就替换成空格。这个增强不改变数据结构,只是给人工阅读提供便利。如果觉得第三行注释不够完整,也可以把fields配置里的comment字段直接写成Lua注释,放在每个字段后面;我个人会把配置里的comment和Excel第三行的注释合并,前者描述字段含义,后者描述具体记录,避免重复维护。
6.2 用mtime做增量导出,再用Lua反向diff验证
增量导出前面已经提到,补一个具体技巧:把每次导出的Lua文件哈希值存到build/export_cache.json,下次构建时比对Excel的MD5和缓存里的值,如果相同就跳过。这能省掉大量无谓的I/O和解析时间。另外一个更重要的习惯是反向校验:导出完成后,写一个小脚本把Lua文件解析回Python的dict,再和Excel数据对比。具体做法是用Lupa或直接读取Lua文件里的赋值逻辑,但更简单的是让工具在导出时同时输出一份JSON中间件,作为校验基准;比较JSON和Lua反序列化后的内容,不一致就报警。
我在项目里遇到过因为字符串末尾多了一个空格,导致游戏里名字显示异常的坑,这种diff能把所有不可见差异揪出来。校验脚本可以放在构建流水线的最后一步,作为发布前的守门员。我的教训是:工具写好后先不要急着上全量,挑一份改动频繁的配置表跑一周,每天对比导出结果,确认稳定后再推广;否则你会被各种奇怪的编码和类型问题淹没。希望这些经验能帮到你,少踩几个我当年翻过的坑。
本文还有配套的精品资源,点击获取