
Python json: ไฟล์ config มี key ซ้ำ ระบบไม่เตือนสักคำ จับให้ได้ด้วย object_pairs_hook
ลองนึกภาพไฟล์ config.json ยาว 200 บรรทัด มีคนเพิ่ม "max_upload_mb": 5 ไว้ข้างบน แล้วอีกคนเพิ่ม "max_upload_mb": 500 ไว้ข้างล่าง โปรแกรมโหลดได้ปกติ ไม่มี error ไม่มี warning แต่ค่าที่ใช้จริงคือ 500 ครับ
นี่ไม่ใช่บั๊ก แต่เป็นพฤติกรรมที่ เอกสาร Python เขียนไว้ชัด ว่าโมดูล json ไม่โยน exception เมื่อเจอ key ซ้ำ และจะเก็บเฉพาะคู่สุดท้าย วันนี้ผมจะทำให้มัน “ร้องเตือน” ตั้งแต่ตอนโหลด
WHY: ทำไม key ซ้ำถึงอันตราย
- มาตรฐาน RFC 8259 บอกว่าชื่อใน object “ควร” ไม่ซ้ำ (SHOULD) แต่ไม่ได้บังคับว่าโปรแกรมต้องทำยังไงเมื่อเจอซ้ำ
- RFC ยังเตือนว่าเมื่อ key ซ้ำ พฤติกรรมของโปรแกรมฝั่งรับ “คาดเดาไม่ได้” หลายไลบรารีเอาค่าสุดท้าย บางตัว error บางตัวรายงานทุกคู่
- ผลคือ ถ้าระบบสองตัวอ่าน JSON ก้อนเดียวกัน อาจเห็นค่าคนละค่า เช่น ตัวตรวจสิทธิ์เห็น
"role": "user"แต่ตัวที่ทำงานจริงเห็น"role": "admin" - สำหรับไฟล์ config คนแก้ไฟล์มักไม่รู้ตัวว่ามี key เดิมอยู่แล้ว ปัญหาจึงเงียบจนกว่าจะเกิดเรื่อง
HOW: object_pairs_hook ทำงานยังไง
ปกติ json.loads แปลงแต่ละ object เป็น dict ทันที ตอนนั้น key ซ้ำก็ถูกทับไปแล้ว
object_pairs_hook เปลี่ยนจุดนี้ได้ ถ้าเราส่งฟังก์ชันเข้าไป Python จะส่ง “ลิสต์ของคู่ (key, value) ตามลำดับในไฟล์” มาให้ฟังก์ชันเรา ก่อนรวมเป็น dict เราจึงเห็นทุกคู่ รวมถึงคู่ที่ซ้ำด้วย
สิ่งที่ฟังก์ชันคืนออกไป จะกลายเป็นค่าของ object นั้น และมันถูกเรียกกับ object ทุกชั้น รวมถึง object ที่ซ้อนอยู่ข้างใน
จุดที่ต้องรู้ก่อนใช้
- ถ้าส่งทั้ง
object_hookและobject_pairs_hookตัวobject_pairs_hookจะได้สิทธิ์ก่อน - key ซ้ำ “คนละชั้น” ไม่ถือว่าซ้ำ เช่น
{"a": {"id": 1}, "b": {"id": 2}}ปกติดี - error ควรบอกชื่อ key ที่ซ้ำ คนแก้ไฟล์จะหาจุดผิดได้เร็ว
- ใช้ได้กับ
json.load(อ่านจากไฟล์) แบบเดียวกัน
โค้ดพร้อม unittest
import json
import unittest
class DuplicateKeyError(ValueError):
pass
def reject_duplicates(pairs):
"""object_pairs_hook: โยน error เมื่อเจอ key ซ้ำในชั้นเดียวกัน"""
result = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"duplicate key: {key!r}")
result[key] = value
return result
def load_config(text):
return json.loads(text, object_pairs_hook=reject_duplicates)
class DuplicateKeyTest(unittest.TestCase):
def test_default_keeps_last_value_silently(self):
data = json.loads('{"max_upload_mb": 5, "max_upload_mb": 500}')
self.assertEqual(data, {"max_upload_mb": 500})
def test_hook_rejects_duplicates(self):
with self.assertRaises(DuplicateKeyError) as ctx:
load_config('{"max_upload_mb": 5, "max_upload_mb": 500}')
self.assertIn("max_upload_mb", str(ctx.exception))
def test_nested_objects_are_checked_too(self):
with self.assertRaises(DuplicateKeyError):
load_config('{"db": {"host": "a", "host": "b"}}')
def test_same_key_in_different_objects_is_fine(self):
data = load_config('{"a": {"id": 1}, "b": {"id": 2}}')
self.assertEqual(data["b"]["id"], 2)
def test_error_is_a_value_error(self):
# โค้ดเดิมที่จับ ValueError อยู่แล้ว ยังจับได้
with self.assertRaises(ValueError):
load_config('{"role": "user", "role": "admin"}')
if __name__ == "__main__":
unittest.main()
ตอนตรวจบทความ ผมรันโค้ดนี้ด้วย python3 -I -B บน Python 3.14 ของเครื่องนี้ เทสต์ผ่านทุกข้อ
ผมให้ DuplicateKeyError สืบทอดจาก ValueError เพราะ json.JSONDecodeError ก็เป็นลูกของ ValueError เหมือนกัน โค้ดที่เขียน except ValueError ไว้จับไฟล์เสียอยู่แล้ว จะจับกรณี key ซ้ำได้ด้วยโดยไม่ต้องแก้
ใช้ตรงไหนในงานจริง
| งาน | ทำไมควรเปิดการตรวจ |
|---|---|
| โหลด config ตอนเริ่มโปรแกรม | หยุดตั้งแต่ต้น ดีกว่ารันด้วยค่าที่ไม่ได้ตั้งใจ |
| รับ JSON จากภายนอกที่มีผลกับสิทธิ์ | กันระบบสองตัวเห็นค่าไม่ตรงกัน |
| CI ตรวจไฟล์ JSON ใน repo | จับ key ซ้ำก่อน merge |
| ไฟล์แปลภาษา (i18n) | key ข้อความซ้ำ ทำให้ข้อความหายแบบเงียบๆ |
Recap
- จำไว้ว่า
json.loadsเก็บค่าตัวสุดท้ายเมื่อ key ซ้ำ โดยไม่เตือน - ใส่
object_pairs_hook=reject_duplicatesตอนโหลด config - ให้ error บอกชื่อ key ที่ซ้ำ
- ให้ error สืบทอดจาก
ValueErrorโค้ดเดิมจะได้จับต่อได้ - เพิ่มการตรวจนี้ใน CI สำหรับไฟล์ JSON ใน repo
- ก๊อปเทสต์ด้านบนไปรันบน Python ของคุณ ดูว่าผลตรงกันไหม