ข้าม​ไป​ยัง​เนื้อหา

อ่า​นอ​อเด​อร์​จาก file JSON

บท​ที่​แล้ว Order เกิด​จาก​การ​พิมพ์​ค่า​ลง​ไป​ใน​โปร​แก​รม​ตรงๆ ของ​จริง​ไม่มี​ใคร​ทำงาน​แบบ​นั้น

บท​นี้ orderctl อ่า​นอ​อเด​อร์​จาก file บน diskเป็น​ครั้ง​แรก และ​ตรง​นั้น​มี​ของ​สาม​อย่าง รอ​อยู่​ที่ Go ทำ​ต่าง​จาก​ภาษา​ที่​คุณ​ใช้​อยู่ — การ​จับ​คู่​คีย์ การ​ปิด​ทรัพยากร และ​การ​รายงาน​ว่า​อะไร​พัง

encoding/json จับ​คู่​คีย์​ของ JSON กับ field ของ struct ผ่าน​ข้อความ​ที่​เขียน​ต่อ​ท้าย field

วา​งอ​อเด​อร์​ตัวอย่าง​ไว้​ที่ testdata/order.json ไดเรกทอรี​นี้​เครื่อง​มือ​ของ Go ข้าม​ให้​เอง จึง​เอา​ไว้​เก็บ​ของ​สำหรับ​เทสต์​ได้​โดย​ไม่​กวน​การ​แปล

{
"id": "ORD-1042",
"table": 7,
"lines": [
{ "sku": "CFE-01", "category": "drink", "qty": 2, "unit": 6500 },
{ "sku": "TEA-03", "category": "drink", "qty": 1, "unit": 5500 }
]
}

unit เป็น 6500 ไม่ใช่ 65 เพราะ Baht ของ​บท​ที่ 2 เก็บ​เงิน​เป็น​สตางค์ · กาแฟ​แก้ว​ละ 65 บาท จึง​เขียน​ลง file เป็น 6500

คีย์​ใน​นี้​เป็น​ตัว​พิมพ์​เล็ก​ล้วน และ​คีย์​อย่าง created_at ที่​จะ​เพิ่ม​เข้า​มายัง​มี​ขีด​ล่าง​คั่น ส่วน field ที่ export ได้​ของ Go ต้อง​ขึ้น​ต้น​ด้วย​ตัว​พิมพ์​ใหญ่ และ​เขียน​ติด​กัน​แบบ CreatedAt

ช่อง​ว่าง​ตรง​นี้​คือ​เหตุผล​ที่ struct tagstruct tagข้อความ​ใน backquote ที่​ติด​หลัง field ของ struct — library อย่าง `encoding/json` อ่าน​มัน​เพื่อ​รู้​ชื่อ​คีย์​และ​ตัว​เลือก มี​อยู่

package order
import "time"
// Baht กับ Status มาจากบทที่ 2 บทนี้แค่เติม tag ให้ Line กับ Order
type Baht int64
type Status int
type Line struct {
SKU string `json:"sku"`
Category string `json:"category"`
Qty int `json:"qty"`
Unit Baht `json:"unit"`
}
type Order struct {
ID string `json:"id"`
Table int `json:"table"`
Lines []Line `json:"lines"`
Status Status `json:"status"`
Discount Baht `json:"discount,omitzero"`
CreatedAt time.Time `json:"created_at,omitzero"`
SourcePath string `json:"-"`
checksum uint32
}

tag คือ string ธรรมดา​ที่​ติด​อยู่​หลัง​ชนิด​ของ field ไม่ใช่​ไวยากรณ์​ของ​ภาษา compiler ไม่​ตรวจ​ข้าง​ใน​ให้​เลย และ​ไม่มี attribute หรือ annotation แบบ​ที่ C# กับ Java มี

รูป​แบบ​ที่ encoding/json อ่าน​คือ json:"ชื่อคีย์,ตัวเลือก" ต้อง​อยู่​ใน backquote และ​ห้าม​มี​ช่อง​ว่าง​คั่น​ระหว่าง json: กับ​เครื่องหมาย​คำ​พูด

tag ที่​พิมพ์​ผิด ไม่มี​ใคร​บอก​คุณ

เขียน json: "created_at" โดย​มี​ช่อง​ว่าง​หลัง json: แล้ว encoding/json จะ​อ่าน​มัน​ไม่​ออก

ผล​ไม่ใช่​ความ​ผิด​พลาด​ตอน​แปล และ​ไม่ใช่​ความ​ผิด​พลาด​ตอน​รัน · ผล​คือ field นั้น​กลับ​ไป​ใช้​ชื่อ เดิม​ของ​ตัว​เอง คือ CreatedAt ซึ่ง​ไม่​ตรง​กับ​คีย์ created_at แล้ว​ค่า​ก็​หาย​ไป​เงียบๆ

