บทเรียน 47 · Day 9 · Config

ทำไมต้อง validate และทำไมต้องทำตอน start

~20 นาที · ต้องมี: บทเรียน 45 (caarlos0/env · env.ParseAs) · บทเรียน 46 (.env · godotenv)

บทนี้มาจากสไลด์ Day 9 หน้า 16–18
ประโยคเดียวที่เป็นหัวใจของทั้งบท: env.ParseAs() ผ่าน ไม่เท่ากับ ค่าใช้ได้จริง
บทที่แล้วเราได้เครื่องมือที่อ่าน env เข้ามาใส่ struct ให้อัตโนมัติ แปลง type ให้ เติม default ให้ ซึ่งแก้ปัญหา "ลืมแปลง type" กับ "พิมพ์ชื่อผิด" ไปเรียบร้อย
แต่มันยังปล่อยของเสียอีกชั้นหนึ่งให้ผ่านไปได้ — และของเสียชั้นนั้นไม่มีอะไรจับได้เลย จนกว่าผู้ใช้คนแรกจะกดใช้งาน
ผมจะพิสูจน์ด้วยเซิร์ฟเวอร์จริงสองตัวที่ต่างกันแค่เรื่อง validate แล้วรันด้วยสภาพเดียวกัน — ตัวที่ไม่ validate ผ่าน health check ได้ 200 ทั้งที่ config พังอยู่ · ส่วนตัวที่ validate ตอน start ตายทันทีจนไม่มีอะไร listen พอร์ตเลย

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64        # CPU: Apple M4 (มีผลเฉพาะตัวเลข benchmark หัวข้อ 6.2)
github.com/go-playground/validator/v10  v10.30.3   # พระเอกของบทนี้
github.com/caarlos0/env/v11             v11.4.1    # จากบทเรียน 45
github.com/lib/pq                       v1.10.9    # ใช้เฉพาะหัวข้อ 3 ตอนทำเซิร์ฟเวอร์ตัวอย่าง

ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป · go mod init v10check · แล้ว
go get github.com/go-playground/validator/v10@v10.30.3
go get github.com/caarlos0/env/v11@v11.4.1
go get github.com/lib/pq@v1.10.9

ในบทนี้มีเซิร์ฟเวอร์ตัวอย่างสองตัว ที่หน้าตาเกือบเหมือนกัน อย่าสับสน:
· api47bad — ไม่มี validate tag เลย (หัวข้อ 3 ฝั่งซ้าย)
· api47 — มี validate tag และเรียก Validate() ตอน start (หัวข้อ 3 ฝั่งขวา)

1 · ParseAs ผ่าน ไม่ได้แปลว่าค่าใช้ได้

ทวนจาก บทเรียน 45 สั้น ๆ ก่อน — env.ParseAs[Config]() ทำสามอย่าง: อ่านค่าจาก env ตามชื่อใน tag env:"..." · แปลง string ให้เป็นชนิดของ field (int, bool, time.Duration) · ถ้าไม่มีค่าก็เติมจาก envDefault
ถ้าแปลงไม่สำเร็จมันคืน error ให้ — ซึ่งดีมาก แต่คำถามคือ มันตรวจแค่ไหน

ทายก่อนรัน — นี่คือ config struct ที่ยังไม่มี validate tag เลย มีแค่ env กับ envDefault

type App struct {
    Port            int           `env:"APP_PORT" envDefault:"8080"`
    Env             string        `env:"APP_ENV" envDefault:"development"`
    ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
}

ตัดให้สั้นเหลือก้อนเดียวพอเห็นภาพ — ของจริงที่ผมรันมีอีกสองก้อนอยู่ใน Config เดียวกัน คือ Logging.Level (env:"LOG_LEVEL" envDefault:"info") และ Database.PostgresDSN (env:"POSTGRES_DSN" — ไม่มี envDefault)
นั่นคือที่มาของค่า Log.Level="info" กับ DSN="" ที่จะโผล่ในเฉลย — บรรทัดผลรันย่อชื่อให้สั้นลงเพื่อให้พอบรรทัดเดียว (Logging.Level → Log.Level · Database.PostgresDSN → DSN · App.ShutdownTimeout → Shutdown)

ทีนี้มีคนตั้งค่าห้าแบบนี้มา — ทายว่า env.ParseAs จะฟ้อง error กี่ตัว
1. APP_PORT=99999 (พอร์ตของ TCP มีแค่ 1–65535)
2. POSTGRES_DSN= (ตั้งชื่อไว้ แต่ค่าว่าง)
3. APP_ENV=prodction (ตั้งใจพิมพ์ production แต่ตก u)
4. LOG_LEVEL=BANANA
5. APP_SHUTDOWN_TIMEOUT=0s

ศูนย์ตัว — ผ่านหมดทั้งห้าแบบ ผมรันจริงทีละเคส (โปรแกรมพิมพ์บรรทัด ParseAs ก่อน แล้วค่อยลอง validate ทีหลัง):

### APP_PORT=99999
ParseAs  : OK -> App.Port=99999 App.Env="development" Log.Level="info" Shutdown=10s DSN=""

### POSTGRES_DSN=
ParseAs  : OK -> App.Port=8080 App.Env="development" Log.Level="info" Shutdown=10s DSN=""

### APP_ENV=prodction
ParseAs  : OK -> App.Port=8080 App.Env="prodction" Log.Level="info" Shutdown=10s DSN=""

### LOG_LEVEL=BANANA
ParseAs  : OK -> App.Port=8080 App.Env="development" Log.Level="BANANA" Shutdown=10s DSN=""

### APP_SHUTDOWN_TIMEOUT=0s
ParseAs  : OK -> App.Port=8080 App.Env="development" Log.Level="info" Shutdown=0s DSN=""

ดูค่าที่มันคืนมาให้ดี ๆ: App.Port=99999 · DSN="" · App.Env="prodction" · Log.Level="BANANA" · Shutdown=0s
ทุกค่าเข้าไปนั่งใน struct เรียบร้อย พร้อมให้โปรแกรมหยิบไปใช้ต่อ โดยไม่มีอะไรบอกเลยว่ามีอะไรผิด

เหตุผลที่มันผ่านหมดตรงไปตรงมามาก — ParseAs ถามคำถามเดียว: "แปลงเป็นชนิดนี้ได้ไหม"
"99999" แปลงเป็น int ได้ · "" เป็น string อยู่แล้ว · "prodction" ก็เป็น string · "0s" แปลงเป็น time.Duration ได้
ทุกอันตอบว่า "ได้" มันเลยส่งต่อ ทั้งที่ทุกอันใช้งานจริงไม่ได้สักอัน:

ค่าที่ได้ParseAsความจริงตอนใช้งาน
APP_PORT=99999 ผ่าน พอร์ต TCP มีแค่ 1–65535 — 99999 ไม่มีอยู่จริง
POSTGRES_DSN= ผ่าน ต่อฐานข้อมูลไม่ได้ — ไม่รู้ว่าจะต่อไปที่ไหน
APP_ENV=prodction ผ่าน พิมพ์ผิด — โค้ดที่เขียน if env == "production" จะเข้าเงื่อนไขไม่ติด แล้วเดินสาย dev อยู่บน production เงียบ ๆ
LOG_LEVEL=BANANA ผ่าน ไม่ใช่ level ที่ slog รู้จัก (มีแค่ debug / info / warn / error)
APP_SHUTDOWN_TIMEOUT=0s ผ่าน ตอนปิดเซิร์ฟเวอร์จะตัดการเชื่อมต่อทันที — request ที่ค้างอยู่ตายกลางคัน

🧭 เคสที่ 3 คือเคสที่คนมองข้ามที่สุด — อีกสี่เคสอย่างน้อยยัง "พัง" ให้เห็น แต่ APP_ENV=prodction ไม่พังเลย มันแค่ทำงานผิด
โค้ดที่เขียนว่า "ถ้าเป็น production ให้ปิด debug log · ให้บังคับ HTTPS · ให้ไม่แสดง stack trace ให้ผู้ใช้เห็น" จะไม่เข้าเงื่อนไขสักข้อ แล้ว production ของคุณก็จะรันโหมด dev อยู่หลายเดือนโดยไม่มีใครรู้

⚠️ กับดักแถม — envDefault กับค่าว่าง

เคสที่ 2 ข้างบนได้ DSN="" ก็เพราะ field PostgresDSN ไม่มี envDefault
ถ้ามันมี envDefault ติดอยู่ ผลจะกลับด้านทันที — ผมลองแล้ว:

// field เดียวกัน แต่คราวนี้ติด envDefault ไว้ด้วย
PostgresDSN string `env:"POSTGRES_DSN" envDefault:"postgres://u:p@localhost:5432/db"`

$ env POSTGRES_DSN= ./probe
ParseAs  : OK -> ... DSN="postgres://u:p@localhost:5432/db"
           os.LookupEnv POSTGRES_DSN -> value="" present=true

ตั้งไว้เป็นค่าว่าง = caarlos0/env เมินแล้วใช้ envDefault แทน ทั้งที่ os.LookupEnv ยืนยันว่าคีย์นี้มีอยู่จริง
ต้นเหตุอยู่ใน env.go ของ v11.4.1 ฟังก์ชัน getOr: case exists && value == "" && defExists: return defaultValue, true, true
จำไว้ว่า สำหรับ caarlos0/env "ตั้งเป็นค่าว่าง" กับ "ไม่ได้ตั้งเลย" ให้ผลเหมือนกัน ถ้า field นั้นมี envDefault — ซึ่งต่างจาก godotenv ใน บทเรียน 46 ที่ถือว่า "ค่าว่าง" คือ "มีอยู่แล้ว" จึงไม่เติมให้

