ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Excel转Lua配置表导出:三行表头、类型转换与增量自检

Excel转Lua配置表导出:三行表头、类型转换与增量自检

简介:面向游戏开发、配置管理及数据驱动型Lua项目的Excel转Lua工具包,适合需要把角色属性、物品配置、地图信息等表格数据批量导出为Lua脚本的初中级开发者,用于减少运行时对Excel库的依赖并提升配置调试效率。压缩包共4个文件,主要由xlsx示例表格、py转换脚本、bat批处理入口和docx使用说明组成,整体约159KB,体积轻便,便于快速部署与按需修改。已有691人学习,具备一定参考热度。资源内含可运行的转换脚本和配套说明,读者可据此了解Excel字段到Lua表的映射方式、数据类型转换规则与批量导出流程,并借助示例表格验证生成结果;批处理入口也方便在Windows环境下直接执行转换。对于希望简化配置数据管理、减少手工誊写错误的开发者,这份工具包提供了较完整的上手样例和可复用脚本基础。

1. 从策划表到 Lua 常量:一行数值改了三个地方,我决定自己写个 excel转lua 工具

数值表、道具表、关卡表全躺在 Excel 里,程序这边要的是 Lua 表。一开始靠手抄,抄到第二周就出事了:同一个道具的攻击力,表里改成了 120,Lua 里还留着 100,测试环境跑得通,线上打出来伤害差一截,复盘时谁也说不清哪份才是准的。这就是 excel转lua 这类工具存在的唯一理由——把「表里的那份」变成「代码里唯一的那份」。

它干的事很朴素:读 xlsx,按表头约定的类型做转换,输出成能直接 require 的 Lua 文件。适合手上有一堆数值表、配置层又用 Lua 的项目;也适合那些还没到上配置中台规模、只想先拿一个脚本把双写问题摁住的团队。下面按我实际拆过的结构讲,从表头怎么定,到代码怎么写,到哪几个地方最容易翻车。

2. 表头定生死:三行表头、类型列与主键模型怎么定

工具本身不难写,难的是先想清楚「一张 sheet 到底要被翻译成什么」。这一步定错了,后面全是补丁:加个字段要改代码,改个类型要改三处,策划多加一列导出就报错。我见过不少这类脚本,第一版两百行跑得挺欢,三个月后变成八百行,里面全是针对某张表的 if 分支。

所以先把三件事定死:表头占几行、类型列怎么写、一张表导出成什么形状的 Lua 表。这三件事确定之后,导出逻辑基本就是纯机械劳动。

2.1 三种表头布局,选错一个后面全是补丁

Excel 里常见三种写法,各有各的代价。

第一种是单行表头,第一行就是字段名,类型靠命名后缀约定,比如atk_i表示 int、name_s表示 string。好处是省一行,策划看着干净;坏处是类型信息寄生在列名里,新人接手第一眼看不出来,改列名的时候很容易把后缀弄丢。这种方案在只有两三个人的小团队里能活,人一多就崩。

第二种是双行表头,第一行字段名,第二行类型。够用,但少了中文注释,策划查表的时候得靠记忆。

第三种是三行表头:第一行字段名(英文,导出成 Lua 的 key),第二行类型,第三行中文注释。我一般选这个,多出来的一行换来的是两件事——导出的 Lua 文件可以把注释原样带上,策划改表不用问程序「这个字段啥意思」;类型列独立成行,解析逻辑不用去猜列名。

布局表头行数类型来源注释适合场景
单行 + 后缀1列名后缀无两三人小团队、表量少
双行2独立类型行无字段稳定的老项目
三行3独立类型行有策划与程序分离、表持续迭代

选三行之后有个约定必须写进文档:数据从第 4 行开始,前三行不允许出现合并单元格。这一条看着像废话,但合并单元格是后面所有诡异问题的源头之一,第 5 章会专门说。

2.2 类型列怎么写:从 int 到 map 的映射规则

类型列是整套工具的核心契约,写死了之后策划和程序都按它走。我用的是一套很土但够用的字符串类型系统,解析时按前缀判断,不引入额外 DSL。