กับ​ดัก​ซ้อน​อยู่​อีก​ชั้น · เมื่อ​ไม่มี tag ให้​ใช้ encoding/json จะ​เทียบ​ชื่อ field แบบ​ไม่​สน​ตัว​พิมพ์ · SKU ที่​พิมพ์ tag ผิด​แบบ​เดียวกัน​จึง​ยัง​รับ​ค่า​จาก​คีย์ sku ได้​ครบ

ผล​คือ struct ครึ่ง​หนึ่ง​ทำงาน​ถูก อีก​ครึ่ง​ว่าง​เปล่า และ​ไม่มี​อะไร​ชี้​ว่า​ต้น​เหตุ​คือ​ช่อง​ว่าง​ตัว​เดียว

go vet มี​ตัว​ตรวจ structtag ที่​จับ​รูป​แบบ​ผิด​ข้อ​นี้​ได้ · บน go1.27.0 มัน​ตอบ​ว่า struct field tag json: "created_at" not compatible with reflect.StructTag.Get: bad syntax for struct tag value

สิ่ง​ที่​มัน​จับ​ไม่​ได้​คือ tag ที่​รูป​แบบ​ถูก​แต่​สะกด​ชื่อ​คีย์​ผิด เช่น json:"skew" · นั่น​ยัง​เงียบ​อยู่ดี · นี่​เป็น​หนึ่ง​ใน​เหตุผล​ที่​คอร์ส​นี้​ให้ go vet ./... อยู่​ใน​วงจร​ประจำ​วัน​ตั้งแต่​บท​แรก

คีย์​ที่​ไม่รู้​จัก tag ขีด​กลาง และ field ที่​ไม่ export ล้วน​ไม่​ทำให้​เกิด​ความ​ผิด​พลาด

  • คีย์​ใน JSON ที่​ไม่​ตรง​กับ field ไหน​เลย ถูก​ข้าม​ไป ไม่มี error คืน​กลับ​มา
  • json:"-" ตัด field นั้น​ออก​จาก​ทั้ง​การ​อ่าน​และ​การ​เขียน แม้ JSON จะ​ส่ง​ค่า​มา​ก็​ไม่​ถูก​ใช้
  • field ที่​ขึ้น​ต้น​ด้วย​ตัว​พิมพ์​เล็ก ถูก​เมิน​ทั้งหมด เพราะ encoding/json ใช้ reflection ซึ่ง​เข้า​ถึง field ที่​ไม่ export ไม่​ได้

ทั้ง​สาม​ข้อ​พิสูจน์​ได้​ใน​โปรแกรม​เดียว

package main
import (
"encoding/json"
"fmt"
)
type Line struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
note string
Unit int `json:"-"`
}
func main() {
data := []byte(`{"sku":"CFE-01","qty":2,"unit":6500,"note":"หวานน้อย"}`)
var l Line
if err := json.Unmarshal(data, &l); err != nil {
fmt.Println("พัง:", err)
return
}
fmt.Printf("%+v\n", l)
}
$ go run .
{SKU:CFE-01 Qty:2 note: Unit:0}

unit มี​ค่า​มา​จริง​แต่ Unit ยัง​เป็น 0 เพราะ tag เป็น​ขีด​กลาง ส่วน note ที่ JSON ส่ง​มา ก็​ไม่​ถึง field note เพราะ​มัน​ไม่ export · และ err เป็น nil ตลอด

Jackson กับ System.Text.Json ไม่​ทำ​แบบ​นี้

ถ้า​คุณ​มา​จาก Java หรือ C# คุณ​เคยชิน​กับ​การ​ที่​คีย์​แปลก​ปลอม​ทำให้​เกิด exception หรือ​อย่าง​น้อย ก็​มี​สวิตช์​ให้​เปิด​การ​เข้ม​งวด

json.Unmarshal ข้าม​คีย์​ที่​ไม่รู้​จัก​เสมอ และ ไม่มี​ตัว​เลือก​ให้​ปิด​พฤติกรรม​นั้น

ทาง​เดียว​ที่​จะ​เข้ม​งวด​ได้​คือ​เปลี่ยน​ไป​ใช้ json.Decoder ซึ่ง​เป็น​หัว​ข้อ​ถัดๆ ไป​ใน​บท​นี้

Discount ที่​เป็น int ให้ 0 ทั้ง​ตอน​ที่ JSON ไม่​ส่ง​คีย์​มา และ​ตอน​ที่​ส่ง 0 มา​จริง

Go ไม่มี​สถานะ “ยัง​ไม่​กำหนด​ค่า” · เมื่อ decoder ไม่​เจอ​คีย์ มัน​ก็​แค่​ไม่​แตะ field นั้น แล้ว field ก็​ยัง​เป็น zero value ที่​ได้​มา​ตั้งแต่​ตอน​ประกาศ

