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

CLI ที่​ใช้ได้​จริง: flag, log และ​สถานะ​บน disk

หกบท​ที่​ผ่าน​มา orderctl มี​ทุก​อย่าง​ที่​โดเมน​ต้องการ — ชนิด​ข้อมูล การ​อ่าน JSON การ​คิด​ส่วนลด และ​การ​ตรวจ​ความ​ถูก​ต้อง​ที่​รายงาน​ทุก​ข้อ​ที่​ผิด

สิ่ง​ที่​มัน​ยัง​ไม่มี​คือ​ทุก​อย่าง​ที่​ทำให้​เครื่อง​มือ​หนึ่ง​ตัว อยู่​รอด​ใน​มือ​คน​อื่น — การ​รับ argument, การ​บอก​ว่า​มัน​กำลัง​ทำ​อะไร​อยู่ และ​การ​จำ​สิ่ง​ที่​ทำ​ไป​แล้ว

📦 orderctl หลัง​จบ​บท​นี้

main เหลือ​หก​บรรทัด งาน​จริง​ย้าย​ไป run(args, stdout, stderr) error ที่​เทสต์​เรียก​ได้​โดย​ไม่​ต้อง spawn process

เพิ่ม flag สาม​ตัว: -state, -percent-off และ -debug · เพิ่ม package internal/store ที่​เขียน​สถานะ​ลง disk แบบ​ที่​ล้ม​กลาง​ทาง​แล้ว​ของ​เดิม​ไม่​พัง

ยัง​เป็น standard library ล้วน​เหมือน​เดิม ไม่มี cobra ไม่มี viper ไม่มี logrus

