ภาพวาดกระรอกดินสีเขียวอมฟ้ากำลังย้ายกล่องข้อมูลข้ามสะพานสามช่วงจากประตูเก่าไปสู่ด่านตรวจใหม่

Workshop: ย้าย API จาก encoding/json v1 ไป v2 โดยไม่เปลี่ยนสัญญาแบบไม่รู้ตัว

พี่โชว์Engineering

การย้าย JSON parser เหมือนเปลี่ยนพนักงานตรวจบัตรหน้าตึก คนใหม่อาจทำงานเร็วและละเอียดกว่า แต่ถ้าเราไม่เคยเขียนกติกาว่า “บัตรแบบไหนผ่านได้” วันแรกของการเปลี่ยนเวรอาจมีทั้งคนที่ควรเข้าแต่เข้าไม่ได้ และคนที่ไม่ควรเข้าแต่เคยชินกับการผ่าน

Workshop นี้ไม่เริ่มด้วยการเปลี่ยน import ครับ เราจะเริ่มด้วย test ที่บันทึกพฤติกรรมเดิมหรือ characterization test จากนั้นใช้โหมดเข้ากันได้ของ JSON v2 เป็นสะพาน แล้วเปิดกติกาเข้มขึ้นทีละข้อ

ต้องใช้ Go 1.27 ขึ้นไป เพราะ encoding/json/v2 เป็นของใหม่ในรุ่นนี้ อ่านภาพรวมได้จาก release notes และรายละเอียดพฤติกรรมจาก migration guide

เป้าหมายของห้องทดลอง

เมื่อจบแล้ว test ต้องพิสูจน์สามเรื่อง:

  1. โหมด v1 เดิมจับชื่อ DISPLAYNAME เข้ากับ field displayName แบบไม่สนตัวพิมพ์ใหญ่–เล็ก
  2. JSON v2 แบบ strict ไม่เดาชื่อนั้น จึงไม่เติมค่าลง struct
  3. เมื่อเปิด RejectUnknownMembers ข้อมูลที่สะกดไม่ตรง contract จะถูกปฏิเสธชัดเจน

นี่เป็น workshop ขนาดเล็กแต่เป็น tracer bullet หรือกระสุนส่องวิถี: เราเห็นเส้นทาง migration ตั้งแต่ compatibility ไปถึง strictness ก่อนนำรูปแบบเดียวกันไปใช้กับ endpoint ใหญ่

Step 1: สร้าง module ชั่วคราว

เปิดโฟลเดอร์ว่าง แล้วสร้าง go.mod ด้วย go mod init example.com/json-migration จากนั้นวางไฟล์ test ด้านล่าง โค้ดนี้ตั้งใจรวมทั้งสามสถานีไว้ด้วยกันเพื่อให้คัดลอกไปรันได้ครบ

package migration

import (
	jsonv1 "encoding/json"
	jsonv2 "encoding/json/v2"
	"testing"
)

type Profile struct {
	DisplayName string `json:"displayName"`
}

var legacyPayload = []byte(`{"DISPLAYNAME":"Nok"}`)

func TestV2WithV1OptionsPreservesLegacyMatching(t *testing.T) {
	var profile Profile
	err := jsonv2.Unmarshal(legacyPayload, &profile, jsonv1.DefaultOptionsV1())
	if err != nil {
		t.Fatalf("compatibility decode failed: %v", err)
	}
	if profile.DisplayName != "Nok" {
		t.Fatalf("display name = %q, want Nok", profile.DisplayName)
	}
}

func TestV2DefaultDoesNotGuessFieldName(t *testing.T) {
	var profile Profile
	err := jsonv2.Unmarshal(legacyPayload, &profile)
	if err != nil {
		t.Fatalf("default decode failed: %v", err)
	}
	if profile.DisplayName != "" {
		t.Fatalf("unexpected fuzzy field match: %q", profile.DisplayName)
	}
}

func TestStrictContractRejectsMisspelledMember(t *testing.T) {
	var profile Profile
	err := jsonv2.Unmarshal(
		legacyPayload,
		&profile,
		jsonv2.RejectUnknownMembers(true),
	)
	if err == nil {
		t.Fatal("strict contract must reject DISPLAYNAME")
	}
}

