🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน
go version go1.26.5 darwin/arm64
github.com/caarlos0/env/v11 v11.4.1
github.com/joho/godotenv v1.5.1
github.com/go-playground/validator/v10 v10.30.3
gorm.io/gorm v1.31.2
gorm.io/driver/postgres v1.6.2
postgres:16-alpine # ผ่าน colima (Docker 29.6.2)
โปรเจกต์ที่ผมรันชื่อ module wongnok/api · go build ./... และ
go vet ./... ผ่านทั้งก้อน
เกร็ดที่ต้องรู้ก่อนอ่านต่อ: ผมรัน Postgres ที่พอร์ต 55432
ไม่ใช่ 5432 เพราะพอร์ตนั้นบนเครื่องผมมีของอื่นจองอยู่ —
ถ้าคุณเห็นเลขพอร์ตแปลก ๆ ใน DSN ตัวอย่าง นั่นคือสาเหตุ ไม่ใช่กฎอะไร
1 · โครงโปรเจกต์ — โฟลเดอร์ใหม่ที่จะเพิ่ม
สไลด์วางโครงไว้แบบนี้ ส่วนที่เป็นของใหม่ในบทนี้คือ internal/config/ ทั้งโฟลเดอร์:
หลักการเดียวของโฟลเดอร์นี้: 1 ไฟล์ = 1 เรื่อง
ไม่ได้แบ่งตาม "ไฟล์ยาวเกินไปแล้ว" แต่แบ่งตามกลุ่มของค่าที่เปลี่ยนพร้อมกัน —
ค่าที่เกี่ยวกับ log อยู่ใน logging.go ค่าที่เกี่ยวกับฐานข้อมูลอยู่ใน database.go
ผลที่ได้ตอนโปรเจกต์โต: เพิ่ม config กลุ่มใหม่ = เพิ่มไฟล์ใหม่
ไม่ต้องไปแทรกกลางไฟล์เดิมที่คนอื่นกำลังแก้อยู่
วันหนึ่งต้องใช้ Redis ก็สร้าง redis.go แล้วเติมบรรทัดเดียวใน Validate() — จบ
2 · ทำไมต้องเป็นโฟลเดอร์แยก — เหตุผล 4 ข้อ
คำถามที่ควรถามก่อนคือ "ทำไมไม่ประกาศ struct Config ไว้ใน main.go เลย มันก็ที่เดียวเหมือนกัน"
สไลด์ให้เหตุผลไว้สี่ข้อ และมีข้อหนึ่งที่ไม่ใช่เรื่องของวินัย แต่เป็นเรื่องที่ Go บังคับให้เอง:
| เหตุผล | แปลว่าอะไรในทางปฏิบัติ |
|---|---|
| จุดเดียวที่รู้ว่าแอปต้องการ env อะไรบ้าง | คนใหม่เข้าทีมถามว่า "ต้องตั้งอะไรบ้างถึงจะรันได้" → เปิดโฟลเดอร์เดียว อ่าน struct tag ครบ ไม่ต้อง grep os.Getenv ทั้งโปรเจกต์ |
internal/ = คนนอก import ไม่ได้ |
ไม่ใช่ข้อตกลงกันเอง — Go บังคับตอน build ให้เลย ดูผลรันข้างล่าง |
| ส่งเป็น argument ได้ | NewHandler(svc, cfg.Auth) แทนที่จะ os.Getenv ข้างใน handler — handler รับค่าที่ตรวจแล้วมา ไม่ต้องไปหาเอง |
| test ง่าย | config.Database{}.Validate() เรียกได้ตรง ๆ ในเทสต์ — ต้องได้ error เพราะ struct เปล่ายังขาด POSTGRES_DSN |
ข้อ 2 — internal/ ไม่ใช่คำแนะนำ มันเป็นกฎของ compiler
คำว่า module ในที่นี้คือหน่วยที่มีไฟล์ go.mod หนึ่งไฟล์คุมอยู่ —
โปรเจกต์ wongnok/api ของเราคือหนึ่ง module
กฎของ Go คือ: package ที่อยู่ใต้โฟลเดอร์ชื่อ internal จะ import ได้เฉพาะโค้ดที่อยู่ใต้โฟลเดอร์แม่ของ internal นั้น
ผมทดสอบสองทาง — จาก ใน module เดียวกัน กับจาก module อื่น ที่ replace ชี้มาที่โฟลเดอร์เดียวกันเป๊ะ:
===== A. import จากใน module เดียวกัน (cmd/api → internal/config) =====
inside module, import ok: wongnok
exit=0
===== B. import จาก module อื่น =====
package outsider
main.go:6:2: use of internal package wongnok/api/internal/config not allowed
exit=1
อ่านเคส B ให้ดี — มันไม่ใช่คำเตือน ไม่ใช่ go vet ที่บ่นแล้วปล่อยผ่าน
แต่คือ go build ตกด้วย exit code 1 โค้ดนั้น compile ไม่ออกเลย
แปลว่าถ้าวันหนึ่งมีคนเอา repo คุณไปเป็น library แล้วอยากแอบใช้ config ของคุณ —
เขาทำไม่ได้ตั้งแต่แรก คุณจึงแก้ struct Config ได้อย่างอิสระ
โดยไม่ต้องกลัวว่าจะไปพังของใคร
เทียบกับการวางไว้ที่ pkg/config/ ซึ่งใครก็ import ได้: พอมีคนใช้แล้ว การเปลี่ยนชื่อ field
ก็กลายเป็น breaking change ทันที
ร้านกาแฟร้านหนึ่งมีสองโซน: หน้าร้านที่ลูกค้าเดินเข้าได้
กับหลังร้านที่เป็นห้องเก็บของกับครัว
โฟลเดอร์ internal/ คือหลังร้าน — และมันไม่ใช่ป้ายกระดาษเขียนว่า "เฉพาะพนักงาน"
ที่ใครอยากเดินผ่านก็ผ่านได้ แต่เป็นประตูที่ล็อกอยู่จริง
"พนักงาน" ในที่นี้คือโค้ดที่อยู่ใต้โฟลเดอร์แม่ของ internal —
cmd/api/main.go เดินเข้าได้สบาย เพราะอยู่ในร้านเดียวกัน (module เดียวกัน)
ส่วนคนจากร้านอื่น (module อื่น) ดันประตูยังไงก็ไม่เปิด —
ผลรันเคส B ข้างบนคือเสียงประตูที่ไม่ยอมเปิดนั่นเอง
ที่ไม่เหมือนกัน: สองข้อ และทั้งสองข้อคือจุดที่คนเข้าใจผิดบ่อย —
(1) ประตูล็อกของจริงกันไม่ให้คนเห็นของข้างใน แต่ internal/ ไม่ได้กันเรื่องนั้นเลย
ใครเปิด repo ก็อ่านโค้ดได้ครบทุกบรรทัด · มันกันแค่การเขียน import ข้าม module เท่านั้น
(2) มันไม่ใช่เครื่องมือด้านความปลอดภัย — ย้าย .env ไปวางใต้ internal/
ก็ไม่ได้ทำให้ secret ปลอดภัยขึ้นแม้แต่นิดเดียว เรื่องนั้นเป็นหน้าที่ของ .gitignore คนละชั้นกัน
- module
- "ร้านทั้งร้าน" = 1 โปรเจกต์ในสายตา Go · หน่วยที่มีไฟล์
go.modคุมอยู่หนึ่งไฟล์ · ประโยคที่เอาไปพูดได้จริง: "cmd กับ internal อยู่ใน module เดียวกันไหมครับ" - module path
- ชื่อเต็มของ module ที่เขียนไว้บรรทัดแรกของ
go.mod— ของเราคือwongnok/api· พิมพ์ถาม AI ได้เลย: "module path ผมคือ wongnok/api ทำไม import wongnok/api/internal/config จากอีก module ไม่ได้" - internal package
- package ที่อยู่ใต้โฟลเดอร์ชื่อ
internalเป๊ะ ๆ (สะกดอื่นไม่นับ) · พูดว่า "ผมย้าย config ไปเป็น internal package แล้ว ข้างนอก import ไม่ได้" จะสื่อกว่าพูดว่า "ผมย้ายไปโฟลเดอร์ชื่อ internal" - breaking change
- การแก้ที่ทำให้โค้ดของคนที่ใช้ของเราอยู่พัง เช่นเปลี่ยนชื่อ field · ประโยคที่จะได้ยินจาก senior: "อยู่ใต้ internal ก็เปลี่ยนชื่อ field ได้เลย ไม่นับเป็น breaking change"
- compile error (build ตก)
- พังตั้งแต่ตอนแปลโค้ด ยังไม่ทันได้รันสักบรรทัด — ต่างจาก "พังตอน runtime" ที่โปรแกรมเริ่มทำงานไปแล้ว · เคส B ข้างบนคือ compile error และนี่คือเหตุผลที่มันน่าเชื่อถือกว่าข้อตกลงในทีม
3 · anti-pattern vs pattern — env ต้องอ่านที่ไหน
นี่คือหัวใจของทั้งบท และเป็นจุดที่คนพลาดกันมากที่สุดตอนเริ่มใช้ config struct:
มี struct แล้ว แต่ยังแอบเรียก os.Getenv ลึก ๆ ข้างในอยู่ดี
❌ ห้าม — อ่าน env ลึกใน handler
func (h *Handler) Login(c *gin.Context) {
secret := os.Getenv("JWT_SECRET")
// ...
}
ลืมตั้ง env → secret เป็น "" · ไม่มีอะไรเตือน handler ยังตอบ 200 ตามปกติ
✅ ทำแบบนี้ — รับ config ทาง constructor
func NewHandler(svc Service, cfg config.Auth) *Handler {
return &Handler{svc: svc, cfg: cfg}
}
ค่าที่ handler ได้ ผ่าน validate มาแล้ว ตั้งแต่ตอน start · ขาดค่า = แอปไม่ขึ้นตั้งแต่แรก
ฟังดูเป็นเรื่องความสวยงามของโค้ด แต่มันไม่ใช่ ผมเขียนสองแบบให้ยิงจริงด้วย
httptest โดยไม่ตั้ง JWT_SECRET เลย (env -u JWT_SECRET)
แล้วดูว่าแต่ละแบบตอบอะไร:
JWT_SECRET ตอนนี้ = ""
=== แบบห้าม (os.Getenv ใน handler) ===
status : 200
body : signed with secret="" (len=0)
=== แบบทำ (validate ตอน start ก่อนถึง handler) ===
แอปตายตั้งแต่ตอน start:
Key: 'Auth.JWTSecret' Error:Field validation for 'JWTSecret' failed on the 'required' tag
status : 200 คือบรรทัดที่น่ากลัวที่สุดในบทนี้
handler แบบซ้าย ทำงานสำเร็จ — มัน "เซ็น" token ด้วยกุญแจที่มีความยาว 0 แล้วตอบกลับสวยงาม
ไม่มี error ไม่มี log ไม่มีอะไรผิดปกติให้เห็นเลย
token ที่เซ็นด้วยกุญแจว่างคือ token ที่ใครก็ปลอมได้ — และคุณจะรู้ตัวก็ตอนที่มีคนปลอมแล้ว
ส่วนแบบขวา แอปไม่ยอมเริ่มทำงานตั้งแต่แรก ซึ่งน่ารำคาญกว่ามากตอนพัฒนา
แต่เป็นความน่ารำคาญที่คุณเจอตอนบ่ายวันอังคาร ไม่ใช่ตอนตีสามวันเสาร์
📌 หลักการที่จำไปใช้ได้ตลอด: อ่าน env ที่ "ขอบ" ของแอปครั้งเดียว แล้วส่งค่าต่อลงไปเป็น argument
ขอบของแอปคือ main() — จุดเดียวที่รู้จักโลกภายนอก
ทุกอย่างที่ลึกกว่านั้น (handler · service · repository) ไม่ควรรู้ด้วยซ้ำว่ามีสิ่งที่เรียกว่า environment variable อยู่ในโลก
มันควรรู้แค่ว่า "ฉันได้ cfg.Auth มา และค่าในนั้นใช้ได้"
- fail fast
- "ถ้าจะพัง พังตั้งแต่ต้นเลย" — ของไม่พร้อมก็อย่าเพิ่งเปิดร้าน · ประโยคที่เอาไปพูดได้จริง: "ผมอยากให้ config fail fast ตอน start ไม่ใช่ไปพังตอน request แรก"
- dependency injection (DI)
- "ยื่นของที่ต้องใช้ให้ถึงมือ" แทนที่จะปล่อยให้มันเดินไปหาเอง ·
NewHandler(svc, cfg.Auth)คือ DI แบบง่ายที่สุด ไม่ต้องมี framework อะไรเลย · พูดว่า "handler ตัวนี้รับ config ทาง constructor แบบ DI" - constructor
- ฟังก์ชัน
NewXxx()ที่ประกอบของให้พร้อมใช้ก่อนส่งคืน · Go ไม่มี constructor ในตัวภาษาแบบ Java/C# เลยใช้ธรรมเนียมตั้งชื่อขึ้นต้นด้วยNewแทน - silent failure
- พังแบบไม่มีใครรู้ ไม่มี error ไม่มี log · บรรทัด
status : 200ที่เซ็น token ด้วยกุญแจความยาว 0 ข้างบน คือตัวอย่างที่ชัดที่สุดในบทนี้ · พิมพ์ถาม AI ได้: "โค้ดนี้มีจุดไหนเป็น silent failure บ้าง" - boundary / edge ของแอป
- "ขอบ" ที่แอปติดกับโลกภายนอก — env · ไฟล์ · network · ในบทนี้คือ
main()· หลักที่ senior พูดกันบ่อย: "อ่านของจากข้างนอกที่ boundary ครั้งเดียว แล้วส่งค่าต่อเข้าไปข้างใน"
4 · config.go — ตัวประกอบ
ไฟล์นี้ไม่ประกาศ env สักตัว หน้าที่มันมีสองอย่างเท่านั้น: รวม section เข้าด้วยกัน กับ เป็นประตูเดียวที่คนนอกเรียกเข้ามา
// internal/config/config.go
package config
import (
"errors"
"fmt"
"github.com/caarlos0/env/v11"
"github.com/go-playground/validator/v10"
)
// ใช้ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())
type Config struct {
App App
Logging Logging
Database Database
}
func Load() (Config, error) {
cfg, err := env.ParseAs[Config]()
if err != nil {
return Config{}, fmt.Errorf("parse environment: %w", err)
}
// ไม่ wrap ด้วย fmt.Errorf — errors.Join จัดบรรทัดไว้แล้ว
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// รวม error ของ "ทุก section" มาแสดงทีเดียว
// เพิ่ม Redis? สร้าง redis.go + เติม 1 บรรทัดตรงนี้
func (cfg Config) Validate() error {
return errors.Join(
cfg.App.Validate(),
cfg.Logging.Validate(),
cfg.Database.Validate(),
)
}
อ่านลำดับใน Load() ให้ชัด — มันคือสามบรรทัดที่สรุปทั้งสามบทที่แล้ว:
ParseAs → Validate → คืนค่า
และมีรายละเอียดสองอย่างที่ดูเหมือนเรื่องสไตล์ แต่จริง ๆ มีเหตุผลที่วัดได้
รายละเอียดที่ 1 — ทำไม wrap อันบน แต่ไม่ wrap อันล่าง
fmt.Errorf("...: %w", err) คือการ ห่อ error เดิมไว้ข้างใน แล้วเติมข้อความนำหน้า
เพื่อบอกว่ามันเกิดที่ไหน (%w คือช่องที่เก็บ error ตัวเดิมไว้ให้ errors.Is/errors.As
ขุดกลับมาได้)
ส่วน errors.Join(a, b, c) คือการ มัด error หลายตัวเป็นก้อนเดียว
โดยที่ตัวไหนเป็น nil มันข้ามให้ และถ้าเป็น nil หมดก็คืน nil
คำถามคือทำไมอันหนึ่ง wrap อีกอันไม่ wrap ทั้งที่มันดูเป็น error เหมือนกัน
ทายก่อนรัน — errors.Join เอา error สองตัวมามัดกัน แล้วผมพิมพ์ออกมาสองแบบ
joined := errors.Join(errA, errB)
fmt.Println(joined) // A. คืนตรง ๆ
fmt.Println(fmt.Errorf("validate config: %w", joined)) // B. wrap ก่อน
errors.Join คั่น error สองตัวด้วยอะไร?
และพอเอาไป wrap ด้วย fmt.Errorf แล้ว คำว่า validate config: จะไปโผล่ตรงไหน —
ทุกบรรทัด หรือบรรทัดเดียว?
===== A. คืน err ตรง ๆ (ไม่ wrap) =====
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
===== B. wrap ด้วย fmt.Errorf("validate config: %w") =====
validate config: 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
errors.Join คั่นด้วย \n (ขึ้นบรรทัดใหม่) ไม่มีช่องว่าง ไม่มีตัวคั่นอื่น
และไม่มี \n ปิดท้าย — ผมยืนยันด้วยการพิมพ์แบบ %q:
errors.Join(a,b,c) ของสตริง alpha/beta/gamma
ได้ "alpha\nbeta\ngamma" ยาว 16 ตัวอักษรพอดี (5+1+4+1+5)
พอ wrap แล้ว prefix ไปแปะแค่บรรทัดแรกบรรทัดเดียว —
บรรทัดที่สองกลายเป็นบรรทัดลอย ๆ ที่อ่านแล้วไม่รู้ว่าเป็นพวกกับใคร
ถ้ามี error ห้า section ก็จะได้ prefix หนึ่งครั้งกับบรรทัดกำพร้าอีกสี่บรรทัด
นี่คือคำตอบทั้งหมด: ParseAs คืน error ก้อนเดียว
การ wrap จึงช่วยบอกที่มาได้จริง · แต่ Validate คืน error ที่
จัดบรรทัดของตัวเองมาเรียบร้อยแล้ว การ wrap มีแต่จะทำให้รูปแบบเสีย
กฎที่ใช้ได้ทั่วไป: wrap error ที่เป็นก้อนเดียว · อย่า wrap error ที่เป็นหลายบรรทัด
ที่ศูนย์กระจายสินค้า ของถูกตีกลับมาสองแบบ
· fmt.Errorf("...: %w", err) = เอาของใส่กล่องอีกใบ แล้วเขียนหน้ากล่องว่ามาจากไหน
— "ตีกลับจากคลัง A" · แกะกล่องออกมา ของเดิมยังอยู่ครบ ไม่ได้หายไปไหน
· errors.Join(a, b, c) = เอาของสามชิ้นมามัดรวมด้วยเชือกเส้นเดียว
ไม่มีกล่องครอบ วางเรียงกันสามชิ้นอย่างนั้น
ทีนี้ถ้าเอาสติกเกอร์ "ตีกลับจากคลัง A" ไปแปะบนมัดสามชิ้นนั้น
มันแปะติดได้แค่ชิ้นบนสุด — อีกสองชิ้นไม่มีป้าย ใครมาหยิบไปก็ไม่รู้ว่าเป็นพวกกับใคร
นี่คือเหตุผลทั้งหมดที่ Load() wrap อันบน แต่ไม่ wrap อันล่าง
ที่ไม่เหมือนกัน: ที่ศูนย์จริงเราเดินไปแปะสติกเกอร์เพิ่มทีละชิ้นได้
แต่ error ใน Go เวลาพิมพ์ออกมาเป็นข้อความก้อนเดียวที่มี \n คั่นอยู่ข้างใน —
fmt.Errorf ทำได้อย่างเดียวคือต่อข้อความไว้ข้างหน้าสุด
ไม่มีวิธีสั่งให้มันไปแปะทุกบรรทัด
- wrap error
- "ใส่กล่องแล้วเขียนหน้ากล่อง" — เติมข้อความบอกที่มา โดยที่ error เดิมยังอยู่ข้างในครบ · ประโยคที่เอาไปพูดได้จริง: "ตรงนี้ผม wrap error ด้วย %w เพื่อให้รู้ว่าพังตอนอ่าน env"
%wเทียบกับ%v- ข้อความที่พิมพ์ออกมาเหมือนกันทุกตัวอักษร แต่
%wเก็บ error ตัวเดิมไว้ให้ขุดกลับได้ ส่วน%vเก็บแค่ตัวหนังสือ · ผมรันเทียบมาแล้ว: ทั้งคู่พิมพ์ออกมาว่าlayer: base failureเหมือนกัน แต่errors.Isตอบtrueกับตัวที่ใช้%wและตอบfalseกับตัวที่ใช้%v - unwrap / error chain
- "โซ่ของ error ที่ห่อกันเป็นชั้น ๆ" · การแกะกล่องออกทีละชั้นเรียกว่า unwrap · พิมพ์ถาม AI ได้: "ช่วยไล่ error chain ของ error ตัวนี้ให้หน่อย"
errors.Is/errors.As- สองวิธีขุดหาของในโซ่ —
Isถามว่า "ในโซ่มี error ตัวนี้ อยู่ไหม" ·Asถามว่า "ในโซ่มี error ชนิดนี้ ไหม ถ้ามีขอตัวมันมาใช้ด้วย" ·Humanize()ในหัวข้อ 8 ใช้As - multi-error
- error หลายตัวที่ถูกมัดเป็นก้อนเดียว — ในไลบรารีมาตรฐานคือ
errors.Join· พูดว่า "Validate() มันคืน multi-error นะ อย่าไป wrap ทับ"
รายละเอียดที่ 2 — Load() คืน Config ไม่ใช่ *Config
สังเกตว่าตอน error โค้ดเขียน return Config{}, err ไม่ใช่ return nil, err
— เพราะมันคืนค่า (value) ไม่ใช่ตัวชี้ (pointer)
ผมรันเทียบสองเวอร์ชันให้ดูว่ามันต่างกันตรงไหนจริง ๆ:
sizeof(config.Config) = 120 bytes
sizeof(*config.Config) = 8 bytes
[value ] err != nil? true | cfgVal.App.Port = 0 | cfgVal.App.Addr() = ":0"
[ptr ] err != nil? true | cfgPtr == nil? true
[ptr ] panic ตอนอ่าน cfgPtr.App.Port: runtime error: invalid memory address or nil pointer dereference
เวอร์ชัน value ตอน error คืน Config{} ที่ยังเรียกเมธอดต่อได้ —
Addr() ตอบ ":0" ซึ่งอาจไม่มีประโยชน์ แต่มันไม่พัง
เวอร์ชัน pointer คืน nil ถ้าคนเรียกเผลอลืมเช็ค err ก็ panic ทันที
ราคาที่จ่ายคือ copy 120 byte แทน 8 byte ซึ่งเล็กมาก และ Load() ถูกเรียกครั้งเดียวตอน start
แต่เหตุผลที่หนักแน่นกว่านั้นคือข้อสอง — ใครแก้ config หลัง validate ผ่านไปแล้วได้บ้าง
ผมเขียน handler สองตัว ตัวหนึ่งรับ value ตัวหนึ่งรับ pointer แล้วให้ทั้งคู่สั่ง cfg.App.Port = 1 เหมือนกัน:
ก่อนเรียก : Port=8080 Addr=":8080"
หลัง handlerA(value) : Port=8080 Addr=":8080"
หลัง handlerB(pointer) : Port=1 Addr=":1" <-- validate ผ่านไปแล้ว แต่ค่าเปลี่ยนได้
ส่ง value = แต่ละคนได้สำเนาของตัวเอง แก้แล้วไม่กระทบตัวจริง →
ค่าที่ผ่าน validate แล้ว ยัง valid ตลอดอายุโปรแกรม
ส่ง pointer = ใครก็ตามที่ถือตัวชี้อยู่ แก้ค่าที่ผ่านการตรวจแล้วได้ทุกเมื่อ
แล้วการ validate ตอน start ก็หมดความหมายไปเลย
เอกสารใบหนึ่งที่ผ่านการตรวจและประทับตราเรียบร้อยแล้ว ต้องส่งให้อีกสามแผนกใช้งาน ทำได้สองวิธี
· ถ่ายสำเนาแจกคนละใบ (คืน Config เป็น value) —
ใครจะขีดจะเขียนบนสำเนาของตัวเองก็เรื่องของเขา ตัวจริงไม่เปลี่ยน
· ส่งตัวจริงเวียนไปทีละแผนก (คืน *Config) —
แผนกที่สองขีดฆ่าตัวเลขทิ้ง แผนกที่สามก็เห็นรอยขีดนั้นด้วย ทั้งที่ตราประทับยังอยู่ ·
นี่คือบรรทัด Port=1 Addr=":1" ในผลรันข้างบน
แล้วตอนผิดพลาด สองแบบนี้ยื่นของกลับมาไม่เหมือนกันด้วย
· value คืน Config{} = ยื่นแบบฟอร์มเปล่าที่ยังไม่ได้กรอก —
เปิดอ่านได้ทุกช่อง แค่ว่างเปล่า (Addr() จึงตอบ ":0" ออกมาได้โดยไม่พัง)
· pointer คืน nil = ยื่นซองเปล่ามาให้ —
พอเปิดซองหาเอกสารแล้วไม่มีอะไรอยู่ข้างใน โปรแกรมก็ล้มทันที
ที่ไม่เหมือนกัน: สองข้อ —
(1) ถ่ายสำเนาในชีวิตจริงเสียทั้งเวลาและกระดาษ แต่ในโค้ดคือคัดลอก 120 byte
ซึ่งเร็วจนวัดแทบไม่ออก และ Load() ถูกเรียกครั้งเดียวตอน start เท่านั้น
(2) สำเนาที่ Go ทำให้เป็นสำเนาชั้นเดียว — Config ของเรามีแต่
string · int · time.Duration จึงแยกขาดจากกันได้จริง
แต่ถ้าวันหนึ่งเติม field ที่เป็น slice หรือ map เข้าไปเมื่อไหร่
ของข้างในนั้นยังชี้ไปที่เดียวกัน สำเนาจะไม่ได้แยกขาดอีกต่อไป
- value / pass by value
- "ส่งสำเนา" · Go ส่ง argument แบบคัดลอกเสมอ ไม่มีข้อยกเว้น — ที่ pointer แก้ของต้นทางได้ ก็เพราะสิ่งที่ถูกคัดลอกไปคือที่อยู่ ไม่ใช่ตัวของ
- pointer
- "ที่อยู่ของของ" ไม่ใช่ตัวของ · เขียนว่า
*Config· ขนาดของมันคงที่ 8 byte บนเครื่อง 64-bit ไม่ว่า struct ปลายทางจะใหญ่แค่ไหน - zero value
- ค่าเริ่มต้นที่ Go แจกให้เองเมื่อเราไม่ได้กำหนด —
0·""·nil·Config{}คือ zero value ของทั้ง struct · จำคำนี้ไว้ให้ดี เดี๋ยวหัวข้อ 5 จะกลับมาใช้ ตอนอธิบายว่าrequiredเช็คอะไรกันแน่ - nil pointer dereference
- "เปิดซองเปล่าแล้วหาของข้างใน" — สาเหตุ panic อันดับหนึ่งของมือใหม่ ·
เห็นข้อความ
invalid memory address or nil pointer dereferenceเมื่อไหร่ ให้ไปหาว่าตัวแปรตัวไหนเป็นnilตอนถูกเรียกใช้ - panic
- โปรแกรมล้มกลางทางแบบไม่ได้วางแผนไว้ ต่างจากการคืน
errorที่เราตั้งใจให้เกิด · ประโยคที่เอาไปพูดได้จริง: "ตรงนี้ผมอยากให้คืน error ไม่อยากให้ panic" - shallow copy
- "สำเนาชั้นเดียว" — คัดลอกแค่ชั้นบนสุด ของที่ชี้ต่อลงไป (slice · map · pointer) ยังใช้ร่วมกันอยู่ · พิมพ์ถาม AI ได้: "struct ตัวนี้ copy แล้วเป็น shallow copy ตรงไหนบ้าง"
5 · app.go — section แรก
ไฟล์แต่ละ section หน้าตาเหมือนกันหมดสามส่วน: struct + helper + Validate() ของตัวเอง
// internal/config/app.go
package config
import (
"fmt"
"time"
)
type App struct {
Name string `env:"APP_NAME" envDefault:"wongnok" validate:"required"`
Env string `env:"APP_ENV" envDefault:"development" validate:"required,oneof=development staging production"`
Port int `env:"APP_PORT" envDefault:"8080" validate:"gte=1,lte=65535"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s" validate:"required"`
}
func (app App) Addr() string { return fmt.Sprintf(":%d", app.Port) }
func (app App) IsProduction() bool { return app.Env == "production" }
func (app App) Validate() error { return validate.Struct(app) }
| env | default | กฎที่บังคับ | ตกแล้วเห็นอะไร (ผลรันจริง) |
|---|---|---|---|
APP_NAME |
wongnok |
required |
— |
APP_ENV |
development |
oneof=development staging production |
APP_ENV=prodction (พิมพ์ตก i) → Key: 'App.Env' … failed on the 'oneof' tag |
APP_PORT |
8080 |
gte=1,lte=65535 |
APP_PORT=99999 → Key: 'App.Port' … failed on the 'lte' tag |
APP_SHUTDOWN_TIMEOUT |
10s |
required — ห้ามเป็น 0s |
APP_SHUTDOWN_TIMEOUT=0s → Key: 'App.ShutdownTimeout' … failed on the 'required' tag |
ทุกเคสในคอลัมน์ขวาผมรันจริง และทุกเคส env.ParseAs ผ่านหมด —
"99999" เป็น int ได้ · "prodction" เป็น string ได้ ·
"0s" เป็น time.Duration ได้
นี่คือการแบ่งงานที่ชัดเจนระหว่างสองไลบรารี: env ตอบว่า "แปลงเป็นชนิดนั้นได้ไหม" ·
validator ตอบว่า "ค่านั้นใช้ได้จริงไหม"
ทายก่อนรัน — ShutdownTimeout ติด validate:"required" อยู่
และเราเพิ่งเห็นว่ามันจับ 0s ได้
ทีนี้มีคนพิมพ์ผิดเป็นค่าติดลบ:
$ env APP_SHUTDOWN_TIMEOUT=-5s go run ./cmd/probe
ผลจะเป็นอย่างไร? ParseAs พังไหม · validator จับได้ไหม
(คำใบ้: ลองนิยามคำว่า required ให้แม่นก่อน — มันเช็คอะไรกันแน่?)
$ env POSTGRES_DSN="postgres://u:p@localhost:5432/db" APP_SHUTDOWN_TIMEOUT=-5s go run ./cmd/probe
ParseAs : OK -> App.Port=8080 App.Env="development" Log.Level="info" Shutdown=-5s DSN="postgres://u:p@localhost:5432/db"
Validate : OK
ผ่านฉลุยทั้งสองด่าน — ค่าติดลบหลุดเข้าไปในแอปเรียบร้อย
เหตุผล: required ของ validator แปลว่า "ห้ามเป็น zero value" เท่านั้น
ไม่ได้แปลว่า "ต้องมากกว่าศูนย์"
zero value ของ time.Duration คือ 0s — ส่วน -5s ไม่ใช่ zero
มันจึงผ่านกฎ อย่างถูกต้องตามนิยาม
ผมลองเติม gt=0 เข้าไปแล้วรันซ้ำทั้งสี่ค่า:
// validate:"required,gt=0"
-5s -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gt' tag
0s -> Key: 'A.T' Error:Field validation for 'T' failed on the 'required' tag
500ms -> <nil>
10s -> <nil>
คราวนี้ครบ — required รับหน้าที่กัน 0s ส่วน gt=0 กันค่าติดลบ
⚠️ ข้อนี้ต่างจากสไลด์: สไลด์โชว์เคส 0s ว่าโดนจับ ซึ่งถูก
แต่ทำให้เข้าใจไปว่า required คุมค่าเวลาได้ครบ — มันคุมได้ครึ่งเดียว
ของจริงที่ควรเขียนคือ validate:"required,gt=0"
gte=1 เปล่า ๆ บน time.Duration
สไลด์เขียนบรรทัดเดียวรวบว่า "gte=1,lte=65535 — ช่วงตัวเลข ใช้กับ time.Duration ได้ เช่น gte=1s"
ซึ่งปนกันสองเรื่อง ผมทดสอบแยกให้ดู:
// tag: gte=1 (ไม่ใส่หน่วย)
gte=1 with 1ns -> <nil>
gte=1 with 0 -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gte' tag
gte=1 with 500ms -> <nil>
// tag: gte=1s,lte=30s (ใส่หน่วย)
0s -> Key: 'B.T' Error:Field validation for 'T' failed on the 'gte' tag
1s -> <nil>
15s -> <nil>
31s -> Key: 'B.T' Error:Field validation for 'T' failed on the 'lte' tag
gte=1 บน Duration หมายถึง 1 นาโนวินาที ไม่ใช่ 1 วินาที
เพราะข้างในมันเป็น int64 ที่นับเป็นนาโน — 500ms จึงผ่านสบาย ๆ
ถ้าจะคุมเป็นหน่วยเวลา ต้องเขียนหน่วยเสมอ: gte=1s
ข่าวดี: gte/lte เป็นแบบ inclusive ทั้งคู่ — 1s พอดีผ่าน · 31s ตก
6 · logging.go — section ที่สั้นที่สุด
// internal/config/logging.go
package config
// ไฟล์นี้ไม่ต้อง import อะไรเลย — validate มาจาก config.go ที่อยู่ package เดียวกัน
type Logging struct {
Level string `env:"LOG_LEVEL" envDefault:"info" validate:"required,oneofci=debug info warn error"`
Format string `env:"LOG_FORMAT" envDefault:"json" validate:"required,oneofci=json text"`
}
func (logging Logging) Validate() error { return validate.Struct(logging) }
ตัวใหม่ในนี้คือ oneofci — เหมือน oneof ทุกอย่าง
ยกเว้นว่าไม่สนตัวพิมพ์ใหญ่เล็ก (ci = case-insensitive) ผมรันยืนยัน:
---- tag: oneofci=Dev Staging PROD ----
oneofci "dev" -> <nil>
oneofci "DEV" -> <nil>
oneofci "prod" -> <nil>
oneofci "PrOd" -> <nil>
oneofci "qa" -> Key: 'C.Env' Error:Field validation for 'Env' failed on the 'oneofci' tag
เขียนรายการใน tag เป็นตัวพิมพ์อะไรก็ได้ ผู้ใช้จะพิมพ์มาแบบไหนก็ได้ ตราบใดที่ตัวสะกดตรง
เหมาะกับ log level มาก เพราะคนตั้งกันทั้ง debug, DEBUG, Debug
ซึ่งทั้งสามอันหมายถึงสิ่งเดียวกันจริง ๆ
🧭 แล้วทำไม APP_ENV ถึงใช้ oneof ธรรมดา ไม่ใช้ oneofci?
สองบรรทัดนี้อยู่คนละไฟล์แต่ควรเข้าใจคู่กัน — มันไม่ใช่ความไม่สม่ำเสมอ แต่เป็นการเลือกที่มีเหตุผล
· LOG_LEVEL เป็นค่าที่คนพิมพ์ให้มนุษย์อ่าน ผิดพิมพ์ใหญ่เล็กไม่ได้แปลว่าเจตนาเปลี่ยน → ยอมรับหมด
· APP_ENV เป็นค่าที่โค้ดเอาไปเทียบตรง ๆ — ดู IsProduction()
ที่เขียน app.Env == "production" ไว้
ถ้ายอมรับ PRODUCTION เข้ามาได้ IsProduction() จะตอบ false
ทั้งที่กำลังรันอยู่บน production จริง — เข้มไว้ตรงนี้ปลอดภัยกว่า
oneof ห้ามคั่นด้วย comma — และมันไม่พังตรงที่คุณคิด
รายการใน oneof คั่นด้วย ช่องว่าง เท่านั้น
ถ้าเผลอเขียน oneof=dev,staging,prod ตามความเคยชิน ผลคือ:
before Struct()
panic: Undefined validation function 'staging' on field 'Env'
goroutine 1 [running]:
github.com/go-playground/validator/v10.(*Validate).parseFieldTagsRecursive(...)
.../cache.go:311 +0x9e4
github.com/go-playground/validator/v10.(*Validate).extractStructCache(...)
github.com/go-playground/validator/v10.(*Validate).Struct(...)
main.main()
exit status 2
สังเกตบรรทัดแรกให้ดี: before Struct() ถูกพิมพ์ออกมาแล้ว
แปลว่า validator.New() ผ่านฉลุย — โปรแกรมเริ่มทำงานได้ปกติ
แล้วไป panic ตอนเรียก validate.Struct() ครั้งแรก (จังหวะที่มันสร้าง cache ของ struct นั้น)
และข้อความ error ไม่มีคำว่า oneof อยู่เลยสักตัว — มันบอกว่า
"ไม่รู้จัก validator ชื่อ staging" เพราะ comma ถูกอ่านเป็นตัวคั่นระหว่าง tag
staging กับ prod จึงกลายเป็นชื่อกฎที่ไม่มีอยู่จริง
⚠️ จุดนี้สไลด์บอกแค่ว่า "จะ panic" ซึ่งทำให้เข้าใจว่าพังตั้งแต่ตอน init —
ของจริงคือ go build ผ่าน · แอป start ผ่าน · แล้วไปตายตอน request แรก
7 · database.go — ที่ที่ struct tag ไปไม่ถึง
ถ้าอ่านสามไฟล์ที่แล้วแล้วสรุปว่า "ก็แค่แปะ tag ให้ครบ ก็ไม่ต้องเขียน if อีกเลย" —
ไฟล์นี้คือไฟล์ที่มาบอกว่าไม่จริง
ก่อน — เขียน if เองทุกกฎ
// internal/config/database.go — เวอร์ชันก่อน
package config
import (
"errors"
"time"
)
type Database struct {
PostgresDSN string `env:"POSTGRES_DSN"`
MaxOpenConns int `env:"DB_MAX_OPEN_CONNS" envDefault:"25"`
MaxIdleConns int `env:"DB_MAX_IDLE_CONNS" envDefault:"25"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}
func (database Database) Validate() error {
if database.PostgresDSN == "" {
return errors.New("POSTGRES_DSN is required")
}
if database.MaxOpenConns < 1 || database.MaxOpenConns > 500 {
return errors.New("DB_MAX_OPEN_CONNS must be between 1 and 500")
}
if database.ConnMaxLifetime <= 0 {
return errors.New("DB_CONN_MAX_LIFETIME must be greater than 0")
}
return nil
}
ยาว · เห็นทีละ error (return ทันทีที่เจอตัวแรก) · ข้อความเป็นภาษาที่เราเขียนเอง
หลัง — ย้ายกฎขึ้นไปอยู่บน tag
// internal/config/database.go — เวอร์ชันหลัง
package config
import "time"
type Database struct {
PostgresDSN string `env:"POSTGRES_DSN" validate:"required"`
MaxOpenConns int `env:"DB_MAX_OPEN_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
MaxIdleConns int `env:"DB_MAX_IDLE_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m" validate:"required"`
}
func (database Database) Validate() error {
return validate.Struct(database)
}
สั้น · เห็น error ครบทุก field พร้อมกัน · กฎอยู่ติดกับ field ที่มันคุม
ผมสลับไฟล์สองเวอร์ชันแล้วรันด้วย env ตัวเดียวกันเป๊ะ:
$ env DB_CONN_MAX_LIFETIME=-5s go run ./cmd/probe
########## [ก่อน refactor]
ParseAs : OK
Validate : FAILED ->
DB_CONN_MAX_LIFETIME must be greater than 0
########## [หลัง refactor]
ParseAs : OK
Validate : OK <-- ค่าติดลบหลุดไปแล้ว
if เดิมเขียนว่า ConnMaxLifetime <= 0 ซึ่งกันติดลบได้
แต่ validate:"required" กันได้แค่ 0s — เหตุผลเดียวกับหัวข้อ 5
ถ้าจะให้เท่าเดิมจริง ๆ ต้องเขียน validate:"required,gt=0"
บทเรียนที่ใหญ่กว่าเรื่องนี้: เวลา refactor validation อย่าเทียบว่า "โค้ดสั้นลงไหม"
ให้เทียบว่า ค่าชุดเดิมยังให้ผลเดิมไหม — วิธีที่ถูกคือเขียนเทสต์ก่อน refactor
ไม่ใช่หลัง
แล้วความต่างอีกข้อที่ควรรู้: เวอร์ชัน if คืน error ทีละตัว
(เจอ POSTGRES_DSN ว่างก็ return เลย ไม่ดูข้ออื่นต่อ) ·
ส่วน validate.Struct คืนครบทุก field พร้อมกัน ผมยืนยันด้วย struct ที่ผิด 5 field รวดเดียว:
=== err.Error() ===
Key: 'App.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
Key: 'App.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
Key: 'App.Timeout' Error:Field validation for 'Timeout' failed on the 'gte' tag
=== count ===
len: 5
ครบทั้งห้าจริง ๆ — และสังเกตว่าแต่ละบรรทัดถูกคั่นด้วย \n มาให้แล้ว
ตั้งแต่ก่อนถึงมือ errors.Join (validator มี ValidationErrors.Error() ที่ทำเอง)
สิ่งที่ struct tag เขียนไม่ได้ — และเหตุผลที่ Validate() ต้องเป็น method
สไลด์หน้า database.go ยกตัวอย่างกฎที่ไม่มี tag ไหนเขียนได้:
DSN สองอันนี้ต่างกันแค่ท้ายสุด แต่ผลลัพธ์คนละเรื่อง
| DSN | เกิดอะไรขึ้น |
|---|---|
postgres://u:p@db:5432/wongnok |
ok — มีชื่อ database (wongnok) ต่อท้าย |
postgres://u:p@db:5432 |
ต่อติด แต่ไปโผล่ที่ database ผิดตัว — ไม่มี error ให้เห็น |
tag url ที่ validator มีให้ ตรวจแค่ว่า "รูปร่างเป็น URL ไหม" — ทั้งสองบรรทัดผ่านหมด
กฎ "ต้องมีชื่อ database ต่อท้าย host" จึงต้องเขียนเอง และนี่คือคำตอบว่า
ทำไม Validate() ถึงต้องเป็น method ของ struct ไม่ใช่ปล่อยให้ validator ทำคนเดียว:
// เมธอดนี้เอาไปแทน Validate() เดิมในไฟล์ internal/config/database.go
// แทนแล้วต้องเติม import ให้ครบเป็น: "errors" · "fmt" · "net/url" · "strings" · "time"
func (database Database) Validate() error {
// ชั้นที่ 1 — กฎมาตรฐาน ให้ validator ทำ
if err := validate.Struct(database); err != nil {
return err
}
// ชั้นที่ 2 — กฎเฉพาะทางที่ tag เขียนไม่ได้ เขียนเองตรงนี้
u, err := url.Parse(database.PostgresDSN)
if err != nil {
return fmt.Errorf("POSTGRES_DSN: %w", err)
}
if strings.Trim(u.Path, "/") == "" {
return errors.New("POSTGRES_DSN: ไม่มีชื่อ database ต่อท้าย host")
}
return nil
}
โครงสองชั้นนี้คือรูปแบบที่ควรจำ — struct tag ครอบคลุมกฎมาตรฐาน
(required · ช่วงตัวเลข · ค่าที่รู้จัก) ให้สั้นและอ่านง่าย
ส่วนกฎที่รู้เรื่องเฉพาะของโดเมนเรา (DSN ต้องมีชื่อ db · ต้องมี MaxIdle ≤ MaxOpen · ฯลฯ)
เขียนต่อท้ายในเมธอดเดียวกัน
ที่มันนั่งอยู่ใน Validate() ของ section นั้นได้พอดี เพราะ
errors.Join ใน config.go ไม่สนใจว่าข้างในทำอะไร ขอแค่คืน error หรือ nil
📌 เกร็ดที่จะทำให้คุณเสียเวลาน้อยลงหนึ่งชั่วโมง — ถ้าคิดจะใช้ tag hostname|ip
ตรวจ DB_HOST (เครื่องหมาย | คือ "หรือ") ผมรันมาแล้ว 9 ค่า ได้ผลครบทุกค่าแบบนี้:
ผ่าน 6 — localhost · api.example.com · 127.0.0.1 ·
::1 · 10.0.0.1 · my-db.internal
ตก 3 และสองตัวแรกคือที่คนไม่คาดคิด:
db_server (มี underscore) ·
example.com:5432 (มี port ติดมา) ·
"" (ค่าว่าง — กับดักตอนลืมตั้ง DB_HOST)
ถ้า docker-compose.yml ของคุณตั้งชื่อ service ที่มี underscore
หรือ .env เก็บ host พร้อมพอร์ตไว้ในตัวเดียว คุณจะ validate ไม่ผ่านทั้งที่ค่าถูกต้องทุกอย่าง
อีกอย่าง: ชื่อ tag ในข้อความ error พิมพ์ออกมาเป็นก้อนเดียว 'hostname|ip'
ไม่ได้แยกว่าตกข้อไหน
8 · Error หน้าตาเป็นยังไง
ทีนี้มาดูของจริง — ผมตั้ง env ให้ผิด สามที่พร้อมกัน คนละ section
แล้วรัน ./cmd/api:
ทายก่อนรัน — สั่งแบบนี้ (สังเกตว่า POSTGRES_DSN= คือตั้งเป็นค่าว่าง)
$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= go run ./cmd/api
แอปจะบ่นกี่ข้อ? ข้อเดียวแล้วตาย (เจออันแรกก็พอ)
หรือครบสามข้อ (บ่นทีเดียวให้หมด)
และเรียงลำดับยังไง — ตามลำดับที่ผมพิมพ์ใน command line
หรือตามลำดับที่ประกาศไว้ในโค้ด?
(1) ของแถมฟรี — errors.Join จัดบรรทัดให้แล้ว
=== 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
ครบสามข้อในรอบเดียว — ไม่ต้องแก้ทีละข้อแล้วรันใหม่สามรอบ
ลำดับไม่ได้ตามที่พิมพ์ใน command line แต่ตามที่ประกาศไว้ในโค้ด:
App → Logging → Database (ตามลำดับใน errors.Join)
และในแต่ละ section เรียงตามลำดับ field ใน struct
แปลว่าลำดับนี้คงที่ทุกครั้ง — เอาไปเขียนเทสต์เทียบตรง ๆ ได้
อ่านออกอยู่ แต่มี noise เยอะ (Error:Field validation for ซ้ำทุกบรรทัด)
พอใช้ได้สำหรับทีมเล็ก แต่ถ้าอยากให้สวยกว่านี้ก็มีทางเลือก:
(2) หลังใส่ Humanize()
time=... level=ERROR msg="load configuration"
invalid configuration:
- App.Port: ผิดกฎ "lte"
- Logging.Level: ผิดกฎ "oneofci"
- Database.PostgresDSN: ผิดกฎ "required"
time=... level=ERROR msg=fatal error="load configuration: Key: 'App.Port' ..."
exit status 1
สั้นลงเยอะ และบอกครบว่า field ไหน · ผิดกฎอะไร
บรรทัด time=... level=ERROR สองบรรทัดมาจาก slog TextHandler ใน
main.go (หัวข้อ 9) — บรรทัดบนคือ bootLogger.Error("load configuration")
บรรทัดล่างคือ slog.Error("fatal", "error", err) ที่พิมพ์ error ดิบไว้ให้ระบบเก็บ log
ส่วนสี่บรรทัดตรงกลางคือของที่ Humanize() พิมพ์ลง os.Stderr ให้ คน อ่าน
หัวใจของมันคือสอง method ของ FieldError:
fe.Namespace() ให้ "App.Port" · fe.Tag() ให้ "lte"
ตัว Humanize() ที่ผมใช้เขียนสั้น ๆ แบบนี้ (อยู่ในไฟล์ humanize.go) —
มันคือการแกะกล่องสองชั้น: ชั้นนอกคือก้อนที่ errors.Join มัดไว้
ชั้นในคือ ValidationErrors ของแต่ละ section
// internal/config/humanize.go
package config
import (
"errors"
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
func Humanize(err error) string {
if err == nil {
return ""
}
var lines []string
// ชั้นนอก: errors.Join คืน error ที่มีเมธอด Unwrap() []error
var joined interface{ Unwrap() []error }
errs := []error{err}
if errors.As(err, &joined) {
errs = joined.Unwrap()
}
for _, e := range errs {
// ชั้นใน: แต่ละ section คืน validator.ValidationErrors
var ve validator.ValidationErrors
if errors.As(e, &ve) {
for _, fe := range ve {
lines = append(lines, fmt.Sprintf(" - %s: ผิดกฎ %q", fe.Namespace(), fe.Tag()))
}
continue
}
lines = append(lines, " - "+e.Error())
}
return "invalid configuration:\n" + strings.Join(lines, "\n")
}
errors.As(err, &ve) คือ "ขุดลงไปในกล่อง error ดูว่ามีตัวที่เป็นชนิดนี้ไหม ถ้ามีเอามาใส่ ve"
— ผมยืนยันว่ามันขุดทะลุ errors.Join ลงไปเจอ ValidationErrors ครบทุกตัวจริง:
errors.As -> true len: 4
errors.As through Join -> true 4
และแต่ละ FieldError ที่ได้มามีข้อมูลมากกว่าที่เราใช้
(ก้อนนี้ผมพิมพ์ช่อง Value ด้วย %#v — จำไว้ก่อน เดี๋ยวมันจะสำคัญ):
Namespace="Cfg.Name" Field="Name" Tag="required" Param="" Kind=string Type=string Value=""
Namespace="Cfg.Env" Field="Env" Tag="oneof" Param="dev prod" Kind=string Type=string Value="qa"
Namespace="Cfg.Timeout" Field="Timeout" Tag="gte" Param="1s" Kind=int64 Type=time.Duration Value=100000000
Namespace="Cfg.DB.Port" Field="Port" Tag="lte" Param="65535" Kind=int Type=int Value=99999
สองอย่างที่น่าสังเกต:
· Namespace ของ field ที่ซ้อนอยู่ใน struct ลูกเป็น "Cfg.DB.Port" —
validator ไล่ลงไปใน nested struct ให้เอง โดยไม่ต้องแปะ tag อะไรที่ field DB เลย
· Param() คืนพารามิเตอร์ของกฎเป็นสตริง เช่น "dev prod" หรือ "65535" —
เอาไปเขียนข้อความว่า "ต้องเป็นหนึ่งใน dev prod" ได้เลย
1. Value=100000000 บรรทัดที่สาม เป็นผลของ %#v ไม่ใช่นิสัยของ time.Duration —
Value() มี static type เป็น interface{} ก็จริง แต่ค่าข้างในยังเป็น
time.Duration เต็มตัว ซึ่งมี String() ติดมาด้วย
ดังนั้นวิธีพิมพ์เปลี่ยนคำตอบ — ผมรัน fe.Value() ตัวเดียวกันสี่แบบ:
%T → time.Duration ·
%v → 100ms ·
%s → 100ms ·
%d และ %#v → 100000000
แปลว่าถ้าเอาไปโชว์ผู้ใช้ด้วย %v ปกติ ได้ 100ms อ่านออกสบาย —
จะได้ตัวเลขนาโนวินาทีก็ต่อเมื่อเจาะจงใช้ %d หรือ %#v อย่างในกล่องข้างบนเท่านั้น
ที่ต้องระวังจริง ๆ คือตอนเอาไปคำนวณต่อ ไม่ใช่ตอนพิมพ์:
fe.Value() เป็น interface{} จึงต้อง type-assert
(fe.Value().(time.Duration)) ก่อน ถึงจะบวกลบกับ duration อื่นได้
2. บรรทัด exit status 1 ไม่ใช่ output ของโปรแกรม —
เป็นข้อความที่ go run พิมพ์เองเมื่อโปรแกรมลูกจบด้วย exit code ที่ไม่ใช่ 0
ผม build เป็น binary แล้วรันตรง ๆ เทียบ: ไม่มีบรรทัดนี้เลย
ถ้าคุณเห็นบรรทัดนี้ในกล่องตัวอย่างที่ไหน ให้รู้ว่าคนเขียนใช้ go run
ไม่ใช่ว่า os.Exit(1) พิมพ์อะไรออกมา
3. บล็อกสามบรรทัดข้างบนใช้ได้เฉพาะ database.go เวอร์ชันหลัง refactor —
ผมรันด้วยเวอร์ชันก่อน refactor ด้วย env ชุดเดียวกัน บรรทัดที่สามกลายเป็น:
POSTGRES_DSN is required (ข้อความที่เราเขียนเองใน if)
แทนที่จะเป็น Key: 'Database.PostgresDSN' … 'required' tag
ซึ่งสมเหตุสมผล แต่ถ้าเอาผลของเวอร์ชันหนึ่งไปวางคู่กับโค้ดอีกเวอร์ชันเมื่อไหร่ คนอ่านจะงงทันที
🔍 รอยรั่วของ Humanize() ตัวนี้ที่ผมเจอตอนรัน — และวิธีคิดที่ได้จากมัน
ผมลองให้ ParseAs พังจริง ๆ (ไม่ใช่ validation) ด้วย APP_PORT=abc:
time=... level=ERROR msg="load configuration"
invalid configuration:
- parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
ปัญหาสองข้อ:
(1) พาดหัวยังขึ้นว่า invalid configuration: ทั้งที่นี่เป็นparse error
ไม่ใช่ค่าที่ผิดกฎ — คนอ่านจะไปนั่งหาว่ากฎไหนผิด
(2) ข้อความที่ Load() ตั้งใจใส่ไว้ (parse environment: env:) หายไป
สาเหตุ: errors.As(err, &joined) ที่มองหา interface{ Unwrap() []error }
มันวิ่งทะลุ fmt.Errorf ลงไปเจอ error รวมของ env/v11
ซึ่งบังเอิญมีเมธอด Unwrap() []error เหมือนกันเป๊ะ
บทเรียนที่เอาไปใช้ได้กว้างกว่าเรื่องนี้: การจับ error ด้วย
รูปร่างของ interface แบบลอย ๆ มันจับ "ทุกอย่างที่หน้าตาเหมือนกัน" ไม่ใช่ "สิ่งที่เราหมายถึง"
ทางแก้: แยกเคส parse ออกมาก่อนตั้งแต่ใน Load() (เช่นทำ error type ของตัวเอง)
แล้วค่อยให้ Humanize ตัดสินใจจากตรงนั้น
9 · main.go — เอาไปใช้จริง
main() ทำอย่างเดียว: เรียก run() แล้วแปลง error เป็น exit code
ส่วนงานจริงทั้งหมดอยู่ใน run() ซึ่งเป็นลำดับ 5 ขั้นที่ห้ามสลับ:
// cmd/api/main.go
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"wongnok/api/internal/config"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
// 1) logger ชั่วคราว — ยังไม่รู้ level/format เพราะยังไม่ได้อ่าน config
bootLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(bootLogger)
_ = godotenv.Load() // 2) .env → environment (ไม่มีไฟล์ก็ไม่เป็นไร)
// 3) โหลด config — พังตรงนี้ = ตายตรงนี้ ไม่ต้องรอ request แรก
cfg, err := config.Load()
if err != nil {
bootLogger.Error("load configuration")
fmt.Fprintf(os.Stderr, "%s\n", config.Humanize(err))
return fmt.Errorf("load configuration: %w", err)
}
// 4) logger ตัวจริง — สร้างจาก config ที่ผ่าน validate แล้ว
logger := newLogger(cfg.Logging)
slog.SetDefault(logger)
logger.Info("configuration loaded",
"app", cfg.App.Name, "env", cfg.App.Env, "addr", cfg.App.Addr())
// 5) จากตรงนี้ไป มั่นใจได้ว่า cfg ทุกค่าใช้งานได้จริง
db, err := gorm.Open(postgres.Open(cfg.Database.PostgresDSN), &gorm.Config{})
if err != nil {
return fmt.Errorf("open database: %w", err)
}
_ = db // ... SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime / Ping ...
mux := http.NewServeMux()
// ... ผูก handler เข้า mux ตามบทที่ผ่านมา ...
return http.ListenAndServe(cfg.App.Addr(), mux)
}
// ขั้นที่ 4 ของ run() อยู่ในฟังก์ชันนี้ — หัวข้อถัดไปจะยกมาอธิบายทีละบรรทัด
func newLogger(cfg config.Logging) *slog.Logger {
var level slog.Level
if err := level.UnmarshalText([]byte(cfg.Level)); err != nil {
level = slog.LevelInfo // validate ผ่านมาแล้ว ปกติไม่มีทางเข้าบรรทัดนี้
}
opts := &slog.HandlerOptions{Level: level}
if strings.EqualFold(cfg.Format, "text") {
return slog.New(slog.NewTextHandler(os.Stderr, opts))
}
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}
โค้ดก้อนนี้ก๊อปไปวางเป็นไฟล์แล้ว go build ผ่านจริง —
บรรทัด _ = db มีไว้เพราะ Go ไม่ยอมให้ประกาศตัวแปรแล้วไม่ใช้
(ตัวแปรที่ประกาศแล้วทิ้งไว้เฉย ๆ คือ compile error ไม่ใช่แค่คำเตือน)
พอคุณเขียนบรรทัด SetMaxOpenConns / Ping ที่ตามมาจริง ๆ
db ก็ถูกใช้งานแล้ว ให้ลบ _ = db ทิ้งไปได้เลย
ทำไมต้องมี logger สองตัว — ปัญหาไก่กับไข่
ขั้นที่ 1 ดูเหมือนเปลืองโค้ดโดยใช่เหตุ ทำไมไม่สร้าง logger ตัวเดียวให้จบ?
เพราะ logger ตัวจริงต้องรู้ LOG_LEVEL กับ LOG_FORMAT ซึ่งอยู่ใน config
แต่ขั้นตอนโหลด config เองก็ต้องการที่ log ตอนมันพัง
ไก่กับไข่พอดี — และทางออกคือยอมมี logger ธรรมดา ๆ ตัวหนึ่งไว้ก่อน
(TextHandler · level Info · ไม่ต้องอ่าน config อะไรเลย)
แล้วค่อยเปลี่ยนเป็นตัวจริงหลังโหลดสำเร็จ
🧭 รูปแบบนี้เจอได้ทั่วไปเวลาระบบต้อง bootstrap ตัวเอง —
"ของชั่วคราวที่ไม่ต้องพึ่งใคร ใช้ไปก่อน แล้วเปลี่ยนเป็นของจริงเมื่อพร้อม"
ผลพลอยได้ที่มองไม่เห็นตอนอ่านโค้ด: log สองบรรทัดแรกของแอปจะเป็น รูปแบบข้อความธรรมดา
ส่วนบรรทัดหลังจากนั้นเป็น JSON ตามที่ config สั่ง — ไม่ใช่ bug แต่เป็นผลของลำดับที่ตั้งใจไว้
newLogger() — จุดที่ LOG_LEVEL กับ LOG_FORMAT ได้ใช้งานจริง
หัวข้อ 6 เราตั้งกฎให้ Logging ไว้แล้ว แต่ยังไม่เคยเห็นว่ามันถูกใช้ตรงไหน —
คำตอบอยู่ที่ฟังก์ชันสั้น ๆ ที่ต่อท้ายไฟล์ main.go เมื่อกี้ ขอยกมาดูใกล้ ๆ อีกที
เพราะมันคือขั้นที่ 4 ของ run() ทั้งหมด:
func newLogger(cfg config.Logging) *slog.Logger {
var level slog.Level
if err := level.UnmarshalText([]byte(cfg.Level)); err != nil {
level = slog.LevelInfo // validate ผ่านมาแล้ว ปกติไม่มีทางเข้าบรรทัดนี้
}
opts := &slog.HandlerOptions{Level: level}
if strings.EqualFold(cfg.Format, "text") {
return slog.New(slog.NewTextHandler(os.Stderr, opts))
}
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}
ผมรันด้วย Logging สามชุดเทียบกัน (แต่ละชุดสั่ง Debug · Info · Warn อย่างละบรรทัด):
--- level=info format=json ---
{"time":"...","level":"INFO","msg":"configuration loaded","app":"wongnok","addr":":8080"}
{"time":"...","level":"WARN","msg":"warn line"}
--- level=DEBUG format=text ---
time=... level=DEBUG msg="debug line"
time=... level=INFO msg="configuration loaded" app=wongnok addr=:8080
time=... level=WARN msg="warn line"
--- level=warn format=JSON ---
{"time":"...","level":"WARN","msg":"warn line"}
สามอย่างที่กล่องนี้ยืนยัน:
· ชุดแรก ไม่มีบรรทัด DEBUG — LOG_LEVEL=info กรองทิ้งจริง
· ชุดที่สาม LOG_LEVEL=warn เหลือบรรทัดเดียว
· "DEBUG" กับ "JSON" ตัวใหญ่ก็ใช้ได้ เพราะ UnmarshalText กับ
strings.EqualFold ไม่สนตัวพิมพ์ — เข้าคู่กับ tag oneofci ที่เราตั้งไว้ในหัวข้อ 6 พอดี
(สาม package ที่ฟังก์ชันนี้ใช้ — log/slog · os · strings —
อยู่ใน import ของ main.go ข้างบนครบแล้ว ไม่ต้องเติมอะไรอีก)
พิสูจน์ว่า "ตายตั้งแต่ start" จริงไหม
ประโยคว่า "config ผิดแล้วแอปตายตั้งแต่ start" ฟังบ่อยจนกลายเป็นคำขวัญ ผมเลยรันแล้ววัดสามชั้นให้เห็นกับตา — สั่ง config ผิดสามที่เหมือนเดิม แล้วเช็คว่า process ยังอยู่ไหม · มีใคร bind พอร์ต 8080 ไหม · curl ต่อติดไหม:
--- ps เช็คว่า process ยังอยู่ไหม ---
process ตายไปแล้ว
--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)
--- curl /healthz ---
http_code=000
curl exit=7
EXIT_CODE=1
curl exit=7 คือ Failed to connect — ไม่ใช่ 404 ไม่ใช่ 500
แต่คือไม่มีอะไรอยู่ปลายสายให้ต่อเลย
นี่คือสิ่งที่เราต้องการจริง ๆ: ไม่มีหน้าต่างเวลาแม้แต่วินาทีเดียวที่แอปเปิดรับ request
ทั้งที่ config ยังผิดอยู่
ทีนี้ตั้ง config ให้ถูกแล้วรันใหม่ (มี Postgres รออยู่จริง) — ได้ลำดับตามที่ออกแบบไว้เป๊ะ:
--- curl /healthz ---
ok
http_code=200
--- stderr ของ server ---
{"time":"2026-08-22T10:13:43.048067+07:00","level":"INFO","msg":"configuration loaded","app":"wongnok","env":"development","addr":":8080"}
{"time":"2026-08-22T10:13:43.084594+07:00","level":"INFO","msg":"database connected"}
{"time":"2026-08-22T10:13:43.085393+07:00","level":"INFO","msg":"server starting","addr":":8080"}
สังเกตว่า log ออกมาเป็น JSON แล้ว — เพราะบรรทัดพวกนี้พิมพ์หลังขั้นที่ 4
logger ตัวจริงจึงถูกสร้างจาก cfg.Logging ที่มี LOG_FORMAT=json เป็น default
10 · config_test.go — เทสต์ config ที่ไม่ต้องมีไฟล์ .env
t.Setenv(key, value) คือของที่ทำให้เทสต์ config ง่ายขึ้นมาก —
มันตั้ง env ให้เฉพาะระหว่างเทสต์นั้น แล้วคืนค่าเดิมให้อัตโนมัติเมื่อเทสต์จบ
ไม่ต้องเขียน cleanup เอง และไม่ต้องมีไฟล์ .env ในเครื่อง CI
// internal/config/config_test.go
package config_test
import (
"strings"
"testing"
"wongnok/api/internal/config"
)
func TestLoad_ThreeErrorsAtOnce(t *testing.T) {
t.Setenv("APP_PORT", "99999")
t.Setenv("LOG_LEVEL", "BANANA")
t.Setenv("POSTGRES_DSN", "")
_, err := config.Load()
if err == nil {
t.Fatal("Load() error = nil, want error")
}
got := config.Humanize(err)
for _, want := range []string{
`App.Port: ผิดกฎ "lte"`,
`Logging.Level: ผิดกฎ "oneofci"`,
`Database.PostgresDSN: ผิดกฎ "required"`,
} {
if !strings.Contains(got, want) {
t.Errorf("Humanize() ไม่มี %q\nได้:\n%s", want, got)
}
}
}
func TestLoad_ReturnsZeroValueOnError(t *testing.T) {
t.Setenv("POSTGRES_DSN", "")
cfg, err := config.Load()
if err == nil {
t.Fatal("want error")
}
// value ไม่ใช่ pointer -> เรียกเมธอดต่อได้เลย ไม่ panic
if cfg.App.Addr() != ":0" {
t.Errorf("Addr() = %q, want %q", cfg.App.Addr(), ":0")
}
}
ในไฟล์จริงผมมีอีกตัวหนึ่งชื่อ TestLoad_Defaults (เช็คว่า envDefault ทำงานจริง)
ซึ่งไม่ได้ยกมาโชว์ตรงนี้ — ตอนรันจึงเห็นสามตัว:
$ go test ./internal/config/ -v
=== RUN TestLoad_Defaults
--- PASS: TestLoad_Defaults (0.00s)
=== RUN TestLoad_ThreeErrorsAtOnce
--- PASS: TestLoad_ThreeErrorsAtOnce (0.00s)
=== RUN TestLoad_ReturnsZeroValueOnError
--- PASS: TestLoad_ReturnsZeroValueOnError (0.00s)
PASS
ok wongnok/api/internal/config 0.572s
เทสต์ตัวที่สามคือตัวที่พิสูจน์เหตุผลข้อ "คืน value ไม่ใช่ pointer" จากหัวข้อ 4 —
เรียก cfg.App.Addr() ได้ทั้งที่ Load() เพิ่งคืน error มา
ถ้าเปลี่ยนเป็น *Config เมื่อไหร่ บรรทัดนี้จะ panic ทันที
📌 เกร็ดเรื่องประสิทธิภาพที่เกี่ยวกับบรรทัด var validate = validator.New(...)
เอกสารของ validator บอกว่ามันออกแบบมาให้ใช้เป็น singleton
(สร้างครั้งเดียวใช้ทั้งโปรแกรม) เพราะมันแคชข้อมูล struct ไว้ข้างใน — ผมวัดจริงด้วย benchmark
ที่เรียก validate.Struct(cfg.App) (section เดียว 4 field) รอบละครั้ง
ต่างกันแค่ตัว validator มาจากไหน:
cpu: Apple M4
BenchmarkSingleton-10 7712509 145.9 ns/op 48 B/op 1 allocs/op
BenchmarkNewEachTime-10 124420 9609 ns/op 16617 B/op 232 allocs/op
เรียก New() ใหม่ทุกครั้ง ช้ากว่าประมาณ 66 เท่า และจองหน่วยความจำ 232 ครั้งต่อรอบ
เทียบกับ 1 ครั้ง
ตัวเลข "กี่เท่า" ผูกกับ struct ที่วัด — ผมลองวัดทั้ง Config
(สาม section รวดในรอบเดียว) ได้ 434.0 ns/op เทียบ 13341 ns/op คือต่างกัน 31 เท่า
เพราะฝั่ง singleton มีงานให้ทำมากขึ้น ส่วนฝั่ง New() ต้นทุนคงที่ก้อนใหญ่เท่าเดิม
สิ่งที่ไม่เปลี่ยนไม่ว่าจะวัด struct ไหน คือช้ากว่าหลายสิบเท่า และจองหน่วยความจำหลักร้อยครั้งต่อรอบ
เทียบกับหลักหน่วย
เหตุผลไม่ใช่แค่ "ทิ้งแคช struct" อย่างที่มักได้ยิน — New()
ต้องคัดลอกตารางกฎ built-in ทั้งชุดใหม่ทุกครั้งด้วย
สำหรับ config ที่โหลดครั้งเดียวตอน start ตัวเลขนี้ไม่มีผลอะไรเลย
แต่ถ้าคุณเอา validator ตัวเดียวกันนี้ไปใช้ตรวจ request body ทุก request (ซึ่ง Gin ทำอยู่)
ความต่างนี้จะเห็นทันที
🔎 อีกเรื่องเกี่ยวกับ WithRequiredStructEnabled() ที่เอกสารเขียนไว้สั้นมาก
ผมรันเทียบ validator.New() เปล่า ๆ กับ New(WithRequiredStructEnabled())
แล้วพบว่ามันต่างกันจุดเดียวเป๊ะ: tag required ที่แปะบน
struct ลูกที่ไม่ใช่ pointer
plain / zero nested struct -> <nil> // tag ถูกเมิน
opt / zero nested struct -> Key: 'Cfg.DB' Error:Field validation for 'DB' failed on the 'required' tag
--- pointer nested (*DB), nil ---
plain -> Key: 'Cfg2.DB' Error:... failed on the 'required' tag
opt -> Key: 'Cfg2.DB' Error:... failed on the 'required' tag // เท่ากันทั้งคู่
ถ้าโค้ดของคุณใช้ *DB (pointer) ตลอด option นี้จะไม่เปลี่ยนอะไรเลย —
ซึ่งเป็นเหตุผลที่หลายคนใส่แล้วลองแล้วไม่เห็นความต่าง แล้วสรุปว่ามันไม่มีผล
ใน Config ของเราที่ประกาศ App App · Logging Logging · Database Database
เป็น non-pointer ทั้งหมด — option นี้จึงมีผลจริง ถ้าวันหนึ่งเราแปะ validate:"required" บน field พวกนั้น
11 · Mental Model — ท่อลำเลียงค่า
ก่อนจะปิดบท ลองถอยออกมามองภาพรวม สิ่งที่เราสร้างมาทั้งสี่บทคือท่อลำเลียงค่าหนึ่งท่อ ที่ค่าไหลจากไฟล์บนดิสก์ ไปจนถึงตัวแปรที่โค้ดใช้จริง:
ถ้ายังนึกภาพไม่ออก สไลด์เทียบกับงานเอกสารที่เคาน์เตอร์ ซึ่งเข้าใจง่ายกว่ามาก:
| ตัวจริงในโค้ด | เทียบกับคนที่เคาน์เตอร์ | ทำอะไร |
|---|---|---|
.env |
โพย | จดค่าที่ต้องใช้ไว้ก่อน ยังไม่ได้ยื่นให้ใคร |
godotenv |
คนเอาโพยไปกรอกเข้าระบบ | อ่านจากกระดาษ ใส่เข้า environment ให้โปรแกรมเห็น |
caarlos0/env |
คนจัดข้อมูลลงช่องในฟอร์ม | เอาค่าที่เป็น string ยัดลงช่องที่ถูกชนิด — ช่องไหนว่าง เติม default ให้ |
validator |
ฝ่ายตรวจเอกสาร | ตรวจว่าครบและถูกกฎไหม ไม่ครบตีกลับ — และตีกลับพร้อมกันทุกข้อที่ผิด |
internal/config |
เคาน์เตอร์รับเรื่อง | ที่เดียวที่รู้ทุกอย่าง — ใครอยากได้ค่าต้องผ่านตรงนี้ ห้ามเดินอ้อมไปหลังร้าน |
| Application | คนที่ได้ใช้ข้อมูลที่ผ่านการตรวจแล้ว | ไม่ต้องเช็คซ้ำ ไม่ต้องรู้ว่าค่ามาจากไหน — ของที่ถึงมือแล้วคือของที่ใช้ได้ |
ประโยคเดียวที่สรุปทั้งบท: ค่าที่ยังไม่ผ่านการตรวจ กับค่าที่ผ่านแล้ว
เป็นของคนละชนิดกัน และ internal/config คือเส้นแบ่งระหว่างสองโลกนั้น
ก่อนเส้นนี้: ทุกอย่างเป็น string ที่ไม่มีใครรับรอง · หลังเส้นนี้: ทุกค่าถูกชนิด ถูกกฎ และใช้ได้เลย
ทุกครั้งที่คุณเผลอเขียน os.Getenv ลึก ๆ ในโค้ด คุณกำลังเจาะรูให้ค่าที่ไม่ผ่านการตรวจข้ามเส้นมา
12 · แบบฝึกหัด — จับคู่
ทั้งหกข้อคือหกอย่างที่ผ่านมาในสี่บทนี้ ถ้าตอบได้ครบแปลว่าคุณเห็นภาพแล้วว่าใครทำอะไร
| ความต้องการ | ตัวเลือก |
|---|---|
1. อ่านไฟล์ .env แล้วยัดค่าเข้า environment | A. validate:"oneof=..." |
2. แปลง "8080" เป็น int ใส่ลง struct | B. .gitignore |
3. ตรวจว่า APP_ENV เป็นค่าที่รู้จัก | C. godotenv/autoload |
| 4. กำหนดค่า default ถ้าไม่ได้ตั้ง env | D. env.ParseAs[Config]() |
| 5. กันไม่ให้ secret หลุดขึ้น Git | E. errors.Join |
| 6. รวม error ของทุก section มาแสดงที่เดียว | F. envDefault |
จับคู่ให้ครบทั้งหกข้อก่อนกดดูเฉลย — ตอบในใจหรือจดลงกระดาษก็ได้
ตัวเลือกทั้งหกใช้ครั้งละหนึ่งข้อพอดี ไม่มีตัวไหนเหลือและไม่มีตัวไหนใช้ซ้ำ
| ข้อ | คู่ | ทำไม |
|---|---|---|
1. อ่าน .env เข้า environment |
C godotenv/autoload |
import แบบ _ แล้วมันทำงานให้ตอน package ถูกโหลด — ก่อน main() เริ่มด้วยซ้ำ |
2. แปลง "8080" เป็น int |
D env.ParseAs[Config]() |
อ่าน tag env:"APP_PORT" แล้วแปลงชนิดตามที่ field ประกาศไว้ให้เอง |
3. ตรวจว่า APP_ENV เป็นค่าที่รู้จัก |
A validate:"oneof=..." |
เป็นงานของ validator ไม่ใช่ของ env — "prodction" แปลงเป็น string ได้ แต่ไม่ใช่ค่าที่ยอมรับ |
| 4. กำหนดค่า default | F envDefault |
เขียนติดไว้กับ field เลย ไม่ต้องมี if x == "" { x = ... } กระจายทั่วโปรเจกต์ |
| 5. กัน secret หลุดขึ้น Git | B .gitignore |
ข้อเดียวในนี้ที่ไม่ใช่โค้ด Go — เป็นเรื่องของ git ล้วน ๆ และเป็นด่านที่สำคัญที่สุด |
| 6. รวม error ของทุก section | E errors.Join |
อยู่ใน Config.Validate() · คั่นบรรทัดด้วย \n ให้เอง · ตัวไหน nil ก็ข้ามให้ |
ข้อที่คนตอบผิดบ่อยที่สุดคือข้อ 2 กับข้อ 3 — เพราะสองอันนี้ดูเหมือน "ตรวจค่า" เหมือนกัน
เส้นแบ่งจำง่าย ๆ: env ถามว่า "แปลงเป็นชนิดนี้ได้ไหม" · validator ถามว่า "ค่านี้ใช้ได้จริงไหม"
ผมพิสูจน์เส้นแบ่งนี้ไปแล้วในหัวข้อ 5: APP_PORT=99999 ผ่าน ParseAs สบาย ๆ
(มันเป็น int ที่ถูกต้อง) แต่ตกที่ validator เพราะไม่มีพอร์ตหมายเลขนั้นในโลก
ทดสอบความเข้าใจ
1. ใน Load() ทำไม error ของ env.ParseAs ถึง wrap ด้วย fmt.Errorf("parse environment: %w", err) แต่ error ของ cfg.Validate() กลับ return err เฉย ๆ?
ผมรันเทียบสองแบบให้ดูในหัวข้อ 4 — แบบ wrap ได้ผลแบบนี้:
validate config: Key: 'App.Port' Error:... 'lte' tag
Key: 'Logging.Level' Error:... 'oneofci' tag ← บรรทัดนี้ไม่มี prefix
ยืนยันด้วยว่า errors.Join คั่นด้วย \n จริง: errors.Join(a,b,c)
ของ alpha/beta/gamma ได้ "alpha\nbeta\ngamma" ยาว 16 ตัวอักษรพอดี และไม่มี \n ปิดท้าย
ตัวเลือกที่ 1 ผิดครึ่งเดียว: errors.Join คืน nil เมื่อทุกตัวเป็น nil จริง
แต่นั่นไม่เกี่ยวกับการ wrap — และโค้ดเช็ค err != nil ก่อนอยู่แล้ว
ตัวเลือกที่ 2 ผิด: fmt.Errorf รับ error ตัวไหนก็ได้ compile ผ่านสบาย มันแค่อ่านแล้วดูแย่
ตัวเลือกที่ 4 ผิด และผมรันพิสูจน์แล้ว: errors.As ขุดทะลุทั้ง fmt.Errorf
และ errors.Join ลงไปเจอ ValidationErrors ครบ 4 ตัว (errors.As through Join -> true 4)
กฎที่ใช้ได้ทั่วไป: wrap error ที่เป็นก้อนเดียว · อย่า wrap error ที่จัดหลายบรรทัดมาให้แล้ว
2. field ShutdownTimeout time.Duration ติด validate:"required" ไว้ แล้วมีคนตั้ง APP_SHUTDOWN_TIMEOUT=-5s — ผลเป็นอย่างไร?
ผลรันจริง: ParseAs : OK -> ... Shutdown=-5s ... แล้วตามด้วย Validate : OK
— ผ่านทั้งสองด่าน
เหตุผล: required ของ validator แปลว่า "ห้ามเป็น zero value" เท่านั้น
zero value ของ time.Duration คือ 0s ส่วน -5s ไม่ใช่ zero มันจึงผ่านตามนิยาม
ตัวเลือกที่ 2 ผิด — time.ParseDuration("-5s") รับค่าติดลบได้ปกติ
ตัวเลือกที่ 3 คือคำตอบของเคส APP_SHUTDOWN_TIMEOUT=0s ต่างหาก ซึ่งโดนจับจริง
(Key: 'App.ShutdownTimeout' Error:... failed on the 'required' tag) —
นี่คือจุดที่หลอกที่สุด เพราะเห็นว่า 0s โดนจับแล้วเลยเหมาเอาว่าคุมได้ครบ
ตัวเลือกที่ 4 ผิด ไม่มี panic
ทางแก้ที่ผมรันยืนยันแล้ว: validate:"required,gt=0" —
-5s ตกที่ 'gt' · 0s ตกที่ 'required' · 500ms กับ 10s ผ่าน
กับดักเดียวกันนี้โผล่อีกครั้งใน database.go: การเปลี่ยน if ConnMaxLifetime <= 0
มาเป็น validate:"required" ทำให้ DB_CONN_MAX_LIFETIME=-5s หลุดไปเลย
3. ในกล่องตัวอย่างของสไลด์ ผลลัพธ์จบด้วยบรรทัด exit status 1 — บรรทัดนี้ใครพิมพ์?
ผมรันเทียบสองทางกับ env ชุดเดียวกันเป๊ะ: ผ่าน go run ./cmd/api มีบรรทัดนี้ ·
build เป็น binary แล้วรันตรง ๆ ไม่มีบรรทัดนี้เลย (บรรทัดสุดท้ายกลายเป็น log
level=ERROR msg=fatal error="load configuration: ..." แทน)
go run ทำหน้าที่เป็น parent process — พอลูกจบด้วย exit code ไม่ใช่ 0 มันรายงานให้เราทราบ
ตัวเลือกที่ 1 ผิด: os.Exit(1) แค่ทำให้ process จบด้วยรหัส 1 มันไม่พิมพ์อะไรเลย
ตัวเลือกที่ 2 กับ 3 ผิด — ทั้ง slog และ Humanize ไม่มีโค้ดส่วนไหนสร้างข้อความนี้
ทำไมต้องรู้: เวลาไล่ปัญหาบน production คุณรัน binary ไม่ได้รันผ่าน go run —
ถ้าไปตามหาบรรทัด exit status 1 ใน log แล้วไม่เจอ อาจเข้าใจผิดว่าโปรแกรมยังไม่ตาย
วิธีเช็ค exit code จริงคือ echo $? หลังคำสั่ง ซึ่งผมวัดได้ 1 ทั้งสองทาง
4. การวาง config ไว้ที่ internal/config/ แทนที่จะเป็น pkg/config/ ให้ผลอะไร?
ผมทดสอบสองทางด้วยโฟลเดอร์เดียวกันเป๊ะ (module นอกใช้ replace ชี้เข้ามา):
· จากใน module เดียวกัน (cmd/api → internal/config): inside module, import ok: wongnok · exit=0
· จาก module อื่น: main.go:6:2: use of internal package wongnok/api/internal/config not allowed · exit=1
ตัวเลือกที่ 1 ผิด — internal เป็นชื่อที่ toolchain ของ Go รู้จักเป็นพิเศษ ต่างจาก pkg
ที่เป็นแค่ธรรมเนียมจริง ๆ
ตัวเลือกที่ 3 ผิด ไม่มี flag แบบนั้น กฎนี้เปิดอยู่ตลอดเวลา ปิดไม่ได้
ตัวเลือกที่ 4 ผิดตรงระดับความรุนแรง: มันไม่ใช่คำเตือนของ go vet แต่คือ
go build ตกจริง exit code 1 — โค้ดนั้น compile ไม่ออกเลย
ประโยชน์จริงที่ได้: คุณเปลี่ยนชื่อ field ใน Config ได้อย่างอิสระ
เพราะรู้แน่ว่าไม่มีใครนอก module นี้ผูกอยู่กับมัน
internal/config ของตัวเอง แล้วทำให้มันพังให้ครบทุกแบบ
เป้าหมายไม่ใช่ "ก๊อปโค้ดในบทนี้ไปวางแล้วรันผ่าน" — เป้าหมายคือ ทำให้มันพังในแบบที่คุณสั่งได้ แล้วอ่าน error ออกว่าเกิดจากอะไร
- ตั้งโครง: ในโปรเจกต์ Go ของคุณ สร้าง
internal/config/แล้วแยกเป็นconfig.go·app.go·logging.go·database.go
ตอนนี้ยังไม่ต้องมี validate — แค่ให้env.ParseAs[Config]()ทำงานได้ก่อน - พิสูจน์กฎ
internal/ด้วยตัวเอง: สร้างโฟลเดอร์ใหม่นอกโปรเจกต์go mod init outsider· เพิ่มreplaceชี้มาที่โปรเจกต์คุณ แล้วลอง importinternal/configดู
ต้องได้use of internal package ... not allowedและexit=1— ถ้าได้ exit 0 แปลว่า import ผิด path - เติม validate ทีละ section: เริ่มจาก
app.goอย่างเดียว แล้วลองยิงสี่เคสนี้ทีละอัน จดข้อความ error ที่ได้ทั้งหมด:APP_PORT=99999·APP_PORT=abc·APP_ENV=prodction·APP_SHUTDOWN_TIMEOUT=0s
ตอบให้ได้ว่าเคสไหนพังที่ParseAsและเคสไหนพังที่Validate(คำใบ้: มีเคสเดียวที่พังที่ParseAs) - ทำให้ error ออกมาพร้อมกันหลายข้อ: เขียน
Config.Validate()ด้วยerrors.Joinแล้วตั้ง env ให้ผิดสามที่คนละ section พร้อมกัน
ต้องเห็น error ครบสามบรรทัดในการรันครั้งเดียว — ถ้าเห็นบรรทัดเดียว แปลว่าคุณเผลอreturnทันทีที่เจอตัวแรก - ลองอีกทางแล้วเทียบ: เปลี่ยน
return errของValidateเป็นreturn fmt.Errorf("validate: %w", err)แล้วรันซ้ำ
ดูว่าคำว่าvalidate:ไปโผล่กี่บรรทัด แล้วตัดสินเองว่าจะเก็บไว้หรือเอาออก - ล่ากับดักค่าติดลบ: ตั้ง
APP_SHUTDOWN_TIMEOUT=-5sแล้วรัน
ยืนยันว่ามันผ่านทั้งสองด่าน · จากนั้นเติมgt=0เข้า tag แล้วรันซ้ำ
สังเกตว่าชื่อ tag ใน error เปลี่ยนจาก'required'เป็น'gt' - เขียน
Humanize()ของตัวเอง: วนvalidator.ValidationErrorsแล้วพิมพ์fe.Namespace()กับfe.Tag()
ท้าทาย: ลองใช้fe.Param()เพิ่ม เพื่อเขียนข้อความแบบ "App.Env: ต้องเป็นหนึ่งใน development staging production" - เขียนเทสต์ด้วย
t.Setenv: อย่างน้อยสองตัว — ตัวหนึ่งเช็คว่า default ทำงาน อีกตัวเช็คว่า config ผิดสามที่แล้วได้ error ครบสามข้อ
รันเทสต์โดยลบไฟล์.envออกก่อน — ถ้ายังผ่าน แปลว่าเทสต์ของคุณ ไม่ได้แอบพึ่งเครื่องตัวเอง ซึ่งเป็นเงื่อนไขที่ต้องมีถ้าจะเอาไปรันบน CI - ท้าทายสุดท้าย: ทำ
run()แบบ 5 ขั้นในmain.goแล้วรันด้วย config ที่ผิด · เปิดอีก terminal ยิงcurl http://localhost:8080/healthz
ต้องต่อไม่ติด (curlexit 7) ไม่ใช่ได้ 500 — ถ้าได้ 500 แปลว่าแอปคุณยังเปิดพอร์ตก่อนตรวจ config อยู่
ผลรันจริงทั้งสี่เคส (ตัวโปรแกรมพิมพ์ผลของ ParseAs กับ Validate แยกบรรทัด):
APP_PORT=99999
ParseAs : OK -> App.Port=99999
Validate : FAILED -> Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
APP_PORT=abc
ParseAs : FAILED -> env: parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
APP_ENV=prodction
ParseAs : OK -> App.Env="prodction"
Validate : FAILED -> Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
APP_SHUTDOWN_TIMEOUT=0s
ParseAs : OK -> Shutdown=0s
Validate : FAILED -> Key: 'App.ShutdownTimeout' Error:Field validation for 'ShutdownTimeout' failed on the 'required' tag
มีเคสเดียวที่พังที่ ParseAs คือ APP_PORT=abc —
เพราะเป็นเคสเดียวที่ "แปลงเป็นชนิดที่ประกาศไว้ไม่ได้"
อีกสามเคสแปลงได้หมด: 99999 เป็น int ที่ถูกต้อง ·
"prodction" เป็น string ที่ถูกต้อง · 0s เป็น time.Duration ที่ถูกต้อง
มันแค่ไม่ใช่ค่าที่แอปเราใช้ได้ ซึ่งเป็นคนละคำถามกัน และเป็นงานของ validator
สังเกตข้อความของสองด่านให้ดี มันหน้าตาคนละแบบชัดเจน:
· ParseAs พูดถึง ชนิดข้อมูล: of type "int", strconv.ParseInt
· validator พูดถึง ชื่อกฎ: failed on the 'lte' tag
อ่านสองคำนี้เป็นก็แยกออกทันทีว่าปัญหาอยู่ด่านไหน โดยไม่ต้องไปเปิดโค้ดดู
เกร็ดเล็ก ๆ ที่คุ้มจะสังเกต: บรรทัด ParseAs ข้างบนมีคำว่า
env: นำหน้า เพราะเป็นข้อความที่ env.ParseAs คืนมาตรง ๆ
แต่ในกล่อง 🔍 ของหัวข้อ 8 กลับไม่มีคำนี้ — ไม่ใช่พิมพ์ตก
แต่เพราะตรงนั้นข้อความผ่าน Humanize() ซึ่ง errors.As
แกะชั้นนอกออกไปแล้วหยิบ error ชั้นในมาพิมพ์ · ห่อกี่ชั้น อ่านออกมาก็ได้ข้อความคนละแบบ
💬 ข้อ 5 กับข้อ 6 คือสองข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้
ข้อ 5 เพราะการเห็น prefix ไปโผล่บรรทัดเดียวด้วยตาตัวเอง จะทำให้คุณจำกฎ
"อย่า wrap error หลายบรรทัด" ได้โดยไม่ต้องท่อง
ข้อ 6 เพราะมันสอนบทเรียนที่ใหญ่กว่าเรื่อง gt=0:
กฎที่เราเขียนไว้ ไม่ได้แปลว่าเราคุมได้อย่างที่คิด —
ต้องยิงค่าจริงเข้าไปทดสอบถึงจะรู้
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย —
ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 ·
env/v11 v11.4.1 · validator/v10 v10.30.3 · godotenv v1.5.1
จุดที่มีโอกาสต่างกันมากที่สุดคือ validator เวอร์ชันอื่น เพราะข้อความ error
ของมันเคยเปลี่ยนรูปแบบมาแล้วในอดีต
อ้างอิง
- Go 1.4 Release Notes — Internal packages · cmd/go — Internal Directories — อ่านสองย่อหน้าสั้น ๆ นี้ จะเห็นกฎเป๊ะ ๆ ว่า "ใต้
internalimport ได้จากใต้โฟลเดอร์แม่ของมันเท่านั้น" ซึ่งเป็นที่มาของ erroruse of internal package ... not allowed - pkg.go.dev —
errors.Join— อ่านประโยคเดียวที่บอกว่าคั่นด้วย newline และประโยคที่บอกว่าข้ามnilให้ · ที่มาของUnwrap() []errorที่Humanize()ใช้แกะกล่อง - pkg.go.dev —
errors.As· fmt.Errorf — ความต่างของ "ห่อ error หนึ่งตัว" กับ "มัดหลายตัว" และเหตุผลที่%wทำให้ขุดกลับมาได้ - validator/v10 —
Validate.Struct· FieldError — รายชื่อ method ทั้งหมดของFieldError· สังเกตว่าValue()คืนinterface{}— พิมพ์ด้วย%vยังได้100msตามปกติ แต่ถ้าจะเอาไปคำนวณต่อต้อง type-assert กลับเป็นtime.Durationก่อน - validator/v10 —
WithRequiredStructEnabled— doc comment บรรทัดเดียวที่บอกครบ: "enables required tag on non-pointer structs" ซึ่งตรงกับผลรันในหัวข้อ 10 พอดี - caarlos0/env v11 — รายการ tag ทั้งหมด (
env·envDefault·envSeparator·required) และชนิดข้อมูลที่แปลงให้ได้เอง - pkg.go.dev —
t.Setenv— อ่านย่อหน้าที่บอกว่าใช้กับt.Parallel()ไม่ได้ ซึ่งเป็นข้อจำกัดที่เจอตอนเทสต์เริ่มเยอะ - The Twelve-Factor App — III. Config — ต้นทางของแนวคิดทั้งสี่บท และเกณฑ์ทดสอบ "เปิด repo เป็น public ตอนนี้ได้ไหม"
- บทเรียน 44 — Configuration & Environment Variables — จุดที่เรื่องนี้เริ่ม: ห้าปัญหาของ
os.Getenvที่ทั้งสี่บทนี้เกิดมาเพื่อแก้