งาน​จริง​ย้าย​ออก​จาก 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` ข้าม `defer` ทุก​ตัว​โดย​ไม่​บอก​ใคร

เอกสาร​ของ os.Exit เขียน​ไว้​ตรงๆ ว่า​มัน​จบ​โปรแกรม​ทันที และ deferred function จะ​ไม่​ถูก​เรียก

แปล​ว่า defer f.Close() ที่​คุณ​วาง​ไว้​อย่าง​ดี​ใน​บท​ที่ 3 จะ​ไม่​ทำงาน​เลย ถ้า​มี​ใคร​เรียก os.Exit อยู่​ลึก​ลง​ไป​ใน​สาย​การ​เรียก

นี่​คือ​เหตุผล​ที่ os.Exit ควร​อยู่​ที่​เดียว​คือ​ใน main หลัง​จาก​ทุก​อย่าง​คืน​ค่า​กลับ​มา​แล้ว · log.Fatal ก็​เรียก os.Exit เหมือน​กัน มัน​จึง​มี​ข้อ​ห้าม​ข้อ​เดียวกัน

package flag ไม่มี subcommand ให้ แต่​มี FlagSet ให้​สร้าง​เอง​ชุด​ละ​คำ​สั่ง

ถ้า​คุณ​เรียก flag.String ตรงๆ คุณ​กำลัง​เขียน​ลงตัว​แปร​ระดับ package ตัว​เดียว​ที่​ใช้​ร่วม​กัน​ทั้ง​โปรแกรม สอง​คำ​สั่ง​ที่​ต้องการ flag คนละ​ชุด​จึง​ชน​กัน​ทันที

flag.NewFlagSet ให้​ชุด​ของ​ตัว​เอง และ argument ตัว​ที่​สอง​คือ​สิ่ง​ที่​ทำให้​มัน​เทสต์​ได้

ค่าเกิด​อะไร​เมื่อ​แจง​ไม่​ผ่าน
flag.ExitOnErrorพิมพ์​วิธี​ใช้​แล้ว​เรียก os.Exit(2) ทันที — ค่า​นี้​คือ​ค่าที่ flag.CommandLine ใช้
flag.ContinueOnErrorคืน error กลับ​มา​ให้​ผู้​เรียก​ตัดสิน​ใจ
flag.PanicOnErrorpanic พร้อม error นั้น

ContinueOnError เป็น​ค่า​เดียว​ที่​เข้า​กับ run ที่​คืน error · อีก​สอง​ค่า​ทำให้​เทสต์​ตาย​ไป​พร้อม​โปรแกรม

-h และ -help เป็น​กรณี​พิเศษ · Parse คืน flag.ErrHelp กลับ​มา ซึ่ง​แปล​ว่า​ผู้​ใช้​ขอ​ดู​วิธี​ใช้ ไม่ใช่​พิมพ์​ผิด run จึง​คืน nil และ​โปรแกรม​จบ​ด้วย​รหัส 0

$ orderctl total -h
Usage of total:
-debug
เปิด log ระดับ debug
-percent-off int
ส่วนลดเป็นเปอร์เซ็นต์
-state string
ที่เก็บสถานะ (default "orderctl-state.json")

fs.SetOutput(stderr) คือ​เหตุผล​ที่​ข้อความ​ชุด​นี้​ไป​โผล่​ที่ stderr ที่​เทสต์​ส่ง​เข้า​มา ไม่ใช่​ที่ os.Stderr จริง

การ parse ของ flag หยุด​ที่ argument ตัว​แรก​ที่​ไม่ใช่ flag และ​มี​อีก​สาม​ข้อ​ที่​ต่าง​จาก​เครื่อง​มือ​ที่​คุณ​คุ้น

โปรแกรม​นี้​เรียก Parse ห้า​ครั้ง​ด้วย argument คนละ​ชุด แล้ว​พิมพ์​สิ่ง​ที่ flag เห็น พร้อม​สิ่ง​ที่​เหลือ​อยู่​หลัง การ parseหยุด

// สาธิตกฎ การ parseของ flag ที่ต่างจาก argparse และ commander
package 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 ตัว​แรก​ที่​เหลือ

สี่​ข้อ​ที่​ต่าง​จาก argparse, commander และ clap
  • การ 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/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=2
level=INFO msg=คิดยอดเสร็จ cmd=total id=A1 satang=13950
level=ERROR msg=บันทึกสถานะไม่สำเร็จ cmd=total path=orderctl-state.json err="permission denied"

บรรทัด Debug ตัว​แรก​หาย​ไป เพราะ​ตอน​นั้น lv ยัง​เป็น LevelInfo · slog.LevelVar คือ​กล่อง​ที่​เปลี่ยน​ค่า​ได้​ตอน​รัน และ​ปลอดภัย​เมื่อ​หลาย goroutine อ่าน​พร้อม​กัน

With ไม่​ได้​แก้ตัว​เดิม มัน​คืน *slog.Logger ตัว​ใหม่​ที่​ติด​คู่​คีย์​กับ​ค่า​นั้น​ไว้​แล้ว · นี่​คือ​วิธี​แนบ cmd หรือ request_id ให้​ทุก​บรรทัด​โดย​ไม่​ต้อง​พิมพ์​ซ้ำ

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 value

go build ไม่​จับ​ข้อ​นี้ แต่ go vet จับ และ go test รัน vet ชุด​ย่อย​ให้​อยู่​แล้ว · นี่​คือ​เหตุผล​ที่​บท​ที่ 1 ให้ go vet ./... อยู่​ใน​วงจร​ประจำ​วัน

log ไป​ที่ stderr เสมอ ไม่ใช่ stdout

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 เป็น JSON
package 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()` ไม่​พอ​สำหรับ​ของ​ที่ **เขียน**

สำหรับ​การ​อ่าน 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 สุดท้าย​ได้ ถ้า​ไม่​ตั้ง​ชื่อ มัน​จะ​เห็น​แค่​ตัวแปร​ของ​ตัว​เอง

run เลือก​เส้น​ทาง​ด้วย switch บน args[0] แล้ว​บันทึก​สถานะ​เฉพาะ​เส้น​ทาง​ที่​คิด​ยอด

package flag ไม่มี subcommand ให้ ส่วน​ที่​เหลือ​จึง​เป็น switch ธรรมดา​ที่​คุณ​เขียน​เอง

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
var rules []pricing.Rule // flag หนึ่งตัวกลายเป็นกฎหนึ่งข้อของบทที่ 5
if *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 nil
case "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.json
order 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 ถึง 40
field lines = "[]": ต้องมีอย่างน้อยหนึ่งรายการ
exit=1

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` แล้วเกิดอะไรขึ้น