ถ้า​โดเมน​ของ​คุณ​ต้อง​แยก​สอง​กรณี​นี้ ทาง​ที่ standard library ให้​คือ​เปลี่ยน​ชนิด​เป็น pointer

package main
import (
"encoding/json"
"fmt"
)
type Plain struct {
Discount int `json:"discount"`
}
type Optional struct {
Discount *int `json:"discount"`
}
func main() {
for _, in := range []string{`{}`, `{"discount":0}`} {
var p Plain
var o Optional
if err := json.Unmarshal([]byte(in), &p); err != nil {
fmt.Println(err)
return
}
if err := json.Unmarshal([]byte(in), &o); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%-16s Discount=%d ส่งคีย์มาไหม=%t\n", in, p.Discount, o.Discount != nil)
}
}
$ go run .
{} Discount=0 ส่งคีย์มาไหม=false
{"discount":0} Discount=0 ส่งคีย์มาไหม=true

ราคา​ที่​จ่าย​คือ​ทุก​จุด​ที่​อ่าน Discount ต้อง​เช็ก nil ก่อน · orderctl จึง​ไม่​ใช้ pointer เพราะ​ส่วนลด 0 กับ​ไม่มี​ส่วนลด มี​ความ​หมาย​เดียวกัน​ใน​โดเมน​นี้

defer เลื่อน​การ​เรียก แต่​ประเมิน​ค่า​ตรง​บรรทัด​ที่​เขียน

หัวข้อ​ที่​มีชื่อ​ว่า “defer เลื่อน​การ​เรียก แต่​ประเมิน​ค่า​ตรง​บรรทัด​ที่​เขียน”

ค่าที่​ส่ง​ให้​ฟังก์ชัน​ถูก​คำนวณ​ตอน defer ทำงาน ส่วน​ตัว​ฟังก์ชัน​ถูก​เรียก​ตอน​ฟังก์ชัน​ที่​ครอบ​อยู่​คืน​ค่า

deferdeferคำ​สั่ง​ที่​เลื่อน​การ​เรียก​ฟังก์ชัน​ไป​จนกว่า​ฟังก์ชัน​ที่​ครอบ​อยู่​จะ​คืน​ค่า ผูก​กับ​ฟังก์ชัน ไม่ใช่​กับ block และ​ทำงาน​ใน​ลำดับ​กลับ​ด้าน คือ​กลไก​ที่ Go ใช้​แทน finally และ using · มัน​ดู​คล้าย​จน​คน​ย้าย​มา​ไม่ทัน​สังเกต​ข้อ​ต่าง

package main
import "fmt"
func main() {
x := 1
defer fmt.Println("defer ตัวแรก เห็น x =", x)
defer func() { fmt.Println("defer ตัวที่สอง เห็น x =", x) }()
x = 99
fmt.Println("บรรทัดสุดท้ายของ main เห็น x =", x)
}
$ go run .
บรรทัดสุดท้ายของ main เห็น x = 99
defer ตัวที่สอง เห็น x = 99
defer ตัวแรก เห็น x = 1

ผลลัพธ์​นี้​บอก​สอง​เรื่อง​พร้อม​กัน ลำดับ​กลับ​ด้าน​จาก​ลำดับ​ที่​เขียน และ defer ตัวแรก จำ​ค่า 1 ไว้ ตั้งแต่​ตอน​ที่​บรรทัด​นั้น​ทำงาน ทั้ง​ที่ x ถูก​แก้​เป็น 99 ไป​แล้ว

ตัว​ที่​สอง​ห่อ​ไว้​ใน​ฟังก์ชัน​ไม่มี​ชื่อ จึง​ไม่มี argument ให้​ประเมิน​ล่วง​หน้า มัน​อ่าน x ตอน​ที่​ตัว​เอง​ถูก​เรียก

สิ่ง​ที่​คุณ​คุ้นdefer ของ Go
finally ทำงาน​เมื่อ​จบ block tryทำงาน​เมื่อ ฟังก์ชัน ที่​ครอบ​อยู่​คืน​ค่า
using ปิด​ของ​ทันที​ที่​หลุด​ขอบเขตไม่มี​ขอบเขต​ระดับ block ให้​ผูก
ลำดับ​ตาม​ที่​เขียนลำดับ​กลับ​ด้าน ตัว​ที่ defer ทีหลัง​ทำงาน​ก่อน
`os.Exit` ข้าม defer ทั้งหมด

defer ผูก​กับ​การ​คืน​ค่า​ของ​ฟังก์ชัน · os.Exit ไม่​ได้​คืน​ค่า มัน​จบ​โปร​เซ​ส​ทันที

ทุก defer ที่​จอง​ไว้​จึง​ไม่​ทำงาน​เลย​สัก​ตัว ไม่มี​คำ​เตือน และ​ไม่มี​ร่อง​รอย​อะไร​ทิ้ง​ไว้

