🧰 ของที่ใช้ในบทนี้ — ทุก 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 ตัวหนึ่ง
ใน 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)
คนที่เพิ่งเห็นสองตัวนี้พร้อมกันมักคิดว่ามันทำอย่างเดียวกัน แล้วสงสัยว่าจะเลือกตัวไหนดี
— ซึ่งเป็นคำถามที่ผิดตั้งแต่ต้น เพราะมันอยู่คนละจุดของท่อเดียวกัน:
godotenv | caarlos0/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 แรก:
สองกล่องทางขวาคือส่วนที่คนอ่านข้ามบ่อยที่สุด แต่มันสำคัญที่สุด:
-
กล่องบน — บน production ค่าเข้าที่ขั้น (2) โดยตรง
ลูกศรชี้เข้า Environment Variables ไม่ได้ชี้เข้า.envแปลว่าขั้น (1) หายไปทั้งขั้นบน production และเส้นทางที่เหลือ (2)→(3)→(4)→(5) เหมือนเดิมทุกประการ -
กล่องล่าง — ถ้าพัง ต้องพังตรงนี้
ทุกขั้นในภาพนี้เกิดก่อนที่ server จะเปิดรับ request ถ้า config ขาดหรือผิดแม้แต่ตัวเดียว แอปต้องexit status 1ทันที ไม่ใช่ start ขึ้นมาแล้วไปพังตอนผู้ใช้จริงยิงเข้ามา (เหตุผลเต็ม ๆ อยู่ใน บทเรียน 44 หัวข้อ 6 ข้อ 5)
เดินทั้งเส้นด้วยของจริง
ผมเอา .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 = ""
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 · โค้ดไม่ต้องแก้ |
| แล้วใครตรวจว่าค่าถูกต้อง | ยังไม่มีใครเลย — ทั้งบทนี้ยังไม่มีอะไรตรวจ (ดูกล่องข้างล่าง) |
ผมรันเคส "ลืมทั้งสองทาง" ให้ดู: ไม่มีไฟล์ .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 ทำงานได้" —
เป้าหมายคือเห็นด้วยตาตัวเองว่าตอนมันพังเงียบ หน้าตาเป็นยังไง
เพื่อที่วันที่เจอของจริง คุณจะจำได้ทันที
- ตั้งโปรเจกต์: สร้างโฟลเดอร์ใหม่ ·
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 ค่า และConfigstruct ตามหัวข้อ 7 - พิมพ์ทั้งสองขั้นออกมา: ให้โปรแกรมพิมพ์
os.Getenvของทั้ง 9 key (ขั้น 2) แล้วตามด้วยcfgที่ได้จากenv.ParseAs[Config]()(ขั้น 3)
พิมพ์ type ออกมาด้วย (%T) — คุณต้องเห็นด้วยตาว่า"25"กลายเป็นintและ"30m"กลายเป็นtime.Duration - ลบ
import _ autoloadออกชั่วคราว แล้วรันใหม่
ตอบให้ได้ว่าขั้นไหนพัง และerrเป็นอะไร — แล้วใส่กลับ - ทดสอบ Load ชนะกันเป็นราย key: รัน
APP_PORT=9999 go run .
ต้องได้AppPort=9999แต่ค่าอื่นยังมาจาก.envครบ
จากนั้นเปลี่ยนไปใช้godotenv.Overload()เอง (เลิกใช้autoload) แล้วรันคำสั่งเดิม — จดว่าได้พอร์ตอะไร แล้วตอบว่าทำไมLoad()ถึงเป็น default ที่ถูกต้อง - ทำให้
.envพังหนึ่งบรรทัด: ลบ=ออกจากบรรทัดLOG_LEVELแล้วรันด้วยautoload
นับดูว่าค่าหายไปกี่ตัวจาก 9 ตัว · ดูexit codeด้วย (go run . ; echo "exit=$?") - เปลี่ยนไปใช้วิธีที่ 2 แล้วทำซ้ำข้อ 5: เอา
autoloadออก แล้วเขียนif err := godotenv.Load(); err != nil { … }ที่พิมพ์errออกมา
เทียบกับข้อ 5 — ตอบว่าคุณอยากใช้วิธีไหนในโปรเจกต์จริง และทำไม - ทดสอบกับดัก
$: เปลี่ยนรหัสผ่านในPOSTGRES_DSNเป็นsecret$SSW0RD(ไม่ใส่ quote) แล้วพิมพ์cfg.PostgresDSNด้วย%q
จากนั้นลองใส่'...'ครอบทั้งค่า แล้วรันใหม่ — เทียบสองผลให้เห็นความต่าง - ทำ
.gitignore+.env.example:git init· ใส่.envลง.gitignore· สร้าง.env.exampleที่เปลี่ยนรหัสผ่านเป็นCHANGE_ME
ยืนยันด้วยgit check-ignore -v .envและgit check-ignore -v .env.example— ต้องได้ exit code คนละค่า - ท้าทาย: ลบไฟล์
.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)
ก็เป็นของเวอร์ชันนั้น
อ้างอิง
- github.com/joho/godotenv — อ่าน README ส่วน "Usage" แล้วเทียบกับหัวข้อ 4 · ที่น่าอ่านกว่าคือไฟล์
autoload/autoload.goที่มี 15 บรรทัดทั้ง package - pkg.go.dev —
godotenv.Load· Overload · Read — อ่านประโยค "WILL NOT OVERRIDE" ในหน้าLoadซึ่งเป็นที่มาของหัวข้อ 5 ทั้งหมด ·Readคืนmapโดยไม่แตะ environment เหมาะกับเวลาอยาก debug ว่าไฟล์ถูก parse เป็นอะไร - github.com/caarlos0/env — ตัวที่ทำขั้น 2 → 3 ของแผนภาพ · รายละเอียดเต็มอยู่ใน บทเรียน 45
- The Twelve-Factor App — III. Config — ต้นทางของประโยค "config ต้องอยู่ใน environment" · สังเกตว่าเอกสารนี้ไม่ได้พูดถึงไฟล์
.envเลย ซึ่งตรงกับที่สไลด์บอกว่ามันเป็นแค่ข้อตกลงของเครื่องมือ - pkg.go.dev —
os.Setenv— สิ่งที่godotenvเรียกให้เราจริง ๆ ข้างใน · อ่านเพื่อเข้าใจว่าค่าที่ตั้งมีผลกับ process ตัวเองและลูกที่เกิดทีหลังเท่านั้น - git —
gitignore· git-check-ignore — อ่านส่วน exit code ของcheck-ignore: 0 = ถูก ignore · 1 = ไม่ถูก ignore ซึ่งเป็นผลที่ใช้ในหัวข้อ 10 - บทเรียน 44 — Configuration & Environment Variables — ปัญหาที่ทั้ง Day 9 เกิดมาแก้ · บทเรียน 45 · บทเรียน 47 · บทเรียน 48