类型写法含义Excel 里的填法生成的 Lua 字面量
int整数10011001
float浮点1.51.5
string字符串长剑"长剑"
bool布尔TRUE/1/是true
array<int>整数数组1,2,3或1#2#3{1, 2, 3}
map<int,int>键值对1:10,2:20{[1]=10, [2]=20}
json兜底结构化一行 JSON转成 Lua table

这里有几个必须提前想明白的细节。

Excel 里的数字在 openpyxl 里读出来一律是 float,1001会变成1001.0。如果直接 str 一下塞进 Lua,生成出来就是1001.0,Lua 里拿它去做 table 的 key,t[1001]和t[1001.0]在 Lua 5.1/5.3 里行为不完全一致,是后面排查起来最费劲的一类问题。所以int的解析必须显式判v != int(v),不相等就报错,而不是静默截断——让配错表的人在导出阶段就看见,别等到运行时。

bool更麻烦。Excel 原生布尔读出来是 Python 的True/False,但策划喜欢填1/0或者是/否,甚至有人填开/关。我一般支持前三种,开/关直接报错,逼着统一。判定顺序是先看类型,再看字符串,全部小写化之后比较,避免大小写折腾。

数组和 map 的分隔符也要定一个。1,2,3看着自然,但如果元素本身是字符串又带逗号就废了。稳妥一点的做法是元素类型是 string 时用|分隔,数值类型才允许用逗号。这条规则写在工具的类型文档里,比事后一个个救火划算得多。

注意:类型列一旦定稿就冻结,新增类型只能加不能改语义。我吃过一次亏,把array<int>的语义从「空列表」改成「null」,结果所有空数组的配置项语义全变,查了两天才定位到。

2.3 一个 sheet 导出成什么样:主键字典还是行数组

同一张表,导出成 Lua 有两种形状,选哪个直接影响运行时代码怎么写。

主键字典是把某一列(通常是 id)当 key:

return { [1001] = { id = 1001, name = "长剑", atk = 120, tags = {"weapon", "sword"} }, [1002] = { id = 1002, name = "短刀", atk = 80, tags = {"weapon"} }, }

行数组是按行顺序排:

return { { id = 1001, name = "长剑", atk = 120 }, { id = 1002, name = "短刀", atk = 80 }, }

主键字典的好处是查表 O(1),Config.Item[itemId]一行完事,代码可读性也好;坏处是遍历时顺序不保证,如果业务上依赖「按配置顺序」遍历,就得额外维护一个顺序数组。行数组反过来,遍历稳,查表要自己建索引。

我的做法是默认导出主键字典,同时在文件末尾额外返回一个__ordered数组存 id 顺序:

local data = { ... } local ordered = { 1001, 1002, ... } return { data = data, ordered = ordered }

调用方想遍历就for _, id in ipairs(cfg.ordered) do local row = cfg.data[id] ... end,两头都不吃亏。多出来的那点文件体积,比后期重构便宜太多。

主键列本身要在表头里标记出来,常见做法是在字段名后面加个星号,或者单独用一个约定列名id。我倾向后者——强制每张表都有一个叫id的 int 列,工具不用猜,策划也不用记规则。没有 id 的表(比如纯文本配置)就走行数组模式,由命令行参数显式指定,不做自动推断。

3. 跑通第一次导出:目录约定、读表函数和一条能复制的命令

原理清楚了,落到代码。这一章给的是能直接抄的骨架,从目录结构到一次完整导出。

3.1 依赖与目录结构

工具本身只要一个第三方库 openpyxl,Python 3.8 以上就够。目录上我习惯把源表和产物分开,源表放excel/,产物放out/,工具代码单独一个包,避免和业务代码混在一起。

# 目录约定 project/ ├── excel/ # 策划维护,只读 │ ├── item.xlsx │ ├── level.xlsx │ └── text.xlsx ├── tools/ │ └── xlsx2lua/ │ ├── __init__.py │ ├── reader.py # 读表 + 类型转换 │ ├── writer.py # Lua 序列化 │ ├── cli.py # 命令行入口 │ └── schema.py # 表头常量与类型表 └── out/ # 生成的 lua,进版本库 ├── item.lua └── level.lua
pip install openpyxl