ผล​ที่​ตาม​มา​คือ main ที่​ต้อง​คืน​รหัส​ออก​ไม่​ควร​เรียก os.Exit ปน​กับ defer ใน​ฟังก์ชัน​เดียวกัน · รูป​ที่​ปลอดภัย​คือ​แยก​งาน​จริง​ไป​ไว้​ใน​ฟังก์ชัน​ที่​คืน error แล้ว​ให้ main ทำ​แค่​แปลง error เป็น​รหัส​ออก ซึ่ง​เป็น​รูป​ที่​บท​ที่ 7 ใช้​กับ run()

วน defer f.Close() ใน​ลูป แล้ว file ทุก​ใบ​เปิด​ค้าง​จนกว่า​ฟังก์ชัน​ทั้ง​ฟังก์ชัน​จะ​จบ

นี่​คือ​กับ​ดัก​ที่​ตาม​มา​จาก​หัวข้อ​ที่​แล้ว​โดยตรง และ​เป็น​ข้อ​ที่​เจ็บ​ที่สุด​เวลา​ย้าย​มา​จาก using

package main
import (
"fmt"
"os"
)
// ❌ แบบดิบ — file เปิดค้างสะสมจนกว่า leak จะคืนค่า
func leak(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
use(f)
}
return nil
}
// ✅ ตัดฟังก์ชันย่อยออกมา ให้ defer มีขอบเขตเท่าที่ต้องการ
func each(paths []string) error {
for _, p := range paths {
if err := withFile(p, use); err != nil {
return err
}
}
return nil
}
func withFile(p string, fn func(*os.File)) error {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
fn(f)
return nil
}
func use(f *os.File) { fmt.Println("อ่าน", f.Name()) }
func main() {
if err := each(os.Args[1:]); err != nil {
fmt.Println(err)
}
}

leak แปล​ผ่าน​และ​รัน​ได้​ตาม​ปกติ กับ​สิบ path มัน​ก็​ไม่​พัง​ให้​เห็น · มัน​พัง​ตอน​รายการ​ยาว​จน ชน limit ของ​จำนวน file ที่​โปร​เซ​ส​เปิด​พร้อม​กัน​ได้

ทาง​แก้​ไม่ใช่​การ​เลี่ยง defer แต่​คือ​การ​สร้าง​ฟังก์ชัน​ให้ defer ผูก · withFile มี​อยู่​เพื่อ​ข้อ​นี้​ข้อ​เดียว

%v ได้​ข้อความ​เท่า​กัน​แต่​ตัด​สาย​ขาด errors.Is และ errors.As จึง​หา​อะไร​ไม่​เจอ

ใน Go ความ​ผิด​พลาด​คือ​ค่าที่​คืน​กลับ​มา​เป็น​ตัว​สุดท้าย ไม่ใช่​สิ่ง​ที่​กระโดด​ขึ้น​ไป​หา​ผู้​เรียก​เอง · ผู้​เรียก​จึง​ต้อง​เขียน​ว่า​จะ​ทำ​อะไร​กับ​มัน​ทุก​จุด

error wrappingerror wrappingการ​ห่อ error เดิม​ไว้​ข้าง​ใน​ตัว​ใหม่​ด้วย​กริยา `%w` ของ `fmt.Errorf` เพื่อ​ให้ `errors.Is` และ `errors.As` เดิน​หา​ต่อ​ได้ คือ​การ​ใส่​บริบท​เพิ่ม​โดย​ไม่​ทิ้ง​ของ​เดิม · กริยา %w ของ fmt.Errorf เก็บ error ตัว​เดิม​ไว้​ข้าง​ใน​ตัว​ใหม่ ส่วน %v แปลง​มัน​เป็น​ข้อความ​แล้ว​ทิ้ง​ตัว​จริง

package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func wrapped(path string) error {
if _, err := os.Open(path); err != nil {
return fmt.Errorf("โหลดออเดอร์จาก %s: %w", path, err)
}
return nil
}
func flattened(path string) error {
if _, err := os.Open(path); err != nil {
return fmt.Errorf("โหลดออเดอร์จาก %s: %v", path, err)
}
return nil
}
func main() {
for _, f := range []func(string) error{wrapped, flattened} {
err := f("testdata/missing.json")
fmt.Println(err)
var pe *fs.PathError
fmt.Println(" errors.Is(err, fs.ErrNotExist) =", errors.Is(err, fs.ErrNotExist))
fmt.Println(" errors.As(err, &pe) =", errors.As(err, &pe))
}
}
$ go run .
โหลดออเดอร์จาก testdata/missing.json: open testdata/missing.json: no such file or directory
errors.Is(err, fs.ErrNotExist) = true
errors.As(err, &pe) = true
โหลดออเดอร์จาก testdata/missing.json: open testdata/missing.json: no such file or directory
errors.Is(err, fs.ErrNotExist) = false
errors.As(err, &pe) = false