2 · คำสองคำที่ต้องแยกให้ออก — syntax กับ semantics

สองคำนี้จะโผล่ตลอดทั้งบท เลยขอนิยามให้ชัดก่อน:

คำแปลว่าคำถามที่มันถามใครตอบให้
syntax (รูปแบบ) ค่านั้นเขียนถูกรูปตามชนิดข้อมูลไหม "แปลงเป็น int ได้ไหม" env.ParseAs
semantics (ความหมาย) ค่านั้นมีความหมายที่ใช้ได้ในบริบทของแอปเราไหม "int ตัวนี้อยู่ในช่วงที่ใช้ได้ไหม" validator

เทียบให้เห็นภาพ: "31 กุมภาพันธ์" เขียนถูกรูปทุกอย่าง — มีเลขวัน มีชื่อเดือน syntax ผ่าน แต่วันนั้นไม่มีอยู่จริงบนโลก semantics ตก
APP_PORT=99999 ก็เรื่องเดียวกันเป๊ะ: เป็นตัวเลขจริง แปลงเป็น int ได้จริง แต่พอร์ตหมายเลขนั้นไม่มีอยู่ในโลกของ TCP

📌 ประโยคที่ต้องจำจากบทนี้
env.ParseAs ตรวจแค่ syntax — "แปลงเป็น int ได้ไหม"
validator ตรวจ semantics — "int ตัวนี้อยู่ในช่วงที่ใช้ได้ไหม"
สองตัวนี้ไม่ได้แทนกัน มันเป็นด่านคนละด่านที่ต้องผ่านทั้งคู่

3 · ทำไมต้องตรวจ "ตอน start" ไม่ใช่ตอนใช้

สมมติว่าเรารู้แล้วว่าต้องตรวจ คำถามถัดมาคือ ตรวจตอนไหน
คนส่วนใหญ่จะตอบว่า "ก็ตรวจตอนจะใช้สิ" — เช่นตอนจะต่อ DB ค่อยเช็คว่า DSN ว่างไหม
ผลของสองทางเลือกนี้ต่างกันมากกว่าที่คิด สไลด์หน้า 16 วางไทม์ไลน์ของวันเดียวกันไว้เทียบกัน:

❌ ไม่ validate

09:00  deploy สำเร็จ
09:00  health check ผ่าน
09:00  pod Running

   ⋮   (เงียบ ๆ ทั้งเช้า)

14:32  user คนแรกกดใช้
14:32  panic
14:32  ลูกค้าเจอ 500

คนที่เจอปัญหาคือลูกค้า · ห่างจากตอน deploy 5 ชั่วโมงครึ่ง — ตอนนั้นไม่มีใครนึกถึง deploy ตอนเช้าแล้ว

✅ validate ตอน start

09:00  deploy
09:00  FATAL: DSN required
09:00  CrashLoopBackOff
       → rollout หยุดเอง
09:01  แก้ Secret แล้ว deploy


ไม่มีใครถูกค้างคาไหนเจอ

คนที่เจอปัญหาคือคนที่เพิ่งกด deploy · ห่างจากตอนทำ ไม่กี่วินาที — เขายังจำได้ว่าเพิ่งไปแก้อะไรมา

🔖 ศัพท์ที่โผล่ในกล่องขวา — CrashLoopBackOff คือสถานะของ Kubernetes ที่แปลว่า "container นี้เริ่มแล้วตาย เริ่มแล้วตาย ซ้ำ ๆ" · ส่วน rollout คือขั้นตอนที่ Kubernetes ค่อย ๆ เปลี่ยนของเก่าเป็นของใหม่ทีละ pod
จุดสำคัญคือ Kubernetes จะหยุด rollout ทันทีที่ pod ใหม่ขึ้นไม่ได้ — แอปที่ตายตั้งแต่ start เลยทำให้ rollout ค้างอยู่ตรงนั้นเอง และpod เก่าที่เหลือก็ยังให้บริการอยู่
(ไม่ใช่ว่ามันไม่แตะของเก่าเลยนะ — RollingUpdate ค่าเริ่มต้นคือ maxUnavailable=25% แปลว่าอาจมีของเก่าถูกเอาลงไปก่อนบางส่วน แล้วค่อยหยุดค้าง)
นี่คือของแถมที่ได้มาฟรี ๆ จากการเลือกตายตอน start

ไทม์ไลน์ข้างบนมาจากสไลด์ ยังเป็นเรื่องเล่า ผมเลยทำของจริงมาให้ดู — เซิร์ฟเวอร์สองตัวที่โค้ดต่างกันแค่เรื่อง validate แล้วรันด้วยสภาพเดียวกันคือลืมตั้ง POSTGRES_DSN

ฝั่งซ้ายของภาพ — เซิร์ฟเวอร์ที่ไม่ validate

ตัวนี้มีแค่ env tag ไม่มี validate tag เลย ปล่อยให้ ParseAs ผ่านแล้วเดินต่อทันที:

// api47bad/main.go — ไฟล์เต็มทั้งไฟล์ ก๊อปไปวางแล้ว go build ผ่าน
package main

import (
    "database/sql"
    "fmt"
    "net/http"
    "os"

    "github.com/caarlos0/env/v11"
    _ "github.com/lib/pq"      // ← ขาดบรรทัดนี้ไม่ได้: driver ชื่อ "postgres" มาจากที่นี่
)

// ไม่มี validate tag เลย — ParseAs ผ่านคือจบ
type Config struct {
    Port        int    `env:"APP_PORT" envDefault:"8080"`
    PostgresDSN string `env:"POSTGRES_DSN"`
}

func main() {
    cfg, err := env.ParseAs[Config]()
    if err != nil { fmt.Fprintln(os.Stderr, "FATAL:", err); os.Exit(1) }
    db, _ := sql.Open("postgres", cfg.PostgresDSN)   // ← DSN ว่างก็ยังผ่าน (บทเรียน 44 หัวข้อ 6)

    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")                          // health check ไม่ได้แตะ DB เลย
    })
    http.HandleFunc("/recipes", func(w http.ResponseWriter, r *http.Request) {
        if err := db.Ping(); err != nil {        // ← จังหวะที่แตะ DB ครั้งแรก
            http.Error(w, "internal error: "+err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprint(w, "[]")
    })
    fmt.Println("server starting on :8080")
    http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), nil)
}
⚠️ บรรทัด _ "github.com/lib/pq" ไม่ใช่ของประดับ

ถ้าลืมบรรทัดนี้ sql.Open("postgres", ...) จะคืน error แล้ว db เป็น nil — และเพราะโค้ดเขียนว่า db, _ := sql.Open(...) (กลืน error ทิ้ง) มันจะไม่มีใครบอกอะไรเลย จนกว่าจะยิง /recipes แล้วเจอ panic แทนที่จะเจอ 500 ตามในบท · ผมลองแล้วได้:
Open err=sql: unknown driver "postgres" (forgotten import?) db==nil? true
panic: runtime error: invalid memory address or nil pointer dereference

รันโดยไม่ตั้ง POSTGRES_DSN แล้วยิงสองปลายทาง:

$ env -u POSTGRES_DSN ./api47bad &
server starting on :8080

--- ps ---
ยังรันอยู่ (deploy สำเร็จ)

--- health check ---
ok http_code=200

--- request จริงของผู้ใช้ ---
internal error: dial tcp [::1]:5432: connect: connection refused
http_code=500
⚠️ อ่านสองบรรทัดกลางอีกรอบ — health check ผ่าน 200

นี่คือจุดที่ไทม์ไลน์ฝั่งซ้ายเกิดขึ้นจริง ๆ: ระบบทุกตัวที่คอยดูแลแอปให้เรา รายงานว่าแอปนี้แข็งแรงดี
Kubernetes เห็น pod เป็น Running · rollout เดินจนจบ · เวอร์ชันเก่าถูกเอาลงไปแล้ว · dashboard เขียว · ไม่มี alert
เพราะ /healthz ตอบแค่ว่า "process ยังอยู่และรับ HTTP ได้" — มันไม่ได้แตะฐานข้อมูลเลย ก็เลยไม่มีทางรู้ว่า DSN ว่าง

แล้วดูข้อความ error ที่ผู้ใช้ได้ตอน 14:32 — dial tcp [::1]:5432: connect: connection refused
ไม่มีคำว่า DSN ไม่มีคำว่า env ไม่มีคำว่า config อยู่ในนั้นเลยสักคำ (เหตุผลอยู่ใน บทเรียน 44 หัวข้อ 6: driver ของ Postgres เจอ DSN ว่างแล้วไปเดา localhost:5432 ให้)
คนที่ถูกเรียกมาแก้ตอนบ่ายจะไปไล่ดู Docker ไล่ดู firewall ไล่ดูว่า Postgres ล่มหรือเปล่า — ทั้งที่ต้นเหตุคือ Secret ที่ลืมใส่ตอนเก้าโมงเช้า

ฝั่งขวาของภาพ — เซิร์ฟเวอร์ที่ validate ตอน start

ตัวนี้ต่างกันแค่สองอย่าง: มี validate tag บน field และเรียก Validate() ก่อนจะทำอะไรทั้งสิ้น