依赖就这一条。不建议为了省事引入 pandas,一张几万行的表 openpyxl 的read_only=True模式已经够快,pandas 反而会因为类型推断把1001识别成 int、把1001.0识别成 float,行为不稳定。

3.2 读表这一步:把 cell 变成 Python 原生类型

# tools/xlsx2lua/schema.py NAME_ROW = 0 # 第 1 行:字段名 TYPE_ROW = 1 # 第 2 行:类型 DESC_ROW = 2 # 第 3 行:中文注释 DATA_ROW = 3 # 第 4 行起:数据 def cell_text(v): """把任意单元格值压成去掉首尾空白的字符串,None 视为空串。""" if v is None: return "" if isinstance(v, float) and v == int(v): return str(int(v)) return str(v).strip()
# tools/xlsx2lua/reader.py from openpyxl import load_workbook from .schema import NAME_ROW, TYPE_ROW, DESC_ROW, DATA_ROW, cell_text def read_sheet(path, sheet_name): wb = load_workbook(path, data_only=True, read_only=True) if sheet_name not in wb.sheetnames: raise KeyError(f"{path} 里没有 sheet: {sheet_name}") ws = wb[sheet_name] rows = [list(r) for r in ws.iter_rows(values_only=True)] wb.close() if len(rows) <= DATA_ROW: raise ValueError(f"{path}:{sheet_name} 数据行不足,检查是否只有表头") ncol = max(len(r) for r in rows[:DATA_ROW]) names = [cell_text(rows[NAME_ROW][i]) for i in range(ncol)] types = [cell_text(rows[TYPE_ROW][i]) for i in range(ncol)] descs = [cell_text(rows[DESC_ROW][i]) for i in range(ncol)] fields = [] for i in range(ncol): if not names[i]: continue # 空列名视为分隔列,直接跳过 if not types[i]: raise ValueError(f"{path}:{sheet_name} 第 {i+1} 列 {names[i]} 缺类型") fields.append({"index": i, "name": names[i], "type": types[i], "desc": descs[i]}) records = [] for rno in range(DATA_ROW, len(rows)): row = rows[rno] if all(cell_text(c) == "" for c in row): continue # 整行空白跳过 rec = {} for f in fields: raw = row[f["index"]] if f["index"] < len(row) else None rec[f["name"]] = convert(raw, f["type"], f"{path}:{sheet_name} 第 {rno+1} 行 {f['name']}") records.append(rec) return fields, records

这段是整个工具的地基,几个设计点值得说清楚。

ncol取的是前三行的最大列数,而不是数据行的最大列数。原因是策划经常在下面几行临时多填一格,如果按数据行算宽度,字段名数组会越界;按表头算,多出来的数据列直接被忽略,行为可预期。

空列名跳过、有列名但没类型报错,这两条是刻意不对称的。空列名往往是策划插的视觉分隔列,静默跳过没人有意见;有名字没类型是明显的配置错误,必须炸出来,否则导出的 Lua 里这列会凭空消失,运行时才发现少字段。

行号在报错信息里带上,是因为出问题的时候策划只会说「导出报错了」,你得让他自己能定位到具体哪一行。

接下来是类型转换,按 2.2 那张表逐个实现。

# tools/xlsx2lua/reader.py(续) import json def convert(raw, typ, ctx): typ = typ.strip() if typ.endswith("[]") or typ.startswith("array<"): inner = typ[6:-1] if typ.startswith("array<") else typ[:-2] return [convert(x, inner, ctx) for x in _split_list(raw, inner, ctx)] if typ.startswith("map<"): kt, vt = typ[4:-1].split(",") out = {} for pair in _split_list(raw, "string", ctx): if not pair: continue k, v = pair.split(":", 1) out[convert(k, kt, ctx)] = convert(v, vt, ctx) return out if typ == "int": if isinstance(raw, bool): raise ValueError(f"{ctx}: 布尔值不能当 int") if isinstance(raw, float): if raw != int(raw): raise ValueError(f"{ctx}: {raw} 不是整数,检查单元格格式") return int(raw) return int(str(raw).strip()) if typ == "float": return float(str(raw).strip()) if not isinstance(raw, (int, float)) else float(raw) if typ == "bool": s = cell_text(raw).lower() if s in ("1", "true", "是", "yes"): return True if s in ("0", "false", "否", "no", ""): return False raise ValueError(f"{ctx}: 无法识别的布尔值 {raw!r}") if typ == "string": return cell_text(raw) if typ == "json": return json.loads(cell_text(raw) or "{}") raise ValueError(f"{ctx}: 未知类型 {typ}") def _split_list(raw, inner, ctx): s = cell_text(raw) if not s: return [] sep = "|" if inner == "string" else "," return [p.strip() for p in s.split(sep)]