สอง​บรรทัด​แรก​ของ​แต่ละ​รอบ​เหมือน​กัน​ทุก​ตัว​อักษร · ความ​ต่าง​มอง​ด้วย​ตา​ไม่​เห็น แต่​ผู้​เรียก ที่​อยาก​รู้​ว่า “file ไม่มี​อยู่​ใช่​ไหม” ได้​คำ​ตอบ​คนละ​อย่าง

  • errors.Is เดิน​ไล่​สาย​ของ %w เพื่อ​เทียบ​กับ​ค่า error ตัว​ที่​กำหนด เช่น fs.ErrNotExist
  • errors.As เดิน​สาย​เดียวกัน​เพื่อ​หา​ชั้น​ที่​เป็น​ชนิด​ที่​กำหนด แล้ว​คัด​ค่า​ออก​มา​ให้​ใช้​ต่อ
  • %v ทำให้​ทั้ง​สอง​ตัว​เดิน​ต่อ​ไม่​ได้ เพราะ​ไม่​เหลือ​อะไร​ให้​เดิน

สวิตช์​ที่​ทำให้​คีย์​แปลก​ปลอม​เป็น​ความ​ผิด​พลาด มี​อยู่​บน Decoder เท่านั้น

เส้น​ทาง​ที่​สั้น​ที่สุด​คือ​อ่าน​ทั้ง file เข้า​มา​เป็น​ไบต์​ก้อน​เดียว​ด้วย os.ReadFile แล้ว​ส่ง​เข้า json.Unmarshal · os.ReadFile คือ​ชื่อ​ใหม่​ของ ioutil.ReadFile ที่ Go 1.16 ย้าย​มา​ไว้​ที่ os · io/ioutil ยัง​ทำงาน​ได้​อยู่ แต่​ของ​ทุก​ชิ้น​ใน​นั้น​ย้าย​ไป​ที่ io กับ os หมด​แล้ว

เส้น​ทาง​ที่​คอร์ส​นี้​เลือก​คือ os.Open แล้ว​ส่ง *os.File เข้า json.NewDecoder · ตาราง​นี้​บอก​ว่าการ​เลือก​นั้น​แลก​อะไร​มา

json.Unmarshaljson.NewDecoder(r)
รับ​อะไร​เข้า[]byte ที่​อ่าน​มา​ครบ​แล้วio.Reader ที่​ยัง​ไหล​อยู่
คีย์​ที่​ไม่รู้​จักข้าม​เสมอ ปิด​ไม่​ได้ตั้ง DisallowUnknownFields() ให้​เป็น error ได้
ค่าที่​ตาม​มา​ข้าง​หลังเป็น error invalid characterไม่​บ่น ต้อง​เรียก Decode ซ้ำ​เพื่อ​เช็ก​เอง
ตัวเลข​ขนาด​ใหญ่ได้ float64 เมื่อ​อ่าน​ลง anyตั้ง UseNumber() ให้​ได้ json.Number แทน

แถว​ที่​สาม​คือ​แถว​ที่​กัด​คน​มาก​ที่สุด · body ที่​เป็น {"id":"A"}{"id":"B"} ผ่าน Decode ครั้ง​แรก​โดย err เป็น nil แล้ว​โปรแกรม​ก็​ทำงาน​ต่อ​กับ​ใบ​แรก​ใบ​เดียว

internal/order/load.go จึง​เช็ก​ข้อ​นี้​ด้วย​มือ

package order
// บล็อกนี้รวมชนิดจากหัวข้อก่อนไว้ให้ครบ จึงคัดลอกไปคอมไพล์ได้ทั้งก้อน
// ของจริงแยกเป็นสอง file ใน package เดียวกัน และ Order ของจริงมี Status
// Discount CreatedAt ด้วย ตัดออกตรงนี้เพื่อให้เห็นทางเดินของ error ชัด
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
)
type Baht int64
func (b Baht) String() string {
sign := ""
if b < 0 { // การหารและ mod ของ Go ตัดเข้าหาศูนย์ ทั้งสองผลจึงติดลบพร้อมกัน
sign, b = "-", -b
}
return fmt.Sprintf("%s%d.%02d บาท", sign, b/100, b%100)
}
type Line struct {
SKU string `json:"sku"`
Category string `json:"category"`
Qty int `json:"qty"`
Unit Baht `json:"unit"`
}
type Order struct {
ID string `json:"id"`
Table int `json:"table"`
Lines []Line `json:"lines"`
}
func Load(path string) (Order, error) {
f, err := os.Open(path)
if err != nil {
return Order{}, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
o, err := Decode(f)
if err != nil {
return Order{}, fmt.Errorf("read %s: %w", path, err)
}
return o, nil
}
func Decode(r io.Reader) (Order, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields() // สวิตช์นี้มีเฉพาะบน Decoder ไม่มีบน Unmarshal
var o Order
if err := dec.Decode(&o); err != nil {
return Order{}, err
}
// Decoder ไม่บ่นเรื่องค่าที่ตามมาข้างหลัง ต้องเช็กเอง
if err := dec.Decode(new(struct{})); !errors.Is(err, io.EOF) {
return Order{}, errors.New("หนึ่ง file ต้องมีออเดอร์เดียว")
}
return o, nil
}
func Explain(err error) string {
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
return fmt.Sprintf("field %q รับค่า %s ไม่ได้ ต้องเป็น %s (ไบต์ที่ %d)",
ute.Field, ute.Value, ute.Type, ute.Offset)
}
var se *json.SyntaxError
if errors.As(err, &se) {
return fmt.Sprintf("JSON พังที่ไบต์ %d: %v", se.Offset, se)
}
return err.Error()
}