// api47/main.go — ไฟล์เต็มทั้งไฟล์ ก๊อปไปวางแล้ว go build ผ่าน
package main

import (
    "fmt"
    "os"

    "github.com/caarlos0/env/v11"
    "github.com/go-playground/validator/v10"
)

// struct ตัวเดียวกับฝั่งซ้าย เติมมาแค่ validate tag
type Config struct {
    Port        int    `env:"APP_PORT" envDefault:"8080" validate:"gte=1,lte=65535"`
    PostgresDSN string `env:"POSTGRES_DSN" validate:"required"`
}

var validate = validator.New(validator.WithRequiredStructEnabled())

func (c Config) Validate() error { return validate.Struct(c) }

func main() {
    cfg, err := env.ParseAs[Config]()
    if err == nil {
        err = cfg.Validate()          // ← บรรทัดที่เปลี่ยนทุกอย่าง
    }
    if err != nil {
        fmt.Fprintf(os.Stderr, "FATAL load configuration:\n%s\n", err)
        os.Exit(1)               // ← ตายก่อนถึงบรรทัด ListenAndServe
    }
    // ... ต่อ DB / เปิดเซิร์ฟเวอร์ ...
}

รันด้วยสภาพเดียวกันเป๊ะ — ลืมตั้ง POSTGRES_DSN เหมือนกัน:

$ env -u POSTGRES_DSN ./api47 &
FATAL load configuration:
Key: 'Config.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag

--- ps: process ยังอยู่ไหม ---
process ตายไปแล้ว

--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)

--- curl /healthz ---
http_code=000
curl exit=7

EXIT_CODE=1

ผลนี้ยืนยันสามชั้นว่าแอปไม่มีทางหลอกใครได้เลย:
· ps — process ตายแล้วจริง
· lsof — ไม่มีอะไร bind พอร์ต 8080 อยู่ ก็เลยไม่มี "เซิร์ฟเวอร์ที่ดูเหมือนพร้อม"
· curl — ต่อไม่ติดตั้งแต่แรก (exit=7 ของ curl แปลว่า Failed to connect ไม่ใช่ "ได้ response ที่เป็น error") และ http_code=000 คือรหัสที่ curl ใช้บอกว่า ไม่เคยมี HTTP response เลย
· EXIT_CODE=1 — exit code ไม่ใช่ 0 คือสัญญาณที่ Docker / Kubernetes / systemd / CI ทุกตัวเข้าใจตรงกันว่า "อันนี้ล้มเหลว"

🧰 เทียบให้เห็นภาพ — ร้านข้าวแกงที่ยกป้าย "เปิดแล้ว" ทั้งที่แก๊สหมด

ร้านเปิดไฟ กวาดพื้นเสร็จ ยกป้าย "เปิดแล้ว" ออกมาตั้งหน้าร้าน พนักงานยืนรออยู่ครบ
ใครเดินผ่านแล้วชะโงกถามว่า "เปิดไหมครับ" ก็ได้คำตอบว่า "เปิดครับ" — ทุกอย่างดูปกติทั้งเช้า
จนลูกค้าคนแรกสั่งผัดกะเพราตอนบ่าย ถึงได้รู้ว่าถังแก๊สหมดมาตั้งแต่ก่อนยกป้าย

เซิร์ฟเวอร์ฝั่งซ้ายคือร้านนั้นเป๊ะ ๆ:
· ป้าย "เปิดแล้ว" = /healthz ที่ตอบ okhttp_code=200 (ที่เห็นติดกันไม่ใช่พิมพ์ตก — handler ตอบ ok โดยไม่มีขึ้นบรรทัดใหม่ แล้ว curl -w ต่อข้อความของมันชิดเข้าไปเลย · อยากให้แยกบรรทัดให้ใส่ \n นำหน้าใน -w)
· ถังแก๊ส = POSTGRES_DSN ที่ลืมตั้ง
· ลูกค้าสั่งกะเพรา = /recipes ที่เพิ่งไปแตะ db.Ping() เป็นครั้งแรก
ส่วนฝั่งขวาคือเขย่าถังแก๊สดูก่อนยกป้าย — ถังเบา ก็ไม่ยกป้าย ไม่เปิดร้าน จบตั้งแต่เช้า

ที่ไม่เหมือนกัน: ร้านข้าวแกงพอรู้ว่าแก๊สหมดก็เก็บป้ายกลับเข้าร้านได้เอง แต่โปรแกรมที่ start ไปแล้วไม่มีใครสั่งให้มันเก็บป้าย — Kubernetes เห็น process ยังอยู่และ /healthz ยังตอบ ก็นับว่าปกติ แล้วเอาเวอร์ชันเก่าที่ยังทำกับข้าวได้ลงไปแทนให้เรียบร้อยด้วย
อีกจุดที่ต่าง: ร้านข้าวแกงเสียลูกค้าไปทีละคน แต่เซิร์ฟเวอร์ที่ config พังเสียลูกค้าทุกคนพร้อมกัน

🗣 ศัพท์ที่เพิ่งเรียนไป
fail fast
"พังให้เร็วที่สุดเท่าที่จะพังได้" — เจอปัญหาแล้วหยุดทันที ไม่ฝืนเดินต่อ · พูดกับ senior ได้ว่า "ผมอยากให้ config validation มัน fail fast ตอน start เลย ดีกว่าไปตายตอน request แรก"
health check / probe
ปลายทางที่ระบบภายนอกยิงมาถามว่า "ยังไหวอยู่ไหม" เช่น /healthz · พิมพ์ถาม AI ได้ตรง ๆ ว่า "health check ที่ไม่แตะ dependency เลย มันซ่อนปัญหาอะไรได้บ้าง"
exit code
เลขที่โปรแกรมคืนให้ระบบตอนจบ · 0 = สำเร็จ · ไม่ใช่ 0 = ล้มเหลว (ในบทนี้คือ os.Exit(1)) · พูดว่า "ให้มัน exit non-zero ตอน config ไม่ผ่าน CI จะได้จับได้"
fail silently (พังเงียบ)
พังจริงแต่ไม่มีสัญญาณอะไรฟ้องเลย — คือสิ่งที่เกิดกับฝั่งซ้ายทั้งเช้า · พูดว่า "เคสนี้มัน fail silently นะ dashboard เขียวหมดแต่ของข้างในเสียอยู่"

สรุปหัวข้อนี้เป็นประโยคเดียว: การ validate ตอน start ไม่ได้ทำให้ bug หายไป มันแค่ย้ายจังหวะที่ bug โผล่ ให้มาอยู่ใกล้กับจังหวะที่มันถูกสร้างที่สุด
ต้นทุนของ bug ไม่ได้อยู่ที่ตัว bug แต่อยู่ที่ระยะห่างระหว่างตอนที่ทำผิดกับตอนที่รู้ตัว — 5 ชั่วโมงครึ่งกับ 30 วินาที คือคนละราคากันคนละโลก

4 · Validation แบบทั่วไป — เขียน if เอง

ก่อนจะไปหา library มาช่วย เรามาลองทำมือกันก่อน เพราะถ้าไม่เคยเจ็บกับวิธีทำมือ คุณจะไม่รู้ว่า library แก้อะไรให้
สไลด์หน้า 17 ให้โค้ดนี้มา — เป็น method Validate() ของ struct Database ที่เช็คทีละเงื่อนไขด้วย if:

// internal/config/database.go — ไฟล์เต็มทั้งไฟล์ ก๊อปไปวางแล้ว go build ผ่าน
package config

import (
    "errors"
    "fmt"
    "time"
)

type Database struct {
    PostgresDSN     string        `env:"POSTGRES_DSN"`
    MaxOpenConns    int           `env:"POSTGRES_MAX_OPEN_CONNS" envDefault:"25"`
    ConnMaxLifetime time.Duration `env:"POSTGRES_CONN_MAX_LIFETIME" envDefault:"5m"`
}

// ↓ นี่คือ 16 บรรทัดที่หัวข้อ 5 จะย่อให้เหลือ 3
func (database Database) Validate() error {
    if database.PostgresDSN == "" {
        return errors.New("POSTGRES_DSN is required")
    }

    if database.MaxOpenConns < 1 || database.MaxOpenConns > 500 {
        return fmt.Errorf("POSTGRES_MAX_OPEN_CONNS must be 1-500, got %d",
            database.MaxOpenConns)
    }

    if database.ConnMaxLifetime <= 0 {
        return errors.New("POSTGRES_CONN_MAX_LIFETIME must be > 0")
    }

    return nil
}

อย่าเพิ่งมองว่าโค้ดนี้แย่ — มันมีข้อดีจริง ๆ อยู่หลายข้อ และหลายโปรเจกต์ก็ใช้แบบนี้ตลอดชีวิตโดยไม่มีปัญหา:

ข้อดีของการเขียน if เอง
· ตรงไปตรงมา — อ่านแล้วรู้ทันทีว่าเช็คอะไร ไม่ต้องไปเปิดคู่มือว่า tag ตัวนี้แปลว่าอะไร
· ไม่มี dependency — ไม่ต้อง go get อะไรเพิ่ม ไม่ต้องกังวลว่า library จะเลิกดูแล
· debug ง่าย — วาง breakpoint หรือ fmt.Println ตรงไหนก็ได้ เพราะมันคือโค้ดของเราเอง
· เขียน message เองได้ตามใจ — บอกชื่อ env จริง ได้ (POSTGRES_MAX_OPEN_CONNS) ไม่ใช่ชื่อ field ใน Go และใส่ค่าที่ได้มาด้วยก็ได้ (got %d)

