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

.env กับ godotenv — คนละหน้าที่กับ caarlos0/env

~20 นาที · ต้องมี: บทเรียน 44 (environment variable คืออะไร) · บทเรียน 45 (caarlos0/env)

บทนี้มาจากสไลด์ Day 9 หน้า 11–14
คำถามที่บทนี้ต้องตอบให้ได้มีข้อเดียว: ในเมื่อ caarlos0/env อ่าน environment ได้อยู่แล้ว แล้วยังต้องมี godotenv ไปทำไม
คำตอบสั้น ๆ ที่สไลด์เขียนไว้ท้ายหน้า 13 คือ: godotenv = เอาค่าเข้า environment · caarlos0/env = เอาค่าออกจาก environment มาใส่ struct — คนละทิศทาง คนละจังหวะ ไม่ได้แข่งกัน
ผมรันทั้งเส้นให้ดูจริง ตั้งแต่ไฟล์ .env จนถึง Config struct แล้วโชว์ตอนที่มันพังเงียบ ด้วย — พิมพ์ผิดในไฟล์ .env บรรทัดเดียว ค่าหายทั้ง 9 ตัว แต่โปรแกรมยัง exit 0 ไม่มี error สักตัว

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64
github.com/joho/godotenv v1.5.1
github.com/caarlos0/env/v11 v11.4.1

ถ้าจะพิมพ์ตาม: สร้างโฟลเดอร์เปล่า · go mod init pipe · go get github.com/joho/godotenv@v1.5.1 · go get github.com/caarlos0/env/v11@v11.4.1
ส่วนหัวข้อ 10 ใช้ git ที่ติดมากับเครื่อง ไม่ต้องลงอะไรเพิ่ม

1 · ปัญหาที่ .env เกิดมาแก้

ใน บทเรียน 44 เราสรุปไว้ว่า ค่า config ต้องอยู่นอกโค้ด แล้วส่งเข้ามาทาง environment variable
ซึ่งถูกต้อง — แต่พอเอามาใช้จริงตอน develop ในเครื่องตัวเอง มันกลายเป็นแบบนี้:

$ export APP_NAME=wongnok
$ export APP_ENV=development
$ export APP_PORT=8080
$ export APP_SHUTDOWN_TIMEOUT=10s
$ export LOG_LEVEL=DEBUG
# ... อีกสิบกว่าบรรทัด ...
$ go run ./cmd/api

แล้ววันรุ่งขึ้นคุณเปิด terminal ใหม่ — ต้องพิมพ์ใหม่ทั้งชุด
เพราะ environment variable ที่ export ไว้อยู่กับ shell ตัวนั้นเท่านั้น ปิด terminal ก็หายหมด (ข้อนี้ต่อจากบท 44 หัวข้อ 2 ที่พิสูจน์เรื่อง parent → child process ไว้แล้ว)
สไลด์หน้า 11 เขียนปัญหานี้ไว้ตรง ๆ ว่า: "ตอน dev ในเครื่อง ไม่มีใครอยากพิมพ์ export 15 บรรทัดใหม่ทุกครั้งที่เปิด terminal"

ทางแก้ที่ทุกภาษาคิดออกตรงกัน: เอา 15 บรรทัดนั้นไปเก็บในไฟล์ แล้วให้โปรแกรมอ่านไฟล์นั้นตอนเริ่มทำงาน
ไฟล์นั้นชื่อ .env — และมันแก้ปัญหาแค่ข้อนี้ข้อเดียว ไม่ได้แก้อย่างอื่น

2 · .env หน้าตายังไง

นี่คือไฟล์ .env ตัวอย่างจากสไลด์ วางไว้ที่ root ของโปรเจกต์:

# .env — วางที่ root ของโปรเจกต์
APP_NAME=wongnok
APP_ENV=development
APP_PORT=8080
APP_SHUTDOWN_TIMEOUT=10s

LOG_LEVEL=DEBUG
LOG_FORMAT=text

POSTGRES_DSN=postgres://postgres:secret@localhost:5432/wongnok?sslmode=disable
POSTGRES_MAX_OPEN_CONNS=25
POSTGRES_CONN_MAX_LIFETIME=30m

สังเกตว่ามันไม่มี syntax อะไรเลย — ไม่มีวงเล็บ ไม่มีเครื่องหมายจบบรรทัด ไม่มี indent ที่มีความหมาย
แค่ KEY=value บรรทัดละคู่ · บรรทัดที่ขึ้นต้นด้วย # คือ comment · บรรทัดว่างข้ามไป
และค่าทุกตัวเป็นตัวอักษรล้วน — 8080 ในไฟล์นี้คือตัวอักษรสี่ตัว ไม่ใช่เลข (เรื่องนี้ยังเหมือนเดิมกับบท 44 ไม่มีอะไรเปลี่ยน)

สไลด์หน้า 11 เน้นสี่ข้อเกี่ยวกับไฟล์นี้ ซึ่งสี่ข้อนี้คือทั้งหมดที่ต้องจำ:

สไลด์บอกว่าแปลว่าอะไรในทางปฏิบัติ
เป็นแค่ไฟล์ text ธรรมดา
Go / OS / Docker ไม่รู้จักมันโดยกำเนิด
ไม่มีใครอ่านให้อัตโนมัติ · วางไฟล์ไว้เฉย ๆ แล้วรันโปรแกรม จะไม่เกิดอะไรขึ้นเลย (พิสูจน์ในหัวข้อ 3)
ต้องมีคนอ่านให้
อ่านไฟล์แล้วยัดค่าเข้า environment = งานของ godotenv
godotenv เป็นแค่ "คนอ่านไฟล์แล้วเรียก os.Setenv ให้" — ไม่มีเวทมนตร์อะไรมากกว่านั้น
ของ local development เท่านั้น
production ไม่ใช้ และไม่ควรมี
บน production ค่ามาจาก Docker / Kubernetes / CI โดยตรง — ไม่ต้องมีไฟล์นี้ และมีก็ไม่ควร
ห้าม commit ขึ้น Git
มี password อยู่ข้างใน — .gitignore ก่อนเสมอ
ดูบรรทัด POSTGRES_DSN ข้างบน มีคำว่า secret อยู่กลางบรรทัด (หัวข้อ 10)

📌 ประโยคปิดของสไลด์หน้านั้นคือ ".env ไม่ใช่ feature ของภาษา — เป็นแค่ข้อตกลงของเครื่องมือ"
แปลว่าถ้าคุณไปดูสเปกของภาษา Go หรือดู man page ของ Linux คุณจะไม่เจอคำว่า .env เลย — มันเป็นแค่ธรรมเนียมที่ library หลาย ๆ ตัว (ในทุกภาษา) ตกลงกันเองว่าจะใช้ชื่อไฟล์นี้

3 · "แค่ไฟล์ text ธรรมดา" แปลว่าอะไรกันแน่

ข้อนี้ฟังดูเหมือนคำพูดลอย ๆ แต่มันมีผลจริงที่จับต้องได้ — และเป็นสาเหตุที่คนติดกันบ่อยตอนขึ้น Docker
ผมเขียนโปรแกรมเล็ก ๆ ตัวหนึ่งที่ import _ "github.com/joho/godotenv/autoload" แล้วพิมพ์ cwd (current working directory) กับค่า env ที่ชื่อ WHERE ออกมา
จากนั้น go build -o app . ได้ binary ก้อนเดียว แล้วเอา binary ก้อนเดิมนั่นแหละ ไปรันสองที่:

ทายก่อนรัน — โครงสร้างโฟลเดอร์เป็นแบบนี้

p8/
├── .env          # มีบรรทัด WHERE=root-dotenv
├── app           # binary ที่ build แล้ว
└── sub/          # โฟลเดอร์ว่าง ไม่มี .env

รันสองครั้ง — ./app จากในโฟลเดอร์ p8 · แล้ว cd sub && ../app
binary ก้อนเดียวกันเป๊ะ ทั้งสองครั้งจะได้ WHERE เหมือนกันไหม?

=== run binary from project root (where .env lives) ===
cwd=/var/folders/.../p8       WHERE="root-dotenv"

=== run same binary from sub/ (no .env there) ===
cwd=/var/folders/.../p8/sub   WHERE=""

ไม่เหมือนกัน — binary ก้อนเดิม โค้ดเดิม แต่ได้คนละผล เพราะ godotenv หาไฟล์ .env จาก current working directory (ที่ที่คุณยืนอยู่ตอนสั่งรัน) ไม่ใช่ที่ที่ binary วางอยู่

ยืนยันจากซอร์สของ godotenv เอง (godotenv.go บรรทัด 179) — ค่า default ของ filenames คือ:

return []string{".env"}

เป็นชื่อไฟล์เปล่า ๆ ไม่ใช่ absolute path — สุดท้ายมันก็คือ os.Open(".env") ธรรมดาที่ทุกโปรแกรมทำได้ ไม่มีอะไรพิเศษ
นี่แหละความหมายของ "Go / OS / Docker ไม่รู้จักมันโดยกำเนิด": ถ้าระบบปฏิบัติการรู้จัก .env จริง binary จะหาไฟล์เจอไม่ว่ารันจากไหน — ที่มันหาไม่เจอ เพราะมันเป็นแค่การเปิดไฟล์ธรรมดาจาก library ตัวหนึ่ง

⚠️ จุดที่กัดคนตอนขึ้น Docker / systemd

