🧰 ของที่ใช้ในบทนี้ — ทุก output ข้างล่างมาจากการรันจริงบน
go version go1.26.5 darwin/arm64
github.com/caarlos0/env/v11 v11.4.1
ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป ·
go mod init envdemo · go get github.com/caarlos0/env/v11@v11.4.1
ระวังเลข v11 ในเส้นทาง import — Go บังคับให้ major version ตั้งแต่ 2 ขึ้นไป
ติดอยู่ในชื่อ path ด้วย ถ้าเขียน github.com/caarlos0/env เฉย ๆ จะได้ของเวอร์ชันเก่าคนละตัว
1 · ทวนโจทย์ที่บทเรียน 44 ค้างไว้ — แล้วนับบรรทัดกันจริง ๆ
บทเรียน 44 พิสูจน์ไว้ห้าข้อว่าการโปรย os.Getenv ไปทั่วโค้ดพังยังไง:
พิมพ์ชื่อผิดไม่มีใครเตือน · ต้องแปลง type เองทุกที่ · default กระจัดกระจาย ·
ไม่มีที่ไหนบอกว่าแอปต้องการ config อะไรบ้าง · และมันระเบิดกลางทางแทนที่จะตายตอน start
ท้ายบทนั้นสรุปสิ่งที่อยากได้ไว้เป็นประโยคเดียว —
ประกาศครั้งเดียว แล้วให้ library ทำที่เหลือ วันนี้เราจะได้ของชิ้นนั้นแล้ว
สไลด์หน้า 9 ทำตารางเทียบว่า ก่อน ใช้ "40 บรรทัด Getenv + Atoi"
ส่วน หลัง เหลือ "struct 4 บรรทัด + ParseAs"
ปัญหาคือ บทเรียน 44 ไม่มีก้อนโค้ด 40 บรรทัดให้ไปนับตรง ๆ —
มันสอนเป็นชิ้น ๆ (os.LookupEnv · strconv.Atoi ·
time.ParseDuration · if x == "" { x = ... })
ผมเลยเอาวิธีของบท 44 มาประกอบเป็น Load() ที่ให้ผลเท่ากันเป๊ะ
กับโค้ดบนสไลด์ แล้วนับด้วยเครื่อง
ผลการนับ: type Config struct = 6 บรรทัด ·
func LoadManual() = 33 บรรทัด (นับบรรทัดว่างด้วย) หรือ
28 บรรทัด ถ้าไม่นับบรรทัดว่าง
รวมทั้งสองก้อน = 39 บรรทัด
👉 สไลด์บอก 40 ผมนับได้ 39 — ถือว่าตรงในทางปฏิบัติ ไม่ได้พูดเว่อร์
(ยังไม่ได้นับ import อีก 6 บรรทัดที่ฝั่งนี้ต้องใช้ strconv กับ time เพิ่ม)
นี่คือของจริงที่ผมนับ — ก๊อปทั้งก้อนไปวางเป็นไฟล์ internal/configmanual/manual.go
แล้ว go build ผ่าน · go vet เงียบ
ต้องเป็นคนละโฟลเดอร์กับโค้ดในหัวข้อ 2 นะ — สองก้อนนี้ประกาศ
type Config ชื่อเดียวกัน ถ้าวางไว้ package เดียวกันจะได้
Config redeclared in this block ทันที
ส่วนบล็อก import หกบรรทัดที่เห็นข้างล่างนี้ ไม่ได้นับรวมใน 39 บรรทัด
ตามที่กล่องข้างบนบอกไว้ — 39 นับเฉพาะ type Config (6) กับ func LoadManual (33)
package configmanual
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
AppPort int
ShutdownTimeout time.Duration
LogLevel string
PostgresDSN string
}
func LoadManual() (Config, error) {
var cfg Config
portStr, ok := os.LookupEnv("APP_PORT")
if !ok || portStr == "" {
portStr = "8080"
}
port, err := strconv.Atoi(portStr)
if err != nil {
return cfg, fmt.Errorf("APP_PORT: %w", err)
}
cfg.AppPort = port
toStr, ok := os.LookupEnv("APP_SHUTDOWN_TIMEOUT")
if !ok || toStr == "" {
toStr = "10s"
}
to, err := time.ParseDuration(toStr)
if err != nil {
return cfg, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
}
cfg.ShutdownTimeout = to
lvl, ok := os.LookupEnv("LOG_LEVEL")
if !ok || lvl == "" {
lvl = "DEBUG"
}
cfg.LogLevel = lvl
cfg.PostgresDSN = os.Getenv("POSTGRES_DSN")
return cfg, nil
}
อ่านแล้วจะเห็นว่ามันไม่ได้ยาก — มันแค่ยาว ซ้ำ และน่าเบื่อ
สังเกตว่า config 4 ตัวใช้ if err != nil ไปแล้วสองก้อน ถ้ามี 15 ตัวจะเป็นยังไง
และบรรทัดที่คนขี้เกียจเขียนที่สุดคือบรรทัดเช็ก error — พอเผลอเขียน _ ไปตัวหนึ่ง
คุณก็ได้กับดักทั้งหมดของบทเรียน 44 กลับมาทันที
2 · โค้ดทั้งหมดที่ต้องเขียน
นี่คือโค้ดจากสไลด์หน้า 9 ทั้งดุ้น — ผมก๊อปมาวางแล้ว go build ผ่าน และ go vet เงียบสนิท
ไฟล์นี้คือ internal/config/config.go ตามโครง internal/ ของ
บทเรียน 27
สังเกต import ให้ดี — สไลด์เขียนไว้บรรทัดเดียวคือ import "github.com/caarlos0/env/v11"
ซึ่งคอมไพล์ไม่ผ่าน เพราะ field ShutdownTimeout ใช้ time.Duration
ต้องเติม "time" เข้าไปด้วย ผมเลยเขียนเป็นบล็อกให้ครบตั้งแต่ตรงนี้
(ถ้าไม่เติม จะได้ cfgtest/config.go:7:18: undefined: time ซึ่งไม่ได้บอกเลยว่าต้องไปเติม import):
package config
import (
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
AppPort int `env:"APP_PORT" envDefault:"8080"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"DEBUG"`
PostgresDSN string `env:"POSTGRES_DSN"`
}
func Load() (Config, error) {
return env.ParseAs[Config]()
}
📌 ศัพท์ที่ต้องนิยามก่อนไปต่อ 3 คำ
· struct tag — สตริงในเครื่องหมาย backtick (`) ที่ห้อยท้าย field ของ struct
Go ไม่ได้เอาไปทำอะไรเองเลย มันเป็นแค่ป้ายกำกับที่ปล่อยให้ library มาอ่าน
(ตัวเดียวกับ json:"..." ที่ใช้ใน บทเรียน 35
และ binding:"..." ใน บทเรียน 39)
· reflection — ความสามารถของ Go ที่ให้โปรแกรมส่อง struct ตอนรันได้ว่ามี field อะไร
ชนิดอะไร tag เขียนว่าอะไร · caarlos0/env ใช้ท่านี้ในการทำงานทั้งหมด
· ParseAs[Config]() — วงเล็บเหลี่ยมคือ generics
แปลว่า "อ่าน env ออกมาเป็น Config ให้หน่อย" แล้วมันคืน (Config, error)
ไม่ต้องประกาศตัวแปรมารอรับก่อน
นึกถึงกล่องพัสดุที่คุณเอาไปส่งที่ร้าน พนักงานแปะสติกเกอร์ให้สองสามใบ —
"ปลายทาง เชียงใหม่" · "ห้ามวางซ้อน" · "ของแตกง่าย"
สติกเกอร์พวกนี้ไม่ได้เปลี่ยนของข้างในกล่องแม้แต่นิดเดียว
มันมีความหมายก็ต่อเมื่อมีคนที่อ่านสติกเกอร์เป็นมาหยิบกล่องขึ้นมา
ถ้าเอากล่องไปวางไว้เฉย ๆ สติกเกอร์ก็เป็นแค่กระดาษแปะ
struct tag คือสติกเกอร์ · Config คือกล่อง ·
caarlos0/env คือพนักงานที่อ่านสติกเกอร์เป็น
ส่วน Go compiler คือสายพานที่ขนกล่องผ่านไปเฉย ๆ — มันตรวจว่ากล่องปิดเรียบร้อยไหม
(ชนิดของ field ถูกไหม) แต่ไม่เคยอ่านสติกเกอร์เลยสักใบ
และคำว่า reflection ก็คือความสามารถ "หยิบกล่องขึ้นมาพลิกอ่านสติกเกอร์ตอนกำลังทำงาน"
นั่นเอง — ไม่ใช่ตอน compile
ที่ไม่เหมือนกัน: สติกเกอร์จริงถ้าเขียนที่อยู่ผิด พนักงานหน้าร้านยังทักได้
แต่ struct tag ที่พิมพ์ผิด ไม่มีใครทักเลยสักคน ผมลองสะกด
envDefault สลับตัวเป็น envDefualt แล้วรันจริง:
// `env:"APP_PORT" envDefualt:"8080"` ← สะกดผิดหนึ่งตัว
$ go build ./... # เงียบ
$ go vet ./... # เงียบ
$ go run .
[typo] main.Typo{AppPort:0, Timeout:10000000000} err=<nil>
คำสี่คำนี้คือคำที่ dev เขาใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง จะได้คำตอบที่ตรง
- struct tag
- "สติกเกอร์" ที่ห้อยท้าย field · เป็นแค่สตริง compiler ไม่อ่านความหมาย · ประโยคที่เอาไปใช้ได้เลย: "field นี้ต้องติด struct tag อะไรบ้าง" หรือพิมพ์ถาม AI ว่า "Go struct tag for caarlos0/env with default value"
- reflection
- ความสามารถส่อง struct ตอนรันว่ามี field อะไร ชนิดอะไร tag เขียนว่าอะไร · ประโยคที่ใช้อธิบายอาการข้างบนได้เป๊ะ: "library มันอ่าน tag ด้วย reflection ตอน runtime เพราะงั้นพิมพ์ tag ผิดก็ไม่มี compile error"
- generics / type parameter
- วงเล็บเหลี่ยมใน
ParseAs[Config]()— คือการส่งชนิดเข้าไปเป็นพารามิเตอร์ · พูดว่า "ParseAs มันเป็น generic function ส่ง type เข้าไปในวงเล็บเหลี่ยม" จะสื่อกว่าพูดว่า "ใส่ Config ในวงเล็บก้ามปู" - declarative
- สไตล์ที่ประกาศว่าอยากได้อะไร แล้วให้เครื่องมือคิดวิธีเอง — ตรงข้ามกับ
imperative ที่สั่งเป็นขั้น ๆ เองแบบ
LoadManualในหัวข้อ 1 · ประโยคสรุปทั้งบทนี้: "เปลี่ยนจากอ่าน env แบบ imperative มาเป็นประกาศ config แบบ declarative"
รันจริงสองรอบ รอบแรกไม่ตั้ง env เลย รอบสองตั้งครบ (พิมพ์ด้วย %#v จึงเห็นชื่อ field)
ตอนทดลองผมย้าย Config มาไว้ใน package main ไฟล์เดียวจบ %#v
เลยพิมพ์ว่า main.Config — ถ้าวางไว้ใน internal/config จริงตามโค้ดข้างบน
จะขึ้นเป็น config.Config แทน (ผมรันเทียบมาแล้วทั้งสองแบบ ค่าที่ได้เหมือนกันเป๊ะ
ต่างแค่ชื่อ package ที่ type ถูกประกาศไว้):
$ go run .
cfg=main.Config{AppPort:8080, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=<nil>
$ APP_PORT=9999 APP_SHUTDOWN_TIMEOUT=1m30s LOG_LEVEL=INFO \
POSTGRES_DSN="postgres://u:p@localhost:5432/db" go run .
cfg=main.Config{AppPort:9999, ShutdownTimeout:90000000000, LogLevel:"INFO", PostgresDSN:"postgres://u:p@localhost:5432/db"}
err=<nil>
🔍 ทำไม ShutdownTimeout ถึงพิมพ์ออกมาเป็น 10000000000 —
ไม่ใช่บั๊ก · time.Duration ข้างในคือ int64 ที่นับหน่วยเป็นนาโนวินาที
10000000000 นาโนวินาที = 10 วินาที · 90000000000 = 1 นาที 30 วินาที
%#v พิมพ์โครงสร้างดิบจึงเห็นเป็นเลข · ถ้าพิมพ์ด้วย %v เฉย ๆ จะได้
10s กับ 1m30s ซึ่งอ่านง่ายกว่า
ที่สำคัญคือ 1m30s ถูกแปลงเป็น time.Duration ให้จริง
ไม่ใช่สตริง — ผมเช็กด้วย %T แล้วได้ time.Duration
สรุปเป็นตารางเทียบตามที่สไลด์ให้ไว้ ทุกช่องฝั่งซ้ายมาจากโค้ดในหัวข้อ 1 ที่ผมนับบรรทัดจริง:
| เรื่อง | ก่อน (os.Getenv + strconv) | หลัง (caarlos0/env) |
|---|---|---|
| ปริมาณโค้ด | 39 บรรทัด (struct 6 + Load 33) — สไลด์เขียนไว้ว่า 40 |
struct 4 field (บล็อก 6 บรรทัด) + ParseAs อีก 3 บรรทัด |
| ชื่อ env อยู่ที่ไหน | กระจายทั้งโปรเจกต์ ตามหาได้ด้วย grep os.Getenv เท่านั้น |
อยู่ที่เดียว มองเห็นครบ — เปิด struct เดียวรู้หมดว่าแอปต้องการอะไร |
| แปลง type | เขียน Atoi / ParseDuration เอง + เช็ก error เองทุกจุด |
แปลงให้อัตโนมัติ ตามชนิดของ field ที่ประกาศไว้ |
| ค่า default | if x == "" { x = ... } กระจาย คนละไฟล์ค่าไม่ตรงกันก็ไม่มีใครรู้ |
envDefault ติดอยู่กับ field นั้นเลย เห็นพร้อมชื่อและชนิด |
3 · struct tag ทั้ง 4 ตัว
สไลด์หน้า 10 ให้มาสี่ตัว ผมทดสอบมาครบทุกตัวแล้ว:
| Tag | ทำอะไร | ตัวอย่างที่ผมรันจริง |
|---|---|---|
env:"NAME" |
ผูก field เข้ากับชื่อ environment variable | AppPort int `env:"APP_PORT"` |
envDefault:"x" |
ค่าที่ใช้เมื่อไม่ได้ตั้ง env ตัวนั้น | `env:"APP_PORT" envDefault:"8080"` → ไม่ตั้งแล้วได้ 8080 |
envSeparator:"," |
ตัวคั่นสำหรับ slice · ไม่ใส่ = ใช้ comma | `env:"ORIGINS" envSeparator:";"` |
envPrefix:"DB_" |
เติมนำหน้าชื่อ env ให้ทุก field ใน struct ซ้อน | Primary DB `envPrefix:"DB_"` → อ่าน DB_HOST, DB_PORT |
envSeparator — ยังใช้ได้กับ slice ของชนิดอื่นด้วย
type Sep struct {
DefaultSep []string `env:"HOSTS"` // ไม่ใส่ separator
CustomSep []string `env:"ORIGINS" envSeparator:";"`
IntsSep []int `env:"PORTS" envSeparator:"|"` // slice ของ int
SepDefault []string `env:"TAGS" envDefault:"a,b,c"` // default ก็ถูก split
}
$ HOSTS="a.com,b.com,c.com" ORIGINS="x.io;y.io" PORTS="1|2|3" go run .
[Sep] main.Sep{DefaultSep:[]string{"a.com", "b.com", "c.com"},
CustomSep:[]string{"x.io", "y.io"},
IntsSep:[]int{1, 2, 3},
SepDefault:[]string{"a", "b", "c"}} err=<nil>
สามเรื่องที่ได้จากผลนี้: ไม่ใส่ envSeparator ก็ได้ comma เป็นค่าเริ่มต้น ·
ใช้กับ []int ได้ ไม่ใช่แค่ []string (มันแยกแล้วแปลงทีละชิ้น) ·
และ envDefault ก็โดน split ด้วยตัวคั่นเดียวกัน — TAGS ที่ไม่ได้ตั้ง
ได้ []string{"a","b","c"} ไม่ใช่สตริงเดียวว่า "a,b,c"
envPrefix — สำหรับ struct ซ้อน struct
type DB struct {
Host string `env:"HOST"`
Port int `env:"PORT" envDefault:"5432"`
}
type Redis struct { Host string `env:"HOST"` }
type Nested struct {
Primary DB `envPrefix:"DB_"`
Cache Redis `envPrefix:"REDIS_"`
NoPfx DB // ← ไม่ใส่ prefix เลย
}
$ DB_HOST="pg.local" DB_PORT=6543 REDIS_HOST="redis.local" HOST="bare.local" go run .
[Nested] main.Nested{Primary:main.DB{Host:"pg.local", Port:6543},
Cache:main.Redis{Host:"redis.local"},
NoPfx:main.DB{Host:"bare.local", Port:5432}} err=<nil>
ประโยชน์ของมันชัดมาก: DB ตัวเดียวเอาไปใช้ซ้ำได้ทั้ง primary และ replica
แค่เปลี่ยน prefix — ไม่ต้องเขียน struct ซ้ำสองรอบเพื่อเปลี่ยนชื่อ env
envPrefix จะไปแย่งอ่าน key เปล่า ๆ
ดู NoPfx ในผลรัน — มันได้ Host:"bare.local" มาจาก env ชื่อ
HOST เฉย ๆ เพราะไม่มี prefix มาเติมข้างหน้า
ถ้าคุณมี struct ซ้อนสองตัวขึ้นไปที่ลืมใส่ prefix ทั้งคู่จะอ่าน HOST
ตัวเดียวกันแล้วได้ค่าเหมือนกันหมด — โดยไม่มี error ไม่มีคำเตือนอะไรเลย
กฎง่าย ๆ: struct ซ้อนทุกตัวใส่ envPrefix ให้ครบ แม้จะมีแค่ตัวเดียวในตอนนี้
4 · option ต่อท้ายใน env tag
ทั้งสี่ตัวนี้เขียนต่อท้ายชื่อ env ในอัญประกาศเดียวกัน คั่นด้วยจุลภาค
คือ env:"NAME,option" ไม่ใช่ tag แยกใหม่ — ตรงนี้คนพิมพ์ผิดกันบ่อย
| Option | สไลด์บอกว่า | ผลรันจริงของผม |
|---|---|---|
,required |
ไม่ได้ตั้ง → error ตอน parse | ✅ จริง — แต่มีเงื่อนไขซ่อนอยู่สองข้อ ดูหัวข้อ 5 |
,notEmpty |
ตั้งแล้วเป็นค่าว่าง → error | ✅ จริง — และเป็นตัวที่คุณต้องการจริง ๆ บ่อยกว่า ,required |
,file |
อ่านค่าเป็น path แล้วเอาเนื้อไฟล์มาใส่ | ⚠️ จริง แต่ไม่ตัดขึ้นบรรทัดใหม่ทิ้งให้ — ดูกล่องทายผลข้างล่าง |
,unset |
อ่านเสร็จลบทิ้งจาก environment | ✅ จริง — os.LookupEnv คืน exists=false หลัง parse |
,notEmpty กับ ,unset — ผลรันจริง
// `env:"NE_VAR,notEmpty"` แล้วสั่ง os.Setenv("NE_VAR", "")
[notEmpty empty] err=env: environment variable "NE_VAR" should not be empty
// `env:"U_VAR,unset"` แล้วสั่ง os.Setenv("U_VAR", "temp-value")
[unset] parsed="temp-value" err=<nil> ; after parse LookupEnv -> value="" exists=false
,unset มีประโยชน์กับค่าลับ: ค่าเข้า struct ครบแล้ว
แต่มันหายไปจาก environment ทันที — process ลูกที่คุณ spawn ทีหลัง (เช่นเรียก
exec.Command) จะไม่เห็นรหัสผ่านนั้นอีก
ทายก่อนรัน — ,file กับไฟล์ที่ลงท้ายด้วยขึ้นบรรทัดใหม่
type File struct {
V string `env:"F_VAR,file"`
}
// เขียนไฟล์ชั่วคราวที่มีเนื้อว่า s3cr3t-from-file\n (มี \n ปิดท้าย)
// แล้ว os.Setenv("F_VAR", ชื่อไฟล์นั้น)
ท่านี้คือท่าที่ใช้กับ Docker secret (/run/secrets/db_password) กันจริง ๆ
ทาย 2 ข้อ
1. V ที่ได้ คือ "s3cr3t-from-file" หรือ "s3cr3t-from-file\n"?
2. ถ้าเปลี่ยน F_VAR ให้ชี้ไป path ที่ไม่มีอยู่จริง จะได้ error หน้าตายังไง —
มันจะบอกชื่อ env ให้ไหม หรือบอกแค่ชื่อไฟล์?
[file ok] value="s3cr3t-from-file\n" err=<nil>
[file missing path] err=env: could not load content of file "/definitely/not/here.txt" from variable F_VAR: open /definitely/not/here.txt: no such file or directory
ข้อ 1 — ได้ \n ติดมาด้วย · ,file อ่านไฟล์มาทั้งก้อน
ไม่ trim อะไรให้เลย
นี่คือกับดักที่สไลด์ยังไม่ได้เตือน และมันเจ็บมาก:
ไฟล์ secret ส่วนใหญ่ถูกสร้างด้วย echo "pass" > secret.txt ซึ่งใส่ \n ให้อัตโนมัติ
ผลคือรหัสผ่านที่แอปคุณส่งไปหา database คือ "pass\n" ไม่ใช่ "pass" →
auth ไม่ผ่าน → error บอกแค่ว่ารหัสผ่านผิด ซึ่งคุณเปิดไฟล์ดูแล้วก็เห็นว่ารหัสถูก
เพราะ \n มองไม่เห็นด้วยตา
ทางแก้: สร้างไฟล์ด้วย printf '%s' "pass" > secret.txt (ไม่มี \n)
หรือ strings.TrimSpace เองหลัง parse
ข้อ 2 — บอกครบทั้งสองอย่าง ทั้ง path ("/definitely/not/here.txt")
และชื่อ env (from variable F_VAR)
ข้อความนี้ดีกว่า error ของบทเรียน 44 คนละชั้น — เทียบกับ
dial tcp [::1]:5432: connect: connection refused ที่ไม่มีคำว่า env อยู่เลยสักตัว
ตรงนี้แหละคือคุณค่าที่แท้จริงของ library: มันรู้จักชื่อ env
เลยใส่ชื่อลงในข้อความ error ให้ได้
5 · กับดักที่สไลด์เตือนเอง — ,required คู่กับ envDefault
มุมล่างขวาของสไลด์หน้า 10 มีกล่องแดงเขียนว่า:
"ระวัง — ,required ใส่คู่กับ envDefault แล้ว envDefault ชนะเสมอ
required จะไม่มีผลเลย"
ผมไปพิสูจน์มาแล้ว ทั้งด้วยการรันและด้วยการเปิดซอร์สอ่าน — สไลด์พูดถูกทุกตัวอักษร
ทายก่อนรัน — สาม struct นี้ แล้วสั่ง go run . โดยไม่ตั้ง env ตัวไหนเลย
type A struct {
Both string `env:"MYVAR,required" envDefault:"fallback"`
}
type B struct {
OnlyRequired string `env:"MYVAR2,required"` // ไม่มี envDefault
}
type C struct {
BothNotEmpty string `env:"MYVAR3,notEmpty" envDefault:"fallback3"`
}
ทายทีละตัวว่าได้ค่าอะไร และ err เป็น nil หรือไม่
· A — มี required แต่ก็มี envDefault ด้วย ใครชนะ?
· B — มี required อย่างเดียว
· C — notEmpty คู่กับ envDefault จะเหมือน A ไหม?
แล้วทายเพิ่มอีกข้อ: ถ้ารัน MYVAR2= go run .
(ตั้ง MYVAR2 เป็นสตริงว่าง) B จะ error หรือผ่าน?
$ go run .
=== ไม่ตั้ง env เลย ===
[A required+envDefault] value="fallback" err=<nil>
[B required only] value="" err=env: required environment variable "MYVAR2" is not set
[C notEmpty+envDefault] value="fallback3" err=<nil>
$ MYVAR2= go run .
=== ตั้ง MYVAR2 เป็นค่าว่าง (MYVAR2=) ===
[A required+envDefault] value="fallback" err=<nil>
[B required only] value="" err=<nil> // ← ผ่านฉลุย!
[C notEmpty+envDefault] value="fallback3" err=<nil>
1. สไลด์พูดถูก — A มี ,required ติดอยู่ชัด ๆ
แต่ได้ "fallback" กับ err=<nil> เงียบ ๆ
ส่วน B ที่มี ,required เปล่า ๆ error ตามคาด — เทียบกันเห็นชัดเจน
2. สิ่งที่สไลด์ยังไม่ได้เตือน ข้อแรก: ,notEmpty โดนกลบเหมือนกันเป๊ะ
C ได้ "fallback3" ทั้งกรณีไม่ตั้ง env และกรณีตั้งเป็นค่าว่าง
(ผมรัน MYVAR3= go run . แยกอีกรอบก็ได้ "fallback3" เหมือนกัน)
ถ้าจะเตือนเรื่อง required + envDefault ต้องเตือน
notEmpty + envDefault คู่กันไปเลย
3. สิ่งที่สไลด์ยังไม่ได้เตือน ข้อสอง — และข้อนี้อันตรายกว่าข้อแรก:
,required ไม่ได้กันค่าว่าง
ดูบรรทัด B ในรอบที่สอง — ตั้ง MYVAR2= (มีตัวแปรอยู่ แต่ค่าว่าง)
แล้วผ่านฉลุย err=<nil> ได้สตริงว่างไปใช้ต่อ
ทำไมถึงอันตราย: ใน docker-compose.yml การเขียน
POSTGRES_DSN= แบบลืมใส่ค่า หรือ POSTGRES_DSN=${DB_URL}
ที่ DB_URL ไม่มีอยู่ เกิดขึ้นง่ายมาก และผลลัพธ์คือ env ที่มีอยู่จริงแต่ค่าว่าง
ซึ่ง ,required ปล่อยผ่านหมด
เหตุผลไม่ได้อยู่ในเวทมนตร์ที่ไหน — มันอยู่ในซอร์สของ env.go เอง สองจุด
ผมเปิดของ v11.4.1 มาให้ดู:
// env.go บรรทัด 613 — เงื่อนไขที่ตัดสินว่า required จะ error ไหม
if fieldParams.Required && !exists && fieldParams.OwnKey != "" {
return "", newVarIsNotSetError(fieldParams.Key)
}
if fieldParams.NotEmpty && val == "" { // บรรทัด 617
// env.go บรรทัด 648 — ฟังก์ชันที่ตัดสินว่า exists กับ val เป็นอะไร
func getOr(key, defaultValue string, defExists bool, envs map[string]string) (val string, exists, isDefault bool) {
value, exists := envs[key]
switch {
case (!exists || key == "") && defExists:
return defaultValue, true, true // ← ไม่มี env แต่มี default → บอกว่า "มีอยู่"
case exists && value == "" && defExists:
return defaultValue, true, true // ← env ว่าง แต่มี default → ก็ยังบอกว่า "มีอยู่"
case !exists:
return "", false, false
}
return value, true, false
}
💡 อ่านสองก้อนนี้ต่อกันแล้วทุกอย่างจะกระจ่าง
· required เช็ก !exists — และ getOr
คืน exists = true ทุกครั้งที่มี envDefault
เงื่อนไข Required && !exists จึงไม่มีทางเป็นจริง เมื่อมี default
· notEmpty เช็ก val == "" — แต่ val ที่ส่งมาถึงบรรทัดนั้น
ถูกแทนด้วย default ไปแล้ว จึงไม่มีวันว่าง
· required ดูแค่ "มีตัวแปรนี้ไหม" ไม่เคยดูว่า "ค่าว่างหรือเปล่า" —
ซึ่งตรงกับความต่างระหว่าง os.LookupEnv กับ os.Getenv
ที่บทเรียน 44 อธิบายไว้เป๊ะ ๆ
หมู่บ้านหนึ่งตั้งกฎว่า "เข้าหมู่บ้านต้องมีบัตร" แล้วให้ยามยืนตรวจหน้าประตู
แต่ยามคนนี้เช็กแค่ว่า "ในมือถือบัตรอยู่หรือเปล่า" —
ไม่เคยพลิกดูว่าบัตรใบนั้นเขียนอะไรไว้
นี่คือ ,required เป๊ะ ๆ: ในซอร์สเขียนว่า Required && !exists
มีแต่คำว่า exists (มีอยู่ไหม) ไม่มีการเทียบค่ากับสตริงว่างเลยสักตัว
ทีนี้ envDefault คือคนที่ยืนแจกบัตรสำรองอยู่ก่อนถึงป้อมยาม —
ใครมือเปล่ามาก็ยัดบัตรใส่มือให้ก่อนหนึ่งใบ
ผลคือพอเดินถึงยาม ทุกคนมีบัตรหมด ยามจึงไม่มีทางจับใครได้อีกเลย
นั่นแหละคือเหตุผลที่ A ได้ "fallback" กับ err=<nil>
ทั้งที่มี ,required ติดอยู่ชัด ๆ
ส่วน ,notEmpty คือยามคนที่สองที่พลิกบัตรดูว่ามีตัวหนังสือไหม —
แต่ในเมื่อบัตรสำรองมีตัวหนังสืออยู่แล้ว ยามคนที่สองก็จับไม่ได้เหมือนกัน (นั่นคือ C)
ที่ไม่เหมือนกัน: สองข้อ —
(1) ที่หมู่บ้านจริง ถ้ามีใครมายืนแจกบัตรสำรองหน้าป้อมยาม นิติบุคคลคงไล่ออกไปตั้งนานแล้ว
แต่ใน Go ไม่มีใครมองเห็นความขัดแย้งนี้เลย — เขียน
`env:"X,required" envDefault:"y"` แล้ว compile ผ่าน · go vet เงียบ ·
ตอนรันก็เงียบ ·
(2) ยามจริงเห็นบัตรเปล่า ๆ ยังเอะใจได้ แต่ ,required
ไม่ได้แปลว่า "ค่าต้องไม่ว่าง" มันแปลแค่ "ตัวแปรต้องมีอยู่" เท่านั้นจริง ๆ
- unset vs empty
- สองสถานะที่คนละเรื่องกัน — ไม่มีตัวแปรนั้นเลย กับ มีตัวแปรแต่ค่าว่าง · เวลาดีบั๊กกับเพื่อนร่วมทีม ถามให้ตรงว่า "ตอนนี้ POSTGRES_DSN มัน unset หรือ set แล้วเป็นค่าว่าง" จะไปได้เร็วกว่าถามว่า "ตั้ง env รึยัง" เยอะมาก
- silent failure (พังเงียบ)
- อาการที่โปรแกรมทำผิดโดยไม่มี error ออกมาเลย · นี่คือชื่อโรคของทั้งหัวข้อนี้ · ประโยคที่เอาไปพูดได้: "เคสนี้มันเป็น silent failure นะ ไม่มี error ให้จับเลย ต้องบังคับให้มันฟ้องเอง"
- fail fast
- ออกแบบให้พังตั้งแต่ตอน start ให้เร็วที่สุด ดีกว่าไปพังตอน request แรกของลูกค้า ·
ประโยคที่ใช้ขายไอเดียนี้กับ senior ได้เลย: "ผมอยากให้ config มัน fail fast ตอน start
ไม่ใช่ไปตายตอน runtime" — และนี่คือเหตุผลทั้งหมดที่มี
,required,notEmpty
1. ค่าไหน "บังคับ" ห้ามใส่ envDefault เด็ดขาด —
มันขัดกันในตัวเองอยู่แล้ว ถ้ามี default ได้ก็แปลว่าไม่บังคับ
2. อยากกันจริง ๆ ใช้ ,required,notEmpty คู่กัน —
required กันกรณี "ไม่มีตัวแปร" · notEmpty กันกรณี "มีแต่ค่าว่าง"
3. ถ้าเขียน `env:"X,required" envDefault:"y"` ไปแล้ว
ไม่มีอะไรฟ้องคุณเลย — ไม่ compile error ไม่ vet warning ไม่ warning ตอนรัน
มันเงียบสนิทเหมือนกับดักทั้งหมดในบทเรียน 44
6 · type ที่รองรับ — และคำว่า "zero dependency"
สไลด์หน้า 9 ลิสต์ type ที่รองรับไว้ ผมเขียน struct ที่มีครบทุกตัวแล้วรันทีเดียว — ผ่านหมด:
| กลุ่ม | ชนิด | ตัวอย่างค่า env ที่ผมทดสอบผ่าน |
|---|---|---|
| ตัวเลข | int ทุกขนาด · uint · float32/64 | -12 · 9223372036854775807 · 3.14 |
| พื้นฐาน | bool · string | true · hello |
| เวลา | time.Duration · time.Location | 1h30m → 1h30m0s · Asia/Bangkok |
| URL | url.URL · *url.URL | postgres://user@db:5432/app |
| รวมกลุ่ม | slice · map · pointer | a,b,c · a:1,b:2 (map ใช้ : คั่นคู่) |
| ทำเอง | ทุก type ที่ implement encoding.TextUnmarshaler | ดูตัวอย่าง LogLevel ข้างล่าง |
TextUnmarshaler คือกุญแจของทั้งหมดนี้
encoding.TextUnmarshaler คือ interface ของ standard library
ที่มีเมธอดเดียว: UnmarshalText([]byte) error
แปลว่า "ชนิดนี้รู้วิธีสร้างตัวเองจากข้อความ" — ใครก็ตามที่เขียนเมธอดนี้ให้ type ของตัวเอง
จะใช้กับ caarlos0/env ได้ทันทีโดย library ไม่ต้องรู้จักคุณมาก่อน
type LogLevel struct{ v string }
func (l *LogLevel) UnmarshalText(b []byte) error {
s := strings.ToUpper(string(b))
switch s {
case "DEBUG", "INFO", "WARN", "ERROR":
l.v = s
return nil
}
return fmt.Errorf("unknown log level %q", s)
}
$ LVL=info LVLP=warn go run .
Lvl=main.LogLevel{v:"INFO"} LvlP=&{WARN} # ส่ง info เข้าไป ได้ INFO ออกมา
$ LVL=verbose go run .
ERR: env: parse error on field "Lvl" of type "main.LogLevel": unknown log level "VERBOSE"
สังเกตข้อความ error ให้ดี — ข้อความที่คุณเขียนเองใน UnmarshalText
ถูกยกมาใส่ในข้อความของ library ทั้งดุ้น พร้อมชื่อ field และชนิด
นี่คือวิธีทำ validation ระดับ "ค่านี้ต้องเป็นหนึ่งในสี่ค่านี้เท่านั้น"
ตั้งแต่ตอน parse โดยไม่ต้องรอ validator (ซึ่งเป็นเรื่องของ
บทเรียน 47)
โรงงานที่ผลิตพัดลม · ที่ชาร์จมือถือ · หม้อหุงข้าว
ไม่เคยต้องโทรไปคุยกับการไฟฟ้าก่อนสักครั้ง
เขาแค่ทำหัวปลั๊กให้ตรงมาตรฐาน — สองขาแบน ระยะห่างเท่านี้ —
แล้วของชิ้นนั้นก็เสียบเต้ารับที่บ้านคุณได้ทันที
ในทางกลับกัน เต้ารับก็ไม่เคยรู้จักว่ากำลังจ่ายไฟให้พัดลมหรือหม้อหุงข้าว
มันรู้อย่างเดียวว่า "หัวปลั๊กรูปทรงนี้เสียบได้"
encoding.TextUnmarshaler คือรูปทรงหัวปลั๊กมาตรฐานที่ Go ประกาศไว้กลาง ๆ —
รูปทรงนั้นคือเมธอดเดียวชื่อ UnmarshalText([]byte) error
caarlos0/env คือเต้ารับ — มันไม่รู้จัก LogLevel ของคุณ
และไม่เคยรู้จัก time.Time เป็นพิเศษด้วยซ้ำ มันแค่ถามว่า "หัวปลั๊กตรงไหม" ถ้าตรงก็ใช้เลย
นี่คือคำตอบของกล่องเกร็ดข้างล่าง: time.Time ใช้ได้เพราะมันทำหัวปลั๊กตรงมาตรฐานไว้ตั้งแต่แรก
ไม่ใช่เพราะคนเขียน library ไปเพิ่มโค้ดรองรับมัน
ที่ไม่เหมือนกัน: ปลั๊กจริงถ้าขาไม่ตรงรู ก็เสียบไม่เข้าให้เห็นกับตา
แต่ Go ไม่มีคำว่า implements ให้เขียนที่ไหนเลย —
คุณไม่ได้ประกาศตรงไหนว่า "type นี้ทำตามมาตรฐานนี้นะ" มันดูจากลายเซ็นเมธอดล้วน ๆ
ผลคือเขียนพลาดนิดเดียวแล้วเงียบ ผมทดสอบมาแล้วสองแบบ — ต่างกันคนละโลก:
// แบบ 1 — ลายเซ็นผิด (รับ string แทน []byte) → library ฟ้องให้ ดีมาก
err=env: no parser found for field "W" of type "main.Wrong"
// แบบ 2 — ลืมใส่ * หน้า receiver: func (l ValRecv) UnmarshalText(...)
$ go vet ./... # เงียบ
$ V_VAR=warn go run .
V=main.ValRecv{v:""} err=<nil> // ← ค่าหายไปเฉย ๆ ไม่มี error
- interface
- "รูปทรงหัวปลั๊ก" — รายการเมธอดที่ type ต้องมี ไม่สนใจว่าข้างในทำอะไร · ประโยคที่เอาไปใช้ได้เลย: "type นี้มันต้อง implement interface ตัวไหนถึงจะใช้กับ library นี้ได้"
- implement / satisfy
- "ทำหัวปลั๊กให้ตรงมาตรฐาน" — คือการเขียนเมธอดครบตามที่ interface กำหนด · ใน Go ไม่ต้องประกาศ ไม่มีคีย์เวิร์ด เขียนเมธอดตรงลายเซ็นก็ถือว่าเข้าเงื่อนไขทันที (ศัพท์ทางการของพฤติกรรมนี้คือ implicit interface หรือ structural typing)
- pointer receiver
- ตัว
*ในfunc (l *LogLevel) ...— แปลว่าเมธอดนี้ แก้ค่าของตัวจริงได้ · ถ้าเขียนเป็น value receiver (ไม่มี*) เมธอดจะได้แค่สำเนา แก้ไปก็หายไปกับสำเนานั้น — คือผลรันแบบ 2 ข้างบนเป๊ะ · เวลาโค้ด "ไม่ error แต่ค่าไม่เข้า" ให้ถามตัวเองก่อนเลยว่า "receiver เป็น pointer รึเปล่า" - parse-time validation
- การตรวจความถูกต้องตั้งแต่ตอนแปลงค่าเข้ามา ไม่ใช่ตรวจทีหลัง · ประโยคที่เอาไปคุยกับ senior ได้: "ค่านี้ผมทำ validation ไว้ใน UnmarshalText เลย มันเลย fail ตั้งแต่ parse ไม่ต้องรอ validator"
🎁 เกร็ด — time.Time ใช้ได้ ทั้งที่ README ไม่ได้ลิสต์ไว้
ผมลอง T time.Time `env:"T"` แล้วตั้ง T="2026-08-22T10:30:00Z"
ได้ T=2026-08-22 10:30:00 +0000 UTC (time.Time)
ทำไมถึงได้? เพราะ time.Time implement TextUnmarshaler ไว้อยู่แล้ว
(รับรูปแบบ RFC3339) — library ไม่ต้องรู้จักมันเป็นพิเศษเลย
นี่คือตัวอย่างที่ดีที่สุดของคำว่า "รองรับทุก type ที่ implement TextUnmarshaler" —
มันไม่ใช่คำโฆษณา มันคือรายชื่อที่ยาวขึ้นเองโดยที่ผู้เขียน library ไม่ต้องทำอะไร
nil
ผมรันโดยไม่ตั้ง env สักตัวแล้วพิมพ์ออกมาดู:
D=0s (time.Duration)
LOC=UTC (*time.Location) # ← ตัวนี้ไม่เป็น nil!
UP=<nil> (*url.URL)
PI=<nil>
SL=[]string(nil)
M=map[string]int(nil)
Lvl=main.LogLevel{v:""} LvlP=<nil>
T=0001-01-01 00:00:00 +0000 UTC (time.Time)
สองเรื่องที่ต้องระวัง:
1. map ที่ได้เป็น nil map — อ่านได้ (คืน zero value)
แต่เขียนลงไปจะ panic ทันที ถ้าโค้ดคุณจะ cfg.M["x"] = 1
ต้องเช็ก nil ก่อนเสมอ หรือใส่ envDefault ให้มัน
2. *time.Location ไม่สม่ำเสมอกับ pointer ตัวอื่น —
ไม่ตั้ง env แล้วได้ UTC ไม่ใช่ nil ในขณะที่
*url.URL, *int, *string ได้ nil หมด
ถ้าโค้ดคุณเช็กว่า "ถ้าเป็น nil แปลว่าผู้ใช้ไม่ได้ตั้ง" — ตรรกะนั้นใช้กับ timezone ไม่ได้
zero dependency — ผมไปตรวจมาแล้ว และมันจริงยิ่งกว่าที่สไลด์บอก
กล่องเขียวล่างสุดของสไลด์หน้า 9 เขียนว่า "zero dependency — ไลบรารีนี้ไม่พึ่ง package นอกเลยสักตัว"
วิธีตรวจคือเปิด go.mod ของมันดูว่ามีบล็อก require ไหม:
$ cat ~/go/pkg/mod/github.com/caarlos0/env/v11@v11.4.1/go.mod
module github.com/caarlos0/env/v11
// v11.0.1 accidentally introduced a breaking change regarding the behavior of nil pointers.
// You can now chose to auto-initialize them by setting the `init` tag option.
retract v11.0.1
// v11.2.0 accidentally introduced a breaking change regarding the behavior of nil slices of complex types.
retract v11.2.0
// v11.3.0 merges OS environment variables with environments set with Options instead of overriding them.
retract v11.3.0
go 1.18
$ wc -c ~/go/pkg/mod/github.com/caarlos0/env/v11@v11.4.1/go.sum
0 /Users/tuwanon/go/pkg/mod/github.com/caarlos0/env/v11@v11.4.1/go.sum
# ได้ 0 byte — ไฟล์ว่างเปล่า ไม่มี checksum ของ dependency ให้เก็บเลย
# (path หลังเลข 0 คือ path ของเครื่องผม ของคุณจะเป็นชื่อ user ตัวเอง)
ไม่มีบรรทัด require แม้แต่บรรทัดเดียว และ go.sum ขนาด 0 byte
ที่แรงกว่านั้น: ผมเปิด env_test.go (ไฟล์ test ขนาด 62KB) ดู import ทั้งหมด —
มีแค่ encoding/base64, encoding/json, errors, fmt,
net/http, net/url, os, path/filepath,
reflect, runtime, strconv, strings,
testing, time — standard library ล้วน ไม่มีแม้แต่ testify
🧭 ทำไมเรื่องนี้ถึงควรสนใจ — dependency ทุกตัวที่คุณลากเข้าโปรเจกต์คือ
โค้ดที่คุณไม่ได้เขียนแต่ต้องรับผิดชอบ: ต้องตามอัปเดตเวลามีช่องโหว่ ·
ต้องตามแก้เวลามันขัดกับ dependency ตัวอื่น · และมันดึงของอื่นตามมาอีกเป็นทอด ๆ
library ที่พึ่งแค่ standard library แปลว่าคุณเพิ่มความเสี่ยงเข้าโปรเจกต์แค่ตัวมันเอง ไม่มีของแถม
💡 อีกเกร็ดจาก go.mod ก้อนนั้น: คำว่า retract คือคำสั่งของ Go
ที่ผู้เขียนใช้ประกาศว่า "เวอร์ชันนี้มีปัญหา อย่าใช้" — go get จะข้ามมันให้เอง
สามเวอร์ชันที่โดน retract (v11.0.1 / v11.2.0 / v11.3.0) ล้วนเป็นเรื่อง
พฤติกรรมของ nil pointer และ nil slice ที่เปลี่ยนไปโดยไม่ตั้งใจ —
ซึ่งตรงกับกล่องเตือนเรื่อง nil ข้างบนพอดี แปลว่ามันเป็นจุดที่ยากจริง ๆ
แม้แต่คนเขียน library เองยังพลาดมาแล้วสามครั้ง
7 · แปลง type ไม่ได้ — error หน้าตาเป็นยังไง
ทายก่อนรัน — กลับมาที่ Config ตัวเดิมจากหัวข้อ 2 แล้วตั้งค่าผิดชนิด
type Config struct {
AppPort int `env:"APP_PORT" envDefault:"8080"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"DEBUG"`
PostgresDSN string `env:"POSTGRES_DSN"`
}
// แล้วรัน: APP_PORT=abc go run .
เรารู้แน่ ๆ ว่าต้องได้ err != nil — คำถามอยู่ที่ cfg ที่คืนมาคู่กับ error ต่างหาก
· AppPort จะเป็นอะไร — 0 หรือ 8080 (ตกกลับไปใช้ envDefault)?
· แล้ว LogLevel กับ ShutdownTimeout ที่ไม่ได้มีปัญหาอะไรเลย
จะถูกเติมค่าให้ไหม หรือ struct โดนทิ้งทั้งก้อน?
ทายให้ตรงก่อนกดเฉลย — เพราะคำตอบข้อนี้คือความต่างระหว่างแอปที่พังเสียงดัง
กับแอปที่รันด้วย port 0
$ APP_PORT=abc go run .
cfg=main.Config{AppPort:0, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
คำตอบ: AppPort=0 (zero value ไม่ใช่ 8080) ส่วน field อื่นเติมครบตามปกติ
ShutdownTimeout ได้ 10s · LogLevel ได้ "DEBUG"
— ทั้งคู่มาจาก envDefault เหมือนตอนที่ทุกอย่างปกติ
แปลว่า envDefault ไม่ใช่ตาข่ายรองรับตอน parse พัง —
มันใช้แค่ตอน "ไม่มี env" เท่านั้น ถ้ามี env แต่แปลงไม่ได้ field นั้นได้ zero value
นี่คือจุดที่ต้องระวังที่สุดของบทนี้ — ถ้าใครเขียนแบบนี้:
cfg, _ := config.Load() แล้ว r.Run(fmt.Sprintf(":%d", cfg.AppPort))
คุณจะได้เซิร์ฟเวอร์ที่เปิดที่พอร์ต :0 ซึ่งระบบปฏิบัติการจะแจกพอร์ตสุ่มให้
แอปขึ้นสวยงาม log ไม่มี error แต่ไม่มีใครต่อเข้ามาได้เลย
👉 กฎเหล็ก: Load() ต้องตามด้วย if err != nil { log.Fatal(err) } เสมอ
ไม่มีข้อยกเว้น
ร้านประจำที่คุณสั่งทุกเช้ามีกติกาอยู่ข้อหนึ่ง: ถ้าไม่บอกความหวาน ร้านจะทำ "หวานปกติ" ให้
นั่นคือ envDefault เป๊ะ ๆ — ไม่ตั้ง APP_PORT ก็ได้ 8080
ทีนี้ลองสั่งว่า "เอาหวาน 300%" ซึ่งเป็นคำสั่งที่ร้านทำไม่ได้
คำถามคือ ร้านจะย้อนกลับไปทำหวานปกติให้ไหม? — ไม่
ร้านยื่นแก้วเปล่ามาให้พร้อมใบแจ้งว่า "รายการนี้ทำไม่ได้"
นี่คือผลรันข้างบนทั้งดุ้น: APP_PORT=abc → ได้ AppPort:0
(แก้วเปล่า) คู่กับ err=...invalid syntax (ใบแจ้ง) —
envDefault ทำงานเฉพาะตอน "ไม่ได้สั่ง" ไม่ใช่ตอน "สั่งแล้วทำไม่ได้"
ที่ไม่เหมือนกัน: สองข้อที่ทำให้ของจริงอันตรายกว่าเยอะ —
(1) ที่ร้าน คุณเห็นแก้วเปล่าด้วยตาทันทีและไม่มีทางเผลอยกดื่ม แต่ AppPort ที่เป็น
0 หน้าตาเหมือนเลขปกติทุกอย่าง ·
(2) ที่ร้าน ใบแจ้งถูกยื่นมาให้แน่ ๆ แต่ในโค้ด ถ้าเขียน cfg, _ := config.Load()
คุณโยนใบแจ้งทิ้งด้วยมือตัวเองตั้งแต่บรรทัดแรก
แล้ว "แก้วเปล่า" ใบนั้นก็ใช้งานได้จริงด้วย — ผมลองสั่งฟังพอร์ต :0 ดู:
$ go run . // net.Listen("tcp", ":0")
listening on [::]:59055 // ← ระบบแจกพอร์ตสุ่มให้ ไม่มี error สักตัว
อีกเคสที่มือใหม่พลาดบ่อยมาก — ลืมใส่หน่วยให้ time.Duration:
$ APP_SHUTDOWN_TIMEOUT=10 go run .
cfg=main.Config{AppPort:8080, ShutdownTimeout:0, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "ShutdownTimeout" of type "time.Duration": unable to parse duration: time: missing unit in duration "10"
เขียน 10 คิดว่าเป็น "10 วินาที" — ต้องเขียน 10s
สังเกตว่าข้อความ error ก้อนนี้ห่อกันสามชั้น:
env: (ของ library) → parse error on field "ShutdownTimeout" (ชื่อ field ที่พัง) →
time: missing unit (ข้อความจาก time.ParseDuration ของ standard library
ซึ่งเป็นตัวเดียวกับที่บทเรียน 44 เจอ)
ทั้งสามชั้นบอกคนละอย่างที่จำเป็นทั้งหมด: ใครฟ้อง · field ไหน · เพราะอะไร
- zero value
- "แก้วเปล่า" — ค่าเริ่มต้นที่ Go ใส่ให้ทุกตัวแปรที่ยังไม่ได้กำหนดค่า
(
int→0·string→""· pointer/slice/map →nil) · ข้อควรระวังคือมันแยกไม่ออกจากค่าจริงที่บังเอิญเป็น 0 · ประโยคที่เอาไปใช้ได้: "ตรงนี้ 0 มันคือ zero value หรือคือค่าที่ผู้ใช้ตั้งมาจริง ๆ" - error wrapping
- การห่อ error เดิมไว้ข้างในแล้วเติมบริบทของตัวเอง — คือ
%wในfmt.Errorf("APP_PORT: %w", err)ที่โผล่ในโค้ดหัวข้อ 1 นั่นแหละ · ข้อความสามชั้นข้างบนคือผลของการห่อสองรอบ · ประโยคที่ใช้ได้เลย: "error ตัวนี้ wrap มากี่ชั้น ข้างในสุดมันคือ error ตัวไหน" - blank identifier (
_) - ขีดล่างที่ใช้ทิ้งค่าที่ไม่อยากรับ ·
cfg, _ := config.Load()แปลว่า "โยน error ทิ้ง" ไม่ใช่ "ไม่มี error" · ตอนรีวิวโค้ดจะได้ยินประโยคนี้บ่อยมาก: "อย่ากลืน error ด้วย_นะ" (ภาษาอังกฤษเรียกอาการนี้ว่า swallowing the error)
ผิดหลายจุดพร้อมกัน — env.AggregateError
ถ้าเขียนเองด้วย os.Getenv แบบหัวข้อ 1 พอเจอ error แรกก็ return ทันที
คุณจะแก้ทีละตัว รันใหม่ทีละรอบ
caarlos0/env ทำคนละแบบ — ผมทดสอบด้วย struct ที่ผิดพร้อมกันสามจุด:
type Multi struct {
A string `env:"M_A,required"` // ไม่ตั้ง
B string `env:"M_B,required"` // ไม่ตั้ง
C int `env:"M_C"` // ตั้งเป็น notanint
}
$ M_C=notanint go run .
[multi errors] err=env: required environment variable "M_A" is not set; required environment variable "M_B" is not set; parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
type=env.AggregateError
inner[0] env.VarIsNotSetError: required environment variable "M_A" is not set
inner[1] env.VarIsNotSetError: required environment variable "M_B" is not set
inner[2] env.ParseError: parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
มันไม่ใช่ fail fast — มันบอกทุกปัญหาพร้อมกัน คั่นด้วย "; "
ประโยชน์ตรง ๆ: คนที่เพิ่งเข้าทีมรันครั้งเดียวก็รู้ว่าต้องตั้ง env อะไรบ้างครบทุกตัว
แทนที่จะแก้ทีละตัวรันใหม่สามรอบ
ถ้าอยากจัดการ error ทีละตัวในโค้ด ทำได้สองทาง:
· type assert เป็น env.AggregateError แล้ววนลูป field .Errors
· ใช้ errors.As กับชนิดที่ต้องการ — สี่ตัวที่เจอบ่อยที่สุดคือ:
env.VarIsNotSetError · env.EmptyVarError ·
env.ParseError · env.LoadFileContentError
(ไม่ได้มีแค่สี่ตัวนะ — v11.4.1 export ชื่อที่ลงท้ายด้วย Error ไว้ 11 ชื่อ
ผมนับจากซอร์สจริง ในนั้นมี 2 ชื่อที่เป็น alias คือ EnvVarIsNotSetError
กับ EmptyEnvVarError ที่ชี้ไปตัวเดียวกับสองตัวแรกในลิสต์)
8 · สรุปสิ่งที่บทนี้ปิดไป และสิ่งที่ยังค้าง
| ปัญหาที่บทเรียน 44 ตั้งไว้ | caarlos0/env แก้ให้ไหม |
|---|---|
| พิมพ์ชื่อ env ผิดไม่มีใครเตือน | ⚠️ แก้ได้ครึ่งเดียว — ชื่ออยู่ที่เดียวใน struct จึงตรวจด้วยตาง่ายขึ้นมาก
แต่ถ้าพิมพ์ผิดใน tag ก็ยังเงียบเหมือนเดิม เว้นแต่จะใส่ ,required |
| แปลง type เองทุกที่ | ✅ แก้ครบ — ประกาศชนิดที่ field แล้วจบ |
| default กระจัดกระจาย | ✅ แก้ครบ — envDefault ติดกับ field |
| ไม่มีที่ไหนบอกว่าแอปต้องการอะไร | ✅ แก้ครบ — เปิด struct เดียวเห็นครบทั้งชื่อ ชนิด และ default |
| ระเบิดกลางทาง ไม่ใช่ตอน start | ⚠️ ยังไม่ครบ — ,required ช่วยได้ระดับ "มีตัวแปรไหม"
แต่ไม่ได้ตรวจว่า ค่าที่ได้ใช้ได้จริงไหม (พอร์ต 70000 · URL ที่ไม่ใช่ URL) → เป็นงานของ
บทเรียน 47 |
📌 แล้ววันนี้ยังเหลืออะไรอีก
บทนี้ทำให้ อ่าน env เข้ามาเป็น struct ได้แล้ว แต่ยังมีสองช่องว่าง:
· ตอน dev ใครจะเป็นคนตั้ง env ให้ — จะพิมพ์
APP_PORT=... POSTGRES_DSN=... go run . ทุกครั้งคงไม่ไหว →
บทเรียน 46 (.env + godotenv)
· ค่าที่อ่านเข้ามาแล้ว ใช้ได้จริงหรือเปล่า — APP_PORT=99999
แปลงเป็น int ผ่านฉลุย แต่มันไม่ใช่พอร์ตที่มีอยู่จริง →
บทเรียน 47 (validator/v10)
· แล้วเอาทั้งสามตัวมาประกอบเป็น internal/config ก้อนเดียวใน
บทเรียน 48
ทดสอบความเข้าใจ
1. เขียน Both string `env:"MYVAR,required" envDefault:"fallback"` แล้วรันโดยไม่ตั้ง MYVAR — ได้อะไร?
ผลรันจริง: [A required+envDefault] value="fallback" err=<nil>
เทียบกับ struct ที่มี ,required เปล่า ๆ ในการรันรอบเดียวกัน:
[B required only] value="" err=env: required environment variable "MYVAR2" is not set
— เห็นชัดว่าตัวที่มี envDefault เท่านั้นที่เงียบ
เหตุผลอยู่ในซอร์ส env.go: บรรทัด 613 เช็ก Required && !exists
ส่วน getOr บรรทัด 648 คืน exists = true ทุกครั้งที่มี default —
เงื่อนไขนั้นจึงไม่มีทางเป็นจริง
ตัวเลือกที่ 4 ผิดเพราะ struct tag เป็นแค่สตริง compiler ไม่ได้อ่านความหมายของมันเลย
จุดที่ต้องจำ: field ไหนบังคับ ห้ามใส่ envDefault —
และ notEmpty คู่กับ envDefault ก็โดนกลบแบบเดียวกันเป๊ะ
2. เขียน OnlyRequired string `env:"MYVAR2,required"` (ไม่มี envDefault) แล้วรัน MYVAR2= go run . คือตั้งตัวแปรไว้แต่ให้ค่าว่าง — ได้อะไร?
ผลรันจริงของ MYVAR2= go run . คือ [B required only] value="" err=<nil>
เทียบกับตอนไม่ตั้งเลย ซึ่งได้ err=env: required environment variable "MYVAR2" is not set
— สองสถานะนี้ต่างกันจริง ๆ
ตรงนี้คือความต่างระหว่าง os.LookupEnv กับ os.Getenv
ที่บทเรียน 44 พิสูจน์ไว้เป๊ะ ๆ:
"ตั้งเป็นค่าว่าง" กับ "ไม่ได้ตั้ง" เป็นคนละสถานะ และ ,required เลือกดูแค่สถานะแรก
ซอร์สยืนยัน: env.go บรรทัด 613 เขียนว่า Required && !exists — มีแต่คำว่า
exists ไม่มีการเทียบค่ากับสตริงว่างเลย
ตัวเลือกที่ 2 คือผลของ ,notEmpty ต่างหาก
ทำไมถึงอันตราย: ใน docker-compose.yml การเขียน
POSTGRES_DSN= ลืมใส่ค่า หรืออ้าง ${DB_URL} ที่ไม่มีอยู่
ทำให้เกิด "ตัวแปรมีอยู่แต่ค่าว่าง" ได้ง่ายมาก · อยากกันจริงต้องเขียน
`env:"X,required,notEmpty"`
3. Config มีสี่ field ที่มี envDefault อยู่สามตัว แล้วรัน APP_PORT=abc go run . — cfg ที่คืนมาคู่กับ error หน้าตาเป็นยังไง?
ผลรันจริง:
cfg=main.Config{AppPort:0, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
พร้อม err=env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
ตัวเลือกที่ 2 คือจุดที่คนเข้าใจผิดกันมากที่สุด — envDefault ไม่ใช่ตาข่ายรองรับตอน parse พัง
มันทำงานเฉพาะกรณี "ไม่มี env" เท่านั้น พอมี env แต่แปลงไม่ได้ field นั้นได้ zero value
ตัวเลือกที่ 3 ผิดเพราะ ParseAs คืน (Config, error) เสมอ —
และนี่แหละคือปัญหา เพราะ struct ที่คืนมาดูใช้ได้
อันตรายจริงอยู่ที่: ถ้าเขียน cfg, _ := config.Load()
แล้วเปิดเซิร์ฟเวอร์ที่ cfg.AppPort คุณจะได้พอร์ต :0
ซึ่งระบบแจกพอร์ตสุ่มให้ · แอปขึ้นสวย log ไม่มี error แต่ไม่มีใครต่อเข้ามาได้
Load() ต้องตามด้วย if err != nil { log.Fatal(err) } เสมอ
4. struct มีสาม field ที่ผิดพร้อมกัน (M_A,required ไม่ตั้ง · M_B,required ไม่ตั้ง · M_C int ตั้งเป็น notanint) — err ที่ได้เป็นยังไง?
ผลรันจริง:
err=env: required environment variable "M_A" is not set; required environment variable "M_B" is not set; parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
และเมื่อ type assert เป็น env.AggregateError แล้ววน .Errors ได้สามตัว:
env.VarIsNotSetError · env.VarIsNotSetError · env.ParseError
ตัวเลือกที่ 4 ผิดตรงที่ AggregateError เป็น type ที่ implement error อยู่แล้ว —
signature ยังเป็น (Config, error) เหมือนเดิม แค่ข้างในมี .Errors ให้เจาะ
ทำไมถึงมีค่ากับคุณจริง ๆ: คนที่เพิ่งเข้าทีม
รันครั้งเดียวก็เห็นครบว่าต้องตั้ง env อะไรบ้าง แทนที่จะแก้ทีละตัวรันใหม่สามรอบ —
ซึ่งเป็นสิ่งที่โค้ด os.Getenv เขียนมือในหัวข้อ 1 ทำไม่ได้ เพราะมัน return ทันทีที่เจอ error แรก
เป้าหมายไม่ใช่ "ทำให้ ParseAs รันผ่าน" (อันนั้นง่ายเกินไป) —
เป้าหมายคือทำให้คุณเห็นกับดักด้วยตาตัวเอง จนจำได้โดยไม่ต้องท่อง
- ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ ·
go mod init envdemo·go get github.com/caarlos0/env/v11@v11.4.1
ก๊อปConfigสี่ field จากหัวข้อ 2 มาวาง แล้วพิมพ์ผลด้วย%#v - รันสองรอบ: ไม่ตั้ง env เลย · แล้วตั้งครบทั้งสี่ตัว
ยืนยันด้วยตาว่าShutdownTimeoutพิมพ์ออกมาเป็นเลขนาโนวินาที แล้วลองเปลี่ยนเป็น%vดูว่ามันเปลี่ยนเป็น10sจริงไหม - ทำกับดักข้อ 1 ให้เห็นเอง: เพิ่ม field
`env:"MYVAR,required" envDefault:"fallback"`เข้าไป แล้วรันโดยไม่ตั้งMYVAR
ต้องได้err=<nil>กับค่า"fallback"— ถ้าได้ error แปลว่าคุณลืมใส่envDefault - ทำกับดักข้อ 2: เพิ่มอีก field ที่มีแค่
,requiredเปล่า ๆ แล้วรันสองแบบ —go run .กับMYVAR2= go run .
ตอบให้ได้ว่าทำไมแบบที่สองถึงไม่ error ทั้งที่ค่าว่างเปล่า - แก้กับดักทั้งสอง: เปลี่ยนเป็น
`env:"MYVAR2,required,notEmpty"`แล้วลบenvDefaultออกจาก field ที่บังคับ · รันซ้ำทั้งสองแบบ
คราวนี้ต้อง error ทั้งสองแบบ ด้วยข้อความคนละอันกัน — จดไว้ว่าคนละอันยังไง - กับดัก
,file: สร้าง secret ด้วยecho "s3cr3t" > /tmp/sec.txtแล้วอ่านด้วย`env:"F_VAR,file"`
พิมพ์ผลด้วย%qแล้วดูให้เห็น\nท้ายค่า
จากนั้นสร้างใหม่ด้วยprintf '%s' "s3cr3t" > /tmp/sec2.txtแล้วเทียบกัน - ทำให้มันตายตอน start: เขียน
main()ที่เรียกcfg, err := config.Load()แล้วlog.Fatal(err)ถ้า err ไม่ nil
รันAPP_PORT=abc go run .— เทียบสองแบบ: แบบที่เช็ก err (ตายทันที) กับแบบที่ใช้_แล้วเอาcfg.AppPortไปใช้ต่อ - ท้าทาย — เขียน type ของตัวเอง: ทำ
type LogLevel stringพร้อมเมธอดUnmarshalTextที่รับแค่DEBUG/INFO/WARN/ERROR(แปลงเป็นตัวใหญ่ให้ด้วย) แล้วเปลี่ยน fieldLogLevelมาใช้ชนิดนี้
รันLOG_LEVEL=verbose go run .แล้วดูว่าข้อความที่คุณเขียนเอง ไปโผล่ในข้อความ error ของ library ตรงไหน
ผลรันจริงของ `env:"MYVAR2,required"` (แบบยังไม่ใส่ notEmpty) สองสถานะ:
# ไม่ตั้งเลย
err=env: required environment variable "MYVAR2" is not set
→ ข้างในเป็น env.VarIsNotSetError
# ตั้งเป็นค่าว่าง — MYVAR2= go run .
err=<nil> # ผ่าน! นี่คือช่องโหว่
พอเพิ่ม ,notEmpty เข้าไป สถานะที่สองถึงจะโดนจับ — ข้อความคนละอันชัดเจน:
err=env: environment variable "NE_VAR" should not be empty
→ ข้างในเป็น env.EmptyVarError
สองข้อความนี้ตอบคนละคำถาม และนั่นคือเหตุผลที่ต้องใส่ทั้งคู่:
· is not set (env.VarIsNotSetError) = ลืมประกาศ —
มักเกิดตอนคนใหม่เข้าทีมแล้วยังไม่ได้ตั้ง env
· should not be empty (env.EmptyVarError) = ประกาศแล้วแต่ลืมใส่ค่า —
มักเกิดใน docker-compose.yml หรือ CI ที่ตัวแปรถูกสร้างจากตัวแปรอื่นที่ไม่มีอยู่
ถ้าใส่แค่ ,required ตัวเดียว คุณจับได้แค่กรณีแรก — และกรณีที่สอง
จะทำให้แอปคุณต่อ database ด้วย DSN ว่างเปล่า ซึ่งเป็นเคสเดียวกับที่
บทเรียน 44 พิสูจน์ว่า
sql.Open คืน <nil> แล้วไปพังตอน Ping
💬 ข้อ 8 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ —
เพราะเมื่อคุณเขียน UnmarshalText เองสำเร็จหนึ่งครั้ง
คำว่า "รองรับทุก type ที่ implement encoding.TextUnmarshaler"
จะเลิกเป็นคำโฆษณาที่อ่านผ่าน ๆ แล้วกลายเป็นประตูที่คุณเปิดเองได้
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย —
ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 กับ
caarlos0/env/v11 v11.4.1
และเลขบรรทัดของซอร์ส (env.go บรรทัด 613 / 617 / 648) ก็เป็นของเวอร์ชันนั้น
ถ้าคุณใช้เวอร์ชันอื่น เลขบรรทัดจะขยับ แต่ตรรกะยังเหมือนเดิม
อ้างอิง
- github.com/caarlos0/env — README ตัวจริง · อ่านหัวข้อ "Caveats" ให้ครบ เพราะมันลิสต์พฤติกรรมที่คนคาดไม่ถึงไว้หลายข้อ
- pkg.go.dev —
caarlos0/env/v11— ดูรายชื่อ error type ให้ครบ — ในซอร์ส v11.4.1 มีชื่อที่ลงท้ายด้วยErrorทั้งหมด 11 ชื่อ สี่ตัวที่เจอบ่อยคือ (VarIsNotSetError,EmptyVarError,ParseError,LoadFileContentError) และAggregateErrorที่ห่อทั้งหมดไว้ - pkg.go.dev —
encoding.TextUnmarshaler— interface เมธอดเดียวที่ทำให้ type ของคุณเข้ากับ library นี้ได้ (และกับencoding/jsonด้วย) - pkg.go.dev —
time.ParseDuration— ตัวที่ทำงานอยู่เบื้องหลังtime.Durationfield · ไม่มีหน่วยdสำหรับวัน อยากได้ 1 วันต้องเขียน24h - pkg.go.dev —
reflect— กลไกที่ library ใช้ส่อง struct tag ตอนรัน · อ่านเพื่อเข้าใจว่าทำไม tag ที่พิมพ์ผิดถึงเงียบสนิท - Go Modules Reference —
retract— คำสั่งที่เจอในgo.modของ library นี้สามบรรทัด อธิบายว่าผู้เขียนถอนเวอร์ชันที่มีปัญหายังไง - บทเรียน 44 — Configuration & Environment Variables — โจทย์ทั้งหมดที่บทนี้มาตอบ · ถ้ายังไม่ชัดว่า "ค่าว่าง" กับ "ไม่ได้ตั้ง" ต่างกันยังไง ให้กลับไปอ่านหัวข้อ 4 ของบทนั้นก่อน
- The Twelve-Factor App — III. Config — ต้นทางของแนวคิด "config อยู่ใน environment"