ข้อดีข้อสุดท้ายสำคัญกว่าที่คิด และเป็นข้อที่ library จะเอาไปคืนได้ยาก — เดี๋ยวเราจะเห็นในหัวข้อ 5 ว่า error ของ validator พูดถึง Database.PostgresDSN ซึ่งเป็นชื่อ field ใน Go ไม่ใช่ POSTGRES_DSN ที่คนตั้งค่าต้องไปแก้

ทีนี้มาดูฝั่งข้อเสีย ซึ่งจะหนักขึ้นเรื่อย ๆ ตามขนาดโปรเจกต์:

ข้อเสียเมื่อโปรเจกต์โตรายละเอียด
ยาวมาก config 20 field → ราว 80 บรรทัด if — และมันคือ 80 บรรทัดที่ไม่ได้ทำงานอะไรเลยนอกจากบอกว่า "ค่านี้ต้องอยู่ในช่วงไหน"
ซ้ำ logic "อยู่ในช่วง 1–65535" ต้องเขียนซ้ำทุกครั้งที่มี port ตัวใหม่ · แล้วก็มีสักตัวที่เผลอเขียนเป็น 65536
หยุดที่ error แรก return ตัวแรกที่เจอ = เห็นทีละข้อ · ลืมตั้ง 5 ตัว → แก้ 5 รอบ deploy 5 รอบ
หลุดง่าย เพิ่ม field ใหม่เข้า struct แล้วลืมเพิ่ม if — ไม่มีใครเตือน เพราะโค้ดยัง compile ผ่าน
อ่านยาก ต้องไล่อ่านทั้ง function ถึงจะรู้ว่า field ไหนบังคับบ้าง · เปิด struct ดูเฉย ๆ ไม่บอกอะไรเลย

ทายก่อนรัน — ข้อเสียข้อ "หยุดที่ error แรก" ฟังดูเล็กน้อย ลองดูของจริง
ผมส่ง Database ที่ผิดครบทั้งสามที่พร้อมกัน เข้าไปใน Validate() ข้างบน:

old.Database{
    PostgresDSN:     "",                // ← ผิด: ต้องไม่ว่าง
    MaxOpenConns:    9999,              // ← ผิด: เกิน 500
    ConnMaxLifetime: -5 * time.Second,  // ← ผิด: ติดลบ
}.Validate()

ทายว่า error ที่ได้กลับมามีกี่บรรทัด และเขียนว่าอะไรบ้าง?
แล้วถ้าคุณคือคนที่ต้องแก้ config นี้ให้ถูก ต้อง deploy กี่รอบ?

=== if ยาว ๆ : ผิด 3 ที่พร้อมกัน ===
POSTGRES_DSN is required

บรรทัดเดียว — และมันบอกแค่ปัญหาข้อแรกเท่านั้น
อีกสองข้อ (MaxOpenConns=9999 กับ ConnMaxLifetime=-5s) ยังผิดอยู่ครบ แต่ไม่มีใครบอกคุณ เพราะ return ตัวแรกทำให้ function จบไปแล้ว

คำตอบข้อสอง: 3 รอบ — แก้ DSN → deploy → เจอ MaxOpenConns → แก้ → deploy → เจอ ConnMaxLifetime → แก้ → deploy
ถ้าแต่ละรอบใช้เวลา build + push + rollout 8 นาที นี่คือ24 นาทีที่หายไป กับปัญหาที่รู้ได้ครบตั้งแต่รอบแรก
เดี๋ยวหัวข้อหน้าเราจะรันเคสเดียวกันนี้ด้วย validator แล้วเทียบผลกัน

5 · Refactor → go-playground/validator/v10

Refactor แปลว่า เปลี่ยนวิธีเขียนโค้ดโดยที่พฤติกรรมยังเหมือนเดิม — จำคำนี้ไว้ให้ดี เพราะหัวข้อ 6.4 จะพิสูจน์ว่า refactor รอบนี้ทำพฤติกรรมหายไปจริง ๆ หนึ่งข้อ ถ้าเราเผลอ
ตัวที่เราจะใช้คือ github.com/go-playground/validator/v10 — ตัวเดียวกับที่ Gin เรียกใช้อยู่เบื้องหลัง ตอนคุณเขียน tag binding:"required" ใน บทเรียน 39 ต่างกันแค่ชื่อ tag: Gin ตั้งให้อ่านคำว่า binding ส่วนเราจะเรียกเองด้วยชื่อมาตรฐานคือ validate

โค้ดทั้งก้อนหลัง refactor ตามสไลด์หน้า 18 หน้าตาแบบนี้:

// internal/config/database.go — ไฟล์เต็ม · คู่กับ config.go ข้างล่าง (package เดียวกัน) แล้ว go build ผ่าน
package config

import "time"

type Database struct {
    PostgresDSN     string        `env:"POSTGRES_DSN" validate:"required"`
    MaxOpenConns    int           `env:"POSTGRES_MAX_OPEN_CONNS" validate:"gte=1,lte=500"`
    ConnMaxLifetime time.Duration `env:"POSTGRES_CONN_MAX_LIFETIME" validate:"required,gt=0"`
}

func (database Database) Validate() error {
    return validate.Struct(database)   // ได้ error ของ "ทุก field" ที่ผิดพร้อมกัน
}

16 บรรทัดกลายเป็น 3 และที่สำคัญกว่านั้นคือ กฎย้ายจาก function ขึ้นไปอยู่ติดกับ field ที่มันคุมอยู่ — เปิด struct ดูเฉย ๆ ก็รู้ครบแล้วว่า field ไหนบังคับ field ไหนมีช่วงเท่าไร

ตัว validate ที่ถูกเรียกใน method นั้นสร้างไว้ที่เดียวทั้ง package:

// internal/config/config.go — ไฟล์เต็ม · ใช้ validator ตัวเดียวทั้ง package
package config

import "github.com/go-playground/validator/v10"

var validate = validator.New(validator.WithRequiredStructEnabled())

ทำไมต้องเป็น var ระดับ package ไม่ใช่สร้างใหม่ในทุก method — เรื่องนี้สำคัญพอที่จะมีหัวข้อของตัวเอง อยู่ที่ 6.2
ส่วน WithRequiredStructEnabled() คือ option ที่แนะนำให้เปิดไว้ อธิบายที่ 6.6

tag ที่ใช้ในบทนี้

Tagความหมายผลรันจริง
required ห้ามเป็น zero value — คือค่าเริ่มต้นของชนิดนั้น: "" สำหรับ string · 0 สำหรับตัวเลข · nil สำหรับ pointer/slice/map PostgresDSN="" → ตก 'required' tag
gte=1,lte=65535 ช่วงตัวเลข · gte = greater than or equal (≥) · lte = less than or equal (≤) · comma = AND คือต้องผ่านทั้งคู่ Port=99999 → ตก 'lte' tag
gte=1s,lte=30s ใช้กับ time.Duration ได้ ถ้าเขียนหน่วยกำกับ · ทั้งสองฝั่งเป็นแบบ inclusive (1s พอดีผ่าน · 31s ตก) · ดูกับดักที่ 6.3 0s → ตก 'gte' · 31s → ตก 'lte'
oneof=a b c ต้องเป็นหนึ่งในรายการนี้ · คั่นด้วย space เท่านั้น — comma = panic (กับดักที่ 6.1) Env="qa" → ตก 'oneof' tag
oneofci=a b c เหมือน oneof แต่ไม่สนตัวพิมพ์ใหญ่เล็ก (ci = case-insensitive) "PrOd" → ผ่าน · "qa" → ตก 'oneofci' tag
hostname|ip | = OR (ผ่านอันใดอันหนึ่งก็พอ) เทียบกับ , = AND (ต้องผ่านทุกอัน) · ดูกับดักที่ 6.5 "localhost" ผ่าน · "::1" ผ่าน · "not a host!" ตก

ผลลัพธ์จริงเทียบกัน — เคสเดียวกับหัวข้อ 4

ผมเอา Database ที่ผิดครบสามที่ก้อนเดิม ยิงเข้าทั้งสองเวอร์ชันในโปรแกรมเดียวกัน:

=== if ยาว ๆ : ผิด 3 ที่พร้อมกัน ===
POSTGRES_DSN is required

=== validator/v10 : ผิด 3 ที่เดียวกัน ===
Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag
Key: 'Database.MaxOpenConns' Error:Field validation for 'MaxOpenConns' failed on the 'lte' tag
Key: 'Database.ConnMaxLifetime' Error:Field validation for 'ConnMaxLifetime' failed on the 'gt' tag

1 บรรทัด ↔ 3 บรรทัด — นี่คือความต่างที่แปลว่า "deploy 3 รอบ" กับ "deploy รอบเดียว"
สังเกตรูปแบบข้อความให้ดี เพราะคุณจะเห็นมันบ่อยมาก:

Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag
     └──────── ตำแหน่งของ field ────┘                └── ชื่อ field ──┘              └─ tag ที่ตก ─┘

