从游戏数据库自动生成 Python schema——别再手写 dataclass 了

游戏自动化项目里有个绕不开的东西:主数据库

游戏里每张卡的数值、每个技能的效果、每个事件的触发条件、每个 NPC 的对话——这些数据都不是写死在客户端代码里的,而是放在一个外部数据库里,客户端运行时去读。这样运营才能不发新版本就调整数值、加新卡。

社区里通常有大佬把这个数据库导出成 JSON / Protobuf / Yaml 给大家用。我们项目里也是依赖外部仓库(gakumasu-diff)提供的导出文件。

问题来了——这个数据库的表结构成百上千,字段也成百上千。每张表你都得在 Python 侧定义一个对应的 dataclass,才能写代码时有类型提示、IDE 自动补全、避免拼错字段名。

手写 dataclass?

1
2
3
表数:~400
平均字段数:15
总字段:~6000

手写一遍要写到天荒地老,且每次游戏更新还得追着改。

正确做法是代码生成——根据导出数据自己推断 schema,自动产出 Python dataclass 文件。我们就这么干的。这篇说说怎么落地。

思路:从数据反推 schema

游戏数据库导出后大致长这样(简化版):

1
2
3
4
5
6
7
8
9
10
11
12
{
"ProduceCard": [
{"id": "card_001", "name": "集中+3", "cost": 4, "effect_id": "buff_focus", "rarity": "R"},
{"id": "card_002", "name": "快攻", "cost": 6, "effect_id": "attack_basic", "rarity": "SR"},
...
],
"ProduceItem": [
{"id": "item_001", "name": "营养剂", "stamina_recovery": 30},
...
],
...
}

每张表(key)对应一个 list,list 里每个元素结构基本一致。

要从这堆数据反推出 dataclass,做两件事:

  1. 遍历 list 的所有元素,收集所有字段名 + 推断每个字段的类型
  2. 把表名 + 字段信息渲染成 Python 代码文件

听起来简单。真做下来,类型推断是难点。

类型推断的几个层次

最朴素的版本:

1
2
3
4
5
6
7
8
9
10
11
12
def infer_type(values):
sample = values[0]
if isinstance(sample, bool):
return "bool"
elif isinstance(sample, int):
return "int"
elif isinstance(sample, float):
return "float"
elif isinstance(sample, str):
return "str"
else:
return "Any"

第一版就这么写的。然后就被打脸——

问题一:只看第一条样本。某个字段在第一条是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def infer_type(values: list) -> str:
non_null = [v for v in values if v is not None]
has_null = len(non_null) < len(values)

if not non_null:
return "None"

# 顺序很重要:bool 必须先判,否则会被 int 吞
types = set()
for v in non_null:
if isinstance(v, bool):
types.add("bool")
elif isinstance(v, int):
types.add("int")
elif isinstance(v, float):
types.add("float")
elif isinstance(v, str):
types.add("str")
elif isinstance(v, list):
inner = infer_type(v) if v else "Any"
types.add(f"list[{inner}]")
elif isinstance(v, dict):
types.add("dict[str, Any]") # 真要严,递归生成嵌套 dataclass
else:
types.add("Any")

# 数值类型混合:int + float 算 float
if types == {"int", "float"}:
result = "float"
elif len(types) == 1:
result = types.pop()
else:
result = "Union[" + ", ".join(sorted(types)) + "]"

if has_null:
result = f"Optional[{result}]"

return result

类型推断的核心思想是”扫全样本、合并所有可能、null 单独标记”。这套逻辑做完,绝大多数字段都能推出靠谱类型。

渲染成 Python 代码

有了表名和字段类型,剩下的就是模板渲染。Jinja2 就够用:

1
2
3
4
5
6
7
8
9
# {{ table_name }}.py - AUTO GENERATED, DO NOT EDIT
from dataclasses import dataclass
from typing import Optional, Union, Any

@dataclass(frozen=True)
class {{ class_name }}:
{% for field_name, field_type in fields.items() %}
{{ field_name }}: {{ field_type }}
{% endfor %}