ใน Dockerfile ถ้าคุณ COPY .env /app/.env แล้วตั้ง WORKDIR /srv โปรแกรมจะหาไฟล์ไม่เจอ — ทั้งที่ไฟล์อยู่ใน image แล้ว
อาการที่เห็นคือ "รันในเครื่องได้ ขึ้น container แล้วค่าหาย" ซึ่งคนมักไปโทษ Docker ทั้งที่ต้นเหตุคือ cwd คนละที่
แต่ทางแก้ที่ถูกจริง ๆ ไม่ใช่การไปแก้ WORKDIR — คือไม่ต้องเอา .env ขึ้น container ตั้งแต่แรก (ดูหัวข้อ 7 กล่องขวา)

4 · godotenv ทำงานยังไง — สองวิธี

สไลด์หน้า 12 เขียนหน้าที่ของ godotenv ไว้บรรทัดเดียวว่า "หน้าที่เดียว: อ่าน .env แล้วยัดค่าเข้า environment"
และให้สองวิธีใช้:

วิธีที่ 1 — autoload (สไลด์ชุดนี้ใช้แบบนี้)

import _ "github.com/joho/godotenv/autoload"

จบ — ไม่ต้องเรียกอะไรเพิ่มเลย
เครื่องหมาย _ ข้างหน้าเรียกว่า blank identifier import แปลว่า "เอา package นี้เข้ามา แต่ฉันไม่ได้จะเรียกฟังก์ชันอะไรของมัน — ฉันต้องการแค่ผลข้างเคียงของการ import"
ผลข้างเคียงที่ว่าคือฟังก์ชัน init() ซึ่ง Go เรียกให้อัตโนมัติทุก package ที่ถูก import

ผมเปิดซอร์สของ package autoload มาให้ดู — ทั้ง package มีแค่ 15 บรรทัดนี้จริง ๆ:

// github.com/joho/godotenv@v1.5.1/autoload/autoload.go — ทั้งไฟล์
package autoload

/*
	You can just read the .env file on import just by doing

		import _ "github.com/joho/godotenv/autoload"

	And bob's your mother's brother
*/

import "github.com/joho/godotenv"

func init() {
	godotenv.Load()
}
⚠️ อ่านบรรทัด godotenv.Load() ให้ดี — มันไม่ได้รับ error กลับมาเลย

Load() คืน error แต่บรรทัดนี้ไม่ได้เอาไปเก็บที่ไหน
แปลว่าถ้าอ่านไฟล์ไม่ได้ หรือไฟล์พัง — ไม่มีใครรู้เลย
นี่คือราคาที่ต้องจ่ายของวิธีที่ 1 ซึ่งสไลด์ยังไม่ได้พูดถึง เดี๋ยวหัวข้อ 8 ผมโชว์ให้ดูว่ามันน่ากลัวแค่ไหน

ผมรันยืนยันว่าแค่ import _ อย่างเดียวพอจริง ไม่ต้องเรียกอะไรเพิ่ม:

--- run 1: autoload, no shell env ---   # .env มี APP_NAME=from-dotenv, PORT=9999
APP_NAME="from-dotenv"
PORT="9999"

แล้ว "init() ทำงานก่อนสุด" หมายถึงก่อนแค่ไหน? ผมรันพิสูจน์ด้วยโปรแกรมที่มี ทั้ง package-level variable, init() ของตัวเอง และ main():

1) package-level var init: GOOD=set-by-dotenv
2) main package init(): GOOD = set-by-dotenv
3) main(): GOOD = set-by-dotenv

ลำดับที่พิมพ์ออกมาคือหลักฐานเอง — package-level variable ถูกสร้างก่อน · แล้วค่อย init() · แล้วค่อย main() และทั้งสามจุดเห็นค่าจาก .env ครบแล้ว
สิ่งที่ผลนี้พิสูจน์คือ: ค่ามาถึงตั้งแต่ตอน package-level variable ถูกสร้าง ซึ่งเกิดก่อนทุกอย่างของ package main — ไม่มีทางที่โค้ดของคุณจะทำงานก่อน autoload ได้

🧰 เทียบให้เห็นภาพ — แอปที่ตั้งไว้ให้ "เปิดเองตอนบูตเครื่อง"

ในคอมของคุณมีโปรแกรมบางตัวที่เด้งขึ้นมาเองทุกครั้งที่เปิดเครื่อง — LINE · ตัวซิงก์ไฟล์ · โปรแกรมปริ้นเตอร์
คุณไม่ได้กดเปิดมันเลยสักครั้ง คุณแค่เคยติดตั้งมันไว้เท่านั้นเอง
· การติดตั้งครั้งนั้น = บรรทัด import _ ".../godotenv/autoload"
· การที่มันเด้งขึ้นมาเองตอนบูต = init()
· ของที่มันแอบทำให้ตอนเด้งขึ้นมา (ซิงก์ไฟล์ · ต่อปริ้นเตอร์) = godotenv.Load()

และมันขึ้นมาก่อนที่คุณจะได้ทำอะไรเลย — ตรงกับผลรันข้างบนที่ค่าจาก .env มาถึงตั้งแต่ตอนสร้าง package-level variable ซึ่งเกิดก่อน init() ของเราเอง และก่อน main()

ทีนี้ลองนึกว่าตัวซิงก์ไฟล์นั้นล็อกอินไม่ผ่านแล้วปิดตัวเองเงียบ ๆ — ไม่เด้ง popup ไม่มีไอคอนแดง คุณจะทำงานไปทั้งวันโดยเชื่อว่าไฟล์ถูกซิงก์อยู่
นั่นคือบรรทัด godotenv.Load() ใน autoload.go ที่ทิ้ง error ทั้งดุ้น เป๊ะ ๆ (หัวข้อ 8 จะโชว์ว่าวันที่มันเกิดขึ้นจริง หน้าตาเป็นยังไง)

ที่ไม่เหมือนกัน: สองข้อ — (1) โปรแกรม startup จริงคุณปิดมันได้จากหน้าตั้งค่า แต่ autoload ปิดไม่ได้เลย ตราบใดที่บรรทัด import _ ยังอยู่ มันทำงานแน่นอน ทางเดียวที่จะหยุดคือลบบรรทัดนั้นทิ้ง · (2) "ตอนบูตเครื่อง" เป็นเวลากว้าง ๆ ที่แต่ละครั้งไม่เหมือนกัน แต่ init() มีลำดับตายตัวที่ Go การันตีให้ — package ที่ถูก import จะ initialize เสร็จก่อน package ที่ import มันเสมอ ไม่ใช่เรื่องของโชค

🗣 ศัพท์ที่เพิ่งเรียนไป
blank identifier (_)
ขีดล่างที่แปลว่า "ฉันรู้ว่ามีของตรงนี้ แต่ไม่ขอเรียกใช้" · วางหน้า import แปลว่าเอา package เข้ามาโดยไม่แตะฟังก์ชันไหนเลย
พูดกับ senior ได้ว่า: "บรรทัดนี้ import ด้วย blank identifier — มัน import มาเพื่ออะไรครับ"
import for side effects
การ import เพื่อเอาผลข้างเคียง ไม่ได้เอาฟังก์ชัน · autoload คือตัวอย่างที่เจอบ่อยที่สุดใน Go (อีกตัวที่จะเจอแน่ ๆ คือ database driver)
พิมพ์ถาม AI ได้เลยว่า: "in Go, what side effects does importing a package for its init() have?"
init()
ฟังก์ชันที่ Go เรียกให้เองตอนโหลด package — เรียกด้วยมือไม่ได้ และห้ามมีพารามิเตอร์
พูดได้ว่า: "ค่านี้ถูกตั้งใน init() ของ package ไหน" ซึ่งเป็นคำถามที่ตรงกว่าการถามว่า "ค่านี้มาจากไหน"
กลืน error (swallow / ignore the error)
เขียนโค้ดที่ได้ error มาแล้วไม่เก็บ ไม่เช็ค ไม่ log · godotenv.Load() ใน autoload.go คือกรณีตัวอย่าง
ประโยคที่ใช้ได้จริงตอน review โค้ด: "ตรงนี้เรากลืน error อยู่นะ ถ้าไฟล์พังเราจะไม่รู้เลย"

วิธีที่ 2 — เรียกเอง (ถ้าต้องคุมลำดับ)

สไลด์หน้า 12 เขียนไว้สามบรรทัดนี้ (เป็นเศษโค้ด ไม่ใช่ไฟล์ — เอาไปวางเดี่ยว ๆ ยังคอมไพล์ไม่ได้):

if err := godotenv.Load(); err != nil {
    slog.Debug("no .env file, using real environment")
}

สามบรรทัดนั้นถูกแล้ว แต่ยังขาด package กับ import ที่ต้องมีครอบ
นี่คือไฟล์เต็มที่ผมเอาไปรันจริงกับ godotenv v1.5.1 คู่กับ caarlos0/env v11.4.1 — ก๊อปทั้งก้อนนี้ไปวางเป็น main.go แล้ว go run . ได้เลย:

// main.go — วิธีที่ 2: เรียก godotenv.Load() เอง แล้วส่งต่อให้ caarlos0/env
package main

import (
    "fmt"
    "log/slog"
    "os"

    "github.com/caarlos0/env/v11"
    "github.com/joho/godotenv"
)

type Config struct {
    AppName string `env:"APP_NAME"`
    Port    int    `env:"PORT" envDefault:"8080"`
}