参数说明:raw是 openpyxl 吐出来的原始值,可能是None、int、float、str、datetime;typ是类型列里的字符串;ctx是拼好的定位信息,一路透传到最内层,保证任何一层报错都能指回具体单元格。

注意bool分支里我把空串也当False。这是个有争议的选择——严格来说空串应该报错,逼策划填值。但实际项目里布尔列大量留空表示「默认关闭」,全报错的话导出日志会被刷屏,反而没人看。真要严格,加个开关单独控制。

3.3 生成 Lua:序列化函数与转义

拿到fields和records之后就是拼字符串。这一步最容易翻车的是转义,第 5 章会细讲,这里先给能用的版本。

# tools/xlsx2lua/writer.py _ESC = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"} def lua_str(s): out = [] for ch in s: if ch in _ESC: out.append(_ESC[ch]) elif ord(ch) < 0x20: out.append("\\%03d" % ord(ch)) # Lua 十进制转义 else: out.append(ch) return '"' + "".join(out) + '"' def lua_val(v, decimals=6): if v is True: return "true" if v is False: return "false" if isinstance(v, int): return str(v) if isinstance(v, float): s = f"{v:.{decimals}f}".rstrip("0").rstrip(".") return s if s not in ("", "-") else "0" if isinstance(v, str): return lua_str(v) if isinstance(v, list): return "{" + ", ".join(lua_val(x, decimals) for x in v) + "}" if isinstance(v, dict): parts = [] for k in sorted(v.keys()): parts.append(f"[{lua_val(k, decimals)}] = {lua_val(v[k], decimals)}") return "{" + ", ".join(parts) + "}" if v is None: return "nil" raise TypeError(f"无法序列化的类型: {type(v)}")
def dump_table(fields, records, key_field="id", indent=" "): lines = ["-- 由 excel 自动生成,请勿手工修改", "local data = {"] ordered = [] for rec in records: key = rec.get(key_field) if key is None: raise ValueError(f"记录缺少主键字段 {key_field}: {rec}") ordered.append(key) row_parts = [] for f in fields: row_parts.append(f"{indent}{indent}{f['name']} = {lua_val(rec[f['name']])},") lines.append(f"{indent}[{lua_val(key)}] = {{") lines.extend(row_parts) lines.append(f"{indent}}},") lines.append("}") lines.append("local ordered = {" + ", ".join(lua_val(k) for k in ordered) + "}") lines.append("return { data = data, ordered = ordered }") return "\n".join(lines) + "\n"

dump_table里key_field默认取id,可以按表覆盖。ordered数组保留原始行序,配合主键字典解决 2.3 提到的遍历顺序问题。

浮点保留 6 位小数再rstrip("0"),是为了避免0.1 + 0.2那类二进制表示误差被原样写进 Lua——0.30000000000000004这种字面量出现在配置里,看着就让人血压升高。6 位对绝大多数游戏数值够用,涉及精确计算的表建议直接改用整数(把数值乘 1000 存)。

3.4 命令行参数与一次完整导出

