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

caarlos0/env — ประกาศ config เป็น struct ครั้งเดียว

~20 นาที · ต้องมี: บทเรียน 44 (env, os.LookupEnv, การแปลง type)

บทเรียน 44 จบลงตรงประโยคเดียว: "อยากประกาศครั้งเดียวว่าแอปต้องการอะไร แล้วให้เครื่องมือจัดการที่เหลือ" — อ่านให้ · แปลง type ให้ · เติม default ให้ · ขาดตัวไหนก็ฟ้องตั้งแต่ตอน start
สไลด์ชุดนั้นหยุดแค่ตรงโจทย์ ไม่เฉลย · Day 9 หน้า 9–10 คือคำตอบ และมันชื่อ github.com/caarlos0/env/v11
บทนี้ผมจะไม่เล่าแค่ว่า tag ไหนแปลว่าอะไร — เพราะอ่าน README เอาก็ได้ ผมจะรันของจริงให้ดูสามเรื่องที่ทำให้คนตกม้าตาย: ,required ที่ใส่คู่กับ envDefault แล้วไม่มีผลเลย (สไลด์เตือนเอง และเตือนถูก) · ,required ที่ไม่ได้กันค่าว่าง (สไลด์ยังไม่ได้เตือน) · และ struct ที่ไม่ได้ว่างเปล่า เวลา parse พัง

🧰 ของที่ใช้ในบทนี้ — ทุก 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 ทำที่เหลือ วันนี้เราจะได้ของชิ้นนั้นแล้ว

⚠️ ตัวเลข "40 บรรทัด" บนสไลด์ — ผมนับให้ดูจริง ๆ ก่อน

สไลด์หน้า 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

⚠️ struct ซ้อนที่ลืมใส่ 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
✅ กฎที่จำไปใช้ได้เลย 3 ข้อ

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 · stringtrue · hello
เวลาtime.Duration · time.Location1h30m → 1h30m0s · Asia/Bangkok
URLurl.URL · *url.URLpostgres://user@db:5432/app
รวมกลุ่มslice · map · pointera,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 ไม่ต้องทำอะไร

⚠️ ไม่ตั้ง env เลย → pointer / slice / map เป็น 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 รันผ่าน" (อันนั้นง่ายเกินไป) — เป้าหมายคือทำให้คุณเห็นกับดักด้วยตาตัวเอง จนจำได้โดยไม่ต้องท่อง

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ · go mod init envdemo · go get github.com/caarlos0/env/v11@v11.4.1
    ก๊อป Config สี่ field จากหัวข้อ 2 มาวาง แล้วพิมพ์ผลด้วย %#v
  2. รันสองรอบ: ไม่ตั้ง env เลย · แล้วตั้งครบทั้งสี่ตัว
    ยืนยันด้วยตาว่า ShutdownTimeout พิมพ์ออกมาเป็นเลขนาโนวินาที แล้วลองเปลี่ยนเป็น %v ดูว่ามันเปลี่ยนเป็น 10s จริงไหม
  3. ทำกับดักข้อ 1 ให้เห็นเอง: เพิ่ม field `env:"MYVAR,required" envDefault:"fallback"` เข้าไป แล้วรันโดยไม่ตั้ง MYVAR
    ต้องได้ err=<nil> กับค่า "fallback" — ถ้าได้ error แปลว่าคุณลืมใส่ envDefault
  4. ทำกับดักข้อ 2: เพิ่มอีก field ที่มีแค่ ,required เปล่า ๆ แล้วรันสองแบบ — go run . กับ MYVAR2= go run .
    ตอบให้ได้ว่าทำไมแบบที่สองถึงไม่ error ทั้งที่ค่าว่างเปล่า
  5. แก้กับดักทั้งสอง: เปลี่ยนเป็น `env:"MYVAR2,required,notEmpty"` แล้วลบ envDefault ออกจาก field ที่บังคับ · รันซ้ำทั้งสองแบบ
    คราวนี้ต้อง error ทั้งสองแบบ ด้วยข้อความคนละอันกัน — จดไว้ว่าคนละอันยังไง
  6. กับดัก ,file: สร้าง secret ด้วย echo "s3cr3t" > /tmp/sec.txt แล้วอ่านด้วย `env:"F_VAR,file"`
    พิมพ์ผลด้วย %q แล้วดูให้เห็น \n ท้ายค่า
    จากนั้นสร้างใหม่ด้วย printf '%s' "s3cr3t" > /tmp/sec2.txt แล้วเทียบกัน
  7. ทำให้มันตายตอน start: เขียน main() ที่เรียก cfg, err := config.Load() แล้ว log.Fatal(err) ถ้า err ไม่ nil
    รัน APP_PORT=abc go run . — เทียบสองแบบ: แบบที่เช็ก err (ตายทันที) กับแบบที่ใช้ _ แล้วเอา cfg.AppPort ไปใช้ต่อ
  8. ท้าทาย — เขียน type ของตัวเอง: ทำ type LogLevel string พร้อมเมธอด UnmarshalText ที่รับแค่ DEBUG/INFO/WARN/ERROR (แปลงเป็นตัวใหญ่ให้ด้วย) แล้วเปลี่ยน field LogLevel มาใช้ชนิดนี้
    รัน 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) ก็เป็นของเวอร์ชันนั้น ถ้าคุณใช้เวอร์ชันอื่น เลขบรรทัดจะขยับ แต่ตรรกะยังเหมือนเดิม

อ้างอิง