บทเรียน 44 · Day 8 · Config

Configuration & Environment Variables

~15 นาที · ต้องมี: บทเรียน 27 (project layout, cmd/api/main.go)

บทนี้มาจากสไลด์ชุด "Day 8 (Post)" ที่อาจารย์เพิ่มมาให้หลังเรียนจบ (หน้า 85–90) ไม่ได้สอนสดในห้อง
เนื้อหาเริ่มจาก main.go ที่มีรหัสผ่านฐานข้อมูลอยู่ในโค้ดตรง ๆ แล้วไล่ไปจนถึงคำถามว่า ทำไม os.Getenv() อย่างเดียวถึงไม่พอ
ผมจะพิสูจน์ให้ดูสามเรื่องที่ไม่มีอะไรเตือนคุณเลย: พิมพ์ชื่อ env ผิดแล้ว go build กับ go vet เงียบสนิท · ค่าว่างกับ "ไม่ได้ตั้ง" ที่ os.Getenv แยกไม่ออก · และการแปลง string เป็น int/bool/duration ที่พังแล้วคืนค่าที่ดูเหมือนค่าปกติ
สไลด์ชุดนี้หยุดแค่ตรงปัญหา ยังไม่เฉลยว่าใช้ library อะไรแก้ — เดี๋ยวท้ายบทผมบอกว่าปลายทางคืออะไร

🧰 ทุก output ในบทนี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64
github.com/lib/pq v1.10.9   # ใช้เฉพาะหัวข้อ 6 ตอนพิสูจน์เรื่อง DSN

ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่าใหม่ · cd เข้าไป · go mod init envcheck
เกือบทุกหัวข้อใช้แค่ standard library (os, strconv, time) ไม่ต้อง go get อะไรเลย

ในบทนี้มีโปรแกรมเล็ก ๆ สองตัว เพราะมันพิมพ์คนละอย่าง — อย่าสับสนว่าเป็นตัวเดียวกัน:
· ./api (หัวข้อ 2) พิมพ์ APP_PORT · LOG_LEVEL · PID ใช้ดูว่า env ส่งต่อถึง process ลูกไหม
· ./envtest (หัวข้อ 4) พิมพ์ผลของ os.Getenv เทียบกับ os.LookupEnv
ทั้งคู่ build ด้วย go build -o api . / go build -o envtest . คนละโฟลเดอร์

1 · โค้ดนี้ผิดตรงไหน

นี่คือ cmd/api/main.go แบบที่เขียนกันตอนเริ่มโปรเจกต์ — เปิดฐานข้อมูล แล้วเปิดเซิร์ฟเวอร์ จบ (โครง cmd/ มาจาก บทเรียน 27):

db, _ := gorm.Open(postgres.Open(
    "host=localhost user=postgres password=P@ssw0rd123 dbname=wongnok"))

recipeRepo := repository.NewRecipeRepo(db)   // db ถูกใช้ต่อตรงนี้

r := gin.Default()
r.Run(":8080")

บรรทัด recipeRepo := … ใส่ไว้เพื่อให้ตัวอย่างคอมไพล์ผ่านจริง — ถ้าประกาศ db แล้วไม่ได้ใช้ต่อ Go จะไม่ยอมให้ผ่านตั้งแต่ตอน build (declared and not used: db) ซึ่งเป็นเรื่องที่ไม่เกี่ยวกับหัวข้อวันนี้เลย

โค้ดนี้ compile ผ่าน รันได้ ทำงานถูกต้อง บนเครื่องคุณ
และมันมีปัญหาสามข้อที่จะโผล่คนละเวลากัน:

ปัญหา 1 — รหัสผ่านอยู่ในโค้ด

P@ssw0rd123 ไม่ได้อยู่แค่ในไฟล์ พอคุณ git commit แล้ว git push มันจะกลายเป็นส่วนหนึ่งของประวัติ repo ถาวร
จุดที่คนพลาดกันมากที่สุด: ลบทิ้งทีหลังไม่ช่วย — commit เก่ายังอยู่ ใครก็ตามที่ clone repo ไปแล้วสั่ง git log -p ก็ยังอ่านเจอ ถ้า repo เคยเป็น public แม้แค่ชั่วโมงเดียว ต้องถือว่ารหัสผ่านนั้นหลุดแล้ว และวิธีแก้ที่ถูกต้องคือไปเปลี่ยนรหัสผ่านที่ฐานข้อมูล ไม่ใช่แก้ไฟล์

ปัญหา 2 — เปลี่ยนค่า = แก้โค้ด

แอปตัวเดียวกันต้องรันอย่างน้อยสามที่: เครื่องคุณ (dev) · เครื่องทดสอบ (staging) · ของจริง (production) ทั้งสามที่ต่อคนละฐานข้อมูล
โค้ดข้างบนมี host เดียวฝังไว้ แปลว่าจะขึ้น staging ต้องแก้ไฟล์ → commit → build ใหม่
ผลคือ binary ที่คุณเทสบน staging กับที่ปล่อยขึ้น production ไม่ใช่ก้อนเดียวกัน — เทสผ่านบนก้อนหนึ่ง แล้วปล่อยอีกก้อนหนึ่ง

ปัญหา 3 — ทีมใช้ค่าเดียวกันไม่ได้

เพื่อนในทีมสามคน คนหนึ่งลง Postgres ไว้ที่ localhost อีกคนรันใน Docker อีกคนใช้พอร์ตอื่นเพราะพอร์ต 5432 ชนกับของเดิม
โค้ดมี host เดียว ใครแก้ให้ตรงเครื่องตัวเองก็ต้องระวังไม่ให้ commit ติดไป — และสุดท้ายก็มีคน commit ติดไปจนได้ แล้วเครื่องคนอื่นพังหมด

สามข้อนี้มีต้นเหตุเดียวกัน — เราเอาค่าที่เปลี่ยนตามที่ที่รัน ไปปนกับตรรกะที่ไม่เคยเปลี่ยน
ทางแก้จึงมีทางเดียว: ย้ายค่าออกไปอยู่นอกโค้ด แล้วส่งเข้ามาตอนรัน

2 · Environment Variables คืออะไรกันแน่

Environment variable คือ ตัวแปรที่อยู่นอกโปรแกรม — ระบบปฏิบัติการเก็บไว้ให้ทุก process แล้วส่งต่อให้โปรแกรมของเราตอนรัน ไม่ใช่ตอน build
(process = โปรแกรมหนึ่งตัวที่กำลังทำงานอยู่ · เปิดสอง terminal แล้วสั่ง ./api ทั้งสองอัน = สอง process คนละตัว คนละ PID)
คุณสมบัติที่ต้องจำมีสี่ข้อ:

คุณสมบัติแปลว่าอะไรในทางปฏิบัติ
เป็น key-value ที่ค่าเป็น string เสมอ ไม่มี int ไม่มี bool ไม่มี list — PORT=8080 ที่โปรแกรมได้คือตัวอักษรสี่ตัว ไม่ใช่ตัวเลข
โปรแกรมอ่านได้ แต่ไม่ควรแก้ แก้ได้ (os.Setenv) แต่แก้แล้วมีผลแค่ในตัวเองกับลูกที่เกิดทีหลัง — ไม่ย้อนกลับไปหาคนที่เรียกเรา
ส่งต่อจาก parent → child process shell ส่งให้โปรแกรม · go run ส่งต่อให้ binary ที่มันสร้าง · Docker ส่งให้ container
อยู่นอก source code เปลี่ยนค่าแล้วรันใหม่ได้เลย ไม่ต้อง compile ใหม่ — binary ก้อนเดิมขึ้นได้ทุก environment

ข้อ "ส่งต่อจาก parent ไป child" ฟังดูนามธรรม ผมพิสูจน์ให้ดูด้วย PID จริง — โปรแกรมทดสอบพิมพ์ os.Getpid() กับ os.Getppid() ออกมา แล้วผมพิมพ์ PID ของ shell ($$) เทียบ:

$ echo "this shell PID = $$"; APP_PORT=6262 go run .
this shell PID = 34148
APP_PORT = 6262
my PID = 34157  parent PID = 34151

สาม PID ต่างกันหมด: shell (34148) → go run (34151) → binary จริงที่ถูก compile แล้วรัน (34157)
ค่า APP_PORT=6262 วิ่งข้ามไปครบสองทอด โดยเราไม่ได้ทำอะไรเพิ่มเลย

⚠️ กับดักอันดับหนึ่งของคนที่เพิ่งเริ่มใช้ env: ลืมคำว่า export

ใน shell การเขียน APP_PORT=5555 เฉย ๆ กับ export APP_PORT=5555 ไม่เหมือนกัน — แบบแรกเป็นตัวแปรของ shell เท่านั้น ไม่ได้เข้าไปอยู่ใน environment ที่จะส่งต่อให้ลูก ผมรันเทียบให้ดู:

# ก้อนที่ 1 — ไม่มี export
$ APP_PORT=5555; echo "shell sees: $APP_PORT"; ./api
shell sees: 5555          # shell เห็นค่า
APP_PORT  =               # แต่โปรแกรมได้ค่าว่าง
LOG_LEVEL =
PID       = 39661

# ก้อนที่ 2 — มี export
$ export APP_PORT=5555; echo "shell sees: $APP_PORT"; ./api
shell sees: 5555
APP_PORT  = 5555          # คราวนี้โปรแกรมเห็น
LOG_LEVEL =
PID       = 39663

echo เห็น 5555 แต่โปรแกรมได้ค่าว่าง — นี่คือสาเหตุอันดับต้น ๆ ที่คนบ่นว่า "ตั้ง env แล้วนะ แต่โปรแกรมอ่านไม่เจอ"

🧰 เทียบให้เห็นภาพ — ซองเอกสารที่ยื่นให้ตอนใช้ไปทำธุระ

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

· สมุดบนโต๊ะ = ตัวแปรของ shell เฉย ๆ (APP_PORT=5555) — echo เห็น แต่โปรแกรมไม่เห็น
· ยื่นซองให้ถือไป = export · นี่คือบรรทัดเดียวที่คนลืมกันมากที่สุด
· พนักงานคนนั้นใช้เด็กในร้านไปต่ออีกทอด ก็ถ่ายสำเนาซองส่งต่อ = ค่าไหลจาก process แม่ไป process ลูก ตามที่เห็นเป็น PID สามตัวข้างบน
· พนักงานขีดแก้กระดาษในซองของตัวเอง สมุดผู้จัดการไม่เปลี่ยนตาม = os.Setenv ไม่ย้อนกลับไปหาคนที่เรียกเรา

ที่ไม่เหมือนกัน: ซองเป็นสำเนา ณ วินาทีที่เดินออกจากร้าน ผู้จัดการแก้สมุดทีหลัง คนที่ออกไปแล้วไม่รู้เรื่อง
env ก็เหมือนกัน — โปรแกรมที่รันค้างอยู่แล้วจะไม่เห็นค่าใหม่ที่คุณเพิ่ง export ต้องปิดแล้วรันใหม่เท่านั้น

มีอีกท่าหนึ่งที่ใช้บ่อยกว่า export เวลาทดสอบ คือเขียนนำหน้าคำสั่ง ซึ่งมีผลเฉพาะคำสั่งนั้น ไม่ค้างใน shell:

$ export APP_PORT=8080; APP_PORT=9090 ./api; echo "shell still: $APP_PORT"
APP_PORT  = 9090             # โปรแกรมเห็น 9090
LOG_LEVEL =
PID       = 39699
shell still: 8080            # shell ยังเป็น 8080 เหมือนเดิม

จำสองเรื่องจากผลนี้: เขียนนำหน้าคำสั่งชนะ export สำหรับ process นั้น · และค่าไม่ค้าง พอคำสั่งจบ shell กลับไปเป็นค่าเดิม

🗣 ศัพท์ที่เพิ่งเรียนไป

ที่เพิ่งเข้าใจไปทั้งหมด วงการเรียกด้วยคำพวกนี้ — ใช้คำให้ตรง แล้วคำตอบที่ได้จาก senior หรือจาก AI จะตรงตาม

process
โปรแกรมหนึ่งตัวที่กำลังทำงานอยู่ · เวลาบอกอาการ ให้พูดว่า “ค่ามันไม่ถึง process ลูก” จะสื่อกว่าพูดว่า “โปรแกรมมันอ่านไม่เจอ”
environment variable (เรียกสั้น ๆ ว่า env)
คู่ key-value ที่ค่าเป็น string เสมอ ซึ่งระบบปฏิบัติการส่งมอบให้ process ตอนเริ่มทำงาน (ก็คือ “ซอง” ในอุปมาข้างบน) · คำว่า env ใช้ได้ทั้งในโค้ดและในบทสนทนา ไม่ต้องพูดเต็ม
export
คำสั่งของ shell ที่แปลว่า “ตัวแปรตัวนี้ใส่ซองไปด้วยนะ” · ประโยคที่ควรถามเป็นอันดับแรกเสมอคือ “ตัวนี้ export แล้วหรือยัง”
parent process / child process
ใครเรียกใครขึ้นมา — go run เป็น parent ของ binary ที่มันสร้าง · ประโยคมาตรฐานคือ “env ไหลจาก parent ไป child ทางเดียว”
PID
เลขประจำตัว process ที่ระบบปฏิบัติการแจกให้ · ใช้ยืนยันตอนไล่ปัญหาว่า “นี่คนละ process กันจริง ๆ นะ”

3 · ไม่ใช่เรื่องเฉพาะ Go

ตรงนี้สำคัญกว่าที่คิด: env ไม่ใช่ feature ของ Go แต่เป็นกติกาของระบบปฏิบัติการ ที่ทุกภาษาและทุกเครื่องมือ deploy เห็นตรงกัน
แปลว่าเมื่อคุณเรียนวิธีนี้ครั้งเดียว คุณใช้ได้กับทุกที่ที่โค้ดของคุณจะไปอยู่:

ที่ที่รันจริงตั้ง env ยังไง
Shell (เครื่องคุณ)export POSTGRES_DSN=...
Docker-e LOG_LEVEL=INFO หรือช่อง environment: ใน compose
Kubernetesenv: ใน manifest · ConfigMap (ค่าธรรมดา) · Secret (ค่าลับ)
CI/CDGitLab CI Variables · GitHub Actions secrets
CloudECS Task Definition · Cloud Run · Lambda

และฝั่งภาษาโปรแกรม ทุกภาษาก็มีวิธีอ่านของตัวเอง หน้าตาต่างกันแต่เป็นของสิ่งเดียวกัน:

ภาษาอ่าน env ยังไง
Goos.Getenv("POSTGRES_DSN")
Pythonos.environ["POSTGRES_DSN"]
Node.jsprocess.env.POSTGRES_DSN
JavaSystem.getenv("POSTGRES_DSN")

แนวคิดนี้มีชื่อเรียกและมีที่มา — มาจากเอกสารชื่อ 12-Factor App ซึ่งเป็นชุดหลักการ 12 ข้อสำหรับเขียนแอปที่ deploy ง่าย
ข้อที่เกี่ยวกับเราคือ Factor III: Config ที่บอกว่า config ต้องแยกออกจาก code และเก็บใน environment
เกณฑ์ทดสอบง่าย ๆ ที่เอกสารนั้นให้ไว้คือ: ถ้าตอนนี้เปิด repo ให้เป็น public ทันที มีอะไรหลุดไหม — ถ้ามี แปลว่า config ยังไม่แยก

⚠️ Docker ไม่ สืบทอด env จาก shell ให้อัตโนมัติ