# tools/xlsx2lua/cli.py import argparse, pathlib, sys from .reader import read_sheet from .writer import dump_table def main(argv=None): p = argparse.ArgumentParser(prog="xlsx2lua") p.add_argument("--src", required=True, help="xlsx 所在目录") p.add_argument("--out", required=True, help="生成的 lua 输出目录") p.add_argument("--sheet", action="append", default=[], help="只导指定 sheet,可重复;不传则导全部") p.add_argument("--key", default="id", help="主键字段名,默认 id") p.add_argument("--check", action="store_true", help="只做校验不写文件") args = p.parse_args(argv) src, out = pathlib.Path(args.src), pathlib.Path(args.out) out.mkdir(parents=True, exist_ok=True) total = 0 for xlsx in sorted(src.glob("*.xlsx")): if xlsx.name.startswith("~$"): continue # Excel 打开时的临时文件 from openpyxl import load_workbook wb = load_workbook(xlsx, read_only=True) names = [n for n in wb.sheetnames if n not in ("说明", "README")] wb.close() for name in names: if args.sheet and name not in args.sheet: continue fields, records = read_sheet(xlsx, name) code = dump_table(fields, records, key_field=args.key) if not args.check: (out / f"{name}.lua").write_text(code, encoding="utf-8") total += len(records) print(f"[OK] {xlsx.name}/{name} {len(records)} 行") print(f"完成,共 {total} 行") return 0 if __name__ == "__main__": sys.exit(main())

一条能直接跑的命令:

python -m tools.xlsx2lua.cli --src excel/ --out out/ --sheet Item --sheet Level

参数逐个说:--src指向 excel 目录,工具会扫描目录下所有.xlsx,跳过以~$开头的临时文件(Excel 打开着表的时候会生成这种文件,不跳过会读到半个空表);--out是产物目录,不存在会自动建;--sheet可重复,不传就导全部 sheet,但说明和README这两类 sheet 名会被硬编码排除,这是约定俗成的文档页;--key指定主键列名,纯数组表传--key ""可以走行数组分支(需要 writer 里补一条判断);--check只校验不落盘,适合放进 CI。

4. 生成策略三选一:字符串拼接、分文件懒加载与 require 的取舍

到这一步工具已经能出东西了,但「能出」和「能长期用」是两回事。表从几十行长到几万行,从一张变成几十张,生成策略的选择就开始显形。

4.1 字符串拼接为什么是最稳的

生成 Lua 的常见做法有三种:手写字符串拼接、套模板引擎、用现成的 Lua 序列化库 dump。

模板引擎(Jinja2 之类)看起来优雅,实际用起来是最不划算的——为了输出一个{[1001] = {...}}的结构,你得在模板里写循环、写缩进控制、写转义 filter,模板本身的复杂度很快就超过直接拼字符串。而且模板的报错信息极难定位,出错时你看到的是「模板第 47 行渲染失败」,不是「item.xlsx 第 1003 行 atk 字段类型不对」。

现成的 Lua 序列化库(比如某些通用 dumper)能省事,但它们通常按 Python 类型自动推断输出,1001和1001.0出来的结果不一样,True/False和1/0的映射也不是你能控制的。配置表这种场景,类型必须是显式的,宁可自己写五十行序列化,也别把类型控制权交出去。

字符串拼接的代价是要自己处理转义和缩进,但换来的是完全可控:每个值怎么输出、每个字段按什么顺序、出错时报什么信息,全都是你说了算。我在这上面花的时间,比后面排查各种玄学问题省下来的时间少得多。

4.2 大表怎么办:分文件 + 懒加载

单表超过一万行之后,生成出来的 Lua 文件会到几 MB 级别。这种文件有两个问题:一是luac编译慢,二是首次require时的内存峰值高。

我的处理是「一张表一个文件」,文件名就是 sheet 名,路径固定out/Item.lua、out/Level.lua。业务侧用一个统一的入口做懒加载:

-- config.lua local M = {} local cache = {} function M.get(name) if cache[name] == nil then cache[name] = require("out." .. name) end return cache[name] end -- 用法:local item = M.get("Item").data[1001] return M

这种做法把加载时机交给业务决定——主界面只 require 它真正用到的表,登录流程里那些用不上的表压根不进内存。代价是第一次访问有 IO,如果某张表在战斗帧里被首次访问,会卡一下。解决办法是在 loading 阶段预热:for _, n in ipairs({"Item","Skill","Buff"}) do M.get(n) end。

另一种是单文件全量导出,把所有表塞进一个大 Lua 文件。好处是加载一次就全有了,坏处是任何一张表改动都会导致整个文件重新编译,增量更新完全失效。表少的时候无所谓,表一多就是灾难。

