尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

当回归不只是截图:JSON数据校验与关卡配置防脱节实践

当回归不只是截图:JSON数据校验与关卡配置防脱节实践 “关卡进了源码却对不上 JSON”不是段子是我最近真实踩过的一次坑。新关卡的功能代码、美术资源、场景搭建全部合入主分支但配置关卡信息的 JSON 文件因为合并冲突被静默跳过结果运行时一直拿不到关卡数据。更讽刺的是回归流程跑了两轮自动对比截图全部通过看起来很稳实际却完美放过了这个致命错误。于是我想写一篇关于源码、JSON 和回归之间关系的东西。不是单纯教你怎么筛配置而是聊聊“当回归被当成‘再截一张图’时它会漏掉什么样的真相”。1. 先聊聊这次事故先描述一下现场。项目用的是前后端分离的关卡配置方案策划在后台编辑关卡数据导出一份 JSON 作为配置表程序侧通过关卡 ID 去加载对应的关卡场景和玩法参数。当天我合完代码后本地手工启动客户端日志里明确打印出一条警告LevelConfig中未找到目标关卡Level_Dream_City系统将使用默认配置。我以为是资源没有导到包里结果检查目录资源、Prefab、场景文件都齐。我再检查编译产物发现代码是新的但配置目录里的levels_prod.json还是加了新功能之前的旧版本。新关卡的levelId、解锁条件、奖励列表、敌人波次参数统统不在里面。代码引用的 JSON 对不上等于是拿着一张新版菜谱进了旧版厨房菜谱上写的都是新菜冰箱里全是旧食材。这就是典型的“代码进了源码JSON 没跟上”。这类问题往往不是二进制依赖缺失而是数据与代码两个仓库维度之间的契约断了。你在 IDE 里 build 能成功运行时也不一定立刻崩但表现一定是错的要么关卡入口消失要么默认难度覆盖了策划配置要么玩家数据写入一个根本不存在的关卡。1.1 “关卡进了源码却对不上 JSON”到底是什么意思用更直白的话描述源码是程序逻辑JSON 是数据配置关卡是这两者的结合体。一个关卡想正常跑起来光有 Script 和 Prefab 是不够的还得有一份让代码“认识它”的配置描述比如 ID、名称、是否解锁、所需星级、可用角色、怪物表、掉落表、音效映射等。这些数据往往存在 JSON 文件里由策划或后端配置系统生成。“对不上 JSON”在实操中有几种常见表现代码里用LevelConfigManager.Get(Level_Dream_City)取配置但 JSON 里根本没有这个键。JSON 里有关卡但字段名是stageName代码却读levelName取出来全是空值。字段类型变了比如策划把难度从字符串hard改成了数字3代码还在做字符串比较逻辑绕过了困难难度的分支。关卡列表的索引顺序被其他分支调整JSON 数组位移了一位导致一张图把另一张图的配置读走。只要犯其中一条游戏中就会出现“画面正常但功能诡异”的现场。视觉上一切都很漂亮你甚至能走进这张图但 AI 不出怪、奖励发不出、下一关无法解锁。最要命的是这些问题在截图对比里完全看不出来因为截图表层的渲染结果可能完全一致。1.2 回归里的“回归”别和机器学习的回归模型搞混提个有意思的现象。搜索“回归”的时候有一大堆词条是“逻辑回归”“回归树”“xgboost 回归模型”“lightgbm 回归预测”这类机器学习内容。很多同事在聊“回归测试”时也常常绕进这个岔路以为回归是拿历史数据去拟合一个预测模型让模型来预测新版本有没有问题。软件开发语境里的“回归”不是这个意思。回归测试是指修改完旧代码、新增功能之后重新跑一遍原有测试目的就是确认已有功能没有被新改动破坏。一个曾经正常的功能因为新合入的代码或配置而重新出错叫“回归缺陷”。所以“回归不是再截一张图”这句话里的回归指的就是这套“防止旧功能被改坏”的质量保障流程。如果把回归测试误解成机器学习里的回归预测那会非常危险。你会倾向于“用历史截图生成一个预测结果”然后拿新截图和预测结果比较认为没差异就是没问题。这等于是在用一个统计模型来代替确定性校验忽略配置结构、字段上下文和运行行为出现漏测几乎不可避免。做质量保障逻辑回归模型也好、随机森林回归也好都没法替你回答一个最简单的问题代码要求读取的 JSON 节点到底存不存在。2. 根因有时候不是“改错了”而是忘了“数据契约”回到我的具体问题。代码本身没有 bug测试环境也没有缺资源纯粹是版本管理流程里失去了“配置与代码的同步性”。要根治这类问题先要理解关卡 JSON 这类数据配置在项目中扮演的角色。2.1 关卡 JSON 在多人协作中的典型结构一个中型项目的关卡配置往往会按功能模块拆成多个 JSON比如level_meta.json记录关卡 ID、名称、排序、解锁条件、推荐战力。level_reward.json记录通关奖励、首通奖励、宝箱掉落。level_wave.json记录每一波敌人的刷新时间、坐标、怪物配置。level_dialog.json记录关卡开始前、BOSS 战前的剧情对话。实际开发中一个关卡模块会对应多个 JSON 文件而不是把所有信息塞进一个超大文件。这样做的好处是策划和程序可以并行编辑坏处是很容易出现“源码里改了关卡流程配置里却只改了资源表”的割裂。拿我们项目举例程序约定每一个配置节点必须包含一个固定的levelId字段并且这个字段要和LevelEnum里的枚举值一一对应。代码合并之后只要 JSON 里缺少某个levelId整个配置加载服务就会跳过一个节点。当时我的新关卡恰好没有在level_meta.json里注册代码里却多了一个枚举值两边就对不上了。2.2 配置与代码脱节三种套路在实际项目里我总结下来配置与代码脱节通常有三种套路每一种都很容易在“回归只截一张图”的流程里溜走。第一种是“新增型脱节”代码新增了一个关卡、一个玩法、一个配置键但配置 JSON 没有同步增加。这种最常见也最容易被忽略因为新增代码往往伴随新增资源新增资源会加进版本管理里但配置 JSON 可能是策划在另一个分支单独维护程序员只有在本地运行后才会发现数据缺失。第二种是“改型脱节”代码里修改了读取逻辑比如原来读enemyList后来拆成了normalEnemyList和eliteEnemyList但 JSON 数据还停留在旧字段结构。没有报错只是normalEnemyList始终为空精英怪永远刷不出来。第三种是“契约漂移型脱节”代码没有改JSON 也没少但数据类型悄悄变了。举个真实例子某个字段原来是布尔值true/false策划某天在表格里填了一个数字1导出 JSON 的时候布尔变成了整型。代码里还用它做if (config.unlockFlag)判断JavaScript 和很多脚本环境会把1当 truthy 直接放行但到严格类型语言里就炸了轻则读不到配置重则整个关卡直接退出。2.3 为什么视觉回归根本测不到这个 bug回归“再截一张图”的最大问题是它只验证了渲染结果完全没有验证数据链路。截图里能看到场景加载出来了、角色站上去了、按钮排列正常但你看不到代码拿到的 JSON 是否正确看不到Level_Dream_City的解锁条件有没有被默认配置覆盖更看不到策划配置的怪物波次是否真的生效。再往深里说视觉回归适合验证 UI 布局、场景渲染、资源替换这类“看得到的变化”。遇到数据链路损坏时表现往往是功能缺失或兜底逻辑代替实际配置。系统可能因为没有配置而走了默认难度、默认奖励、默认文本而这些默认值在截图里看起来反而很“正常”。如果程序员在代码里做了很强的容错比如“取不到配置就返回一个空对象”那客户端甚至不会报明显的错只会悄悄地上一个空关卡给你玩。所以真正的回归强调两个维度一个是行为回归跑自动化用例去确认玩法逻辑另一个是数据回归用脚本去确认配置在结构、类型、枚举、业务规则上依然满足代码的预期。后者往往被团队一张截图策略完全覆盖掉属于质量体系里最容易缺失的一环。3. 给 JSON 上“户口”模式验证是第一步那么怎么把“JSON 对不上代码”的这类问题挡在合入之前我推荐的第一道防线不是写几十个单元测试而是给 JSON 定义一套明确的“户口”——JSON Schema。3.1 先用工具校验格式再用 Schema 校验业务很多项目已经会在 CI 里跑一次 JSON 语法检查但语法检查只能保证“文件能被解析”不能保证“字段是否齐全、类型是否正确、枚举是否合法”。比如一个缺了levelId的 JSON 文件它依然是语法的 JSON但业务上根本不合格。JSON Schema 可以理解为“配置的说明书”它声明了哪些字段必填、字段值类型、枚举范围、数组长度、嵌套结构等约束。只要 JSON 符合 Schema代码读取时就不会因为字段缺失或类型变化而出错。早期的 JSON Schema Draft-04 用得比较多现在 Draft-07 和 2020-12 也很常见大部分语言都有现成库支持。下面是一个针对level_meta.json的 Schema 示例用来表达“关卡 ID 必填、名称必填、难度必须属于 1 到 5 的整数、解锁条件可填写”{ $schema: http://json-schema.org/draft-07/schema#, type: object, required: [levelId, name, difficulty], properties: { levelId: { type: string, pattern: ^Level_ }, name: { type: string, minLength: 1 }, difficulty: { type: integer, minimum: 1, maximum: 5 }, unlockCondition: { type: [string, null] }, stages: { type: array, items: { $ref: #/definitions/stageItem } } }, definitions: { stageItem: { type: object, required: [stageId, enemyWave], properties: { stageId: { type: string }, enemyWave: { type: array } } } } }这个 Schema 的意义在于它把配置编写者的自由度限制在代码需求的范围内。如果策划在后台导出配置时漏掉了关卡名称校验脚本会直接报错而不会等到游戏运行时让玩家看到一张没有名字的关卡卡片。3.2 一个能落到项目里的快速验证脚本我在项目里习惯用 Python 做这类脚本因为不管后面接 Jenkins、GitLab CI 还是 GitHub Actions都只需要一条命令。先安装jsonschema库pip install jsonschema然后写一个validate_level_config.py假设schema目录下放着 JSON Schema 文件config/level目录下放着待验证的关卡配置import json import sys from pathlib import Path import jsonschema SCHEMA_DIR Path(schema) CONFIG_DIR Path(config/level) def validate_file(schema, config_path): with open(config_path, r, encodingutf-8) as f: data json.load(f) try: jsonschema.validate(instancedata, schemaschema) print(f[OK] {config_path.name}) return True except jsonschema.ValidationError as e: print(f[FAIL] {config_path.name}: {e.message}) return False def main(): schema_path SCHEMA_DIR / level_meta.schema.json with open(schema_path, r, encodingutf-8) as f: schema json.load(f) failures [] for config_path in CONFIG_DIR.glob(*_meta.json): if not validate_file(schema, config_path): failures.append(config_path) if failures: print(存在配置校验失败) for failure in failures: print(f - {failure}) sys.exit(1) print(全部 JSON 通过 schema 校验。) if __name__ __main__: main()脚本很简单但它直接把“字段必填”“类型正确”“枚举合法”变成了机器检查。任何一个新增关卡没更新 JSON或者策划改坏了字段类型都会在提交阶段被这个脚本拦下来连回归测试环境都不需要触发。3.3 把验证塞进 CI而不是等到临上线很多团队在出问题后才想加校验但校验一旦只存在于本地脚本就形同虚设。正确的做法是把校验命令接到 CI Pipeline 上。比如在 GitHub Actions 里加一个 Jobname: validate-json on: push: branches: [ main, develop ] pull_request: jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Set up Python uses: actions/setup-pythonv5 with: python-version: 3.12 - name: Install dependencies run: | pip install jsonschema - name: Validate level config run: | python scripts/validate_level_config.py把脚本接到 CI 上之后新的问题是谁来维护 Schema我建议由负责底层配置读取的程序员维护 Schema因为只有他们知道代码里到底需要哪些字段。策划不需要直接写 Schema他们只需要保证在自己的配置工具里导出 JSON 时能通过 Schema 校验这样“程序改动字段结构”带来的风险就能第一时间暴露在导入阶段。4. 回归要建立在“数据潮”上截图之外的字典检验Schema 能拦住“字段不全、类型错误、枚举越界”但它拦不住“代码里的枚举比 JSON 多”或者“JSON 里有关卡但代码里没有”。这种语义层面的不对应必须靠回归脚本来做双向对账。4.1 截图不变不代表配置没坏回到开头那个表述回归不是再截一张图。截图适合做“像素级对比”但配置类回归需要的是“字典级对比”。每次发版你要检查的不是编辑器里画面长什么样而是“代码里声明的关卡集合”和“JSON 里实际存在的关卡集合”是不是同一个集合。如果 JSON 里少了一个代码已声明的关卡相当于玩家入口缺失。如果 JSON 里多了一个代码不认识的关卡说明配置积压了废弃数据虽然不至于马上出 bug但会让后续维护人员产生混乱可能误以为某个关卡还在线上。所以我会在回归脚本里加入一个“双集合比对”收集代码中所有枚举值或常量节点构成code_levels集合。读取 JSON 里的所有levelId构成config_levels集合。检查code_levels - config_levels差集不应为空同时检查config_levels - code_levels多余的也应被明确标记。有人会问“代码里的枚举怎么收集”最简单的方式是给枚举定义一个独立文件比如LevelEnum.cs里面全是常量字符串。脚本可以直接解析这个文件用正则提取常量值。如果项目用的是 C#也可以先跑一次代码生成把枚举自动导出成一个 JSON 清单再给校验脚本读取。这样就不用手工维护两份列表避免第二次信息漂移。4.2 用代码对账代码枚举和 JSON manifest 的互查用 Python 写一个对账脚本的思路大致是这样import json import re from pathlib import Path ENUM_FILE Path(Assets/Scripts/LevelEnum.cs) CONFIG_FILE Path(config/level/level_meta.json) def extract_code_level_ids(enum_path): with open(enum_path, r, encodingutf-8) as f: content f.read() pattern rpublic\sconst\sstring\s\w\s*\s*\(Level_[^\])\ return set(re.findall(pattern, content)) def extract_config_level_ids(config_path): with open(config_path, r, encodingutf-8) as f: data json.load(f) if isinstance(data, dict): data data.get(levels, []) return {item.get(levelId) for item in data if item.get(levelId)} def main(): code_levels extract_code_level_ids(ENUM_FILE) config_levels extract_config_level_ids(CONFIG_FILE) missing_in_config code_levels - config_levels extra_in_config config_levels - code_levels if missing_in_config: print([差异] 代码里有JSON 里缺失) for level_id in sorted(missing_in_config): print(f - {level_id}) if extra_in_config: print([差异] JSON 里有代码里未声明) for level_id in sorted(extra_in_config): print(f - {level_id}) if missing_in_config or extra_in_config: raise SystemExit(1) print(关卡枚举与 JSON 完全对应。) if __name__ __main__: main()这个脚本不复杂但它比截图可靠得多。它能直接回答“关卡有没有进源码JSON 是否对得上”这个具体问题。只要有新关卡合入代码枚举一更新脚本就必须看到 JSON 里有对应的levelId。如果对不上回归测试跑到一半就能直接从 Pipeline 失败里看到原因。4.3 用例级的数据回归每个关卡都能创建、能加载对账脚本解决了“ID 有没有”但实战里还可能出现“ID 都在内容却废了”的情况。比如某个波次 JSON 引用了一个不存在的敌人 ID或者奖励表引用了未上线的物品 ID。这种更深一层的数据引用关系不是简单集合比对能覆盖的需要用用例级的数据回归去验证。所谓用例级数据回归就是“每个关卡都跑一遍最小加载用例”。对服务端逻辑可以写一段脚本遍历所有levelId调用配置加载接口断言每个关卡能正常初始化、能拿到敌人波次数据、能算出奖励内容。对客户端逻辑可以拆分成纯配置解析层不依赖渲染直接解析 JSON 并断言数据完整性。比如下面这个伪代码风格的示例def test_each_level_can_load(): level_meta load_json(config/level/level_meta.json) for level in level_meta[levels]: level_id level[levelId] wave_config load_json(fconfig/level/{level_id}/wave.json) assert len(wave_config[waves]) 0, f{level_id} 缺少波次配置 assert all(enemy[enemyId] in enemy_table for enemy in [...]) print(f{level_id} 加载通过)这类用例的回归价值在于它会把“JSON 里有关键字”扩展成“JSON 里的关键字能被业务系统正确消费”。截图看不到这些手点测试也未必覆盖每一种组合自动跑一遍是成本最低、收益最稳定的方案。5. 实操搭一个能落地的 JSON 数据回归流水线前面说了很多原理这一节我给出一套可以直接照搬的落地流程从目录结构到脚本再到 CI 配置尽量走最小可用路径。5.1 目录结构该怎么拆分我建议在仓库根目录下统一规划配置和校验脚本的位置。结构不能太乱否则脚本很快会变成没人敢碰的废代码。下面是我用着比较顺的一种方案repo/ config/ level/ level_meta.json level_reward.json level_wave.json schema/ level_meta.schema.json level_reward.schema.json level_wave.schema.json scripts/ validate_json.py validate_level_mapping.py run_data_regression.py tests/ data_regression/ test_level_load.pyconfig目录放策划导出的配置schema目录放程序维护的约束定义scripts目录放独立的验证脚本tests目录放数据回归用例。这样做的理由是职责清晰Schema 表达“数据应该长什么样”脚本表达“结构应该如何被验证”用例表达“每个关卡在业务上能否被正确加载”。三层各管一摊将来即使换人也容易接手。5.2 核心脚本怎么串起来一个完整的回归流水线至少包含三个动作顺序很关键语法解析先把所有 JSON 文件读入失败就立刻停不做后续校验。Schema 校验用每个文件对应的 Schema 验证字段和类型确保结构正确。数据对账在结构正确的前提下对比代码枚举、配置节点和业务引用关系。我把三个动作串在一个入口脚本里方便 CI 只需要调用一条命令# scripts/run_level_checks.py import sys from pathlib import Path from validate_level_mapping import main as validate_mapping from validate_json import main as validate_schema if __name__ __main__: print( Step 1: Schema 校验 ) if validate_schema() ! 0: sys.exit(1) print( Step 2: 枚举与 JSON 对账 ) if validate_mapping() ! 0: sys.exit(2) print( 关卡数据回归基础检查通过 )实际项目里可以把 pytest 作为第三步专门跑用例级数据回归。这里我给一个“能加载”的最小测试示例用 pytest 组织import json import pytest from pathlib import Path CONFIG_DIR Path(config/level) with open(CONFIG_DIR / level_meta.json, r, encodingutf-8) as f: level_meta json.load(f) ALL_LEVELS level_meta[levels] pytest.mark.parametrize(level, ALL_LEVELS, idslambda lv: lv[levelId]) def test_level_config_loaded(level): assert level[name], 关卡名称不能为空 assert 1 level[difficulty] 5, 难度必须介于 1 和 5 之间 wave_path CONFIG_DIR / f{level[levelId]} / wave.json if not wave_path.exists(): pytest.skip(f{level[levelId]} 没有独立波次文件) wave_data json.loads(wave_path.read_text(encodingutf-8)) assert wave_data[waves], 波次列表不能为空这段用例的价值在于它会逼着每个关卡都满足最小可运行定义。新增关卡而没写 wave 配置时要么测试直接失败要么显式跳过无论如何都能让团队知道“这个关卡还不完整”而不是任由它悄悄上线。5.3 与合并冲突和多人开发结合再提一个开发中很容易踩的点JSON 合并冲突。两个分支同时在一份level_meta.json里追加关卡Git 通常会生成冲突标记程序员手动解决时一旦删错一个花括号整个 JSON 就废了。很多团队遇到这种情况就是“看截图没问题合了再说”等到跑起来发现一批关卡没了。更稳的做法有几种第一尽可能把多份 JSON 拆细降低两个分支同时改同一文件的概率。关卡独立成文件冲突范围就小得多。第二在合并请求的 CI 上强制跑 JSON 校验。一旦格式坏了Pipeline 红得非常快不需要等到手工截图。第三如果团队经常因为列表型 JSON 冲突可以考虑改成按levelId作为 key 的 JSON 对象而不是数组。对象结构天然有利于 Git 的逐键合并冲突概率和对齐成本都低很多。像json merge conflict这类网络搜索出现频率很高说明这几乎是每个用 JSON 做配置的团队都绕不过去的痛点。我的建议是不要把希望寄托在 Git 智能合并上靠数据脚本兜底才是最稳的。6. 常见问题排查实录速查表把这类问题按照“症状 可能原因 修复手段”整理成一张表查起来效率最高也方便贴进团队文档。症状可能原因修复手段新关卡在游戏里找不到入口代码新增了 LevelEnum但level_meta.json没有对应levelId跑枚举与 JSON 对账脚本补齐配置节点关卡能进但敌人不刷新level_wave.json的波次结构或敌人 ID 引用了旧表用 Schema 校验波次字段检查敌人 ID 是否存在于敌人配置表难度/数值不对difficulty类型从字符串变成了数字或代码分支被绕过确保 Schema 中声明类型并在 CI 强制校验某些平台正常某些平台报配置空大小写敏感、路径分隔符、编码格式不一致统一 JSON 文件编码为 UTF-8检查加载路径与平台差异合并代码后 JSON 文件解析失败Git 冲突手动解决时出现多余逗号或括号在 CI 第一时间跑 JSON 语法解析必要时拆分配置文件截图回归通过功能依然异常回归只比较画面没有检查数据链路补数据回归用例验证配置能被代码正确加载旧版本玩家打开新关卡后存档异常新关卡的 ID 与旧版本默认关卡 ID 冲突检查 ID 命名规范避免使用猜不到的隐式默认值这张表里最后一条特别值得说。旧版本客户端可能没有新 JSON 里的某个字段但它不想读不到就会走一套“找不到就返回 level_id 1”的兜底逻辑结果把玩家数据写进了旧关卡。这种问题光靠当前分支的截图测试发现不了必须做版本兼容层面的数据回归。如果要彻底一些还可以同时跑“服务端最新配置 客户端最新代码”和“服务端最新配置 客户端上一版本代码”的组合把跨版本差异也暴露出来。7. 说到底回归要回答的是“数据链路通没通”这次踩坑之后我调整了一套自用检查单。每次涉及新关卡、新 JSON 字段、枚举值变更时不再只问“截图新不新”而是按顺序问四个问题JSON 能不能解析JSON 结构符不符合 Schema代码枚举和 JSON 关卡 ID 是否完全对应每个关卡的最小加载用例能不能通过这四个问题全部通过后我才会放心地把版本交给 QA 做手工验证。截图回归仍然有价值它的价值在于看 UI 和渲染层而不在于验证配置对不对。数据链路是否通畅一定得靠脚本和用例来回答而不是靠一双眼睛去盯截图。最后再分享一个小技巧如果你觉得每次维护 Schema 和脚本成本高就别把所有校验都堆在一开始。先用最核心的levelId对账脚本把“关卡进了源码但 JSON 没有”这个最大风险兜住等跑起来之后再把字段类型、数组长度、跨表引用逐步加进去。这样团队不会因为步骤繁琐而抵制校验反而能在一两次救火之后真切体会到回归确实不只是再截一张图。
返回列表