หัวข้อที่แล้วบอกว่า env ไหลจาก parent ไป child — จริงสำหรับ go run แต่ไม่จริงสำหรับ container เพราะ container เป็นพื้นที่แยกของมันเอง
กลไกจริงไม่ใช่ "มีอะไรมาตัด env ทิ้ง" แต่เป็นเรื่องใครเป็น parent: docker run ที่คุณพิมพ์เป็นแค่ตัวสั่งงาน Docker daemon อีกที process ในคอนเทนเนอร์จึงเป็นลูกของ daemon ไม่ใช่ลูกของ shell คุณ — กฎ parent→child ยังจริงทุกข้อ แค่คุณไม่ได้เป็น parent ของมัน ค่าเลยไม่ไหลตามไป
(ข้างใน container เอง process ลูกก็ยังรับ env ต่อจากแม่ได้ตามปกติ) ผมรันพิสูจน์:

$ export LOG_LEVEL=FROM_HOST; docker run --rm alpine printenv LOG_LEVEL
host LOG_LEVEL = FROM_HOST
exit=1                          # container ไม่เห็นเลย ไม่พิมพ์อะไรออกมา

$ docker run --rm -e LOG_LEVEL alpine printenv LOG_LEVEL
FROM_HOST                      # เขียน -e ชื่อเปล่า ๆ = หยิบค่าจาก shell ส่งเข้าไป

ท่า -e LOG_LEVEL แบบไม่ใส่ = มีประโยชน์มาก: ส่งค่าเข้า container ได้โดยค่าลับไม่โผล่ในบรรทัดคำสั่ง (ซึ่ง shell history เก็บไว้)
อีกเรื่องที่ควรรู้: --env-file app.env ใช้ได้ และถ้าตั้งชื่อเดียวกันทั้งสองทาง -e ชนะ --env-file — ผมรันเทียบแล้วได้ FROM_FLAG ทับ FROM_FILE

4 · เรียกใช้ env ใน Go แบบพื้นฐาน

Go มีสองฟังก์ชันสำหรับอ่าน env และความต่างของมันคือหัวใจของหัวข้อนี้:

// แบบที่ 1 — ได้ค่ามาอย่างเดียว ไม่มี error
level := os.Getenv("LOG_LEVEL")

// แบบที่ 2 — ได้ค่า + บอกด้วยว่า "ตัวแปรนี้มีอยู่จริงไหม"
level, ok := os.LookupEnv("LOG_LEVEL")

os.Getenv ถ้าไม่เจอจะคืน "" เฉย ๆ · ไม่มี error ไม่มี panic
ส่วน os.LookupEnv คืนค่าที่สองเป็น bool ซึ่งตอบคำถามคนละข้อ: ไม่ใช่ "ค่าว่างไหม" แต่คือ "ตัวแปรนี้ถูกตั้งไว้หรือเปล่า"

📮 เทียบให้เห็นภาพ — ตู้จดหมายหน้าบ้าน

สมมติคุณใช้ให้คนไปดูตู้จดหมายหน้าบ้านหลังหนึ่ง แล้วกลับมารายงาน
· os.Getenv คือคนที่กลับมายื่นมือเปล่าให้ดู พูดคำเดียวว่า “ไม่มีจดหมาย”
· os.LookupEnv คือคนที่กลับมาบอกสองอย่าง: “บ้านนี้มีตู้ไหม” และ “ในตู้มีอะไร”

ทีนี้ลองนึกถึงสองบ้านนี้ — บ้าน A มีตู้ แต่ตู้ว่าง · บ้าน B ไม่มีตู้เลย
คนแรกกลับมารายงานเหมือนกันเป๊ะทั้งสองบ้าน เพราะมือเปล่าเหมือนกัน
คนที่สองแยกออก เพราะเขาตอบคำถาม “มีตู้ไหม” มาด้วย — นั่นคือค่า ok

ที่ไม่เหมือนกัน: ตู้จดหมายที่ว่างมักแปลว่า “ยังไม่มีใครส่งมา” คือไม่มีใครตั้งใจ
แต่ env ที่ตั้งเป็นค่าว่างส่วนใหญ่ตั้งใจ — เช่น HTTP_PROXY="" ที่แปลว่า “ห้ามใช้ proxy” · ถ้าโค้ดเหมาว่าเป็น “ไม่ได้ตั้ง” แล้วไปเติม default ให้ ก็คือทำตรงข้ามกับที่เขาสั่ง

ทายก่อนรัน — โปรแกรมทดสอบมีแค่สองบรรทัดนี้ (พิมพ์ด้วย %q จึงมีเครื่องหมายคำพูดครอบค่าที่ได้)

fmt.Printf("Getenv    = %q\n", os.Getenv("LOG_LEVEL"))
v, ok := os.LookupEnv("LOG_LEVEL")
fmt.Printf("LookupEnv = %q, ok=%v\n", v, ok)

รันสามแบบ — ทายว่าแต่ละแบบได้อะไร
1. env LOG_LEVEL=DEBUG ./envtest (ตั้งค่าปกติ)
2. env LOG_LEVEL= ./envtest (ตั้งเป็นค่าว่าง — ไม่มีอะไรหลัง =)
3. env -u LOG_LEVEL ./envtest (ไม่ได้ตั้งเลย)
คำถามจริง ๆ ที่ผมอยากให้ตอบคือ: แบบ 2 กับแบบ 3 ต่างกันไหม ในสายตาของแต่ละฟังก์ชัน?

$ env LOG_LEVEL=DEBUG ./envtest
Getenv    = "DEBUG"
LookupEnv = "DEBUG", ok=true

$ env LOG_LEVEL= ./envtest
Getenv    = ""
LookupEnv = "", ok=true

$ env -u LOG_LEVEL ./envtest
Getenv    = ""
LookupEnv = "", ok=false

os.Getenv ให้ผลเหมือนกันเป๊ะในแบบ 2 กับแบบ 3 — ได้ "" ทั้งคู่ แยกไม่ออก
มีแต่ os.LookupEnv ที่แยกได้ด้วยค่า ok

และมันไม่ใช่เรื่องในหัวของ Go อย่างเดียว — ผมเช็คในฝั่ง shell ด้วย printenv ยืนยันว่า สองสถานะนี้มีอยู่จริงในระบบปฏิบัติการ:

$ export LOG_LEVEL=""; ./envtest; printenv | grep -c '^LOG_LEVEL'
LookupEnv = "", ok=true
--- printenv | grep -c LOG_LEVEL ---
1        # ตัวแปรมีอยู่จริง แค่ค่าว่าง

$ unset LOG_LEVEL; ./envtest; printenv | grep -c '^LOG_LEVEL'
LookupEnv = "", ok=false
--- printenv | grep -c LOG_LEVEL ---
0        # ไม่มีตัวแปรนี้แล้วจริง ๆ

สรุปเป็นตารางสามกรณี ทุกช่องมาจากผลรันในกล่องเฉลยข้างบน:

สถานะของ LOG_LEVELos.Getenvos.LookupEnvแยกออกไหม
ตั้งเป็น DEBUG "DEBUG" "DEBUG", ok=true —
ตั้งเป็นค่าว่าง (export LOG_LEVEL="") "" "", ok=true Getenv แยกไม่ออก
LookupEnv แยกออก
ไม่ได้ตั้งเลย (unset LOG_LEVEL) "" "", ok=false

🧭 แล้วความต่างนี้สำคัญตอนไหน — ตอนที่คุณจะเขียน "ถ้าไม่ได้ตั้งมา ให้ใช้ค่า default"
ถ้าใช้ Getenv คุณจะเดา default ให้คนที่ตั้งใจตั้งเป็นค่าว่างด้วย — เช่นคนที่ตั้ง HTTP_PROXY="" เพื่อบอกว่า "ไม่ต้องใช้ proxy" แล้วโปรแกรมคุณกลับไปหยิบ proxy ตัว default มาใช้แทน
ระวังตอนเช็คใน shell ด้วย: echo $LOG_LEVEL หรือ [ -z "$LOG_LEVEL" ] แยกสองสถานะนี้ไม่ออกเหมือนกัน — ต้องใช้ printenv | grep '^LOG_LEVEL' ถึงจะเห็นความจริง