还有第三种:按行分片,比如Item_1.lua到Item_10.lua,每片一千行。这种适合超大表(十万行级),但索引逻辑要自己维护,一般项目用不上,量到了再说。

4.3 require 还是 loadfile:运行时加载的选择

Lua 里有两种加载方式,行为差别不小。

require走的是package.path查找,带缓存(package.loaded),同一个模块第二次 require 直接返回缓存值,不会重复执行。适合配置这种「一个进程内只加载一次」的场景。

loadfile是直接按路径读文件并编译,不走缓存,每次调用都重新读盘。适合需要热重载的编辑器工具——策划改完表,点一下「重载配置」,工具用loadfile重新加载,不用重启进程。

热重载的正确写法是先把旧表清掉再加载,否则package.loaded里还留着旧的:

function reload(name) package.loaded["out." .. name] = nil return require("out." .. name) end

有个细节容易被忽略:require在加载失败时会留下不完整的状态,某些 Lua 版本里package.loaded会被清理,某些不会。热重载脚本里最好用pcall包一层,失败了保留旧表继续跑,而不是让整个编辑器崩掉。

5. 避坑与排查:五个让导出结果对不上的坑

工具跑通只是开始,真正花时间的是「导出的结果和预期不一致」。下面五条都是实际撞过的,按「现象 → 原因 → 解决」写,照着对号入座基本能覆盖八成问题。

5.1 现象:Lua 里t[1001]取不到值,但打印出来明明有 1001

原因:Excel 里所有数字读出来都是浮点,int类型列如果走的是cell_text那条路径,1001.0会被压成"1001"再int()转回来,没问题;但如果某条路径直接用了lua_val(raw),isinstance(1001.0, int)是 False,会落到 float 分支,输出1001。看着一样,实际在 Lua 里被当成整数还是浮点,取决于编译选项和版本,t[1001.0]在 5.3 里会报table index is nil或者静默变成另一个 key。

解决:convert里对int分支强制isinstance(raw, float)时做raw != int(raw)判断并返回int(raw),dump_table之前再断言一遍所有主键字段是int类型。加一条assert(isinstance(key, int)),在导出阶段炸掉,比运行时排查便宜一百倍。

5.2 现象:生成的 Lua 语法错误,报「unfinished string」

原因:策划在描述字段里填了英文双引号,比如他说"这个不对"。序列化时只做了最外层的引号包裹,内层引号没转义,生成出来是"他说"这个不对"",Lua 解析器直接断在第二个引号上。同样是换行符——Excel 里 Alt+Enter 换行,读出来是\n,没转义的话在 Lua 里就是一个真实的换行,字符串没闭合。

解决:lua_str必须逐字符遍历,反斜杠、双引号、\n、\r、\t全部转义,控制字符统一走\ddd十进制转义。别用str.replace链式替换,替换顺序错了会二次转义(先转义反斜杠再转义引号,引号前面会多出一个反斜杠)。逐字符遍历只扫一遍,不存在顺序问题。

5.3 现象:本来应该是空数组的字段,到 Lua 里变成了nil

原因:_split_list里空串返回[],序列化成{},Lua 里#t == 0,看着是空表。但有些版本的 dump 逻辑会做「空表优化」,直接输出nil,业务侧for i, v in ipairs(cfg.tags)直接崩在 nil 上。

解决:空数组永远输出{},绝不优化成nil。同理,空字符串输出""而不是nil。这个约定写进工具文档,并且在 writer 里加断言:lua_val遇到空 list 返回"{}",遇到空 str 返回'""',不允许任何捷径。另外要区分「没填」(None)和「填了空」(""),前者输出nil,后者输出"",语义完全不同。

5.4 现象:某张表导出的行数比 Excel 里看到的少

原因有三个常见来源。一是合并单元格——策划在 id 列合并了几行,openpyxl 读出来只有第一行有值,后面全是 None,这些行要么被当成空白行跳过,要么主键是 None 直接报错。二是隐藏行,iter_rows默认会把隐藏行也读出来,但如果表里用了筛选,某些行在你的视图里看不见,实际还在。三是整行空白判断写成了all(c is None for c in row),而实际上单元格里是空格字符串" ",不是 None。