输出文件一定要带醒目的 “AUTO GENERATED, DO NOT EDIT” 注释——不然后面肯定有人在生成文件里手改,再次生成的时候改动全没了,事故的标准剧本。

frozen=True 是个好习惯——主数据是只读的,dataclass 也不允许修改。哪天发现某段代码在改主数据 dataclass,那基本是设计错了。

几个生成器的设计点

1. 输出文件按表名拆分

不要把几百个 dataclass 塞一个大文件。一表一个文件:

1
2
3
4
5
6
src/entity/master_db/
├── __init__.py
├── produce_card.py
├── produce_item.py
├── support_card.py
└── ...

好处:

  • IDE 跳转友好(光标点 ProduceCard 直接跳到那个文件)
  • diff 友好(每次生成只动有变化的表,git diff 干净)
  • import 友好(要哪个表导哪个)

2. 提供一个聚合入口

按表拆分之后,调用方不希望写一堆 import。所以再生成个 __init__.py

1
2
3
4
5
6
7
# __init__.py - AUTO GENERATED
from .produce_card import ProduceCard
from .produce_item import ProduceItem
from .support_card import SupportCard
...

__all__ = ["ProduceCard", "ProduceItem", "SupportCard", ...]

业务代码就能:

1
from src.entity.master_db import ProduceCard

3. 处理新增/删除字段

游戏每次更新可能:

  • 新增字段
  • 删除字段
  • 修改字段类型(罕见但发生过)

直接重新生成会覆盖旧的 dataclass,调用方代码可能突然挂掉——某个被删的字段,调用方还在引用。

解决思路是生成时检测变化,把变化报告给开发者

1
2
3
4
5
[Schema Diff Report]
ProduceCard:
+ new_field: int (新增)
- old_field (删除!调用方需要更新)
~ rarity: str -> int (类型变化!调用方需要更新)

打印一份变化报告,开发者一眼看到”哦这次更新动了什么”,提前修业务代码。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
python devtools/generate_game_database_schemas.py

内部干了这么些事:

  1. 读取最新的主数据 JSON
  2. 遍历每张表,做类型推断
  3. 和上次生成的 schema diff 对比,报告变化
  4. 渲染 Python 文件
  5. 更新 __init__.py 聚合
  6. 自动跑 ruff format 美化输出

一次跑下来几秒钟。对比手写 dataclass 维护几百个表的工作量,这工具早就把开发成本赚回来无数次了

几条心得

写完这套之后总结:

1. 生成的代码也是代码——要 commit 进仓库,不要在运行时生成。

跑代码生成会拉 master 数据、跑推断、写文件,这些都不该在生产运行时发生。生成阶段一次性做完,把产物 commit 进 git,运行时只读。

2. 生成器本身要有测试。

生成器看似”工具脚本”,但它的输出影响整个项目的类型系统。测试要覆盖:类型推断准确性、命名转换正确性、edge case(空 list、全 null 字段)。这些不测,哪天生成出来一坨坏代码全靠运气没崩。

3. 给生成的文件加显眼的”不要改”标记。

文件头加大字:

1
2
# THIS FILE IS AUTO GENERATED, DO NOT EDIT MANUALLY.
# Run `python devtools/generate_game_database_schemas.py` to regenerate.

文件名前缀加 _generated_ 也行——总之要让人一眼看出”这不是手写的”。

4. 别试图 100% 推断完美。

类型推断永远会有边界 case。允许某些字段最终落到 Any,标个 TODO 注释,让开发者后续手动 override(通过单独的 patch 文件)。强求 100% 自动推断会让生成器复杂度爆炸。

收个尾

代码生成这个套路很老了,但在数据驱动的项目里永远值得用一次

几百张表、几千字段,手写不仅累、还出错率高、还跟不上游戏更新节奏。换成”读数据 → 推断 → 渲染”的小流水线,把人力释放出来去做真正需要思考的事——比如这些数据该怎么组合、能解锁什么决策能力

工程师的时间应该用来做机器做不到的事。能自动生成的东西,别手写。

欢迎关注我的其它发布渠道