รัน go test ./... แล้วควรเห็นทั้งสาม test ผ่าน จุดสำคัญคือเราไม่ได้ถามแค่ว่า code compile ไหม แต่ถามว่ากติกาแต่ละแบบให้ผลตรงที่คาดหรือไม่

Step 2: เลือก policy ไม่ใช่เลือกแค่ package

จากผลทดลอง เรามีสามทางเลือกที่เหมาะกับบริบทต่างกัน:

  • Compatibility: ใช้ jsonv1.DefaultOptionsV1() เพื่อให้ระบบทำงานเหมือนเดิมระหว่างย้าย engine
  • V2 default: รับ field ที่รู้จักแบบตรงชื่อ และข้าม field อื่น เหมาะกับ API ที่ต้อง forward-compatible
  • Closed contract: เพิ่ม RejectUnknownMembers(true) เหมาะกับคำสั่งสำคัญที่การสะกดผิดไม่ควรถูกกลืนเงียบ

อย่าเลือก strict เพราะฟังดูปลอดภัยที่สุดโดยไม่ดูผู้ใช้ API ถ้า mobile client รุ่นเก่าส่ง metadata เพิ่ม การปฏิเสธทันทีอาจกลายเป็น outage ในทางกลับกัน endpoint เปลี่ยนสิทธิ์ผู้ใช้ควรแจ้งการสะกด isAdmin ผิด ดีกว่าทำงานต่อเหมือนไม่มีอะไรเกิดขึ้น

Step 3: นำไปใช้กับ endpoint จริงแบบวงแคบ

เลือก endpoint หนึ่งตัวที่มี traffic และ test มองเห็นได้ แล้วทำตามลำดับนี้:

  1. เก็บ fixture ของ request ที่สำเร็จและล้มเหลวจาก contract ที่อนุญาต ไม่คัดลอกข้อมูลส่วนบุคคลจาก production
  2. เพิ่ม test ด้วย decoder เดิมให้ผ่านก่อน นี่คือเส้นฐาน
  3. เปลี่ยนเป็น jsonv2.Unmarshal พร้อม DefaultOptionsV1() แล้วตรวจว่า test และ error mapping ยังเดิม
  4. เปิด behavior แบบ v2 ทีละข้อ พร้อม test ที่ตั้งใจให้ผลเปลี่ยน
  5. deploy ผ่านกระบวนการปกติและดู metric ของ decode error แยกตามสาเหตุ

เอกสาร package มี option อื่น เช่นการจับชื่อแบบไม่สนตัวพิมพ์ใหญ่–เล็ก แต่ควรกำหนดใกล้ขอบเขตที่ต้องใช้ อย่าสร้างถุง option กลางหนึ่งใบแล้วส่งไปทุก endpoint เพราะเราจะย้อนกลับไปสู่ contract ที่ไม่มีใครรู้ว่าเข้มตรงไหน

ตรวจว่า workshop ใช้งานได้จริงอย่างไร

พี่โชว์รันโค้ดชุดนี้ใน temporary module ด้วย Go 1.27 และคำสั่ง go test ./... ตัวตรวจไม่ได้ยอมรับกรณีศูนย์ test: มี test function สามตัวและแต่ละตัวตรวจผลเชิงพฤติกรรม ทั้ง compatibility, default matching และ strict rejection

ถ้านำไปลองแล้ว test แรกไม่ compile ให้เช็ก go version ก่อน ถ้าเป็น Go 1.26 หรือต่ำกว่าอย่าดึง remote installer มารันในเครื่องงาน ให้ใช้ toolchain ที่ทีมอนุมัติหรือ container สำหรับทดลองแทน

Recap

เส้นทางที่ปลอดภัยไม่ใช่ v1 → v2 ใน commit เดียว แต่เป็น “บันทึกพฤติกรรมเดิม → เปลี่ยน engine โดยยังเข้ากันได้ → เลือก strictness ตาม contract → เฝ้าดูผล”

Workshop นี้จงใจใช้ field ที่ต่างกันเฉพาะตัวพิมพ์เพื่อทำให้ความเปลี่ยนแปลงมองเห็นได้ เมื่อเข้าใจแล้ว ลองเพิ่มกรณีชื่อซ้ำ, UTF-8 ผิดรูป และ nil slice จาก payload ของระบบคุณเอง การย้ายที่ดีคือทีมตอบได้ว่าพฤติกรรมใดเปลี่ยน เพราะอะไร และ test ไหนเป็นหลักฐานครับ