🗣 ศัพท์ที่เพิ่งเรียนไป
comma ok
ท่าเขียน v, ok := ... ที่ค่าที่สองบอกว่า “มีของอยู่จริงไหม” · Go ใช้ท่านี้กับ map และอีกหลายที่ · พูดว่า “ตรงนี้ต้องรับแบบ comma ok มาเช็คก่อน” แล้ว dev Go จะเข้าใจทันที
unset vs empty
“ไม่ได้ตั้ง” กับ “ตั้งเป็นค่าว่าง” · เวลาแจ้งปัญหาให้ระบุให้ชัดว่าเป็นแบบไหน เช่น “ตัวแปรนี้ unset หรือ set เป็น empty string กันแน่” — คำถามเดียวนี้ตัดสาเหตุทิ้งไปได้ครึ่งหนึ่ง
default / fallback
ค่าที่โปรแกรมหยิบมาใช้เองเมื่อไม่มีใครกำหนดมา · ประโยคที่ควรถามให้ชัดคือ “เรา fallback ตอนไหน — ตอน unset หรือตอนค่าว่างด้วย”

5 · กับดักแรก — ทุกอย่างเป็น string

PORT=8080 ที่โปรแกรมได้รับคือสตริง "8080" ไม่ใช่เลข 8080
อยากได้ int ต้องแปลงเอง และทุกฟังก์ชันแปลงคืน error มาด้วยเสมอ:

อยากได้ใช้ฟังก์ชันตัวอย่างที่ผ่าน (ผลรันจริง)
intstrconv.AtoiAtoi("8080") -> v=8080, err=<nil>
time.Durationtime.ParseDurationParseDuration("30s") -> v=30s, err=<nil>
boolstrconv.ParseBoolParseBool("true") -> v=true, err=<nil>

ทายก่อนรัน — สามบรรทัดนี้ ค่าที่ป้อนเข้าไปคือค่าที่คนเขียนไฟล์ config พิมพ์มาจริง ๆ

strconv.Atoi("")                  // PORT ไม่ได้ตั้งค่า → Getenv คืน ""
time.ParseDuration("30")        // คนเขียน TIMEOUT=30 แล้วคิดว่า "30 วินาที"
strconv.ParseBool("yes")          // คนที่มาจาก YAML เคยชินกับ yes/no

ทั้งสามอันได้ error หมด — แต่ค่าที่คืนมาคู่กับ error คืออะไร?
และถ้าโค้ดเผลอเขียน v, _ := strconv.ParseBool(...) (ทิ้ง error) จะเกิดอะไรขึ้นกับแอป?

strconv.Atoi("") -> v=0, err=strconv.Atoi: parsing "": invalid syntax
    err type: *strconv.NumError
    NumError{Func:"Atoi", Num:"", Err:invalid syntax}

time.ParseDuration("30") -> v=0s, err=time: missing unit in duration "30"
    err type: *time.parseDurationError

strconv.ParseBool("yes") -> v=false, err=strconv.ParseBool: parsing "yes": invalid syntax
    err type: *strconv.NumError

สังเกตค่าที่คืนมาให้ดี ๆ: 0 · 0s · false
ทั้งสามค่านี้ไม่หน้าตาเหมือน error เลย — มันดูเหมือนค่าที่ตั้งใจตั้งมา:
0 ดูเหมือน "พอร์ต 0" · 0s ดูเหมือน "timeout ทันที" · false ดูเหมือน "ตั้งใจปิดฟีเจอร์ไว้"

นี่คือเหตุผลที่การเขียน _ ทิ้ง error ตอนแปลง config ไม่ทำให้แอป crash แต่ทำให้แอปทำงานผิดแบบเงียบสนิท — ซึ่งหาสาเหตุยากกว่า crash หลายเท่า

เคสข้างบนยังพอเดาได้ ทีนี้ผมรันเคสที่เดาไม่ได้ ให้ดู เริ่มจาก Atoi:

strconv.Atoi("8080 ")   -> n=0, err=strconv.Atoi: parsing "8080 ": invalid syntax   # ช่องว่างท้ายตัวเดียว
strconv.Atoi(" 8080")   -> n=0, err=strconv.Atoi: parsing " 8080": invalid syntax
strconv.Atoi("8080\n")  -> n=0, err=strconv.Atoi: parsing "8080\n": invalid syntax
strconv.Atoi("8080.0")  -> n=0, err=strconv.Atoi: parsing "8080.0": invalid syntax
strconv.Atoi("+8080")   -> n=8080, err=<nil>                                        # ผ่าน
strconv.Atoi("08080")   -> n=8080, err=<nil>                                        # ผ่าน
strconv.Atoi("8_080")   -> n=0, err=strconv.Atoi: parsing "8_080": invalid syntax
strconv.Atoi("99999999999999999999")
    -> n=9223372036854775807, err=strconv.Atoi: parsing "99999999999999999999": value out of range
⚠️ อ่านบรรทัดสุดท้ายอีกรอบ — "error แล้วได้ 0 เสมอ" เป็นความเชื่อที่ผิด

เลขที่ใหญ่เกิน int64 ทำให้ Atoi คืน 9223372036854775807 (ค่าสูงสุดของ int64) พร้อม error ไม่ใช่คืน 0
ถ้าโค้ดเขียน n, _ := strconv.Atoi(os.Getenv("MAX_ITEMS")) แล้วมีคนพิมพ์เลขยาวเกินไป คุณจะได้ลิมิตเก้าล้านล้าน แทนที่จะเป็นศูนย์ — ค่าที่หลุดออกมาดูเหมือนตัวเลขจริง
และ error กรณีนี้เป็นคนละตัวกับเคสอื่น: value out of range ไม่ใช่ invalid syntax
ส่วนช่องว่างท้ายบรรทัด: Atoi ไม่ trim ให้เลย — ซึ่งเจอบ่อยมากเวลาก๊อปค่าจากไฟล์ .env ที่มีช่องว่างติดมา

⚖️ เทียบให้เห็นภาพ — ตาชั่งเข็มหน้าร้านผลไม้

ตาชั่งเข็มหน้าร้านผลไม้ ชั่งได้สูงสุด 5 กิโล
· ไม่วางอะไรเลย → เข็มชี้ 0
· วางทุเรียนลูก 12 กิโล → เข็มวิ่งไปสุดสเกลแล้วค้างอยู่ที่ 5

เลขทั้งสองอันนี้อ่านได้ปกติ ดูไม่ออกเลยว่าชั่งไม่สำเร็จ — คนซื้อเห็น “5 กิโล” ก็จ่ายเงินไป
ตรงกับผลรันข้างบนเป๊ะ: Atoi("") ได้ 0 · Atoi("99999999999999999999") ได้ 9223372036854775807 ซึ่งก็คือ “สุดสเกล” นั่นเอง
(Atoi คืนชนิด int ซึ่งบนเครื่อง 64-bit กว้างเท่า int64 ตัวเลขนี้จึงเป็นค่าสูงสุดของทั้งสองชนิดพอดี)

ที่ไม่เหมือนกัน: ตาชั่งไม่มีทางบอกเราได้ว่ามันชั่งไม่ไหว
แต่ Go บอกอยู่แล้ว — มันคืน error มาคู่กับค่าเสมอ
ค่าที่หลอกตาจึงไม่ใช่ความผิดของ Go แต่เกิดตอนที่โค้ดเขียน _ ทิ้ง error ตัวนั้นไปเอง

time.ParseDuration แปลกกว่านั้นอีก เพราะมันมีข้อความ error ถึงสามแบบ:

time.ParseDuration("1h30m") -> d=1h30m0s, err=<nil>      # ต่อหน่วยกันได้
time.ParseDuration("-5s")   -> d=-5s, err=<nil>
time.ParseDuration("30ms")  -> d=30ms, err=<nil>
time.ParseDuration("0")     -> d=0s, err=<nil>            # ข้อยกเว้นเดียวที่ไม่ต้องมีหน่วย

time.ParseDuration("")      -> d=0s, err=time: invalid duration ""
time.ParseDuration("abc")   -> d=0s, err=time: invalid duration "abc"
time.ParseDuration("30")    -> d=0s, err=time: missing unit in duration "30"
time.ParseDuration(" 30s")  -> d=0s, err=time: invalid duration " 30s"
time.ParseDuration("30s ")  -> d=0s, err=time: unknown unit "s " in duration "30s "
time.ParseDuration("30S")   -> d=0s, err=time: unknown unit "S" in duration "30S"

สองบรรทัดล่างสุดคือของแปลก: "30s " ที่มีช่องว่างท้ายไม่ได้ขึ้นว่า invalid แต่ขึ้นว่า unknown unit "s " — ซึ่งอ่านแล้วงงมากว่าหน่วย s ไม่รู้จักได้ยังไง (คำตอบ: หน่วยที่มันเห็นคือ "s " พร้อมช่องว่าง ไม่ใช่ "s")
และ "30S" ตัว S ใหญ่ก็ไม่ผ่าน เพราะหน่วยเป็น case-sensitive

strconv.ParseBool เป็นตัวที่คนเดาผิดบ่อยที่สุด เพราะมันรับเฉพาะ 12 ค่านี้ ไม่มีอย่างอื่นเลย ผมยืนยันสองทาง — brute force ทดสอบ 24 ค่า (ทุกค่าที่โชว์ข้างล่างนี้) และเปิดซอร์สจริงของ Go 1.26.5 อ่าน:

// จาก src/internal/strconv/atob.go ของ Go 1.26.5 — เป็น switch ตรง ๆ ไม่มี trim ไม่มี lowercase
func ParseBool(str string) (bool, error) {
	switch str {
	case "1", "t", "T", "true", "TRUE", "True":
		return true, nil
	case "0", "f", "F", "false", "FALSE", "False":
		return false, nil
	}
	return false, ErrSyntax
}
  "1" "t" "T" "TRUE" "true" "True"      OK  -> true
  "0" "f" "F" "FALSE" "false" "False"   OK  -> false

  "tRue"   ERROR  strconv.ParseBool: parsing "tRue": invalid syntax   # พิมพ์ผสมไม่ได้
  "yes"    ERROR  strconv.ParseBool: parsing "yes": invalid syntax
  "no"     ERROR  strconv.ParseBool: parsing "no": invalid syntax
  "on"     ERROR  strconv.ParseBool: parsing "on": invalid syntax
  "off"    ERROR  strconv.ParseBool: parsing "off": invalid syntax
  "y" "n" "Y" "N" "2" "-1"              ERROR  ... invalid syntax
  " true"  ERROR  strconv.ParseBool: parsing " true": invalid syntax   # ช่องว่างก็ไม่ได้

📌 ที่ต้องระวังคือ case-sensitive แบบเลือกข้าง — "true" "TRUE" "True" ผ่านหมด แต่ "tRue" ไม่ผ่าน เพราะซอร์สข้างบนเทียบ string ตรง ๆ ทีละค่า ไม่ได้ทำ lowercase ก่อน
และ yes/no/on/off ที่ใช้กันเกลื่อนในไฟล์ YAML ของ stack อื่น ใช้กับ Go ไม่ได้เลย — ใครย้ายมาจาก Python หรือ Rails จะเจอกับดักนี้แน่นอน

🗣 ศัพท์ที่เพิ่งเรียนไป
parse
“อ่านข้อความแล้วแปลงเป็นค่าที่ใช้งานได้จริง” · ชื่อฟังก์ชันอย่าง ParseBool ParseDuration มาจากคำนี้ · พูดว่า “มัน parse ค่านี้ไม่ผ่าน” ชัดกว่าพูดว่า “มันอ่านค่าไม่ได้”
zero value
ค่าเริ่มต้นประจำชนิดข้อมูลของ Go — 0 ของตัวเลข · false ของ bool · "" ของ string · แต่จำไว้ว่าเวลา error ไม่ได้คืน zero value เสมอ (เคส out of range คืนค่าสูงสุดมาให้)
blank identifier (เครื่องหมาย _)
ที่ทิ้งค่าที่เราไม่อยากรับ · ประโยคที่ควรพูดตอนรีวิวโค้ดคือ “ตรงนี้ทิ้ง error ด้วย blank identifier อยู่ ขอรับมาเช็คก่อนได้ไหม”
silent failure ↔ fail fast
silent failure = พังแบบเงียบ ไม่ crash ไม่มี log แต่ผลลัพธ์ผิด · fail fast = ผิดปุ๊บตายปั๊บตั้งแต่เนิ่น ๆ · ประโยคที่ senior ใช้กันบ่อยคือ “เคสนี้เรา fail fast ดีกว่าไหม”

6 · ปัญหาของการใช้ os.Getenv() เยอะ ๆ

มาถึงตรงนี้เรามีเครื่องมือครบแล้ว — อ่าน env ได้ แปลง type ได้ เช็ค error ได้
แล้วปัญหาอยู่ตรงไหน? ปัญหาคือพอโปรเจกต์โต แล้วเรียก os.Getenv กระจายไปทั่วโค้ด มันจะพังห้าแบบที่ไม่มีอะไรจับได้เลย

1 · พิมพ์ผิดไม่มีใครเตือน

ชื่อ env เป็นสตริงธรรมดา — compiler ไม่รู้จักมัน ไม่มีรายชื่อให้เทียบ
ผมทดสอบด้วยการเขียน os.Getenv("POSTGRES_DNS") (สลับ SN เป็น NS) ทั้งที่ตอนรันตั้ง POSTGRES_DSN ไว้จริง:

$ go build ./... ; echo "exit=$?"
exit=0
(ไม่มี output ใด ๆ เลย)

$ go vet ./... ; echo "exit=$?"
exit=0
(ไม่มี output ใด ๆ เลย)

เครื่องมือทั้งสองตัวเงียบสนิท แล้วตอนรันได้แบบนี้:

$ POSTGRES_DSN="postgres://user:pass@localhost:5432/mydb?sslmode=disable" go run .
dsn = ""
len(dsn) = 0
LookupEnv(POSTGRES_DSN) = "postgres://user:pass@localhost:5432/mydb?sslmode=disable", ok=true
LookupEnv(POSTGRES_DNS) = "", ok=false
-> โปรแกรมเดินต่อได้ ไม่ panic ไม่ error
exit=0

ค่าจริงอยู่ครบในตัวแปรที่สะกดถูก แต่โค้ดหยิบตัวที่สะกดผิดแล้วได้ "" แล้วเดินต่อเหมือนไม่มีอะไรเกิดขึ้น
สังเกตว่า LookupEnv ตอบ ok=false ให้แล้ว — ข้อมูลมีอยู่ แต่ถ้าโค้ดใช้ Getenv ก็ไม่มีใครเอาไปใช้

2 · แปลง type เองทุกที่

ทุกจุดที่ต้องการตัวเลขต้องเขียนสามบรรทัดเดิมซ้ำ: อ่าน → แปลง → จัดการ error
สิบ config = สามสิบบรรทัดที่หน้าตาเหมือนกันหมด และบรรทัดจัดการ error คือบรรทัดที่คนขี้เกียจเขียนที่สุด พอมี _ โผล่มาสักตัวเดียว คุณก็ได้ผลตามหัวข้อ 5 ทันที

3 · default กระจัดกระจาย