func main() {
    if err := godotenv.Load(); err != nil {
        slog.Debug("no .env file, using real environment")
    }

    fmt.Println("=== with .env ===")
    fmt.Printf("after Load, raw os.Getenv PORT=%q\n", os.Getenv("PORT"))

    cfg, err := env.ParseAs[Config]()
    fmt.Printf("ParseAs err=%v cfg=%+v\n", err, cfg)
}

ไฟล์ .env ที่วางคู่กันมีแค่สองบรรทัด — APP_NAME=svc-from-dotenv และ PORT=8081 · ผลรัน:

=== with .env ===
after Load, raw os.Getenv PORT="8081"
ParseAs err=<nil> cfg={AppName:svc-from-dotenv Port:8081}

🧭 เก็บรายละเอียดเล็ก ๆ ที่สไลด์เขียนไม่ตรงกับตัวอย่างของตัวเอง
ตารางหน้า 13 เขียนว่า godotenv "ทำงานตอน — ก่อนสุด init()"
ประโยคนี้จริงเฉพาะวิธีที่ 1 — ส่วนวิธีที่ 2 ที่สไลด์เองสอนอยู่หน้าเดียวกัน วาง godotenv.Load() ไว้ใน main() ซึ่งเกิดหลัง init() ทุกตัว
เวลาจำ ให้จำแยกว่า: init() เป็นของ autoload เท่านั้น · ถ้าเรียก Load() เอง มันทำงานตอนที่บรรทัดนั้นถูกรัน ไม่ใช่ก่อนสุด

แล้วถ้าไม่มีไฟล์ .env เลย Load() คืน error หน้าตายังไง? ผมรันดู:

err (%v)   = open .env: no such file or directory
err type   = *fs.PathError
os.ErrNotExist? = true
program still running, did not panic
exit code: 0

สามชื่อในผลรันข้างบน ถ้ายังไม่เคยเจอ นิยามสั้น ๆ ก่อน:
· *fs.PathError คือชนิดของ error ที่ Go คืนมาเวลาเปิดไฟล์ไม่ได้ (มาจาก package io/fs) — ข้างในเก็บชื่อไฟล์กับสาเหตุไว้ให้
· os.ErrNotExist คือก้อน error สำเร็จรูปของ Go ที่แปลว่า "ไม่มีไฟล์นี้" — มีไว้เอาไปเทียบโดยเฉพาะ
· errors.Is(err, target) คือการถามว่า "error ก้อนนี้เป็นเรื่องเดียวกับ target ไหม" โดยมันจะแกะ error ที่ถูกห่อไว้เป็นชั้น ๆ ให้เองจนเจอ — ไม่ต้องเทียบด้วย == เอง

ในเคสนี้จึงอ่านได้ว่า: error ที่ได้เป็น *fs.PathError ธรรมดา และ errors.Is(err, os.ErrNotExist) คืน true
ข้อมูลนี้มีประโยชน์กว่าที่คิด: ถ้าอยากเขียนให้ดีกว่า slog.Debug เฉย ๆ คุณแยกได้ว่า "ไม่มีไฟล์" (ปกติมากบน production — ข้ามไปเถอะ) กับ "มีไฟล์แต่ไฟล์พัง" (อันนี้ควรตายตั้งแต่ตอน start):

if err := godotenv.Load(); err != nil {
    if errors.Is(err, os.ErrNotExist) {
        slog.Debug("no .env file, using real environment")   // ปกติบน prod
    } else {
        log.Fatalf("broken .env: %v", err)                     // ไฟล์มีอยู่แต่พัง = ปัญหาจริง
    }
}

(ก้อนนี้เป็นเศษโค้ด ไม่ใช่ไฟล์ — เอาไปวางแทนที่ if ใน main() ของไฟล์เต็มข้างบน แล้วเพิ่ม "errors" กับ "log" เข้าไปใน import ด้วย ถึงจะคอมไพล์ผ่าน)

5 · Load() กับ Overload() ต่างกันตรงไหน

ทายก่อนรัน — ไฟล์ .env มี APP_NAME=from-dotenv และผมสั่งรันแบบนี้ (ตั้ง env จาก shell นำหน้าคำสั่ง ซึ่งชนะ export ตามบท 44):

$ APP_NAME=from-shell go run . load
$ APP_NAME=from-shell go run . overload

สองคำสั่งนี้ได้ APP_NAME เป็นอะไร?
และคำถามที่สำคัญกว่า: ถ้าเป็น Load() แล้วค่าจาก shell ชนะ ค่าอื่นใน .env ที่ shell ไม่ได้ตั้ง จะยังเข้ามาไหม — หรือทั้งไฟล์ตกไปเลย?

=== Load() with shell env set ===
before: APP_NAME="from-shell"
err=<nil>
after(load): APP_NAME="from-shell"        # .env แพ้ ของเดิมอยู่ต่อ

=== Load() with NO shell env ===
before: APP_NAME=""
err=<nil>
after(load): APP_NAME="from-dotenv"       # ไม่มีของเดิม .env จึงได้เข้า

=== Overload() with shell env set ===
before: APP_NAME="from-shell"
err=<nil>
after(overload): APP_NAME="from-dotenv"   # .env ทับของเดิม

ส่วนคำถามข้อสอง — ผมรันแยกอีกชุดด้วย autoload (ซึ่งข้างในคือ Load()) โดย shell ตั้งแค่ APP_NAME ตัวเดียว:

--- run 2: autoload + APP_NAME from shell ---
APP_NAME="from-shell"    # shell ชนะ
PORT="9999"             # PORT ที่ shell ไม่ได้ตั้ง ยังได้จาก .env

มันชนะกันเป็นราย key ไม่ใช่ทั้งไฟล์ — key ไหนมีอยู่ใน environment แล้ว .env ก็ข้ามไป · key ไหนยังไม่มีอยู่ใน environment .env ก็เติมให้
ยืนยันจาก comment ในซอร์ส godotenv.go บรรทัด 50 ที่เขียนไว้ตรง ๆ ว่า "It's important to note that it WILL NOT OVERRIDE an env variable that already exists"
และบรรทัด 73 ของ Overload: "this WILL OVERRIDE an env variable that already exists"

ระวังคำว่า "ยังไม่มี" ให้ดี — มันไม่ใช่ "ค่าว่าง"
godotenv ตัดสินจากชื่อ key มีอยู่ใน os.Environ() หรือเปล่า ไม่ได้ดูว่าค่าว่างหรือไม่ว่าง (ซอร์ส godotenv.go บรรทัด 190–201 สร้างตาราง currentEnv จาก strings.Split(rawEnvLine, "=")[0] แล้วเช็คแค่ !currentEnv[key])
แปลว่าถ้าคุณตั้งเป็นสตริงว่าง ถือว่า "มีอยู่แล้ว" — .env จะไม่เติมให้ ผมรันเทียบให้ดู:

$ APP_NAME= go run .        # ตั้งเป็นสตริงว่าง
APP_NAME=""              # .env ไม่เติมให้ ทั้งที่ .env มี APP_NAME=from-dotenv
PORT="9999"

$ go run .                  # ไม่ตั้งเลย
APP_NAME="from-dotenv"    # คราวนี้ .env เติมให้
PORT="9999"

นี่คือความต่างระหว่าง "ค่าว่าง" กับ "ไม่ได้ตั้ง" ที่ บทเรียน 44 พูดไว้ทั้งหัวข้อ — มันโผล่มาใช้จริงตรงนี้พอดี

ฟังก์ชันพฤติกรรม (สไลด์หน้า 12)ผลรันจริง
godotenv.Load() env ที่มีอยู่แล้วชนะ — .env ไม่ทับของจริง APP_NAME="from-shell" (ค่าจาก shell อยู่ต่อ)
godotenv.Overload() .env ทับของเดิม — ใช้ระวัง APP_NAME="from-dotenv" (ค่าจาก shell โดนทับ)

🧭 ทำไม Load() ถึงเป็น default ที่ถูกต้อง
เพราะมันทำให้ .env เป็นค่าสำรอง ไม่ใช่คำสั่ง — ใครก็ตามที่ตั้ง env จริงมา (Docker, CI, หรือคุณเองตอนอยาก override ชั่วคราว) จะชนะเสมอ
ท่าที่ใช้บ่อยมากตอน dev คือ APP_PORT=9999 go run ./cmd/api — ได้พอร์ตใหม่ครั้งเดียวโดยไม่ต้องแก้ไฟล์ .env ซึ่งจะทำแบบนี้ไม่ได้เลยถ้าใช้ Overload()

🧰 เทียบให้เห็นภาพ — ลานจอดรถที่ทุกช่องมีป้ายชื่อ

นึกถึงลานจอดที่แต่ละช่องมีป้ายชื่อกำกับ — ช่อง APP_NAME · ช่อง APP_PORT · ช่อง LOG_LEVEL ไล่ไปเก้าช่อง
ลานจอดนี้คือ environment · ไฟล์ .env คือรถเก้าคันที่ขับตามกันมาถึงตอนเช้า

· Load() = "ช่องไหนว่างค่อยจอด" — เดินดูทีละช่อง ช่องไหนมีรถอยู่แล้วก็ข้ามไป ช่องไหนว่างถึงจอด
· Overload() = "ยกรถเดิมออก แล้วจอดทับ" — ไม่สนว่าใครจอดอยู่ก่อน

ภาพนี้ตอบคำถามข้อสองในกล่องเฉลยได้ทันที: มันตัดสินทีละช่อง ไม่ใช่ทั้งลาน
รถคัน APP_NAME จอดไม่ได้เพราะมีรถของ shell จอดอยู่ ก็ไม่ได้แปลว่า อีกแปดคันต้องขับกลับบ้านตามไปด้วย — PORT ยังจอดของมันได้ตามปกติ