📌 รายละเอียดสองข้อที่ต้องรู้เกี่ยวกับข้อความนี้
1. มันเป็นข้อความก้อนเดียวที่คั่นบรรทัดด้วย \n มาให้แล้วตั้งแต่ต้น — ไม่ได้เกิดจาก errors.Join หรืออะไร แต่เป็นสิ่งที่ตัว error ของ validator (ชนิด validator.ValidationErrors) ทำเองในเมธอด Error() ของมัน
2. มันพูดถึงชื่อ field ใน Go ไม่ใช่ชื่อ env — คนอ่านเห็น PostgresDSN แต่สิ่งที่เขาต้องไปแก้คือ POSTGRES_DSN
นี่คือราคาที่จ่ายให้ความสั้น และเป็นสิ่งที่ บทเรียน 48 จะเอามาแปลงเป็นข้อความที่มนุษย์อ่านรู้เรื่อง

แล้ว struct หลายก้อนล่ะ

config จริงไม่ได้มีแค่ Database — มันมี App มี Logging ด้วย วิธีที่สไลด์ใช้คือให้แต่ละก้อนมี Validate() ของตัวเอง แล้วเอามารวมด้วย errors.Join (ฟังก์ชันของ standard library ที่เอา error หลายตัวมามัดเป็นก้อนเดียว โดยข้าม nil ให้อัตโนมัติ):

func (config Config) Validate() error {
    return errors.Join(
        config.App.Validate(),
        config.Logging.Validate(),
        config.Database.Validate(),
    )
}

ผลรันจริงตอนตั้งค่าผิดสามที่พร้อมกัน คนละ struct กันทั้งสามตัว:

$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= go run ./probe2
=== raw error ===
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag
Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag

$ env POSTGRES_DSN="postgres://u:p@localhost:5432/db" go run ./probe2
config OK

ครบสามก้อน สามบรรทัด รอบเดียว
และเทียบกับตอนที่ Database ยังไม่ได้ refactor (ยังเป็น if อยู่) ผลจะต่างกันที่บรรทัดสุดท้าย:

Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag
POSTGRES_DSN is required          // ← ข้อความจาก errors.New ของเราเอง ไม่ใช่ของ validator

🧭 สังเกตว่าสองแบบผสมกันได้ — errors.Join ไม่สนใจว่าแต่ละ error มาจากไหน
แปลว่าคุณไม่ต้อง refactor ทั้งหมดรวดเดียว จะเปลี่ยนทีละ struct ก็ได้ หรือจะเก็บ if ไว้เฉพาะกฎแปลก ๆ ที่ไม่มี tag รองรับ แล้วใช้ tag กับที่เหลือก็ได้

🧰 เทียบให้เห็นภาพ — ใบสมัครที่มีกฎเขียนไว้ข้างช่องกรอก

แบบเก่า (if ยาว ๆ) เหมือนเจ้าหน้าที่นั่งไล่ตรวจใบสมัครทีละช่อง เจอช่องแรกที่ผิดปุ๊บก็ตีกลับทันที "ช่องชื่อว่างครับ ไปแก้มาใหม่"
คุณเดินกลับไปแก้ชื่อ ต่อคิวใหม่ แล้วเขาถึงบอกว่า "ช่องอายุก็ผิดครับ" — ต่อคิวอีกรอบ
นี่คือ "แก้ทีละข้อ deploy ทีละรอบ" ที่หัวข้อ 4 คำนวณว่าเสียไป 24 นาที

แบบใหม่ (validate tag) เหมือนย้ายกฎไปเขียนไว้ข้างช่องกรอกบนตัวใบสมัครเลย
· validate:"required" = ป้าย "ห้ามเว้นว่าง"
· validate:"gte=1,lte=500" = ป้าย "กรอกได้ 1–500"
แล้วให้เครื่องสแกนทั้งใบรวดเดียว วงแดงทุกช่องที่ผิดพร้อมกัน — ต่อคิวรอบเดียวจบ
ของแถมคือเปิดใบสมัครดูเฉย ๆ ก็รู้กฎครบ ไม่ต้องไปตามอ่านคู่มือในหัวเจ้าหน้าที่

ที่ไม่เหมือนกัน: ป้ายบนใบสมัครถ้าพิมพ์ผิด คนอ่านยังเดาออกว่าหมายถึงอะไร แต่ validate tag เป็นแค่สตริงธรรมดาในสายตา compiler — พิมพ์ผิดแล้ว go build ก็ยังผ่าน ไม่มีใครเตือนสักคำ แล้วค่อยไประเบิดตอนรัน
และเจ้าหน้าที่คนเดิมยังเขียนข้อความตีกลับเองได้ (POSTGRES_MAX_OPEN_CONNS must be 1-500) ส่วนเครื่องสแกนพูดได้แค่ชื่อ field ใน Go — ข้อดีข้อสุดท้ายของหัวข้อ 4 ที่ยังไม่ได้คืนมา

🗣 ศัพท์ที่เพิ่งเรียนไป
struct tag
สตริงที่ห้อยท้าย field ใน struct (ก้อนที่อยู่ในเครื่องหมาย backtick) · library อ่านมันตอนรัน ส่วน compiler ไม่ตรวจความหมายให้ · พูดว่า "กฎของ field นี้อยู่ใน struct tag นะ ไม่ได้เขียนเป็น if"
declarative ↔ imperative
validate:"gte=1,lte=500" คือ declarative — บอกแค่ว่า "ต้องเป็นยังไง" · if x < 1 || x > 500 คือ imperative — บอกว่า "ต้องตรวจยังไง" · ถาม AI ได้ว่า "เคสนี้ควรใช้ declarative validation หรือเขียน if เองดี"
ValidationErrors
ชนิด error ที่ validate.Struct() คืนมา · ข้างในเก็บทุก field ที่ตก ไม่ใช่แค่ตัวแรก · พูดว่า "ผมจะแปลงเป็น validator.ValidationErrors แล้ววนอ่านทีละ field เพื่อทำข้อความเอง" (บทเรียน 48 ทำจริง)
aggregate error
error ก้อนเดียวที่ห่อ error หลายตัวไว้ข้างใน — ในบทนี้คือสิ่งที่ errors.Join ทำให้ · พูดว่า "ให้ Validate() คืน aggregate error จะได้เห็นทุกปัญหาในรอบเดียว"

6 · กับดักหกข้อที่ต้องรู้ก่อนเอาไปใช้จริง

6.1 · oneof คั่นด้วย space — ใส่ comma แล้ว panic

ข้อนี้สไลด์เตือนไว้ และเป็นข้อที่คนพลาดมากที่สุด เพราะทุกที่ในโลกของโปรแกรมคั่นรายการด้วย comma
แต่ใน tag ของ validator comma ถูกจองไว้สำหรับคั่น tag คนละตัว (ที่แปลว่า AND) แล้ว รายการใน oneof เลยต้องใช้ space:

✅ ถูก — คั่นด้วย space

Env string `validate:"oneof=dev staging prod"`

อ่านได้ว่า: tag ชื่อ oneof หนึ่งตัว มีพารามิเตอร์เป็นรายการ 3 คำ

❌ ผิด — คั่นด้วย comma

Env string `validate:"oneof=dev,staging,prod"`

ถูกอ่านว่า: tag ชื่อ oneof=dev + tag ชื่อ staging + tag ชื่อ prod

ผลของฝั่งขวาเมื่อรันจริง:

validator.New() ผ่านแล้ว
before Struct()
panic: Undefined validation function 'staging' on field 'Env'

goroutine 1 [running]:
github.com/go-playground/validator/v10.(*Validate).parseFieldTagsRecursive(...)
	.../validator/v10@v10.30.3/cache.go:311 +0x9e4
github.com/go-playground/validator/v10.(*Validate).extractStructCache(...)
⚠️ จุดที่ panic สำคัญกว่าตัว panic — มันไม่ได้ตายตอน validator.New()

ดูสองบรรทัดแรกของ output อีกรอบ: validator.New() ผ่านแล้ว และ before Struct() ถูกพิมพ์ออกมาแล้วทั้งคู่
แปลว่า panic เกิดตอนเรียก validate.Struct() ครั้งแรก (จังหวะที่ validator ไปแกะ tag ของ struct นั้นมาทำ cache) ไม่ใช่ตอนสร้าง validator
โปรแกรมของคุณผ่าน go build ผ่าน go vet และ start ขึ้นมาได้ปกติ แล้วค่อยไประเบิดตอนไปถึงบรรทัดที่เรียก Struct() ครั้งแรก

และดูข้อความอีกรอบ: Undefined validation function 'staging' on field 'Env'
ไม่มีคำว่า oneof อยู่ในนั้นเลย ไม่มีคำว่า comma ไม่มีคำว่า tag
มือใหม่ที่เจอข้อความนี้จะไปหาว่า "ฟังก์ชัน staging" คืออะไร ทั้งที่ต้นเหตุคือเครื่องหมายจุลภาคสองตัว
ที่ต้องจำ: ถ้าเห็น Undefined validation function '<คำที่คุณพิมพ์เอง>' ให้ไปดู tag ที่มี comma ก่อนเสมอ

6.2 · validator ต้องเป็น singleton

Singleton แปลว่า สร้างขึ้นมาก้อนเดียวแล้วใช้ก้อนนั้นทั้งโปรแกรม
เอกสารของ validator บอกเรื่องนี้ไว้ตรง ๆ ใน doc.go ของ v10.30.3:

// จาก doc.go บรรทัด 10–15
# Singleton

Validator is designed to be thread-safe and used as a singleton instance.
It caches information about your struct and validations,
in essence only parsing your validation tags once per struct type.
Using multiple instances neglects the benefit of caching.

