CLI ที่ใช้ได้จริง: flag, log และสถานะบน disk
หกบทที่ผ่านมา orderctl มีทุกอย่างที่โดเมนต้องการ — ชนิดข้อมูล การอ่าน JSON การคิดส่วนลด
และการตรวจความถูกต้องที่รายงานทุกข้อที่ผิด
สิ่งที่มันยังไม่มีคือทุกอย่างที่ทำให้เครื่องมือหนึ่งตัว อยู่รอดในมือคนอื่น — การรับ argument, การบอกว่ามันกำลังทำอะไรอยู่ และการจำสิ่งที่ทำไปแล้ว
main เหลือหกบรรทัด งานจริงย้ายไป run(args, stdout, stderr) error ที่เทสต์เรียกได้โดยไม่ต้อง
spawn process
เพิ่ม flag สามตัว: -state, -percent-off และ -debug · เพิ่ม package internal/store
ที่เขียนสถานะลง disk แบบที่ล้มกลางทางแล้วของเดิมไม่พัง
ยังเป็น standard library ล้วนเหมือนเดิม ไม่มี cobra ไม่มี viper ไม่มี logrus
main ที่บาง กับ run ที่ทดสอบได้
หัวข้อที่มีชื่อว่า “main ที่บาง กับ run ที่ทดสอบได้”งานจริงย้ายออกจาก
mainเพราะmainเป็นที่เดียวที่เรียกos.Exitได้ และos.Exitทำลายสิ่งที่เทสต์ต้องการ
func main() ไม่รับอะไรและไม่คืนอะไร เทสต์จึงไม่มีทางเรียกมันแล้วดูผลได้
วิธีแก้ที่ Go ใช้กันคือย้ายทุกอย่างไปฟังก์ชันที่คืน error
ฟังก์ชันนั้นต้องรับ io.Writer ที่จะเขียนผลลงไปเป็น argument ด้วย ไม่ใช่เขียนลง os.Stdout ตรงๆ
เทสต์จะได้ส่ง bytes.Buffer เข้าไปแทน แล้วอ่านสิ่งที่โปรแกรมพิมพ์ออกมา
// Command orderctl จัดการออเดอร์อาหารจากบรรทัดคำสั่งpackage main
import ( "errors" "flag" "fmt" "io" "log/slog" "os")
func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, "orderctl:", err) os.Exit(1) // จุดเดียวในโปรแกรมที่เรียก os.Exit }}
func run(args []string, stdout, stderr io.Writer) error { if len(args) == 0 { return errors.New("ใช้: orderctl <validate|total> [flags] <file>") }
var lv slog.LevelVar // เปลี่ยนระดับได้ตอนรัน และปลอดภัยกับหลาย goroutine log := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: &lv}))
fs := flag.NewFlagSet(args[0], flag.ContinueOnError) // ไม่เรียก os.Exit เอง fs.SetOutput(stderr) statePath := fs.String("state", "orderctl-state.json", "ที่เก็บสถานะ") percent := fs.Int("percent-off", 0, "ส่วนลดเป็นเปอร์เซ็นต์") debug := fs.Bool("debug", false, "เปิด log ระดับ debug")
if err := fs.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { // -h ไม่ใช่ความผิดพลาด return nil } return err } if *debug { lv.Set(slog.LevelDebug) } if fs.NArg() != 1 { return errors.New("ต้องระบุที่อยู่ของออเดอร์หนึ่งชื่อ") }
log.Debug("แจง argument เสร็จ", "cmd", args[0], "state", *statePath, "percent", *percent) fmt.Fprintf(stdout, "%s %s\n", args[0], fs.Arg(0)) return nil}สังเกตว่า os.Args[1:] ถูกตัดที่ main ไม่ใช่ที่ run · ชื่อโปรแกรมเป็นเรื่องของ
สภาพแวดล้อม ส่วน run ควรเห็นเฉพาะสิ่งที่ผู้ใช้พิมพ์
เอกสารของ os.Exit เขียนไว้ตรงๆ ว่ามันจบโปรแกรมทันที และ deferred function จะไม่ถูกเรียก
แปลว่า defer f.Close() ที่คุณวางไว้อย่างดีในบทที่ 3 จะไม่ทำงานเลย ถ้ามีใครเรียก
os.Exit อยู่ลึกลงไปในสายการเรียก
นี่คือเหตุผลที่ os.Exit ควรอยู่ที่เดียวคือใน main หลังจากทุกอย่างคืนค่ากลับมาแล้ว
· log.Fatal ก็เรียก os.Exit เหมือนกัน มันจึงมีข้อห้ามข้อเดียวกัน
flag.FlagSet หนึ่งชุดต่อหนึ่ง subcommand
หัวข้อที่มีชื่อว่า “flag.FlagSet หนึ่งชุดต่อหนึ่ง subcommand”package
flagไม่มี subcommand ให้ แต่มีFlagSetให้สร้างเองชุดละคำสั่ง
ถ้าคุณเรียก flag.String ตรงๆ คุณกำลังเขียนลงตัวแปรระดับ package ตัวเดียวที่ใช้ร่วมกันทั้งโปรแกรม
สองคำสั่งที่ต้องการ flag คนละชุดจึงชนกันทันที
flag.NewFlagSet ให้ชุดของตัวเอง และ argument ตัวที่สองคือสิ่งที่ทำให้มันเทสต์ได้
| ค่า | เกิดอะไรเมื่อแจงไม่ผ่าน |
|---|---|
flag.ExitOnError | พิมพ์วิธีใช้แล้วเรียก os.Exit(2) ทันที — ค่านี้คือค่าที่ flag.CommandLine ใช้ |
flag.ContinueOnError | คืน error กลับมาให้ผู้เรียกตัดสินใจ |
flag.PanicOnError | panic พร้อม error นั้น |
ContinueOnError เป็นค่าเดียวที่เข้ากับ run ที่คืน error · อีกสองค่าทำให้เทสต์ตายไปพร้อมโปรแกรม
-h และ -help เป็นกรณีพิเศษ · Parse คืน flag.ErrHelp กลับมา ซึ่งแปลว่าผู้ใช้ขอดูวิธีใช้
ไม่ใช่พิมพ์ผิด run จึงคืน nil และโปรแกรมจบด้วยรหัส 0
$ orderctl total -hUsage of total: -debug เปิด log ระดับ debug -percent-off int ส่วนลดเป็นเปอร์เซ็นต์ -state string ที่เก็บสถานะ (default "orderctl-state.json")fs.SetOutput(stderr) คือเหตุผลที่ข้อความชุดนี้ไปโผล่ที่ stderr ที่เทสต์ส่งเข้ามา
ไม่ใช่ที่ os.Stderr จริง
กฎ การ parseของ Go ที่ทำให้คนย้ายภาษาสะดุด
หัวข้อที่มีชื่อว่า “กฎ การ parseของ Go ที่ทำให้คนย้ายภาษาสะดุด”การ parse ของ
flagหยุดที่ argument ตัวแรกที่ไม่ใช่ flag และมีอีกสามข้อที่ต่างจากเครื่องมือที่คุณคุ้น
โปรแกรมนี้เรียก Parse ห้าครั้งด้วย argument คนละชุด แล้วพิมพ์สิ่งที่ flag เห็น
พร้อมสิ่งที่เหลืออยู่หลัง การ parseหยุด
// สาธิตกฎ การ parseของ flag ที่ต่างจาก argparse และ commanderpackage main
import ( "flag" "fmt" "io")
// parse คืนค่าที่ flag เห็น และ argument ที่เหลือหลัง การ parseหยุดfunc parse(args []string) (int, bool, []string) { fs := flag.NewFlagSet("total", flag.ContinueOnError) fs.SetOutput(io.Discard) percent := fs.Int("percent-off", 0, "ส่วนลดเป็นเปอร์เซ็นต์") debug := fs.Bool("debug", false, "เปิด log ระดับ debug") if err := fs.Parse(args); err != nil { fmt.Println("แจงไม่ผ่าน:", err) return 0, false, nil } return *percent, *debug, fs.Args()}
func main() { fmt.Println(parse([]string{"-percent-off", "10", "order.json"})) fmt.Println(parse([]string{"order.json", "-percent-off", "10"})) fmt.Println(parse([]string{"--percent-off=10", "order.json"})) fmt.Println(parse([]string{"-debug=false", "order.json"})) fmt.Println(parse([]string{"-debug", "false", "order.json"}))}ผลที่ได้จริง เรียงตามลำดับการเรียก
10 false [order.json]0 false [order.json -percent-off 10]10 false [order.json]0 false [order.json]0 true [false order.json]แถวที่สองคือกับดักที่แพงที่สุด · orderctl total order.json -percent-off 10
ไม่ได้ผิดพลาดอะไรเลย มันแค่ ไม่เห็น -percent-off แล้วคิดยอดเต็มให้เงียบๆ
แถวสุดท้ายคือกับดักที่สอง · -debug false ทำให้ debug เป็น true และ false
กลายเป็น argument ตัวแรกที่เหลือ
- การ parse หยุดที่ argument ตัวแรกที่ไม่ใช่ flag — flag ต้องมาก่อนเสมอ ไม่มีการสลับที่
- ขีดหนึ่งกับขีดสองใช้แทนกันได้ — ไม่มีรูปสั้นกับรูปยาว
-percent-offกับ--percent-offคือตัวเดียวกัน · แต่--ที่ยืนเดี่ยวคือตัวปิด การ parse และ-ที่ยืนเดี่ยวนับเป็น argument ที่ไม่ใช่ flag - flag ชนิด bool ต้องเขียน
-debug=false— เอกสารของ package เขียนไว้ว่า “You must use the -flag=false form to turn off a boolean flag” - ไม่มี subcommand ให้ — ต้องอ่าน
args[0]เองแล้วเขียนswitchเอง
log ที่เป็นคู่คีย์กับค่า
หัวข้อที่มีชื่อว่า “log ที่เป็นคู่คีย์กับค่า”
log/slogรับคู่คีย์กับค่า ไม่ใช่ message template และslog.LevelVarเปลี่ยนระดับได้ตอนรัน
structured loggingstructured loggingการเขียน log เป็นคู่คีย์กับค่าแทนการต่อข้อความ package `log/slog` คือของมาตรฐานตั้งแต่ Go 1.21 คือการเขียน log ให้เครื่องอ่านได้ก่อน แล้วคนอ่านทีหลัง ไม่ใช่ต่อข้อความเป็นประโยคเดียวแล้วหวังว่าจะมีคน grep เจอ
slog เข้า standard library ตั้งแต่ Go 1.21 · ก่อนหน้านั้นทุกทีมต้องเลือก library
ภายนอกกันเอง ซึ่งเป็นเหตุผลที่ logrus กับ zap ยังอยู่ในหลาย repository จนวันนี้
// สาธิต log/slog: คู่คีย์กับค่า และการเปลี่ยนระดับตอนรันpackage main
import ( "log/slog" "os")
func main() { var lv slog.LevelVar // ค่าเริ่มต้นคือ slog.LevelInfo log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: &lv}))
log.Debug("โหลดออเดอร์", "id", "A1", "lines", 2) // เงียบ เพราะระดับยังเป็น info lv.Set(slog.LevelDebug) log.Debug("โหลดออเดอร์", "id", "A1", "lines", 2) // ออกแล้ว
log = log.With("cmd", "total") // คู่นี้ติดไปกับทุกบรรทัดหลังจากนี้ log.Info("คิดยอดเสร็จ", "id", "A1", "satang", 13950) log.Error("บันทึกสถานะไม่สำเร็จ", "path", "orderctl-state.json", "err", os.ErrPermission)}สิ่งที่ออกมาที่ stderr — ตัดค่า time= ออกเพราะมันเปลี่ยนทุกครั้ง
level=DEBUG msg=โหลดออเดอร์ id=A1 lines=2level=INFO msg=คิดยอดเสร็จ cmd=total id=A1 satang=13950level=ERROR msg=บันทึกสถานะไม่สำเร็จ cmd=total path=orderctl-state.json err="permission denied"บรรทัด Debug ตัวแรกหายไป เพราะตอนนั้น lv ยังเป็น LevelInfo · slog.LevelVar
คือกล่องที่เปลี่ยนค่าได้ตอนรัน และปลอดภัยเมื่อหลาย goroutine อ่านพร้อมกัน
With ไม่ได้แก้ตัวเดิม มันคืน *slog.Logger ตัวใหม่ที่ติดคู่คีย์กับค่านั้นไว้แล้ว
· นี่คือวิธีแนบ cmd หรือ request_id ให้ทุกบรรทัดโดยไม่ต้องพิมพ์ซ้ำ
argument ที่เป็นเลขคี่คือ bug ที่ go vet จับให้
หัวข้อที่มีชื่อว่า “argument ที่เป็นเลขคี่คือ bug ที่ go vet จับให้”slog รับคู่ · ถ้าคุณลืมค่าของคีย์สุดท้าย มันคอมไพล์ผ่าน แต่ผลที่ได้ไม่ใช่สิ่งที่ตั้งใจ
package main
import ( "log/slog" "os")
func main() { log := slog.New(slog.NewTextHandler(os.Stderr, nil)) log.Info("โหลดออเดอร์", "id", "A1", "lines") // argument เป็นเลขคี่}รันแล้วได้ !BADKEY ซึ่งเป็นสิ่งที่ slog ใส่ให้เมื่อมีค่าที่ไม่มีคีย์คู่กัน
$ go build ./... # ผ่าน$ go run .level=INFO msg=โหลดออเดอร์ id=A1 !BADKEY=lines
$ go vet ./...main.go:10:2: call to slog.Logger.Info missing a final valuego build ไม่จับข้อนี้ แต่ go vet จับ และ go test รัน vet ชุดย่อยให้อยู่แล้ว
· นี่คือเหตุผลที่บทที่ 1 ให้ go vet ./... อยู่ในวงจรประจำวัน
stdout ของ orderctl คือผลลัพธ์ที่ผู้ใช้เอาไปต่อท่อ · ถ้า log ปนลงไป
คำสั่งอย่าง orderctl total x.json | jq จะพังทันทีที่คุณเปิด -debug
ในบทนี้ run ส่ง stderr ให้ handler และส่ง stdout ให้ผลลัพธ์ · การแยกสองทางนี้
ไม่ใช่ความสวยงาม มันคือสิ่งที่ทำให้เครื่องมือประกอบกับเครื่องมืออื่นได้
เขียนสถานะทับของเดิมโดยไม่ทำของเดิมพัง
หัวข้อที่มีชื่อว่า “เขียนสถานะทับของเดิมโดยไม่ทำของเดิมพัง”
os.WriteFileล้มกลางทางแล้วเหลือของครึ่งใบ ส่วนCreateTempแล้วRenameไม่เหลือ
orderctl total ต้องจำยอดของแต่ละออเดอร์ไว้ · ที่เก็บคือ JSON หนึ่งชื่อบน disk
และมันจะถูกเขียนทับทุกครั้งที่คำสั่งทำงานสำเร็จ
วิธีที่ทุกคนเขียนก่อนเป็นวิธีนี้ และมันผิด
// ❌ แบบดิบ: os.WriteFile ไม่ใช่การเขียนที่จบในก้าวเดียวpackage main
import ( "encoding/json" "log" "os")
func main() { b, err := json.Marshal(map[string]int64{"A1": 13950}) if err != nil { log.Fatal(err) } // truncate ก่อน แล้วค่อยเขียน · ล้มกลางทางเมื่อไร ของเดิมหายและของใหม่ยังไม่ครบ if err := os.WriteFile("orderctl-state.json", b, 0o644); err != nil { log.Fatal(err) }}เอกสารของ os.WriteFile เตือนเรื่องนี้ด้วยตัวเอง โดยเขียนว่า “a failure mid-operation
can leave the file in a partially written state”
atomic writeatomic file writeการเขียนลงชื่อชั่วคราวในไดเรกทอรีเดียวกันก่อน แล้ว `os.Rename` ทับ เพื่อไม่ให้ล้มกลางทางแล้วเหลือของครึ่งใบ แก้ด้วยการไม่แตะของเดิมเลยจนกว่าของใหม่จะครบ · เขียนลงชื่อชั่วคราวก่อน
แล้วค่อยสลับชื่อในก้าวเดียวด้วย os.Rename
flowchart TD A["CreateTemp<br/>สร้างชื่อชั่วคราว<br/>ในไดเรกทอรีเดียวกับปลายทาง"] --> B["Write<br/>เขียนข้อมูลทั้งก้อน"] B --> C["Close<br/>เช็ก error ตรงนี้"] C --> D["Chmod 0644<br/>CreateTemp ให้สิทธิ์ 0600 มา"] D --> E["Rename<br/>สลับชื่อในก้าวเดียว"] E --> F["ผู้อ่านเห็นของเก่า หรือของใหม่<br/>ไม่มีสถานะครึ่งใบ"] B -. "ล้ม" .-> X["Remove ชื่อชั่วคราว<br/>ของเดิมไม่ถูกแตะเลย"] C -. "ล้ม" .-> X D -. "ล้ม" .-> X
คำบรรยายภาพ: ทางเดินปกติไล่จาก CreateTemp ไป Write ไป Close ไป Chmod แล้วจบที่ Rename · ทุกจุดที่ล้มกลางทางจะกลับไปลบชื่อชั่วคราวทิ้ง โดยไม่แตะของเดิมเลย
ชื่อชั่วคราวต้องอยู่ในไดเรกทอรีเดียวกับปลายทาง · os.Rename ข้ามอุปกรณ์ไม่ได้
การวางไว้ที่ /tmp จึงทำให้มันล้มบนเครื่องที่ /tmp เป็นคนละพาร์ทิชัน
สิ่งที่ Rename รับประกันคือ สิ่งที่ผู้อ่านคนอื่นเห็น เปลี่ยนในก้าวเดียว มันไม่ได้รับประกันว่า
ข้อมูลลง disk แล้ว · ไฟดับตอนนั้นยังทำให้เสียได้ทั้งสองใบ
งานที่ต้องทนไฟดับต้องเรียก
tmp.Sync() ก่อน Close แล้วเปิดไดเรกทอรีปลายทางมา Sync อีกครั้งหลัง Rename
· orderctl ไม่ทำสองข้อนี้ เพราะสิ่งที่มันกันคือคำสั่งที่ล้มกลางทาง ไม่ใช่ไฟดับ
// Package store เก็บสถานะสะสมของ orderctl ลง disk เป็น JSONpackage store
import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath")
// Baht ย่อมาจากบทที่ 2 มาไว้ตรงนี้เพื่อให้ block นี้คอมไพล์ได้เดี่ยวๆ// ของจริงไม่มีสองบรรทัดนี้ · มันใช้ order.Baht ที่ import มาtype Baht int64
type State struct { Totals map[string]Baht `json:"totals"`}
// Load คืนสถานะว่างเมื่อยังไม่มีของเดิม · การรันครั้งแรกไม่ใช่ความผิดพลาดfunc Load(path string) (State, error) { b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { // ห้ามเทียบตรงๆ ด้วย err == fs.ErrNotExist return State{Totals: map[string]Baht{}}, nil } if err != nil { return State{}, fmt.Errorf("read state: %w", err) } var s State if err := json.Unmarshal(b, &s); err != nil { return State{}, fmt.Errorf("parse state %s: %w", path, err) } if s.Totals == nil { // JSON ที่ไม่มีคีย์ totals ให้ nil map มา s.Totals = map[string]Baht{} } return s, nil}
// Save เขียนลงชื่อชั่วคราวก่อน แล้วค่อย rename ทับของเดิมfunc Save(path string, s State) (err error) { b, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("encode state: %w", err) }
tmp, err := os.CreateTemp(filepath.Dir(path), ".orderctl-*.tmp") if err != nil { return fmt.Errorf("create temp: %w", err) } name := tmp.Name() defer func() { // named result ทำให้ deferred function อ่านค่า err ได้ if err != nil { os.Remove(name) // ล้มกลางทาง ต้องไม่ทิ้งขยะไว้ } }()
if _, err = tmp.Write(b); err != nil { tmp.Close() return fmt.Errorf("write temp: %w", err) } if err = tmp.Close(); err != nil { // ของที่เขียนต้องเช็ก error ของ Close return fmt.Errorf("close temp: %w", err) } if err = os.Chmod(name, 0o644); err != nil { // CreateTemp ให้สิทธิ์ 0600 มา return fmt.Errorf("chmod temp: %w", err) } if err = os.Rename(name, path); err != nil { return fmt.Errorf("rename temp over %s: %w", path, err) } return nil}block ข้างบนรวมทุกอย่างไว้ที่เดียวเพื่อให้คัดลอกไปคอมไพล์ได้ทันที
internal/store/store.go
ของจริงไม่ประกาศ Baht เอง มัน import "example.com/orderctl/internal/order" แล้วเขียน
Totals map[string]order.Baht เหมือนที่ internal/pricing ทำมาตั้งแต่บทที่ 4 · ชนิดเงินของโปรเจกต์
มีตัวเดียว ไม่ใช่ตัวละ package
Load ใช้ errors.Is(err, fs.ErrNotExist) ไม่ใช่การเทียบด้วย == · error ที่
os.ReadFile คืนมาเป็น *fs.PathError ที่ห่อ syscall.ENOENT ไว้ข้างใน และ fs.ErrNotExist
ไม่ได้อยู่ในสายนั้นเลยสักชั้น
ที่ทำให้ errors.Is ตอบว่าใช่คือ method Is ของ ENOENT เอง
การเทียบด้วย == จึงไม่มีวันเจอ
สำหรับการอ่าน defer f.Close() ทิ้ง error ได้โดยไม่เสียหาย เพราะไม่มีอะไรค้างรอ flush
สำหรับการเขียนมันเป็น bug จริง · ข้อมูลที่ยังอยู่ใน buffer ถูก flush ตอน Close
ถ้า disk เต็มพอดีตอนนั้น คุณจะได้ error ที่ไม่มีใครอ่าน แล้ว Rename ก็เอาของครึ่งใบไปทับของเต็ม
Save ข้างบนจึงเขียน if err = tmp.Close(); err != nil แทน defer · ส่วน defer ที่มี
เอาไว้ทำงานคนละอย่าง คือลบชื่อชั่วคราวเมื่อสายนี้จบด้วยความผิดพลาด
Save ประกาศผลลัพธ์เป็น (err error) ที่มีชื่อ · นี่คือเหตุผลเดียวที่ deferred function
ข้างในอ่านค่า err สุดท้ายได้ ถ้าไม่ตั้งชื่อ มันจะเห็นแค่ตัวแปรของตัวเอง
ต่อ store เข้ากับ run
หัวข้อที่มีชื่อว่า “ต่อ store เข้ากับ run”
runเลือกเส้นทางด้วยswitchบนargs[0]แล้วบันทึกสถานะเฉพาะเส้นทางที่คิดยอด
package flag ไม่มี subcommand ให้ ส่วนที่เหลือจึงเป็น switch ธรรมดาที่คุณเขียนเอง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์var rules []pricing.Rule // flag หนึ่งตัวกลายเป็นกฎหนึ่งข้อของบทที่ 5if *percent > 0 { rules = append(rules, pricing.PercentOff{Percent: *percent})}
switch args[0] {case "validate": if err := o.Validate(); err != nil { // กฎทั้งชุดจากบทที่ 6 return err } fmt.Fprintf(stdout, "ok %s\n", o.ID) return nilcase "total": sum := pricing.Total(o, rules...) fmt.Fprint(stdout, pricing.Render(o)) // Render รับออเดอร์ตัวเดียว ไม่รับกฎ fmt.Fprintf(stdout, " %-10s %10s\n", "หลังส่วนลด", sum) s, err := store.Load(*statePath) if err != nil { return err } s.Totals[o.ID] = sum return store.Save(*statePath, s)default: return fmt.Errorf("ไม่รู้จักคำสั่ง %q", args[0])}pricing.Render ของบทที่ 4 ไม่เคยรู้จัก Rule เลย บรรทัด TOTAL ของมันคือ o.Subtotal()
ซึ่งเป็นยอดก่อนหักส่วนลดเสมอ · ส่วนลดจึงต้องพิมพ์เป็นบรรทัดของตัวเอง และค่าที่บันทึกลง
สถานะคือ sum ตัวเดียวกับที่พิมพ์ ไม่ใช่ยอดที่ Render แสดง
ประกอบครบแล้วสั่งจริงได้แบบนี้
$ orderctl total -percent-off 10 testdata/order-ok.jsonorder A1 (โต๊ะ 4) drink 35.00 บาท food 120.00 บาท TOTAL 155.00 บาท หลังส่วนลด 139.50 บาท
$ cat orderctl-state.json{ "totals": { "A1": 13950 }}Validate ที่บทที่ 6 เขียนไว้คืน error ตัวเดียวที่รวมทุกข้อที่ผิดด้วย errors.Join
· run ส่งมันขึ้นไปตรงๆ แล้ว main พิมพ์ทั้งก้อนพร้อมคำนำหน้า orderctl:
เส้นทางที่ผิดจึงคืนรหัสออกที่ไม่ใช่ศูนย์เอง โดย run ไม่ต้องรู้จัก os.Exit เลย
$ orderctl validate testdata/order-bad.json ; echo "exit=$?"orderctl: ออเดอร์ไม่ผ่านการตรวจ:field table = "99": ต้องอยู่ระหว่าง 1 ถึง 40field lines = "[]": ต้องมีอย่างน้อยหนึ่งรายการexit=1ทดสอบ CLI ทั้งตัวโดยไม่ต้องรันโปรแกรม
หัวข้อที่มีชื่อว่า “ทดสอบ CLI ทั้งตัวโดยไม่ต้องรันโปรแกรม”
runที่รับทั้ง argument และio.Writerครบ ทำให้เทสต์เรียกมันได้เหมือนฟังก์ชันธรรมดา
t.TempDir() สร้างไดเรกทอรีเปล่าให้หนึ่งชุด แล้วลบทิ้งให้เองเมื่อเทสต์จบ
มันคือคู่หูของ Save และ Load ที่ต้องเขียนลง disk จริง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์func TestSaveLoadRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") // ลบทิ้งให้เองเมื่อเทสต์จบ
s, err := Load(path) // ยังไม่มีของเดิม if err != nil { t.Fatalf("Load() ครั้งแรก = %v, want nil", err) } if len(s.Totals) != 0 { t.Fatalf("Load() = %v, want ว่าง", s.Totals) }
s.Totals["A1"] = 13950 if err := Save(path, s); err != nil { t.Fatalf("Save() = %v", err) }
back, err := Load(path) if err != nil { t.Fatalf("Load() = %v", err) } if back.Totals["A1"] != 13950 { t.Errorf("Totals[A1] = %v, want 13950", back.Totals["A1"]) }}เทสต์ของ CLI ทำแบบเดียวกัน แต่เรียก run ตรงๆ พร้อม bytes.Buffer สองใบ
ไม่มีการ spawn process และไม่มีการอ่านจาก os.Stdout จริงเลยสักครั้ง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์func TestRunTotalWritesState(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "order.json") body := `{"id":"A1","table":4,"lines":[{"sku":"phad-thai","category":"food","qty":2,"unit":6000}]}` if err := os.WriteFile(src, []byte(body), 0o644); err != nil { t.Fatal(err) } state := filepath.Join(dir, "state.json")
var out, errOut bytes.Buffer err := run([]string{"total", "-state", state, src}, &out, &errOut) if err != nil { t.Fatalf("run() = %v (stderr %q)", err, errOut.String()) } if !strings.Contains(out.String(), "120.00 บาท") { t.Errorf("stdout = %q, ต้องมียอด 120.00 บาท", out.String()) } if _, err := os.Stat(state); err != nil { t.Errorf("ไม่พบสถานะที่ %s: %v", state, err) }}เทสต์นี้ตรวจสามอย่างพร้อมกัน คือ การ parse flag, ผลที่พิมพ์ออก stdout และการที่สถานะถูกเขียนลง disk จริง
· มันคือเทสต์ที่ใกล้เคียงกับสิ่งที่ผู้ใช้ทำที่สุด โดยที่ยังรันในหน่วยความจำทั้งหมด
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ไม่ได้เพิ่มความสามารถของโดเมนเลย มันเพิ่มสิ่งที่ทำให้เครื่องมือใช้ได้จริง
main เหลือแค่การแปลง error เป็นรหัสออก · งานจริงอยู่ใน run ที่รับ argument
และรับ io.Writer ครบ จึงถูกเรียกจากเทสต์ได้โดยตรง
การ parse ของ flag หยุดที่ argument ตัวแรกที่ไม่ใช่ flag · ข้อนี้เงียบมาก เพราะมันไม่ทำให้เกิด
ความผิดพลาด มันแค่คิดยอดผิดให้คุณ
slog รับคู่คีย์กับค่า และ go vet จับ argument ที่เป็นเลขคี่ให้ · การเขียนสถานะใช้
CreateTemp แล้ว Rename เพื่อให้ผู้อ่านเห็นของเก่าหรือของใหม่เท่านั้น
บทหน้าเป็นบทปิดคอร์ส — เปิด HTTP ด้วย routing pattern ของ Go 1.22 และประมวลผลหลายออเดอร์
พร้อมกันด้วย goroutine ที่ยกเลิกได้ · แล้วเราจะรัน go test -race เพื่อดูว่า
สิ่งที่ดูถูกต้องบนกระดาษ ถูกจริงหรือเปล่า
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- package flag (เข้าถึง 2026-08-19) — การ parse หยุดที่ argument ตัวแรกที่ไม่ใช่ flag และ “You must use the -flag=false form to turn off a boolean flag”
- os.WriteFile (เข้าถึง 2026-08-19) — คำเตือนว่า “a failure mid-operation can leave the file in a partially written state”
- Go 1.21 Release Notes (2023-08-08) —
log/slogเข้า standard library - Structured Logging with slog (Jonathan Amsterdam, 2023-08-22) — ที่มาของการออกแบบคู่คีย์กับค่า และ
Logger.With - Go 1.24 Release Notes (2025-02-11) —
t.Context,t.Chdirและos.Rootสำหรับงานที่แตะ disk ในเทสต์
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3สั่ง `orderctl total order.json -percent-off 10` แล้วเกิดอะไรขึ้น