ส่วนกรวยจราจรคือคำตอบของกับดัก APP_NAME= (ตั้งเป็นค่าว่าง) — ในช่องนั้นไม่มีรถ แต่มันก็ไม่ว่าง รถจาก .env จึงจอดไม่ได้อยู่ดี
ตรงกับผลรันข้างบนที่ APP_NAME ออกมาเป็น "" ทั้งที่ในไฟล์ .env มี APP_NAME=from-dotenv เขียนไว้ชัด ๆ

ที่ไม่เหมือนกัน: ลานจอดจริง ถ้ามีคนยกรถคุณออกไปจอดทับ เจ้าของรถโวยแน่นอน แต่ที่นี่ไม่มีใครส่งเสียงเลย — ทั้ง Load() และ Overload() คืน err=<nil> เหมือนกันหมดทั้งสามชุดในผลรันข้างบน
แปลว่าคุณดูจาก error ไม่ได้ว่าตัวเองเรียกตัวไหนอยู่ ต้องไปอ่านที่โค้ดอย่างเดียว

🗣 ศัพท์ที่เพิ่งเรียนไป
override
"ทับค่าเดิม" · เป็นคำที่ documentation ของ godotenv ใช้ตรง ๆ ("WILL NOT OVERRIDE" / "WILL OVERRIDE")
พูดกับ senior ได้ว่า: "ค่าจาก .env จะ override ค่าที่ Docker ส่งมาไหมครับ" — ชัดกว่าถามว่า "ตัวไหนมาก่อน"
precedence (ลำดับความสำคัญของค่า config)
กติกาว่าถ้าค่าเดียวกันมาจากหลายที่ ใครชนะ · ของบทนี้คือ shell/Docker ชนะ .env เมื่อใช้ Load()
พิมพ์ถาม AI ได้เลยว่า: "what is the config precedence order when using godotenv.Load with caarlos0/env?"
fallback (ค่าสำรอง)
ค่าที่จะถูกใช้ก็ต่อเมื่อไม่มีใครกำหนดมาให้ · นี่คือบทบาทที่ถูกต้องของไฟล์ .env
ประโยคที่ใช้ได้จริงตอนออกแบบ: "เราอยากให้ .env เป็น fallback เฉย ๆ ไม่ใช่ตัวสั่ง เพราะงั้นใช้ Load() ไม่ใช่ Overload()"
set-but-empty กับ unset
"ตั้งไว้แต่เป็นค่าว่าง" (APP_NAME=) กับ "ไม่ได้ตั้งเลย" — คนละเรื่องกัน และ godotenv ดูแค่ว่าชื่อ key มีอยู่ไหม
พิมพ์ถาม AI ได้เลยว่า: "in Go, how do I tell an unset environment variable from one set to an empty string?" (คำตอบที่ควรได้คือ os.LookupEnv ซึ่งคืนค่ามาสองตัว)

6 · godotenv กับ caarlos0/env ทำหน้าที่ไม่เหมือนกัน

มาถึงหน้าที่สำคัญที่สุดของบทนี้ (สไลด์หน้า 13)
คนที่เพิ่งเห็นสองตัวนี้พร้อมกันมักคิดว่ามันทำอย่างเดียวกัน แล้วสงสัยว่าจะเลือกตัวไหนดี — ซึ่งเป็นคำถามที่ผิดตั้งแต่ต้น เพราะมันอยู่คนละจุดของท่อเดียวกัน:

godotenvcaarlos0/env
หน้าที่ อ่านไฟล์ .env → ยัดเข้า environment อ่าน environment → เทค่าลง struct
อ่านจาก ไฟล์ .env บน disk os.Environ()
เขียนไปที่ environment ของ process Config struct ในโปรแกรม
ทำงานตอน ก่อนสุด — init() (เฉพาะแบบ autoload · ดูหมายเหตุหัวข้อ 4) ตอนเรียก env.ParseAs()
ไม่มีมันได้ไหม ได้ — บน prod ไม่ต้องใช้เลย ควรมี ไม่งั้นกลับไป os.Getenv
ถ้าใช้ผิดที่ ใส่บน prod = ไม่เกิดอะไร (ไม่มีไฟล์) ขาดไป = ไม่มีใครแปลง type ให้

ประโยคเดียวที่ควรจำจากทั้งบท (สไลด์เขียนไว้ท้ายหน้า 13):
· godotenv = เอาค่าเข้า environment
· caarlos0/env = เอาค่าออกจาก environment มาใส่ struct
ทิศทางตรงข้ามกัน ทำงานคนละจังหวะ และตัวหนึ่งไม่รู้จักอีกตัวเลย — caarlos0/env ไม่มีโค้ดบรรทัดไหนที่รู้ว่าไฟล์ .env มีอยู่ในโลก มันเห็นแค่ environment ที่ระบบส่งให้

🧰 เทียบให้เห็นภาพ — โพยฝากซื้อของ · ตู้เย็น · คนทำกับข้าว

ในครัวหนึ่งครัวมีสามอย่างที่หน้าที่ไม่ปนกันเลย:
· โพยกระดาษที่แปะไว้หน้าตู้เย็น = ไฟล์ .env — เป็นแค่กระดาษ กินไม่ได้ และถ้าไม่มีใครหยิบไปทำอะไร มันก็แปะอยู่อย่างนั้นทั้งวัน (ตรงกับหัวข้อ 3 ที่ binary หาไฟล์ไม่เจอแล้วก็เดินหน้าต่อเฉย ๆ)
· คนที่ถือโพยไปซื้อของแล้วเอามายัดใส่ตู้เย็น = godotenv
· คนทำกับข้าวที่เปิดตู้ หยิบของตามสูตร แล้วชั่ง–ตวง–หั่นให้ตรงกับสูตร = caarlos0/env
· สูตรอาหารที่เขียนไว้ว่าต้องใช้อะไรบ้าง กี่กรัม = Config struct

ตู้เย็นตรงกลางคือ environment — และของทุกอย่างในตู้เป็น "ของดิบ" เหมือนกันหมด
ซึ่งตรงกับผลรันที่กำลังจะเห็นข้างล่าง: ค่าที่ออกจากตู้เป็น string ล้วน "8080" ยังเป็นตัวอักษรสี่ตัว ไม่ใช่เลข จนกว่าคนทำกับข้าวจะแปลงให้

ภาพนี้ตอบคำถามหลักของทั้งบทได้ในประโยคเดียว — สองตัวนี้ไม่ได้แข่งกัน เพราะมันยืนคนละฝั่งของตู้เย็น
และบน production ก็แค่ไม่มีโพย ไม่มีคนไปซื้อของ — แต่มีรถส่งของ (Docker · Kubernetes · CI) มาเติมตู้ให้ตรง ๆ คนทำกับข้าวเปิดตู้เจอของครบเหมือนเดิม จึงไม่ต้องแก้โค้ดสักบรรทัด

ที่ไม่เหมือนกัน: คนทำกับข้าวจริงเปิดตู้แล้วไม่เจอไข่ จะร้องทันทีว่า "ไข่หมด"
แต่ caarlos0/env เปิดตู้เจอตู้เปล่ายังคืน err=<nil> แล้วทำอาหารต่อด้วย "ไข่ศูนย์ฟอง" — คือ 0 · 0s · "" (ดูผลรันในหัวข้อ 8 และในกล่องเฉลยท้ายบท)
นี่คือช่องว่างที่ยังไม่มีใครอุด และเป็นเหตุผลที่แผนภาพข้างล่างยังมีขั้น validator ต่อท้ายอีกขั้น ซึ่งเป็นเนื้อหาของ บทเรียน 47

🗣 ศัพท์ที่เพิ่งเรียนไป
parse
แปลงข้อความดิบให้กลายเป็นของที่โปรแกรมใช้ได้จริง · ชื่อฟังก์ชัน env.ParseAs[Config]() มาจากคำนี้ตรง ๆ
พูดกับ senior ได้ว่า: "ค่านี้ parse ไม่ผ่านหรือเปล่าครับ" ซึ่งแปลว่า "แปลงจาก string เป็น type ที่ประกาศไว้ไม่ได้" — คนละเรื่องกับ "ไม่มีค่า"
zero value
ค่าตั้งต้นที่ Go ใส่ให้เองเมื่อไม่มีใครกำหนด — int ได้ 0 · string ได้ "" · time.Duration ได้ 0s
ประโยคที่ใช้ได้จริงตอน debug: "DSN เป็น zero value อยู่ แปลว่าไม่มีใครส่งค่ามาให้เลย ไม่ใช่ส่งมาผิด"
struct tag
ข้อความในเครื่องหมาย `...` ที่ห้อยท้าย field เช่น env:"APP_PORT" · มันคือ "ป้ายบอกทาง" ที่ทำให้ library รู้ว่า field นี้ผูกกับ env ตัวไหน
พิมพ์ถาม AI ได้เลยว่า: "what struct tags does caarlos0/env v11 support?"
separation of concerns (แยกหน้าที่)
หลักที่ว่า "ของหนึ่งชิ้นทำเรื่องเดียว" · ทั้งบทนี้คือตัวอย่างของหลักข้อนี้ — godotenv รู้จักแค่ไฟล์ · caarlos0/env รู้จักแค่ environment · ตัวหนึ่งไม่รู้ว่าอีกตัวมีอยู่
พูดได้ว่า: "สองตัวนี้แยก concern กันชัดเจน เลยถอดตัวไหนออกก็ได้"