แปลไทย: validator จำผลการแกะ tag ของ struct แต่ละชนิดไว้ เพื่อจะได้แกะแค่ครั้งเดียว ถ้าสร้าง instance ใหม่ทุกครั้ง แคชนั้นก็ถูกทิ้งทุกครั้ง
ฟังดูเป็นเรื่องเล็ก ผมเลยวัดจริงด้วย benchmark เทียบสองแบบ บน Database ก้อนเดียวกับหัวข้อ 5 (ใส่ค่าที่ถูกต้องครบทุก field จะได้ไม่ไปวัดต้นทุนของการสร้าง error):

// bench_test.go — ไฟล์เต็มทั้งไฟล์ ก๊อปไปวางแล้ว go test -bench=. -benchmem รันได้เลย
package v10check

import (
    "testing"
    "time"

    "github.com/go-playground/validator/v10"
)

type Database struct {
    PostgresDSN     string        `validate:"required"`
    MaxOpenConns    int           `validate:"gte=1,lte=500"`
    ConnMaxLifetime time.Duration `validate:"required,gt=0"`
}

// ใส่ค่าที่ถูกต้องครบทุก field จะได้ไม่ไปวัดต้นทุนของการสร้าง error
var good = Database{"postgres://u:p@localhost:5432/db", 25, 5 * time.Minute}

// ทั้งสองตัววน validate.Struct(good) เหมือนกันเป๊ะ ต่างแค่ validator มาจากไหน
var validate = validator.New(validator.WithRequiredStructEnabled())

func BenchmarkSingleton(b *testing.B) {
    for i := 0; i < b.N; i++ { _ = validate.Struct(good) }        // ใช้ตัวเดิมทุกรอบ
}

func BenchmarkNewEachTime(b *testing.B) {
    for i := 0; i < b.N; i++ {
        v := validator.New(validator.WithRequiredStructEnabled())   // สร้างใหม่ทุกรอบ
        _ = v.Struct(good)
    }
}
$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: v10check
cpu: Apple M4
BenchmarkSingleton-10      	 8937212	       120.8 ns/op	      32 B/op	       1 allocs/op
BenchmarkNewEachTime-10    	  131307	      9394 ns/op	   16375 B/op	     227 allocs/op

อ่านตัวเลขนี้: ns/op คือนาโนวินาทีต่อหนึ่งรอบ · allocs/op คือจำนวนครั้งที่ขอหน่วยความจำใหม่ต่อรอบ
ช้าลงประมาณ 78 เท่า (9394 ÷ 120.8) และขอหน่วยความจำ 227 ครั้งต่อรอบ เทียบกับ 1 ครั้ง — ต่างกัน 200 กว่าเท่าในแง่จำนวนครั้งที่ไปกวน memory
เหตุผลไม่ใช่แค่ "ทิ้งแคช" อย่างเดียว — validator.New() ยังต้องคัดลอกตารางรายชื่อ validator สำเร็จรูปทั้งชุดใหม่ทุกครั้งด้วย ซึ่ง v10.30.3 มีอยู่ 181 ตัว (นับจาก map bakedInValidators ใน baked_in.go — หรือให้โปรแกรมนับให้ก็ได้ค่าเดียวกัน)

🧭 ตัวเลขบนเครื่องคุณจะไม่เท่ากับผมเป๊ะ ๆ — ns/op ขึ้นกับ CPU ส่วน B/op กับ allocs/op ขึ้นกับว่า struct มีกี่ field และมีกี่ tag
สิ่งที่จะเหมือนกันทุกเครื่องคือรูปทรงของผล: ฝั่ง singleton จบใน หลักสิบถึงหลักร้อยนาโนวินาที กับ alloc หลักหน่วย · ฝั่งสร้างใหม่ทุกรอบกระโดดไป หลักพันปลาย ๆ ถึงหลักหมื่นนาโนวินาที กับ alloc หลักสองร้อย (ผมลองกับ struct สามแบบ ได้ 212 / 218 / 227 allocs)
(ฝั่ง singleton ได้ 1 alloc/op เพราะ struct ตัวอย่างมี field — ผมรันเทียบสามแบบบน validator v10.30.3 ได้ struct{} ที่เปล่าจริง ๆ = 0 allocs · มี field แต่ไม่มี tag = 1 · มี tag = 1 เท่ากัน แปลว่าตัว alloc มาจาก "มี field ให้เดิน" ไม่ใช่ "มี tag ให้ตรวจ")

🧭 แล้วมันสำคัญจริงไหมสำหรับ config? — ตอบตรง ๆ ว่าสำหรับ config อย่างเดียว ไม่สำคัญเลย เพราะ Load() ถูกเรียกครั้งเดียวตอน start ต่อให้ช้าลง 78 เท่าก็ยังเป็นหลักไมโครวินาที
ที่สำคัญคือตอนคุณเอา validator ตัวเดียวกันนี้ไปใช้ตรวจ request body ของทุก request — ตรงนั้นแหละที่มันวิ่งหลายพันครั้งต่อวินาที
เขียนเป็น var ระดับ package ตั้งแต่ต้นไปเลยง่ายกว่ามาไล่แก้ทีหลัง และมันปลอดภัยเพราะเอกสารรับรองว่า thread-safe (หลาย goroutine เรียกพร้อมกันได้)

6.3 · gte=1 บน time.Duration = 1 นาโนวินาที

ข้อนี้สไลด์ไม่ได้เตือน แต่ผมเจอตอนรันทดสอบ และมันเงียบมาก
สไลด์เขียนบรรทัดเดียวว่า "gte=1,lte=65535 ช่วงตัวเลข ใช้กับ time.Duration ได้ (gte=1s)" — ซึ่งพูดสองเรื่องปนกัน แล้วทำให้คนเผลอเขียน gte=1 บน Duration

// field ชนิด time.Duration ที่ tag ไม่มีหน่วย
T time.Duration `validate:"gte=1"`
--- gte=1 (ไม่มีหน่วย) ---
gte=1 with 1ns   -> <nil>                # ผ่าน
gte=1 with 0s    -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gte' tag
gte=1 with 500ms -> <nil>                # ผ่านฉลุย!

--- gte=1s,lte=30s ---
0s     -> Key: 'B.T' Error:Field validation for 'T' failed on the 'gte' tag
1s     -> <nil>                          # 1s พอดีผ่าน (inclusive)
15s    -> <nil>
31s    -> Key: 'B.T' Error:Field validation for 'T' failed on the 'lte' tag
⚠️ gte=1 ไม่ได้แปลว่า "อย่างน้อย 1 วินาที"

time.Duration ข้างในคือจำนวนนาโนวินาที เก็บเป็น int64
เขียน gte=1 เปล่า ๆ validator เลยเทียบกับเลข 1 ตรง ๆ = 1 นาโนวินาที
ผลคือ 500ms ผ่านสบาย ทั้งที่ถ้าคุณตั้งใจจะเขียน "shutdown timeout อย่างน้อย 1 วินาที" ค่านี้ควรจะตก
กฎง่าย ๆ: บน time.Duration ให้เขียนหน่วยกำกับเสมอ — gte=1s ไม่ใช่ gte=1

6.4 · required บน time.Duration ไม่ได้กันค่าติดลบ

ข้อนี้คือข้อที่ทำให้คำว่า "refactor" ในหัวข้อ 5 ไม่จริง ถ้าเราไม่ระวัง
ย้อนไปดู if ตัวที่สามของโค้ดเดิม:

if database.ConnMaxLifetime <= 0 {                          // ← กันทั้ง 0 และค่าติดลบ
    return errors.New("POSTGRES_CONN_MAX_LIFETIME must be > 0")
}

ถ้าเราแปลบรรทัดนี้เป็น validate:"required" เฉย ๆ ตามที่สไลด์หน้า 18 ทำกับ PostgresDSN เราจะเสียครึ่งหนึ่งของกฎไป เพราะ required แปลว่า "ห้ามเป็น zero value" เท่านั้น — ไม่ได้แปลว่า "ต้องมากกว่า 0"
ผมรันเทียบสามเวอร์ชันบนค่าเดียวกัน:

value    if(<=0)   required   required,gt=0
0s       FAIL      FAIL       FAIL
-5s      FAIL      OK         FAIL
5m0s     OK        OK         OK

-5s / required      -> <nil>
-5s / required,gt=0 -> Key: 'ReqGt.ConnMaxLifetime' Error:Field validation for 'ConnMaxLifetime' failed on the 'gt' tag
⚠️ อ่านแถวกลาง — -5s ผ่าน required ไปได้

-5s ไม่ใช่ zero value (zero value ของ time.Duration คือ 0) required เลยพอใจแล้ว
และค่าติดลบนี้หลุด env.ParseAs มาด้วย เพราะ -5s แปลงเป็น time.Duration ได้สนิท — syntax ผ่านเต็ม ๆ
แปลว่าถ้า refactor แบบนี้ ค่า DB_CONN_MAX_LIFETIME=-5s จะทะลุทั้งสองด่าน ทั้งที่โค้ดเดิมจับได้

ทางแก้คือเติม gt=0 (greater than) เข้าไปด้วย: validate:"required,gt=0" ซึ่งจับได้ทั้งสองเคสตามตาราง
บทเรียนที่ใหญ่กว่านั้น: เวลา refactor validation อย่าดูแค่ว่า "เคสที่เคยตกยังตกอยู่ไหม" ต้องดูด้วยว่า "มีเคสที่เคยตกแล้วตอนนี้ผ่านไหม" — และวิธีเดียวที่รู้ได้คือเขียนเทสต์ครอบไว้ก่อนแตะโค้ด