解决:导出前先跑一遍体检脚本,检查合并单元格范围和隐藏行数量,把结果打印出来。整行空白判断改用all(cell_text(c) == "" for c in row),能同时覆盖 None、空串和纯空格。主键为 None 的行直接报错并打印行号,不要静默跳过——跳过是最糟糕的处理方式,数据凭空消失比报错难查十倍。

5.5 现象:字段名和 Lua 关键字撞了,生成的代码编译不过

原因:策划把字段名写成了end、for、repeat、function这类 Lua 保留字。生成的 Lua 里出现end = 1,直接语法错误。更隐蔽的是和标准库函数同名,比如type、next、select,这些不报错但会覆盖同名全局,运行时行为诡异。

解决:导出前维护一张保留字表,字段名命中就报错,让策划改名(加后缀_或者改成end_time)。这张表要把 Lua 5.1 到 5.4 的关键字全部并进去,别只抄 5.1 的那 21 个。另一个容易漏的是字段名大小写不一致——Excel 里有的写Atk有的写atk,生成出来是两套 key,业务侧取不到值。统一在read_sheet里转小写并检查重复,重复的直接报错。

再补一个排查习惯:导出的 Lua 文件头部固定写一行注释,包含源文件名、sheet 名、导出时间、行数。出问题的时候,一眼就能确认线上跑的是不是最新那份,不用去比对文件内容。

6. 增量导出与自检:让工具活过三个版本迭代的两个小机制

工具最怕的不是写得烂,是没人敢改。项目跑三个月,表从 5 张变成 40 张,每次全量导出要 20 秒,开发就绕过去手工改 lua 了——工具一旦被绕过,就等于死了。所以有两个机制我基本每次都会加。

第一个是内容哈希缓存。对每个 xlsx 文件算一次sha1,和.cache.json里存的上次结果比对,没变的 sheet 直接跳过。

# tools/xlsx2lua/cache.py import hashlib, json, pathlib def file_hash(path): h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def load_cache(p): p = pathlib.Path(p) return json.loads(p.read_text("utf-8")) if p.exists() else {} def save_cache(p, cache): pathlib.Path(p).write_text(json.dumps(cache, indent=2), encoding="utf-8")

参数上要注意一点:哈希算的是文件字节,不是「逻辑内容」。策划打开表什么都没改就保存了一次,Excel 也会重写文件头,哈希变了,照样触发全量导出。这个误判率在接受范围内,真要更精确,可以对每个 sheet 的「字段名 + 全部数据单元格」拼成一个字符串再算哈希,代价是每次都要完整读一遍表——省下的那点导出入不敷出。我一般用文件级哈希,够用。

第二个是产物语法自检。导出的 Lua 到底能不能被加载,光看代码是看不出来的,得让 Lua 自己说。

#!/usr/bin/env bash # tools/check_out.sh set -euo pipefail OUT="${1:-out}" fail=0 for f in "$OUT"/*.lua; do if ! lua -e "assert(loadfile('$f'))" 2>/tmp/lua_err; then echo "[FAIL] $f" cat /tmp/lua_err fail=1 fi done exit $fail

loadfile只编译不执行,速度快,几 MB 的文件也是毫秒级。这一步挂进 CI 的 pre-commit,比等到运行时爆attempt to index a nil value好太多。真要更严一点,把文件真的执行一次,断言type(result.data) == "table",加到上面的循环里就是几行的事。

这两个机制加起来不到八十行代码,但它们决定了一件事:改一行表就导出一次的成本够低,低到没人愿意绕过它。工具能不能活过三个版本,靠的从来不是功能多全,是这条路径够不够短。

我现在的习惯是,任何配置表相关的新项目,第一件事不是写导出逻辑,而是先把「哈希缓存 + 语法自检 + 头部注释」这三件套搭起来,然后再去填convert里的类型分支。头两次会觉得是多余的仪式,等到第一次在凌晨被告警叫醒、打开文件看到头部那行「由 excel 自动生成」并确认线上跑的就是最新版本时,就知道这十分钟花得值。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进