7 · แผนภาพการทำงานทั้งหมด

สไลด์หน้า 14 รวมทุกอย่างของ Day 9 ไว้ในภาพเดียว ผมวาดตามให้ดูทั้งเส้น — ตัวเลข 1–5 คือลำดับเวลา ทุกขั้นเกิดก่อนที่ server จะรับ request แรก:

┌──────────────────────────────┐ (1) │ .env │ ไฟล์ที่อยู่ในเครื่อง dev เท่านั้น └──────────────┬───────────────┘ │ godotenv/autoload │ อ่านไฟล์ แล้วยัดค่าเข้า environment v ┌──────────────────────────────┐ ┌────────────────────────────── (2) │ Environment Variables │ <────┤ OS · Docker · Kubernetes · CI │ key = value (string only) │ │ บน production ค่ามาทางนี้ └──────────────┬───────────────┘ │ ไม่มีไฟล์ .env และไม่ต้องมี │ caarlos0/env └────────────────────────────── │ map คีย์ + แปลง type ให้ตรงกับที่ประกาศไว้ v ┌──────────────────────────────┐ ┌────────────────────────────── (3) │ Config Struct │ - - -┤ ⛔ ถ้าพัง ต้องพังตรงนี้ │ type Config struct { ... } │ │ ทุกขั้นตอนนี้เกิดก่อนรับ request └──────────────┬───────────────┘ │ ขาดแม้แต่ตัวเดียว = exit status 1 │ validator + errors.Join │ ตรวจว่าค่าที่ได้ใช้งานได้จริงไหม v ┌──────────────────────────────┐ (4) │ Valid Config │ ค่าทุกตัวใช้งานได้จริง └──────────────┬───────────────┘ │ v ┌──────────────────────────────┐ (5) │ Application │ slog · Gin · GORM └──────────────────────────────┘

สองกล่องทางขวาคือส่วนที่คนอ่านข้ามบ่อยที่สุด แต่มันสำคัญที่สุด:

เดินทั้งเส้นด้วยของจริง

ผมเอา .env ของสไลด์ (ไฟล์เดียวกับหัวข้อ 2 เป๊ะ ๆ ทั้ง 9 ค่า) มาต่อกับ struct จริง แล้วพิมพ์ผลของขั้น (2) และ (3) ออกมาให้เห็นทั้งคู่
ไฟล์เต็มมีแค่นี้ — ก๊อปทั้งก้อนไปวางเป็น main.go ข้าง ๆ ไฟล์ .env ของหัวข้อ 2 แล้ว go run . ได้เลย:

// main.go — เดินท่อทั้งเส้น: .env → environment → Config struct
package main

import (
    "fmt"
    "os"
    "time"

    "github.com/caarlos0/env/v11"         // ขั้น 2 → 3
    _ "github.com/joho/godotenv/autoload" // ขั้น 1 → 2
)

type Config struct {
    AppName         string        `env:"APP_NAME"`
    AppEnv          string        `env:"APP_ENV"`
    AppPort         int           `env:"APP_PORT" envDefault:"8080"`
    ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
    LogLevel        string        `env:"LOG_LEVEL" envDefault:"DEBUG"`
    LogFormat       string        `env:"LOG_FORMAT"`
    PostgresDSN     string        `env:"POSTGRES_DSN"`
    MaxOpenConns    int           `env:"POSTGRES_MAX_OPEN_CONNS"`
    ConnMaxLifetime time.Duration `env:"POSTGRES_CONN_MAX_LIFETIME"`
}

func main() {
    fmt.Println("--- STEP 2: os.Getenv (ค่าเป็น string ทั้งหมด) ---")
    for _, k := range []string{
        "APP_ENV", "APP_NAME", "APP_PORT", "APP_SHUTDOWN_TIMEOUT",
        "LOG_FORMAT", "LOG_LEVEL", "POSTGRES_CONN_MAX_LIFETIME",
        "POSTGRES_DSN", "POSTGRES_MAX_OPEN_CONNS",
    } {
        fmt.Printf("%-26s  %q\n", k, os.Getenv(k))
    }

    cfg, err := env.ParseAs[Config]()

    fmt.Println("\n--- STEP 3: env.ParseAs[Config]() ---")
    fmt.Printf("err=%v\n", err)
    fmt.Printf("%-15s %-8T %v\n", "AppPort", cfg.AppPort, cfg.AppPort)
    fmt.Printf("%-15s %-8T %v\n", "ShutdownTimeout", cfg.ShutdownTimeout, cfg.ShutdownTimeout)
    fmt.Printf("%-15s %-8T %v\n", "MaxOpenConns", cfg.MaxOpenConns, cfg.MaxOpenConns)
    fmt.Printf("%-15s %-8T %v\n", "ConnMaxLifetime", cfg.ConnMaxLifetime, cfg.ConnMaxLifetime)
    fmt.Printf("%-15s %-8T %q\n", "AppName", cfg.AppName, cfg.AppName)
    fmt.Printf("%-15s %-8T %q\n", "PostgresDSN", cfg.PostgresDSN, cfg.PostgresDSN)
}

🧭 %-15s / %-8T ในโค้ดข้างบนคือแค่การจัดคอลัมน์ให้อ่านง่าย ไม่ได้เกี่ยวกับ config
ตัวที่สำคัญคือ %T — มันพิมพ์ type ของค่า ออกมาให้ดู ซึ่งเป็นทั้งหมดที่บทนี้อยากให้เห็น: ของที่ออกจากขั้น (2) เป็น string เสมอ แต่ของที่ออกจากขั้น (3) เป็น int / time.Duration แล้ว

$ go run .          # มีไฟล์ .env อย่างเดียว ไม่ได้ตั้ง env จาก shell เลย

--- STEP 2: os.Getenv (ค่าเป็น string ทั้งหมด) ---
APP_ENV                     "development"
APP_NAME                    "wongnok"
APP_PORT                    "8080"
APP_SHUTDOWN_TIMEOUT        "10s"
LOG_FORMAT                  "text"
LOG_LEVEL                   "DEBUG"
POSTGRES_CONN_MAX_LIFETIME  "30m"
POSTGRES_DSN                "postgres://postgres:secret@localhost:5432/wongnok?sslmode=disable"
POSTGRES_MAX_OPEN_CONNS     "25"

--- STEP 3: env.ParseAs[Config]() ---
err=<nil>
AppPort         int      8080
ShutdownTimeout time.Duration 10s
MaxOpenConns    int      25
ConnMaxLifetime time.Duration 30m0s
AppName         string   "wongnok"
PostgresDSN     string   "postgres://postgres:secret@localhost:5432/wongnok?sslmode=disable"

เทียบสองบล็อกนี้แล้วจะเห็นการแบ่งงานชัดที่สุด
· godotenv ทำให้ STEP 2 มีค่า — ถ้าไม่มีมัน ทั้งเก้าบรรทัดนั้นจะเป็น "" หมด
· caarlos0/env ทำให้ STEP 3 เกิดขึ้น — "25" (ตัวอักษรสองตัว) กลายเป็น int ที่มีค่า 25 · "30m" กลายเป็น time.Duration ที่พิมพ์ออกมาเป็น 30m0s
ไม่มีใครทำงานแทนกันได้ และลำดับสลับกันไม่ได้ — ถ้า ParseAs ทำงานก่อน godotenv มันจะเห็น environment เปล่า ๆ

ทีนี้ลองจำลอง production ตามกล่องขวาบนของแผนภาพ — ลบไฟล์ .env ทิ้ง แล้วส่งค่าทาง env แทน:

$ # (ไม่มีไฟล์ .env อยู่ในโฟลเดอร์แล้ว)
$ APP_NAME=wongnok APP_ENV=production APP_PORT=8080 LOG_LEVEL=INFO LOG_FORMAT=json \
    POSTGRES_DSN='postgres://app:realsecret@db:5432/wongnok?sslmode=require' \
    POSTGRES_MAX_OPEN_CONNS=25 POSTGRES_CONN_MAX_LIFETIME=30m go run .

--- STEP 2: os.Getenv (ค่าเป็น string ทั้งหมด) ---
APP_ENV                     "production"
APP_NAME                    "wongnok"
APP_PORT                    "8080"
APP_SHUTDOWN_TIMEOUT        ""            # ตัวนี้ไม่ได้ตั้ง
LOG_FORMAT                  "json"
LOG_LEVEL                   "INFO"
POSTGRES_CONN_MAX_LIFETIME  "30m"
POSTGRES_DSN                "postgres://app:realsecret@db:5432/wongnok?sslmode=require"
POSTGRES_MAX_OPEN_CONNS     "25"

--- STEP 3: env.ParseAs[Config]() ---
err=<nil>
AppPort         int      8080
ShutdownTimeout time.Duration 10s          # ได้จาก envDefault:"10s"
MaxOpenConns    int      25
ConnMaxLifetime time.Duration 30m0s
AppName         string   "wongnok"
PostgresDSN     string   "postgres://app:realsecret@db:5432/wongnok?sslmode=require"
exit=0

โค้ดไม่ได้แก้แม้แต่บรรทัดเดียว · import _ autoload ยังอยู่ในโปรแกรม · ไม่มีไฟล์ .env · ไม่มี error · แอปได้ config ครบ
นี่คือความหมายของช่อง "ถ้าใช้ผิดที่ → ใส่บน prod = ไม่เกิดอะไร (ไม่มีไฟล์)" ในตารางหัวข้อ 6
สังเกต APP_SHUTDOWN_TIMEOUT ด้วย: env เป็น "" แต่ struct ได้ 10s เพราะ envDefault เข้ามาเติม — คนละกลไกกับ godotenv คนละขั้นตอนกันเลย