Load แยก​จาก Decode เพราะ Decode รับ io.Reader จึง​เทสต์​ได้​ด้วย strings.NewReader โดย​ไม่​ต้อง​แตะ diskเลย · บท​ที่ 8 เอา​ฟังก์ชัน​ตัว​เดียวกัน​นี้​ไป​รับ body ของ HTTP ต่อ

flowchart TD
  A["Load(path)"] --> B{"os.Open ผ่านไหม"}
  B -- "ไม่" --> E1["ห่อด้วย %w<br/>คืน open path ..."]
  B -- "ผ่าน" --> C["defer f.Close()<br/>จองการปิดไว้ล่วงหน้า"]
  C --> D["Decode(f)"]
  D --> D1["json.NewDecoder<br/>DisallowUnknownFields"]
  D1 --> D2{"Decode ครั้งแรกผ่านไหม"}
  D2 -- "ไม่" --> E2["คืน error ของ decoder"]
  D2 -- "ผ่าน" --> D3{"Decode ครั้งที่สอง<br/>ได้ io.EOF ไหม"}
  D3 -- "ไม่" --> E3["มีมากกว่าหนึ่งออเดอร์<br/>คืน error ของเราเอง"]
  D3 -- "ได้" --> F["คืน Order"]
  E2 --> G["Load ห่อซ้ำ<br/>คืน read path ..."]
  E3 --> G

คำ​บรรยาย​ภาพ: Load เปิด file แล้ว​จอง​การ​ปิด​ด้วย defer ก่อน​ส่ง​ต่อ​ให้ Decode · Decode เรียก Decode ของ decoder สอง​ครั้ง ครั้ง​แรก​เพื่อ​อ่า​นอ​อเด​อร์ ครั้ง​ที่​สอง​เพื่อ​ยืนยัน​ว่า​ไม่มี​อะไร​ตาม​มา​ข้าง​หลัง · ทุก​ทาง​ที่​ผิด​ถูก​ห่อ​ด้วย %w ก่อน​คืน​กลับ

Go 1.27 เปลี่ยน​เครื่องยนต์​ข้าง​ใต้ `encoding/json`

ตั้งแต่ Go 1.27 encoding/json ทำงาน​บน​เครื่องยนต์​ของ encoding/json/v2 เป็น​ค่า​ตั้ง​ต้น และ release note เขียน​เอง​ว่า​พฤติกรรม​ของ​การ​แปลง​ยัง​คง​เดิม แต่ ข้อความ​ของ error อาจ​ต่าง​ไป

ผล​ที่​ตาม​มา​คือ​กฎ​ข้อ​หนึ่ง​ของ​คอร์ส​นี้ · ห้าม​แยก​ชนิด​ของ​ความ​ผิด​พลาด​ด้วย​การ​ค้น​ข้อความ​ด้วย strings.Contains ให้​ใช้ errors.Is กับ errors.As เท่านั้น

encoding/json/v2 เป็น package แยก​ที่​มี API ของ​ตัว​เอง ไม่ใช่​ของ​ที่​คอร์ส​นี้​ใช้

Explain ดึง *json.UnmarshalTypeError ออก​จาก​สาย​แล้ว​แปลง​เป็น​ประโยค​ที่​ระบุ​ชื่อ field และ​ตำแหน่ง

ข้อความ​ตั้ง​ต้น​ของ decoder เขียน​ถึง​คน​ที่​เขียน Go ไม่ใช่​คน​ที่​แก้ JSON · Explain อยู่​ตรง​กลาง เพื่อ​แปลง​มัน​หนึ่ง​ชั้น

วาง​อีก​สาม​ใบ​ไว้​ข้าง order.json แต่ละ​ใบ​พัง​คนละ​แบบ

