อ่านออเดอร์จาก file JSON
บทที่แล้ว Order เกิดจากการพิมพ์ค่าลงไปในโปรแกรมตรงๆ ของจริงไม่มีใครทำงานแบบนั้น
บทนี้ orderctl อ่านออเดอร์จาก file บน diskเป็นครั้งแรก และตรงนั้นมีของสามอย่าง
รออยู่ที่ Go ทำต่างจากภาษาที่คุณใช้อยู่ — การจับคู่คีย์ การปิดทรัพยากร และการรายงานว่าอะไรพัง
ออเดอร์ใบแรกอยู่ใน file ไม่ได้อยู่ใน code
หัวข้อที่มีชื่อว่า “ออเดอร์ใบแรกอยู่ใน file ไม่ได้อยู่ใน code”
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 กับ Ordertype 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: กับเครื่องหมายคำพูด
เขียน 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 ./... อยู่ในวงจรประจำวันตั้งแต่บทแรก
สามข้อที่ struct tag ทำเงียบๆ
หัวข้อที่มีชื่อว่า “สามข้อที่ struct tag ทำเงียบๆ”คีย์ที่ไม่รู้จัก 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 ตลอด
ถ้าคุณมาจาก Java หรือ C# คุณเคยชินกับการที่คีย์แปลกปลอมทำให้เกิด exception หรืออย่างน้อย ก็มีสวิตช์ให้เปิดการเข้มงวด
json.Unmarshal ข้ามคีย์ที่ไม่รู้จักเสมอ และ ไม่มีตัวเลือกให้ปิดพฤติกรรมนั้น
ทางเดียวที่จะเข้มงวดได้คือเปลี่ยนไปใช้ json.Decoder ซึ่งเป็นหัวข้อถัดๆ ไปในบทนี้
field ที่ไม่ได้ส่งมา แยกจากค่าศูนย์ไม่ออก
หัวข้อที่มีชื่อว่า “field ที่ไม่ได้ส่งมา แยกจากค่าศูนย์ไม่ออก”
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 = 99defer ตัวที่สอง เห็น x = 99defer ตัวแรก เห็น x = 1ผลลัพธ์นี้บอกสองเรื่องพร้อมกัน ลำดับกลับด้านจากลำดับที่เขียน และ defer ตัวแรก จำค่า 1 ไว้
ตั้งแต่ตอนที่บรรทัดนั้นทำงาน ทั้งที่ x ถูกแก้เป็น 99 ไปแล้ว
ตัวที่สองห่อไว้ในฟังก์ชันไม่มีชื่อ จึงไม่มี argument ให้ประเมินล่วงหน้า มันอ่าน x ตอนที่ตัวเองถูกเรียก
| สิ่งที่คุณคุ้น | defer ของ Go |
|---|---|
finally ทำงานเมื่อจบ block try | ทำงานเมื่อ ฟังก์ชัน ที่ครอบอยู่คืนค่า |
using ปิดของทันทีที่หลุดขอบเขต | ไม่มีขอบเขตระดับ block ให้ผูก |
| ลำดับตามที่เขียน | ลำดับกลับด้าน ตัวที่ defer ทีหลังทำงานก่อน |
defer ผูกกับการคืนค่าของฟังก์ชัน · os.Exit ไม่ได้คืนค่า มันจบโปรเซสทันที
ทุก defer ที่จองไว้จึงไม่ทำงานเลยสักตัว ไม่มีคำเตือน และไม่มีร่องรอยอะไรทิ้งไว้
ผลที่ตามมาคือ main ที่ต้องคืนรหัสออกไม่ควรเรียก os.Exit ปนกับ defer ในฟังก์ชันเดียวกัน
· รูปที่ปลอดภัยคือแยกงานจริงไปไว้ในฟังก์ชันที่คืน error แล้วให้ main ทำแค่แปลง error
เป็นรหัสออก ซึ่งเป็นรูปที่บทที่ 7 ใช้กับ run()
defer ผูกกับฟังก์ชัน ไม่ใช่กับ block
หัวข้อที่มีชื่อว่า “defer ผูกกับฟังก์ชัน ไม่ใช่กับ block”วน
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 มีอยู่เพื่อข้อนี้ข้อเดียว
%w ต่อสาย error ให้ผู้เรียกอ่านต่อได้
หัวข้อที่มีชื่อว่า “%w ต่อสาย error ให้ผู้เรียกอ่านต่อได้”
%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.ErrNotExisterrors.Asเดินสายเดียวกันเพื่อหาชั้นที่เป็นชนิดที่กำหนด แล้วคัดค่าออกมาให้ใช้ต่อ%vทำให้ทั้งสองตัวเดินต่อไม่ได้ เพราะไม่เหลืออะไรให้เดิน
Unmarshal กับ Decoder ไม่ใช่ของแทนกัน
หัวข้อที่มีชื่อว่า “Unmarshal กับ Decoder ไม่ใช่ของแทนกัน”สวิตช์ที่ทำให้คีย์แปลกปลอมเป็นความผิดพลาด มีอยู่บน
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.Unmarshal | json.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 ทำงานบนเครื่องยนต์ของ encoding/json/v2 เป็นค่าตั้งต้น
และ release note เขียนเองว่าพฤติกรรมของการแปลงยังคงเดิม แต่ ข้อความของ error อาจต่างไป
ผลที่ตามมาคือกฎข้อหนึ่งของคอร์สนี้ · ห้ามแยกชนิดของความผิดพลาดด้วยการค้นข้อความด้วย
strings.Contains ให้ใช้ errors.Is กับ errors.As เท่านั้น
encoding/json/v2 เป็น package แยกที่มี API ของตัวเอง ไม่ใช่ของที่คอร์สนี้ใช้
errors.As บอกผู้ใช้ว่าผิดที่ไบต์ไหน
หัวข้อที่มีชื่อว่า “errors.As บอกผู้ใช้ว่าผิดที่ไบต์ไหน”
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 จึงคืนข้อความเดิม · นี่คือพฤติกรรมที่ตั้งใจ
ไม่ใช่ช่องโหว่ที่ยังไม่ได้ปิด
os.Open คืน *fs.PathError ที่พิมพ์ตัวเองออกมาเป็น open <path>: <เหตุผล> อยู่แล้ว
Load ห่อทับด้วย open %s: %w อีกชั้น ผลจึงเป็นข้อความซ้อนกันตามที่เห็นในผลลัพธ์ข้างบน
บทเรียนที่ได้คือ ชั้นที่ห่อควรใส่บริบทของงาน ไม่ใช่ชื่อของ syscall ที่ชั้นล่างใส่มาแล้ว
· เขียน โหลดออเดอร์จาก %s: %w แล้วผู้อ่าน log จะได้ข้อมูลเพิ่มจริงในทุกชั้น
omitempty กับ omitzero ตัดคนละแบบ
หัวข้อที่มีชื่อว่า “omitempty กับ omitzero ตัดคนละแบบ”
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
ต่อเข้ากับ orderctl
หัวข้อที่มีชื่อว่า “ต่อเข้ากับ orderctl”
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.jsonorderctl: field "table" รับค่า string ไม่ได้ ต้องเป็น int (ไบต์ที่ 28)$ echo $?1main ไม่มี 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 ทีละใบ จะเกิดอะไรขึ้น