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

结论:先把配置做成默认值、环境、本地三层并启动即校验,再谈格式选择;夹具则先固定时间、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"
}
当接口契约升级时:
- 新增一版夹具目录(
v3/),不要原地改旧夹具; - 保留旧夹具用于回归,直到旧版本接口下线;
- CI 里同时跑新旧两版,确认向后兼容。
八、检查清单
- [ ] 配置三层结构清晰,本地覆盖不入库;
- [ ] 启动即校验,失败直接退出;
- [ ] 密钥只走环境变量,配置文件里只有占位;
- [ ] 夹具按
valid/invalid/edge分目录,命名表达场景; - [ ] 夹具确定(时间、id、顺序固定),可 diff;
- [ ] 真实数据经脱敏脚本处理;
- [ ] 契约升级时新增版本目录,不原地改。
常见问题
如何管理配置文件的层级与合并顺序?
分默认值、环境配置、本地覆盖三层,合并顺序是默认 → 环境 → 本地,后者覆盖前者。只做业务需要的浅合并,数组与标量整体替换,避免深合并把数组意外拼接起来。本地覆盖文件加进 .gitignore,不要把个人配置提交进仓库。
为什么配置文件要在启动时校验?
配置错误的代价随发现时间指数上升:启动时只是一行日志,结算当天就可能变成对账事故。用 JSON Schema 在启动时校验,必填项缺失或类型错误直接退出,不要给「看起来合理」的默认值。
是否可以直接用生产数据当测试夹具?
不能。姓名、手机号、身份证号、地址、金额都要换成占位或明显虚构的值,团队最好有一个 scripts/mask-fixture.mjs,把生产导出跑一遍生成脱敏版本,避免手改遗漏。