testdata/bad-type.json {"id":"ORD-1042","table":"7","lines":[]}
testdata/unknown.json {"id":"ORD-1042","table":7,"lines":[],"note":"less spicy"}
testdata/two.json {"id":"A","table":1,"lines":[]}{"id":"B","table":2,"lines":[]}

แล้ว​เรียก Load ที​ละ​ใบ รวม gone.json ที่​ไม่มี​อยู่​จริง​เข้าไป​ด้วย

$ go run .
testdata/order.json ok : {ID:ORD-1042 Table:7 Lines:[{SKU:CFE-01 Category:drink Qty:2 Unit:65.00 บาท} {SKU:TEA-03 Category:drink Qty:1 Unit:55.00 บาท}]}
testdata/bad-type.json ผิด: field "table" รับค่า string ไม่ได้ ต้องเป็น int (ไบต์ที่ 28)
testdata/unknown.json ผิด: read testdata/unknown.json: json: unknown field "note"
testdata/two.json ผิด: read testdata/two.json: หนึ่ง file ต้องมีออเดอร์เดียว
testdata/gone.json ผิด: open testdata/gone.json: open testdata/gone.json: no such file or directory

แถว​ของ bad-type.json คือ​แถว​ที่ errors.As ทำงาน · มัน​เดิน​ผ่าน​ชั้น​ที่ Load ห่อ​ไว้ ไป​เจอ *json.UnmarshalTypeError ข้าง​ใน​แล้ว​อ่าน Field Value Type และ Offset ออก​มา

อีก​สาม​แถว​ไม่​ตรง​กับ​ชนิด​ไหน​เลย Explain จึง​คืน​ข้อความ​เดิม · นี่​คือ​พฤติกรรม​ที่​ตั้งใจ ไม่ใช่​ช่อง​โหว่​ที่​ยัง​ไม่​ได้​ปิด

แถว​สุดท้าย​มี​คำ​ว่า open สอง​ครั้ง — และ​นั่น​คือ​ของ​จริง

os.Open คืน *fs.PathError ที่​พิมพ์​ตัว​เอง​ออก​มา​เป็น open <path>: <เหตุผล> อยู่​แล้ว

Load ห่อ​ทับ​ด้วย open %s: %w อีก​ชั้น ผล​จึง​เป็น​ข้อความ​ซ้อน​กัน​ตาม​ที่​เห็น​ใน​ผลลัพธ์​ข้าง​บน

บท​เรียน​ที่​ได้​คือ ชั้น​ที่​ห่อ​ควร​ใส่​บริบท​ของ​งาน ไม่ใช่​ชื่อ​ของ syscall ที่​ชั้น​ล่าง​ใส่​มา​แล้ว · เขียน โหลดออเดอร์จาก %s: %w แล้ว​ผู้​อ่าน log จะ​ได้​ข้อมูล​เพิ่ม​จริง​ใน​ทุก​ชั้น

omitempty ไม่​ถือว่า struct เปล่า​เป็น​ค่า​ว่าง time.Time ที่​ยัง​ไม่​ตั้ง​ค่า​จึง​หลุด​ออก​ไป​ทั้ง​ก้อน

สอง​ตัว​เลือก​นี้​ใช้​ตอน​เขียน JSON ออก ไม่ใช่​ตอน​อ่าน​เข้า

package main
import (
"encoding/json"
"fmt"
"time"
)
type WithEmpty struct {
Discount int `json:"discount,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
type WithZero struct {
Discount int `json:"discount,omitzero"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func main() {
a, err := json.Marshal(WithEmpty{})
if err != nil {
fmt.Println(err)
return
}
b, err := json.Marshal(WithZero{})
if err != nil {
fmt.Println(err)
return
}
fmt.Println("omitempty:", string(a))
fmt.Println("omitzero :", string(b))
}
$ go run .
omitempty: {"created_at":"0001-01-01T00:00:00Z"}
omitzero : {}

omitempty นิยาม​คำ​ว่า​ว่าง​ไว้​แคบ เอกสาร​ไล่​ไว้​ครบ​คือ false 0 pointer ที่​เป็น nil ค่า interface ที่​เป็น nil และ array slice map หรือ string ที่​ยาว​เป็น​ศูนย์

time.Time ไม่​อยู่​ใน​รายการ​นั้น​เลย เพราะ​มัน​เป็น struct · ค่าที่​ยัง​ไม่​ได้​ตั้ง​จึง​หลุด​ออก​ไป​เต็ม​ก้อน

เปิด​หน้า​เอกสาร​ของ encoding/json แล้ว​คุณ​จะ​เห็น​นิยาม​ของ omitempty สอง​ชุด · ชุด​ที่​สอง เป็น​ของ encoding/json/v2 ซึ่ง​เป็น package คนละ​ตัว · encoding/json ยัง​ใช้​ชุด​แรก​อยู่

omitzero มาใน Go 1.24 และ​ถาม​คำถาม​ที่​ตรง​กว่า คือ​ค่า​นี้​เท่ากับ zero value ของ​ชนิด​ตัว​เอง​ไหม · Order ของ​บท​นี้​จึง​ใช้ omitzero กับ​ทั้ง Discount และ CreatedAt

main แปลง error เป็น​รหัส​ออก แล้ว​ให้ Explain เป็น​คน​คุย​กับ​ผู้​ใช้

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// cmd/orderctl/main.go — ต้องมี internal/order ของบทนี้อยู่ใน module เดียวกัน
package main
import (
"fmt"
"os"
"example.com/orderctl/internal/order"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "ใช้งาน: orderctl <path ของ order.json>")
os.Exit(2)
}
o, err := order.Load(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "orderctl:", order.Explain(err))
os.Exit(1)
}
// %v เรียก String() ของ Baht จากบทที่ 2 ซึ่งแปลงสตางค์เป็นบาทให้เอง
fmt.Printf("ออเดอร์ %s โต๊ะ %d · %d รายการ · ยอด %v\n",
o.ID, o.Table, len(o.Lines), o.Subtotal())
}

