JSON 工具箱

配置、夹具与本地数据的 JSON 管理

结论:配置要能分层与校验,夹具要确定、可读、可 diff。这两类文件管不好,排查成本会长期居高不下。

入门约 10 分钟发布于 2026-10-30更新于 2026-10-30
配置、夹具与本地数据的 JSON 管理封面

结论:先把配置做成默认值、环境、本地三层并启动即校验,再谈格式选择;夹具则先固定时间、ID、键序与缩进,保证可 diff,最后才考虑用脚本批量脱敏。

这两类 JSON 不参与业务逻辑,却决定了「新同事多久能跑起来」和「线上问题多久能定位」。

一、配置:三层结构

层 来源 谁维护 是否入库
默认值 代码里的常量 / default.json 开发者 是
环境配置 config/production.json、环境变量 运维 是(不含密钥)
本地覆盖 .env.local、config/local.json 个人 否(gitignore)

合并顺序:默认 → 环境 → 本地,后者覆盖前者。合并只做浅合并到业务需要的层级, 不要做无脑深合并——深合并会让「数组被意外拼接」这种问题极难发现。

/**
 * 按优先级合并配置层,后者覆盖前者,数组整体替换而非拼接。
 * @param layers 从低到高的配置层
 * @returns 合并后的配置对象
 */
function mergeConfigLayers(layers: Array<Record<string, unknown>>): Record<string, unknown> {
  return layers.reduce<Record<string, unknown>>((mergedConfig, currentLayer) => {
    for (const [configKey, configValue] of Object.entries(currentLayer)) {
      const isPlainObject =
        typeof configValue === 'object' && configValue !== null && !Array.isArray(configValue);
      const existingValue = mergedConfig[configKey];

      if (isPlainObject && typeof existingValue === 'object' && existingValue !== null) {
        mergedConfig[configKey] = mergeConfigLayers([
          existingValue as Record<string, unknown>,
          configValue as Record<string, unknown>,
        ]);
        continue;
      }

      // 数组与标量一律整体替换,避免意外的元素拼接
      mergedConfig[configKey] = configValue;
    }
    return mergedConfig;
  }, {});
}

二、启动即校验,不要运行时才发现

配置错误的代价随发现时间指数上升:

发现时机 代价
启动时 一行日志,重启即可
首次请求时 部分功能报错,排查一小时
定时任务触发时 半夜告警
结算当天 对账事故
import Ajv2020 from 'ajv/dist/2020';
import configSchema from './config.schema.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

/**
 * 校验配置并在失败时立刻抛错,实现 fail fast。
 * @param rawConfig 合并后的原始配置
 * @returns 通过校验的配置
 */
export function assertConfig(rawConfig: unknown): Record<string, unknown> {
  const validate = ajv.compile(configSchema);
  if (!validate(rawConfig)) {
    const messages = (validate.errors ?? []).map(
      (errorItem) => `${errorItem.instancePath || '/'} ${errorItem.message ?? ''}`,
    );
    throw new Error(`配置校验失败:\n${messages.join('\n')}`);
  }
  return rawConfig as Record<string, unknown>;
}

配套的原则:

  • 必填项缺失直接退出,不要给「看起来合理」的默认值(比如把数据库地址默认成 localhost);
  • 类型错误直接退出,字符串 "false" 不等于布尔 false,环境变量读取后要显式转换;
  • 密钥不落库,只从环境变量或密钥管理服务读取。

三、配置文件用什么格式

文件 格式 原因
package.json 标准 JSON 包管理器只认它
tsconfig.json JSONC 允许注释,官方支持
.vscode/settings.json JSONC 同上
业务配置 YAML 或 JSONC 需要注释解释每项含义
环境变量清单 .env 扁平键值,便于注入

一个实用判断:这份文件会不会被非人类工具读取? 会 → 标准 JSON;只有自家代码读 → 可以用方言。

四、测试夹具:目录与命名

tests/fixtures/
├─ valid/
│  ├─ order-minimal.json
│  ├─ order-full.json
│  └─ order-with-null-fields.json
├─ invalid/
│  ├─ order-missing-id.json
│  ├─ order-price-as-number.json
│  └─ order-nested-broken.json
└─ edge/
   ├─ big-integer.json
   ├─ emoji-and-surrogate.json
   └─ deep-nesting-64.json

