งูสีมิ้นต์ต้นฉบับกำลังเลื้อยเชื่อมแผ่น config โปร่งสามชั้นบนโต๊ะสีครีม

Python ChainMap: ซ้อน config หลายชั้นโดยไม่รีบ copy

พี่โชว์Engineering

ระบบ config มักมีลำดับ CLI ชนะ environment และ environment ชนะค่า default ครับ วิธีตรงไปตรงมาคือ copy dict แล้ว update ทีละชั้น แต่หลัง merge เรามองไม่เห็นว่าค่ามาจากไหน และการเปลี่ยน mapping ต้นทางก็ไม่สะท้อนอีก

WHY: ต้องการ view มากกว่า dict ก้อนใหม่

collections.ChainMap ทำหลาย mapping ให้ดูเหมือน mapping เดียว การค้นเดินจากตัวแรกไปตัวท้าย จึงวาง precedence อ่านจากซ้ายไปขวาได้ทันที ต่างจากการ merge ที่สร้าง snapshot ใหม่ ChainMap อ้าง mapping เดิม ทำให้การเปลี่ยนต้นทางปรากฏใน view

ข้อสำคัญคือการเขียน แก้ และลบจะเกิดกับ mapping แรกเท่านั้น เปรียบเหมือนแผ่นใสหลายชั้น เรามองทะลุลงไปหา key ได้ แต่ปากกาเขียนบนแผ่นบนสุด

HOW: กรองค่าว่างก่อนวางชั้นบน

import unittest
from collections import ChainMap


def build_config(cli, env, defaults):
    explicit_cli = {k: v for k, v in cli.items() if v is not None}
    return ChainMap(explicit_cli, env, defaults)


class ConfigTests(unittest.TestCase):
    def test_precedence(self):
        config = build_config(
            {"region": "ap-southeast-1"},
            {"region": "us-east-1", "debug": "1"},
            {"region": "local", "debug": "0"},
        )
        self.assertEqual(config["region"], "ap-southeast-1")
        self.assertEqual(config["debug"], "1")

    def test_write_targets_first_map(self):
        cli, env = {}, {"debug": "1"}
        config = build_config(cli, env, {"debug": "0"})
        config["debug"] = "0"
        self.assertEqual(config.maps[0]["debug"], "0")
        self.assertEqual(env["debug"], "1")

    def test_source_change_is_visible(self):
        env = {"timeout": "5"}
        config = build_config({}, env, {"timeout": "3"})
        env["timeout"] = "8"
        self.assertEqual(config["timeout"], "8")


if __name__ == "__main__":
    unittest.main()

การกรอง None สำคัญ เพราะ argparse มักสร้าง key ที่ผู้ใช้ไม่ได้ระบุ หากวาง None บนชั้นแรก มันจะบัง environment แทนที่จะปล่อยให้ fallback ทำงาน

เมื่อควร flatten

ถ้าต้อง serialize config ส่งข้าม process หรือเก็บ audit snapshot ให้ใช้ dict(config) ณ boundary เพื่อหยุดการเปลี่ยนตามต้นทาง ถ้าต้อง validate schema ให้แปลงเป็น typed object หลัง resolve precedence ไม่ควรปล่อย ChainMap เดินทั่ว domain เพราะแต่ละส่วนอาจแก้ชั้นแรกโดยไม่รู้ตัว

สำหรับ concurrent mutation ต้องมี synchronization ของ mapping ต้นทางเอง ChainMap ไม่ได้เพิ่ม thread safety และการใช้ os.environ โดยตรงทำให้ test แยกยาก ควรส่ง dict ที่คัดเฉพาะ key ที่ต้องใช้

Recap

  • เรียง mapping จาก precedence สูงไปต่ำ
  • กรองค่า None ที่ไม่ใช่การ override จริง
  • จำไว้ว่าการเขียนลง mapping แรก แต่ lookup มองทุกชั้น
  • flatten ที่ boundary เมื่อต้องการ snapshot หรือ serialization
  • ส่ง mapping เข้าฟังก์ชันเพื่อให้ทดสอบง่าย

ลองแทน update chain หนึ่งจุดด้วย ChainMap แล้วเขียน test precedence ก่อนเปลี่ยน production path ครับ