🧰 เทียบให้เห็นภาพ — ช่อง "ห้ามเว้นว่าง" บนใบสั่งของ

ใบสั่งของมีช่อง จำนวน เขียนกำกับไว้ว่า "ห้ามเว้นว่าง"
คนกรอกใส่เลข −5 ลงไป — ช่องนั้นไม่ว่างแล้ว กฎที่เขียนไว้ก็เลยพอใจ ผ่านฉลุย
แต่ "สั่งของ −5 ชิ้น" ก็ยังเป็นคำสั่งที่ทำตามไม่ได้อยู่ดี

validate:"required" คือป้าย "ห้ามเว้นว่าง" ใบนั้นเป๊ะ ๆ — มันถามคำถามเดียวคือ "ช่องนี้ยังเป็นค่าเริ่มต้นอยู่ไหม" ไม่ได้ถามว่าค่าที่กรอกมาสมเหตุสมผลไหม
อยากกัน −5 ด้วย ต้องแขวนป้ายเพิ่มอีกใบ: validate:"required,gt=0"

ที่ไม่เหมือนกัน: บนใบสั่งของ ถ้าเขียนช่องเวลาไว้ว่า "อย่างน้อย 1" คนอ่านยังเดาหน่วยจากบริบทได้ว่าน่าจะเป็นวัน
แต่ gte=1 บน time.Duration ไม่เดาให้เลย — มันอ่านเป็น 1 นาโนวินาที ตรงตัว ทำให้ 500ms ผ่านสบาย
ผมรันเทียบมาแล้วในรอบนี้: gte=1 กับ 500ms ได้ <nil> (ผ่าน) · gte=1s กับ 500ms ได้ failed on the 'gte' tag (ตก)
บน time.Duration จึงต้องเขียนหน่วยกำกับเสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
zero value
ค่าเริ่มต้นที่ Go ใส่ให้เองเมื่อไม่ได้กำหนดอะไร — 0 สำหรับตัวเลขและ time.Duration · "" สำหรับ string · nil สำหรับ pointer/slice/map · พูดกับ senior ว่า "ระวังนะ required มันเช็คแค่ว่าไม่ใช่ zero value ไม่ได้เช็คว่าค่าใช้ได้"
gt · gte · lt · lte
ย่อจาก greater than · greater than or equal · less than · less than or equal · ชื่อชุดนี้ใช้เหมือนกันทั้งใน validator, MongoDB และ Elasticsearch ไม่ใช่ศัพท์เฉพาะของ Go · พูดว่า "ใส่ gt=0 ไปด้วยสิ ไม่งั้นค่าติดลบหลุด"
regression (พฤติกรรมถอยหลัง)
ของที่เคยทำงานถูก แล้วกลับมาผิดหลังแก้โค้ด — เคส -5s ในหัวข้อนี้คือ regression ที่เกิดจากการ refactor · พูดว่า "ขอเขียนเทสต์ครอบเคสติดลบไว้ก่อน กัน regression ตอน refactor validation"

6.5 · hostname ไม่รับ underscore และไม่รับ host:port

tag hostname|ip ดูสะดวกมาก แต่มันเข้มกว่าที่คิด ผมยิงค่าเก้าแบบเข้าไป:

hostname|ip "localhost"        -> <nil>
hostname|ip "api.example.com"  -> <nil>
hostname|ip "127.0.0.1"        -> <nil>
hostname|ip "::1"              -> <nil>                  # IPv6 ก็ผ่าน
hostname|ip "192.168.1.300"    -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "not a host!"      -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip ""                 -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "db_server"        -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "example.com:5432" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
⚠️ สองบรรทัดล่างคือสองเคสที่คุณจะเจอจริงในคอร์สนี้

· db_server — hostname ไม่รับ underscore ตามมาตรฐาน แต่ชื่อ service ใน docker-compose.yml ตั้งชื่อมี underscore ได้สบาย ถ้าคุณเขียน DB_HOST=db_server ใน .env ตาม compose แล้ว validate ตกทันที
· example.com:5432 — hostname ไม่ใช่ที่สำหรับใส่พอร์ต ถ้า config ของคุณเก็บ host กับ port รวมกันในตัวแปรเดียว tag นี้ใช้ไม่ได้
สังเกตอีกอย่าง: ชื่อ tag ใน error พิมพ์เป็น 'hostname|ip' ทั้งก้อน ไม่ได้แยกว่าตกที่ hostname หรือ ip — ซึ่งก็สมเหตุสมผล เพราะ OR แปลว่าตกทั้งคู่ถึงจะถือว่าตก

6.6 · WithRequiredStructEnabled() เปลี่ยนอะไรกันแน่

option ตัวนี้ทุกตัวอย่างใส่ไว้จนดูเหมือนต้องใส่ แต่มันเปลี่ยนพฤติกรรมเดียวเป๊ะ ๆ — doc comment ในซอร์สของ v10.30.3 (options.go บรรทัด 6–11 · ตัดย่อ) เขียนว่า:

// WithRequiredStructEnabled enables required tag on non-pointer structs to be applied instead of ignored.
//
// ...
//
// It is recommended you enabled this as it will be the default behaviour in v11+

แปลว่า: ถ้าไม่ใส่ option นี้ tag required ที่แปะบน field ซึ่งเป็น struct ธรรมดา (ไม่ใช่ pointer) จะถูกเมินทิ้งเงียบ ๆ
ผลรันเทียบ (zero nested struct คือ struct ลูกที่ทุก field ยังเป็นค่าเริ่มต้น):

plain  / zero nested struct  -> <nil>                     # ไม่ใส่ option = tag ถูกเมิน
opt    / zero nested struct  -> Key: 'Cfg.DB' Error:Field validation for 'DB' failed on the 'required' tag
plain  / filled nested       -> <nil>
opt    / filled nested       -> <nil>

--- pointer nested (*DB), nil ---
plain -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag
opt   -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag

📌 ดูสองบรรทัดล่างสุด — เหมือนกันเป๊ะ
ถ้า config ของคุณใช้ pointer struct (*DB) ตลอด option นี้จะไม่เปลี่ยนอะไรเลย เพราะ nil เป็น zero value ของ pointer อยู่แล้ว required จับได้ทั้งสองแบบ
ที่มันมีอยู่เพราะเหตุผลเรื่องความเข้ากันได้ย้อนหลัง — เอกสารบอกว่าใน v11 ขึ้นไปจะเป็นค่าเริ่มต้น เลยแนะนำให้เปิดไว้ตั้งแต่วันนี้ จะได้ไม่เจอพฤติกรรมเปลี่ยนตอนอัปเกรด

ทดสอบความเข้าใจ

1. APP_PORT=99999 ถูกส่งเข้า struct ที่มี field Port int พร้อม tag env:"APP_PORT" (ยังไม่มี validate tag) แล้วเรียก env.ParseAs — ได้อะไร?

ผลรันจริง: ParseAs : OK -> App.Port=99999 ...
"99999" แปลงเป็น int ได้สนิท — syntax ผ่าน — ParseAs ก็เลยส่งต่อ
ตัวเลือกที่ 1 ผิดเพราะ caarlos0/env ไม่รู้จักความหมายของคำว่า "port" เลย มันเห็นแค่ว่า field นี้เป็น int
ตัวเลือกที่ 2 ผิด — ไม่มี library ตัวไหนแอบตัดค่าให้ (และถ้ามี จะน่ากลัวกว่าเดิมมาก เพราะคุณจะได้พอร์ตที่ไม่ได้ตั้ง)
ตัวเลือกที่ 4 ผิดเพราะ field เป็น int ซึ่งบน 64-bit เก็บได้ถึง 9223372036854775807 (ราว 9.2 × 1018) — 99999 ไม่ล้นอะไรทั้งนั้น
จุดที่ต้องจำ: ค่านี้จะโดนจับก็ต่อเมื่อเราเติม validate:"gte=1,lte=65535" เข้าไปเอง แล้วผลที่ได้คือ failed on the 'lte' tag

2. Database ที่ใช้ if เช็คสามเงื่อนไขเรียงกัน ถูกส่งค่าที่ผิดครบทั้งสามที่พร้อมกัน — Validate() คืน error กี่ตัว?

ผลรันจริงกับ PostgresDSN:"" · MaxOpenConns:9999 · ConnMaxLifetime:-5s:
POSTGRES_DSN is required — บรรทัดเดียว
return ใน Go จบ function ทันที บรรทัดที่เหลือไม่ถูกรันเลย ปัญหาอีกสองข้อจึงยังอยู่ครบแต่ไม่มีใครบอก
ตัวเลือกที่ 2 กับ 3 คือผลของฝั่ง validator.Struct() ต่างหาก — ผมรันเคสเดียวกันได้ครบสามบรรทัด (required · lte · gt) และมันคั่นบรรทัดให้เองโดยไม่ต้องใช้ errors.Join
ตัวเลือกที่ 4 ผิดตามหลักภาษา — โค้ดหลัง return ไม่มีทางรัน
ผลกระทบจริง: 1 บรรทัด = แก้แล้ว deploy แล้วเจอข้อถัดไป 3 รอบ · 3 บรรทัด = รอบเดียวจบ

3. มีคนเขียน Env string `validate:"oneof=dev,staging,prod"` (ใช้ comma) แล้ว build และรันโปรแกรม — เกิดอะไรขึ้น?