Subtotal() มา​จาก​บท​ที่ 2 · บท​นี้​ไม่​ได้​แตะ​มัน​เลย แค่​ป้อน​ข้อมูล​จริง​เข้าไป​ให้​แทน​ค่าที่​พิมพ์​ไว้

$ go build ./cmd/orderctl
$ ./orderctl testdata/order.json
ออเดอร์ ORD-1042 โต๊ะ 7 · 2 รายการ · ยอด 185.00 บาท
$ echo $?
0
$ ./orderctl testdata/bad-type.json
orderctl: field "table" รับค่า string ไม่ได้ ต้องเป็น int (ไบต์ที่ 28)
$ echo $?
1

main ไม่มี defer อยู่​เลย​สัก​ตัว จึง​เรียก os.Exit ได้​อย่าง​ปลอดภัย · บท​ที่ 7 จะ​ย้าย​งาน​จริง ออก​ไป​ไว้​ใน​ฟังก์ชัน run() ที่​คืน error เพื่อ​ให้​เทสต์​เรียก​มัน​ได้​โดย​ไม่​ฆ่า​โปร​เซ​ส​ของ​ตัว​รัน​เทสต์

orderctl อ่าน​ของ​จริง​ได้​แล้ว และ​บอก​ผู้​ใช้ได้​ว่า​อะไร​ผิด

ตอน​นี้​คุณ​ติด struct tag ให้​ชนิด​โดเมน​เป็น รู้​ว่า encoding/json ทำ​อะไร​เงียบๆ บ้าง และ​รู้​ว่า​จะ​เข้ม​งวด​กว่า​นั้น​ต้อง​เปลี่ยน​ไป​ใช้ Decoder

คุณ​เปิด file แล้ว​ปิด​ด้วย defer ที่​ผูก​กับ​ฟังก์ชัน และ​ห่อ​ความ​ผิด​พลาด​ด้วย %w เพื่อ​ให้ errors.Is กับ errors.As ยัง​เดิน​ต่อ​ได้​ตลอด​สาย

บท​หน้า​เป็น slice map string และ rune · ตรง​นั้น​มี​กับ​ดัก​ที่​เงียบ​กว่า​นี้​อีก เพราะ slice สอง​ตัว​ใช้​ที่​เก็บ​ก้อน​เดียวกัน​ได้ โดย​ไม่มี​อะไร​ใน​ไวยากรณ์​บอก​คุณ​เลย


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • encoding/json — Marshal (เข้า​ถึง 2026-08-19) — นิยาม​ของ​คำ​ว่า​ว่าง​ที่ omitempty ใช้
  • Go 1.24 Release Notes (2025-02-11) — omitzero และ​ประโยค​ที่​ว่า​มัน​ตัด​ค่า time.Time ที่​เป็น​ศูนย์​ออก​ได้
  • encoding/json — Decoder.DisallowUnknownFields (เข้า​ถึง 2026-08-19) — สวิตช์​ที่​ทำให้​คีย์​ที่​ไม่รู้​จัก​เป็น​ความ​ผิด​พลาด
  • The Go Programming Language Specification — Defer statements (2026-05-26) — ลำดับ “in the reverse order they were deferred” และ​เวลา​ที่ argument ถูก​ประเมิน
  • Go 1.16 Release Notes (2021-02-16) — io/ioutil ถูก​แทนที่​ทั้ง package จึง​ใช้ os.ReadFile แทน
  • Go 1.27 Release Notes (2026-08) — ประโยค “Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ”

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

เขียน `defer f.Close()` ไว้ในลูป `for` ที่เปิด file ทีละใบ จะเกิดอะไรขึ้น