Go 1.27 เปิด encoding/json/v2: ค่าเริ่มต้นที่เข้มขึ้นเพื่อหยุดข้อมูลกำกวม
JSON มักดูเหมือนข้อมูลที่ตรงไปตรงมา: มีกุญแจหนึ่งดอกต่อกล่องหนึ่งใบ แล้วโปรแกรมก็เปิดกล่องตามชื่อ แต่ของจริงอาจส่งกุญแจชื่อเดียวกันมาสองครั้ง ใช้ตัวอักษรที่ไม่ใช่ UTF-8 ถูกต้อง หรือแอบเพิ่ม field ที่ server สะกดไม่ตรงกับที่ client คาดไว้
ปัญหาไม่ได้อยู่แค่ว่า parser อ่านได้หรือไม่ แต่อยู่ที่ระบบสองฝั่งอาจ “อ่านคนละความหมาย” จากข้อความเดียวกัน ตัวกรองความปลอดภัยอาจใช้ค่าตัวแรก ขณะที่ business logic ใช้ค่าตัวท้าย คล้ายใบส่งของที่เขียนที่อยู่สองแห่งแล้วคนเฝ้าประตูกับคนขับรถเลือกอ่านคนละบรรทัด
Go 1.27 จึงเปิด package ใหม่ encoding/json/v2 และ encoding/json/jsontext พร้อมค่าเริ่มต้นที่เคร่งครัดกว่าเดิม โดยเฉพาะการปฏิเสธชื่อ object ที่ซ้ำและข้อมูล UTF-8 ผิดรูป ส่วน API แบบ v1 ยังอยู่เพื่อรักษาความเข้ากันได้กับโค้ดเดิม
WHY: ความกำกวมเป็น bug ก่อนจะกลายเป็นช่องโหว่
สมมติ payload สมัครสมาชิกมี role สองค่า ถ้า gateway ตรวจค่าแรกว่าเป็น member แต่ service ด้านหลังเลือกค่าท้ายว่าเป็น admin ระบบแต่ละชั้นทำงาน “ถูกตาม parser ของตัวเอง” แต่ผลรวมผิดอย่างร้ายแรง การปฏิเสธข้อมูลกำกวมตั้งแต่ประตูจึงง่ายกว่าตามแก้ความหมายในทุก service
JSON v2 เลือกแนวคิดนี้เป็นค่าเริ่มต้น:
- ชื่อ member ที่ซ้ำใน object ถูกปฏิเสธ แทนการปล่อยให้ค่าท้ายทับค่าแรกเงียบ ๆ
- ข้อมูล UTF-8 ผิดรูปถูกปฏิเสธ แทนการแทนที่อักขระแล้วเดินหน้าต่อ
- การจับชื่อ field ของ struct ใช้แบบตรงตัวมากขึ้น ลดการเดาสะกดใหญ่–เล็ก
- ทีมสามารถเปิด
RejectUnknownMembersเมื่อต้องการให้ contract ของ endpoint ปิดแน่น
ข้อสุดท้ายไม่ได้เปิดโดยอัตโนมัติ เพราะหลาย API ตั้งใจรับ payload ที่มี field เพิ่มเพื่อรองรับ client หลายรุ่น แต่ endpoint สำคัญอย่างคำสั่งโอนเงินหรือเปลี่ยนสิทธิ์มักได้ประโยชน์จากการปฏิเสธของที่ไม่รู้จัก
HOW: วางด่านตรวจตรงขอบระบบ
ตัวอย่างนี้ตรวจสองพฤติกรรมจริงด้วย Go 1.27: ชื่อซ้ำต้องไม่ผ่านค่าเริ่มต้น และ field ที่ไม่อยู่ใน struct ต้องไม่ผ่านเมื่อเราเปิด option เฉพาะ endpoint
package jsoncontract
import (
json "encoding/json/v2"
"testing"
)
type Signup struct {
Name string `json:"name"`
}
func TestDuplicateNameIsRejected(t *testing.T) {
var request Signup
err := json.Unmarshal([]byte(`{"name":"Mali","name":"Admin"}`), &request)
if err == nil {
t.Fatal("duplicate JSON member must be rejected")
}
}
func TestUnknownMemberCanBeRejected(t *testing.T) {
var request Signup
err := json.Unmarshal(
[]byte(`{"name":"Mali","isAdmin":true}`),
&request,
json.RejectUnknownMembers(true),
)
if err == nil {
t.Fatal("unknown JSON member must be rejected")
}
}
อย่าเพิ่งเปลี่ยน import ทั้ง repository ในครั้งเดียวครับ คู่มือย้ายไป JSON v2 แนะนำให้สำรวจพฤติกรรมที่โค้ดพึ่งอยู่ก่อน เช่น การจับชื่อ field แบบไม่สนตัวพิมพ์ใหญ่–เล็ก, การจัดการ nil slice/map และรูปแบบ error เพราะการเปลี่ยน parser เป็นการเปลี่ยน contract แม้ type จะ compile เหมือนเดิม
แนวทางที่ปลอดภัยคือเริ่มจากขอบระบบหนึ่งจุด: เลือก endpoint ที่มี fixture และ integration test ดี, บันทึก payload จริงที่ถูกต้อง, เพิ่มกรณีข้อมูลซ้ำ/เกิน/ผิดรูป แล้วค่อยสลับ decoder จากนั้นดูอัตรา 4xx และ error reason ก่อนขยายวง
ถ้าต้องรักษาพฤติกรรม v1 ชั่วคราว JSON v2 รับ option จาก encoding/json.DefaultOptionsV1() ได้ นี่ควรเป็นสะพานย้าย ไม่ใช่บ้านถาวร เพราะเป้าหมายคือระบุให้ชัดว่าระบบยอมรับอะไร ไม่ใช่เพียงทำให้ test เก่ากลับมาเขียว
เรื่องประสิทธิภาพ: วัดกับ payload ของเรา
Release notes รายงานว่า v2 ปรับประสิทธิภาพการ unmarshal อย่างมีนัยสำคัญในหลายกรณี แต่ตัวเลขกลางไม่แทน workload จริง โครงสร้างซ้อนลึก, custom marshaler, จำนวน allocation และขนาด payload ล้วนเปลี่ยนผลได้
พี่โชว์แนะนำให้ benchmark หลังจาก semantic tests ผ่านแล้ว การเร็วขึ้นแต่ตีความข้อมูลผิดไม่ใช่ optimization ส่วนการช้าลงเล็กน้อยเพื่อปฏิเสธ payload อันตรายอาจเป็นต้นทุนที่คุ้มมากสำหรับ endpoint สำคัญ
Recap
encoding/json/v2 สำคัญเพราะทำให้ “ข้อมูลชัดเจนก่อนประมวลผล” เป็นค่าเริ่มต้น ชื่อซ้ำและ UTF-8 ผิดรูปหยุดที่ประตู ส่วน unknown member เปิดเป็นราย contract ได้
เริ่มย้ายทีละ endpoint, เขียน test จากความหมายของ API, ใช้โหมดเข้ากันได้เฉพาะช่วงเปลี่ยนผ่าน และวัด performance หลัง correctness ผ่าน เมื่อทำตามลำดับนี้ JSON v2 จะเป็นการลดความกำกวม ไม่ใช่การเปลี่ยน import แบบเสี่ยงดวงครับ