ผลรันจริงพิมพ์ validator.New() ผ่านแล้ว และ before Struct() ออกมาก่อน แล้วค่อย panic: Undefined validation function 'staging' on field 'Env'
สาเหตุ: ใน tag ของ validator comma ถูกจองไว้เป็นตัวคั่น tag คนละตัว (AND) ตัวแยก tag เลยอ่าน oneof=dev,staging,prod เป็น สาม tag คือ oneof=dev · staging · prod แล้วหาฟังก์ชันชื่อ staging ไม่เจอ
ตัวเลือกที่ 3 คือตัวที่หลอกที่สุด และเป็นสิ่งที่สไลด์ทำให้เข้าใจผิดได้ถ้าบอกแค่ว่า "จะ panic" — ของจริงคือ panic ตอน Struct() ครั้งแรก (จังหวะที่ validator แกะ tag มาทำแคช) ไม่ใช่ตอนสร้าง instance แปลว่าโปรแกรม start ขึ้นได้ก่อน แล้วค่อยตาย
ตัวเลือกที่ 1 ผิดเพราะ struct tag เป็นแค่สตริงในสายตา compiler — ตรวจอะไรไม่ได้
สิ่งที่ต้องจำ: ข้อความ panic ไม่มีคำว่า oneof เลย — เห็น Undefined validation function เมื่อไร ให้ไปไล่หา comma ใน tag ก่อน

4. Refactor จาก if database.ConnMaxLifetime <= 0 { ... } มาเป็น validate:"required" แล้วมีคนตั้ง DB_CONN_MAX_LIFETIME=-5s — ผลเป็นอย่างไร?

ผลรันจริง — -5s / required -> <nil> (ผ่าน) ส่วน -5s / required,gt=0 -> ... failed on the 'gt' tag (ตก)
required แปลว่า "ห้ามเป็น zero value" เท่านั้น — zero value ของ time.Duration คือ 0 ส่วน -5s ไม่ใช่ 0 มันเลยพอใจแล้ว
ตัวเลือกที่ 3 ผิดด้วย: time.ParseDuration("-5s") คืนค่าสำเร็จ — ค่าติดลบเป็น Duration ที่ถูกต้องตามภาษา ค่านี้จึงหลุดทั้งสองด่าน
ตารางผลรันเต็ม: 0s ตกทั้งสามแบบ · -5s ตกเฉพาะ if เดิมกับ required,gt=0 · 5m0s ผ่านหมด
บทเรียนที่ใหญ่กว่าคำตอบ: คำว่า "refactor" แปลว่าพฤติกรรมต้องเหมือนเดิม — แต่ refactor validation ทำพฤติกรรมหายได้ง่ายมากโดยไม่มีอะไรฟ้อง เพราะโค้ดยัง compile ผ่านและเทสต์ที่ไม่ครอบเคสติดลบก็ยังเขียว

🛠️ ภารกิจ — ทำให้ตัวเองเห็นความต่างระหว่าง "พังตอนบ่าย" กับ "ตายตอนเช้า"

เป้าหมายไม่ใช่ "เขียน validate tag เป็น" — เป้าหมายคือ ทำให้คุณเคยเห็นด้วยตาว่า health check ตอบ 200 ให้แอปที่ config พังได้

  1. ตั้งโปรเจกต์: โฟลเดอร์ใหม่ · go mod init v10check · go get github.com/go-playground/validator/v10@v10.30.3 และ go get github.com/caarlos0/env/v11@v11.4.1
  2. ทำ struct ที่ยังไม่มี validate: สร้าง App ที่มี Port int · Env string · ShutdownTimeout time.Duration พร้อม env/envDefault tag แล้วเรียก env.ParseAs[App]()
    รันด้วย APP_PORT=99999 · APP_ENV=prodction · APP_SHUTDOWN_TIMEOUT=0s
    ยืนยันด้วยตาว่าไม่มี error สักตัว แล้วพิมพ์ค่าที่ได้ออกมาดู
  3. เติม validate tag: ใส่ validate:"required,gte=1,lte=65535" ให้ Port · validate:"required,oneof=development staging production" ให้ Env · validate:"required,gt=0" ให้ ShutdownTimeout
    สร้าง var validate = validator.New(validator.WithRequiredStructEnabled()) ระดับ package แล้วเรียก validate.Struct(cfg)
    รันซ้ำเคสเดิมทั้งสามแบบ แล้วจดชื่อ tag ที่ปรากฏใน error ของแต่ละเคส
  4. พิสูจน์ว่าได้ครบทุก field พร้อมกัน: ตั้งค่าผิดทั้งสามตัวในคำสั่งเดียว
    ต้องได้ error 3 บรรทัด — ถ้าได้บรรทัดเดียว แปลว่าคุณเผลอเขียน if + return อยู่ที่ไหนสักแห่ง
  5. สร้างกับดัก comma: เปลี่ยน tag ของ Env เป็น oneof=development,staging,production ชั่วคราว
    เพิ่ม fmt.Println("new ok") ไว้ หลัง validator.New() แต่ ก่อน validate.Struct()
    ยืนยันด้วยตาว่า new ok ถูกพิมพ์ออกมาก่อน panic — แล้วตอบว่าทำไมเรื่องนี้อันตรายกว่า panic ตอน New()
  6. กับดัก Duration: เปลี่ยน tag ของ ShutdownTimeout เป็น gte=1 (ไม่มีหน่วย) แล้วยิงค่า 500ms เข้าไป
    ทายก่อนว่าจะผ่านหรือตก แล้วค่อยรัน · เสร็จแล้วเปลี่ยนเป็น gte=1s แล้วรันซ้ำ
  7. ทำเซิร์ฟเวอร์สองตัวเทียบกัน (ข้อสำคัญที่สุด): เขียนเซิร์ฟเวอร์ที่มี /healthz ตอบ ok เฉย ๆ และ /recipes ที่เรียก db.Ping() ก่อนตอบ
    ทำสองเวอร์ชัน: เวอร์ชันที่ไม่เรียก Validate() กับเวอร์ชันที่เรียกแล้ว os.Exit(1) ถ้าไม่ผ่าน
    รันทั้งคู่โดยไม่ตั้ง POSTGRES_DSN แล้วยิง curl -w "http_code=%{http_code}\n" ใส่ทั้งสองปลายทาง
  8. ท้าทาย: ลองเช็คด้วย lsof -nP -iTCP:8080 -sTCP:LISTEN ทั้งสองเวอร์ชัน แล้วตอบคำถามนี้: ในสายตาของ Kubernetes ที่ดูแค่ "pod ยัง Running ไหม" กับ "health check ตอบ 200 ไหม" — สองเวอร์ชันนี้ต่างกันตรงไหน และทำไมความต่างนั้นถึงสำคัญ

เวอร์ชันที่ไม่ validate — รันโดยไม่ตั้ง POSTGRES_DSN:

server starting on :8080
--- ps ---
ยังรันอยู่ (deploy สำเร็จ)
--- health check ---
ok http_code=200
--- request จริงของผู้ใช้ ---
internal error: dial tcp [::1]:5432: connect: connection refused
http_code=500

เวอร์ชันที่ validate ตอน start — สภาพเดียวกันเป๊ะ:

FATAL load configuration:
Key: 'Config.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag
--- ps: process ยังอยู่ไหม ---
process ตายไปแล้ว
--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)
--- curl /healthz ---
http_code=000
curl exit=7
EXIT_CODE=1

คำตอบข้อ 8: ในสายตา Kubernetes เวอร์ชันแรกผ่านทุกด่าน — pod เป็น Running · health check ได้ 200 · rollout เดินจนจบ · เวอร์ชันเก่าที่ทำงานได้ถูกเอาลงไปเรียบร้อยแล้ว
เวอร์ชันที่สองไม่มีอะไรผ่านเลยแม้แต่ด่านเดียว pod จะวนเป็น CrashLoopBackOff rollout เลยหยุดค้างไว้เอง และเวอร์ชันเก่ายังให้บริการอยู่ต่อ

ความต่างนั้นสำคัญเพราะ ระบบ deploy ทุกตัวตัดสินใจจากสัญญาณเดียวคือ "process นี้ start สำเร็จไหม"
ถ้าเรายอมให้แอปที่ config พัง start สำเร็จ เราก็กำลังโกหกระบบ deploy ของตัวเอง — แล้วมันก็จะเอาของดีลงไปแทนที่ด้วยของเสียให้เราอย่างเรียบร้อย
การ os.Exit(1) ตอน config ผิด จึงไม่ใช่ "การทำให้แอปเปราะ" แต่คือการบอกความจริงกับเครื่องมือที่คอยดูแลเราอยู่

💬 ข้อ 7 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ — เพราะบรรทัด okhttp_code=200 ที่โผล่บนแอปที่ config พังอยู่ คือภาพที่จะติดตาคุณไปตลอด และมันอธิบายทุกอย่างที่บทนี้พยายามพูดได้ในบรรทัดเดียว
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 กับ validator/v10 v10.30.3 และ env/v11 v11.4.1
เกร็ดที่ต้องระวังเวลาเทียบผล: เครื่องผมไม่มี Postgres รันอยู่ที่พอร์ต 5432 ถ้าเครื่องคุณมี เคส DSN ว่างอาจต่อติดแล้วไปเจอ error เรื่อง auth หรือชื่อ database แทน ซึ่งยิ่งหลอกกว่าเดิม แต่ข้อสรุปยังเหมือนเดิมทุกประการ

อ้างอิง