🧰 ของที่ใช้ในบทนี้ — เวอร์ชันจริงที่ผมรัน ทุก output ในหน้านี้มาจากเครื่องนี้
go version go1.26.5 darwin/arm64
golang.org/x/oauth2 v0.36.0
github.com/coreos/go-oidc/v3 v3.20.0 # ลาก github.com/go-jose/go-jose/v4 v4.1.4 มาด้วย
goose version: v3.27.3
gorm.io/gorm v1.31.2 # หัวข้อ 8 และโค้ด handler ท้ายหัวข้อ 9
gorm.io/driver/postgres v1.6.2
github.com/google/uuid v1.6.0 # โค้ด handler ท้ายหัวข้อ 9 เรียก uuid.NewString()
PostgreSQL 16.15 on aarch64-unknown-linux-musl # image postgres:16-alpine
Docker client 29.6.2 / server 29.5.2 # รันผ่าน colima ไม่ใช่ Docker Desktop
สังเกตบรรทัด Docker ให้ดี — docker --version บอก 29.6.2
แต่นั่นคือเลขของ client ส่วน engine ที่รัน container จริง ๆ คือ 29.5.2
ดูได้ด้วย docker version --format '{{.Server.Version}}'
เวลามีคนถามว่า "Docker เวอร์ชันอะไร" แล้วปัญหาเกี่ยวกับพฤติกรรมของ container
เลขที่เขาอยากรู้คือเลข server ไม่ใช่ client
1 · บทนี้กำลังต่ออะไรเข้ากับอะไร
จากบทที่แล้ว Keycloak รันอยู่แล้วที่ localhost:8081 และมันเก็บผู้ใช้ของมันเองไว้ครบ
— ชื่อ อีเมล รหัสผ่าน role
คำถามที่คนถามทันทีคือ: ถ้า Keycloak เก็บ user ให้แล้ว ทำไมเรายังต้องมีตาราง users ในฐานข้อมูลเราอีก
คำตอบสั้น ๆ คือ ของสองฝั่งเก็บคนละเรื่อง:
| ฝั่ง | เก็บอะไร | ตัวอย่าง |
|---|---|---|
| Keycloak (identity) | ตัวตนและสิทธิ์ — ใครคือใคร รหัสผ่านถูกไหม อยู่ group ไหน | sub, preferred_username, email, role |
| ฐานข้อมูลเรา (application data) | ข้อมูลที่เป็นของแอปเรา และความสัมพันธ์กับตารางอื่น | bio, สูตรอาหารที่คนนี้เขียน, ดาวที่ให้ไว้ |
เหตุผลที่ตัดสินเรื่องนี้จริง ๆ คือข้อสอง — foreign key
ตาราง recipes ต้องมีคอลัมน์ที่ชี้ว่า "สูตรนี้ใครเขียน" และมันชี้ไปหาแถวในฐานข้อมูลเดียวกันได้เท่านั้น
ชี้ข้ามไปหา user ที่อยู่ในฐานข้อมูลของ Keycloak ไม่ได้
เพราะแบบนี้ทุกระบบที่ใช้ identity provider ภายนอกจึงต้องมีตารางเงาของผู้ใช้ฝั่งตัวเองเสมอ
และหัวข้อ 7 จะบอกว่าตารางเงานี้ต้องมีคอลัมน์อะไรเป็นพิเศษ
🧭 ลำดับที่บทนี้จะเดิน — ตามสไลด์เป๊ะ ๆ:
หน้า 49 ลง library สองตัว → เขียนโค้ด OIDC (หัวข้อ 2–3)
หน้า 50 ตั้ง goose ใน .env (หัวข้อ 4–5)
หน้า 51 migration create_users (หัวข้อ 6–9)
หน้า 53–54 ใช้ AI agent สร้าง migration ที่เหลือ (หัวข้อ 10 แล้วต่อบทเรียน 54)
· หน้า 48 กับ 52 ไม่มีหัวข้อของตัวเองในบทนี้ เพราะเป็นหน้าคั่นบทล้วน ๆ ไม่มีคำสั่งหรือโค้ด —
หน้า 48 เขียนว่า "🔓 Keycloak Auth 🔑 For Wongnok API" · หน้า 52 เขียนว่า "Implement recipes resource"
2 · Dependencies — oauth2 กับ go-oidc ต่างกันยังไง
สไลด์หน้า 49 มีแค่สองบรรทัดนี้ ไม่มีคำอธิบายอะไรเลย:
go get golang.org/x/oauth2
go get github.com/coreos/go-oidc/v3
คนใหม่จะงงว่าทำไมต้องลงสองตัว ในเมื่อมันก็เรื่องเดียวกัน คำตอบคือมันทำงานคนละครึ่งของกระบวนการ และไม่มีตัวไหนทำแทนกันได้:
| Library | รับผิดชอบอะไร | ถ้าไม่มีตัวนี้จะต้องเขียนเองว่าอะไร |
|---|---|---|
golang.org/x/oauth2 |
ขั้นตอนแลกของ — ประกอบ URL พาผู้ใช้ไปหน้า login · เอา code ที่ได้กลับมา
ยิงไป token_endpoint เพื่อแลกเป็น token · เก็บ token ไว้ต่ออายุให้ |
ต้องประกอบ query string เอง ยิง POST เอง แกะ JSON เอง จัดการ refresh เอง |
github.com/coreos/go-oidc/v3 |
ขั้นตอนตรวจของ — อ่าน discovery document ให้ · ไปโหลด public key จาก jwks_uri
มาเอง · verify ลายเซ็นของ id_token และเช็ค iss/aud/วันหมดอายุให้ |
ต้องดึง JWKS เอง จับคู่ kid เอง ประกอบ RSA public key เอง verify เอง แล้วเช็ค claim เองทีละตัว |
นึกถึงตอนไปทำธุระที่เคาน์เตอร์ธนาคาร งานมันแยกเป็นสองคนละคนอยู่แล้ว:
· คนเดินเรื่อง — รู้ว่าต้องไปช่องไหน ยื่นใบคิวตรงไหน เอาใบคิวไปแลกเป็นของกลับมายังไง
· เครื่องส่องแบงก์ปลอม — ไม่สนใจว่าคุณเดินมาจากช่องไหน มันมีหน้าที่เดียวคือเอาของที่ได้มา
ส่องดูว่าเป็นของจริงหรือของปลอม
oauth2 คือคนเดินเรื่อง — ประกอบ URL พาผู้ใช้ไปหน้า login
แล้วเอา code ที่ได้กลับมาไปแลกเป็น token
go-oidc คือเครื่องส่อง — เอา id_token ที่แลกมาได้ไปตรวจลายเซ็น
ว่า Keycloak เซ็นมาจริงไหม หมดอายุหรือยัง
คนเดินเรื่องไม่มีเครื่องส่อง เครื่องส่องก็เดินเรื่องแทนไม่ได้ — ต้องมีทั้งคู่ ถึงได้ลงสองบรรทัด
ที่ไม่เหมือนกัน: เครื่องส่องแบงก์ที่เคาน์เตอร์เสียบปลั๊กก็ทำงานได้เลย ตรวจจากตัวแบงก์อย่างเดียว
แต่ go-oidc ต้องออกไปโหลด public key จาก jwks_uri ผ่านเน็ตก่อน
ถึงจะตรวจได้ — เน็ตไปไม่ถึง Keycloak เมื่อไหร่ การ verify ก็ทำไม่ได้ทันที
เพราะแบบนี้ oidc.NewProvider ในหัวข้อ 3 ถึงยิง HTTP ออกไปตั้งแต่ตอนสร้าง
ไม่ใช่ฟังก์ชันที่แค่ตั้งค่าไว้เฉย ๆ
📖 ศัพท์ที่ต้องรู้ก่อนไปต่อ (จากบทเรียน 50–51 แต่ทวนให้)
· id_token = JWT ที่บอกว่า "คนนี้คือใคร" — มีชื่อ อีเมล และ sub อยู่ข้างใน
เอาไว้ให้แอปเรารู้จักผู้ใช้ ไม่ใช่ เอาไว้ยิง API
· access_token = JWT ที่บอกว่า "คนนี้ทำอะไรได้บ้าง" เอาไว้แนบไปกับ request
· jwks_uri = URL ที่ Keycloak แจก public key ของตัวเอง
ใครก็โหลดได้ ไม่ใช่ความลับ — เอาไว้ตรวจว่า token ใบนี้ Keycloak เซ็นมาจริงหรือมีคนปลอม
· sub (subject) = รหัสประจำตัวผู้ใช้ที่ Keycloak ตั้งให้ เป็น UUID
และไม่เปลี่ยนตลอดชีพของ user คนนั้น — จำคำนี้ไว้ เดี๋ยวหัวข้อ 7 ใช้
ผลรันจริงของ go get ทั้งสองบรรทัด
$ go mod init oidcdemo
go: creating new go.mod: module oidcdemo
$ go get golang.org/x/oauth2
go: added golang.org/x/oauth2 v0.36.0
$ go get github.com/coreos/go-oidc/v3
go: added github.com/coreos/go-oidc/v3 v3.20.0
$ go list -m all
oidcdemo
cloud.google.com/go/compute/metadata v0.3.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-jose/go-jose/v4 v4.1.4
golang.org/x/oauth2 v0.36.0
สั่งไปสองบรรทัด แต่ได้มาสี่ module — สองตัวที่ไม่ได้สั่งคือ:
· go-jose/v4 เป็นตัวที่ทำงานเข้ารหัสจริง ๆ ให้ go-oidc (แกะ JWT, เช็คลายเซ็น)
· cloud.google.com/go/compute/metadata ติดมากับ oauth2 เพราะมันมีโค้ดฝั่ง Google รวมอยู่ในแพ็กเกจ
go build ไม่ผ่าน — ผมเจอกับตัว
go get ทั้งสองบรรทัดสำเร็จ ไม่มี error สักตัว แต่พอเขียนโค้ดเสร็จแล้วสั่ง build:
$ go build ./... ; echo "BUILD_EXIT=$?"
/Users/…/go-oidc/v3@v3.20.0/oidc/jose.go:3:8: missing go.sum entry for module providing
package github.com/go-jose/go-jose/v4 (imported by github.com/coreos/go-oidc/v3/oidc); to add:
go get github.com/coreos/go-oidc/v3/oidc@v3.20.0
BUILD_EXIT=1
$ go vet ./... ; echo "VET_EXIT=$?"
(ข้อความเดียวกันเป๊ะ)
VET_EXIT=1
สาเหตุ: สไลด์เขียน go get github.com/coreos/go-oidc/v3 ซึ่งเป็นชื่อ module
ไม่ใช่ชื่อpackage ที่โค้ดเรา import จริง (โค้ด import github.com/coreos/go-oidc/v3/oidc — มี /oidc ต่อท้าย)
Go เลยจดแค่ module ลง go.mod แต่ยังไม่ได้จด checksum ของ dependency ที่ package นั้นต้องใช้ลง go.sum
วิธีแก้ที่สั้นที่สุดและควรทำเป็นนิสัย คือสั่ง go mod tidy หลังเขียนโค้ดเสร็จ
— มันไล่อ่าน import จริงในโค้ดแล้วเติมทุกอย่างให้ครบ:
$ go mod tidy # ไม่พิมพ์อะไรออกมาเลย = เรียบร้อย
$ go build ./... ; echo "BUILD_EXIT=$?"
BUILD_EXIT=0
$ go vet ./... ; echo "VET_EXIT=$?"
VET_EXIT=0
$ cat go.mod
module oidcdemo
go 1.26.5
require (
github.com/coreos/go-oidc/v3 v3.20.0
golang.org/x/oauth2 v0.36.0
)
require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
สังเกตอีกอย่างใน go.mod หลัง tidy: cloud.google.com/go/compute/metadata หายไปแล้ว
เพราะ tidy ตัดของที่โค้ดเราไม่ได้ใช้จริงออก — เหลือแต่ go-jose ที่ติดป้าย
// indirect แปลว่า "เราไม่ได้ import เอง แต่ของที่เรา import ต้องใช้"
สี่คำนี้คือคำที่ dev ใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไปในหัวข้อนี้ — ใช้คำให้ตรง จะได้คำตอบที่ตรง
- OIDC (OpenID Connect)
- ชั้นที่วางทับ OAuth2 อีกที · OAuth2 ตอบว่า "ให้ทำอะไรได้บ้าง" ·
OIDC เติมคำตอบว่า "คนนี้คือใคร" เข้ามาด้วย
id_token· ประโยคที่ใช้ได้เลย: "อันนี้เป็น OAuth2 เฉย ๆ หรือ OIDC ด้วยครับ ผมต้องได้id_tokenด้วย" - discovery document / well-known endpoint
- ไฟล์ JSON ที่
issuer+/.well-known/openid-configurationซึ่งบอก URL ทุกตัวของ provider ในที่เดียว · ประโยคที่ใช้ได้เลย: "ขอ issuer URL ตัวเดียวพอครับ เดี๋ยวผมอ่าน well-known เอา ไม่ต้องส่ง endpoint มาทีละอัน" - indirect dependency
- ของที่เราไม่ได้
importเอง แต่ของที่เรา import ต้องใช้ · ในgo.modมันติดป้าย// indirectไว้ให้ · ประโยคที่ใช้ได้เลย: "go-joseเป็น indirect ของเรานะ มันมาทางgo-oidc" go mod tidy- คำสั่งที่ไล่อ่าน
importจริงในโค้ด แล้วจัดgo.mod/go.sumให้ตรงกับของที่ใช้จริง · พิมพ์ถาม AI ว่า "missing go.sum entry in Go, how to fix" คำตอบแรกจะเป็นคำสั่งนี้ — ซึ่งคือ error ที่กล่องข้างบนเจอเป๊ะ ๆ
3 · โค้ด OIDC ครบ flow — ที่ compile ผ่านจริง
สไลด์บอกแค่ให้ลง library แต่ไม่ได้ให้โค้ดมา ผมเลยเขียนให้ครบทั้งเส้นแล้ว
build ผ่านและ vet ผ่านจริง (ผลอยู่ในกล่องข้างบน)
ก่อนดูโค้ด ดูลำดับก่อน เพราะโค้ดจะอ่านง่ายขึ้นมาก — มันมีแค่ 6 ขั้น:
| # | ฟังก์ชัน | เกิดอะไรขึ้น |
|---|---|---|
| 1 | oidc.NewProvider(ctx, issuer) |
ยิง HTTP ไปที่ issuer + /.well-known/openid-configuration ตอนนั้นเลย แล้วจำ URL ทุกตัวไว้ |
| 2 | provider.Endpoint() |
คืน AuthURL กับ TokenURL ที่เพิ่งอ่านมา — เราไม่ต้อง hardcode URL เอง |
| 3 | oauth2.Config{…} |
ก้อนตั้งค่า: client id/secret, redirect URL, scope และ endpoint จากข้อ 2 |
| 4 | cfg.AuthCodeURL(state) |
ประกอบ URL หน้า login ให้ แล้วเรา redirect ผู้ใช้ไป |
| 5 | cfg.Exchange(ctx, code) |
ยิง POST ไป token_endpoint เอา code แลกเป็น token — ขั้นนี้เป็น server-to-server |
| 6 | verifier.Verify(ctx, rawIDToken) |
โหลด public key จาก jwks_uri มาตรวจลายเซ็นของ id_token แล้วคืน token ที่แกะได้ |
ทีนี้โค้ดจริง — ไฟล์ auth/auth.go ก้อนตั้งค่า
ก้อนนี้กับก้อนถัดไปคือไฟล์เดียวกัน วางต่อกันตามลำดับแล้ว go build ./... ผ่านเลย
(ต้องอยู่ในโฟลเดอร์ auth/ ให้ตรงกับบรรทัด package)
package auth
import (
"context"
"errors"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Claims คือ field ที่เราสนใจจาก id_token ของ Keycloak
type Claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
type Authenticator struct {
provider *oidc.Provider
oauth2 oauth2.Config
verifier *oidc.IDTokenVerifier
}
func New(ctx context.Context, issuer, clientID, clientSecret, redirectURL string) (*Authenticator, error) {
// ① ยิงไปอ่าน discovery document ตรงนี้เลย — ถ้า Keycloak ยังไม่ขึ้น บรรทัดนี้ error ทันที
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}
cfg := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(), // ② ไม่ hardcode URL
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return &Authenticator{
provider: provider,
oauth2: cfg,
verifier: provider.Verifier(&oidc.Config{ClientID: clientID}),
}, nil
}
oidc.ScopeOpenID ไม่ใช่ของประดับ — ขาดแล้วไม่มี id_token
oidc.ScopeOpenID คือค่าคงที่ที่มีค่าเป็นสตริง "openid"
ถ้าไม่ใส่ scope ตัวนี้ Keycloak จะถือว่านี่เป็นคำขอแบบ OAuth2 ธรรมดา ไม่ใช่ OIDC —
มันจะคืน access_token มาให้ตามปกติ แต่จะไม่มี id_token ในคำตอบ
อาการที่คุณจะเจอคือโค้ดขั้นที่ 6 พังด้วยข้อความว่าหา id_token ไม่เจอ
ทั้งที่ทุกอย่างก่อนหน้านั้นดูสำเร็จหมด
ต่อด้วยสองฟังก์ชันที่ handler เรียกใช้จริง — ขาไปกับขากลับ (ตัว handler เองอยู่ท้ายหัวข้อ 9)
ก้อนนี้เขียนต่อท้ายไฟล์ auth/auth.go เดิม จึงไม่มีบรรทัด package
และไม่มี import ซ้ำอีก — context กับ errors ที่ใช้ตรงนี้
ประกาศไว้ในก้อนบนแล้ว
// LoginURL คือ URL ที่เรา redirect ผู้ใช้ไปหน้า login ของ Keycloak
func (a *Authenticator) LoginURL(state string) string {
return a.oauth2.AuthCodeURL(state)
}
// Callback เอา code ที่ Keycloak ส่งกลับมาแลกเป็น token แล้ว verify id_token
func (a *Authenticator) Callback(ctx context.Context, code string) (*Claims, error) {
tok, err := a.oauth2.Exchange(ctx, code)
if err != nil {
return nil, err
}
// ⚠️ id_token ไม่ได้อยู่ใน tok.AccessToken — ต้องดึงจาก Extra
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token in token response")
}
idToken, err := a.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
var c Claims
if err := idToken.Claims(&c); err != nil {
return nil, err
}
return &c, nil
}
tok.Extra("id_token")
oauth2.Token ที่ Exchange คืนมามี field มาตรฐานอยู่ไม่กี่ตัว:
AccessToken, TokenType, RefreshToken, Expiry
ไม่มี field ชื่อ IDToken เพราะ id_token เป็นของ OIDC ไม่ใช่ของ OAuth2
ซึ่ง library oauth2 ไม่รู้จัก — มันเก็บ field ที่ไม่รู้จักไว้ในถุงรวมแล้วให้หยิบด้วย
tok.Extra(ชื่อ) ซึ่งคืน any จึงต้อง .(string) แปลงเอง
คนที่เพิ่งเริ่มมักเผลอเอา tok.AccessToken ไป Verify แล้วงงว่าทำไมชื่อกับอีเมลไม่ครบ
— เพราะมันคนละใบกันจริง ๆ
และ Verify คืน สองค่า คือ (*oidc.IDToken, error) ยังไม่ใช่ข้อมูลผู้ใช้ —
ต้องเรียก idToken.Claims(&dst) อีกทีถึงจะได้ field ออกมา
ทายก่อนรัน — ผมเขียนโปรแกรมเล็ก ๆ ที่เรียกแค่สามอย่าง โดยชี้ไป Google
แทน Keycloak (เพราะ Google เป็น OIDC provider เหมือนกันและเปิดสาธารณะ ใครก็ยิงได้)
ก้อนข้างล่างเป็นเศษโค้ดที่ตัดมาเฉพาะใจความ ไม่ใช่ไฟล์เต็ม — ก๊อปไปวางเปล่า ๆ ยัง build ไม่ผ่าน
ต้องมี package main · import (context, fmt,
oidc, oauth2) · func main() และประกาศ ctx กับ err ครอบไว้ก่อน
provider, _ := oidc.NewProvider(ctx, "https://accounts.google.com")
ep := provider.Endpoint()
fmt.Println("AuthURL =", ep.AuthURL)
fmt.Println("TokenURL =", ep.TokenURL)
cfg := oauth2.Config{ClientID: "my-client-id", RedirectURL: "http://localhost:8080/auth/callback",
Endpoint: ep, Scopes: []string{oidc.ScopeOpenID, "profile", "email"}}
fmt.Println("AuthCodeURL =", cfg.AuthCodeURL("state-abc123"))
verifier := provider.Verifier(&oidc.Config{ClientID: "my-client-id"})
_, err = verifier.Verify(ctx, "this-is-not-a-jwt") // จงใจส่งขยะเข้าไป
ทายสองข้อ
1. TokenURL ของ Google จะขึ้นต้นด้วย https://accounts.google.com/... เหมือน AuthURL ไหม?
2. ตอนส่งสตริง "this-is-not-a-jwt" เข้า Verify —
error ที่ได้จะเป็นข้อความของ go-oidc เอง หรือหลุดมาจาก library ชั้นล่าง?
$ go run ./cmd/probe
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
ข้อ 1 — คนละโดเมนกัน AuthURL อยู่ที่ accounts.google.com
แต่ TokenURL อยู่ที่ oauth2.googleapis.com
นี่คือเหตุผลที่ provider.Endpoint() มีค่ามาก: ถ้า hardcode เองคุณจะเดาผิดแน่นอน
และเวลา provider ย้ายโดเมน โค้ดเราไม่ต้องแก้เลยเพราะมันอ่านใหม่ทุกครั้งที่ start
ข้อ 2 — error ไหลมาจากชั้นล่าง ข้อความมีสองท่อน: ท่อนหน้า
oidc: malformed jwt: เป็นของ go-oidc ส่วนท่อนหลัง
go-jose/go-jose: compact JWS format must have three parts มาจาก go-jose/v4 ตรง ๆ
มันกำลังบอกว่า JWT ที่ถูกต้องต้องมีสามท่อนคั่นด้วยจุด (header.payload.signature)
แต่สตริงที่เราส่งไปไม่มีจุดเลย
นี่เป็นตัวอย่างที่ดีของเวลาอ่าน error ในโปรเจกต์จริง — ชื่อ library ที่อยู่หน้าข้อความคือเบาะแสว่าใครเป็นคนบ่น
สังเกต AuthCodeURL ด้วย: redirect_uri ถูก encode เป็น
http%3A%2F%2Flocalhost%3A8080%2F… ให้อัตโนมัติ และ scope สามตัวถูกรวมด้วยเครื่องหมาย +
— นี่คืองานที่ oauth2 ทำแทนเรา
🧭 state คืออะไร ทำไมต้องมี —
state เป็นสตริงสุ่มที่เราสร้างตอนส่งผู้ใช้ไป login แล้ว Keycloak จะส่งกลับมาให้เหมือนเดิมตอน callback
หน้าที่ของมันคือเช็คว่า callback นี้มาจากคำขอที่เราเป็นคนเริ่มจริง ๆ
ไม่ใช่ใครยิง URL callback ใส่ผู้ใช้ของเราเอง (การโจมตีแบบนี้เรียกว่า CSRF)
ในตัวอย่างข้างบนผมใส่ "state-abc123" ตายตัวเพราะเป็นแค่การทดสอบ —
ของจริงต้องสุ่มใหม่ทุกครั้ง เก็บไว้ใน session แล้วเทียบตอนกลับมา
โค้ดที่ทำแบบนั้นจริงอยู่ท้ายหัวข้อ 9 (ฟังก์ชัน Login กับ Callback)
4 · ตั้ง goose ใน .env — และ DSN ในสไลด์ที่ต้องรู้ก่อนพิมพ์ตาม
goose คือเครื่องมือ migration — โปรแกรมที่รันไฟล์ SQL ตามลำดับแล้วจำไว้ว่ารันถึงไฟล์ไหนแล้ว
ทำให้ทุกคนในทีมและทุกเครื่องมีโครงสร้างตารางเหมือนกัน
สไลด์หน้า 50 บอกให้ใส่สามบรรทัดนี้ในไฟล์ .env:
# Setup .env file
# Goose
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok
สองบรรทัดแรกตรงไปตรงมา — บอกว่าไฟล์ migration อยู่โฟลเดอร์ไหน และใช้ฐานข้อมูลชนิดไหน
บรรทัดที่สามคือ DSN (Data Source Name — สตริงเส้นเดียวที่บอกว่าจะต่อฐานข้อมูลที่ไหน ด้วย user อะไร รหัสอะไร)
และมันมีสองจุดที่ผิดหลักการเขียน URL:
| จุด | สไลด์เขียน | ปัญหาคืออะไร |
|---|---|---|
| ก | รหัสผ่าน Pe@devp00l ใส่ดิบ ๆ |
ตัว @ ใน URL คือตัวคั่นระหว่างรหัสผ่านกับชื่อ host — พอรหัสผ่านมี @ อยู่ในตัว จะมี @ สองตัวในสตริงเดียว แล้วโปรแกรมต้องเดาว่าตัวไหนเป็นตัวคั่น |
| ข | ?database=wongnok |
ชื่อฐานข้อมูลตามมาตรฐาน PostgreSQL URI ต้องอยู่ในส่วน path คือ /wongnok ไม่ใช่ query parameter |
ทายก่อนรัน — นี่เป็นข้อที่ผมทายผิดเองตอนแรก
ถ้าเอา DSN บรรทัดนั้นไปใช้จริง ๆ กับ goose up คุณคิดว่าจะเกิดอะไร?
ก. goose พังทันที parse DSN ไม่ผ่าน
ข. goose ผ่าน แต่ไปสร้างตารางผิดฐาน (ลงฐาน postgres แทน wongnok)
ค. goose ผ่านและลงฐาน wongnok ถูกต้องทุกอย่าง
และคำถามที่สำคัญกว่า: ถ้าเอา DSN บรรทัดเดียวกันนี้ ไปแปะใส่ psql
เพื่อเข้าไปส่องตารางที่เพิ่งสร้าง จะเกิดอะไรขึ้น?
ฝั่ง Go: ผ่านหมด ไม่มี error สักตัว ทั้งสามจุดที่ผมเช็ค —
=== DSN: postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok
net/url.Parse -> user="postgres" password="Pe@devp00l" hasPassword=true host="localhost:5432" path="" 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' ===
List of relations
Schema | Name | Type | Owner
--------+------------------+-------+----------
public | goose_db_version | table | postgres
public | users | table | postgres
(2 rows)
คำตอบคือ ข้อ ค — ตารางไปลงฐาน wongnok ถูกต้อง ฐาน postgres ว่างเปล่า
สาเหตุมีสองข้อ: net/url ของ Go ตัดที่ @ ตัวสุดท้าย
รหัสผ่านเลยออกมาถูก · และ pgx บังเอิญรู้จักคีย์ชื่อ database อยู่แล้ว
(ภายในมันใช้ชื่อนี้ ส่วน dbname เป็นชื่อพ้อง)
ฝั่ง 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"
อันแรกคือของแสบจริง: libpq ตัดที่ @ ตัวแรก ตรงข้ามกับ Go
มันเลยอ่านว่า host ชื่อ devp00l@localhost ซึ่งไม่มีอยู่ในโลก
อันที่สองคือ psql ปฏิเสธคีย์ database= ตรง ๆ เพราะมันไม่รู้จัก —
คีย์นี้เป็นของฝั่ง Go เท่านั้น
ถ้าคุณลอกสไลด์ไปเป๊ะ ๆ ทุกอย่างจะดูปกติดี — goose up ผ่าน แอปต่อฐานได้
คุณจะไม่รู้เลยว่ามีอะไรผิด
แล้ววันหนึ่งคุณอยากเข้าไปดูข้อมูลในตาราง ก็ทำสิ่งที่ทุกคนทำ: ก๊อป DSN จาก .env ไปแปะใส่ psql
ด่านแรก คุณจะได้ invalid URI query parameter: "database" —
อันนี้ยังพอเดาออก เพราะ error ชี้ตรงไปที่ ?database= ที่ท้ายบรรทัด
พอแก้ตามที่ error ใบ้ (ย้าย ?database=wongnok ไปเป็น path /wongnok) แล้วสั่งใหม่ —
ด่านสองถึงจะโผล่: could not translate host name "devp00l@localhost"
ซึ่งไม่มีคำว่ารหัสผ่านอยู่ในข้อความเลย
คุณจะไปนั่งไล่ดู DNS ไล่ดู /etc/hosts ไล่ดูว่า Postgres รันอยู่ไหม
DSN ที่ถูก — ผ่านทั้ง Go และ psql:
GOOSE_DBSTRING=postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable
$ psql "postgres://postgres:Pe%40devp00l@localhost:5432/wongnok" -c "select current_database()"
current_database
------------------
wongnok
(1 row)
แก้สองที่: @ ในรหัสผ่านเขียนเป็น %40 · ชื่อฐานย้ายไปเป็น path /wongnok
กฎที่ควรจำติดตัว: อักขระพิเศษในรหัสผ่านต้อง URL-encode เสมอ —
ตัวที่เจอบ่อยคือ @ → %40 · : → %3A ·
/ → %2F · # → %23
DSN คือที่อยู่ของฐานข้อมูลที่ยัดทุกอย่างไว้ในบรรทัดเดียว —
ใครเข้า รหัสอะไร เครื่องไหน พอร์ตอะไร ฐานชื่อไหน
แล้วใช้เครื่องหมายเป็นตัวคั่นว่าตรงไหนจบเรื่องอะไร · @ คือตัวคั่นที่แปลว่า
"ก่อนหน้านี้คือชื่อกับรหัสผ่าน หลังจากนี้คือที่อยู่เครื่อง"
ทีนี้ลองนึกว่าเขียนหน้าซองว่า "สมชาย, ร้านข้าวมันไก่, ซอย 5"
แล้วระบบเขาใช้ลูกน้ำเป็นตัวคั่นชื่อผู้รับกับที่อยู่พอดี
พนักงานคนหนึ่งอาจตัดที่ลูกน้ำตัวแรก (ผู้รับ = "สมชาย")
อีกคนตัดที่ลูกน้ำตัวสุดท้าย (ผู้รับ = "สมชาย, ร้านข้าวมันไก่")
— ข้อความเดียวกัน แต่ได้ผลคนละอย่าง
นี่คือสิ่งที่เกิดขึ้นจริงในหัวข้อนี้: รหัสผ่าน Pe@devp00l มี @ อยู่ในตัวเอง
Go ตัดที่ @ ตัวสุดท้าย จึงรอด · libpq ที่ psql ใช้ตัดที่ตัวแรก จึงพัง
ที่ไม่เหมือนกัน: พนักงานไปรษณีย์อ่านแล้วเอะใจได้เองว่าอันไหนน่าจะเป็นชื่อคน
แต่โปรแกรมไม่เดา มันทำตามกฎที่เขียนไว้ตายตัว และกฎของสองฝั่งนี้เขียนไว้คนละแบบจริง ๆ
ทางแก้จึงไม่ใช่ "เขียนให้อ่านง่ายขึ้น" หรือ "ครอบด้วยเครื่องหมายคำพูด" —
แต่คือทำให้ไม่มี @ ดิบเหลืออยู่ในสตริงเลย ด้วยการเขียนเป็น %40
ซึ่งทั้งสองฝั่งอ่านตรงกันว่า "นี่คือตัวอักษร ไม่ใช่ตัวคั่น"
- DSN (Data Source Name)
- สตริงเส้นเดียวที่บอกครบว่าจะต่อฐานไหน ที่เครื่องไหน ด้วยใคร รหัสอะไร · ประโยคที่ใช้ได้เลย: "ขอ DSN ของ staging หน่อยครับ" — สั้นกว่าไล่ถามทีละช่อง และอีกฝ่ายจะรู้ทันทีว่าต้องส่งอะไรมาให้
- percent-encoding (URL-encode)
- วิธีเขียนอักขระที่มีความหมายพิเศษให้กลายเป็นตัวอักษรธรรมดา โดยแทนด้วย
%ตามด้วยเลขฐานสิบหก —@เขียนเป็น%40· พิมพ์ถาม AI ว่า "url-encode special characters in postgres connection string password" จะได้คำตอบตรงจุด - driver
- ตัวกลางที่แปลคำสั่งจากภาษาเราให้เป็นภาษาที่ฐานข้อมูลฟังรู้เรื่อง ·
ฝั่ง Go มีหลายตัวและตีความ DSN ไม่เหมือนกัน —
pgxกับlib/pqคือคนละตัว (ผลรันข้างบนโชว์ทั้งคู่) · ส่วนpsqlไม่ได้ใช้ของฝั่ง Go เลย มันใช้libpqที่เป็นไลบรารีภาษา C - sslmode
- ตัวเลือกท้าย DSN ที่บอกว่าจะบังคับเข้ารหัสการเชื่อมต่อหรือไม่ ·
sslmode=disableที่เห็นในบรรทัดที่ถูกคือ ปิด — ใช้ได้เฉพาะฐานที่รันบนเครื่องตัวเอง · ประโยคที่ใช้ได้เลย: "ของ production ต้องตั้ง sslmode เป็นอะไรครับ"
goose อ่าน .env ให้เองไหม หรือต้อง export
บทเรียน 44 ย้ำว่าตัวแปรใน shell ที่ไม่ได้ export จะไม่ถึงโปรแกรมลูก
คำถามคือ goose ที่เป็น CLI แยกตัว จะเห็นค่าใน .env ได้ยังไง
ผมทดสอบด้วยการลบตัวแปรทั้งสามตัวออกจาก environment ให้หมดก่อน แล้วสั่ง goose status:
$ env -u GOOSE_DRIVER -u GOOSE_DBSTRING -u GOOSE_MIGRATION_DIR goose status # รันในโฟลเดอร์ที่มี .env
2026/08/22 10:15:27 Applied At Migration
2026/08/22 10:15:27 =======================================
2026/08/22 10:15:27 Sat Aug 22 03:14:26 2026 -- 20260822031131_create_users.sql
ทำงานได้ทั้งที่ไม่มี env สักตัว แล้วพอย้ายไฟล์ .env ออกจากโฟลเดอร์ รันคำสั่งเดิม:
Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND
...
-env string
load environment variables from file (default .env)
คำตอบอยู่ในบรรทัดสุดท้าย: goose CLI มี flag -env ที่ค่าเริ่มต้นคือ .env
มันจึงโหลดไฟล์นี้ให้เองอัตโนมัติ
เงื่อนไขเดียวคือต้องรันในโฟลเดอร์ที่มีไฟล์ .env อยู่ —
cd ไปที่อื่นแล้วสั่ง goose จะเจอหน้า usage แบบข้างบนทันที
ซึ่งเป็นอาการที่คนเจอบ่อยแล้วนึกว่า goose พัง
5 · สร้างไฟล์ migration
สไลด์หน้า 50 ครึ่งล่างมีสองคำสั่ง:
# Make directory
mkdir migrations
# Create migration
goose create create_users sql
$ goose create create_users sql
2026/08/22 10:11:31 Created new file: migrations/20260822031131_create_users.sql
ไฟล์ที่ได้มีแค่นี้ — เป็นโครงเปล่าที่รอเราเติม:
-- +goose Up
SELECT 'up SQL query';
-- +goose Down
SELECT 'down SQL query';
บรรทัด -- +goose Up และ -- +goose Down หน้าตาเหมือน comment ของ SQL
แต่จริง ๆ เป็นคำสั่งที่ goose อ่าน — มันแบ่งไฟล์เป็นสองส่วน:
ส่วน Up รันตอน goose up (เดินหน้า) ส่วน Down รันตอน goose down (ถอยกลับ)
ห้ามลบสองบรรทัดนี้ ไม่งั้น goose จะไม่รู้ว่าจะรันอะไร
📌 เลขหน้าชื่อไฟล์คือเวลา UTC ไม่ใช่เวลาไทย —
20260822031131 อ่านว่า 2026-08-22 เวลา 03:11:31
แต่นาฬิกาบนเครื่องตอนนั้นคือ 10:11:31 (ไทยเร็วกว่า UTC 7 ชั่วโมง)
ที่ goose ทำแบบนี้เพราะเลขนี้คือลำดับการรัน — ทีมที่อยู่คนละ timezone
ต้องได้ลำดับตรงกัน ถ้าใช้เวลาท้องถิ่นของแต่ละคน ไฟล์จะสลับลำดับกันมั่ว
6 · เขียน migration create_users แล้วรันจริง
เนื้อ migration ตามสไลด์หน้า 51 — ผมพิมพ์ตามทุกบรรทัดแล้วรันจริง:
image_url
ในสไลด์ฉบับก่อนเรียน ตาราง users ไม่มีคอลัมน์นี้
ฉบับหลังเรียนแทรก image_url TEXT ไว้ใต้ uid —
และมันไม่ได้โผล่มาเฉย ๆ
สไลด์หน้า 65 (โจทย์สอบกลางภาค) สั่งว่า PUT /api/v1/users/me
ต้องแก้ได้แค่ bio กับ imageUrl เท่านั้น
แปลว่าคอลัมน์นี้ถูกเติมมาเพื่อรองรับโจทย์สอบโดยเฉพาะ —
ถ้าไม่มี คุณจะเขียนข้อนั้นไม่ได้ (รายละเอียดโจทย์อยู่ในบทที่ 55)
-- +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 UNIQUE INDEX idx_users_uid ON users (uid);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
-- +goose Down
DROP TABLE IF EXISTS users;
goose up แล้วเข้าไปส่องด้วย psql ว่าได้ตารางหน้าตายังไงจริง ๆ:
🧰 เครื่องผมไม่มี psql ติดตั้งบน host (which psql ไม่เจอ)
ผมเลยรัน psql ทุกคำสั่งในบทนี้ผ่านคอนเทนเนอร์ Postgres ตัวเดียวกับที่ goose ต่อ —
ตอน docker run ผมตั้งชื่อไว้ว่า day9check (docker run --name day9check …)
คำสั่งจึงขึ้นต้นด้วย docker exec day9check psql … ทุกอัน
ถ้าเครื่องคุณมี psql อยู่แล้ว ตัด docker exec day9check ข้างหน้าออกได้เลย
แต่ต้องเติมปลายทางให้ครบเสมอ (-U postgres -d wongnok หรือใส่ DSN เต็ม) —
psql -c '…' เปล่า ๆ จะไปต่อฐานที่ชื่อเดียวกับ username ของคุณ ไม่ใช่ wongnok
$ docker exec day9check 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)
ตรงกับ SQL ในสไลด์ทุกบรรทัด สังเกตสองอย่าง:
· TIMESTAMPTZ โผล่ออกมาเป็น timestamp with time zone — มันคือชื่อเดียวกัน แค่เขียนย่อ
· คอลัมน์ id ไม่มี Default (ช่องขวาสุดว่าง) — จำจุดนี้ไว้ เดี๋ยวหัวข้อ 8 จะกลับมาที่บรรทัดนี้
up → down → up ให้ผลยังไง
$ goose status # หลัง up
2026/08/22 10:11:57 Applied At Migration
2026/08/22 10:11:57 =======================================
2026/08/22 10:11:57 Sat Aug 22 03:11:57 2026 -- 20260822031131_create_users.sql
$ goose down
2026/08/22 10:14:26 OK 20260822031131_create_users.sql (5.22ms)
$ goose status # หลัง down
2026/08/22 10:14:26 Applied At Migration
2026/08/22 10:14:26 =======================================
2026/08/22 10:14:26 Pending -- 20260822031131_create_users.sql
$ goose up
2026/08/22 10:14:26 OK 20260822031131_create_users.sql (8.73ms)
2026/08/22 10:14:26 goose: successfully migrated database to version: 20260822031131
$ goose version
2026/08/22 10:14:26 goose: version 20260822031131
สามอย่างที่ควรสังเกตจากผลนี้:
1. goose down ไม่พิมพ์บรรทัด successfully migrated… เหมือน up
มันเงียบกว่า อย่าตกใจว่าไม่ทำงาน
2. down ถอยแค่หนึ่งขั้น ไม่ใช่ถอยหมดทุกไฟล์ — อยากถอยหมดต้องใช้ goose reset
3. คอลัมน์ Applied At โชว์ 03:11:57 ทั้งที่ตอนรันนาฬิกาเป็น 10:11:57
Applied At เป็นเวลา UTC — ต่างจากเวลาไทย 7 ชั่วโมง
goose จดสถานะไว้ในตารางชื่อ goose_db_version ที่มันสร้างเองในฐานเดียวกัน เปิดดูได้:
$ docker exec day9check psql -U postgres -d wongnok -c 'select * from goose_db_version;' -c 'show timezone;'
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
(1 row)
$ date
Sat Aug 22 10:15:38 +07 2026
คอลัมน์ tstamp เป็นชนิด timestamp ที่ไม่มี time zone ติดมา
และเก็บเวลา UTC — คนที่รันในไทยจะเห็นเวลาย้อนหลังไป 7 ชั่วโมงเสมอ แล้วสงสัยว่าทำไม "เพิ่งรันไป"
ถึงโชว์เวลาเมื่อเช้า
อีกจุดที่น่าสังเกต: id กระโดดจาก 1 ไป 3 ไม่มี 2
เพราะตอน goose down มันลบแถวทิ้งจริง ๆ ไม่ได้แค่เปลี่ยน is_applied เป็น false
(แถว version_id = 0 คือแถวที่ goose สร้างไว้เป็นหมุดเริ่มต้นตอนตั้งตารางครั้งแรก)
7 · ทำไมต้องมี uid ทั้งที่มี id อยู่แล้ว
ตารางนี้มีคอลัมน์ที่หน้าตาเหมือนรหัสประจำตัวอยู่สองตัว ซึ่งเป็นจุดที่คนอ่านสไลด์แล้วงงที่สุด
ทั้งสองตัวไม่ใช่ของซ้ำกัน มันเป็นรหัสของคนละระบบ:
id | uid | |
|---|---|---|
| ใครเป็นคนตั้งค่า | แอปเราเอง (UUID ที่เราสร้าง) | Keycloak — คือค่า sub ที่อยู่ใน token |
| เอาไว้ทำอะไร | เป็น primary key · ให้ตารางอื่น (recipes, ratings) ชี้มาด้วย foreign key |
เป็นสะพานเชื่อมระหว่างคนใน token กับแถวในตารางเรา |
| ใช้ตอนไหน | ทุกครั้งที่ join ตาราง | ตอน login — เอา sub จาก token มาค้นว่า WHERE uid = ? |
| ถ้าย้าย identity provider | ไม่กระทบ ข้อมูลทุกตารางยังชี้ถูก | ต้อง map ใหม่ เพราะ sub ของเจ้าใหม่เป็นคนละค่า |
แถวสุดท้ายคือเหตุผลจริง ๆ ที่ต้องแยกสองคอลัมน์
ลองคิดตามว่าถ้าเราขี้เกียจ แล้วเอา sub ของ Keycloak มาเป็น primary key ตรง ๆ เลย
วันหนึ่งบริษัทเปลี่ยนไปใช้ Google Workspace หรือย้าย Keycloak ไปอีก realm —
sub ของทุกคนเปลี่ยนหมด แปลว่า primary key เปลี่ยนหมด
แปลว่าทุก foreign key ในทุกตารางที่ชี้มาหา user พังหมดพร้อมกัน
พอแยกเป็นสองคอลัมน์ วันนั้นเราแค่ UPDATE users SET uid = ... — ข้อมูลอื่นไม่รู้เรื่องด้วยเลย
นี่เป็นรูปแบบที่มีชื่อเรียกว่า internal ID กับ external ID และใช้กับทุกอย่างที่มาจากข้างนอก
ไม่ใช่แค่ผู้ใช้ — เช่น stripe_customer_id หรือ line_user_id ก็ตรรกะเดียวกัน
โรงเรียนออกเลขประจำตัวนักเรียนให้คุณเอง แล้วใช้เลขนี้ในเอกสารภายในทุกใบ —
ใบเช็คชื่อ ใบเกรด ใบยืมหนังสือห้องสมุด ทุกใบอ้างเลขนี้
ส่วนเลขบัตรประชาชน โรงเรียนไม่ได้เป็นคนตั้ง มันมาจากข้างนอก
โรงเรียนแค่จดไว้ในทะเบียนช่องหนึ่ง เพื่อจะได้จับคู่คุณกับตัวตนที่ออกมาจากข้างนอกได้ถูกคน
id = เลขประจำตัวนักเรียน — แอปเราตั้งเอง และทุกตาราง
(recipes, ratings) ชี้มาที่เลขนี้
uid = เลขบัตรประชาชน — Keycloak เป็นคนออก (ก็คือ claim sub)
เราแค่จดไว้เพื่อใช้ตอน login ว่า "คนที่ถือ token ใบนี้ คือแถวไหนในตารางเรา"
ที่ไม่เหมือนกัน: เลขบัตรประชาชนไทยติดตัวไปทั้งชีวิต ย้ายโรงเรียนกี่ครั้งก็เลขเดิม —
แต่ sub ของ Keycloak ผูกอยู่กับ realm และ provider ตัวนั้น
วันที่ย้ายไป identity provider เจ้าใหม่ ทุกคนได้เลขใหม่หมด
ตรงนี้แหละคือเหตุผลที่ห้ามเอา sub มาเป็น primary key ตรง ๆ —
ไม่ใช่เพราะมันสั้นไปยาวไป แต่เพราะมันเป็นเลขที่คนอื่นเป็นเจ้าของ และเปลี่ยนได้โดยที่เราไม่ได้สั่ง
- primary key
- คอลัมน์ที่ใช้ระบุแถวในตารางนี้แบบไม่ซ้ำ · ในตารางเราคือ
id· ประโยคที่ใช้ได้เลย: "primary key ของตารางนี้เป็น UUID ที่เราสร้างเองนะ ไม่ได้เอามาจาก Keycloak" - foreign key
- คอลัมน์ในตารางหนึ่งที่ชี้ไปหา primary key ของอีกตาราง ·
ประโยคที่ใช้ได้เลย: "
recipes.user_idเป็น foreign key ไปหาusers.idนะ ไม่ใช่ไปหาuid" — พูดแบบนี้อีกฝ่ายจะเห็นภาพทันทีว่าทำไมสองคอลัมน์นี้ห้ามสลับกัน - internal ID / external ID
- ชื่อเรียกรูปแบบที่เพิ่งใช้ไป: เก็บรหัสจากระบบข้างนอกไว้คนละคอลัมน์กับ primary key ของเราเอง · พิมพ์ถาม AI ว่า "internal id vs external id pattern with an external identity provider" จะเจอทั้งเหตุผลและตัวอย่าง schema
- identity provider (IdP)
- ระบบที่รับหน้าที่ยืนยันตัวตนแทนแอปเรา — Keycloak, Google, LINE เป็น IdP ทั้งนั้น · ประโยคที่ใช้ได้เลย: "ถ้าวันหนึ่งเราย้าย IdP ตารางไหนต้อง map ใหม่บ้างครับ"
- claim
- ช่องข้อมูลหนึ่งช่องใน JWT —
sub,email,preferred_usernameแต่ละตัวเรียกว่า claim หนึ่งตัว · ประโยคที่ใช้ได้เลย: "token ที่ Keycloak ออกให้ มี claimemailติดมาด้วยไหมครับ" (หัวข้อ 9 จะบอกว่าคำตอบขึ้นกับ scope ที่ขอไป)
แล้วทำไม uid ต้อง UNIQUE
ตอน login โค้ดจะทำสิ่งนี้: เอา sub จาก id_token ไปค้นว่า
"คนนี้เคยมาแล้วหรือยัง" ด้วย WHERE uid = ?
ถ้าคำค้นนี้คืนมาสองแถว โค้ดจะทำอะไรถูก? แถวไหนคือ "เขา"?
สูตรอาหารที่เขาเขียนไว้ผูกกับแถวไหน?
UNIQUE คือการบังคับให้คำถามนี้มีคำตอบเดียวเสมอ ในระดับฐานข้อมูล —
ไม่ได้ฝากไว้กับความระวังของโค้ด
และมันสำคัญเพราะ race condition — สถานการณ์ที่สองคำขอวิ่งพร้อมกันแล้วเหยียบกัน
ผู้ใช้กดปุ่ม login รัว ๆ สองครั้ง เกิดสอง request พร้อมกัน ทั้งคู่ค้นแล้วไม่เจอ (เพราะยังไม่มีใครเขียน)
ทั้งคู่จึงสั่ง INSERT
ถ้าไม่มี UNIQUE คุณได้ผู้ใช้ซ้ำสองแถวเงียบ ๆ · ถ้ามี ฐานข้อมูลจะปฏิเสธแถวที่สอง
แล้วโค้ดค่อยจับ error นั้นแล้วไปอ่านแถวที่มีอยู่แทน
index ยังทำให้การค้นเร็วขึ้นด้วย เพราะทุก request ที่มี token ต้องค้นด้วย uid —
ถ้าไม่มี index ฐานข้อมูลต้องไล่อ่านทั้งตารางทุกครั้ง
8 · เรื่องที่สไลด์ไม่ได้พูด — UNIQUE ชนกับ soft delete
ก่อนอื่นนิยามศัพท์: soft delete คือการ "ลบ" ที่ไม่ได้ลบจริง —
แทนที่จะ DELETE แถวทิ้ง เราแค่ประทับเวลาลงคอลัมน์ deleted_at
แล้วให้ทุก query กรองแถวที่มีค่าออกไป
ข้อดีคือกู้คืนได้ และข้อมูลที่ผูกอยู่ (สูตรอาหารของคนนี้) ไม่หายตามไปด้วย
คอลัมน์ deleted_at TIMESTAMPTZ กับ index idx_users_deleted_at ในสไลด์
คือรูปแบบที่ GORM รู้จักโดยตรง ผมยืนยันด้วยการรันจริง:
// field ที่ต้องประกาศฝั่ง GORM — ชนิดต้องเป็น gorm.DeletedAt เท่านั้น ไม่ใช่ *time.Time
DeletedAt gorm.DeletedAt `gorm:"index:idx_users_deleted_at"`
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
บรรทัด 3 กับ 4 คือหัวใจ: Count() ธรรมดาได้ 0 (มองไม่เห็นแล้ว)
แต่ Unscoped().Count() ได้ 1 — แถวยังอยู่ในตารางจริง ๆ
GORM แค่แอบเติม WHERE deleted_at IS NULL ให้ทุก query
ทายก่อนรัน — ข้อนี้คือข้อที่สไลด์ข้ามไป และมันจะกัดคุณตอน production
ลำดับเหตุการณ์สามขั้น ทุกขั้นเป็นเรื่องปกติที่เกิดได้จริง:
1. ผู้ใช้ sub = "oidc-sub-123" login ครั้งแรก -> INSERT แถวใหม่ uid = 'oidc-sub-123'
2. ผู้ใช้กด "ลบบัญชี" -> UPDATE deleted_at = now() (soft delete)
3. ผู้ใช้คนเดิม เปลี่ยนใจ login กลับเข้ามาใหม่ -> ???
ขั้นที่ 3 โค้ดจะค้นด้วย WHERE uid = 'oidc-sub-123' ก่อน — ค้นเจอไหม?
แล้วถ้าค้นไม่เจอ มันจะ INSERT แถวใหม่ — INSERT ผ่านไหม?
(ใบ้: sub ของ Keycloak ไม่เคยเปลี่ยนตลอดชีพของ user คนนั้น ·
และ index idx_users_uid เป็น UNIQUE ธรรมดา ไม่มีเงื่อนไขอะไรต่อท้าย)
ค้นไม่เจอ และ INSERT ไม่ผ่าน — ผู้ใช้คนนี้ติดตาย กลับเข้ามาไม่ได้อีกเลย
SQL ล้วน ๆ ก่อน เพื่อให้เห็นว่าไม่ใช่ความผิดของ ORM:
INSERT 0 1 # ขั้น 1 สมัครครั้งแรก
UPDATE 1 # ขั้น 2 soft delete
uid | soft_deleted
--------------+--------------
oidc-sub-123 | t # แถวยังอยู่ในตาราง แค่ถูกทำเครื่องหมายไว้
(1 row)
# ขั้น 3 สมัครกลับเข้ามาด้วย uid เดิม
ERROR: duplicate key value violates unique constraint "idx_users_uid"
DETAIL: Key (uid)=(oidc-sub-123) already exists.
ฝั่ง GORM ก็เหมือนกัน และตัวที่โค้ด OIDC ชอบใช้ที่สุด (FirstOrCreate) เจ็บกว่าเพื่อน:
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 ถูกเงื่อนไข deleted_at IS NULL ที่ GORM แอบเติมให้ทุก query กรองออก
เลยหาไม่เจอ
(GORM เรียกตัวกรองอัตโนมัตินี้ว่า scope — คนละความหมายกับ scope ของ OAuth
ในหัวข้อ 3 ที่แปลว่า "ขอสิทธิ์อะไรบ้าง" ชื่อพ้องกันเฉย ๆ)
ท่อน Create จึงทำงาน แล้วไปชน unique ของแถวที่ถูกลบ ซึ่งยังนอนอยู่ในตาราง
ผลคือทางตัน: หาก็ไม่เจอ สร้างก็ไม่ได้ — และมันเกิดกับผู้ใช้ที่เคยลบบัญชีทุกคน
ทางแก้: partial unique index — บังคับ unique เฉพาะแถวที่ยังไม่ถูกลบ
PostgreSQL ให้เติม WHERE ท้าย index ได้ แปลว่า "กฎข้อนี้ใช้กับแถวที่เข้าเงื่อนไขนี้เท่านั้น"
DROP INDEX idx_users_uid;
CREATE UNIQUE INDEX idx_users_uid ON users (uid) WHERE deleted_at IS NULL;
ผลรันหลังแก้ — ลอง INSERT ซ้ำสองรอบติดกัน:
DROP INDEX
CREATE INDEX
INSERT 0 1 # รอบแรก: สมัครใหม่หลัง soft delete → ผ่านแล้ว
uid | active
--------------+--------
oidc-sub-123 | f # แถวเก่าที่ถูกลบ
oidc-sub-123 | t # แถวใหม่ที่ยัง active
(2 rows)
ERROR: duplicate key value violates unique constraint "idx_users_uid"
DETAIL: Key (uid)=(oidc-sub-123) already exists. # รอบสอง: ยังกัน uid ซ้ำในแถวที่ active อยู่
ได้ทั้งสองอย่างพร้อมกัน: สมัครกลับเข้ามาได้ และ ยังกันซ้ำในหมู่คนที่ยัง active
ฝั่ง GORM เขียนเป็น tag แบบนี้:
gorm:"uniqueIndex:idx_users_uid,where:deleted_at IS NULL"
ถ้าจะแก้ในโปรเจกต์จริง อย่าไปแก้ไฟล์ migration เดิมที่รันไปแล้ว —
ให้สร้าง migration ใหม่ที่ DROP INDEX แล้ว CREATE ใหม่ (คนอื่นในทีมรันไฟล์เดิมไปแล้ว
แก้ไฟล์เดิมจะทำให้ฐานของแต่ละคนไม่เหมือนกัน)
คุณเคยสมัครสมาชิกร้านไว้ด้วยเบอร์มือถือ วันหนึ่งขอยกเลิกสมาชิก
พนักงานไม่ได้ลบประวัติทิ้ง เขาแค่กดปุ่มให้บัญชีนั้นเป็น "ยกเลิกแล้ว"
เพราะประวัติการซื้อยังต้องเก็บไว้
วันหลังคุณเปลี่ยนใจ กลับมาสมัครใหม่ด้วยเบอร์เดิม — แล้วเจอสองด่านนี้:
· พนักงานค้นเบอร์คุณในหน้าจอ หาไม่เจอ เพราะหน้าค้นหากรองบัญชีที่ยกเลิกออกไปหมดแล้ว
· พนักงานเลยกดสมัครใหม่ให้ ระบบเด้งว่า "เบอร์นี้มีสมาชิกอยู่แล้ว"
เพราะกฎห้ามเบอร์ซ้ำยังนับรวมบัญชีที่ยกเลิกด้วย
หาก็ไม่เจอ สมัครก็ไม่ได้ — นี่คือเหตุการณ์เดียวกับ FirstOrCreate เป๊ะ ๆ:
ท่อน First โดน deleted_at IS NULL กรองออก ·
ท่อน Create ไปชน idx_users_uid ของแถวที่ถูกลบซึ่งยังนอนอยู่ในตาราง
ที่ไม่เหมือนกัน: ที่ร้านคุณเรียกผู้จัดการมาปลดล็อกให้ได้ในนาทีนั้น
แต่ unique index ไม่มีใครมาปลดให้ตอนโปรแกรมกำลังรัน มันเป็นกฎที่ฝังอยู่ในตัวฐานข้อมูล
ทางออกจึงมีแค่สองทาง และทั้งคู่ต้องลงมือเขียนเอง: แก้ตัวกฎ
(ทำให้เป็น partial index อย่างที่กล่องข้างบนทำ) หรือ เขียนโค้ดไปปลดแถวเก่าเอง
ด้วย Unscoped() ของ GORM แล้วเคลียร์ deleted_at ทิ้ง
อีกจุดที่การเปรียบเทียบพาไปผิดได้: ที่ร้านคุณอาจอยากได้ประวัติเก่ากลับมาด้วย
แต่ท่า partial index ในกล่องข้างบนไม่ได้กู้แถวเก่าคืน — มันสร้างแถวใหม่คนละ id
ของเก่ายังนอนอยู่แบบถูกลบเหมือนเดิม (ผลรันโชว์ 2 แถว)
- soft delete
- "ลบ" ด้วยการประทับเวลาลง
deleted_atแล้วให้ทุก query กรองออก แทนที่จะDELETEจริง · ประโยคที่ใช้ได้เลย: "ตารางนี้เป็น soft delete นะ แถวยังอยู่ อย่าลืมตอนเขียน query" - partial index
- index ที่มี
WHEREต่อท้าย แปลว่า "กฎข้อนี้ใช้กับแถวที่เข้าเงื่อนไขเท่านั้น" · พิมพ์ถาม AI ว่า "postgres partial unique index soft delete deleted_at" จะได้ท่าเดียวกับที่ใช้แก้ในกล่องข้างบน - unique violation · SQLSTATE 23505
- รหัสมาตรฐานที่แปลว่า "แถวนี้ชน unique" · โผล่ในผลรันทั้ง SQL ล้วนและฝั่ง GORM · รหัสนี้เอาไปเทียบในโค้ดได้ตรงกว่าการอ่านข้อความ error เป็นสตริง เพราะข้อความเปลี่ยนตามภาษาและเวอร์ชันได้
- upsert ·
ON CONFLICT - "มีอยู่แล้วก็อัปเดต ยังไม่มีก็แทรกใหม่" จบในคำสั่งเดียว ไม่ต้อง
SELECTก่อน · เป็นท่าที่โค้ด handler ท้ายหัวข้อ 9 ใช้แทนFirstOrCreate· ประโยคที่ใช้ได้เลย: "ตรงนี้ทำเป็น upsert onuidดีกว่าไหมครับ จะได้ไม่มีช่องให้ race" Unscoped()- คำสั่งของ GORM ที่บอกว่า "รอบนี้อย่าเติมตัวกรอง
deleted_at IS NULLให้" · ใช้ตอนอยากเห็นหรืออยากปลดแถวที่ถูก soft delete — ไม่ใส่ก็มองไม่เห็นแถวนั้นเลย
บั๊กเงียบอีกตัวจากบรรทัด id UUID PRIMARY KEY
ย้อนกลับไปดู \d users ในหัวข้อ 6: คอลัมน์ id ไม่มี DEFAULT
แปลว่าฐานข้อมูลไม่ได้สร้าง UUID ให้ — โค้ดฝั่งเราต้องเซ็ตเอง
ผมลองรัน GORM แบบที่ลืมเซ็ต ID ดูสองรอบ (คนละ uid):
รอบแรก:
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
(1 row)
Go ให้ค่าเริ่มต้นของ UUID เป็นศูนย์ทั้งหมด 00000000-…-000000000000
ซึ่งเป็นค่าที่ถูกต้องตามชนิด uuid — ฐานข้อมูลจึงรับไว้เฉย ๆ ไม่บ่นอะไร
บั๊กนี้เลยผ่านทุกการทดสอบที่มี user คนเดียว แล้วไประเบิดตอนมีคนที่สองสมัคร
ด้วย error ที่พูดถึง users_pkey ซึ่งไม่ได้ชี้ไปที่ต้นเหตุเลย
แก้ได้สองทาง เลือกทางใดทางหนึ่ง:
· ฝั่งฐานข้อมูล — เติม DEFAULT gen_random_uuid() ให้คอลัมน์ id ใน migration
(ฟังก์ชันนี้มีมาให้ใน PostgreSQL 13 ขึ้นไป ไม่ต้องลง extension)
· ฝั่ง Go — เขียน BeforeCreate hook ใน GORM ให้สร้าง UUID ก่อนบันทึกทุกครั้ง
9 · last_signed_in_at และ preferred_username มาจากไหน
สองคอลัมน์นี้ในสไลด์ไม่มีคำอธิบาย แต่มันไม่ใช่ของที่เราคิดขึ้นเอง —
มันมาจากข้อมูลใน token ที่ Keycloak ส่งมา
นี่คือ payload จริงที่ผมถอดออกมาจาก token ของ Keycloak (ตัดเฉพาะบรรทัดที่เกี่ยว):
{
"sub": "5da7f595-196f-44a6-8dfd-64306ce734d6", // → คอลัมน์ uid
"preferred_username": "chef1", // → คอลัมน์ preferred_username
"name": "Chef One", // → คอลัมน์ name
"email": "chef1@wongnok.dev", // → คอลัมน์ email
"given_name": "Chef",
"family_name": "One",
"email_verified": true,
"iat": 1787368445,
"exp": 1787368745 // = iat + 300 วินาที
}
| คอลัมน์ | มาจากไหน | เขียนตอนไหน |
|---|---|---|
uid | claim sub | ตอน login ครั้งแรก แล้วไม่แก้อีก |
email, name | claim email, name | อัปเดตทุกครั้งที่ login (ผู้ใช้อาจไปแก้ที่ Keycloak) |
preferred_username | claim preferred_username | เหมือนกัน — เอาไว้โชว์เป็นชื่อผู้ใช้ในแอป |
last_signed_in_at | ไม่มีใน claim — เราเซ็ตเป็น now() เอง | ทุกครั้งที่ callback สำเร็จ |
bio | ไม่มีใน claim — ผู้ใช้พิมพ์เองในแอปเรา | ตอนผู้ใช้แก้โปรไฟล์ |
image_url | ไม่มีใน claim — ผู้ใช้ใส่ลิงก์รูปเองในแอปเรา | ตอนผู้ใช้แก้โปรไฟล์ (สไลด์ Post เพิ่งเพิ่มคอลัมน์นี้มา) |
สามแถวล่างคือคำตอบของคำถามในหัวข้อ 1 ว่า "ทำไมต้องมีตาราง users ในเมื่อ Keycloak เก็บให้แล้ว" —
bio · image_url · last_signed_in_at เป็นของแอปเรา Keycloak ไม่มีให้และไม่ควรมี
email กับ preferred_username จะมาก็ต่อเมื่อขอ scope profile และ email
ซึ่งเป็นเหตุผลที่โค้ดในหัวข้อ 3 เขียนว่า
Scopes: []string{oidc.ScopeOpenID, "profile", "email"}
(ค่าเริ่มต้นของ Keycloak ใส่ scope สองตัวนี้ให้อยู่แล้ว แต่ถ้าใครไปปรับ client scope
แล้วโค้ดไม่ได้ขอเอง คุณจะได้ token ที่ไม่มีอีเมล แล้วบันทึกช่องว่างลงฐาน)
อีกเรื่องที่ต้องรู้จาก payload ข้างบน: exp ห่างจาก iat แค่ 300 วินาที
— access token ของ Keycloak 26.7 อายุ 5 นาที ไม่ใช่ 1 ชั่วโมงอย่างที่หลายคนจำ
(refresh token 1800 วินาที = 30 นาที)
สะพานที่ขาดไป — handler ที่พา Claims ลงตาราง users
ถึงตรงนี้บทนี้มีสองครึ่งที่ยังไม่ต่อกัน: หัวข้อ 3 ให้ auth.Callback ที่คืน *Claims มา
และหัวข้อ 6 ให้ตาราง users — แต่ยังไม่มีโค้ดที่พา c.Sub ไปลงคอลัมน์ uid
ก้อนนี้คือสะพานนั้น เป็น handler สองตัวของ net/http ที่ผมคอมไพล์และรันจริงกับ Postgres ตัวเดิม
(ไฟล์ httpx/handler.go) — และมันยังตอบเรื่อง state ที่หัวข้อ 3 ค้างไว้ด้วยว่า
"สุ่มใหม่ทุกครั้งแล้วเทียบตอนกลับมา" เขียนยังไง
ก้อนนี้ต้องลงเพิ่มอีกสองตัวจากหัวข้อ 2 ก่อน แล้วค่อย go mod tidy:
go get github.com/google/uuid · go get gorm.io/gorm
(ส่วน gorm.io/driver/postgres ใช้ในไฟล์ที่เปิดคอนเนกชัน ไม่ใช่ไฟล์นี้)
package httpx
import (
"crypto/rand"
"encoding/base64"
"net/http"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oidcdemo/auth" // ← บรรทัดแรกของ go.mod + ชื่อโฟลเดอร์ ถ้า module คุณชื่ออื่นก็แก้ตรงนี้
)
type User struct { // map ตรงกับตารางในหัวข้อ 6
ID string `gorm:"type:uuid;primaryKey"`
Email string
Name string
Bio string
UID string `gorm:"column:uid"`
PreferredUsername string
LastSignedInAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt // ← ตัวนี้แหละที่เปิดโหมด soft delete
}
type Server struct {
Auth *auth.Authenticator
DB *gorm.DB
}
// GET /auth/login — สุ่ม state ใหม่ทุกครั้ง เก็บใส่ cookie แล้วค่อย redirect
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
http.Error(w, "cannot generate state", http.StatusInternalServerError)
return
}
state := base64.RawURLEncoding.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: "oauth_state", Value: state,
Path: "/", HttpOnly: true, MaxAge: 300,
})
http.Redirect(w, r, s.Auth.LoginURL(state), http.StatusFound)
}
// GET /auth/callback — เทียบ state ก่อน แล้วค่อยเอา code ไปแลก token
func (s *Server) Callback(w http.ResponseWriter, r *http.Request) {
saved, err := r.Cookie("oauth_state")
if err != nil || saved.Value != r.URL.Query().Get("state") {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
c, err := s.Auth.Callback(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
now := time.Now()
u := User{
ID: uuid.NewString(), // ← หัวข้อ 8 บอกไว้: id ไม่มี DEFAULT เราต้องเซ็ตเอง
UID: c.Sub, // ← claim sub ลงคอลัมน์ uid ตรงนี้
Email: c.Email,
Name: c.Name,
PreferredUsername: c.PreferredUsername,
LastSignedInAt: &now, // ← ไม่มีใน claim เราเซ็ตเอง
}
// upsert: มี uid นี้อยู่แล้ว = อัปเดต · ยังไม่มี = INSERT ใหม่
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uid"}},
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}},
DoUpdates: clause.AssignmentColumns([]string{
"email", "name", "preferred_username", "last_signed_in_at", "updated_at",
}),
}).Create(&u).Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
ผลรันจริง — login ซ้ำสองครั้งด้วย sub เดิม (หลังแก้ index เป็น partial ตามหัวข้อ 8 แล้ว):
$ go build ./... ; echo "BUILD_EXIT=$?"
BUILD_EXIT=0
$ go vet ./... ; echo "VET_EXIT=$?"
VET_EXIT=0
login #1 err = <nil>
after #1 -> rows=1 id=c38fd545-7be8-4c04-aee5-af687e93867a last_signed_in_at=04:51:45
login #2 err = <nil>
after #2 -> rows=1 id=c38fd545-7be8-4c04-aee5-af687e93867a last_signed_in_at=04:51:48
สองครั้งได้แถวเดียว · id ไม่เปลี่ยน · last_signed_in_at ขยับจาก
04:51:45 เป็น 04:51:48 — ตรงกับตารางข้างบนทุกช่อง
TargetWhere ไม่ใช่ของประดับ — มันคือหัวข้อ 8 ที่ตามมาเก็บ
ตอนแรกผมเขียนแค่ Columns: {uid} เฉย ๆ ไม่มี TargetWhere ซึ่งใช้ได้ปกติ
ตราบใดที่ index ยังเป็น UNIQUE ธรรมดา
พอเปลี่ยนเป็น partial index ตามที่หัวข้อ 8 แนะนำ โค้ดเดิมพังทันที:
login #1 err = ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification (SQLSTATE 42P10)
เพราะ PostgreSQL ต้องหา index ที่ตรงกับที่เราประกาศไว้ใน ON CONFLICT ให้เจอ —
ถ้า index มี WHERE deleted_at IS NULL ติดอยู่ ตัว ON CONFLICT ก็ต้องมีเงื่อนไขเดียวกัน
ซึ่งฝั่ง GORM เขียนด้วย field TargetWhere
บทเรียน: เปลี่ยน index ในฐานข้อมูลแล้วต้องกลับมาดูโค้ดที่พึ่ง index นั้นด้วยเสมอ
— migration กับ query ผูกกันแน่นกว่าที่คิด
ของแถมที่โผล่มาตอนรัน: upsert ตัวนี้กับ soft delete เข้ากันได้พอดี
ลอง soft delete แถวนั้นทิ้ง แล้วให้ผู้ใช้ login กลับเข้ามาใหม่:
login #1 err = <nil>
after #1 -> rows=1 id=0351bc34-73a3-4d5c-834e-baceac290515 last_signed_in_at=04:51:48
$ docker exec day9check psql -U postgres -d wongnok -c "select uid, deleted_at is null as active from users"
uid | active
--------------------------------------+--------
5da7f595-196f-44a6-8dfd-64306ce734d6 | f # แถวเก่าที่ถูกลบ ยังอยู่
5da7f595-196f-44a6-8dfd-64306ce734d6 | t # แถวใหม่ที่ยัง active
(2 rows)
ได้ id ใหม่ (ไม่ใช่ตัวเดิมที่ถูกลบ) และไม่มี error —
ซึ่งเป็นสิ่งที่ FirstOrCreate ในหัวข้อ 8 ทำไม่ได้
10 · หน้า 53–54 — ให้ AI agent สร้าง migration ที่เหลือ
ตาราง users เป็นแค่ตารางแรก โปรเจกต์ wongnok ยังต้องการอีกหลายตาราง —
recipes, difficulties, และตารางเชื่อมอื่น ๆ
สไลด์หน้า 53 โชว์การสร้างไฟล์ตัวถัดไปด้วยคำสั่งเดิม:
# Create migration
goose create difficalties sql
📌 ในสไลด์สะกดว่า difficalties (ที่ถูกคือ difficulties)
ผมยกมาตามหน้าจริงเพื่อไม่ให้คุณงงตอนเทียบ
และมันเป็นตัวอย่างที่ดีของบทเรียนหนึ่ง: ชื่อไฟล์ migration แก้ทีหลังไม่ได้ง่าย ๆ
เพราะพอรันไปแล้ว ชื่อกับ version_id จะไปนอนอยู่ในตาราง goose_db_version
ของทุกคนในทีมและของ production ด้วย
เปลี่ยนชื่อไฟล์ทีหลัง = เครื่องที่รันไปแล้วกับเครื่องที่ยังไม่รันจะเห็นคนละเรื่อง —
ตรวจการสะกดก่อนกด goose up ครั้งแรกเสมอ
หน้า 54 คือจุดที่สไลด์เปลี่ยนวิธีทำงาน — แทนที่จะพิมพ์ SQL เอง ให้ AI agent เขียน migration ให้จากเอกสารออกแบบ ด้วย prompt นี้:
# Prompt
สร้างไฟล์ migrations ตาม docs/ARCHITECTURE.md โดยให้ทำเพียง
สร้างไฟล์อย่างเดียวเท่านั้นเพื่อนำไปรีวิวก่อน ไม่ต้องตรวจสอบและ migrate จริง
prompt นี้สั้นแต่มีของอยู่สามชิ้นที่วางไว้ตั้งใจ และเป็นเหตุผลที่มันใช้ได้จริง:
| ท่อนในคำสั่ง | ทำหน้าที่อะไร |
|---|---|
"ตาม docs/ARCHITECTURE.md" |
ชี้แหล่งความจริงให้ชัด — ไม่ให้ agent เดาโครงตารางเอาเอง ทุกคอลัมน์ต้องอ้างกลับไปที่เอกสารได้ |
| "ทำเพียงสร้างไฟล์อย่างเดียว" | จำกัดขอบเขต — ไม่ให้ไปแก้ไฟล์อื่น ไม่ให้ไปรันคำสั่งอื่น |
| "ไม่ต้อง migrate จริง" | กันไม่ให้แตะฐานข้อมูล — ผลลัพธ์เป็นแค่ไฟล์ที่กลับคืนได้ ไม่ใช่ตารางที่สร้างไปแล้ว |
ท่อนที่สามคือท่อนสำคัญที่สุด และเชื่อมกลับมาที่ทั้งบทนี้พอดี:
ทุกกับดักในหัวข้อ 8 เป็นสิ่งที่คนรีวิวเห็นได้ตอนอ่านไฟล์ แต่มองไม่เห็นถ้าปล่อยให้รันไปแล้ว
ทั้ง unique index ที่ชนกับ soft delete และ id UUID ที่ไม่มี default —
ทั้งคู่ goose up ผ่านฉลุยทั้งคู่ ไม่มี error ให้เห็น
เกณฑ์รีวิว migration ที่ AI เขียนให้ ขั้นต่ำสามข้อ — ทั้งสามข้อมาจากสิ่งที่รันเจอในบทนี้:
- ทุก
UNIQUE INDEXบนตารางที่มีdeleted_at— มีWHERE deleted_at IS NULLต่อท้ายหรือยัง - ทุก
PRIMARY KEYชนิดUUID— มีDEFAULTไหม ถ้าไม่มี ใครเป็นคนเซ็ตค่า - ส่วน
-- +goose Down— เขียนย้อนกลับไว้ครบจริงไหม หรือปล่อยว่างไว้ (ซึ่งแปลว่าถอยไม่ได้)
บทเรียน 54 จะพูดถึงเรื่องใหญ่กว่านั้น คือโหมดการทำงานร่วมกับ AI agent ว่าจะสั่งงานแบบไหน ควบคุมขอบเขตยังไง ซึ่ง prompt หน้า 54 เป็นตัวอย่างหนึ่งของโหมดเหล่านั้น
ทดสอบความเข้าใจ
1. ทำไมต้องลงทั้ง golang.org/x/oauth2 และ github.com/coreos/go-oidc/v3 ตัวเดียวไม่พอเหรอ?
สองตัวนี้ทำคนละครึ่งของกระบวนการและไม่ทำงานแทนกัน
oauth2 รับผิดชอบขั้นตอนแลกของ: AuthCodeURL ประกอบ URL หน้า login ·
Exchange ยิง POST ไป token_endpoint เอา code แลกเป็น token
go-oidc รับผิดชอบขั้นตอนตรวจของ: NewProvider อ่าน discovery document ·
Verifier(...).Verify โหลด public key จาก jwks_uri มาตรวจลายเซ็น แล้วเช็ค iss/aud/วันหมดอายุให้
ตัวเลือกที่ 4 ผิดคนละทาง: มันไม่ใช่ dependency บังคับของกันและกัน — go list -m all
หลัง go mod tidy แสดงว่าทั้งคู่เป็น direct dependency ทั้งสองตัว
และตัวที่เป็น // indirect จริง ๆ คือ github.com/go-jose/go-jose/v4 v4.1.4
ซึ่งเป็นตัวที่ go-oidc ใช้ทำงานเข้ารหัส
หลักฐานว่าทั้งสองตัวจำเป็น: ผลรัน go run ./cmd/probe มีทั้ง
AuthCodeURL (งานของ oauth2) และ error
oidc: malformed jwt: … (งานของ go-oidc) อยู่ในโปรแกรมเดียวกัน
2. ใช้ DSN ตามสไลด์เป๊ะ ๆ (postgres://postgres:Pe@devp00l@localhost:5432?database=wongnok) แล้วสั่ง goose up — เกิดอะไรขึ้นจริง ๆ?
ข้อนี้เป็นข้อที่ผมทายผิดเองตอนแรก ผลรันจริงคือฝั่ง Go ผ่านหมด:
goose up พิมพ์ OK 20260822031131_create_users.sql (12.99ms)
และ psql -d wongnok เห็นตาราง users กับ goose_db_version
ส่วนฐาน postgres ขึ้นว่า Did not find any relations.
สาเหตุมีสองข้อ: net/url ของ Go ตัดที่ @ ตัวสุดท้าย
รหัสผ่านเลยได้ "Pe@devp00l" ถูกต้อง · และ pgx รู้จักคีย์ database
อยู่แล้ว (เป็นชื่อที่มันใช้ภายใน ส่วน dbname เป็นชื่อพ้อง)
แต่พอเอา DSN เดิมไป psql พังทั้งสองจุด:
could not translate host name "devp00l@localhost" เพราะ
libpq ตัดที่ @ ตัวแรก ตรงข้ามกับ Go ·
และ invalid URI query parameter: "database" เพราะ psql ไม่รู้จักคีย์นั้น
ตัวเลือกที่ 4 คือความเข้าใจผิดที่ต้องแก้: Go กับ libpq ตีความ DSN เดียวกันไม่เหมือนกัน
เขียนให้ปลอดภัยทั้งสองฝั่ง:
postgres://postgres:Pe%40devp00l@localhost:5432/wongnok?sslmode=disable
3. ผู้ใช้ลบบัญชี (soft delete — deleted_at ถูกเซ็ต) แล้ววันหลัง login กลับเข้ามาใหม่ด้วยบัญชี Keycloak เดิม โค้ดเรียก FirstOrCreate — เกิดอะไรขึ้น?
ผลรันจริงคือ err = ERROR: duplicate key value violates unique constraint "idx_users_uid" (SQLSTATE 23505)
ทั้งกรณี db.Create ธรรมดาและกรณี FirstOrCreate
เหตุผลที่มันเป็นทางตัน: ท่อน First ถูกตัวกรอง soft delete ของ GORM (deleted_at IS NULL) กรองออก
(WHERE deleted_at IS NULL) เลยหาไม่เจอ · ท่อน Create จึงทำงาน
แล้วไปชน unique index ของแถวที่ยังนอนอยู่ในตาราง — หาก็ไม่เจอ สร้างก็ไม่ได้
ตัวเลือกที่ 3 คือสิ่งที่หลายคนหวังว่า GORM ทำให้ แต่มันไม่ทำ —
การ undelete ต้องเขียนเองด้วย Unscoped()
ยืนยันด้วย SQL ล้วนแล้วว่าไม่ใช่ความผิดของ ORM: INSERT 0 1 →
UPDATE 1 → ERROR: duplicate key … Key (uid)=(oidc-sub-123) already exists.
ทางแก้คือ partial unique index:
CREATE UNIQUE INDEX idx_users_uid ON users (uid) WHERE deleted_at IS NULL;
ซึ่งพอรันแล้วได้ INSERT 0 1 (สมัครใหม่ผ่าน) แต่รอบถัดไปยังได้
ERROR: duplicate key อยู่ (ยังกันซ้ำในแถวที่ active) — ได้ทั้งสองอย่างพร้อมกัน
ฝั่ง GORM: gorm:"uniqueIndex:idx_users_uid,where:deleted_at IS NULL"
4. หลัง tok, err := cfg.Exchange(ctx, code) สำเร็จ จะเอา id_token มา verify ต้องทำยังไง?
oauth2.Token ไม่มี field ชื่อ IDToken — ตัวเลือกที่ 3 จึง compile ไม่ผ่านด้วยซ้ำ
เหตุผล: id_token เป็นของมาตรฐาน OIDC ไม่ใช่ของ OAuth2
library oauth2 จึงไม่รู้จักมัน และเก็บ field ที่ไม่รู้จักไว้ในถุงรวมที่หยิบด้วย
tok.Extra(ชื่อ) ซึ่งคืน any จึงต้อง .(string) แปลงเอง
ตัวเลือกที่ 1 ผิดและอันตราย เพราะ access_token กับ id_token
เป็น JWT คนละใบ คนละหน้าที่ — ใบหนึ่งบอกว่าทำอะไรได้ อีกใบบอกว่าเป็นใคร
ตัวเลือกที่ 4 ผิดหนึ่งขั้น: Verify คืน *oidc.IDToken ซึ่งยังเป็นก้อนที่ตรวจแล้ว
แต่ยังไม่ได้แกะเป็น field — ต้อง idToken.Claims(&c) โดยที่ c เป็น struct
ที่มี tag json:"preferred_username" ฯลฯ ถึงจะได้ค่ามาเขียนลงตาราง users
โค้ดก้อนนี้ผมรัน go build ./... และ go vet ./... แล้วได้ exit=0 ทั้งคู่
(หลังสั่ง go mod tidy — ถ้าไม่สั่ง จะติด missing go.sum entry ก่อน)
users ของคุณรอดเคสที่สไลด์ไม่ได้ทดสอบ
เป้าหมายไม่ใช่ "รัน goose up ให้ผ่าน" — ผ่านมันง่ายมาก
เป้าหมายคือทำให้คุณเห็นด้วยตาว่ามันผ่านทั้งที่ยังมีบั๊กอยู่ แล้วค่อยแก้
- ตั้งฐานข้อมูล: รัน Postgres สักตัว แล้วสร้างฐาน
wongnok
ถ้าใช้ Docker ให้ตั้งชื่อคอนเทนเนอร์ไว้ด้วย (docker run --name day9check …) เดี๋ยวข้ออื่นจะได้เรียกdocker exec day9check psql …ได้เลย
สร้างโฟลเดอร์โปรเจกต์ ·mkdir migrations - พิสูจน์เรื่อง DSN ด้วยตัวเอง: เขียน
.envด้วย DSN ตามสไลด์เป๊ะ ๆ ก่อน แล้วสั่งgoose status
จากนั้นก๊อป DSN บรรทัดเดียวกันไปแปะใส่psql
ต้องได้ผลต่างกัน — ถ้าได้เหมือนกันแปลว่าพิมพ์ตกอะไรไปสักตัว จดข้อความ error ของpsqlไว้ - แก้เป็น DSN ที่ถูก: เปลี่ยน
@เป็น%40และย้ายชื่อฐานไปเป็น path
ยืนยันว่าทั้งgoose statusและpsqlใช้ DSN เดียวกันได้แล้ว - พิสูจน์ว่า goose อ่าน
.envเอง: สั่งenv -u GOOSE_DRIVER -u GOOSE_DBSTRING -u GOOSE_MIGRATION_DIR goose status
แล้วลองcdออกไปโฟลเดอร์อื่นแล้วสั่งgoose statusอีกที
ตอบให้ได้ว่าทำไมครั้งแรกทำงาน แต่ครั้งที่สองขึ้นหน้า usage - สร้างและรัน migration:
goose create create_users sql· เติม SQL ตามหัวข้อ 6 ·goose up·psql -U postgres -d wongnok -c '\d users'
ยืนยันด้วยตาว่าidx_users_uidขึ้นคำว่าUNIQUEและคอลัมน์idช่องDefaultว่างเปล่า - สร้างบั๊กด้วยมือตัวเอง (สำคัญที่สุด): ยิง SQL สามคำสั่งตามลำดับ —
INSERTuser ที่uid='oidc-sub-123'·UPDATE users SET deleted_at = now() WHERE uid='oidc-sub-123'·INSERTซ้ำด้วยuidเดิม
จดข้อความ error ที่ได้แบบเต็ม ๆ รวมทั้งบรรทัดDETAIL: - แก้ด้วย migration ใหม่ (อย่าแก้ไฟล์เดิม):
goose create fix_users_uid_partial_index sql
ส่วนUpให้DROP INDEXแล้วCREATE UNIQUE INDEX … WHERE deleted_at IS NULL· ส่วนDownต้องเขียนย้อนกลับให้ครบด้วย
รันข้อ 6 ซ้ำ — รอบนี้ต้องINSERTผ่าน แล้วลองINSERTอีกรอบให้ได้ error กลับมา (ยืนยันว่ายังกันซ้ำอยู่) - ทดสอบขาถอย:
goose down·goose status·goose up·goose status
แล้วเปิดตารางgoose_db_versionดู
ตอบให้ได้ว่าเลขidในตารางนั้นเรียงต่อกันหรือกระโดด และทำไม - ท้าทาย: ปิดช่อง
id UUIDที่ไม่มี default — เลือกทางใดทางหนึ่ง (DEFAULT gen_random_uuid()ใน migration หรือBeforeCreatehook ใน GORM)
แล้วเขียนคำอธิบายสามบรรทัดว่าทางที่คุณเลือกมีข้อเสียอะไร เทียบกับอีกทาง
(ใบ้: ถ้ามีคนยิง SQL เข้าฐานตรง ๆ โดยไม่ผ่านแอปของคุณ ทางไหนยังคุ้มครองอยู่?)
ข้อ 4 — ทำไมครั้งแรกทำงาน ครั้งที่สองขึ้น usage
$ env -u GOOSE_DRIVER -u GOOSE_DBSTRING -u GOOSE_MIGRATION_DIR goose status
2026/08/22 10:15:27 Applied At Migration
2026/08/22 10:15:27 =======================================
2026/08/22 10:15:27 Sat Aug 22 03:14:26 2026 -- 20260822031131_create_users.sql
# หลังย้าย .env ออกจากโฟลเดอร์
Usage: goose DRIVER DBSTRING [OPTIONS] COMMAND
...
-env string
load environment variables from file (default .env)
คำอธิบายอยู่ในบรรทัดสุดท้ายที่ goose พิมพ์ออกมาเอง — มันมี flag -env
ที่ค่าเริ่มต้นเป็น .env จึงโหลดไฟล์นี้ให้อัตโนมัติ ไม่ต้อง source ไม่ต้อง export
แต่มันหาไฟล์ในโฟลเดอร์ปัจจุบัน — cd ไปที่อื่นก็หาไม่เจอ แล้วไม่มี DSN ให้ใช้ จึงพิมพ์ usage
ข้อควรระวังที่ตามมา: ถ้าคุณ export GOOSE_DBSTRING ไว้ในเทอร์มินัลเป็นค่าเก่า
แล้วแก้ .env เป็นค่าใหม่ ตัวไหนชนะ? ผมทดลองให้แล้ว —
ค่าที่อยู่ใน environment ของ shell ชนะเสมอ ตัวโหลด .env ของ goose
จะไม่ทับตัวแปรที่มีค่าอยู่แล้ว มันเติมให้เฉพาะตัวที่ยังว่าง
(ผมพิสูจน์ในโฟลเดอร์ทดลองแยกอีกอันหนึ่ง เลข migration เลยคนละตัวกับข้างบน — ดูที่ผลลัพธ์เป็นหลัก)
# .env ชี้ไปฐานที่ไม่มีอยู่จริง แต่ export ค่าที่ถูกทับหน้าคำสั่ง
$ GOOSE_DBSTRING='postgres://…/wongnok?sslmode=disable' goose status
2026/08/22 11:42:46 Applied At Migration
2026/08/22 11:42:46 =======================================
2026/08/22 11:42:46 Sat Aug 22 04:41:01 2026 -- 20260822044053_create_users.sql
# → อ่านฐาน wongnok ได้ปกติ แปลว่า env ชนะ .env
# สลับกัน: .env ถูก แต่ export ค่าที่พัง
$ GOOSE_DBSTRING='postgres://…/DOES_NOT_EXIST?sslmode=disable' goose status
2026/08/22 11:42:46 goose run: failed to ensure DB version: failed to connect to `user=postgres database=DOES_NOT_EXIST`:
… (localhost): server error: FATAL: database "DOES_NOT_EXIST" does not exist (SQLSTATE 3D000)
# → พังทั้งที่ .env ถูก ยืนยันอีกทางว่า env ชนะ
เพราะฉะนั้นเวลาแก้ .env แล้วไม่เห็นผล ให้เช็ค echo $GOOSE_DBSTRING ก่อนเสมอ —
แต่ทางที่ปลอดภัยกว่าคืออย่าตั้งซ้ำสองที่ตั้งแต่แรก
ข้อ 8 — ทำไมเลข id กระโดด
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)
หายไปคือ id = 2 ซึ่งเป็นแถวที่ถูกเขียนตอน goose up รอบแรก
พอสั่ง goose down มันลบแถวนั้นทิ้งจริง ๆ ไม่ได้แค่เปลี่ยน is_applied เป็น false
แล้วพอ goose up อีกรอบก็เขียนแถวใหม่ ซึ่งได้เลข 3 เพราะตัวนับของ Postgres เดินหน้าไปแล้วไม่ย้อนกลับ
สิ่งที่ควรจำจากข้อนี้: goose_db_version ไม่ได้เก็บประวัติว่าเคยรันอะไรบ้าง
มันเก็บสถานะปัจจุบันว่าตอนนี้ migration ไหน apply อยู่ —
ถ้าอยากรู้ประวัติจริง ต้องดูจาก git ไม่ใช่จากตารางนี้
💬 ข้อ 6 คือข้อที่ผมอยากให้ทำจริง ๆ ของบทนี้ —
เพราะ error duplicate key value violates unique constraint "idx_users_uid"
ที่โผล่ขึ้นมาต่อหน้าคุณเอง จะจำได้นานกว่าอ่านผ่านตาสิบรอบ
และมันเป็นบั๊กประเภทที่ไม่มีทางเจอตอนพัฒนา เพราะตอนพัฒนาไม่มีใครลบบัญชีแล้วสมัครกลับ —
มันจะโผล่ตอนมีผู้ใช้จริงเท่านั้น
ถ้ารันแล้ว output ไม่ตรงกับในบทนี้ ส่งมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน
Go 1.26.5 darwin/arm64 · goose v3.27.3 ·
PostgreSQL 16.15 (image postgres:16-alpine) ·
oauth2 v0.36.0 กับ go-oidc v3.20.0
จุดที่ต่างได้บ่อยที่สุดคือเวอร์ชัน Postgres — gen_random_uuid()
ที่ผมพูดถึงในหัวข้อ 8 มีมาให้เองตั้งแต่ PostgreSQL 13 แต่เวอร์ชันเก่ากว่านั้นต้อง
CREATE EXTENSION pgcrypto ก่อน
อีกอย่างที่ควรถามตัวเองก่อนส่งมา: คุณรัน go mod tidy แล้วหรือยัง
อ้างอิง
- pkg.go.dev —
oauth2.Config·Token.Extra— อ่านย่อหน้าของExtraมันบอกตรง ๆ ว่าเก็บ field ที่ไม่ใช่มาตรฐาน OAuth2 ไว้ตรงนี้ ซึ่งอธิบายว่าทำไมid_tokenต้องหยิบด้วยวิธีนี้ - pkg.go.dev —
go-oidc/v3/oidc— ดู signature ของNewProvider,Provider.Endpoint,Provider.Verifier· สังเกตว่าVerifyคืน*IDTokenไม่ใช่ struct ของ claim - OpenID Connect Core — Standard Claims — นิยามทางการของ
sub,preferred_username,email,name· อ่านคำเตือนเรื่องpreferred_usernameว่า spec ไม่รับประกันว่ามันไม่ซ้ำและไม่เปลี่ยน — เหตุผลที่เราใช้subเป็นสะพานแทน - pressly/goose — README — รายการคำสั่งทั้งหมด (
up,down,status,reset,version) และรายชื่อ env ที่มันอ่าน - PostgreSQL 16 — Connection URIs — อ่านย่อหน้าที่บอกว่าอักขระพิเศษต้อง percent-encode และดูว่าชื่อฐานข้อมูลอยู่ส่วนไหนของ URI (คำตอบ: path ไม่ใช่ query)
- PostgreSQL 16 — Partial Indexes — ต้นทางของท่า
CREATE UNIQUE INDEX … WHERE …ที่ใช้แก้ปัญหาในหัวข้อ 8 - GORM — Soft Delete — อ่านว่าทำไมชนิดต้องเป็น
gorm.DeletedAtและUnscoped()ใช้ตอนไหน - pkg.go.dev —
net/url.Parse— ที่มาของพฤติกรรม "ตัดที่@ตัวสุดท้าย" ที่ทำให้ฝั่ง Go รอด ส่วนlibpqไม่รอด - บทเรียน 51 — Keycloak: Authorization Server ที่รันเอง — ที่มาของ
issuer,jwks_uriและ claim ทั้งหมดที่หัวข้อ 9 ใช้ - บทเรียน 44 — Configuration & Environment Variables — เรื่อง
exportและไฟล์.envที่หัวข้อ 4 ต่อยอด