🧰 ทุก 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 วิ่งข้ามไปครบสองทอด โดยเราไม่ได้ทำอะไรเพิ่มเลย
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 |
| Kubernetes | env: ใน manifest · ConfigMap (ค่าธรรมดา) · Secret (ค่าลับ) |
| CI/CD | GitLab CI Variables · GitHub Actions secrets |
| Cloud | ECS Task Definition · Cloud Run · Lambda |
และฝั่งภาษาโปรแกรม ทุกภาษาก็มีวิธีอ่านของตัวเอง หน้าตาต่างกันแต่เป็นของสิ่งเดียวกัน:
| ภาษา | อ่าน env ยังไง |
|---|---|
| Go | os.Getenv("POSTGRES_DSN") |
| Python | os.environ["POSTGRES_DSN"] |
| Node.js | process.env.POSTGRES_DSN |
| Java | System.getenv("POSTGRES_DSN") |
แนวคิดนี้มีชื่อเรียกและมีที่มา — มาจากเอกสารชื่อ 12-Factor App
ซึ่งเป็นชุดหลักการ 12 ข้อสำหรับเขียนแอปที่ deploy ง่าย
ข้อที่เกี่ยวกับเราคือ Factor III: Config ที่บอกว่า
config ต้องแยกออกจาก code และเก็บใน environment
เกณฑ์ทดสอบง่าย ๆ ที่เอกสารนั้นให้ไว้คือ:
ถ้าตอนนี้เปิด repo ให้เป็น public ทันที มีอะไรหลุดไหม — ถ้ามี แปลว่า config ยังไม่แยก
หัวข้อที่แล้วบอกว่า 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_LEVEL | os.Getenv | os.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 มาด้วยเสมอ:
| อยากได้ | ใช้ฟังก์ชัน | ตัวอย่างที่ผ่าน (ผลรันจริง) |
|---|---|---|
int | strconv.Atoi | Atoi("8080") -> v=8080, err=<nil> |
time.Duration | time.ParseDuration | ParseDuration("30s") -> v=30s, err=<nil> |
bool | strconv.ParseBool | ParseBool("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
เลขที่ใหญ่เกิน 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
- “อ่านข้อความแล้วแปลงเป็นค่าที่ใช้งานได้จริง” · ชื่อฟังก์ชันอย่าง
ParseBoolParseDurationมาจากคำนี้ · พูดว่า “มัน 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
ดูข้อความในเคส 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) · errorsql: 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 มาให้เช็ค — ซึ่งดีกว่าไม่มีอะไรเลย
เป้าหมายไม่ใช่ "ทำให้อ่าน env ได้" — เป้าหมายคือ ทำให้คุณเห็นด้วยตาตัวเองว่ามันพังเงียบยังไง แล้วค่อยแก้
- ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ ·
cdเข้าไป ·go mod init envcheck
เขียนmain.goที่พิมพ์ผลos.Getenv("LOG_LEVEL")กับos.LookupEnv("LOG_LEVEL")ด้วย%qและ%v - รันสามแบบให้เห็นสามผล:
LOG_LEVEL=DEBUG go run .·env LOG_LEVEL= go run .·env -u LOG_LEVEL go run .
ต้องได้ok=trueสองครั้ง และok=falseครั้งเดียว — ถ้าได้ไม่ตรงนี้ แปลว่าคำสั่งที่พิมพ์ยังไม่ตรงกับที่ตั้งใจ - พิสูจน์เรื่อง
export: ลองAPP_PORT=5555เฉย ๆ (ไม่มีexport) แล้วecho $APP_PORTตามด้วยgo run .
ตอบให้ได้ว่าทำไม echo เห็นค่า แต่โปรแกรมไม่เห็น - สร้างกับดัก type: เพิ่ม
PORTเข้าโปรแกรม แล้วแปลงด้วยstrconv.Atoiโดยทิ้ง error ด้วย_ไปก่อน
รันสี่แบบ: ไม่ตั้งเลย ·PORT=8080·PORT="8080 "(มีช่องว่างท้าย) ·PORT=99999999999999999999
จดค่าที่ได้ทั้งสี่ครั้ง แล้วตอบว่าครั้งไหนอันตรายที่สุดและทำไม - รับ error กลับมา: เปลี่ยน
_เป็นตัวแปร error จริง แล้วlog.Fatalfพร้อมบอกชื่อ env ที่มีปัญหา
รันซ้ำทั้งสี่แบบ — สังเกตว่าตอนนี้แอปตายทันทีพร้อมข้อความที่ชี้ต้นเหตุได้ - ทำกับดักชื่อพิมพ์ผิด: ตั้งใจเขียน
os.Getenv("POSTGRES_DNS")แล้วสั่งgo build ./...และgo vet ./...
ยืนยันด้วยตาว่าทั้งคู่ได้exit=0และไม่มี output (ดู exit code ด้วยecho "exit=$?") - ทำ
MustEnvของตัวเอง: เขียนฟังก์ชันfunc MustEnv(key string) stringที่ใช้os.LookupEnvข้างใน และlog.Fatalf("missing required env: %s", key)ถ้าokเป็นfalse
เปลี่ยนทุกที่ในโปรแกรมมาใช้ตัวนี้ แล้วลองรันโดยไม่ตั้ง env ดู - ท้าทาย: เขียน
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 แบบเวอร์ชันเก่า)
อ้างอิง
- pkg.go.dev —
os.Getenv· os.LookupEnv — อ่านย่อหน้าเดียวของLookupEnv: มันอธิบายตรง ๆ ว่าทำไมถึงต้องมีตัวที่คืนboolเพิ่ม - pkg.go.dev —
strconv.Atoi· ParseBool · NumError — ที่มาของErrSyntax/ErrRangeและรายชื่อ 12 ค่าที่ParseBoolรับ - pkg.go.dev —
time.ParseDuration— อ่านรายการหน่วยที่รองรับ (ns,us— เขียนµsหรือμsก็ได้ ·ms,s,m,h) และสังเกตว่าไม่มีdสำหรับวัน — อยากได้ 1 วันต้องเขียน24h - The Twelve-Factor App — III. Config — ต้นทางของแนวคิดทั้งบท พร้อมเกณฑ์ทดสอบ "เปิด repo เป็น public ตอนนี้ได้ไหม"
- pkg.go.dev —
sql.Open— อ่านประโยคที่บอกว่ามันไม่เปิด connection จริง ซึ่งอธิบายผลรันในหัวข้อ 6 ทั้งหมด - Docker —
-e/--env-file— ท่า-e NAMEแบบไม่ใส่ค่า และลำดับความแรงเมื่อชื่อชนกัน - joho/godotenv · caarlos0/env · go-playground/validator — สามตัวที่หน้าปกสไลด์ระบุเป็นปลายทาง (บทนี้ยังไม่ได้ใช้)
- Cheat sheet Day 8 — ตารางกับดักรวมทั้งวัน