🧰 ของที่ใช้ในหน้านี้ — ทุก output ข้างล่างมาจากการรันจริงบน
go version go1.26.5 darwin/arm64
github.com/caarlos0/env/v11 v11.4.1
github.com/joho/godotenv v1.5.1
github.com/go-playground/validator/v10 v10.30.3
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/oauth2 v0.36.0
github.com/coreos/go-oidc/v3 v3.20.0
gorm.io/gorm v1.31.2
gorm.io/driver/postgres v1.6.2
goose v3.27.3
quay.io/keycloak/keycloak:26.7 # tag นี้ชี้ไปที่ 26.7.2
postgres:16-alpine # PostgreSQL 16.15 บน colima
ที่ต้องรู้ก่อนอ่านต่อ: ผมรัน Postgres ที่พอร์ต 55432 และ Keycloak ที่ 8081 เพราะพอร์ตมาตรฐานบนเครื่องผมมีของอื่นจองอยู่ — เห็นเลขพอร์ตแปลก ๆ ใน DSN ตัวอย่างก็คือสาเหตุนี้ ไม่ใช่กฎอะไร
สารบัญ
- เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
- caarlos0/env — struct tag · option · type ที่รองรับ
- caarlos0/env — กับดัก
- godotenv — autoload vs Load vs Overload
- .env — รูปแบบไฟล์ + .gitignore
- validator/v10 — tag · error จริง · errors.Join · Humanize
- โครง internal/config ที่ก็อปไปใช้ได้
- ท่อลำเลียงค่าทั้งเส้น
- JWT — โครงสร้าง · golang-jwt/v5 · error ทุกเคส
- OAuth 2.0 — ตัวละคร 4 ตัว + Authorization Code Grant
- Keycloak — compose · endpoint · components
- flow /login /callback /exchange /logout
- goose + ตาราง users
- ตารางกับดักรวมทั้งวัน
- ตารางแปล error / panic ที่เจอบ่อย
1 · เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
| ของ | เวอร์ชัน | ติดตั้งยังไง |
|---|---|---|
| Go | 1.26.5 darwin/arm64 | — |
github.com/caarlos0/env/v11 | v11.4.1 | go get github.com/caarlos0/env/v11 |
github.com/joho/godotenv | v1.5.1 | go get github.com/joho/godotenv |
github.com/go-playground/validator/v10 | v10.30.3 | go get github.com/go-playground/validator/v10 |
github.com/golang-jwt/jwt/v5 | v5.3.1 | go get github.com/golang-jwt/jwt/v5 |
golang.org/x/oauth2 | v0.36.0 | go get golang.org/x/oauth2 |
github.com/coreos/go-oidc/v3 | v3.20.0 | go get github.com/coreos/go-oidc/v3 |
gorm.io/gorm · gorm.io/driver/postgres | v1.31.2 · v1.6.2 | ของเดิมจาก Day 7 |
goose (CLI) | v3.27.3 | ของเดิมจาก Day 7 |
| Keycloak | image tag 26.7 → เวอร์ชันจริง 26.7.2 | quay.io/keycloak/keycloak:26.7 |
| PostgreSQL | 16.15 (aarch64-unknown-linux-musl) | postgres:16-alpine |
| Docker | client 29.6.2 · server 29.5.2 | ผ่าน colima ไม่ใช่ Docker Desktop |
$ cat $(go env GOMODCACHE)/github.com/caarlos0/env/v11@v11.4.1/go.mod
module github.com/caarlos0/env/v11
// (มีแต่ retract 3 บรรทัด กับ go 1.18 — ไม่มี require เลยสักบรรทัด)
go 1.18
$ ls -l .../env/v11@v11.4.1/go.sum
--- go.sum: 0B (ไฟล์ว่างเปล่า) ---
แม้แต่ env_test.go ก็ใช้แค่ stdlib —
encoding/base64, encoding/json, errors, fmt, net/http, net/url, os, path/filepath,
reflect, runtime, strconv, strings, testing, time — ไม่มี testify ·
เกร็ด: go.mod ประกาศ retract ไว้สามเวอร์ชัน (v11.0.1 / v11.2.0 / v11.3.0)
อาการที่เจอจริงบนเครื่องทดสอบ สองอย่าง:
① docker compose (แบบ subcommand) ใช้ไม่ได้ ขึ้น
docker: unknown command: docker compose เพราะ
~/.docker/cli-plugins/docker-compose เป็น symlink ค้างไปที่ /Applications/Docker.app
ที่ถูกลบไปแล้ว → ใช้ docker-compose (มีขีด) แทน
② docker pull พังด้วย
error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATH
ซึ่งเป็นซาก credsStore ใน ~/.docker/config.json
2 · caarlos0/env — struct tag · option · type ที่รองรับ
โครงพื้นฐาน
import "github.com/caarlos0/env/v11"
type Config struct {
AppPort int `env:"APP_PORT" envDefault:"8080"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"DEBUG"`
PostgresDSN string `env:"POSTGRES_DSN"`
}
cfg, err := env.ParseAs[Config]()
--- run 1: no env set (defaults) ---
cfg=main.Config{AppPort:8080, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=<nil>
AppPort type=int value=8080
ShutdownTimeout type=time.Duration value=10s ms=10000
--- run 2: env set ---
cfg=main.Config{AppPort:9999, ShutdownTimeout:90000000000, LogLevel:"INFO", PostgresDSN:"postgres://u:p@localhost:5432/db"}
err=<nil>
AppPort type=int value=9999
ShutdownTimeout type=time.Duration value=1m30s ms=90000
PostgresDSN ไม่มี envDefault และไม่ได้ตั้ง env → ได้ ""
เงียบ ๆ ไม่ error เพราะไม่ได้ใส่ ,required
struct tag ทั้ง 4 ตัว
| tag | ทำอะไร | เขียนยังไง | ไม่ใส่แล้วเป็นยังไง |
|---|---|---|---|
env | บอกว่า field นี้อ่านจาก env ชื่ออะไร | `env:"APP_PORT"` | field นั้นไม่ถูกอ่านเลย |
envDefault | ค่าที่ใช้เมื่อไม่มี env ตัวนั้น | `env:"APP_PORT" envDefault:"8080"` | ได้ zero value ของ type นั้น |
envSeparator | ตัวคั่นของ slice | `env:"ORIGINS" envSeparator:";"` | ใช้ , เป็นค่าเริ่มต้น |
envPrefix | เติมหน้า key ของ struct ซ้อน | Primary DB `envPrefix:"DB_"` | struct ซ้อนอ่าน key เปล่า ๆ (เช่น HOST) |
$ HOSTS="a.com,b.com,c.com" ORIGINS="x.io;y.io" PORTS="1|2|3" go run .
[Sep] main.Sep{DefaultSep:[]string{"a.com", "b.com", "c.com"}, CustomSep:[]string{"x.io", "y.io"},
IntsSep:[]int{1, 2, 3}, SepDefault:[]string{"a", "b", "c"}} err=<nil>
envSeparator ใช้ได้กับ slice ของ type อื่นด้วย ไม่ใช่แค่ []string
(ทดสอบ []int ผ่าน) · และ envDefault ก็ถูก split ด้วย separator เดียวกัน
(SepDefault มาจาก envDefault:"a,b,c" ทั้งที่ไม่ได้ตั้ง env TAGS เลย)
$ DB_HOST="pg.local" DB_PORT=6543 REDIS_HOST="redis.local" HOST="bare.local" go run .
[Nested] main.Nested{Primary:main.DB{Host:"pg.local", Port:6543},
Cache:main.Redis{Host:"redis.local"}, NoPfx:main.DB{Host:"bare.local", Port:5432}} err=<nil>
NoPfx เป็น struct ซ้อนที่ไม่ได้ใส่ envPrefix — มันอ่าน
HOST เปล่า ๆ · มีหลาย struct ซ้อนแล้วลืมใส่ prefix เมื่อไหร่ ทั้งคู่จะไปแย่ง key เดียวกัน
option ท้าย tag — เขียนต่อหลังชื่อ key คั่นด้วย ,
| option | เช็คอะไร | error จริงเมื่อไม่ผ่าน |
|---|---|---|
,required |
มี env ตัวนี้อยู่ไหม — ไม่ได้เช็คว่าว่างหรือเปล่า | env: required environment variable "MYVAR2" is not set |
,notEmpty |
ต้องมี และห้ามเป็นค่าว่าง | env: environment variable "NE_VAR" should not be empty |
,file |
เอาค่า env เป็น path แล้วอ่านเนื้อไฟล์มาใส่แทน | env: could not load content of file "/definitely/not/here.txt" from variable F_VAR: open /definitely/not/here.txt: no such file or directory |
,unset |
parse เสร็จแล้ว ลบ env ตัวนั้นทิ้ง | ไม่มี error — เป็นการกระทำ ไม่ใช่การตรวจ |
// ,unset — ค่าเข้า struct ครบ แต่หลังจากนั้น env หายไปจริง
[unset] parsed="temp-value" err=<nil> ; after parse LookupEnv -> value="" exists=false
,unset เหมาะกับ secret ที่ไม่อยากให้ process ลูกเห็นต่อ —
โปรแกรมเราอ่านไปแล้ว ใครที่ fork ต่อจากเราจะไม่เห็นอีก
,file ไม่ trim ขึ้นบรรทัดใหม่
$ # ไฟล์มีเนื้อว่า "s3cr3t-from-file\n"
[file ok] value="s3cr3t-from-file\n" err=<nil>
ได้ \n ติดมาด้วย · ถ้าเอาไปใช้กับ Docker secret
(/run/secrets/db_password) รหัสผ่านจะผิดแบบหาสาเหตุไม่เจอ
เพราะ log จะพิมพ์ออกมาดูเหมือนถูกทุกตัวอักษร — ต้อง strings.TrimSpace เองเสมอ
type ที่รองรับ — รันครบทุกตัวแล้ว
$ I8=-12 I64=9223372036854775807 U16=65535 U64=18446744073709551615 F32=3.14 F64=2.718281828 \
B=true S=hello D=1h30m LOC=Asia/Bangkok U="https://api.example.com:8443/v1/users?q=1" \
UP="postgres://user@db:5432/app" PI=42 PS=ptrval SL="a,b,c" SLD="1s,2m,3h" \
M="a:1,b:2" MS="k1:v1,k2:v2" LVL=info LVLP=warn T="2026-08-22T10:30:00Z" go run .
I8=-12 I64=9223372036854775807 U16=65535 U64=18446744073709551615
F32=3.14 F64=2.718281828 B=true S="hello"
D=1h30m0s (time.Duration)
LOC=Asia/Bangkok (*time.Location)
U=url.URL{Scheme:"https", Opaque:"", User:(*url.Userinfo)(nil), Host:"api.example.com:8443",
Path:"/v1/users", Fragment:"", RawQuery:"q=1", RawPath:"", RawFragment:"",
ForceQuery:false, OmitHost:false}
UP=postgres://user@db:5432/app (*url.URL)
PI=0x5de58d7140d0 deref=42
PS deref="ptrval"
SL=[]string{"a", "b", "c"}
SLD=[]time.Duration{1000000000, 120000000000, 10800000000000}
M=map[string]int{"a":1, "b":2}
MS=map[string]string{"k1":"v1", "k2":"v2"}
Lvl=main.LogLevel{v:"INFO"} LvlP=&{WARN}
T=2026-08-22 10:30:00 +0000 UTC (time.Time)
| กลุ่ม | type | รูปแบบค่าใน env |
|---|---|---|
| จำนวนเต็ม | int int8 int16 int32 int64 · uint …uint64 | -12 · 65535 |
| ทศนิยม / bool / string | float32 float64 bool string | 3.14 · true · hello |
| เวลา | time.Duration | 1h30m — ต้องมีหน่วยเสมอ |
| เวลา | time.Time | 2026-08-22T10:30:00Z (RFC3339) |
| โซนเวลา | *time.Location | Asia/Bangkok |
| URL | url.URL · *url.URL | https://api.example.com:8443/v1/users?q=1 |
| slice | []string · []int · []time.Duration | a,b,c (เปลี่ยนตัวคั่นด้วย envSeparator) |
| map | map[string]int · map[string]string | a:1,b:2 — : คั่น key/value · , คั่นคู่ |
| pointer | *int · *string · … | เหมือน type ที่มันชี้ |
| ของเราเอง | อะไรก็ได้ที่เขียน encoding.TextUnmarshaler | ตามที่เราเขียนเอง |
time.Time ใช้ได้ทั้งที่ README ไม่ได้ list ไว้ — เพราะมันเป็น
encoding.TextUnmarshaler อยู่แล้ว · นี่คือเหตุผลว่าทำไม
TextUnmarshaler สำคัญ: เขียนเมธอดเดียวแล้ว type ของเราใช้กับ env ได้ทันที
// custom type ที่คืน error → env ห่อให้เป็น parse error
$ LVL=verbose go run .
ERR: env: parse error on field "Lvl" of type "main.LogLevel": unknown log level "VERBOSE"
D=0s (time.Duration)
LOC=UTC (*time.Location) ← ไม่ใช่ nil!
UP=<nil> (*url.URL)
PI=nil
PS=nil
SL=[]string(nil)
M=map[string]int(nil) ← nil map: เขียนลงไปจะ panic
T=0001-01-01 00:00:00 +0000 UTC (time.Time)
*time.Location เป็นข้อยกเว้นตัวเดียว — ไม่ตั้ง env แล้วได้
UTC ไม่ใช่ nil ต่างจาก pointer ตัวอื่นทั้งหมด · อย่าเขียนโค้ดที่เช็ค
== nil เหมือนกันหมด
3 · caarlos0/env — กับดัก
① ,required คู่กับ envDefault = required ไม่มีผลเลย
type A struct { Both string `env:"MYVAR,required" envDefault:"fallback"` }
type B struct { OnlyRequired string `env:"MYVAR2,required"` }
=== ไม่ตั้ง env เลย ===
[A required+envDefault] value="fallback" err=<nil>
[B required only] value="" err=env: required environment variable "MYVAR2" is not set
A มี required แต่ได้ default ไปเงียบ ๆ err=<nil> ·
B ที่มี required เปล่า ๆ error ตามคาด · เหตุผลอยู่ในซอร์ส
env.go ฟังก์ชัน getOr (env.go:648-660 ของ v11.4.1):
func getOr(key, defaultValue string, defExists bool, envs map[string]string) (val string, exists, isDefault bool) {
value, exists := envs[key]
switch {
case (!exists || key == "") && defExists:
return defaultValue, true, true // ← บรรทัด 651-652 · คืน exists=true
case exists && value == "" && defExists:
return defaultValue, true, true // ← ตั้งเป็นค่าว่างก็ยังได้ default
พอ exists เป็น true เสมอเมื่อมี envDefault เงื่อนไขที่
env.go:613
if fieldParams.Required && !exists && fieldParams.OwnKey != "" {
ก็ไม่มีวันเป็นจริง
② ,notEmpty คู่กับ envDefault ก็โดนกลบเหมือนกันเป๊ะ
type C struct { BothNotEmpty string `env:"MYVAR3,notEmpty" envDefault:"fallback3"` }
=== ไม่ตั้ง env เลย ===
[C notEmpty+envDefault] value="fallback3" err=<nil>
=== ตั้ง MYVAR3 ว่าง (MYVAR3=) ===
[C notEmpty+envDefault] value="fallback3" err=<nil>
ตั้งเป็นค่าว่างก็ยังได้ default (มี case exists && value == "" && defExists ใน
getOr อีกอัน) · กฎที่จำง่ายที่สุด: ใส่ envDefault เมื่อไหร่
required และ notEmpty ตายทันที
,required ไม่กันค่าว่าง
$ MYVAR2= go run . # ตั้งเป็นสตริงว่าง
[B required only] value="" err=<nil>
,required เช็คแค่ “มี env ตัวนี้อยู่ไหม”
(ซอร์ส env.go:613 ดูแค่ exists ไม่ได้ดูค่า) · ใน docker-compose.yml การเขียน
POSTGRES_DSN= ทิ้งไว้แบบไม่ใส่ค่าเกิดขึ้นง่ายมาก และมันจะผ่านฉลุย
อยากกันค่าว่างจริง ต้องใช้ ,notEmpty และต้องไม่มี envDefault
③ แปลง type ไม่ได้ — struct ไม่ได้ว่างทั้งก้อน
$ APP_PORT=abc go run t1.go
cfg=main.Config{AppPort:0, ShutdownTimeout:10000000000, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
AppPort type=int value=0
ShutdownTimeout type=time.Duration value=10s ms=10000
สังเกตให้ดี: field ที่พังได้ zero value ส่วน field อื่นถูกเติมครบตามปกติ
(ShutdownTimeout=10s, LogLevel=DEBUG) · ใครลืมเช็ค err
จะได้ AppPort=0 ไปใช้ต่อ แล้ว server จะ bind พอร์ตสุ่ม
④ time.Duration ที่ลืมใส่หน่วย
$ APP_SHUTDOWN_TIMEOUT=10 go run t1.go
cfg=main.Config{AppPort:8080, ShutdownTimeout:0, LogLevel:"DEBUG", PostgresDSN:""}
err=env: parse error on field "ShutdownTimeout" of type "time.Duration": unable to parse duration: time: missing unit in duration "10"
เขียน 10 คิดว่าเป็นวินาที — ต้องเขียน 10s · มือใหม่พลาดตรงนี้บ่อยที่สุดในบทนี้
⑤ error หลายจุดพร้อมกัน — env.AggregateError ไม่ fail fast
$ M_C=notanint go run . # struct มี M_A,required · M_B,required · M_C int
[multi errors] err=env: required environment variable "M_A" is not set; required environment variable "M_B" is not set; parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
type=env.AggregateError
inner[0] env.VarIsNotSetError: required environment variable "M_A" is not set
inner[1] env.VarIsNotSetError: required environment variable "M_B" is not set
inner[2] env.ParseError: parse error on field "C" of type "int": strconv.ParseInt: parsing "notanint": invalid syntax
ข่าวดี: มันบอกทุกปัญหาพร้อมกันคั่นด้วย "; "
ไม่ใช่หยุดที่ตัวแรก — แปลว่าแก้ env ทีเดียวจบ ไม่ต้องรัน–แก้–รันทีละตัว
เจาะทีละตัวได้ด้วย errors.As กับ env.VarIsNotSetError ·
env.EmptyVarError · env.ParseError · env.LoadFileContentError
หรือ type assert เป็น env.AggregateError แล้ววน .Errors
4 · godotenv — autoload vs Load vs Overload
วิธีที่ 1 — autoload (สั้นที่สุด)
import _ "github.com/joho/godotenv/autoload"
// จบ — ไม่ต้องเรียกอะไรอีกเลย os.Getenv ได้ค่าจาก .env ทันที
--- run 1: autoload, no shell env ---
APP_NAME="from-dotenv"
PORT="9999"
ทั้ง package autoload มีแค่ 15 บรรทัด และส่วนที่ทำงานจริงคือ:
import "github.com/joho/godotenv"
func init() {
godotenv.Load()
}
ไม่รับค่า error กลับมาเลย — ทิ้งทั้งดุ้น · จำบรรทัดนี้ไว้ เดี๋ยวจะได้เห็นราคาของมัน
// พิสูจน์ว่าค่ามาถึงก่อน 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 var init ซึ่งเกิดก่อนทุกอย่างของ main
วิธีที่ 2 — เรียก godotenv.Load() เอง
func main() {
if err := godotenv.Load(); err != nil {
slog.Debug("no .env file", "err", err)
}
cfg, err := env.ParseAs[Config]()
// ...
}
=== with .env ===
after Load, raw os.Getenv PORT="8081"
ParseAs err=<nil> cfg={AppName:svc-from-dotenv Port:8081}
Load vs Overload
godotenv.Load() | godotenv.Overload() | |
|---|---|---|
| env ที่มีอยู่แล้ว | ชนะ — ไม่ถูกทับ | แพ้ — ถูก .env ทับ |
| env ที่ยังไม่มี | เอาค่าจาก .env มาใส่ | เอาค่าจาก .env มาใส่ |
| ตัดสินเป็นราย | key ไม่ใช่ทั้งไฟล์ — key ที่ shell ไม่มี ก็ยังได้จาก .env | |
autoload ใช้ตัวไหน | ใช้ตัวนี้ | — |
| เอาไว้ทำอะไร | ค่าจริงจาก shell/CI/k8s ต้องชนะเสมอ | อยากบังคับให้ .env ชนะจริง ๆ (ระวังมาก) |
$ APP_NAME=from-shell go run . load
before: APP_NAME="from-shell"
err=<nil>
after(load): APP_NAME="from-shell" ← shell ชนะ
$ APP_NAME=from-shell go run . overload
before: APP_NAME="from-shell"
err=<nil>
after(overload): APP_NAME="from-dotenv" ← .env ชนะ
ซอร์สเขียนไว้ตรง ๆ — godotenv.go:50 ว่า
“It's important to note that it WILL NOT OVERRIDE an env variable that already exists”
ส่วน godotenv.go:73 ว่า “this WILL OVERRIDE an env variable that already exists”
ไม่มีไฟล์ .env เลย
$ # วิธีที่ 2 — จับ 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
อยากแยก “ไม่มีไฟล์” (ปกติบน prod) ออกจาก “ไฟล์พัง” (ต้องดัง) ใช้
errors.Is(err, os.ErrNotExist) ได้เลย ดีกว่า slog.Debug เฉย ๆ
ใครทำอะไร — godotenv vs caarlos0/env
| เรื่อง | godotenv | caarlos0/env |
|---|---|---|
| หน้าที่ในหนึ่งประโยค | ยกค่าจากไฟล์ .env เข้าไปเป็น environment variable | อ่านenvironment variable ใส่ struct |
| ทำงานตอนไหน | autoload = init() ก่อน main · Load() = ตอนที่เราเรียกเอง | ตอนเรียก env.ParseAs() เท่านั้น |
| แปลง type ให้ไหม | ไม่ — ทุกอย่างเป็น string | ให้ — int / Duration / slice / map / URL / custom |
| ค่า default | ไม่มีแนวคิดนี้ | envDefault |
| บังคับว่าต้องมีค่า | ไม่มี | ,required · ,notEmpty |
| บน production | ไม่ได้ใช้ — prod inject env ตรง ๆ | ใช้ทั้ง dev และ prod |
| ถ้าเอาออกจากโปรเจกต์ | dev ต้อง export เองทุกตัว แต่แอปยังทำงาน | ต้องเขียน os.Getenv + strconv เองทุก field |
วิธีที่ 2 วาง godotenv.Load() ไว้ใน main()
ซึ่งไม่ใช่ init() · สองวิธีนี้ต่างกันจริงที่จุดนี้ อย่าจำรวมกัน
ส่วนครึ่งหลังจริงเสมอ: env.ParseAs() อ่าน os env ณ วินาทีที่ถูกเรียก
— อะไรที่ยัดเข้า env หลังจากนั้นมันไม่รู้เรื่อง
5 · .env — รูปแบบไฟล์ที่รองรับ + สิ่งที่ต้องใส่ .gitignore
| เขียนใน .env | ค่าที่ได้จริง | หมายเหตุ |
|---|---|---|
# this is a comment | — | ทั้งบรรทัดถูกข้าม ไม่กลายเป็น key |
PLAIN=hello | "hello" | รูปพื้นฐาน |
SPACED=hello world | "hello world" | ช่องว่างในค่าไม่ต้อง quote ก็ได้ |
SPACES_AROUND = padded | "padded" | trim ช่องว่างรอบ = ให้ |
DQ="double quoted value" | "double quoted value" | quote คู่ |
SQ='single quoted value' | "single quoted value" | quote เดี่ยว |
export EXPORTED2=yes-with-export | "yes-with-export" | มี export นำหน้าก็อ่านได้ |
MULTILINE="line1⏎line2⏎line3" | ขึ้นบรรทัดใหม่จริง | hex ที่ได้: … 31 0a 6c … — 0a คือ newline จริง |
TRAILING=value # trailing comment? | "value" | comment ท้ายบรรทัดถูกตัดออก |
DOLLAR="literal $PLAIN" | "literal hello" | quote คู่ ขยาย $VAR ให้ |
SQ_DOLLAR='literal $PLAIN' | "literal $PLAIN" | quote เดี่ยว ไม่ขยาย |
ESCAPED="tab\there" | "tabthere" | \t ไม่กลายเป็น tab — ดูกล่องข้างล่าง |
① \t ถูกกินหาย — hex ที่ได้จริงคือ
74 61 62 74 68 65 72 65 = t a b t h e r e · ตัวที่สี่คือ
0x74 (ตัวอักษร t) ไม่ใช่ 0x09 (tab) ·
parser รู้จักแค่ \n กับ \r ที่เหลือ backslash ถูกตัดทิ้งเฉย ๆ
(parser.go:192-205)
② รหัสผ่านที่มี $ ใน quote คู่จะถูกแทนค่า —
แต่เฉพาะชื่อตัวพิมพ์ใหญ่ · regex ของ parser คือ
(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}? (parser.go:253)
จึงจับได้แค่ [A-Z0-9_]+ · secret ที่มี $
ต้องใส่ quote เดี่ยว
# d2.env
BCD=SURPRISE
P1="a$BCD" → "aSURPRISE" ถูกแทนค่า
P3="p@ss$W0RD" → "p@ss" $W0RD ไม่มีค่า จึงถูกแทนด้วยค่าว่าง
P4="a${BCD}x" → "aSURPRISEx"
# dollar.env — ชื่อพิมพ์เล็ก ไม่โดนอะไรเลย
bcd=SURPRISE
PASS="a$bcd" → "a$bcd" รอด เพราะ bcd เป็นตัวพิมพ์เล็ก
③ # ที่มีช่องว่างนำหน้าเท่านั้นที่เป็น comment —
TOKEN=ab #cd ได้ "ab" แต่ TOKEN=ab#cd (ติดกัน)
ได้ "ab#cd" เต็ม ๆ · token แบบ ghp_abc#123def จึงรอด ·
ใส่ quote ครอบไว้ก็หมดเรื่อง
ไฟล์ .env พัง — เกิดอะไรขึ้น
# 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"
Load 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 — quote ไม่ปิด
GOOD=1
UNCLOSED="oops
AFTER=2
err = unterminated quoted value "oops
Load err = unterminated quoted value "oops
GOOD after Load = ""
AFTER after Load = ""
GOOD=1 อยู่บรรทัดก่อนบรรทัดที่พัง
แต่ก็ไม่ถูก set เลย — ทั้งไฟล์ถูกทิ้งหมด ·
คนส่วนใหญ่เดาว่ามันจะอ่านได้ถึงบรรทัดที่พังแล้วค่อยหยุด ซึ่งไม่ใช่
=== bad2.env === # มีบรรทัด 123INVALID=x
err = <nil>
123INVALID => "x"
=== bad4.env === # มีบรรทัด =novalue (ไม่มีชื่อ key)
err = <nil>
=> "novalue"
GOOD after Load = "1"
godotenv ไม่ validate ชื่อ key เลย —
ชื่อขึ้นต้นด้วยตัวเลข ผ่าน · key ว่างเปล่า ผ่าน · err=<nil> ทั้งคู่ ·
พังเงียบกว่า syntax error จริง ๆ อีก เพราะไม่มีสัญญาณอะไรเลย
ราคาของ autoload ที่ต้องรู้
$ # .env พัง (บรรทัดไม่มี =) + import _ autoload อย่างเดียว
main package init() runs; GOOD =
main(): GOOD="" AFTER=""
no error was ever surfaced
exit code: 0
ไม่ panic · ไม่มี log · exit 0 · เพราะ
init() ของ autoload ทิ้ง error ทั้งดุ้น (ดูหัวข้อ 4)
นี่คือข้อแลกเปลี่ยนจริงของ “วิธีที่ 1 (แนะนำ)” — สั้นกว่า แต่ไม่มีทางรู้เลยว่าไฟล์พัง ·
วิธีที่ 2 อย่างน้อยยังจับ err ได้
.env ถูกหาจาก current working directory ไม่ใช่ที่ตั้งของ binary
=== 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 ตัวเดียวกันเป๊ะ ผลต่างกันเพราะ cwd ต่างกัน · ยืนยันจากซอร์ส
godotenv.go:179 ที่ return []string{".env"} ซึ่งเป็น path สัมพัทธ์ ·
จุดนี้กัดคนตอนขึ้น Docker / systemd บ่อยมาก
.env เป็นแค่ไฟล์ text ธรรมดา —
Go, ระบบปฏิบัติการ และ Docker ไม่รู้จักมันโดยกำเนิด ·
ที่มันทำงานได้เพราะมีไลบรารีตัวหนึ่ง open(".env") ให้เท่านั้น ·
หลักฐานคือผลข้างบน: ถ้าระบบรู้จักมันจริง binary ตัวเดิมย่อมหาไฟล์เจอไม่ว่ารันจากไหน
สิ่งที่ต้องใส่ .gitignore
# .gitignore
.env
.env.*
!.env.example
| ไฟล์ | commit ได้ไหม | ข้างในมีอะไร |
|---|---|---|
.env | ห้าม | ค่าจริงของเครื่องเรา — DSN จริง รหัสจริง client_secret จริง |
.env.local · .env.production | ห้าม | เหมือนกัน แค่คนละสภาพแวดล้อม |
.env.example | ต้อง commit | รายชื่อ key ครบทุกตัว แต่ค่าเป็นของปลอม/ว่าง |
# .env.example — ก็อปเป็น .env แล้วเติมค่าจริงเอง
APP_NAME=wongnok
APP_ENV=development
APP_PORT=8080
APP_SHUTDOWN_TIMEOUT=10s
LOG_LEVEL=info
LOG_FORMAT=json
POSTGRES_DSN=postgres://user:pass@localhost:5432/wongnok?sslmode=disable
ทำไมไม่ commit .env ถึงปลอดภัยพอ —
สองผลรันข้างบนหนุนกันพอดี: ① .env ไม่ถูกอ่านถ้า cwd ไม่ตรง และ
② Load() ให้ env จริงชนะ .env เสมอ ·
แปลว่า production ที่ inject env เข้ามาตรง ๆ จะไม่มีทางโดน .env แย่งอยู่แล้ว
ไฟล์นี้จึงเป็นของ local dev ล้วน ๆ
6 · validator/v10 — tag · error จริง · errors.Join · Humanize
import "github.com/go-playground/validator/v10"
// ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())
err := validate.Struct(cfg)
tag ที่ใช้บ่อย
| tag | แปลว่า | ตัวอย่าง | ที่ต้องระวัง |
|---|---|---|---|
required | ห้ามเป็น zero value ของ type นั้น | validate:"required" | 0 · "" · 0s · false ตกหมด |
min / max | ความยาว string / จำนวนสมาชิก slice | validate:"required,min=3" | comma = AND ต้องผ่านทุกข้อ |
gte / lte | ช่วงตัวเลข (inclusive ทั้งคู่) | validate:"gte=1,lte=65535" | ใช้กับ time.Duration ได้ แต่ดูกล่องเตือน |
gt / lt | ช่วงตัวเลขแบบไม่รวมขอบ | validate:"required,gt=0" | ตัวเดียวที่กันค่าติดลบได้ |
oneof | ต้องเป็นค่าใดค่าหนึ่งในรายการ | validate:"oneof=dev staging prod" | คั่นด้วยเว้นวรรค ห้ามใช้ comma |
oneofci | เหมือน oneof แต่ไม่สนตัวพิมพ์ | validate:"oneofci=debug info warn error" | DEV · PrOd ผ่านหมด |
hostname|ip | | = OR — เป็นอย่างใดอย่างหนึ่งก็ผ่าน | validate:"hostname|ip" | underscore และ host:port ตก |
ข้อความ error จริง — คืนครบทุก field พร้อมกัน
=== err.Error() ===
Key: 'App.Name' Error:Field validation for 'Name' failed on the 'required' tag
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
Key: 'App.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
Key: 'App.Timeout' Error:Field validation for 'Timeout' failed on the 'gte' tag
=== count ===
len: 5
รูปแบบเป๊ะทุกช่องว่าง — มีช่องว่างหลัง Key: แต่ไม่มีหลัง Error:
· ห้าบรรทัดนี้คั่นด้วย \n ที่ ValidationErrors.Error() ใส่ให้เอง
ไม่ได้มาจาก errors.Join
oneof กับ hostname|ip — ผลจริงทีละค่า
---- oneofci=Dev Staging PROD ----
oneofci "dev" -> <nil>
oneofci "DEV" -> <nil>
oneofci "prod" -> <nil>
oneofci "PrOd" -> <nil>
oneofci "qa" -> Key: 'C.Env' Error:Field validation for 'Env' failed on the 'oneofci' tag
hostname|ip "localhost" -> <nil>
hostname|ip "api.example.com" -> <nil>
hostname|ip "127.0.0.1" -> <nil>
hostname|ip "::1" -> <nil>
hostname|ip "192.168.1.300" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "not a host!" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "db_server" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname|ip "example.com:5432" -> Key: 'H.Host' Error:Field validation for 'Host' failed on the 'hostname|ip' tag
hostname ปฏิเสธ underscore และปฏิเสธ host:port
db_server ตก และ example.com:5432 ก็ตก ·
ถ้าใน docker-compose.yml ตั้งชื่อ service ว่า db_server
หรือใน .env เก็บ host พร้อม port ไว้ในตัวแปรเดียว จะ validate ไม่ผ่านทันที
ชื่อ tag ใน error พิมพ์เป็น 'hostname|ip' ทั้งก้อน ไม่ได้แยกว่าตกข้อไหน
oneof = panic ตอน request แรก
// Env string `validate:"oneof=dev,staging,prod"` ← ผิด
before Struct()
panic: Undefined validation function 'staging' on field 'Env'
goroutine 1 [running]:
github.com/go-playground/validator/v10.(*Validate).parseFieldTagsRecursive(...)
.../validator/v10@v10.30.3/cache.go:311 +0x9e4
github.com/go-playground/validator/v10.(*Validate).extractStructCache(...)
.../cache.go:156 +0x454
github.com/go-playground/validator/v10.(*Validate).Struct(...)
.../validator_instance.go:347
exit status 2
สองอย่างที่ต้องจำ:
① ไม่ได้ panic ตอน validator.New() — panic ตอนเรียก
validate.Struct() ครั้งแรก (ตอนสร้าง struct cache) ·
โปรแกรม go build ผ่าน start ขึ้นได้ แล้วไปตายตอน request แรก
② ข้อความไม่มีคำว่า oneof เลย — เพราะ comma ถูกอ่านเป็นตัวคั่น tag
ทำให้ staging กลายเป็นชื่อ validator ที่ไม่มีอยู่ · หาไม่เจอว่าเกิดจากอะไรถ้าไม่รู้ล่วงหน้า
gte=1 เปล่า ๆ บน time.Duration = 1 นาโนวินาที
--- gte=1 (ไม่มีหน่วย) ---
gte=1 with 1ns -> <nil>
gte=1 with 0 -> Key: 'A.T' Error:Field validation for 'T' failed on the 'gte' tag
gte=1 with 500ms-> <nil> ← ผ่านฉลุย
--- gte=1s,lte=30s (มีหน่วย) ---
0s -> Key: 'B.T' Error:Field validation for 'T' failed on the 'gte' tag
1s -> <nil>
15s -> <nil>
31s -> Key: 'B.T' Error:Field validation for 'T' failed on the 'lte' tag
จะคุมเป็นวินาที ต้องเขียนหน่วยเสมอ —
gte=1s ไม่ใช่ gte=1 · ทั้ง gte และ lte
เป็น inclusive (1s พอดีผ่าน · 31s ตกเพราะเกิน 30s)
WithRequiredStructEnabled() ต่างยังไง
--- nested struct (ไม่ใช่ pointer) ---
plain / zero nested struct -> <nil>
opt / zero nested struct -> Key: 'Cfg.DB' Error:Field validation for 'DB' failed on the 'required' tag
plain / filled nested -> <nil>
opt / filled nested -> <nil>
--- pointer nested, nil ---
plain -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag
opt -> Key: 'Cfg2.DB' Error:Field validation for 'DB' failed on the 'required' tag
ต่างกันจุดเดียวเป๊ะ: required บน nested struct
ที่ไม่ใช่ pointer · ไม่ใส่ option = tag นั้นถูกเมิน · ใส่ option = zero struct ตก ·
ส่วน *DB (pointer) ไม่เกี่ยว ตกทั้งสองแบบอยู่แล้ว ·
doc comment ในซอร์สเขียนว่า “WithRequiredStructEnabled enables required tag on non-pointer
structs to be applied instead of ignored.”
แกะ error ทีละ field
var ve validator.ValidationErrors
if errors.As(err, &ve) {
for _, fe := range ve {
fmt.Println(fe.Namespace(), fe.Field(), fe.Tag(), fe.Param(), fe.Value())
}
}
errors.As -> true len: 4
Namespace="Cfg.Name" Field="Name" Tag="required" Param="" Kind=string Type=string Value=""
Namespace="Cfg.Env" Field="Env" Tag="oneof" Param="dev prod" Kind=string Type=string Value="qa"
Namespace="Cfg.Timeout" Field="Timeout" Tag="gte" Param="1s" Kind=int64 Type=time.Duration Value=100000000
Namespace="Cfg.DB.Port" Field="Port" Tag="lte" Param="65535" Kind=int Type=int Value=99999
| เมธอด | คืนอะไร | เอาไปทำอะไร |
|---|---|---|
Namespace() | Cfg.DB.Port — ทางเดินเต็มรวม struct ซ้อน | บอกผู้ใช้ว่าผิดที่ field ไหน |
Field() | Port — ชื่อ field เปล่า ๆ | ข้อความสั้น ๆ |
Tag() | lte — กฎที่ตก | map ไปเป็นข้อความภาษาไทย |
Param() | 65535 · dev prod | บอกว่าค่าที่ยอมรับคืออะไร |
Value() | ค่าที่ส่งมาจริง | โชว์ให้เห็นว่าพิมพ์อะไรผิด |
สองอย่างที่เห็นจากผลรัน: ① Value() ของ time.Duration
คืน any ที่มี dynamic type เป็น time.Duration —
พิมพ์ด้วย %v / %s / fmt.Sprint ได้ 100ms
อ่านรู้เรื่องอยู่แล้ว · เลข 100000000 (int64 นาโนวินาที) โผล่ก็ต่อเมื่อใช้
%d หรือ %#v — ซึ่งเป็น verb ที่บล็อกข้างบนใช้อยู่พอดี ·
② Namespace ของ nested field เป็น Cfg.DB.Port —
validator ไล่เข้า struct ซ้อนให้เองโดยไม่ต้องใส่ tag อะไรที่ field DB
// รันจริง — validator v10.30.3 + go1.26.5 · fe.Value() ของ field Timeout
dynamic type = time.Duration
%v -> 100ms
%s -> 100ms
Sprint -> 100ms
%#v -> 100000000
%d -> 100000000
ที่ต้องระวังจริง ๆ คือตอน type assert —
fe.Value().(int64) panic (dynamic type เป็น time.Duration
ไม่ใช่ int64) · เขียน fe.Value().(time.Duration) แล้วพิมพ์ตรง ๆ ได้เลย
errors.Join — คั่นด้วยอะไรกันแน่
Error() = "alpha\nbeta\ngamma"
contains \n: 16
errors.Is(j,b) -> true
Unwrap() []error len: 3
errors.Join with nils: true true
| พฤติกรรม | ผลจริง |
|---|---|
| ตัวคั่นระหว่าง error | \n เปล่า ๆ ไม่มีช่องว่าง |
มี \n ปิดท้ายไหม | ไม่มี — "alpha\nbeta\ngamma" ยาว 16 = 5+1+4+1+5 |
errors.Is ทะลุถึงตัวใน | ได้ |
Unwrap() []error | มี — วนดูทีละตัวได้ |
errors.Join(nil, a, nil) | คืน error (ข้าม nil ให้) |
errors.Join(nil, nil) | คืน nil |
// ยัด ValidationErrors เข้า Join แล้วยังดึงกลับมาได้
errors.As through Join -> true 4
jv.Error() = "config load failed\nKey: 'Cfg.Name' Error:Field validation for 'Name' failed on the 'required' tag\nKey: 'Cfg.Env' Error:...
ValidationErrors.Error() คั่นบรรทัดด้วย \n
ให้อยู่แล้วตั้งแต่ต้น · errors.Join ใส่ \n ให้แค่ระหว่าง error
คนละก้อน เท่านั้น (ในตัวอย่างข้างบนคือระหว่าง "config load failed"
กับก้อนของ validator)
Humanize — แปลง error ก้อนใหญ่ให้คนอ่านรู้เรื่อง
// internal/config/humanize.go
func Humanize(err error) string {
var joined interface{ Unwrap() []error }
errs := []error{err}
if errors.As(err, &joined) {
errs = joined.Unwrap()
}
lines := []string{"invalid configuration:"}
for _, e := range errs {
var ve validator.ValidationErrors
if errors.As(e, &ve) {
for _, fe := range ve {
lines = append(lines, fmt.Sprintf(" - %s: ผิดกฎ %q", fe.Namespace(), fe.Tag()))
}
continue
}
lines = append(lines, " - "+e.Error())
}
return strings.Join(lines, "\n")
}
$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= go run ./cmd/api
FATAL load configuration:
invalid configuration:
- App.Port: ผิดกฎ "lte"
- Logging.Level: ผิดกฎ "oneofci"
- Database.PostgresDSN: ผิดกฎ "required"
exit status 1
ลำดับบรรทัดคาดเดาได้เสมอ — errors.Join เรียงตามลำดับที่เราใส่
(App → Logging → Database) และในแต่ละ struct เรียงตามลำดับ field
① exit status 1 ไม่ใช่ข้อความของโปรแกรม —
go run พิมพ์เองเมื่อ process ลูกจบด้วยโค้ดที่ไม่ใช่ 0 ·
build เป็น binary แล้วรันตรง ๆ จะไม่มีบรรทัดนี้
② Humanize มีรอยรั่ว —
ตอน ParseAs พังจริง (ไม่ใช่ validation) พาดหัวยังขึ้น
invalid configuration: และ prefix parse environment: env: หายไป
เพราะ errors.As(err, &joined) วิ่งทะลุ fmt.Errorf ไปเจอ
env.AggregateError ที่บังเอิญมี Unwrap() []error เหมือนกัน:
$ env APP_PORT=abc ./api
time=2026-08-22T10:15:12.112+07:00 level=ERROR msg="load configuration"
invalid configuration:
- parse error on field "Port" of type "int": strconv.ParseInt: parsing "abc": invalid syntax
BenchmarkSingleton-10 8658763 120.6 ns/op 48 B/op 1 allocs/op
BenchmarkNewEachTime-10 140612 8638 ns/op 16229 B/op 224 allocs/op
// Apple M4, go1.26.5
doc ของไลบรารีเขียนไว้ว่า “Validator is designed to be thread-safe
and used as a singleton instance. It caches information about your struct and validations …
Using multiple instances neglects the benefit of caching.”
เหตุผลไม่ใช่แค่แคช struct — New() ต้องคัดลอกตาราง built-in validator
ทั้งชุดใหม่ทุกครั้งด้วย · ประกาศเป็น package-level var ครั้งเดียวจบ
7 · โครง internal/config ที่ก็อปไปใช้ได้
config.go
package config
import (
"errors"
"fmt"
"github.com/caarlos0/env/v11"
"github.com/go-playground/validator/v10"
)
// ใช้ตัวเดียวทั้ง package — validator แคชข้อมูล struct ไว้ข้างใน
var validate = validator.New(validator.WithRequiredStructEnabled())
type Config struct {
App App
Logging Logging
Database Database
}
func Load() (Config, error) {
cfg, err := env.ParseAs[Config]()
if err != nil {
return Config{}, fmt.Errorf("parse environment: %w", err)
}
// ไม่ wrap ด้วย fmt.Errorf — errors.Join จัดบรรทัดไว้แล้ว
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// รวม error ของ "ทุก section" มาแสดงทีเดียว
// เพิ่ม Redis? สร้าง redis.go + เติม 1 บรรทัดตรงนี้
func (cfg Config) Validate() error {
return errors.Join(
cfg.App.Validate(),
cfg.Logging.Validate(),
cfg.Database.Validate(),
)
}
package + import ออกให้อ่านง่าย
app.go · logging.go · database.go อยู่ใน
package config เดียวกับ config.go — ก็อปไปวางดิบ ๆ จะเจอตามลำดับนี้:
$ go build ./...
internal/config/app.go:1:1: expected 'package', found 'type'
# เติม package config แล้วยังไม่พอ
$ go build ./...
internal/config/app.go:7:18: undefined: time
internal/config/app.go:10:45: undefined: fmt
ที่ต้องเติมเองคือ: app.go → "fmt" + "time" ·
database.go → "time" · logging.go → ไม่ต้องเติมอะไร ·
เติมครบแล้ว go build ./... ผ่านและ sizeof(config.Config) = 120 bytes ตามที่โชว์ข้างล่าง
app.go
type App struct {
Name string `env:"APP_NAME" envDefault:"wongnok" validate:"required"`
Env string `env:"APP_ENV" envDefault:"development" validate:"required,oneof=development staging production"`
Port int `env:"APP_PORT" envDefault:"8080" validate:"gte=1,lte=65535"`
ShutdownTimeout time.Duration `env:"APP_SHUTDOWN_TIMEOUT" envDefault:"10s" validate:"required"`
}
func (app App) Addr() string { return fmt.Sprintf(":%d", app.Port) }
func (app App) IsProduction() bool { return app.Env == "production" }
func (app App) Validate() error { return validate.Struct(app) }
logging.go
type Logging struct {
Level string `env:"LOG_LEVEL" envDefault:"info" validate:"required,oneofci=debug info warn error"`
Format string `env:"LOG_FORMAT" envDefault:"json" validate:"required,oneofci=json text"`
}
func (logging Logging) Validate() error { return validate.Struct(logging) }
database.go
type Database struct {
PostgresDSN string `env:"POSTGRES_DSN" validate:"required"`
MaxOpenConns int `env:"DB_MAX_OPEN_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
MaxIdleConns int `env:"DB_MAX_IDLE_CONNS" envDefault:"25" validate:"gte=1,lte=500"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m" validate:"required,gt=0"`
}
func (database Database) Validate() error { return validate.Struct(database) }
ConnMaxLifetime ต้องมี gt=0 ด้วย
เวอร์ชันแรกของไฟล์นี้เขียนเป็น if ยาว ๆ:
if database.ConnMaxLifetime <= 0 {
return errors.New("DB_CONN_MAX_LIFETIME must be greater than 0")
}
พอ refactor เป็น validate:"required" เฉย ๆ ความสามารถหายไปจริง:
########## [ก่อน refactor] DB_CONN_MAX_LIFETIME=-5s
Validate : FAILED ->
DB_CONN_MAX_LIFETIME must be greater than 0
########## [หลัง refactor] DB_CONN_MAX_LIFETIME=-5s
Validate : OK ← ค่าติดลบผ่านฉลุย
required บน time.Duration แปลว่า
“ห้ามเป็น zero” เท่านั้น ไม่ใช่ “ต้องมากกว่า 0” · ต้องเขียน
required,gt=0 ถึงจะเทียบเท่าของเดิม
เรื่องเดียวกันนี้ใช้กับ App.ShutdownTimeout ด้วย —
APP_SHUTDOWN_TIMEOUT=-5s หลุดทั้ง ParseAs และ validator
config_test.go — เทสต์ config โดยไม่ต้องมี .env
func TestLoad_Defaults(t *testing.T) {
t.Setenv("POSTGRES_DSN", "postgres://u:p@localhost:5432/db")
// t.Setenv คืนค่า env เดิมให้อัตโนมัติเมื่อจบเทสต์
cfg, err := config.Load()
// ...
}
$ go test ./internal/config/ -v
=== RUN TestLoad_Defaults
--- PASS: TestLoad_Defaults (0.00s)
=== RUN TestLoad_ThreeErrorsAtOnce
--- PASS: TestLoad_ThreeErrorsAtOnce (0.00s)
=== RUN TestLoad_ReturnsZeroValueOnError
--- PASS: TestLoad_ReturnsZeroValueOnError (0.00s)
PASS
ok wongnok/api/internal/config 0.572s
ทำไม Load() คืน Config ไม่ใช่ *Config
sizeof(config.Config) = 120 bytes
sizeof(*config.Config) = 8 bytes
[value ] err != nil? true | cfgVal.App.Port = 0 | cfgVal.App.Addr() = ":0"
[ptr ] err != nil? true | cfgPtr == nil? true
[ptr ] panic ตอนอ่าน cfgPtr.App.Port: runtime error: invalid memory address or nil pointer dereference
ก่อนเรียก : Port=8080 Addr=":8080"
หลัง handlerA(value) : Port=8080 Addr=":8080"
หลัง handlerB(pointer) : Port=1 Addr=":1" <-- validate ผ่านไปแล้ว แต่ค่าเปลี่ยนได้
คืน Config (value) | คืน *Config (pointer) | |
|---|---|---|
| ตอน error | ได้ Config{} ที่ยังเรียกเมธอดได้ (Addr() = ":0") | ได้ nil — ลืมเช็ค err แล้ว panic ทันที |
| ส่งต่อให้ handler | แต่ละคนได้สำเนา แก้แล้วไม่กระทบตัวจริง | ใครก็แก้ค่าที่ validate ผ่านแล้วได้ |
| ราคาที่จ่าย | คัดลอก 120 bytes | คัดลอก 8 bytes |
Load() ถูกเรียกครั้งเดียวตอน start — 120 bytes ไม่ใช่เรื่อง ·
เหตุผลที่หนักแน่นกว่าคือแถวกลาง: ค่าที่ผ่าน validate แล้วต้อง valid ตลอดอายุโปรแกรม
8 · ท่อลำเลียงค่าทั้งเส้น
| ด่าน | จับอะไรได้ | จับอะไรไม่ได้ |
|---|---|---|
godotenv | ไฟล์ .env พัง syntax | ชื่อ key แปลก ๆ (123INVALID ผ่าน) · ค่าผิดความหมาย |
caarlos0/env | แปลง type ไม่ได้ (APP_PORT=abc) · ไม่มี env ที่ ,required | ค่าที่ type ถูกแต่ไร้สาระ — APP_PORT=99999 ผ่านสบาย |
validator | ค่านอกช่วง · ค่าไม่อยู่ในรายการ · ค่าว่าง | ค่าติดลบ ถ้าใช้แค่ required (ต้องเติม gt=0) |
พิสูจน์ว่าด่าน 2 ปล่อยผ่านจริง แล้วด่าน 3 รับช่วง
$ env POSTGRES_DSN="postgres://u:p@localhost:5432/db" APP_PORT=99999 go run ./cmd/probe
ParseAs : OK -> App.Port=99999 App.Env="development" Log.Level="info" Shutdown=10s DSN="postgres://u:p@localhost:5432/db"
Validate : FAILED ->
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag
$ env POSTGRES_DSN= go run ./cmd/probe
ParseAs : OK -> App.Port=8080 ... DSN=""
Validate : FAILED ->
Key: 'Database.PostgresDSN' Error:Field validation for 'PostgresDSN' failed on the 'required' tag
$ env POSTGRES_DSN="..." APP_ENV=prodction go run ./cmd/probe
ParseAs : OK -> App.Env="prodction" ...
Validate : FAILED ->
Key: 'App.Env' Error:Field validation for 'Env' failed on the 'oneof' tag
$ env POSTGRES_DSN="..." LOG_LEVEL=BANANA go run ./cmd/probe
ParseAs : OK -> Log.Level="BANANA" ...
Validate : FAILED ->
Key: 'Logging.Level' Error:Field validation for 'Level' failed on the 'oneofci' tag
$ env POSTGRES_DSN="..." APP_SHUTDOWN_TIMEOUT=0s go run ./cmd/probe
ParseAs : OK -> Shutdown=0s ...
Validate : FAILED ->
Key: 'App.ShutdownTimeout' Error:Field validation for 'ShutdownTimeout' failed on the 'required' tag
ห้าเคสนี้ ParseAs ผ่านหมด — พิมพ์ prodction ตกตัว u
ก็ยังเป็น string ที่ถูกต้องในสายตา env · ตัวที่จับได้คือ validator ล้วน ๆ
ตายตั้งแต่ start ไม่ใช่ตอน request แรก
$ env APP_PORT=99999 LOG_LEVEL=BANANA POSTGRES_DSN= ./api & sleep 2
--- ps เช็คว่า process ยังอยู่ไหม ---
process ตายไปแล้ว
--- lsof :8080 ---
(ไม่มี process ไหน listen :8080)
--- curl /healthz ---
http_code=000
curl exit=7
EXIT_CODE=1
พิสูจน์ครบสามชั้น: process ตาย · ไม่มีอะไร bind :8080 · curl ต่อไม่ติด
(exit 7 = Failed to connect) · exit code จริงคือ 1
$ # config ถูกต้อง — เดินจนสุดสาย
--- curl /healthz ---
ok
http_code=200
--- stderr ของ server ---
{"time":"2026-08-22T10:13:43.048067+07:00","level":"INFO","msg":"configuration loaded","app":"wongnok","env":"development","addr":":8080"}
{"time":"2026-08-22T10:13:43.084594+07:00","level":"INFO","msg":"database connected"}
{"time":"2026-08-22T10:13:43.085393+07:00","level":"INFO","msg":"server starting","addr":":8080"}
ลำดับใน run() ที่ทำให้ได้ log แบบนี้: logger ชั่วคราว (slog TextHandler)
→ godotenv.Load() → config.Load() → logger ตัวจริงที่สร้างจาก
cfg.Logging (เลยกลายเป็น JSON ตั้งแต่บรรทัดแรก) → gorm.Open
+ SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime + Ping
→ ListenAndServe
9 · JWT — โครงสร้าง · golang-jwt/v5 · error ทุกเคส
สามส่วน คั่นด้วยจุด
| ส่วน | decode ได้โดยไม่มี key ไหม | เนื้อจริงของ token ตัวอย่าง |
|---|---|---|
| header | ได้ | {"alg":"HS256","typ":"JWT"} |
| payload | ได้ | {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022} |
| signature | decode ได้ แต่ตรวจไม่ได้ถ้าไม่มี key | — |
$ echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | tr '_-' '/+' | base64 -d
{"alg":"HS256","typ":"JWT"}
# payload — ไม่ต้องมี secret เลย
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
# ทำซ้ำด้วย Go: jwt.NewParser().ParseUnverified (ไม่ส่ง secret เข้าไป)
err=<nil>
alg=HS256 typ=JWT
claims=map[admin:true iat:1.516239022e+09 name:John Doe sub:1234567890]
จำนวนส่วนของ token=3
base64 -d ของ macOS ตัดท้ายทิ้งถ้าความยาวไม่หารด้วย 4 ลงตัว
คำสั่งข้างบนใช้ได้กับ header เพราะมันยาว 36 ตัวพอดี (หาร 4 ลงตัว) · พอเอาคำสั่งเดิมไป decode payload ซึ่งยาว 91 ตัว จะขาดท้าย:
$ echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' | tr '_-' '/+' | base64 -d
{"sub":"1234567890","name":"John Doe","admin":true,"iat":151623902 ← ขาด 2 ตัวท้าย ไม่มี error ด้วย
ใช้คำสั่งนี้แทน — เติม padding ให้เอง แล้วได้ครบทุกครั้ง:
$ python3 -c "import base64,sys;print(base64.urlsafe_b64decode(sys.argv[1]+'='*(-len(sys.argv[1])%4)).decode())" <segment>
{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}
err=<nil> ทั้งที่ไม่ได้ส่ง secret เข้าไปสักตัว
และเห็น admin:true ชัด ๆ · signature กันการแก้ไข ไม่ได้กันการอ่าน
(iat 1516239022 = 2018-01-18 01:30:22 UTC)
your-256-bit-secret reGQzG3OKdoIMWLDKOZ4TICJit3EW69cQE72E2CfzRE no
secret 39jkN-bckg4fbZQEb0xHIxzYL9qI_g4c4WyzEYNHZok no
a-string-secret-at-least-256-bits-long KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 MATCH
target: KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
# ยืนยันซ้ำด้วย jwt/v5
secret="your-256-bit-secret" -> FAIL: token signature is invalid: signature is invalid
secret="a-string-secret-at-least-256-bits-long" -> OK (signature ผ่าน)
secret จริงคือ a-string-secret-at-least-256-bits-long
ไม่ใช่ your-256-bit-secret (ของเก่า) · ลองตามด้วยตัวเก่าจะขึ้น
signature is invalid แล้วงงว่าตัวเองพิมพ์ผิดตรงไหน
โครงที่ก็อปไปใช้ได้
import (
"time" // ขาดไม่ได้ — โค้ดข้างล่างใช้ time.Now()
"github.com/golang-jwt/jwt/v5" // v5.3.1
)
secret := []byte("a-string-secret-at-least-256-bits-long")
// ---------- สร้าง ----------
claims := jwt.MapClaims{
"sub": "42",
"name": "somchai",
"role": "admin",
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(), // ต้องใส่เอง! ดูกล่องเตือน
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
// ---------- ตรวจ ----------
tok, err := jwt.Parse(token,
func(t *jwt.Token) (any, error) { return secret, nil },
// algorithm confusion = attacker สลับ alg ใน header ให้เราหยิบ "คีย์ผิดประเภท" ไปตรวจ (ดูท้ายหัวข้อ)
jwt.WithValidMethods([]string{"HS256"}), // กัน algorithm confusion
jwt.WithExpirationRequired(), // บังคับว่าต้องมี exp
)
=== 3) สร้าง JWT เอง (HS256) ===
token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODczNzE5MjUsImlhdCI6MTc4NzM2ODMyNSwibmFtZSI6InNvbWNoYWkiLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.MTGaDfheYUXIvMe-j3oO8iTOvdgRPLaizc0cHV_ybEQ
len = 185 byte
--- 3b) parse กลับ + verify ---
OK valid=true claims=map[exp:1.787371925e+09 iat:1.787368325e+09 name:somchai role:admin sub:42]
MapClaims decode ตัวเลขเป็น float64 เสมอ
เห็น exp:1.787371925e+09 ไหม — นั่นคือ float64
ไม่ใช่ int · เขียน claims["sub"].(int) หรือ .(int64)
จะ panic · ต้อง .(float64) แล้วค่อยแปลง หรือใช้ custom claims struct
error จริงของทุกเคส
| เคส | err ที่ได้จริง |
|---|---|
token หมดอายุ (exp ผ่านไปแล้ว) | token has invalid claims: token is expired |
| signature ผิด (แก้ตัวอักษรเดียว) | token signature is invalid: signature is invalid |
| alg ไม่ตรง (token=HS256 ยอมรับแค่ RS256) | token signature is invalid: signing method HS256 is invalid |
alg=none + WithValidMethods([HS256]) | token signature is invalid: signing method none is invalid |
alg=none + ไม่มี WithValidMethods | token signature is invalid: token is unverifiable: 'none' signature type is not allowed |
ไม่มี exp + WithExpirationRequired() | token has invalid claims: token is missing required claim: exp claim is required |
ไม่มี exp เฉย ๆ (default) | <nil> — ผ่าน! ดูกล่องเตือน |
nbf อยู่ในอนาคต | token has invalid claims: token is not valid yet |
aud ไม่ตรง | token has invalid claims: token has invalid audience |
WithIssuer() แต่ token ไม่มี iss | token has invalid claims: token is missing required claim: iss claim is required |
| token มี 2 ส่วน / เป็นข้อความมั่ว / ว่างเปล่า | token is malformed: token contains an invalid number of segments (เหมือนกันทั้งสามเคส) |
algorithm confusion RS256→HS256 (ไม่มี WithValidMethods) | token signature is invalid: key is of invalid type: HMAC verify expects []byte |
exp = token ตลอดชีพ
token ไม่มี exp เลย (default) err = <nil>
token ไม่มี exp + WithExpirationRequired() err = token has invalid claims: token is missing required claim: exp claim is required
คำพูดที่ว่า “JWT ดีกว่า Basic Auth เพราะมีวันหมดอายุ” จริงก็ต่อเมื่อ
เราใส่ exp เองตอนเซ็น · golang-jwt/v5 ไม่ได้ใส่ให้ และไม่บ่นถ้าไม่มี ·
ฝั่งตรวจควรใส่ jwt.WithExpirationRequired() เสมอ
WithLeeway เป็นดาบสองคม —
default leeway = 0 · ใส่ jwt.WithLeeway(60*time.Second) แล้ว token ที่หมดอายุไป 20 วินาที
จะผ่าน (err=<nil>) · มีไว้เผื่อนาฬิกาสองเครื่องคลาดกัน
แต่ก็แปลว่าเราตั้งใจยอมรับ token ที่หมดอายุแล้ว
ErrSignatureInvalid กับ
ErrTokenSignatureInvalid ไม่เหมือนกัน —
เคส signature ผิด: errors.Is เป็น true ทั้งคู่
เคส alg ไม่ตรง: ErrSignatureInvalid = false แต่
ErrTokenSignatureInvalid = true
จะเช็คด้วย errors.Is ให้ใช้ jwt.ErrTokenSignatureInvalid ตัวที่ครอบคลุมกว่า
alg=none ทะลุได้จริงไหม
evil token = eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4iLCJzdWIiOiI0MiJ9.
A) มี WithValidMethods([HS256]) -> err = token signature is invalid: signing method none is invalid
B) ไม่มี WithValidMethods -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed
C) keyfunc คืน UnsafeAllowNoneSignatureType -> err = <nil> ← ทะลุ!
D) ยกระดับ user เป็น admin ผ่าน alg=none -> err = token signature is invalid: token is unverifiable: 'none' signature type is not allowed valid = false
golang-jwt/v5 บล็อก alg=none
ให้ตั้งแต่ค่าเริ่มต้นแม้ไม่ใส่ WithValidMethods (เคส B) ·
ทางเดียวที่ทะลุคือ dev เขียน keyfunc คืน jwt.UnsafeAllowNoneSignatureType เอง (เคส C)
ดังนั้นเหตุผลจริงที่ต้องใส่ WithValidMethods คือ
กัน algorithm confusion + ให้ error ชัด ไม่ใช่ “กัน alg=none”
// algorithm confusion: attacker เอา RSA public key (ที่เปิดเผยอยู่แล้ว) มาเซ็นแบบ HS256
attacker เซ็น HS256 ด้วย public key: err=<nil>
A) ไม่มี WithValidMethods -> err = token signature is invalid: key is of invalid type: HMAC verify expects []byte
B) มี WithValidMethods([RS256]) -> err = token signature is invalid: signing method HS256 is invalid
เคส A รอดเพราะ type ไม่ match โดยบังเอิญ ไม่ใช่เพราะมันเช็ค alg —
ถ้า keyfunc คืน []byte ตอนไหนก็ทะลุ · เคส B กันแบบตั้งใจ ·
ใส่ WithValidMethods เสมอ
Basic Auth vs JWT — ทำไมถึงย้าย
| Basic Auth | JWT | |
|---|---|---|
| header ที่ส่ง | Authorization: Basic c29tY2hhaTpwQHNzdzByZA== | Authorization: Bearer eyJhbGci… |
| ข้างในมีอะไร | somchai:p@ssw0rd — รหัสผ่านจริง | {"sub":…,"exp":…,"role":…} — ไม่มีรหัสผ่าน |
| ส่งบ่อยแค่ไหน | ทุก request (header เดิมเป๊ะ) | ทุก request เหมือนกัน |
| decode ได้โดยไม่มี key | ได้ (base64 เฉย ๆ) | ได้ (base64url เฉย ๆ) |
| มีวันหมดอายุ | ไม่มีที่ให้ใส่ | มี — ถ้าเราใส่ exp เอง |
| มี role / claims | ไม่มี | มี |
| ต้องถาม DB ทุก request | ต้อง | ไม่ต้อง (signature บอกว่าเชื่อได้) |
$ # ยิง 3 ครั้ง — header ตัวเดิมเป๊ะทุกครั้ง
[server] GET /foods Authorization: "Basic c29tY2hhaTpwQHNzdzByZA=="
[server] r.BasicAuth() -> user="somchai" pass="p@ssw0rd" ok=true
$ # decode กลับได้ทันทีโดยไม่ต้องมี key
somchai:p@ssw0rd
r.BasicAuth() คืน ok=true ทั้งที่รหัสผิด
$ # ยิงด้วยรหัสผิด
[server] GET /foods Authorization: "Basic c29tY2hhaTp3cm9uZw=="
[server] r.BasicAuth() -> user="somchai" pass="wrong" ok=true
ok แปลว่า “แกะ header สำเร็จ”
ไม่ใช่ “รหัสถูก” · เขียน if ok { ให้ผ่าน } คือช่องโหว่เต็ม ๆ
ต้องเทียบ user/pass เองเสมอ (และควรใช้ subtle.ConstantTimeCompare)
ราคาที่จ่าย — JWT ใหญ่กว่า session id มาก
| แบบ | byte | เทียบ session hex |
|---|---|---|
JWT minimal (sub+exp) | 120 | 3.8 เท่า |
| JWT typical (6 claims) | 225 | 7.0 เท่า |
| JWT fat (roles + permissions + ชื่อไทย) | 553 | 17.3 เท่า |
| session id 128-bit hex | 32 | — |
| session id 256-bit base64url | 43 | 1.3 เท่า |
header ที่ส่งจริง Authorization: Bearer <typical> = 247 byte
header ที่ส่งจริง Cookie: sid=<hex> = 44 byte
ถ้ามี 1000 request: fat JWT กินแบนด์วิดท์ขาขึ้นเพิ่ม 521000 byte เทียบ session
fat JWT บวมเพราะ claims เยอะ แล้ว ชื่อไทยยิ่งแพงกว่าชื่ออังกฤษ ~4 เท่าต่อตัวอักษร —
ภาษาไทยเป็น UTF-8 ตัวละ 3 byte แล้ว base64 ขยายอีก ×4/3
กลายเป็นตัวละ 4 byte ใน token · อย่ายัด permission ทั้งชุดลง token
encoding/json ของ Go ไม่ escape ภาษาไทยเป็น
\uXXXX — ต่างจาก json.dumps ของ Python ที่ ensure_ascii=True
เป็นค่าเริ่มต้น · ตรวจแล้วกับ golang-jwt/jwt/v5 v5.3.1:
json.Marshal -> {"name":"สมชาย ใจดี"} len: 39 byte
มี \u escape ไหม: false
payload ที่ decode กลับจาก token: {"exp":…,"name":"สมชาย ใจดี","permissions":[…],…}
"สมชาย ใจดี" = 10 rune · 28 byte (UTF-8) · base64url แล้ว 38 byte
Swagger — บอก Swagger UI ว่ามีช่องใส่ token
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description ใส่ค่าเป็น "Bearer {token}"
=== securityDefinitions ที่ได้จริง (swag v1.16.4) ===
{
"BearerAuth": {
"description": "ใส่ค่าเป็น \"Bearer {token}\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
--- security ระดับ endpoint ---
[ { "BearerAuth": [] } ]
--- swagger version --- 2.0
swag v1 ออก Swagger 2.0 ซึ่งไม่มี
type: http / scheme: bearer — จึงต้องใช้ apiKey
และผู้ใช้ต้องพิมพ์คำว่า Bearer นำหน้าเองในช่อง Authorize ·
รายละเอียด annotation ครบชุดอยู่ที่ บทเรียน 41
10 · OAuth 2.0 — ตัวละคร 4 ตัว + Authorization Code Grant
OAuth 2.0 คือ framework สำหรับ มอบสิทธิ์แบบจำกัดขอบเขต (delegated authorization) — ให้แอปหนึ่งทำอะไรบางอย่างแทนเราได้ โดยไม่ต้องบอกรหัสผ่านของเราให้มันรู้
| ตัวละคร | ศัพท์ทางการ | ในภาพของ Wongnok คือใคร | ถืออะไรไว้ |
|---|---|---|---|
| ผู้ใช้ | Resource Owner | คนที่นั่งอยู่หน้าจอ | รหัสผ่านของตัวเอง |
| แอปของเรา | Client | wongnok API (Go/Gin) | client_id + client_secret |
| คนออก token | Authorization Server | Keycloak | ฐานผู้ใช้ · หน้า login · private key ที่ใช้เซ็น |
| คนถือของ | Resource Server | API ที่มีข้อมูลจริง (บางทีก็คือ wongnok API เอง) | ข้อมูล + public key ไว้ตรวจ token |
Authorization Code Grant — 8 ขั้น
| ขั้น | เกิดอะไร | ทำไมต้องมีขั้นนี้ |
|---|---|---|
| 1 | ผู้ใช้กดปุ่ม Login ในแอปเรา | จุดเริ่ม — แอปยังไม่รู้ว่าเขาเป็นใคร |
| 2 | แอปตอบ redirect ไปหน้า login ของ Auth Server | หน้ากรอกรหัสต้องเป็นของ Auth Server เท่านั้น |
| 3 | ผู้ใช้กรอก username/password ที่ Auth Server | รหัสผ่านไม่เคยผ่านแอปของเราเลย — หัวใจของ OAuth |
| 4 | Auth Server redirect กลับ พร้อม authorization code | code เป็นแค่ “คูปอง” ใช้ครั้งเดียว อายุสั้น ไม่ใช่ token |
| 5 | แอปส่ง code + client_secret ไปแลก token | ทำที่ backend — เบราว์เซอร์ไม่มีทางเห็น client_secret |
| 6 | Auth Server คืน access_token (+ refresh_token) | ของจริงมาถึงมือแอป โดย code ที่เคยโผล่ใน URL ใช้ไม่ได้อีกแล้ว |
| 7 | แอปเรียก API พร้อม Authorization: Bearer … | ใช้ token แทนรหัสผ่าน |
| 8 | Resource Server ตรวจ token แล้วตอบข้อมูล | ตรวจด้วย public key ได้เลย ไม่ต้องถาม Auth Server |
ทำไมต้องแลกสองต่อ (code ก่อน แล้วค่อย token) —
ขั้น 4 ค่าเดินทางผ่านแถบที่อยู่ของเบราว์เซอร์ ซึ่งเข้า access log · เข้า history ·
โผล่ใน Referer · ถ้าส่ง token ตรง ๆ ตรงนั้น token จะติดไปทุกที่
จึงส่งแค่ “คูปองใช้ครั้งเดียว อายุสั้น” ไปก่อน แล้วเอาไปแลกของจริงที่ backend
ซึ่งต้องมี client_secretถึงจะแลกได้
ขั้น 5 พึ่ง client_secret ที่เก็บไว้ที่ backend · ถ้า client เป็น
public (SPA / mobile ที่ไม่มีที่ซ่อน secret) จะไม่มี client_secret
ให้ใช้เลย — ต้องใช้ PKCE แทน:
ขั้น 2 App สุ่ม code_verifier แล้วส่ง code_challenge = BASE64URL(SHA256(code_verifier)) ไปกับ request
ขั้น 5 App ส่ง code + code_verifier ตัวจริง (แทน client_secret) ไปแลก token
Auth Server แฮชแล้วเทียบกับ code_challenge ที่จำไว้ — ไม่ตรงก็ไม่ให้ token
Keycloak รองรับอยู่แล้ว — discovery ของตัวที่รันจริง (Keycloak 26.7.2)
ประกาศไว้ว่า code_challenge_methods_supported = ["plain", "S256"] (ใช้ S256) ·
OAuth 2.1 บังคับ PKCE กับทุก client ไม่ใช่แค่ public แล้ว
โค้ด OIDC ฝั่ง Go — compile และรันจริงแล้ว
import (
"github.com/coreos/go-oidc/v3/oidc" // v3.20.0
"golang.org/x/oauth2" // v0.36.0
)
provider, err := oidc.NewProvider(ctx, issuerURL) // อ่าน discovery ให้เอง
conf := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: provider.Endpoint(), // ไม่ต้อง hardcode URL
RedirectURL: "http://localhost:8080/auth/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// ขั้น 2
url := conf.AuthCodeURL(state)
// ขั้น 5-6
tok, err := conf.Exchange(ctx, code)
// id_token ไม่ได้อยู่ใน tok.AccessToken — ต้องดึงแยก
rawIDToken, ok := tok.Extra("id_token").(string)
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
var claims struct {
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
}
err = idToken.Claims(&claims)
$ go build ./... ; go vet ./...
BUILD_EXIT=0
VET_EXIT=0
$ go run ./cmd/probe # ยิง issuer จริง (Google) ไม่ใช่แค่ compile
AuthURL = https://accounts.google.com/o/oauth2/v2/auth
TokenURL = https://oauth2.googleapis.com/token
AuthCodeURL = https://accounts.google.com/o/oauth2/v2/auth?client_id=my-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email&state=state-abc123
Verify(ขยะ) ERROR = oidc: malformed jwt: go-jose/go-jose: compact JWS format must have three parts
| คำสั่ง | ลากอะไรมาด้วยจริง (module เปล่า · go1.26.5) |
|---|---|
go get golang.org/x/oauth2@v0.36.0 | go: added golang.org/x/oauth2 v0.36.0 — บรรทัดเดียว |
go get github.com/coreos/go-oidc/v3@v3.20.0 | go: added github.com/coreos/go-oidc/v3 v3.20.0 — บรรทัดเดียว เหมือนกัน |
go mod tidy หลังเขียนโค้ดที่ import ทั้งสองตัวแล้ว | ถึงจะโผล่ github.com/go-jose/go-jose/v4 v4.1.4 // indirect เข้ามาใน go.mod |
cloud.google.com/go/compute/metadata
จะไม่โผล่ในโปรเจกต์เรา — มันอยู่ใน go.mod ของ oauth2 เอง
แต่ถูกใช้แค่ในแพ็กเกจย่อย golang.org/x/oauth2/google ·
ตราบใดที่ไม่ได้ import แพ็กเกจนั้น module graph pruning จะตัดทิ้ง ·
ตรวจแล้วกับ module เปล่า: grep cloud.google.com go.sum ได้ 0 บรรทัด
ข้อคิด: go get เฉย ๆ ไม่ได้บอกความจริงทั้งหมด — ดู go.mod
หลัง go mod tidy ที่มีโค้ดจริงแล้วเท่านั้น
11 · Keycloak — compose · endpoint · components
Keycloak คือ Authorization Server แบบ open source ที่เรารันเองได้ — ทำหน้าที่เก็บผู้ใช้ · แสดงหน้า login · ออก token · ปิด session ให้เรา โดยที่เราไม่ต้องเขียนระบบเก็บรหัสผ่านเอง
compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7
container_name: wongnok-keycloak
restart: always
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# ฉบับ Post ลบ KC_HOSTNAME: localhost ออกแล้ว
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "8081:8080"
volumes:
# เก็บข้อมูลของ embedded database ไว้
- wongnok-keycloak-data:/opt/keycloak/data
# ← บล็อกนี้ขาดไม่ได้ ไม่งั้น compose จะบอกว่า volume undefined
volumes:
wongnok-keycloak-data:
$ docker-compose up -d
Image quay.io/keycloak/keycloak:26.7 Pulled
Network kc_default Created
Volume kc_wongnok-keycloak-data Created
Container wongnok-keycloak Created
Container wongnok-keycloak Started
$ # รอจน ready
READY after ~4s (HTTP 200)
$ docker logs wongnok-keycloak | tail
2026-08-22 03:13:34,745 INFO [org.keycloak.services] (main) KC-SERVICES0077: Created temporary admin user with username admin
2026-08-22 03:13:34,883 INFO [io.quarkus] (main) Keycloak 26.7.2 on JVM (powered by Quarkus 3.33.3.1) started in 5.582s. Listening on: http://0.0.0.0:8080
| บรรทัดใน compose | ทำอะไรจริง ๆ |
|---|---|
image: …:26.7 | tag นี้มีจริง มี arm64 ด้วย และเป็น floating tag ที่ตอนนี้ชี้ไป 26.7.2 |
start-dev | โหมด dev — ใช้ H2 ไฟล์เดียวใน /opt/keycloak/data/h2 |
--import-realm | ไม่ทำอะไรเลย ถ้าไม่มีไฟล์ realm json อยู่ใน /opt/keycloak/data/import — และไม่ error ด้วย |
KC_BOOTSTRAP_ADMIN_* | สร้าง temporary admin user ให้ตอน start |
KC_HOSTNAME: localhost | ฉบับ Post ลบทิ้งแล้ว — ถ้าปักหมุดไว้ issuer จะตอบเป็น localhost เสมอ แม้ถูกเรียกด้วยชื่อ service ใน compose → oidc.NewProvider() error |
KC_HOSTNAME_STRICT: "false" | ผลเท่ากับไม่ใส่ในโหมด start-dev · ตั้ง "true" โดยไม่มี hostname = คอนเทนเนอร์ตาย |
8081:8080 | ข้างนอก 8081 · ข้างใน 8080 (เลี่ยงชนกับ API ของเราที่ 8080) |
wongnok-keycloak-data:/opt/keycloak/data | ทำให้ realm/user/group/role รอด restart จริง — ทดสอบแล้ว |
$ docker logs wongnok-keycloak | head -20
2026-08-22 03:13:30,102 INFO [org.keycloak.url.HostnameV2ProviderFactory] (main) If hostname is specified, hostname-strict is effectively ignored
$ docker logs wongnok-keycloak 2>&1 | grep -i -E "import|realm"
2026-08-22 03:13:34,071 INFO [org.keycloak.services] (main) KC-SERVICES0050: Initializing master realm
# ← ไม่มีบรรทัด import สักบรรทัด
① KC_HOSTNAME_STRICT ถูกเมิน — Keycloak พิมพ์บอกเองในล็อก
(ฉบับ Post แก้แล้ว ด้วยการลบ KC_HOSTNAME ทิ้ง บรรทัดเตือนนี้จึงหายไปจากล็อก)
② --import-realm เงียบ ๆ ไม่ทำอะไร เพราะไม่มีอะไร mount เข้า
/opt/keycloak/data/import
③ ถ้าลอก compose มาแค่ส่วน services: จะ error ตั้งแต่ยังไม่ทันขึ้น —
service "keycloak" refers to undefined volume wongnok-keycloak-data: invalid compose project
(ตัดบล็อก volumes: ออกแล้วรัน docker-compose up -d จริง · exit code 1)
— ต้องมี top-level volumes: ด้วย
และล็อกยังเตือนเองว่า “Running the server in development mode. DO NOT use this configuration
in production.” — start-dev คือของ dev เท่านั้น
endpoint สำคัญ — จาก OIDC discovery
$ curl -s http://localhost:8081/realms/master/.well-known/openid-configuration
| field | ค่าจริง (realm master) | ใช้ตอนไหน |
|---|---|---|
issuer | http://localhost:8081/realms/master | ตรวจ iss ใน token |
authorization_endpoint | …/protocol/openid-connect/auth | ขั้น 2 — พาผู้ใช้ไปหน้า login |
token_endpoint | …/protocol/openid-connect/token | ขั้น 5 — แลก code เป็น token · และ refresh |
jwks_uri | …/protocol/openid-connect/certs | ดึง public key มา verify token |
end_session_endpoint | …/protocol/openid-connect/logout | logout |
userinfo_endpoint | …/protocol/openid-connect/userinfo | ถามข้อมูลผู้ใช้เพิ่ม |
introspection_endpoint | …/protocol/openid-connect/token/introspect | ถาม Keycloak ตรง ๆ ว่า token ยังใช้ได้ไหม |
grant_types_supported = ["authorization_code", "client_credentials", "implicit", "password",
"refresh_token", "urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer", "urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:uma-ticket", "urn:openid:params:grant-type:ciba"]
response_types_supported = ["code", "none", "id_token", "token", "id_token token",
"code id_token", "code token", "code id_token token"]
issuer มี :8081 ติดมาด้วย
เพราะ Keycloak เติม port จาก request · เอาไปตรวจ iss ต้องใส่ port ให้ตรงเป๊ะ
ไม่งั้น validate ไม่ผ่านแบบหาสาเหตุยาก
components — ของสี่อย่างที่ต้องรู้จัก
Group → Role ทำงานจริง — พิสูจน์ด้วย token
== 3. map role recipe:write -> group chefs == HTTP 204
== 4. put user chef1 into group chefs == HTTP 204
== 5a. DIRECT realm roles of chef1 ==
['default-roles-wongnok'] ← ไม่เคยผูก recipe:write กับ user เลย
== 5b. COMPOSITE/effective realm roles of chef1 ==
['default-roles-wongnok', 'offline_access', 'recipe:write', 'uma_authorization']
== 5c. groups of chef1 ==
['/chefs']
== access_token payload AFTER group->role ==
"azp": "wongnok-api",
"preferred_username": "chef1",
"realm_access": { "roles": ["default-roles-wongnok", "recipe:write", "offline_access", "uma_authorization"] }
role โผล่ใน realm_access.roles (realm role)
ไม่ใช่ resource_access — หาผิดที่แล้วจะงงว่าทำไมไม่เห็น
claim ที่ Keycloak ใส่มาใน access_token
{
"exp": …, "iat": 1787368445,
"jti": "onrtro:f1adacbf-bd71-072f-c8c8-b8a6d666cc03",
"iss": "http://localhost:8081/realms/wongnok",
"aud": "account",
"sub": "5da7f595-196f-44a6-8dfd-64306ce734d6",
"typ": "Bearer",
"azp": "wongnok-api",
"sid": "grEBoqUUX084tjH4AEFbI5aJ",
"acr": "1",
"allowed-origins": ["http://localhost:8080"],
"realm_access": { "roles": ["default-roles-wongnok", "offline_access", "uma_authorization"] },
"resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } },
"scope": "openid profile email",
"email_verified": true,
"name": "Chef One",
"preferred_username": "chef1",
"given_name": "Chef", "family_name": "One",
"email": "chef1@wongnok.dev"
}
aud = "account" ไม่ใช่ชื่อ client
ใครเขียนโค้ดตรวจว่า aud == "wongnok-api"
จะ fail ทุกใบ · ตัวที่บอกว่า token ออกให้ client ไหนคือ azp ต่างหาก ·
อยากให้ aud เป็น client id ต้องไปเพิ่ม audience mapper เอง
ส่วน email / preferred_username จะมีก็ต่อเมื่อขอ scope
profile email (default client scope ให้มาอยู่แล้ว)
อายุ token ค่าเริ่มต้นของ 26.7
expires_in = 300 # access_token — 5 นาที
refresh_expires_in = 1800 # refresh_token — 30 นาที
token_type = Bearer
scope = openid profile email
access_token len 1346 refresh_token len 694 id_token len 1037
ไม่ใช่ 1 ชั่วโมงตามที่หลายคนจำ · ถ้าอยากได้ token มาเทสต์เร็ว ๆ ต้องเปิด
directAccessGrantsEnabled: true ที่ client ก่อนถึงจะใช้ password grant ได้
verify ด้วย public key อย่างเดียว — ไม่ต้องแชร์ secret
$ curl -s http://localhost:8081/realms/wongnok/protocol/openid-connect/certs
number of keys = 2
{'kid': 'wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbO...', 'kty': 'RSA', 'alg': 'RS256', 'use': 'sig', ...}
{'kid': 'Mf4wHVuGfb9hhl_aZu4jyayOm94BXmiUsDX413Re...', 'kty': 'RSA', 'alg': 'RSA-OAEP', 'use': 'enc', ...}
$ # Go stdlib ล้วน: ดึง JWKS → match kid → ประกอบ rsa.PublicKey → VerifyPKCS1v15
token header kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4 alg = RS256
matched JWKS key: kid = wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4 use = sig alg = RS256
public key modulus bits = 2048 exponent = 65537
VERIFY OK -> signature valid using ONLY the public key from jwks_uri (no client_secret used)
tampered token correctly REJECTED: crypto/rsa: verification error
ใบ use:sig alg:RS256 เอาไว้ verify ·
ใบ use:enc alg:RSA-OAEP เอาไว้เข้ารหัส ·
โค้ดที่หยิบ keys[0] ตรง ๆ จะถูกบ้างผิดบ้างแบบสุ่ม —
ต้อง match ด้วย kid จาก header ของ JWT เสมอ
refresh_token verify ด้วย public key ไม่ได้
== JWT header of access_token ==
{ "alg": "RS256", "typ": "JWT", "kid": "wyvKSfqZdLBxEgiFH8uLjDWqHbA_HNNaoo7eusbOGF4" }
== refresh_token header ==
{ "alg": "HS512", "typ": "JWT", "kid": "b2ffdb37-5e0e-4ae7-b420-370b9638d5b1" }
refresh_token เซ็นด้วย HS512
ซึ่งเป็น symmetric — secret อยู่ที่ Keycloak เท่านั้น ·
service อื่นเอา public key ไป verify มันไม่ได้และไม่ควรทำ
ต้องส่งกลับให้ Keycloak แลกที่ token_endpoint เท่านั้น
ประโยค “token ทุกใบ verify ด้วย public key ได้” จริงเฉพาะ
access_token กับ id_token
เลิกใช้แล้วเก็บกวาด
$ docker-compose down -v
Container wongnok-keycloak Removed
Volume kc_wongnok-keycloak-data Removed
Network kc_default Removed
$ # image ยังคาไว้ ถ้าอยากลบ
$ docker rmi quay.io/keycloak/keycloak:26.7
12 · flow /login /callback /exchange /logout
| endpoint | ใครเป็นคนเรียก | เรียกยังไง | แตะ Redis ยังไง | ตอบอะไรกลับ |
|---|---|---|---|---|
GET /api/v1/auth/login |
Browser | window.location · ห้าม AJAX |
SET oauth_state:xxx TTL 300 วินาที |
302 + Location: ไปหน้า login ของ Keycloak |
GET /api/v1/auth/callback |
Browser (เดินตาม 302 ของ Keycloak) |
ไม่มีใครสั่ง — เบราว์เซอร์เดินเองตาม redirect | GETDEL oauth_state:xxx แล้ว SET auth_ticket:yyy TTL 30 วินาที |
302 + Location: ไป FRONTEND_URL/auth/success?ticket=yyy( FRONTEND_URL = env ของ API เราเอง ไม่ใช่ของ Keycloak — URL ของหน้าเว็บฝั่ง frontend) |
POST /api/v1/auth/exchange |
Frontend JS | axios.post (AJAX) · body {ticket} |
GETDEL auth_ticket:yyy |
200 + body {accessToken, refreshToken, bearerType, expiresAt} |
POST /api/v1/auth/logout |
Frontend JS | axios.post (AJAX) · body {refreshToken} |
ไม่แตะเลย | 204 No Content (body ว่าง) |
| คำที่ต้องแยกให้ออก | หมายความว่า |
|---|---|
window.location = "…" | สั่งเบราว์เซอร์ให้ย้ายทั้งหน้าไปที่ URL นั้น — หน้าปัจจุบันถูกทิ้ง แถบที่อยู่เปลี่ยน และ redirect ต่อ ๆ ไปเบราว์เซอร์เดินตามให้เอง |
AJAX (axios.get(…)) | ยิง request เบื้องหลังโดยไม่เปลี่ยนหน้า — JavaScript ได้ response กลับมาเป็นข้อมูลก้อนหนึ่ง หน้าจอไม่ขยับ |
ทำไม /callback ไม่คืน token ตรง ๆ แต่คืน ticket
| เทียบสองสิ่ง | ticket (อยู่ใน URL) | accessToken (อยู่ใน body ของ POST) |
|---|---|---|
| เดินทางยังไง | query string ของ 302 → แถบที่อยู่ | response body ของ POST /exchange |
| เข้า access log ไหม | เข้า | ไม่เข้า |
| เข้า browser history ไหม | เข้า | ไม่เข้า |
โผล่ใน Referer ไหม | โผล่ (สำหรับ origin เดียวกัน) | ไม่โผล่ |
| อายุ | 30 วินาที | 300 วินาที (5 นาที) ตามค่า Keycloak |
| ใช้ได้กี่ครั้ง | ครั้งเดียว — ถูก GETDEL ทันทีที่แลก | ใช้ซ้ำได้จนหมดอายุ |
| ถ้าหลุดแล้วเกิดอะไร | ผู้เก็บได้ต้องชิงใช้ให้ทันภายใน 30 วินาที และต้องชิงก่อนเจ้าของ — ซึ่งเจ้าของแลกไปแล้วตั้งแต่วินาทีแรก | เข้าถึง API ในนามผู้ใช้ได้จนกว่าจะหมดอายุ |
GETDEL คืออะไร — คำสั่ง Redis ที่
อ่านค่าและลบทิ้งในจังหวะเดียวกัน · สำคัญตรงที่มัน atomic:
ถ้าสอง request มาพร้อมกัน มีแค่ตัวเดียวที่ได้ค่า อีกตัวได้ค่าว่าง —
ทำให้ “ใช้ครั้งเดียว” เป็นจริงโดยไม่ต้องล็อกอะไรเอง
13 · goose + ตาราง users
DSN — ตัวที่ถูกต้อง กับตัวที่มีปัญหา
| DSN | goose / pgx / lib/pq | psql (libpq) |
|---|---|---|
postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok |
ผ่าน — ต่อเข้าฐาน wongnok ถูกต้อง |
พังสองต่อ — ดูข้างล่าง |
postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable |
ผ่าน | ผ่าน |
$ # ฝั่ง Go ไม่พังเลย — สาม parser ให้ผลตรงกันหมด
=== DSN: postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok
net/url.Parse -> user="postgres" password="Pe@devp00l" host="localhost:5432" rawquery="database=wongnok"
pgx ParseConfig-> host="localhost" port=5432 user="postgres" password="Pe@devp00l" database="wongnok"
lib/pq ParseURL-> "database='wongnok' host='localhost' password='Pe@devp00l' port='5432' user='postgres'"
$ goose up
2026/08/22 10:11:57 OK 20260822031131_create_users.sql (12.99ms)
2026/08/22 10:11:57 goose: successfully migrated database to version: 20260822031131
$ # ลงฐานไหน? เช็คทั้งสองฐาน
=== tables in DB 'postgres' === Did not find any relations.
=== tables in DB 'wongnok' ===
public | goose_db_version | table | postgres
public | users | table | postgres
psql
$ psql "postgres://postgres:Pe@devp00l@localhost:5432/wongnok" -c "select current_database()"
psql: error: could not translate host name "devp00l@localhost" to address: Name does not resolve
$ psql "postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok" -c "select current_database()"
psql: error: invalid URI query parameter: "database"
① @ ที่ไม่ encode —
Go ตัดที่ @ ตัวสุดท้าย (LastIndex) เลยได้ password ถูก
แต่ libpq ตัดที่ @ ตัวแรก host เลยกลายเป็น devp00l@localhost
→ ต้องเขียน %40 เสมอ
② ?database= เป็นของฝั่ง Go เท่านั้น — pgx
ใช้ชื่อคีย์ว่า database อยู่แล้วเลยตีความได้ แต่ psql ปฏิเสธตรง ๆ
→ ใช้ path /wongnok แทน
ทั้งสองข้อกัดตอน debug เพราะเราจะก็อป DSN เดิมไปเปิด psql ดูตาราง แล้วงงว่าทำไมต่อไม่ได้
ทั้งที่แอปรันได้
$ psql "postgres://postgres:Pe%40devp00l@localhost:5432/wongnok" -c "select current_database()"
current_database
------------------
wongnok
(1 row)
คำสั่ง goose ที่ใช้จริง
# .env — goose CLI อ่านไฟล์นี้ให้เอง (flag -env ค่าเริ่มต้นคือ .env)
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable
GOOSE_MIGRATION_DIR=migrations
| คำสั่ง | ทำอะไร | ผลจริง |
|---|---|---|
goose create create_users sql | สร้างไฟล์ migration เปล่า | Created new file: migrations/20260822031131_create_users.sql |
goose up | รัน migration ที่ยังไม่ได้รัน | OK … (8.73ms) + goose: successfully migrated database to version: … |
goose down | ถอย 1 ขั้น | OK 20260822031131_create_users.sql (5.22ms) — ไม่มีบรรทัด successfully |
goose status | ดูว่าอันไหนรันแล้ว/ยัง | Pending หรือวันเวลาที่รัน |
goose version | เลข version ปัจจุบันของฐาน | goose: version 20260822031131 |
$ # ไฟล์ที่ goose create สร้างมาให้
-- +goose Up
SELECT 'up SQL query';
-- +goose Down
SELECT 'down SQL query';
goose CLI โหลด .env ใน cwd ให้เอง —
ไม่ต้อง source .env ไม่ต้อง export ·
พิสูจน์โดย env -u GOOSE_DRIVER -u GOOSE_DBSTRING -u GOOSE_MIGRATION_DIR goose status
แล้วยังรันได้ พอย้าย .env ออกถึงพิมพ์ usage ·
แต่ต้องรันในโฟลเดอร์ที่มี .env
migration ของตาราง users
-- +goose Up
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
bio TEXT,
uid VARCHAR(255) NOT NULL,
image_url TEXT,
preferred_username VARCHAR(255),
last_signed_in_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_users_email ON users (email);
CREATE UNIQUE INDEX idx_users_uid ON users (uid);
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
-- +goose Down
DROP TABLE users;
$ psql -U postgres -d wongnok -c '\d users'
Table "public.users"
Column | Type | Collation | Nullable | Default
--------------------+--------------------------+-----------+----------+---------
id | uuid | | not null |
email | character varying(255) | | not null |
name | character varying(255) | | |
bio | text | | |
uid | character varying(255) | | not null |
image_url | text | | |
preferred_username | character varying(255) | | |
last_signed_in_at | timestamp with time zone | | |
created_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
deleted_at | timestamp with time zone | | |
Indexes:
"users_pkey" PRIMARY KEY, btree (id)
"idx_users_deleted_at" btree (deleted_at)
"idx_users_email" btree (email)
"idx_users_uid" UNIQUE, btree (uid)
| คอลัมน์ | มาจากไหน | ทำไมต้องมี |
|---|---|---|
uid | sub ใน token ของ Keycloak | กุญแจที่ผูกผู้ใช้ในฐานเรากับผู้ใช้ใน Keycloak — จึงต้อง unique |
preferred_username | claim ชื่อเดียวกันใน token | ชื่อที่เอาไว้แสดง |
image_url | ไม่มีใน claim — ผู้ใช้ใส่เอง | สไลด์ฉบับ Post เพิ่งเพิ่มมา เพื่อรองรับ PUT /api/v1/users/me ในโจทย์สอบ |
email · name | claim email / name | ข้อมูลโปรไฟล์ที่ Keycloak ให้มา |
last_signed_in_at | เราเขียนเองตอน callback | รู้ว่าใครยังใช้งานอยู่ |
deleted_at | GORM soft delete | มี index เพราะทุก query จะมี WHERE deleted_at IS NULL |
soft delete ของ GORM ทำงานยังไง
type User struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_users_deleted_at"`
// ชนิดต้องเป็น gorm.DeletedAt เท่านั้น — *time.Time ใช้ไม่ได้
}
1) Create OK, id = bae1dc16-8585-4f91-bbe6-d30160e7dbdd
2) db.Delete() -> DeletedAt.Valid = true
3) Count() ปกติ (ถูกกรอง deleted_at IS NULL) = 0
4) Unscoped().Count() (นับแถวที่ soft delete ด้วย) = 1
uid เฉย ๆ ชนกับ soft delete
INSERT 0 1
UPDATE 1 # soft delete
uid | soft_deleted
--------------+--------------
oidc-sub-123 | t
INSERT ...
ERROR: duplicate key value violates unique constraint "idx_users_uid"
DETAIL: Key (uid)=(oidc-sub-123) already exists.
soft delete แล้วแถวยังอยู่ในตาราง unique index จึงยังกันอยู่ — ผู้ใช้ที่ลบบัญชีไปแล้ว login กลับเข้ามาไม่ได้อีกเลย
5) สมัครใหม่ uid เดิม -> err = ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)
6) FirstOrCreate -> err = ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)
FirstOrCreate เจ็บกว่า — First หาไม่เจอ
(โดน scope deleted_at IS NULL กรอง) เลยไป Create แล้วชน unique
ของแถวที่ถูกลบ · โค้ด OIDC ชอบใช้ท่านี้พอดี
DROP INDEX idx_users_uid;
CREATE UNIQUE INDEX idx_users_uid ON users (uid) WHERE deleted_at IS NULL;
DROP INDEX
CREATE INDEX
INSERT 0 1
uid | active
--------------+--------
oidc-sub-123 | f
oidc-sub-123 | t
(2 rows)
ERROR: duplicate key value violates unique constraint "idx_users_uid" # ยังกัน uid ซ้ำในแถวที่ active
ได้ทั้งสองอย่าง: สมัครใหม่หลัง soft delete ผ่าน
แต่ยังกัน uid ซ้ำในแถวที่ยัง active ·
ฝั่ง GORM เขียนเป็น gorm:"uniqueIndex:idx_users_uid,where:deleted_at IS NULL"
id UUID PRIMARY KEY ไม่มี DEFAULT
รอบแรก:
Create โดยไม่เซ็ต ID -> err=<nil>, id=00000000-0000-0000-0000-000000000000
รอบสอง (uid ต่างกัน):
Create โดยไม่เซ็ต ID -> err=ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505)
แถวในตาราง:
id | uid
--------------------------------------+-----------
00000000-0000-0000-0000-000000000000 | no-id-set
บั๊กเงียบสุดคลาสสิก — user คนแรกเข้าได้ด้วย UUID ศูนย์
คนที่สองถึงจะพัง · แก้ได้สองทาง: เติม DEFAULT gen_random_uuid()
ใน migration หรือเขียน BeforeCreate hook ใน GORM
อ่าน goose status ให้ถูก
$ goose status # หลัง up
Applied At Migration
=======================================
Sat Aug 22 03:11:57 2026 -- 20260822031131_create_users.sql
$ goose down ; goose status
Applied At Migration
=======================================
Pending -- 20260822031131_create_users.sql
$ psql -c 'select * from goose_db_version;' -c 'show timezone;' ; date
id | version_id | is_applied | tstamp
----+----------------+------------+----------------------------
1 | 0 | t | 2026-08-22 03:11:46.240423
3 | 20260822031131 | t | 2026-08-22 03:14:26.155434
(2 rows)
TimeZone
----------
UTC
local now:
Sat Aug 22 10:15:38 +07 2026
สองเรื่องที่ทำให้คนงง:
① เวลาเป็น UTC — tstamp เป็น timestamp ไม่มี timezone
และเก็บ UTC · เพิ่งรันเสร็จแต่โชว์ย้อนหลัง 7 ชั่วโมง เป็นเรื่องปกติ ·
ชื่อไฟล์ที่ goose create ตั้งให้ก็เป็น UTC เหมือนกัน
(20260822031131 = 03:11:31 UTC ไม่ใช่ 10:11 เวลาไทย)
② id กระโดด 1 → 3 — เพราะ goose down
ลบแถวทิ้งจริง ไม่ได้แค่ mark is_applied=false
14 · ตารางกับดักรวมทั้งวัน
| เขียนแบบนี้ | สิ่งที่เกิดขึ้นจริง | แก้ |
|---|---|---|
`env:"X,required" envDefault:"y"` | required ไม่มีผลเลย — ได้ default เงียบ ๆ err=<nil> | เลือกอย่างใดอย่างหนึ่ง อย่าใส่คู่กัน |
`env:"X,notEmpty" envDefault:"y"` | โดนกลบเหมือนกันเป๊ะ — ทั้งกรณีไม่ตั้ง env และตั้งเป็นค่าว่าง | เอา envDefault ออก |
POSTGRES_DSN= ใน compose (ไม่ใส่ค่า) | ,required ผ่านฉลุย — มันเช็คแค่ว่า “มีตัวแปรนี้ไหม” | ใช้ ,notEmpty (และห้ามมี envDefault) |
`env:"SECRET,file"` ชี้ไฟล์ที่ลงท้ายด้วย newline | ได้ค่าติด \n มาด้วย → รหัสผ่านผิดแบบ debug ไม่เจอ | strings.TrimSpace หลัง parse |
APP_SHUTDOWN_TIMEOUT=10 | unable to parse duration: time: missing unit in duration "10" | ใส่หน่วยเสมอ — 10s |
ใช้ cfg ต่อโดยไม่เช็ค err จาก ParseAs | field ที่พังได้ zero value แต่ field อื่นเติมครบ → ได้ Port=0 ไปใช้ | เช็ค err แล้ว os.Exit(1) ทันที |
| เขียนลง map ที่มาจาก env โดยไม่ตั้งค่า | ได้ nil map → panic: assignment to entry in nil map | ใส่ envDefault หรือ make เอง |
import _ ".../godotenv/autoload" แล้ว .env พิมพ์ผิด | เงียบสนิท — ไม่ panic ไม่ log exit 0 แต่ไม่มี env จาก .env เลยสักตัว | ใช้ godotenv.Load() แล้วจับ err เอง |
.env มีบรรทัดพัง 1 บรรทัด | all-or-nothing — บรรทัดก่อนหน้าที่ถูกต้องก็ไม่ถูก set เลย ทั้งไฟล์ถูกทิ้ง | อ่าน error ของ Load() ทุกครั้ง |
123INVALID=x หรือ =novalue ใน .env | err=<nil> — godotenv ไม่ validate ชื่อ key เลย | ตรวจ .env.example ให้ตรงกับที่โค้ดอ่านจริง |
P1="a$BCD" ใน .env | quote คู่ทำ variable expansion — ได้ "aSURPRISE" · จับเฉพาะชื่อ [A-Z0-9_]+ (PASS="a$bcd" พิมพ์เล็ก รอด) | secret ที่มี $ ใช้ quote เดี่ยว |
TOKEN=ab #cd ใน .env | ได้ "ab" — comment ท้ายบรรทัดถูกตัด · ต้องมีช่องว่างนำหน้า # เท่านั้น (TOKEN=ab#cd ติดกันได้ "ab#cd" ครบ) | ใส่ quote ครอบ |
TAB="a\tb" ใน .env | ได้ "atb" — รองรับแค่ \n กับ \r | อย่าพึ่ง escape อื่นใน .env |
| รัน binary จากโฟลเดอร์อื่น | .env หาไม่เจอ — มันหาจาก cwd ไม่ใช่ที่ตั้ง binary | รันจาก project root หรือส่ง env ตรง ๆ |
validate:"oneof=dev,staging,prod" | panic ตอน Struct() ครั้งแรก ไม่ใช่ตอน New() — โปรแกรม start ผ่านแล้วตายตอน request แรก | คั่นด้วยเว้นวรรค: oneof=dev staging prod |
validate:"gte=1" บน time.Duration | = 1 นาโนวินาที · 500ms ผ่านฉลุย | เขียนหน่วยเสมอ — gte=1s |
validate:"required" บน time.Duration | กันได้แค่ 0s · -5s ผ่านทั้ง ParseAs และ validator | validate:"required,gt=0" |
DB_HOST=db_server + validate:"hostname|ip" | ตก — hostname ไม่ยอมรับ underscore (และไม่ยอมรับ host:port) | ตั้งชื่อ service ด้วย - หรือเปลี่ยน tag |
validator.New() ใหม่ทุก request | ช้ากว่า singleton ~72 เท่า (8638 ns vs 120.6 ns · 224 allocs vs 1) | ประกาศเป็น package-level var ครั้งเดียว |
validator.New() เปล่า ๆ + required บน nested struct | tag นั้นถูกเมิน zero struct ผ่าน | validator.New(validator.WithRequiredStructEnabled()) |
FieldError.Value().(int64) ของ Duration | panic — interface conversion: interface {} is time.Duration, not int64 · และถ้าพิมพ์ด้วย %d/%#v จะได้ 100000000 แทน 100ms | assert เป็น time.Duration · พิมพ์ด้วย %v |
Load() คืน *Config | ตอน error คืน nil — ลืมเช็ค err แล้ว panic · แถม handler แก้ค่าที่ validate แล้วได้ | คืนเป็น value Config |
claims["sub"].(int) กับ jwt.MapClaims | panic — MapClaims decode ตัวเลขเป็น float64 เสมอ | .(float64) หรือใช้ custom claims struct |
เซ็น JWT โดยไม่ใส่ exp | jwt.Parse ผ่าน (err=<nil>) = token ตลอดชีพ | ใส่ exp ตอนเซ็น + jwt.WithExpirationRequired() ตอนตรวจ |
jwt.Parse โดยไม่ใส่ WithValidMethods | กัน algorithm confusion ได้เพราะ type ไม่ match โดยบังเอิญ เท่านั้น | ใส่ jwt.WithValidMethods([]string{"RS256"}) เสมอ |
if ok { ให้ผ่าน } หลัง r.BasicAuth() | ช่องโหว่ — ok=true แม้รหัสผิด (แปลว่า “แกะ header สำเร็จ”) | เทียบ user/pass เองด้วย subtle.ConstantTimeCompare |
| ยัด roles + permissions + ชื่อไทยลง JWT | บวมเป็น 553 byte = 17.3 เท่าของ session id — และเดินทางทุก request | ใส่แค่ sub · exp · role ที่จำเป็น |
ตรวจ aud == "wongnok-api" กับ token ของ Keycloak | fail ทุกใบ — aud เป็น "account" | ดู azp แทน หรือเพิ่ม audience mapper |
หยิบ keys[0] จาก jwks_uri | ถูกบ้างผิดบ้าง — มี 2 คีย์ (use:sig RS256 กับ use:enc RSA-OAEP) | match ด้วย kid จาก header ของ JWT |
เอา public key ไป verify refresh_token | ไม่ได้ — มันเซ็นด้วย HS512 (symmetric) | ส่งกลับ Keycloak ที่ token_endpoint |
ลอก compose ของ Keycloak มาแค่ services: | error volume undefined ตั้งแต่ยังไม่ทันขึ้น | ใส่ top-level volumes: ด้วย |
เชื่อว่า --import-realm import อะไรให้ | ไม่ทำอะไรเลยถ้าไม่มีไฟล์ใน /opt/keycloak/data/import — และไม่ error | mount ไฟล์ realm json เข้าไปเอง |
KC_HOSTNAME: localhost ค้างไว้ แล้วเรียก Keycloak ด้วยชื่อ service ใน compose | oidc.NewProvider() error ตั้งแต่บูต — issuer URL provided to client … did not match the issuer URL returned by provider | ลบ KC_HOSTNAME ทิ้ง (ฉบับ Post ทำแล้ว) |
ปล่อย issuer ลอยตาม Host: บน production | ยิง Host: evil.example.com แล้วได้ issuer = http://evil.example.com/realms/master | ปักหมุด KC_HOSTNAME ให้เป็นโดเมนจริง — อย่าปิด strict |
DSN ที่มี @ ไม่ encode | Go ผ่าน แต่ psql พัง — could not translate host name "devp00l@localhost" | เขียน %40 |
DSN ที่ใช้ ?database=wongnok | Go ผ่าน แต่ psql ปฏิเสธ — invalid URI query parameter | ใช้ path /wongnok |
unique index บน uid เฉย ๆ + soft delete | ผู้ใช้ที่ลบบัญชี login กลับมาไม่ได้อีก · FirstOrCreate ก็พัง | partial index … WHERE deleted_at IS NULL |
id UUID PRIMARY KEY ไม่มี DEFAULT + ลืมเซ็ตใน GORM | คนแรกได้ UUID ศูนย์เงียบ ๆ คนที่สองถึงพัง | DEFAULT gen_random_uuid() หรือ BeforeCreate |
อ่าน Applied At ของ goose เป็นเวลาไทย | เป็น UTC — ต่างจากเวลาไทย 7 ชั่วโมง | บวก 7 เอง หรือดูที่ version_id แทน |
คาดว่า exit status 1 เป็นข้อความของโปรแกรม | go run พิมพ์เอง — build เป็น binary แล้วรันจะไม่มีบรรทัดนี้ | — |
15 · ตารางแปล error / panic ที่เจอบ่อยของวันนี้
| ข้อความ | แปลว่า |
|---|---|
env: required environment variable "MYVAR2" is not set | ,required ไม่เจอ env ตัวนั้น · type จริงคือ env.VarIsNotSetError ห่อใน env.AggregateError |
env: environment variable "NE_VAR" should not be empty | ,notEmpty เจอค่าว่าง · type คือ env.EmptyVarError |
env: could not load content of file "…" from variable F_VAR: open …: no such file or directory | ,file ชี้ไป path ที่ไม่มีจริง · type คือ env.LoadFileContentError |
env: parse error on field "AppPort" of type "int": strconv.ParseInt: parsing "abc": invalid syntax | ค่าใน env แปลงเป็น type ของ field ไม่ได้ · field อื่นยังถูกเติมครบ |
env: parse error on field "ShutdownTimeout" of type "time.Duration": unable to parse duration: time: missing unit in duration "10" | ลืมหน่วยของเวลา — เขียน 10s |
env: parse error on field "Lvl" of type "main.LogLevel": unknown log level "VERBOSE" | UnmarshalText ที่เราเขียนเองคืน error — ข้อความหลัง : คือของเราเอง |
error หลายอันคั่นด้วย "; " | env.AggregateError — รวมทุกปัญหาไว้ในรอบเดียว ไม่ได้หยุดที่ตัวแรก |
open .env: no such file or directory | ไม่มีไฟล์ .env ใน cwd · type *fs.PathError · errors.Is(err, os.ErrNotExist) = true |
unexpected character "\n" in variable name near "this line has no equals sign\nAFTER=2\n" | .env มีบรรทัดที่ไม่มี = · ทั้งไฟล์ถูกทิ้ง |
unterminated quoted value "oops | .env มี quote ที่ไม่ปิด · ทั้งไฟล์ถูกทิ้งเหมือนกัน |
Key: 'App.Port' Error:Field validation for 'Port' failed on the 'lte' tag | validator: App.Port เกินค่าที่ lte กำหนด · รูปแบบนี้เป๊ะทุกช่องว่าง |
panic: Undefined validation function 'staging' on field 'Env' | ใช้ comma ใน oneof · panic ตอน validate.Struct() ครั้งแรก ไม่ใช่ตอน New() |
token has invalid claims: token is expired | JWT: exp ผ่านไปแล้ว · errors.Is(err, jwt.ErrTokenExpired) = true |
token has invalid claims: token is not valid yet | JWT: nbf อยู่ในอนาคต |
token has invalid claims: token has invalid audience | JWT: aud ไม่ตรงกับที่ WithAudience กำหนด |
token has invalid claims: token is missing required claim: exp claim is required | ใส่ WithExpirationRequired() แต่ token ไม่มี exp |
token has invalid claims: token is missing required claim: iss claim is required | WithIssuer() ทำให้ iss กลายเป็น required ไปด้วย — ไม่ใช่ “issuer ผิด” |
token signature is invalid: signature is invalid | JWT ถูกแก้ หรือ secret ไม่ตรง · ErrSignatureInvalid และ ErrTokenSignatureInvalid เป็น true ทั้งคู่ |
token signature is invalid: signing method HS256 is invalid | alg ของ token ไม่อยู่ในรายการของ WithValidMethods · ErrSignatureInvalid เป็น false ในเคสนี้ |
token signature is invalid: token is unverifiable: 'none' signature type is not allowed | เจอ token alg=none — golang-jwt/v5 บล็อกให้ตั้งแต่ค่าเริ่มต้น |
token signature is invalid: key is of invalid type: HMAC verify expects []byte | algorithm confusion — keyfunc คืน *rsa.PublicKey แต่ token อ้างว่าเป็น HS256 |
token is malformed: token contains an invalid number of segments | token ว่าง / เป็นข้อความมั่ว / มีไม่ครบ 3 ส่วน — ทั้งสามเคสได้ข้อความเดียวกัน |
oidc: malformed jwt: go-jose/go-jose: compact JWS format must have three parts | เหมือนข้างบน แต่มาจากฝั่ง go-oidc ซึ่งใช้ go-jose/v4 |
psql: error: could not translate host name "devp00l@localhost" to address: Name does not resolve | DSN มี @ ในรหัสผ่านที่ไม่ได้ encode — libpq ตัดที่ @ ตัวแรก |
psql: error: invalid URI query parameter: "database" | DSN ใช้ ?database= ซึ่งเป็นของฝั่ง Go เท่านั้น — ใช้ path /wongnok |
ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505) | สมัครซ้ำด้วย uid เดิมหลัง soft delete — แถวเก่ายังอยู่ในตาราง |
ERROR: duplicate key value violates unique constraint "users_pkey" (SQLSTATE 23505) | ลืมเซ็ต ID ใน GORM — คนที่สองชนกับ UUID ศูนย์ของคนแรก |
runtime error: invalid memory address or nil pointer dereference | Load() คืน *Config เป็น nil ตอน error แล้วมีคนอ่านต่อโดยไม่เช็ค err |
docker: unknown command: docker compose | symlink ของ compose plugin ค้างไปที่ Docker Desktop ที่ถูกลบแล้ว — ใช้ docker-compose (มีขีด) |
docker: error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATH | ซาก credsStore ใน ~/.docker/config.json |
service "keycloak" refers to undefined volume wongnok-keycloak-data: invalid compose project | ลอก compose มาแค่ services: ลืม top-level volumes: |
💬 ถามได้เจอ error ที่ไม่มีในตารางนี้ ส่งข้อความเต็ม ๆ มาได้เลย เดี๋ยวรันซ้ำแล้วเพิ่มลงตาราง — ทุกบรรทัดในตารางนี้มาจากของที่รันเจอจริงทั้งหมด ไม่ได้ลอกมาจากที่ไหน
อ้างอิง
- caarlos0/env — README มีรายการ tag/option/type ครบ · pkg.go.dev
- joho/godotenv — โดยเฉพาะ
godotenv.goและparser.goที่หัวข้อ 4–5 อ้างถึง - go-playground/validator — รายการ
validatetag ทั้งหมด · pkg.go.dev - pkg.go.dev —
errors.Join· errors.As - The Twelve-Factor App — III. Config — ต้นทางของแนวคิด “config อยู่ใน environment”
- golang-jwt/jwt — pkg.go.dev v5 · รายการ
Parser optionทั้งหมด - RFC 7519 — JSON Web Token (JWT) · jwt.io — เครื่องมือ decode
- RFC 6749 — The OAuth 2.0 Authorization Framework — นิยามตัวละคร 4 ตัวและ grant ทุกแบบ
- OpenID Connect Core 1.0 · coreos/go-oidc · golang.org/x/oauth2
- Keycloak Documentation · Running Keycloak in a container · Hostname configuration
- pressly/goose · GORM — Soft Delete
- PostgreSQL — Partial Indexes — ที่มาของทางแก้ในหัวข้อ 13
- Cheat sheet Day 8 — REST · Gin · Swagger · Environment Variables พื้นฐาน
- Cheat sheet Day 7 — Mockery · Testcontainers · Goose · Redis