8 · ราคาของ autoload ที่สไลด์ยังไม่ได้บอก

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

ทายก่อนรัน — ผมแก้ไฟล์ .env ของหัวข้อ 2 แค่จุดเดียว คือบรรทัดที่ 7 ลืมพิมพ์เครื่องหมาย =:

# .env — วางที่ root ของโปรเจกต์
APP_NAME=wongnok
APP_ENV=development
APP_PORT=8080
APP_SHUTDOWN_TIMEOUT=10s

LOG_LEVEL DEBUG          ← บรรทัดที่ 7 ลืม = (ที่ถูกคือ LOG_LEVEL=DEBUG)
LOG_FORMAT=text

POSTGRES_DSN=postgres://postgres:secret@localhost:5432/wongnok?sslmode=disable
POSTGRES_MAX_OPEN_CONNS=25
POSTGRES_CONN_MAX_LIFETIME=30m

โปรแกรมยังใช้ import _ "github.com/joho/godotenv/autoload" เหมือนเดิม
ทายสามข้อ:
1. APP_NAME ซึ่งอยู่บรรทัดแรกสุด ก่อนบรรทัดที่พัง — จะยังได้ค่าไหม?
2. โปรแกรมจะพิมพ์ error อะไรออกมาไหม?
3. exit code จะเป็นเท่าไหร่?

$ go run .

--- STEP 2: os.Getenv (ค่าเป็น string ทั้งหมด) ---
APP_ENV                     ""
APP_NAME                    ""
APP_PORT                    ""
APP_SHUTDOWN_TIMEOUT        ""
LOG_FORMAT                  ""
LOG_LEVEL                   ""
POSTGRES_CONN_MAX_LIFETIME  ""
POSTGRES_DSN                ""
POSTGRES_MAX_OPEN_CONNS     ""

--- STEP 3: env.ParseAs[Config]() ---
err=<nil>
AppPort         int      8080
ShutdownTimeout time.Duration 10s
MaxOpenConns    int      0
ConnMaxLifetime time.Duration 0s
AppName         string   ""
PostgresDSN     string   ""
exit=0

ตอบทีละข้อ:
1. ไม่ได้ค่า — APP_NAME อยู่บรรทัดแรกสุด ก่อนบรรทัดที่พังตั้ง 5 บรรทัด แต่ก็ไม่ถูกตั้งเลย · godotenv เป็นแบบ all-or-nothing: เจอบรรทัดพังที่ไหน ทิ้งทั้งไฟล์ ไม่ใช่ "อ่านได้ถึงบรรทัดที่พังแล้วหยุด" อย่างที่คนส่วนใหญ่เดา
2. ไม่พิมพ์อะไรเลย — เพราะ init() ของ autoload ทิ้ง error ทั้งดุ้น (ดูซอร์สในหัวข้อ 4)
3. exit=0 — ในสายตาของ shell, CI, Kubernetes โปรแกรมนี้สำเร็จ

ที่ร้ายกว่านั้นคือบรรทัด err=<nil> ของ caarlos0/env — มันไม่ได้ทำอะไรผิด environment ว่างจริง ๆ ไม่มีอะไรให้ error
ผลคือแอปได้ AppPort=8080 (จาก envDefault) กับ PostgresDSN="" แล้วเดินหน้าต่อ ไปเปิดเซิร์ฟเวอร์ตามปกติ

แล้วมันไปพังที่ไหน? — ตอนต่อฐานข้อมูล ด้วยข้อความว่า connection refused ที่ไม่มีคำว่า .env อยู่ในนั้นเลย (เคสเดียวกับ บทเรียน 44 หัวข้อ 6)
คุณจะนั่งไล่ดู Docker ดู Postgres ดู firewall — ทั้งที่ต้นเหตุคือ = ตัวเดียวที่ลืมพิมพ์

ผมทดสอบรูปแบบบรรทัดพังอีกสองแบบ ได้อาการเดียวกันทั้งหมด:

# bad1.env — GOOD=1 / this line has no equals sign / AFTER=2
err  = unexpected character "\n" in variable name near "this line has no equals sign\nAFTER=2\n"
GOOD after Load  = ""
AFTER after Load = ""

# bad3.env — GOOD=1 / UNCLOSED="oops / AFTER=2   (quote ไม่ปิด)
err  = unterminated quoted value "oops
GOOD after Load  = ""
AFTER after Load = ""
⚠️ และมี syntax ที่ดูพัง แต่กลับผ่านฉลุย ซึ่งเงียบกว่าอีก

godotenv ไม่ validate ชื่อ key เลย — ผมลองสองเคสที่ไม่ควรผ่าน:

# bad2.env — 123INVALID=x   (ชื่อขึ้นต้นด้วยตัวเลข)
err  = <nil>
123INVALID => "x"

# bad4.env — =novalue   (ไม่มีชื่อ key เลย)
err  = <nil>
           => "novalue"        # key เป็นสตริงว่าง
GOOD after Load = "1"

ทั้งคู่ err=<nil> — ผ่านหมด
แปลว่าถ้าคุณพิมพ์ชื่อ env ผิดแบบที่ยังเป็น KEY=value ที่ถูกรูป (เช่น POSTGRES_DNS= แทน POSTGRES_DSN=) จะไม่มีใครฟ้องคุณตลอดทั้งเส้น — ไม่ใช่ godotenv ไม่ใช่ compiler และไม่ใช่ caarlos0/env ด้วย (เว้นแต่ field นั้นใส่ ,required ไว้ · บทเรียน 45)

วิธีที่ 1 — autoloadวิธีที่ 2 — godotenv.Load() เอง
โค้ดที่ต้องเขียน1 บรรทัด (import _)3–7 บรรทัดใน main()
ทำงานตอนinit() — ก่อนทุกอย่างของ mainตอนที่บรรทัดนั้นถูกรัน
ไฟล์ .env พังเงียบสนิท · exit 0 · ค่าหายทั้งไฟล์ได้ err มาให้ตัดสินใจเอง
เลือกได้ไหมว่าจะ fatalไม่ได้ได้ (ตัวอย่าง errors.Is ในหัวข้อ 4)
เหมาะกับโปรเจกต์เรียน / เดโม / เด็คนี้โปรเจกต์ที่มีคนอื่นมาช่วยรันด้วย

9 · กับดักในไฟล์ .env ที่เจอตอนใส่ค่าจริง

ไฟล์ตัวอย่างในสไลด์เป็นค่าง่าย ๆ ทั้งหมด แต่ของจริงคุณจะต้องใส่รหัสผ่าน และรหัสผ่านคือที่ที่กับดักอยู่ ผมรันทดสอบมาให้ครบ

9.1 · เครื่องหมาย $ ในรหัสผ่าน — ค่าหายได้จริง

godotenv ทำ variable expansion ให้ด้วย — คือถ้าเจอ $SOMETHING ในค่า มันจะแทนที่ด้วยค่าของตัวแปรชื่อนั้น เหมือนที่ shell ทำ
ฟีเจอร์นี้มีประโยชน์ (เช่น DSN="postgres://$DB_USER@localhost/db") แต่มันทำงานกับรหัสผ่านที่มี $ ด้วย ผมทดสอบสามแบบ:

# .env
A_lower=secret$ssw0rd
B_UPPER=secret$SSW0RD
C_squote='secret$SSW0RD'
$ go run .           # อ่านด้วย godotenv.Read() แล้วพิมพ์ด้วย %q
err=<nil>
A_lower        => "secret$ssw0rd"
B_UPPER        => "secret"
C_squote       => "secret$SSW0RD"
⚠️ ดูบรรทัด B_UPPER อีกรอบ — รหัสผ่านหายไปครึ่งหนึ่ง โดยไม่มี error

$SSW0RD ถูกมองว่าเป็นชื่อตัวแปร · ไม่มีตัวแปรชื่อนั้น ก็เลยแทนด้วย สตริงว่าง เหลือแค่ "secret"
แล้วทำไม A_lower ถึงรอด? ผมเปิดซอร์ส parser.go บรรทัด 253 มาดู:

expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`)

ชื่อตัวแปรที่มันรู้จักคือ [A-Z0-9_]+ — ตัวพิมพ์ใหญ่กับตัวเลขเท่านั้น
$ssw0rd เป็นตัวพิมพ์เล็ก จึงไม่เข้าเงื่อนไข เลยรอดมาได้โดยบังเอิญ
ทางแก้: รหัสผ่านที่มี $ ให้ใส่ single quote '...' เสมอ — ในไฟล์เดียวกัน expandVariables ถูกเรียกแค่สองที่คือบรรทัด 157 (ค่าที่ไม่มี quote) กับบรรทัด 177 (ค่าใน double quote) — ไม่มีบรรทัดไหนเรียกให้ค่าใน single quote เลย

9.2 · เครื่องหมาย # ในค่า — ตัดตอนได้ ถ้ามีช่องว่างนำหน้า

TOK_NOSPACE=abc#123        => "abc#123"       # ติดกัน — ปลอดภัย
TOK_SPACE=abc #123         => "abc"           # มีช่องว่างก่อน # → โดนตัดเป็น comment
TOK_SPACE_Q="abc #123"     => "abc #123"     # ใส่ quote แล้วรอด

กฎคือ # ที่มีช่องว่างนำหน้าจะกลายเป็น comment ท้ายบรรทัด
ซึ่งเป็นกฎที่คุณจะไม่นึกถึงเลยตอนก๊อป API token มาวาง — ถ้าโชคร้ายมันมี " #" อยู่กลางค่า ค่าจะถูกตัดทิ้งครึ่งหลังแบบเงียบ ๆ

9.3 · escape ทำงานแค่ \n กับ \r

MULTILINE="line1
line2
line3"                      # ขึ้นบรรทัดใหม่จริงใน double quote — ใช้ได้
    bytes: 6c 69 6e 65 31 0a 6c 69 6e 65 32 0a 6c 69 6e 65 33
                             ↑ 0a = newline จริง

ESCAPED="tab\there"         # หวังว่า \t จะเป็น tab
    bytes: 74 61 62 74 68 65 72 65
                    ↑ 74 = ตัวอักษร 't' ธรรมดา ไม่ใช่ 09 (tab)

ผมยืนยันจากซอร์ส parser.go ฟังก์ชัน expandEscapes — switch ข้างในรู้จักแค่ case "n" กับ case "r" · ที่เหลือ regex \\([^$]) ตัด backslash ทิ้งเฉย ๆ เหลือแต่ตัวอักษรที่ตามมา

9.4 · รูปแบบอื่นที่รองรับ (รันยืนยันแล้ว)

เขียนแบบนี้ใน .envค่าที่ได้จริงหมายเหตุ
SPACED=hello world"hello world"ค่ามีช่องว่างได้ ไม่ต้องใส่ quote
SPACES_AROUND = padded"padded"ช่องว่างรอบ = ถูก trim ให้
export EXPORTED=yes-with-export"yes-with-export"เขียน export นำหน้าได้ (ก๊อปจาก shell มาวางได้เลย)
DQ="double quoted value""double quoted value"quote ถูกถอดออกให้
SQ='single quoted value'"single quoted value"เหมือนกัน แต่ไม่ทำ variable expansion
# this is a comment—ไม่โผล่เป็น key ในผลลัพธ์เลย

10 · ห้าม commit .env ขึ้น Git

ข้อสุดท้ายของสไลด์หน้า 11 ข้อนี้ไม่ใช่พฤติกรรมของโค้ด — เป็นธรรมเนียมปฏิบัติ แต่เป็นข้อที่แพงที่สุดถ้าพลาด เพราะดูบรรทัดนี้ในไฟล์ตัวอย่างของสไลด์:

POSTGRES_DSN=postgres://postgres:secret@localhost:5432/wongnok?sslmode=disable

คำว่า secret ตรงนั้นคือรหัสผ่านฐานข้อมูล — ถ้าไฟล์นี้เข้า Git มันจะอยู่ใน ประวัติ repo ถาวร ลบทีหลังไม่ช่วย (เหตุผลเต็มอยู่ใน บทเรียน 44 หัวข้อ 1)
วิธีกันมีบรรทัดเดียว ผมรันให้ดูของจริงในโฟลเดอร์ทดสอบ:

$ /usr/bin/git status --porcelain      # ยังไม่มี .gitignore
?? .env
?? .env.example

$ printf '.env\n' > .gitignore
$ /usr/bin/git status --porcelain
?? .env.example
?? .gitignore                          # .env หายไปจากรายการแล้ว

ถ้าอยากเช็คให้แน่ใจว่ากฎไหนเป็นตัวกัน ใช้ git check-ignore -v:

$ git check-ignore -v .env
.gitignore:1:.env	.env          # กฎบรรทัดที่ 1 ของ .gitignore เป็นตัวกัน
exit=0                                 # exit 0 = ถูก ignore

$ git check-ignore -v .env.example
exit=1                                 # exit 1 = ไม่ถูก ignore (ถูกต้องแล้ว)

คู่หูที่ต้องมาด้วยกันเสมอ: .env (ignore) + .env.example (commit)
สังเกตในผลรันข้างบน — .env.example ไม่ถูก ignore ซึ่งตั้งใจให้เป็นแบบนั้น
ไฟล์ .env.example คือ .env ที่ถอดค่าลับออกหมด เหลือแต่ชื่อ key:

# .env.example — commit ตัวนี้ขึ้น Git ได้
APP_NAME=wongnok
APP_ENV=development
APP_PORT=8080
APP_SHUTDOWN_TIMEOUT=10s
LOG_LEVEL=DEBUG
LOG_FORMAT=text
POSTGRES_DSN=postgres://postgres:CHANGE_ME@localhost:5432/wongnok?sslmode=disable
POSTGRES_MAX_OPEN_CONNS=25
POSTGRES_CONN_MAX_LIFETIME=30m

มันตอบคำถามที่คนใหม่เข้าทีมถามวันแรกพอดี — "ต้องตั้ง env อะไรบ้างถึงจะรันได้"
คำสั่งแรกของคนเข้าทีมใหม่จึงกลายเป็น cp .env.example .env แล้วแก้เฉพาะค่าลับ

🧭 ถ้าเผลอ commit ไปแล้วต้องทำยังไง
สั่ง git rm --cached .env เพื่อเอาออกจาก index (ไฟล์ในเครื่องยังอยู่) แล้วเพิ่ม .gitignore — แต่นั่นแก้แค่ commit ต่อไป
commit เก่ายังมีรหัสผ่านอยู่ · ใครที่ clone ไปแล้วก็ยังอ่านได้
สิ่งที่ต้องทำจริงคือไปเปลี่ยนรหัสผ่านที่ฐานข้อมูล ถือว่ารหัสเดิมหลุดแล้ว

11 · สรุปห้าบรรทัด

คำถามคำตอบ
.env คืออะไร ไฟล์ text ธรรมดา ที่เป็นแค่ข้อตกลงของเครื่องมือ — ไม่ใช่ feature ของ Go หรือของ OS
godotenv ทำอะไร อ่านไฟล์นั้นแล้วยัดค่าเข้า environment (ขั้น 1 → 2 ของแผนภาพ) จบแค่นั้น
caarlos0/env ทำอะไร ดึงค่าออกจาก environment มาใส่ struct + แปลง type + เติม default (ขั้น 2 → 3)
บน production ใช้อะไร ตัดขั้น 1 ทิ้ง — ค่าเข้าที่ขั้น 2 โดยตรงจาก Docker / Kubernetes / CI · โค้ดไม่ต้องแก้
แล้วใครตรวจว่าค่าถูกต้อง ยังไม่มีใครเลย — ทั้งบทนี้ยังไม่มีอะไรตรวจ (ดูกล่องข้างล่าง)
⚠️ ช่องโหว่ที่ยังเปิดอยู่ — ไปต่อที่บทเรียน 47

ผมรันเคส "ลืมทั้งสองทาง" ให้ดู: ไม่มีไฟล์ .env และไม่ได้ตั้ง env เลยสักตัว

--- STEP 3: env.ParseAs[Config]() ---
err=<nil>
AppPort         int      8080         // จาก envDefault
ShutdownTimeout time.Duration 10s     // จาก envDefault
MaxOpenConns    int      0
ConnMaxLifetime time.Duration 0s
AppName         string   ""
PostgresDSN     string   ""
exit=0

err=<nil> · exit=0 — ไม่มีอะไรผิดในสายตาของโปรแกรม แต่ PostgresDSN เป็นสตริงว่าง และ ConnMaxLifetime เป็น 0s
แอปตัวนี้จะ start ขึ้นมาสวยงาม แล้วไปพังตอนต่อฐานข้อมูล
นี่คือกล่องแดง "ถ้าพัง ต้องพังตรงนี้" ในแผนภาพหัวข้อ 7 ที่เรายังไม่ได้ทำ — ขั้นที่ขาดคือ validator + errors.Join ซึ่งเป็นเนื้อหาของ บทเรียน 47

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

1. โปรแกรมใช้ import _ "github.com/joho/godotenv/autoload" · ไฟล์ .env ของหัวข้อ 2 (12 บรรทัด · 9 คู่ key=value) แต่บรรทัดที่ 7 คือ LOG_LEVEL ลืมพิมพ์ = — เกิดอะไรขึ้น?

ผลรันจริงกับไฟล์ .env ของสไลด์ที่แก้บรรทัด LOG_LEVEL=DEBUG เป็น LOG_LEVEL DEBUG: ค่าทั้ง 9 ตัวได้ "" หมด รวมทั้ง APP_NAME ที่อยู่บรรทัดแรกสุด
เหตุผลสองชั้น:
· godotenv เป็น all-or-nothing — parse ทั้งไฟล์ก่อน เจอที่พังตรงไหนก็คืน error แล้วไม่ตั้งอะไรเลยสักตัว (ตัวเลือกที่ 1 กับ 4 จึงผิด)
· package autoload ทั้ง package มี 15 บรรทัด และ init() ข้างในเขียนแค่ godotenv.Load() เฉย ๆ — ไม่ได้รับ error กลับมาเลย (ตัวเลือกที่ 2 จึงผิด)
จุดที่ต้องจำ: ถ้าใช้วิธีที่ 2 (if err := godotenv.Load(); err != nil) อย่างน้อยคุณจะได้ unexpected character "\n" in variable name near ... มาให้อ่าน

2. ไฟล์ .env มี APP_NAME=from-dotenv และ PORT=9999 · โปรแกรมใช้ autoload · สั่งรันด้วย APP_NAME=from-shell go run . — ได้อะไร?

ผลรันจริง: APP_NAME="from-shell" · PORT="9999"
autoload เรียก godotenv.Load() ข้างใน ซึ่ง comment ในซอร์ส (godotenv.go บรรทัด 50) เขียนไว้ว่า "It's important to note that it WILL NOT OVERRIDE an env variable that already exists"
ตัวเลือกที่ 2 คือพฤติกรรมของ godotenv.Overload() ต่างหาก — ผมรันเทียบแล้วได้ after(overload): APP_NAME="from-dotenv"
ตัวเลือกที่ 3 คือจุดที่คนเข้าใจผิดบ่อย: การชนะกันเกิดเป็นราย key ไม่ใช่ทั้งไฟล์ — PORT ที่ shell ไม่ได้ตั้ง ก็ยังได้ค่าจาก .env ตามปกติ
ประโยชน์จริงของพฤติกรรมนี้: คุณ override ค่าครั้งเดียวได้ด้วย APP_PORT=9999 go run . โดยไม่ต้องแตะไฟล์ .env เลย

3. ทีมกำลังจะขึ้น production บน Kubernetes โดยส่ง config ทาง env: ใน manifest · โค้ดยังมี import _ ".../godotenv/autoload" ค้างอยู่ — ควรทำยังไง?

ผมรันเคสนี้จริง: ลบไฟล์ .env ทิ้ง แล้วส่งค่าทาง env ทั้งหมด โดยไม่แก้โค้ดเลย ผลคือ err=<nil> · config ครบทุกตัว · exit=0
ตัวเลือกที่ 1 ผิด: autoload ทิ้ง error อยู่แล้ว และต่อให้เรียก Load() เอง error ที่ได้ก็เป็นแค่ open .env: no such file or directory (*fs.PathError) ที่ errors.Is(err, os.ErrNotExist) เป็น true — ไม่ใช่ crash
ตัวเลือกที่ 2 ผิดและอันตราย: .env มีรหัสผ่าน dev อยู่ข้างใน การเอาขึ้น image คือการแจกรหัสผ่านไปกับ image · แถมยังเสี่ยงเจอปัญหา cwd ตามหัวข้อ 3 อีก
ตัวเลือกที่ 3 ผิดกลับด้าน: Overload() จะทำให้ .env ไปทับ ค่าจริงจาก Kubernetes ซึ่งเป็นสิ่งที่ไม่ควรเกิดเด็ดขาด
นี่คือช่อง "ถ้าใช้ผิดที่" ของตารางหน้า 13: ใส่บน prod = ไม่เกิดอะไร (ไม่มีไฟล์)

4. รหัสผ่านฐานข้อมูลจริงคือ secret$SSW0RD · เขียนใน .env ว่า DB_PASS=secret$SSW0RD — godotenv อ่านได้ค่าอะไร?

ผลรันจริง: B_UPPER => "secret" — รหัสผ่านหายไปครึ่งหลัง โดยไม่มี error (ตัวเลือกที่ 3 จึงผิด · err=<nil>)
สาเหตุคือ variable expansion · ผมเปิด parser.go บรรทัด 253 มาดู regex ที่มันใช้: (\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?
ชื่อตัวแปรที่มันจับคือ [A-Z0-9_]+ — พิมพ์ใหญ่กับตัวเลขเท่านั้น
ผมทดสอบเทียบให้เห็นชัด: secret$ssw0rd (พิมพ์เล็ก) ได้ค่าครบ "secret$ssw0rd" เพราะไม่เข้า regex — รอดโดยบังเอิญ ไม่ใช่เพราะปลอดภัย
ทางแก้: ใช้ single quote — DB_PASS='secret$SSW0RD' ได้ค่าครบ เพราะ expandVariables ถูกเรียกเฉพาะกรณี double quote และกรณีไม่มี quote เท่านั้น

🛠️ ภารกิจ — ต่อท่อทั้งเส้นด้วยมือตัวเอง แล้วทำให้มันพัง

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

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ · go mod init pipe · go get github.com/joho/godotenv@v1.5.1 · go get github.com/caarlos0/env/v11@v11.4.1
    สร้างไฟล์ .env ตามหัวข้อ 2 ทั้ง 9 ค่า และ Config struct ตามหัวข้อ 7
  2. พิมพ์ทั้งสองขั้นออกมา: ให้โปรแกรมพิมพ์ os.Getenv ของทั้ง 9 key (ขั้น 2) แล้วตามด้วย cfg ที่ได้จาก env.ParseAs[Config]() (ขั้น 3)
    พิมพ์ type ออกมาด้วย (%T) — คุณต้องเห็นด้วยตาว่า "25" กลายเป็น int และ "30m" กลายเป็น time.Duration
  3. ลบ import _ autoload ออกชั่วคราว แล้วรันใหม่
    ตอบให้ได้ว่าขั้นไหนพัง และ err เป็นอะไร — แล้วใส่กลับ
  4. ทดสอบ Load ชนะกันเป็นราย key: รัน APP_PORT=9999 go run .
    ต้องได้ AppPort=9999 แต่ค่าอื่นยังมาจาก .env ครบ
    จากนั้นเปลี่ยนไปใช้ godotenv.Overload() เอง (เลิกใช้ autoload) แล้วรันคำสั่งเดิม — จดว่าได้พอร์ตอะไร แล้วตอบว่าทำไม Load() ถึงเป็น default ที่ถูกต้อง
  5. ทำให้ .env พังหนึ่งบรรทัด: ลบ = ออกจากบรรทัด LOG_LEVEL แล้วรันด้วย autoload
    นับดูว่าค่าหายไปกี่ตัวจาก 9 ตัว · ดู exit code ด้วย (go run . ; echo "exit=$?")
  6. เปลี่ยนไปใช้วิธีที่ 2 แล้วทำซ้ำข้อ 5: เอา autoload ออก แล้วเขียน if err := godotenv.Load(); err != nil { … } ที่พิมพ์ err ออกมา
    เทียบกับข้อ 5 — ตอบว่าคุณอยากใช้วิธีไหนในโปรเจกต์จริง และทำไม
  7. ทดสอบกับดัก $: เปลี่ยนรหัสผ่านใน POSTGRES_DSN เป็น secret$SSW0RD (ไม่ใส่ quote) แล้วพิมพ์ cfg.PostgresDSN ด้วย %q
    จากนั้นลองใส่ '...' ครอบทั้งค่า แล้วรันใหม่ — เทียบสองผลให้เห็นความต่าง
  8. ทำ .gitignore + .env.example: git init · ใส่ .env ลง .gitignore · สร้าง .env.example ที่เปลี่ยนรหัสผ่านเป็น CHANGE_ME
    ยืนยันด้วย git check-ignore -v .env และ git check-ignore -v .env.example — ต้องได้ exit code คนละค่า
  9. ท้าทาย: ลบไฟล์ .env ทิ้ง และไม่ตั้ง env เลย แล้วรัน
    คุณจะได้ err=<nil> กับ PostgresDSN=""
    เขียนโค้ดเช็คเองสัก 5 บรรทัด ให้แอป log.Fatal ถ้า PostgresDSN ว่าง แล้วดูว่าโค้ดแบบนี้จะบวมแค่ไหนถ้ามี config 15 ตัว — นั่นคือคำถามที่ บทเรียน 47 เกิดมาตอบ

ขั้นที่พังคือขั้น 1 → 2 · ไม่มีใครอ่านไฟล์ .env อีกแล้ว environment จึงว่างเปล่า
แต่จุดที่คนตอบผิดคือ err — ผลรันจริงของเคส "ไม่มีค่าเข้ามาเลย" คือ:

--- STEP 3: env.ParseAs[Config]() ---
err=<nil>
AppPort         int      8080
ShutdownTimeout time.Duration 10s
MaxOpenConns    int      0
ConnMaxLifetime time.Duration 0s
AppName         string   ""
PostgresDSN     string   ""

err ยังเป็น <nil> — เพราะ caarlos0/env ไม่ได้ทำอะไรผิดเลย environment ว่างจริง ๆ และ struct ก็ไม่มี field ไหนใส่ ,required ไว้
สังเกตว่า AppPort กับ ShutdownTimeout ยังดูปกติ เพราะมี envDefault — ส่วนตัวที่ไม่มี default ได้ zero value (0, 0s, "") ซึ่งหน้าตาไม่เหมือน error เลย

ถ้าตอบว่า "ParseAs จะ error เพราะไม่มีค่า" แปลว่ายังติดกับดักเดียวกับ บทเรียน 44 — ค่าที่แปลงไม่ได้/ไม่มีค่า ไม่ได้กลายเป็น error มันกลายเป็นค่าที่ดูปกติแต่ผิด
นี่คือเหตุผลที่แผนภาพหัวข้อ 7 ยังมีขั้น validator ต่อท้ายอีกขั้น

💬 ข้อ 5 กับ 6 คือคู่ที่ผมอยากให้ทำจริง ๆ ของบทนี้ — เพราะความรู้สึกตอนเห็นค่าหาย 9 ตัวโดยไม่มีอะไรบอก คือสิ่งที่จะทำให้คุณตัดสินใจเองได้ว่าจะใช้ autoload หรือไม่ แทนที่จะเลือกเพราะสไลด์เขียนว่า "แนะนำ"
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 กับ godotenv v1.5.1 และ caarlos0/env v11.4.1 ซึ่งเลขบรรทัดของซอร์สที่ผมอ้าง (parser.go:253, godotenv.go:50, godotenv.go:179) ก็เป็นของเวอร์ชันนั้น

อ้างอิง