游戏自动化项目里有个绕不开的东西:主数据库。
游戏里每张卡的数值、每个技能的效果、每个事件的触发条件、每个 NPC 的对话——这些数据都不是写死在客户端代码里的,而是放在一个外部数据库里,客户端运行时去读。这样运营才能不发新版本就调整数值、加新卡。
社区里通常有大佬把这个数据库导出成 JSON / Protobuf / Yaml 给大家用。我们项目里也是依赖外部仓库(gakumasu-diff)提供的导出文件。
问题来了——这个数据库的表结构成百上千,字段也成百上千。每张表你都得在 Python 侧定义一个对应的 dataclass,才能写代码时有类型提示、IDE 自动补全、避免拼错字段名。
手写 dataclass?
1 | |
手写一遍要写到天荒地老,且每次游戏更新还得追着改。
正确做法是代码生成——根据导出数据自己推断 schema,自动产出 Python dataclass 文件。我们就这么干的。这篇说说怎么落地。
思路:从数据反推 schema
游戏数据库导出后大致长这样(简化版):
1 | |
每张表(key)对应一个 list,list 里每个元素结构基本一致。
要从这堆数据反推出 dataclass,做两件事:
- 遍历 list 的所有元素,收集所有字段名 + 推断每个字段的类型
- 把表名 + 字段信息渲染成 Python 代码文件
听起来简单。真做下来,类型推断是难点。
类型推断的几个层次
最朴素的版本:
1 | |
第一版就这么写的。然后就被打脸——
问题一:只看第一条样本。某个字段在第一条是 null,于是被推断为 NoneType。其他几百条是 int,全无视。
问题二:bool 在 Python 里是 int 的子类。isinstance(True, int) 返回 True。判断顺序错了就把 bool 当 int。
问题三:null 值很常见。同一个字段,有的条目有值有的没有,应该是 Optional[X],不是 X。
问题四:嵌套结构。某个字段是个 dict,得递归推断。
问题五:list 字段。[1, 2, 3] 是 list[int],["a", "b"] 是 list[str],空 list 是 list[Any]。
第二版的推断函数变成这样(伪代码):
1 | |
类型推断的核心思想是”扫全样本、合并所有可能、null 单独标记”。这套逻辑做完,绝大多数字段都能推出靠谱类型。
渲染成 Python 代码
有了表名和字段类型,剩下的就是模板渲染。Jinja2 就够用:
1 | |
输出文件一定要带醒目的 “AUTO GENERATED, DO NOT EDIT” 注释——不然后面肯定有人在生成文件里手改,再次生成的时候改动全没了,事故的标准剧本。
frozen=True 是个好习惯——主数据是只读的,dataclass 也不允许修改。哪天发现某段代码在改主数据 dataclass,那基本是设计错了。
几个生成器的设计点
1. 输出文件按表名拆分
不要把几百个 dataclass 塞一个大文件。一表一个文件:
1 | |
好处:
- IDE 跳转友好(光标点
ProduceCard直接跳到那个文件) - diff 友好(每次生成只动有变化的表,git diff 干净)
- import 友好(要哪个表导哪个)
2. 提供一个聚合入口
按表拆分之后,调用方不希望写一堆 import。所以再生成个 __init__.py:
1 | |
业务代码就能:
1 | |
3. 处理新增/删除字段
游戏每次更新可能:
- 新增字段
- 删除字段
- 修改字段类型(罕见但发生过)
直接重新生成会覆盖旧的 dataclass,调用方代码可能突然挂掉——某个被删的字段,调用方还在引用。
解决思路是生成时检测变化,把变化报告给开发者:
1 | |
打印一份变化报告,开发者一眼看到”哦这次更新动了什么”,提前修业务代码。CI 里把这份 diff 作为 review 内容。
4. 不要让生成器决定文件名命名风格
外部数据库里的表名可能是 ProduceCard(PascalCase)、produce_card(snake_case)、甚至 Produce.Card(带点)。
生成器要做的事是归一化命名:
- 表名 → 文件名:
produce_card.py(snake_case) - 表名 → 类名:
ProduceCard(PascalCase) - 字段名 → Python 属性:保持 snake_case,特殊字符替换
命名转换函数写干净,集中处理,不要在 Jinja 模板里搞字符串处理——模板里写一堆字符串操作的代码后面没人能 review。
一个意外好处:用生成器做契约检查
生成器跑一遍就能检测出数据本身的异常:
- 某个字段在 99% 的条目里都是
int,但 1% 的条目突然是str→ 数据脏了? - 某张表的字段集合两次生成之间剧烈变化 → 上游导出脚本是不是出了问题?
我们的做法是生成器本身会做几种校验,发现异常打 warning。这等于免费的”主数据健康检查”——光是用着用着,就能帮你发现数据源的问题。
跑起来什么样
最终用户调用就一行命令:
1 | |
内部干了这么些事:
- 读取最新的主数据 JSON
- 遍历每张表,做类型推断
- 和上次生成的 schema diff 对比,报告变化
- 渲染 Python 文件
- 更新
__init__.py聚合 - 自动跑
ruff format美化输出
一次跑下来几秒钟。对比手写 dataclass 维护几百个表的工作量,这工具早就把开发成本赚回来无数次了。
几条心得
写完这套之后总结:
1. 生成的代码也是代码——要 commit 进仓库,不要在运行时生成。
跑代码生成会拉 master 数据、跑推断、写文件,这些都不该在生产运行时发生。生成阶段一次性做完,把产物 commit 进 git,运行时只读。
2. 生成器本身要有测试。
生成器看似”工具脚本”,但它的输出影响整个项目的类型系统。测试要覆盖:类型推断准确性、命名转换正确性、edge case(空 list、全 null 字段)。这些不测,哪天生成出来一坨坏代码全靠运气没崩。
3. 给生成的文件加显眼的”不要改”标记。
文件头加大字:
1 | |
文件名前缀加 _generated_ 也行——总之要让人一眼看出”这不是手写的”。
4. 别试图 100% 推断完美。
类型推断永远会有边界 case。允许某些字段最终落到 Any,标个 TODO 注释,让开发者后续手动 override(通过单独的 patch 文件)。强求 100% 自动推断会让生成器复杂度爆炸。
收个尾
代码生成这个套路很老了,但在数据驱动的项目里永远值得用一次。
几百张表、几千字段,手写不仅累、还出错率高、还跟不上游戏更新节奏。换成”读数据 → 推断 → 渲染”的小流水线,把人力释放出来去做真正需要思考的事——比如这些数据该怎么组合、能解锁什么决策能力。
工程师的时间应该用来做机器做不到的事。能自动生成的东西,别手写。