port := os.Getenv("PORT")
if port == "" { port = "8080" }        // ← อยู่ใน main.go

// อีกไฟล์หนึ่ง คนละคนเขียน
p := os.Getenv("PORT")
if p == "" { p = "3000" }             // ← default ไม่ตรงกัน แต่ไม่มีใครเห็น

ก้อน if x == "" { x = ... } กระจายอยู่ทั่วโปรเจกต์ · เวลาจะเปลี่ยน default ต้องไล่หาให้ครบทุกจุด และพลาดหนึ่งจุดก็ไม่มีอะไรฟ้อง

4 · ไม่มีที่ไหนบอกว่าแอปต้องการ config อะไรบ้าง

คำถามที่คนใหม่เข้าทีมถามวันแรกเสมอ: "ต้องตั้ง env อะไรบ้างถึงจะรันได้"
คำตอบที่ตรงที่สุดในโค้ดแบบนี้คือ grep -r "os.Getenv" . — ซึ่งบอกได้แค่ชื่อ ไม่บอกว่าตัวไหนบังคับ ตัวไหนมี default ตัวไหนต้องเป็นตัวเลข
เอกสารก็เชื่อไม่ได้ เพราะมันไม่ได้ผูกกับโค้ด — คนเพิ่ม env ใหม่แล้วลืมอัปเดต README เป็นเรื่องปกติ

5 · ระเบิดกลางทาง ไม่ใช่ตอน start

ข้อนี้แสบที่สุด ผมรันพิสูจน์กับ database/sql จริง — ลืมตั้ง DSN (DSN = ข้อความบรรทัดเดียวที่บอกว่าจะต่อฐานข้อมูลเครื่องไหน ด้วยชื่อผู้ใช้และรหัสอะไร) แล้วส่งค่าว่างเข้าไป:

=== A. DSN ว่าง (ลืมตั้ง env) | driver="postgres" dsn=""
  sql.Open err  = <nil>                    # เปิดผ่าน ไม่มี error
  Ping err      = dial tcp [::1]:5432: connect: connection refused
  Query err     = dial tcp [::1]:5432: connect: connection refused

sql.Open คืน <nil> — แอป start ผ่าน log ขึ้นสวยงามว่าเซิร์ฟเวอร์พร้อมแล้ว
สาเหตุคือ database/sql เป็นแบบ lazy: sql.Open แค่เช็คว่า รู้จักชื่อ driver ตัวนี้ไหม แล้วสร้าง connection pool เปล่า ๆ ไว้ (connection pool = แอ่งของสายที่ต่อค้างไว้ ให้หลาย request หยิบใช้สลับกัน แทนที่จะต่อใหม่ทุกครั้ง) ยังไม่ต่อจริง
ผมทดสอบเคสอื่นเทียบด้วย ยืนยันว่ามันไม่ตรวจ DSN เลยแม้แต่รูปแบบ:

=== B. DSN ขยะ | dsn="this-is-not-a-dsn"
  sql.Open err  = <nil>                    # ขยะชัด ๆ ก็ยังผ่าน
  Ping err      = missing "=" after "this-is-not-a-dsn" in connection info string"

=== D. driver ที่ไม่ได้ register | driver="mysqlx"
  sql.Open err  = sql: unknown driver "mysqlx" (forgotten import?)   # เคสเดียวที่ error
⚠️ error ที่ได้ ไม่ได้ชี้ไปที่ต้นเหตุเลย

ดูข้อความในเคส A อีกรอบ: dial tcp [::1]:5432: connect: connection refused
ไม่มีคำว่า DSN ไม่มีคำว่า env ไม่มีคำว่า config อยู่ในข้อความเลยสักตัว
เพราะ driver ของ Postgres เจอ DSN ว่าง มันไม่บ่น แต่ไปเดาค่า default ให้ (localhost:5432) แล้วต่อไม่ติด
ผมลองเทียบกับเคสที่ DSN ถูกต้องทุกอย่างแต่ชี้ไปพอร์ตที่ไม่มีใครฟัง:

A. dsn=""                     -> Ping err = dial tcp [::1]:5432: connect: connection refused
C. dsn="postgres://u:p@127.0.0.1:59999/db?..."
                              -> Ping err = dial tcp 127.0.0.1:59999: connect: connection refused

error สองอันนี้หน้าตาเหมือนกันทุกประการ ทั้งที่ต้นเหตุคนละเรื่องสุดขั้ว — อันหนึ่งลืมตั้ง env อีกอันตั้งครบแต่ DB ล่ม
คุณจะเสียเวลาไปไล่ดู Docker ไล่ดู firewall ไล่ดูว่า Postgres รันอยู่ไหม ทั้งที่ปัญหาอยู่ที่บรรทัด export ที่ลืมพิมพ์

📱 เทียบให้เห็นภาพ — บันทึกเบอร์โทรลงมือถือ

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

· sql.Open = บันทึกเบอร์ — แค่จำไว้ว่าจะต่อไปที่ไหน ยังไม่ได้ต่อ
· db.Ping() หรือ query แรก = กดโทรออก — ตรงนี้แหละที่ความจริงโผล่
และเสียงที่ได้ยินตอนโทรไม่ติดก็บอกแค่ว่า “ติดต่อไม่ได้” เหมือนกันหมด ไม่ว่าจะเพราะบันทึกเบอร์ผิดหรือปลายทางปิดเครื่อง — ตรงกับ connection refused สองอันข้างบนที่หน้าตาเหมือนกันเป๊ะ

ที่ไม่เหมือนกัน: มือถือให้เราบันทึกอะไรมั่ว ๆ ก็ได้ แต่ sql.Open ยังตรวจ “รู้จัก driver ชื่อนี้ไหม” ให้ — ซึ่งคือเคส D ที่เป็นเคสเดียวที่มี error ออกมาในผลรันข้างบน
และอย่าจำว่า “sql.Open ไม่มีวัน error เรื่อง DSN” — ที่ไม่แตะรูปแบบ DSN เลย คือ lib/pq ที่ใช้ทดสอบในบทนี้ · บาง driver parse DSN ตั้งแต่ sql.Open เช่น go-sql-driver/mysql ที่ sql.Open("mysql", "this-is-not-a-dsn") คืน error invalid DSN: missing the slash separating the database name ทันที
สรุปคือใช้ sql.Open เป็นด่านตรวจ config ไม่ได้ทั้งสองทาง — ผ่านก็ไม่ได้แปลว่าถูก และ error ก็ไม่ได้แปลว่าชื่อ driver ผิดเสมอไป

🔍 ข้อควรระวังเรื่องถ้อยคำ — จะพูดว่า "พังตอน query" ไม่แม่นทีเดียว ผลรันจริงคือ error โผล่ตั้งแต่ Ping ซึ่งคือจังหวะที่แตะ connection ครั้งแรก
ถ้าโค้ดคุณมี db.Ping() ตอน startup อยู่แล้ว คุณจะเจอตั้งแต่ตอน start (ซึ่งดีกว่ามาก) · ถ้าไม่มี มันจะไปโผล่ตอน request แรกของผู้ใช้จริง
เกร็ด: เครื่องที่ผมทดสอบไม่มี Postgres รันที่พอร์ต 5432 — ถ้าเครื่องคุณมี เคส DSN ว่างอาจต่อติดแล้วไปเจอ error เรื่อง auth หรือชื่อ database แทน ซึ่งยิ่งหลอกกว่าเดิม

