การจัดการ JSON สำหรับคอนฟิก fixture และข้อมูลภายในเครื่อง
สรุปสำคัญ: คอนฟิกต้องถูกแบ่งชั้นและตรวจสอบ; fixture ต้องแน่นอน อ่านง่าย และ diff ได้ หากไฟล์สองชนิดนี้ถูกจัดการไม่ดี ต้นทุนการวินิจฉัยปัญหาจะสูงไปอีกนาน

สรุปสั้น: สร้างคอนฟิกเป็นค่าเริ่มต้น สภาพแวดล้อม และการโอเวอร์ไรด์ภายในเครื่อง พร้อมการตรวจสอบตอนเริ่มระบบก่อนจะกังวลเรื่องรูปแบบไฟล์; ทำให้ fixture แน่นอนในเรื่องเวลา ID ลำดับคีย์ และการย่อหน้าเพื่อให้ยัง diff ได้ และค่อยทำระบบปกปิดอัตโนมัติ
JSON สองชนิดนี้ไม่ได้มีส่วนร่วมในตรรกะธุรกิจ แต่พวกมันกำหนดว่า "เพื่อนใหม่ต้องใช้เวลานานแค่ไหนจึงจะเริ่มทำงานได้" และ "ต้องใช้เวลานานแค่ไหนจึงจะหาปัญหาใน production เจอ"
1. คอนฟิก: โครงสร้างสามชั้น
| ชั้น | แหล่งที่มา | ดูแลโดย | commit หรือไม่ |
|---|---|---|---|
| ค่าเริ่มต้น | ค่าคงที่ในโค้ด / default.json |
นักพัฒนา | ใช่ |
| คอนฟิกสภาพแวดล้อม | config/production.json, environment variable |
ฝ่ายปฏิบัติการ | ใช่ (ไม่มีความลับ) |
| การโอเวอร์ไรด์ภายในเครื่อง | .env.local, config/local.json |
รายบุคคล | ไม่ (gitignore) |
ลำดับการ merge: ค่าเริ่มต้น → สภาพแวดล้อม → ภายในเครื่อง โดยแต่ละชั้นถัดไปโอเวอร์ไรด์ชั้นก่อนหน้า merge ลึกเท่าที่ ธุรกิจต้องการ เท่านั้น; อย่า deep-merge แบบไม่ลืมหูลืมตา — การ deep merge ทำให้ปัญหาเช่น "อาร์เรย์ถูกต่อกันโดยไม่คาดคิด" หาเจอยากมาก
/**
* รวมชั้นคอนฟิกตามลำดับความสำคัญ; ชั้นหลังโอเวอร์ไรด์ชั้นก่อน และอาร์เรย์ถูกแทนที่ทั้งหมดไม่ใช่ต่อกัน
* @param layers ชั้นคอนฟิกจากความสำคัญต่ำสุดไปสูงสุด
* @returns ออบเจ็กต์คอนฟิกที่ merge แล้ว
*/
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;
}, {});
}
2. ตรวจสอบตอนเริ่มระบบ อย่ารอถึงตอนรัน
ต้นทุนของคอนฟิกที่ผิดเพิ่มขึ้นเป็นเอ็กซ์โพเนนเชียลตามความล่าช้าในการค้นพบ:
| ค้นพบเมื่อใด | ต้นทุน |
|---|---|
| ตอนเริ่มระบบ | log หนึ่งบรรทัด รีสตาร์ทแล้วไปต่อ |
| ที่ request แรก | บางฟีเจอร์ error ใช้เวลา debug หนึ่งชั่วโมง |
| เมื่อ scheduled job ทำงาน | แจ้งเตือนกลางดึก |
| ในวันปิดบัญชี | เหตุการณ์กระทบยอด |
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 คอนฟิกดิบที่ merge แล้ว
* @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ดังนั้น environment variable ต้องถูกแปลงอย่างชัดเจนหลังอ่าน; - ความลับไม่เข้า repository เด็ดขาด; อ่านจาก environment variable หรือบริการจัดการความลับเท่านั้น
3. ใช้รูปแบบใดสำหรับไฟล์คอนฟิก
| ไฟล์ | รูปแบบ | เหตุผล |
|---|---|---|
package.json |
JSON มาตรฐาน | ตัวจัดการแพ็กเกจรับเฉพาะแบบนี้ |
tsconfig.json |
JSONC | อนุญาตคอมเมนต์ รองรับอย่างเป็นทางการ |
.vscode/settings.json |
JSONC | เหมือนข้างบน |
| คอนฟิกธุรกิจ | YAML หรือ JSONC | ต้องมีคอมเมนต์อธิบายความหมายแต่ละรายการ |
| รายการ environment variable | .env |
คู่คีย์-ค่าแบบแบน ฉีดได้ง่าย |
การทดสอบเชิงปฏิบัติ: ไฟล์นี้จะถูกอ่านโดยเครื่องมือที่ไม่ใช่มนุษย์หรือไม่? หากใช่ → JSON มาตรฐาน; หากมีเพียงโค้ดของคุณเองที่อ่าน → ใช้ภาษาถิ่นได้
4. Test fixture: ไดเรกทอรีและการตั้งชื่อ
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
ธรรมเนียมการตั้งชื่อที่ดีคือ object-scenario: order-missing-field มีประโยชน์กว่า test1.json มาก
ชื่อไฟล์ที่ไม่ใช่ ASCII บางครั้งทำให้เกิดปัญหาการเข้ารหัสใน toolchain ข้ามแพลตฟอร์ม ดังนั้นให้ชื่อไฟล์เป็นภาษาอังกฤษขณะที่เนื้อหายังเป็นภาษาจีนได้
4.1 กรณีสามชนิด ความคาดหวังสามแบบ
| ไดเรกทอรี | ความคาดหวัง | วัตถุประสงค์ |
|---|---|---|
valid/ |
การตรวจสอบผ่าน | เส้นทางหลัก การสร้างชนิด |
invalid/ |
การตรวจสอบล้มเหลว และ error ชี้ไปที่ฟิลด์ที่คาด | การจัดการข้อผิดพลาด ข้อความแจ้ง |
edge/ |
พฤติกรรมชัดเจน (ผ่านหรือล้มเหลวก็ได้ แต่ต้อง assert) | การป้องกันการถดถอย |
JSONTestSuite ใช้คำนำหน้า y_ / n_ / i_ สำหรับสามชนิดเดียวกัน (ควรรับ / ควรปฏิเสธ / ขึ้นกับการใช้งาน)
และคุณสามารถยืมธรรมเนียมการตั้งชื่อนั้นไปใช้ได้โดยตรง
5. ความแน่นอน: ทำให้ fixture diff ได้
Fixture ที่ไม่มั่นคงทำให้ code review ไร้ประโยชน์:
| แหล่งความรบกวน | วิธีรับมือ |
|---|---|
| เวลาปัจจุบัน | ตรึงเป็นค่าคงที่ เช่น 2026-01-01T00:00:00Z |
| ID สุ่ม / UUID | ใช้ลำดับเพิ่มขึ้นหรือค่าคงที่ |
| ลำดับคีย์ | เรียงตามตัวอักษรตอนสร้าง |
| การจัดรูปแบบทศนิยม | เก็บสองตำแหน่งอย่างสม่ำเสมอหรือแปลงเป็นสตริง |
| เขตเวลา | ใช้ UTC ทุกที่ |
| การย่อหน้า | ตรึงที่ 2 ช่องว่าง ตรวจด้วย Prettier ใน CI |
/**
* เรียงคีย์แบบเรียกซ้ำเพื่อสร้างข้อความ JSON ที่มั่นคงและ diff ได้
* @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);
}
6. การปกปิดและข้อกำหนดด้านการปฏิบัติตาม
ข้อมูลจริงไม่สามารถใช้เป็น fixture โดยตรงได้:
- ชื่อ → ตัวแทนเช่น "John Doe" และ "Jane Roe";
- เบอร์โทร → หมายเลขที่สมมติชัดเจนเช่น
13800138000; - เลขบัตรประชาชน → หมายเลขปลอมที่มีรูปแบบถูกต้องแต่หลักตรวจสอบไม่ถูก;
- ที่อยู่ → คงโครงสร้างจังหวัด/อำเภอ ตัดบ้านเลขที่;
- จำนวนเงิน → คงลำดับขนาด แต่ไม่เก็บข้อมูลบัญชีจริงเด็ดขาด
ดีที่สุดคือทีมมี scripts/mask-fixture.mjs ที่รันบนตัวอย่าง production ที่ส่งออกมาเพื่อสร้างฉบับที่ปกปิดแล้ว
หลีกเลี่ยง "แก้มนุษย์สามที่แล้วลืมที่เหลือ"
7. การย้ายข้อมูลและเวอร์ชัน
Fixture ก็เก่าได้เช่นกัน เพิ่มฟิลด์ schemaVersion ใน fixture หรือวางไฟล์ VERSION ในไดเรกทอรี:
{
"schemaVersion": 3,
"id": "1001",
"amount": "89.50"
}
เมื่อ API contract ถูกอัปเกรด:
- เพิ่มไดเรกทอรี fixture ใหม่ (
v3/); อย่าแก้ fixture เก่าในที่; - เก็บ fixture เก่าไว้สำหรับการทดสอบถดถอยจนกว่า API เวอร์ชันเก่าจะเลิกใช้;
- รันทั้งสองเวอร์ชันใน CI เพื่อยืนยันความเข้ากันได้ย้อนหลัง
8. รายการตรวจสอบ
- [ ] โครงสร้างคอนฟิกสามชั้นชัดเจน และการโอเวอร์ไรด์ภายในเครื่องไม่ถูก commit;
- [ ] การตรวจสอบเกิดขึ้นตอนเริ่มระบบ และล้มเหลวแล้วออกทันที;
- [ ] ความลับมาจาก environment variable เท่านั้น ไฟล์คอนฟิกมีแต่ตัวแทน;
- [ ] Fixture แยกเป็นไดเรกทอรี
valid/invalid/edgeและชื่อสื่อถึงสถานการณ์; - [ ] Fixture แน่นอน (เวลาคงที่ ID คงที่ ลำดับคงที่) และ diff ได้;
- [ ] ข้อมูลจริงผ่านสคริปต์ปกปิดแล้ว;
- [ ] การอัปเกรด contract เพิ่มไดเรกทอรีเวอร์ชันใหม่แทนการแก้ในที่
FAQ
ลำดับชั้นคอนฟิกและวิธี merge ควรเป็นอย่างไร?
ใช้สามชั้น — ค่าเริ่มต้น คอนฟิกสภาพแวดล้อม และการโอเวอร์ไรด์ภายในเครื่อง — merge ค่าเริ่มต้น → สภาพแวดล้อม → ภายในเครื่อง โดยแต่ละชั้นถัดไปโอเวอร์ไรด์ชั้นก่อนหน้า merge ลึกเท่าที่ธุรกิจต้องการและแทนที่อาร์เรย์กับค่าสเกลาทั้งหมด เพื่อไม่ให้ deep merge ต่ออาร์เรย์โดยไม่คาดคิด ให้ gitignore ไฟล์โอเวอร์ไรด์ภายในเครื่องแทนการ commit การตั้งค่าส่วนบุคคล
ทำไมต้องตรวจสอบคอนฟิกตอนเริ่มระบบ?
ต้นทุนของคอนฟิกที่ผิดเพิ่มขึ้นเป็นเอ็กซ์โพเนนเชียลตามความล่าช้าในการค้นพบ: log หนึ่งบรรทัดตอนเริ่มระบบเทียบกับเหตุการณ์กระทบยอดในวันปิดบัญชี ตรวจสอบกับ JSON Schema ตอนเริ่มระบบและออกทันทีเมื่อรายการที่จำเป็นขาดหายหรือชนิดผิด แทนที่จะให้ค่าเริ่มต้นที่ "ดูสมเหตุสมผล"
ควรใช้ข้อมูล production เป็น test fixture โดยตรงหรือไม่?
ไม่ ชื่อ เบอร์โทร เลขบัตรประชาชน ที่อยู่ และจำนวนเงินล้วนต้องใช้ตัวแทนหรือค่าที่สมมติชัดเจน และทีมควรมี scripts/mask-fixture.mjs ที่รันบนข้อมูล production ที่ส่งออกมาเพื่อสร้างฉบับปกปิด หลีกเลี่ยงการแก้ด้วยมือที่พลาดบางจุด