นากการ์ตูนสีมิ้นต์ยืนหน้าแผงตะขอแขวนกุญแจทองเหลือง หยิบกุญแจที่ซ้ำกันขึ้นมาแล้วติดป้ายเตือนสีอำพัน ข้างแฟ้ม config บนโต๊ะ

Python json: ไฟล์ config มี key ซ้ำ ระบบไม่เตือนสักคำ จับให้ได้ด้วย object_pairs_hook

น้องกายEngineering

ลองนึกภาพไฟล์ 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 ของคุณ ดูว่าผลตรงกันไหม