🗣 ศัพท์ที่เพิ่งเรียนไป
DSN (Data Source Name)
ข้อความบรรทัดเดียวที่บอกว่าจะต่อฐานข้อมูลที่ไหน ด้วยใคร รหัสอะไร · ถามว่า “DSN ที่แอปได้รับตอนรันจริง ๆ คือค่าอะไร” ตรงประเด็นกว่าถามว่า “ทำไมต่อ DB ไม่ได้”
driver
ตัวกลางที่แปลระหว่างโค้ด Go กับฐานข้อมูลยี่ห้อนั้น ๆ (postgres, mysql) · error sql: unknown driver "..." (forgotten import?) แปลว่าชื่อ driver ที่ส่งเข้าไปไม่มีใคร register ไว้ — ไม่ลืม import ก็พิมพ์ชื่อผิด (เคส D ข้างบนคือพิมพ์ผิดเป็น mysqlx ไม่ใช่ลืม import)
lazy
“ยังไม่ทำจนกว่าจะจำเป็นจริง ๆ” · sql.Open เป็นแบบ lazy · พูดกับคนอื่นได้เลยว่า “sql.Open มัน lazy นะ ต้อง Ping ถึงจะรู้ว่าต่อติดไหม”
connection pool
“แอ่งของสายที่ต่อค้างไว้” ให้หลาย request หยิบใช้สลับกัน · sql.Open สร้างแอ่งเปล่าไว้ก่อน โดยยังไม่มีสายจริงสักเส้น
startup validation
“ตรวจ config ให้ครบตั้งแต่ตอนแอปเริ่ม” · นี่คือคำที่ควรใช้ตอนเสนอทางแก้ — “ขอทำ startup validation ให้มันตายตั้งแต่ตอน start ได้ไหม” ซึ่งก็คือปลายทางของหัวข้อถัดไปพอดี

7 · สิ่งที่อยากได้ — และจุดที่สไลด์หยุด

ห้าปัญหาข้างบนมีต้นเหตุร่วมข้อเดียว: เรากระจายความรู้เรื่อง config ไว้ทั่วโปรเจกต์ ทั้งชื่อ ทั้ง type ทั้ง default ทั้งกฎว่าอันไหนบังคับ
สิ่งที่อยากได้จริง ๆ คือประกาศครั้งเดียวว่าแอปนี้ต้องการอะไรบ้าง แล้วให้เครื่องมือจัดการที่เหลือ — อ่านให้ · แปลง type ให้ · เติม default ให้ · และถ้าขาดตัวไหนก็หยุดตั้งแต่ตอน start ไม่ใช่ไปพังตอนผู้ใช้ยิงเข้ามา

ปัญหาจากหัวข้อ 6อาการที่เจอ (ผลรันจริง)สิ่งที่อยากให้เป็น
พิมพ์ชื่อผิด go build / go vet ได้ exit=0 ทั้งคู่ ไม่มี output ชื่อ env เขียนที่เดียว ที่อื่นอ้างผ่าน field ของ struct
แปลง type เอง Atoi("") -> 0 · ParseBool("yes") -> false · ทั้งคู่คู่กับ error ที่มักโดนทิ้ง บอกชนิดไว้ที่ struct แล้วให้แปลงให้อัตโนมัติ
default กระจาย if x == "" { x = ... } คนละค่าคนละไฟล์ เขียน default ติดไว้กับ field นั้นเลย
ไม่รู้ว่าต้องการอะไร ตอบได้แค่ด้วย grep os.Getenv เปิด struct เดียวแล้วเห็นครบทุกตัว
ระเบิดกลางทาง sql.Open err = <nil> แล้วพังตอน Ping ด้วยข้อความที่ไม่ชี้ต้นเหตุ ขาด config ตัวไหน ให้แอปตายทันทีตอน start พร้อมบอกชื่อตัวที่ขาด

📌 สไลด์ชุดนี้จบตรงนี้ — ที่ "สิ่งที่อยากได้" ยังไม่เฉลยว่าใช้อะไรทำ
แต่หน้าปกของสไลด์เขียนปลายทางไว้ให้แล้วสามชื่อ ซึ่งแบ่งงานกันคนละส่วนพอดี:
· godotenv — อ่านไฟล์ .env เข้ามาเป็น env ตอน dev (แก้ปัญหา "ทีมใช้ค่าเดียวกันไม่ได้" จากหัวข้อ 1)
· caarlos0/env — ประกาศ config เป็น struct แล้วให้มันอ่าน+แปลง type+เติม default ให้ (แก้ปัญหา 1–4)
· go-playground/validator — ตรวจว่าค่าที่ได้ใช้ได้จริงไหม ตั้งแต่ตอน start (แก้ปัญหา 5) — ตัวเดียวกับที่อ่าน tag binding ให้ Gin ใน บทเรียน 39
น่าจะได้เรียนกันวันถัดไป · บทนี้จึงจบแค่ "เข้าใจปัญหาให้ชัด" ซึ่งเป็นเงื่อนไขที่ต้องมีก่อน ไม่งั้นเวลาใช้ library คุณจะใช้เพราะเขาบอกให้ใช้ ไม่ใช่เพราะรู้ว่ามันแก้อะไร

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

1. shell สั่ง export LOG_LEVEL="" แล้วโปรแกรม Go เรียกทั้ง os.Getenv("LOG_LEVEL") และ os.LookupEnv("LOG_LEVEL") — ได้อะไร?

ผลรันจริงคือ Getenv = "" และ LookupEnv = "", ok=true
ผมยืนยันอีกทางด้วย printenv | grep -c '^LOG_LEVEL' ซึ่งนับได้ 1 แปลว่าตัวแปรอยู่ใน environment จริง ๆ แค่ค่าเป็นสตริงว่าง
ตัวเลือกที่ 2 คือผลของ unset LOG_LEVEL ต่างหาก — เคสนั้น printenv นับได้ 0 และ LookupEnv ได้ ok=false
ตัวเลือกที่ 3 กับ 4 ผิดทั้งคู่: การอ่าน env ไม่มี error ไม่มี panic — นั่นแหละคือปัญหา เพราะมันเงียบเกินไป
จุดที่ต้องจำ: "ค่าว่าง" กับ "ไม่ได้ตั้ง" เป็นคนละสถานะจริง ๆ ในระบบปฏิบัติการ แต่ os.Getenv กลืนความต่างนี้ทิ้ง

2. มีคนตั้ง MAX_ITEMS=99999999999999999999 (ยาวเกิน int64) แล้วโค้ดเรียก strconv.Atoi — ได้อะไร?

ผลรันจริง: strconv.Atoi("99999999999999999999") -> n=9223372036854775807, err=strconv.Atoi: parsing "99999999999999999999": value out of range
ค่า 9223372036854775807 คือค่าสูงสุดของ int64 — ไม่ใช่ 0 ตามที่หลายคนเข้าใจว่า "error แล้วได้ zero value เสมอ"
ตัวเลือกที่ 1 ผิดสองชั้น: ทั้งค่าที่คืนและชนิดของ error — เคสนี้เป็น value out of range ไม่ใช่ invalid syntax ที่เจอตอน Atoi("") หรือ Atoi("8080 ")
ตัวเลือกที่ 3 ผิดเพราะ Go ไม่มีการขยายชนิดอัตโนมัติ · ตัวเลือกที่ 4 ผิดเพราะ Atoi ไม่เคย panic
อันตรายจริงอยู่ที่: ถ้าเขียน n, _ := strconv.Atoi(...) คุณจะได้ลิมิตเก้าล้านล้านที่ดูเหมือนตัวเลขจริง แทนที่จะได้ศูนย์ที่ยังพอสังเกตได้

3. ไฟล์ config เขียน TIMEOUT=30 โดยตั้งใจว่า "30 วินาที" แล้วโค้ดเรียก time.ParseDuration("30") — ได้อะไร?