命名规则建议「对象-场景」:订单-缺字段 比 test1.json 有用得多。 中文命名在跨平台工具链里偶有编码问题,文件名用英文,内容可以用中文。

4.1 三类用例对应三种期望

目录 期望 用途
valid/ 校验通过 主流程、类型生成
invalid/ 校验失败,且错误定位到预期字段 错误处理、提示文案
edge/ 行为明确(通过或失败都可以,但要断言) 回归防护

JSONTestSuite 用 y_ / n_ / i_ 前缀表达同样的三类(应当接受 / 应当拒绝 / 实现自定义), 可以直接借鉴这套命名。

五、确定性:让夹具可 diff

不稳定的夹具会让 code review 失效:

噪声来源 处理方式
当前时间 固定为常量,如 2026-01-01T00:00:00Z
随机 id / uuid 用递增序号或固定值
键顺序 生成时按字母序排序
浮点格式 统一保留两位小数或转字符串
时区 全部用 UTC
缩进 固定 2 空格,CI 用 Prettier 检查
/**
 * 递归按键名排序,生成稳定可 diff 的 JSON 文本。
 * @param value 待序列化的值
 * @param indentWidth 缩进宽度
 * @returns 稳定顺序的 JSON 字符串
 */
function toStableJson(value: unknown, indentWidth = 2): string {
  /**
   * 递归排序对象键。
   * @param input 任意 JSON 值
   * @returns 键已排序的新值
   */
  const sortKeysDeep = (input: unknown): unknown => {
    if (Array.isArray(input)) {
      return input.map((item) => sortKeysDeep(item));
    }
    if (typeof input === 'object' && input !== null) {
      return Object.fromEntries(
        Object.keys(input)
          .sort()
          .map((entryKey) => [entryKey, sortKeysDeep((input as Record<string, unknown>)[entryKey])]),
      );
    }
    return input;
  };

  return JSON.stringify(sortKeysDeep(value), null, indentWidth);
}

六、脱敏与合规

真实数据不能直接当夹具:

  • 姓名 → 「张三」「李四」这类占位;
  • 手机号 → 13800138000 这类明显虚构的号码;
  • 身份证号 → 用格式正确但校验位无效的假号;
  • 地址 → 保留省市结构,去掉门牌;
  • 金额 → 可以保留数量级,但不要保留真实账户信息。

团队里最好有一个 scripts/mask-fixture.mjs,把生产导出的一份样例跑一遍生成脱敏版本, 避免「先手动改三处、剩下忘了」。

七、迁移与版本

夹具也会过期。给夹具加一个 schemaVersion 字段,或者在目录里放 VERSION 文件:

{
  "schemaVersion": 3,
  "id": "1001",
  "amount": "89.50"
}

当接口契约升级时:

  1. 新增一版夹具目录(v3/),不要原地改旧夹具;
  2. 保留旧夹具用于回归,直到旧版本接口下线;
  3. CI 里同时跑新旧两版,确认向后兼容。

八、检查清单

  • [ ] 配置三层结构清晰,本地覆盖不入库;
  • [ ] 启动即校验,失败直接退出;
  • [ ] 密钥只走环境变量,配置文件里只有占位;
  • [ ] 夹具按 valid/invalid/edge 分目录,命名表达场景;
  • [ ] 夹具确定(时间、id、顺序固定),可 diff;
  • [ ] 真实数据经脱敏脚本处理;
  • [ ] 契约升级时新增版本目录,不原地改。

常见问题

如何管理配置文件的层级与合并顺序?

分默认值、环境配置、本地覆盖三层,合并顺序是默认 → 环境 → 本地,后者覆盖前者。只做业务需要的浅合并,数组与标量整体替换,避免深合并把数组意外拼接起来。本地覆盖文件加进 .gitignore,不要把个人配置提交进仓库。

为什么配置文件要在启动时校验?

配置错误的代价随发现时间指数上升:启动时只是一行日志,结算当天就可能变成对账事故。用 JSON Schema 在启动时校验,必填项缺失或类型错误直接退出,不要给「看起来合理」的默认值。

是否可以直接用生产数据当测试夹具?

不能。姓名、手机号、身份证号、地址、金额都要换成占位或明显虚构的值,团队最好有一个 scripts/mask-fixture.mjs,把生产导出跑一遍生成脱敏版本,避免手改遗漏。