ผลรันจริง: time.ParseDuration("30") -> v=0s, err=time: missing unit in duration "30"
ต้องมีหน่วยเสมอ — "30s" ผ่านได้ 30s · "1h30m" ผ่านได้ 1h30m0s
ตัวเลือกที่ 4 น่าสนใจเพราะ ข้อความนั้นมีอยู่จริง แต่ใช้กับเคสอื่น: ParseDuration("") และ ParseDuration("abc") ต่างหากที่ได้ time: invalid duration
ที่จริง ParseDuration มี error ถึงสามแบบ อีกแบบคือ time: unknown unit "s " in duration "30s " ที่เกิดจากช่องว่างท้ายบรรทัด
ข้อยกเว้นเดียวของกฎ "ต้องมีหน่วย" คือ "0" ซึ่งผ่านได้ 0s
จุดที่ต้องจำ: ค่าที่คืนมาคือ 0s ซึ่งแปลว่า timeout ทันที — ถ้าทิ้ง error ทุก request จะ timeout หมด โดยไม่มีอะไรบอกว่าเพราะอะไร

4. โค้ดเขียน os.Getenv("POSTGRES_DNS") (สะกดผิด ที่ถูกคือ POSTGRES_DSN) แล้ว build กับรัน — เกิดอะไรขึ้น?

ผมรันครบสามขั้น: go build ./... ได้ exit=0 ไม่มี output · go vet ./... ได้ exit=0 ไม่มี output · ตอนรันได้ dsn = "" กับ len(dsn) = 0 แล้วโปรแกรมเดินต่อ exit=0
เหตุผล: ชื่อ env เป็นสตริงธรรมดา ไม่มีรายชื่อให้ compiler เทียบ และ go vet ก็ไม่มี analyzer ที่รู้จักชื่อ env
ตัวเลือกที่ 3 ผิดเพราะ os.Getenv ไม่เคย panic — มันคืน "" ให้เสมอ
ที่หลอกที่สุดคือตอนที่มันไปพัง: sql.Open ยังคืน <nil> (แอป start ผ่าน) แล้วไปโผล่ตอน Ping ด้วยข้อความ dial tcp [::1]:5432: connect: connection refused ซึ่งไม่มีคำว่า DSN หรือ env เลย
ถ้าใช้ os.LookupEnv อย่างน้อยคุณจะได้ ok=false มาให้เช็ค — ซึ่งดีกว่าไม่มีอะไรเลย

🛠️ ภารกิจ — ทำให้แอปตายตั้งแต่ตอน start แทนที่จะพังตอนผู้ใช้ยิงเข้ามา

เป้าหมายไม่ใช่ "ทำให้อ่าน env ได้" — เป้าหมายคือ ทำให้คุณเห็นด้วยตาตัวเองว่ามันพังเงียบยังไง แล้วค่อยแก้

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ · cd เข้าไป · go mod init envcheck
    เขียน main.go ที่พิมพ์ผล os.Getenv("LOG_LEVEL") กับ os.LookupEnv("LOG_LEVEL") ด้วย %q และ %v
  2. รันสามแบบให้เห็นสามผล: LOG_LEVEL=DEBUG go run . · env LOG_LEVEL= go run . · env -u LOG_LEVEL go run .
    ต้องได้ ok=true สองครั้ง และ ok=false ครั้งเดียว — ถ้าได้ไม่ตรงนี้ แปลว่าคำสั่งที่พิมพ์ยังไม่ตรงกับที่ตั้งใจ
  3. พิสูจน์เรื่อง export: ลอง APP_PORT=5555 เฉย ๆ (ไม่มี export) แล้ว echo $APP_PORT ตามด้วย go run .
    ตอบให้ได้ว่าทำไม echo เห็นค่า แต่โปรแกรมไม่เห็น
  4. สร้างกับดัก type: เพิ่ม PORT เข้าโปรแกรม แล้วแปลงด้วย strconv.Atoi โดยทิ้ง error ด้วย _ ไปก่อน
    รันสี่แบบ: ไม่ตั้งเลย · PORT=8080 · PORT="8080 " (มีช่องว่างท้าย) · PORT=99999999999999999999
    จดค่าที่ได้ทั้งสี่ครั้ง แล้วตอบว่าครั้งไหนอันตรายที่สุดและทำไม
  5. รับ error กลับมา: เปลี่ยน _ เป็นตัวแปร error จริง แล้ว log.Fatalf พร้อมบอกชื่อ env ที่มีปัญหา
    รันซ้ำทั้งสี่แบบ — สังเกตว่าตอนนี้แอปตายทันทีพร้อมข้อความที่ชี้ต้นเหตุได้
  6. ทำกับดักชื่อพิมพ์ผิด: ตั้งใจเขียน os.Getenv("POSTGRES_DNS") แล้วสั่ง go build ./... และ go vet ./...
    ยืนยันด้วยตาว่าทั้งคู่ได้ exit=0 และไม่มี output (ดู exit code ด้วย echo "exit=$?")
  7. ทำ MustEnv ของตัวเอง: เขียนฟังก์ชัน func MustEnv(key string) string ที่ใช้ os.LookupEnv ข้างใน และ log.Fatalf("missing required env: %s", key) ถ้า ok เป็น false
    เปลี่ยนทุกที่ในโปรแกรมมาใช้ตัวนี้ แล้วลองรันโดยไม่ตั้ง env ดู
  8. ท้าทาย: เขียน MustEnvInt กับ MustEnvBool เพิ่ม แล้วนับดูว่าโค้ดสามฟังก์ชันนี้ซ้ำกันกี่บรรทัด
    จากนั้นตอบว่า: ถ้าโปรเจกต์มี config 15 ตัว วิธีนี้ยังไหวไหม และอะไรคือสิ่งที่คุณอยากให้เครื่องมือทำแทน — นี่คือคำถามที่ library ในหัวข้อ 7 เกิดมาเพื่อตอบพอดี

ผลรันจริงของทั้งสี่เคส:

ไม่ตั้ง PORT      -> Atoi("")      = 0,    err=strconv.Atoi: parsing "": invalid syntax
PORT=8080         -> Atoi("8080")  = 8080, err=<nil>
PORT="8080 "      -> Atoi("8080 ") = 0,    err=strconv.Atoi: parsing "8080 ": invalid syntax
PORT=999...999    -> Atoi(...)     = 9223372036854775807, err=...: value out of range

เคสที่ 4 อันตรายที่สุด — เพราะอีกสามเคสได้ 0 ซึ่งถ้าเอาไปเปิดเซิร์ฟเวอร์ที่ :0 ระบบจะแจกพอร์ตสุ่มให้ ซึ่งผิดปกติชัดพอที่จะสังเกตได้
ส่วนเคสที่ 4 ได้เลขเก้าล้านล้านที่ดูเหมือนค่าที่ตั้งใจตั้งมา — ถ้ามันเป็นลิมิตจำนวนแถวที่ query ได้ หรือขนาดไฟล์อัปโหลดสูงสุด คุณจะไม่มีทางรู้จนกว่าเซิร์ฟเวอร์จะหน่วงหรือหน่วยความจำหมด

เคสที่ 3 ก็ควรจำไว้: ช่องว่างท้ายบรรทัดตัวเดียวทำให้พังได้ และมันมองไม่เห็นด้วยตาเวลาเปิดไฟล์ .env ดู
ข้อสรุปเดียวกันทั้งสี่เคส: ตัวแปรที่รับค่าที่แปลงไม่สำเร็จ ไม่ได้ถือ "ค่าว่าง" มันถือค่าที่ดูปกติแต่ผิด — และนั่นคือเหตุผลที่ห้ามทิ้ง error ตอนอ่าน config

💬 ข้อ 8 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ — เพราะความอึดอัดตอนเขียน MustEnvInt ตัวที่สามซ้ำกับตัวแรกเกือบทั้งก้อน คือความรู้สึกที่จะทำให้คุณเข้าใจ library ในวันถัดไป แทนที่จะแค่ก๊อป config มาวาง
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 และซอร์สของ ParseBool ที่ผมยกมาก็เป็นของเวอร์ชันนั้น (ใน Go 1.26 โค้ดจริงย้ายไปอยู่ที่ src/internal/strconv/atob.go แล้ว ไม่ใช่ src/strconv/atob.go แบบเวอร์ชันเก่า)

อ้างอิง