Cheat sheet · Day 8

REST · Gin · Swagger — แผ่นเดียวจบ

คู่กับ บทเรียน 33–44 · ทุกผลลัพธ์ในหน้านี้มาจากการรันจริงบน Go 1.26.5 darwin/arm64

หน้านี้ไม่ได้เขียนไว้ให้อ่านรวดเดียว — เขียนไว้ให้เปิดตอนมือติดคีย์บอร์ด ตารางกับดัก (หัวข้อ 16) กับตารางแปล error (หัวข้อ 17) คือสองอันที่จะได้ใช้บ่อยที่สุด
หัวข้อ 18 เพิ่มเรื่อง environment variable — ตาราง os.Getenv vs os.LookupEnv ตารางแปลง type พร้อม error จริง และวิธีตั้ง env ตั้งแต่ shell ยัน k8s · หัวข้อ 19 คือตาราง แปลง API ที่ออกแบบแย่ให้เป็น RESTful ทีละเส้น

สารบัญ

  1. เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
  2. REST — method กับความหมาย
  3. Status code — เลือกตัวไหน
  4. กฎตั้งชื่อ 7 ข้อ
  5. query / body / header / path — ข้อมูลไปทางไหน
  6. net/http — โครงที่ก็อปไปใช้ได้
  7. net/http — ไวยากรณ์ pattern (Go 1.22+)
  8. Gin — โครงที่ก็อปไปใช้ได้
  9. Gin — Context ที่ใช้บ่อย
  10. Gin — binding tag + ข้อความ error จริง
  11. Gin — middleware & CORS
  12. httptest — โครงเทสที่ก็อปไปใช้ได้
  13. Swagger — คำสั่ง + annotation ครบชุด
  14. Redis — สองคำสั่งที่เพิ่มจาก Day 7
  15. Wongnok API spec
  16. ตารางกับดักรวมทั้งวัน
  17. ตารางแปล error / panic ที่เจอบ่อย
  18. Environment Variables — Getenv/LookupEnv · แปลง type · วิธีตั้งค่า · 12-Factor
  19. ตารางแปลง API ที่ออกแบบแย่ → RESTful

1 · เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน

ของเวอร์ชันติดตั้งยังไง
Go1.26.5 darwin/arm64—
github.com/gin-gonic/ginv1.12.0go get github.com/gin-gonic/gin
github.com/gin-contrib/corsv1.7.7go get github.com/gin-contrib/cors
github.com/go-playground/validator/v10v10.30.1ติดมากับ gin
github.com/stretchr/testifyv1.12.1go get github.com/stretchr/testify
swag (CLI)v1.16.4go install github.com/swaggo/swag/cmd/swag@latest
github.com/swaggo/swag (library)v1.16.6go get github.com/swaggo/swag@latest ← ต้องทำ ดูหัวข้อ 13
github.com/swaggo/gin-swaggerv1.6.1go get -u github.com/swaggo/gin-swagger
github.com/swaggo/filesv1.0.1go get -u github.com/swaggo/files
PATH

พิมพ์ swag แล้วขึ้น command not found แปลว่า GOPATH/bin ไม่อยู่ใน PATH — แก้ด้วย
export PATH=$PATH:$(go env GOPATH)/bin

2 · REST — method กับความหมาย

Methodแปลว่ามี body ไหมidempotentตัวอย่าง
GETอ่าน — ห้ามเปลี่ยนอะไรไม่✔GET /recipes/12
POSTสร้างใหม่ / สั่งให้ทำใช่✘POST /recipes
PUTแทนที่ทั้งก้อนใช่✔PUT /recipes/12
PATCHแก้บางส่วนใช่✘*PATCH /recipes/12
DELETEลบไม่✔DELETE /recipes/12

idempotent = ยิงซ้ำกี่ครั้งผลสุดท้ายเหมือนยิงครั้งเดียว — ไม่ได้แปลว่า “ได้ status เดิม” · DELETE ครั้งแรกได้ 204 ครั้งที่สองได้ 404 ก็ยังถือว่า idempotent เพราะสถานะของ server เหมือนกัน

* PATCH idempotent หรือไม่ขึ้นกับว่าเขียนยังไง — {"stock": 5} idempotent แต่ {"stock": "+1"} ไม่

PUT ที่คนใหม่พลาดที่สุด — PUT /users/1 ด้วย body {"name":"ใหม่"} ตามนิยามคือ แทนที่ทั้ง resource แปลว่า email ที่ไม่ได้ส่งมาควรถูกลบทิ้ง ถ้าอยากแก้แค่ชื่อต้องใช้ PATCH

3 · Status code — เลือกตัวไหน

Codeใช้เมื่ออย่าใช้เมื่อ
200 OKสำเร็จ และมี body จะตอบสร้างของใหม่ (ใช้ 201)
201 Createdสร้าง resource สำเร็จ — ควรใส่ Location ชี้ของใหม่แก้ของเดิม
204 No Contentสำเร็จ แต่ไม่มีอะไรจะตอบ เช่น DELETEเมื่อยังอยากส่ง body (204 ห้ามมี body)
400 Bad Requestรูปแบบ request ผิด / validation ไม่ผ่านยังไม่ได้ล็อกอิน (ใช้ 401)
401 Unauthorizedยังไม่รู้ว่าคุณเป็นใคร — ไม่มี token / token ผิดรู้ว่าเป็นใครแล้วแต่ห้ามทำ (ใช้ 403)
403 Forbiddenรู้ว่าคุณเป็นใครแล้ว แต่ไม่มีสิทธิ์token หมดอายุ (ใช้ 401)
404 Not Foundไม่มี resource นี้มีของแต่ห้ามดู (403 หรือ 404 ก็ได้ ถ้าตั้งใจซ่อน)
409 Conflictชนกับสถานะปัจจุบัน เช่น email ซ้ำข้อมูลผิดรูป (ใช้ 400)
500 Internal Server Errorเราผิดเอง — bug, DB ล่มผู้ใช้ส่งข้อมูลผิด (นั่นคือ 4xx)
เส้นแบ่งเดียวที่ต้องจำ

4xx = ฝั่ง client ต้องแก้ · 5xx = ฝั่งเราต้องแก้
ตอบ 200 พร้อม {"error": "..."} คือความผิดพลาดที่แพงที่สุด — client library ที่เช็ค status จะคิดว่าสำเร็จ และ dashboard จะมองไม่เห็น error rate เลย

4 · กฎตั้งชื่อ 7 ข้อ

#กฎ✔ ทำแบบนี้✘ อย่าทำ
1ใช้ noun ไม่ใช่ verbGET /usersGET /getUsers
2ชื่อ resource ต้องสม่ำเสมอทั้งระบบ/users /orders /products/user /orders /product-list
3lowercase/users/Users /PRODUCTS
4หลายคำคั่นด้วย -/payment-methods/paymentMethods /payment_methods
5ความสัมพันธ์ใช้ path/users/123/orders/orders?userId=123 (ใช้ได้ แต่สื่อความเป็นเจ้าของน้อยกว่า)
6filter ใช้ query/orders?status=paid/orders/status/paid
7อย่าใส่ action ใน URI ถ้า method สื่อได้แล้วPOST /users · DELETE /users/123POST /users/create · POST /users/123/delete
⚠ สไลด์หน้า 35 ติดเครื่องหมายกลับด้าน

สไลด์เขียน // ✅ แนะนำ ไว้เหนือ POST /users/create และ // ❌ ไม่แนะนำ เหนือ POST /users ซึ่งสลับกัน — ขัดกับตัวกฎข้อ 7 เอง และขัดกับสไลด์หน้า 22 ที่บอกว่า POST /users = สร้าง · ตารางข้างบนนี้คือฉบับที่ถูก

ข้อยกเว้นของกฎ 7 ที่ถูกต้อง — action ที่ HTTP method สื่อไม่ได้จริง ๆ ใส่ใน path ได้ (สไลด์หน้า 36 เขียนถูก): PATCH /users/123/suspend · PATCH /users/123/reset-password · POST /orders/123/cancel

Versioning

// stable
/api/v1/users
// เมื่อมี breaking change
/api/v2/users

breaking = ลบ field · เปลี่ยน type · เปลี่ยนความหมายของค่าเดิม · เปลี่ยน field ที่ required  |  ไม่ breaking = เพิ่ม field ใหม่ที่ optional · เพิ่ม endpoint ใหม่ · เพิ่มค่า enum ใหม่

5 · query / body / header / path — ข้อมูลไปทางไหน

ถามตัวเองว่า…ไปที่ตัวอย่าง
มันคือตัวระบุว่า resource ไหนpath/recipes/12
มันคือการกรอง / เรียง / แบ่งหน้าquery?difficulty=easy&page=2
มันคือเนื้อข้อมูลที่จะบันทึกbody (JSON){"name":"ผัดไทย"}
มันคือmetadata ของการเชื่อมต่อheaderAuthorization: Bearer …
อย่าส่ง token ทาง query

?token=eyJhb… ไปโผล่ใน access log ของทุก proxy ที่ผ่าน, ใน browser history, และใน Referer ที่ส่งต่อไปเว็บอื่น — ใส่ใน header เสมอ

Header สามตัวที่ต้องรู้จัก

Headerใครส่งบอกอะไร
Authorizationclientcredential เช่น Bearer <token>
Content-Typeทั้งคู่“body ที่ผมส่งมาเป็นรูปแบบนี้”
Acceptclient“ผมอยากได้คำตอบเป็นรูปแบบนี้”

6 · net/http — โครงที่ก็อปไปใช้ได้

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")          // Go 1.22+

    // ลำดับนี้ห้ามสลับ: Header → WriteHeader → Encode
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(User{ID: id, Name: "Somchai"})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("ok"))
    })
    mux.HandleFunc("GET /users/{id}", getUser)

    log.Fatal(http.ListenAndServe(":8080", mux))
}

ทำไม w ไม่ใช่ pointer แต่ r เป็น — http.ResponseWriter เป็น interface (ข้างในมี pointer อยู่แล้ว) ส่วน http.Request เป็น struct ก้อนใหญ่ จึงส่งเป็น *http.Request

สิ่งที่ net/http ทำให้ฟรี

# ยิง POST ไปที่ route ที่ลงเป็น GET
$ curl -s -i -X POST http://127.0.0.1:8765/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff

Method Not Allowed

405 พร้อม Allow header มาให้เอง — Gin ไม่ทำให้ (ดูหัวข้อ 9)

7 · net/http — ไวยากรณ์ pattern (Go 1.22+)

เขียนแมตช์อะไรหมายเหตุ
"/hello"path เป๊ะ ทุก method
"GET /hello"เฉพาะ GET (และ HEAD)method ต้องตัวใหญ่ + เว้นวรรค 1 ที
"/users/{id}"1 segmentอ่านด้วย r.PathValue("id")
"/files/{path...}"ที่เหลือทั้งหมดต้องอยู่ท้าย pattern
"/files/"subtree — ทุกอย่างใต้ /files/ยิง /files จะถูก redirect 307
"/exact/{$}"/exact/ เป๊ะ ๆ ห้าม subtree

ลำดับความสำคัญ: pattern ที่เจาะจงกว่าชนะเสมอ ไม่เกี่ยวกับลำดับที่ลงทะเบียน — /users/me ชนะ /users/{id} · และ ServeMux ไม่ panic เมื่อลงทั้งสองอัน

ไวยากรณ์ที่พิมพ์ผิดแล้วพังเงียบ ๆ

pattern="get /hello"    // ลงทะเบียนผ่าน! แต่ GET จริงได้ 405 Allow: get
pattern="GET/hello"     // Go ตีเป็น host="GET" path="/hello" → 404 ทุกครั้ง เงียบสนิท
pattern="GET  /hello"   // เว้นวรรคเกิน → ใช้ได้ปกติ
pattern="GET hello"     // PANIC: parsing "GET hello": at offset 4: host/path missing /

8 · Gin — โครงที่ก็อปไปใช้ได้

package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func NewRouter() *gin.Engine {
    r := gin.Default()                // = New() + Logger() + Recovery()
    r.HandleMethodNotAllowed = true    // อยากได้ 405 ต้องเปิดเอง

    v1 := r.Group("/api/v1")
    {
        v1.GET("/users/:id", getUser)
        v1.POST("/users", createUser)
    }
    return r
}

func getUser(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"id": c.Param("id")})
}

func main() { NewRouter().Run(":8080") }

แยก NewRouter() ออกจาก main() ไม่ใช่เรื่องความสวย — เป็นเงื่อนไขที่ทำให้เขียนเทสด้วย httptest ได้ (หัวข้อ 12) เพราะเทสต้องหยิบ *gin.Engine มาใช้โดยไม่เปิด port

9 · Gin — Context ที่ใช้บ่อย

เขียนได้อะไรตอนไม่ส่งมา
c.Param("id")ค่าจาก /users/:idไม่เข้า handler เลย → 404
c.Query("q")string""
c.DefaultQuery("q","x")string"x" — แต่ ?q= ให้ ""
c.GetQuery("q")(string, bool)("", false) ← ตัวเดียวที่แยก “ไม่ส่ง” ออกจาก “ส่งมาว่าง” ได้
c.GetHeader("Authorization")string""
c.JSON(code, obj)ตอบ JSONใส่ Content-Type: application/json; charset=utf-8 ให้เอง
c.Status(code)ตั้ง status เปล่า ๆใช้กับ 204
gin.H{...}map[string]anyใช้ตอบ JSON เร็ว ๆ ไม่ต้องประกาศ struct

Gin ต่างจาก net/http ตรงไหนจริง ๆ

net/http (Go 1.22+)Gin v1.12.0
method ผิด405 + Allow อัตโนมัติ404 จนกว่าจะตั้ง HandleMethodNotAllowed = true
/users/:id + /users/meได้ static ชนะได้เหมือนกัน static ชนะ (ไม่ panic)
route ชนกันจริง ๆpanic ตอน register เหมือนกัน — แต่ข้อความบอกเลขบรรทัดที่ลงทะเบียนทั้งสองที่: pattern "GET /users/{id}" (registered at main.go:8) conflicts with … (registered at main.go:7)panic ตอน register (ดูหัวข้อ 17) · ข้อความไม่บอกเลขบรรทัด
อ่าน path paramr.PathValue("id")c.Param("id")
ตอบ JSONต้อง set header เอง ไม่งั้นได้ text/plainc.JSON จัดการให้
อ่าน+ตรวจ bodyเขียนเองShouldBindJSON + tag

10 · Gin — binding tag + ข้อความ error จริง

type CreateRecipeRequest struct {
    Name       string  `json:"name"       binding:"required"`
    Email      string  `json:"email"      binding:"required,email"`
    Level      int     `json:"level"      binding:"min=1,max=5"`
    Difficulty string  `json:"difficulty" binding:"oneof=easy medium hard"`
    Note       *string `json:"note"`   // pointer = แยก "ไม่ส่งมา" ออกจาก "ส่งค่าว่าง"
}

func create(c *gin.Context) {
    var req CreateRecipeRequest
    if err := c.ShouldBindJSON(&req); err != nil {   // ShouldBind เสมอ ไม่ใช่ Bind
        c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
        return
    }
    c.JSON(http.StatusCreated, req)
}

ข้อความ error ที่ได้จริง

# ยิงเข้า struct CreateRecipeRequest ข้างบนตรง ๆ — ทุกเคสได้ HTTP 400

body={"name":"","email":"a@b.co","level":3,"difficulty":"easy"}
  Key: 'CreateRecipeRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag

body={"name":"ผัดกะเพรา","email":"not-an-email","level":3,"difficulty":"easy"}
  Key: 'CreateRecipeRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag

body={"name":"ผัดกะเพรา","email":"a@b.co","level":9,"difficulty":"extreme"}
  Key: 'CreateRecipeRequest.Level' Error:Field validation for 'Level' failed on the 'max' tag
  Key: 'CreateRecipeRequest.Difficulty' Error:Field validation for 'Difficulty' failed on the 'oneof' tag

สังเกตชื่อขึ้นต้น — Key: ใช้ชื่อ struct ใน Go (CreateRecipeRequest.Name) ไม่ใช่ชื่อใน json tag (name) · ข้อความนี้จึงไม่ควรส่งกลับไปให้ผู้ใช้ตรง ๆ เพราะมันเปิดเผยโครงสร้างภายในของโค้ดเรา

ผิดหลาย field พร้อมกัน → error ทุกอันต่อกันด้วย \n ในสตริงเดียว

กับดัก required + zero value

Age int `binding:"required"` แล้วผู้ใช้ส่ง {"age":0} มา → ไม่ผ่าน เพราะ validator เช็ค “ไม่ใช่ zero value” ไม่ใช่ “มี key นี้” เจอกับ 0, "", false เหมือนกันหมด
แก้ด้วย pointer: Age *int `binding:"required"` — ส่ง 0 มาผ่าน แต่ไม่ส่งมาเลยไม่ผ่าน

เมธอดอ่านจากtag ที่ใช้
ShouldBindJSONbodyjson + binding
ShouldBindQueryquery stringform + binding
ShouldBindUripath paramuri + binding

11 · Gin — middleware & CORS

func RequireAuth() gin.HandlerFunc {
    return func(c *gin.Context) {
        if c.GetHeader("Authorization") == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "no token"})
            return
        }
        c.Set("userID", "u-123")
        c.Next()                 // ปล่อยให้ตัวถัดไปทำงาน
        // โค้ดตรงนี้รัน "ขากลับ" หลัง handler เสร็จแล้ว
    }
}

r.Use(RequireAuth())              // ทั้งเว็บ
admin := r.Group("/admin")
admin.Use(RequireAuth())          // เฉพาะกลุ่มนี้
เขียนผล
c.Next()เรียกตัวถัดไป แล้วกลับมาทำโค้ดที่เหลือ
c.Abort() เปล่า ๆหยุด handler ถัดไป แต่ไม่ได้เขียน response → client ได้ 200 body ว่าง
c.AbortWithStatusJSON(401, …)หยุด + ตอบ JSON — ใช้ตัวนี้เสมอ
c.Set / c.Getส่งค่าให้ handler ถัดไป · c.MustGet panic ถ้าไม่มี key

CORS — ผลรันจริง

preflight = request OPTIONS ที่เบราว์เซอร์ยิงเองก่อน โดยที่โค้ดเราไม่ได้สั่ง เพื่อถามเซิร์ฟเวอร์ว่า “ขอส่ง method/header ชุดนี้ได้ไหม” ถ้าคำตอบไม่ผ่าน เบราว์เซอร์จะไม่ยิง request จริงเลย (ละเอียดใน บทเรียน 39)

# request จริง (มี Origin) กับ cors.Default()
$ curl -s -i -H "Origin: https://evil.example.com" http://127.0.0.1:39182/data
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *

# preflight
$ curl -s -i -X OPTIONS -H "Origin: https://evil.example.com" \
    -H "Access-Control-Request-Method: POST" http://127.0.0.1:39182/data
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Origin,Content-Length,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Max-Age: 43200
⚠ cors.Default() ไม่อนุญาต Authorization

ดู Access-Control-Allow-Headers ข้างบน — มีแค่ Origin, Content-Length, Content-Type · เว็บที่ส่ง JWT ผ่าน Authorization: Bearer … จะโดนเบราว์เซอร์บล็อกทั้งที่ server ตอบ 204
ต้องเขียนเองด้วย cors.New:

r.Use(cors.New(cors.Config{
    AllowOrigins: []string{"https://app.example.com"},
    AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
    AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
}))

CORS ไม่ใช่ระบบความปลอดภัยของ server — เป็นกฎที่เบราว์เซอร์บังคับตัวเอง curl และ Postman ไม่สนใจ CORS เลย ยิงได้ตลอด

12 · httptest — โครงเทสที่ก็อปไปใช้ได้

func TestGetUser(t *testing.T) {
    gin.SetMode(gin.TestMode)
    router := NewRouter()

    w := httptest.NewRecorder()
    req := httptest.NewRequest("GET", "/api/v1/users/1", nil)
    router.ServeHTTP(w, req)

    assert.Equal(t, 200, w.Code)
    assert.JSONEq(t, `{"id":"1"}`, w.Body.String())
    assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
}

⚠ path ต้องมี /api/v1 นำหน้า เพราะ NewRouter() ในหัวข้อ 8 ลงทะเบียน route ไว้ใต้ r.Group("/api/v1") · ถ้าเทสยิง /users/1 เฉย ๆ จะได้ 404 ไม่ใช่ 200 และ assert.JSONEq จะฟ้องต่ออีกดอกว่า Input ('404 page not found') needs to be valid json
ทำนองเดียวกัน body ที่ assert ต้องตรงกับที่ getUser คืนจริง ซึ่งมีแค่ id — เทสที่ก๊อปมาแล้วไม่ได้ดูว่า handler คืนอะไร คือเทสที่แดงตั้งแต่บรรทัดแรก

เขียนได้
w.Codeint เช่น 200
w.Body.String()body ทั้งก้อนเป็นสตริง
w.Header().Get("X")header — ไม่ต้องเรียก w.Result()
httptest.NewRequestpanic ถ้า URL พัง — ใช้ในเทส
http.NewRequestคืน error — ใช้ในโค้ด production
assert.JSONEqเทียบเชิงความหมาย — สลับลำดับ key ก็ผ่าน
assert.Equal กับสตริงเทียบตัวอักษร — เปราะ ใช้เฉพาะตอนอยากล็อกรูปแบบเป๊ะ

NewRecorder = ยิงเข้า router ในหน่วยความจำ ไม่เปิด port — ใช้ 99% ของเวลา  |  httptest.NewServer = เปิด server จริงบน port สุ่ม ใช้เมื่อต้องทดสอบตัว HTTP client ของเราเอง

13 · Swagger — คำสั่ง + annotation ครบชุด

ติดตั้ง

go install github.com/swaggo/swag/cmd/swag@latest
go get -u github.com/swaggo/gin-swagger
go get -u github.com/swaggo/files
go get github.com/swaggo/swag@latest        # ← สำคัญ ดูกล่องเตือนข้างล่าง
⚠ CLI ใหม่กว่า library แล้วคอมไพล์ไม่ผ่าน

gin-swagger v1.6.1 ลาก swaggo/swag v1.8.12 เข้ามา แต่ swag CLI ที่เพิ่งลงเป็น v1.16.4 → docs.go ที่มันสร้างใช้ field ที่ library เก่ายังไม่มี:

# wongnok/docs
docs/docs.go:169:2: unknown field LeftDelim in struct literal of type "github.com/swaggo/swag".Spec
docs/docs.go:170:2: unknown field RightDelim in struct literal of type "github.com/swaggo/swag".Spec
FAIL	wongnok/cmd/api [build failed]

แก้ด้วย go get github.com/swaggo/swag@latest (v1.8.12 => v1.16.6) แล้วผ่านทันที · สไลด์ไม่ได้บอกขั้นตอนนี้

General API Info — เขียนเหนือ func main

//	@title			Wongnok API
//	@version		1.0
//	@description	API สำหรับจัดการข้อมูลผู้ใช้
//	@host			localhost:8080
//	@BasePath		/api/v1
//	@schemes		http https

//	@securityDefinitions.apikey	BearerAuth
//	@in							header
//	@name						Authorization
//	@description				ใส่ค่าเป็น "Bearer {token}"
func main() { /* … */ }

ระดับ endpoint

// FindByID godoc
//
//	@Summary		ดึงข้อมูล User รายคน
//	@Description	ค้นหา User จาก UUID แล้วคืนข้อมูลกลับมา
//	@Tags			users
//	@Produce		json
//	@Param			id	path		string	true	"User ID (UUID)"	format(uuid)
//	@Success		200	{object}	user.UserResponse
//	@Failure		404	{object}	httputil.ErrorResponse
//	@Security		BearerAuth
//	@Router			/users/{id} [get]
func (handler *Handler) FindByID(ctx *gin.Context) { /* … */ }

@Param มี 6 ช่อง: <ชื่อ> <path|query|header|body|formData> <type> <true|false> "<คำอธิบาย>" <attribute>

@Success/@Failure: <code> {object|array|string} <package>.<Struct> — ชื่อ type ต้องมี package นำหน้าเสมอ เขียน UserResponse เฉย ๆ swag หาไม่เจอ

ต่อเข้ากับ Gin

import (
    swaggerFiles "github.com/swaggo/files"
    ginSwagger   "github.com/swaggo/gin-swagger"
    _ "wongnok/docs"          // blank import — ไม่ใส่แล้วโหลด spec ไม่ขึ้น
)

router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// เปิดที่ localhost:8080/swagger/index.html

รัน swag init

# โครงที่ main.go อยู่ cmd/api/ ใช้แบบนี้ไม่ได้
swag init
#   → cannot parse source files .../main.go: no such file or directory
#   → ไม่มีโฟลเดอร์ docs/ เกิดขึ้นเลย

# ต้องชี้ไฟล์ด้วย -g
swag init -g cmd/api/main.go
Flagทำอะไรผลที่วัดได้จริง (swag v1.16.4)
-g cmd/api/main.goชี้ไฟล์ที่มี General API Infoจำเป็น เมื่อ main.go ไม่ได้อยู่ root
--parseInternalไม่ใช่ ./internal ของเรา — หมายถึง internal package ของ dependency และของ Go เองใส่เดี่ยว ๆ ไม่มีผลอะไรเลย · md5 ของ swagger.json เท่ากับตอนไม่ใส่ทุกไบต์ · มีความหมายต่อเมื่อคู่กับ --parseDependency
--parseDependencyสแกน struct จาก module อื่นใช้เมื่อ response อ้าง type จาก external package · ตัวนี้เองที่ทำให้ชื่อเปลี่ยนเป็น internal_user.UserResponse (ตัดสั้นด้วย --useStructName)

ไฟล์ที่ได้: docs/docs.go (ตัวที่ blank import) · docs/swagger.json · docs/swagger.yaml
บรรทัด warning: failed to get package name in dir: ./ ขึ้นแม้ตอนสำเร็จ — ไม่เป็นไร

struct tag ที่มีผลกับเอกสาร

tagผลใน Swagger UI
json:"name"ชื่อ field ในเอกสาร
example:"ผัดไทย"ค่าตัวอย่างในกล่อง Example Value
binding:"required"field ขึ้นเป็น required
enums:"a,b,c"แสดงเป็น dropdown
swaggerignore:"true"ซ่อน field จากเอกสาร

สามพฤติกรรมของ swag ที่ยืนยันด้วยผลรันจริง: Name *string → เอกสารขึ้นเป็น string ธรรมดา · json:"name,omitempty" → ใช้ชื่อ name · method อย่าง ToUser() ถูกมองข้าม swag สนใจแค่ field

เอา swagger.json ไปใช้ต่อ

14 · Redis — สองคำสั่งที่เพิ่มจาก Day 7

เรื่อง Redis ที่เหลือทั้งหมดอยู่ใน บทเรียน 32 และ cheat sheet Day 7 แล้ว สไลด์ Day 8 มีของใหม่จริง ๆ แค่สองคำสั่งนี้

# SET พร้อมตั้งเวลาหมดอายุในคำสั่งเดียว (นิยมใช้สุด)
SET username "devpool" EX 60

# GETDEL — อ่านค่าแล้วลบทิ้งในคำสั่งเดียว (atomic)
GETDEL username

SET … EX 60 ดีกว่า SET แล้วตามด้วย EXPIRE เพราะ ถ้าโปรเซสตายระหว่างสองคำสั่ง key จะค้างอยู่ตลอดไป

15 · Wongnok API spec

ResourceEndpoint
RecipesPOST /recipes — สร้างสูตรอาหาร
GET /recipes — แสดงสูตรของทุกคน
GET /recipes/:id — รายละเอียดสูตรนั้น
PUT /recipes/:id — แก้ไขสูตร
DELETE /recipes/:id — ลบสูตร
FavoritePOST /recipes/:id/favorite
DELETE /recipes/:id/favorite
RatingPOST /recipes/:id/rating
UserGET /users/:id
PUT /users/:id
AuthGET /login — เข้าสู่ระบบด้วย Keycloak
GET /callback — รับข้อมูล authentication
POST /exchange — แลก one-time code เป็น access token
GET /logout

กลุ่ม Auth ขัดกฎข้อ 1 และ 7 (เป็น verb และ GET ที่เปลี่ยน state) แต่มีเหตุผลรองรับ — OAuth redirect flow ต้องให้เบราว์เซอร์พาไปเอง ซึ่งทำได้แค่ GET · ดู บทเรียน 42

16 · ตารางกับดักรวมทั้งวัน

เขียนแบบนี้สิ่งที่เกิดขึ้นจริงแก้
w.Header().Set(…) หลัง w.WriteHeader(…)ไม่มีผล เงียบสนิท ไม่มี error ไม่มี logลำดับ Header → WriteHeader → Write
w.WriteHeader(400) หลังเขียน body แล้วstatus ยังเป็น 200 · server log http: superfluous response.WriteHeader callreturn ทันทีหลังตอบ error
json.NewEncoder(w).Encode(…) โดยไม่ set Content-Typeclient ได้ text/plain; charset=utf-8set เองก่อน WriteHeader
mux.HandleFunc("GET/hello", …)Go ตีเป็น host=GET → 404 ทุก requestเว้นวรรคหลัง method
Gin: ยิง method ผิดได้ 404 ไม่ใช่ 405r.HandleMethodNotAllowed = true
c.BindJSON แล้วเขียน c.JSON(422, …) ต่อได้ 400 ไม่ใช่ 422 · [GIN-debug] [WARNING] Headers were already written.ใช้ c.ShouldBindJSON เสมอ
Age int `binding:"required"`ส่ง 0 มาแล้วไม่ผ่านเปลี่ยนเป็น *int
c.Abort() เปล่า ๆclient ได้ 200 body ว่างc.AbortWithStatusJSON(…)
r.Use(cors…) หลัง ลง route แล้วroute นั้นไม่ได้ middleware เลยUse ก่อนลง route เสมอ
cors.Default() + JWTเบราว์เซอร์บล็อก Authorizationcors.New แล้วใส่ AllowHeaders เอง
ทดสอบ CORS ด้วย curl ธรรมดาไม่มี CORS header ออกมาสักตัวต้องใส่ -H "Origin: …"
assert.Equal กับ body JSONพังเมื่อลำดับ key เปลี่ยนassert.JSONEq
เทียบ "application/json" เฉย ๆพัง — Gin ใส่ ; charset=utf-8 ด้วยเทียบสตริงเต็ม
router.GET("/swagger", …)/swagger/index.html → 404 page not foundต้องเป็น /swagger/*any
ลืม _ "wongnok/docs"หน้า UI ยัง 200 แต่ /swagger/doc.json คืน 500 body ว่าง → UI เปิดได้แต่ไม่มี endpointใส่ blank import
แก้ annotation แล้วไม่รัน swag initเอกสารเป็นของเก่ารันใหม่ทุกครั้ง + restart server
os.Getenv("POSTGRES_DNS") — พิมพ์ชื่อ env ผิดคืน "" เงียบสนิท · go build และ go vet ผ่านทั้งคู่ (exit=0 ไม่มี output) · โปรแกรมเดินต่อได้ ไม่ panicos.LookupEnv แล้วเช็ค ok — ดูหัวข้อ 18
ลืมตั้ง DSN แล้วเรียก sql.Opensql.Open คืน err=<nil> · error โผล่ตอนแตะ connection ครั้งแรก (Ping/Query) เป็น dial tcp [::1]:5432: connect: connection refused ซึ่งไม่มีคำว่า DSN หรือ env เลยตรวจ env ให้ครบตั้งแต่ตอน start

17 · ตารางแปล error / panic ที่เจอบ่อย

ข้อความแปลว่า
http: superfluous response.WriteHeader call from main.func8 (main.go:60)เขียน status ซ้ำ — ตัวเลขในวงเล็บคือบรรทัดที่ต้องไปแก้
parsing "GET hello": at offset 4: host/path missing /pattern ของ ServeMux ลืม / นำหน้า path
':userId' in new path '/users/:userId' conflicts with existing wildcard ':id'Gin: param ตำแหน่งเดียวกันต้องชื่อเดียวกันทั้ง tree
handlers are already registered for path '/users/:id'Gin: ลง path+method เดิมซ้ำ
catch-all routes are only allowed at the end of the path in path '/a/*any/b'Gin: * ต้องอยู่ท้ายสุด
Key: 'Req.Age' Error:Field validation for 'Age' failed on the 'required' tagvalidator: field เป็น zero value — ดูกล่องเตือนหัวข้อ 10
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 422เขียน response ไปแล้วครั้งหนึ่ง — มักเกิดจาก BindJSON
unknown field LeftDelim in struct literal of type "github.com/swaggo/swag".Specเวอร์ชัน swag CLI กับ library ไม่ตรงกัน
cannot parse source files .../main.go: no such file or directoryswag init หา main.go ที่ root ไม่เจอ — ต้องใช้ -g

💬 ถามได้เจอ error ที่ไม่มีในตารางนี้ ส่งข้อความเต็ม ๆ มาได้เลย เดี๋ยวรันซ้ำแล้วเพิ่มลงตาราง — ตารางนี้โตมาจากของที่รันเจอจริงทั้งหมด ไม่ได้ลอกมาจากที่ไหน

18 · Environment Variables — อ่านค่าคอนฟิกให้ไม่พังเงียบ

ทุกอย่างในหัวข้อนี้รันจริงบน Go 1.26.5 darwin/arm64 · ตัวแปรที่ใช้ทดสอบคือ LOG_LEVEL · โปรแกรมพิมพ์ค่าด้วย %q เครื่องหมายคำพูดในผลลัพธ์จึงเป็นของจริง

os.Getenv vs os.LookupEnv — สามกรณีที่ต่างกันจริง

สถานะของตัวแปรตั้งยังไงos.Getenv คืนos.LookupEnv คืน
ตั้งค่าปกติenv LOG_LEVEL=DEBUG cmd"DEBUG""DEBUG", ok=true
ตั้งเป็นค่าว่างenv LOG_LEVEL= cmd · export LOG_LEVEL="""""", ok=true
ไม่ได้ตั้งเลยenv -u LOG_LEVEL cmd · unset LOG_LEVEL"""", ok=false
# 1) ตั้งค่าปกติ
$ env LOG_LEVEL=DEBUG ./envtest
Getenv    = "DEBUG"
LookupEnv = "DEBUG", ok=true

# 2) ตั้งเป็นค่าว่าง — ไม่มีอะไรหลังเครื่องหมาย =
$ env LOG_LEVEL= ./envtest
Getenv    = ""
LookupEnv = "", ok=true

# 3) ไม่ได้ตั้งเลย
$ env -u LOG_LEVEL ./envtest
Getenv    = ""
LookupEnv = "", ok=false
แถวที่ 2 กับ 3 คือทั้งหมดของเรื่องนี้

os.Getenv คืน "" เหมือนกันเป๊ะทั้ง “ตั้งเป็นค่าว่าง” และ “ไม่ได้ตั้งเลย” — แยกไม่ออก · os.LookupEnv คืน ok มาให้ด้วย จึงเป็นตัวเดียวที่แยกได้
เทียบกับ c.GetQuery ของ Gin ในหัวข้อ 9 ได้ตรง ๆ — ปัญหาเดียวกัน แก้ด้วยวิธีเดียวกัน

ฝั่ง shell ก็แยกไม่ออกเหมือนกัน — echo $LOG_LEVEL และ [ -z "$LOG_LEVEL" ] ให้ผลเหมือนกันทั้งสองกรณี ต้องใช้ท่านี้ถึงจะเห็นความต่าง:

$ export LOG_LEVEL=""; printenv | grep -c '^LOG_LEVEL'
1                       # ตัวแปรมีอยู่จริง แค่ค่าว่าง

$ unset LOG_LEVEL;      printenv | grep -c '^LOG_LEVEL'
0                       # ไม่มีตัวแปรนี้แล้วจริง ๆ

# แบบสั้นในสคริปต์
$ [ -z "${LOG_LEVEL+x}" ] && echo 'UNSET'

os.Setenv(k, "") ไม่เท่ากับ os.Unsetenv(k) — แบบแรก process ลูกเห็นตัวแปรอยู่แต่ค่าว่าง (ok=true) แบบหลังลูกไม่เห็นตัวแปรเลย (ok=false) · และการแก้ env ในโปรแกรม Go มีผลกับลูกที่ spawn หลังจากนั้นเท่านั้น ส่งกลับขึ้นไปที่ shell แม่ไม่ได้ — โปรแกรมออกไปพร้อม LOG_LEVEL ที่ตั้งไว้ แต่ shell แม่ยังเห็นว่า UNSET เหมือนเดิม environment ไหลลงล่างทางเดียว

แปลง type — env เป็น string เสมอ

func mustEnv(key string) string {
    v, ok := os.LookupEnv(key)
    if !ok {
        log.Fatalf("missing required env %s", key)   // ตายตั้งแต่ตอน start ดีกว่าพังตอนมีคนใช้
    }
    return v
}

func envInt(key string, def int) int {
    raw, ok := os.LookupEnv(key)
    if !ok || raw == "" {
        return def
    }
    n, err := strconv.Atoi(strings.TrimSpace(raw))   // TrimSpace: Atoi ไม่ตัดช่องว่างให้
    if err != nil {
        log.Fatalf("env %s=%q is not an int: %v", key, raw, err)
    }
    return n
}

strconv.Atoi — ผลรันจริงทุกเคส

ส่งเข้าไปได้ค่าerror
"8080"8080<nil>
"+8080"8080<nil>
"08080"8080<nil>
"" (env ไม่ได้ตั้ง)0strconv.Atoi: parsing "": invalid syntax
"8080 " (ช่องว่างท้าย)0strconv.Atoi: parsing "8080 ": invalid syntax
" 8080" (ช่องว่างหน้า)0strconv.Atoi: parsing " 8080": invalid syntax
"8080\n"0strconv.Atoi: parsing "8080\n": invalid syntax
"8080.0"0strconv.Atoi: parsing "8080.0": invalid syntax
"8_080"0strconv.Atoi: parsing "8_080": invalid syntax
"99999999999999999999"9223372036854775807strconv.Atoi: parsing "99999999999999999999": value out of range
⚠ error แล้วไม่ได้ 0 เสมอ

แถวสุดท้าย: เลขล้น int64 ทำให้ Atoi คืน 9223372036854775807 (math.MaxInt64) พร้อม error ไม่ใช่คืน 0 · ใครที่เผลอ _ = err แล้วใช้ค่าที่คืนมาต่อ จะได้เลขมหาศาลแทนที่จะเป็นศูนย์ · และ error กรณีนี้เป็น value out of range (strconv.ErrRange) ไม่ใช่ invalid syntax

strconv.ParseBool — รับได้ 12 ค่าเท่านั้น

ได้ trueได้ falseerror ทั้งหมด
1 t T true TRUE True 0 f F false FALSE False yes no on off y n Y N 2 -1 "" tRue TrUe " true" "true "

โค้ดจริงของ Go 1.26.5 (src/internal/strconv/atob.go) เป็น switch เทียบสตริงตรง ๆ 12 case ไม่มี trim ไม่มี lowercase — นี่คือเหตุผลที่ TRUE ผ่านแต่ tRue ไม่ผ่าน

strconv.ParseBool("yes") -> v=false, err=strconv.ParseBool: parsing "yes": invalid syntax
strconv.ParseBool("")    -> v=false, err=strconv.ParseBool: parsing "": invalid syntax

จุดที่หลอกที่สุด ตอน error ParseBool คืน false ซึ่งหน้าตาเหมือน “ตั้งใจปิดฟีเจอร์” เป๊ะ ๆ · คนที่เขียน .env มาจาก YAML หรือ stack อื่น จะพิมพ์ FEATURE_X=yes แล้วงงว่าทำไมฟีเจอร์ไม่เปิด

time.ParseDuration — error message มีสามแบบ

ส่งเข้าไปได้ค่าerror
"30s"30s<nil>
"1h30m"1h30m0s<nil>
"30ms"30ms<nil>
"-5s"-5s<nil>
"0"0s<nil> ← ข้อยกเว้นเดียวที่ไม่ต้องมีหน่วย
""0stime: invalid duration ""
"abc"0stime: invalid duration "abc"
" 30s"0stime: invalid duration " 30s"
"30" (ลืมหน่วย)0stime: missing unit in duration "30"
"30s " (ช่องว่างท้าย)0stime: unknown unit "s " in duration "30s "
"30S" (S ตัวใหญ่)0stime: unknown unit "S" in duration "30S"

TIMEOUT=30 ที่คนเขียนไฟล์คอนฟิกคิดว่า “สามสิบวินาที” พังทันทีด้วยข้อความ missing unit · ส่วนช่องว่างท้ายบรรทัดไปโผล่เป็น unknown unit "s " ซึ่งอ่านแล้วงงกว่ามาก — สังเกตช่องว่างในเครื่องหมายคำพูดให้ดี

strconvtime.ParseDuration
error type*strconv.NumError*time.parseDurationError (ไม่ export)
แยกแยะเชิงโปรแกรมerrors.As แล้วอ่าน Func/Num/Err · เทียบกับ strconv.ErrSyntax · strconv.ErrRangeทำได้แค่อ่านข้อความ
รูปแบบข้อความstrconv.Atoi: parsing "…": invalid syntaxไม่มีคำว่า parsing เลย คนละแบบสิ้นเชิง
ทำไม ignore err ตรงนี้ถึงอันตรายเป็นพิเศษ

ทั้งสามฟังก์ชันคืนค่าที่ดูเหมือนค่าปกติตอน error — Atoi ได้ 0 (เหมือนตั้ง port 0) · ParseBool ได้ false (เหมือนตั้งใจปิด) · ParseDuration ได้ 0s (เหมือน timeout ทันที)
แอปจึงไม่ crash แต่ทำงานผิด — เป็นบั๊กชนิดที่หาที่มายากที่สุด

วิธีตั้ง env — ห้าที่ที่ต้องรู้จัก

ที่ไหนเขียนยังไงขอบเขต
shell — exportexport APP_PORT=8080ทุกคำสั่งที่รันหลังจากนี้ใน shell เดียวกัน
shell — ไม่มี exportAPP_PORT=8080 เฉย ๆshell เห็น แต่ process ลูกไม่เห็น
inline หน้าคำสั่งAPP_PORT=9090 ./apiเฉพาะ process นั้น ไม่ค้างใน shell
docker — ระบุค่าdocker run -e LOG_LEVEL=INFO …container นั้น
docker — ส่งต่อจาก hostdocker run -e LOG_LEVEL … (ไม่มี =)หยิบค่าจาก shell ปัจจุบันส่งเข้าไป — ค่าลับไม่โผล่ใน command line
docker — ไฟล์docker run --env-file app.env …ทั้งไฟล์ · -e ชนะ --env-file
composeenvironment: / env_file:service นั้น
k8senv: · configMapKeyRef · secretKeyRefcontainer นั้นใน pod
# assignment เปล่า ๆ ไม่มี export — shell เห็น แต่โปรแกรมไม่เห็น
$ APP_PORT=5555; echo "shell sees: $APP_PORT"; ./api
shell sees: 5555
APP_PORT  =                      ← ว่าง

# ใส่ export แล้วลูกถึงเห็น
$ export APP_PORT=5555; ./api
APP_PORT  = 5555

# inline ชนะ export — และไม่ค้างใน shell
$ export APP_PORT=8080; APP_PORT=7777 ./api; echo "shell still: $APP_PORT"
APP_PORT  = 7777
shell still: 8080

env ข้ามได้หลายทอดจริง — shell → go run → binary เป็นคนละ process กันทั้งหมด แต่ค่าวิ่งถึงครบ:

$ echo "this shell PID = $$"; APP_PORT=6262 go run .
this shell PID = 34148
APP_PORT = 6262
my PID = 34157  parent PID = 34151

Docker — ไม่สืบทอดอัตโนมัติ

$ docker run --rm -e LOG_LEVEL=INFO alpine printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=9666ec3f3209
LOG_LEVEL=INFO
HOME=/root

# -e ชื่อเปล่า ๆ = หยิบค่าจาก shell ปัจจุบัน
$ export LOG_LEVEL=FROM_HOST; docker run --rm -e LOG_LEVEL alpine printenv LOG_LEVEL
FROM_HOST

# --env-file vs -e : -e ชนะ
$ printf 'LOG_LEVEL=FROM_FILE\nAPP_PORT=3000\n' > app.env
$ docker run --rm --env-file app.env -e LOG_LEVEL=FROM_FLAG alpine printenv LOG_LEVEL
FROM_FLAG
⚠ container ไม่สืบทอด env จาก host shell
$ export LOG_LEVEL=FROM_HOST
$ echo $LOG_LEVEL
FROM_HOST
$ docker run --rm alpine printenv LOG_LEVEL; echo exit=$?
exit=1                    # ไม่พิมพ์อะไรเลย

go run สืบทอด env จาก shell ให้อัตโนมัติ แต่ Docker ไม่ — ต้องส่งด้วย -e หรือ --env-file เท่านั้น · อย่าเหมารวมว่า “process ลูกเห็นเสมอ” เพราะ container ไม่ใช่ process ลูกธรรมดา

บน macOS ที่ใช้ colima — docker run กับ image ที่ยังไม่มีในเครื่อง อาจพังก่อนได้เห็นเรื่อง env ด้วยซ้ำ:
docker: error getting credentials - err: exec: "docker-credential-osxkeychain": executable file not found in $PATH
สาเหตุคือ ~/.docker/config.json ตั้ง "credsStore": "osxkeychain" ไว้แต่ไม่มี binary นั้น · ทางออกง่ายสุดคือ docker pull image ไว้ล่วงหน้า หรือใช้ image ที่มีในเครื่องอยู่แล้ว

compose และ k8s — ไวยากรณ์อ้างอิง

สองบล็อกนี้เป็นไวยากรณ์สำหรับก็อปไปใช้ ไม่ใช่ผลรัน — ต่างจากบล็อกอื่นในหน้านี้

# docker-compose.yml
services:
  api:
    image: myapi:latest
    environment:
      LOG_LEVEL: INFO
      APP_PORT: "8080"
      POSTGRES_DSN: ${POSTGRES_DSN}   # หยิบจาก shell / .env ของ host
    env_file:
      - app.env
# k8s — ส่วน env ของ container
env:
  - name: LOG_LEVEL
    value: "INFO"

  - name: APP_PORT              # ค่าธรรมดา → ConfigMap
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: APP_PORT

  - name: POSTGRES_DSN          # ความลับ → Secret
    valueFrom:
      secretKeyRef:
        name: app-secret
        key: POSTGRES_DSN

เส้นแบ่ง ConfigMap = ค่าที่เปิดให้ใครดูก็ได้ (log level, port, feature flag) · Secret = ค่าที่หลุดแล้วเดือดร้อน (DSN ที่มีรหัสผ่าน, API key, JWT secret) · ทั้งคู่จบลงที่ตัวแปร env ตัวเดียวกันในสายตาโปรแกรม — โค้ด Go เขียน os.LookupEnv("POSTGRES_DSN") เหมือนกันหมด ไม่ต้องรู้ว่ามาจากไหน

12-Factor — Factor III: Config

“เก็บ config ไว้ใน environment” · นิยามของ config ในที่นี้คือ ทุกอย่างที่ต่างกันระหว่าง deploy — dev, staging, production — เช่น DSN ของฐานข้อมูล, endpoint ของ service อื่น, credential, ชื่อโฮสต์ · ส่วนที่เหมือนกันทุก deploy เช่น routing table หรือชื่อ route ไม่ใช่ config

ทำไมต้อง env ไม่ใช่ไฟล์ในโค้ดผลที่ได้
ค่าลับไม่อยู่ใน repoเผลอ push ขึ้น GitHub แล้วรหัสผ่านไม่หลุด
เปลี่ยนค่าโดยไม่ต้อง build ใหม่artifact ก้อนเดียว deploy ได้ทุก environment
env เป็นมาตรฐานของ OSใช้ได้ทุกภาษา ทุก runtime ทุก orchestrator

บททดสอบข้อเดียว — ถ้า repo นี้กลายเป็น open source พรุ่งนี้ มีอะไรหลุดไหม · ถ้ามี แปลว่าค่านั้นควรอยู่ใน env ไม่ใช่ในโค้ด

19 · ตารางแปลง API ที่ออกแบบแย่ → RESTful

ของเดิมที่ควรเป็นผิดกฎข้อไหน
GET /getUser?id=123 GET /users/123 ใช้กริยาใน path (กฎ 1) + ใช้ query แทน path param สำหรับระบุตัวตน (หัวข้อ 5)
POST /deleteUser?id=123 DELETE /users/123 ใช้ method ผิด ทำให้ไม่ idempotent (หัวข้อ 2) + action อยู่ใน URI (กฎ 7)
POST /createUser?name=Alice&password=123456 POST /users + body ความลับอยู่ใน query string โผล่ใน log (หัวข้อ 5) + กริยาใน path (กฎ 1)
GET /getPaidOrders GET /orders?status=paid ยัดตัวกรองไว้ในชื่อ path แทน query (กฎ 6) + กริยาใน path (กฎ 1)

ทั้งสี่คู่ลงทะเบียนบน Gin v1.12.0 จริงแล้วยิงเทียบกัน — คู่ที่ 1 กับ 4 ให้ผลลัพธ์เหมือนกันเป๊ะ เส้นแบ่งจึงไม่ได้อยู่ที่ “ทำงานได้ไหม” แต่อยู่ที่สามอย่างข้างล่างนี้

① เส้นที่ 3 — password ใน query โผล่ใน access log จริง

$ curl -s -X POST 'http://127.0.0.1:18080/createUser?name=Alice&password=123456'
$ grep -n '123456' gin-access.log
7:[GIN] 2026/08/21 - 13:01:28 | 200 |  54.083µs | 127.0.0.1 | POST "/createUser?name=Alice&password=123456"

# ฝั่งที่ถูก — ส่ง password ทาง body
$ curl -s -X POST 'http://127.0.0.1:18080/users' \
    -H 'Content-Type: application/json' -d '{"name":"Alice","password":"123456"}'
$ grep -n '123456' gin-access.log
7:[GIN] … POST "/createUser?name=Alice&password=123456"      ← เจอแค่บรรทัดเดิมบรรทัดเดียว

# บรรทัดที่ log ไว้ของ POST /users มีแค่นี้
8:[GIN] 2026/08/21 - 13:01:28 | 201 | 195.958µs | 127.0.0.1 | POST "/users"

gin.Logger() เขียน path + RawQuery ลง log ทุกครั้ง — รหัสผ่านจึงถูกบันทึกเป็น plaintext อ่านได้ตรง ๆ · ส่วน body ไม่ถูก log เลย ทั้งที่ server อ่านค่าได้ครบ · และนี่เพิ่งแค่ log ของ server เราเอง ยังไม่นับ proxy, CDN, browser history และ Referer ที่หัวข้อ 5 เตือนไว้

② เส้นที่ 2 — idempotency ไม่ได้ดูจาก status code

# ✔ DELETE /users/123 ยิงซ้ำ 3 ครั้ง
call 1 -> {"deleted":"123","remaining":1}                  | HTTP 200
call 2 -> {"error":"user not found","id":"123",…}          | HTTP 404
call 3 -> {"error":"user not found","id":"123",…}          | HTTP 404

# ✘ POST /deleteUser?id=123 ยิงซ้ำ 3 ครั้ง
call 1 -> {"deleted":"123","remaining":1}                  | HTTP 200
call 2 -> {"error":"user not found","id":"123",…}          | HTTP 404
call 3 -> {"error":"user not found","id":"123",…}          | HTTP 404
⚠ ยิงซ้ำแล้วไม่เห็นความต่าง — และนั่นคือประเด็น

status code ออกมา 200, 404, 404 เหมือนกันเป๊ะทั้งสองแบบ และ state ปลายทางก็เหมือนกัน · idempotency ไม่ใช่สิ่งที่ server สะท้อนออกมาเป็นตัวเลข แต่เป็นสัญญาระดับ HTTP spec (RFC 9110 กำหนดว่า DELETE idempotent ส่วน POST ไม่) ที่ client library, proxy และ retry layer เอาไปใช้ตัดสินใจว่า “ยิงซ้ำเองได้ไหมเมื่อ timeout” — ใช้ POST แปลว่าไม่มีใครกล้า retry ให้

อีกจุดหนึ่ง: 404 ที่ได้ตอนยิงซ้ำ หน้าตาแยกไม่ออกจาก 404 ที่เกิดจากลบของที่ไม่เคยมี — DELETE /users/999 ก็ได้ 404 ตั้งแต่ครั้งแรก · API หลายเจ้าจึงเลือกตอบ 204 No Content ทุกครั้งแทน เพื่อให้ผลลัพธ์เหมือนกันจริง ๆ ทุกครั้ง

③ เส้นที่ 1 และ 4 — ดึงค่าจากคนละที่

GET /getUser?id=123      -> {"extracted_by":"c.Query(\"id\")",  "id":"123", …}
GET /users/123           -> {"extracted_by":"c.Param(\"id\")",  "id":"123", …}
GET /orders?status=paid  -> {"extracted_by":"c.Query(\"status\")","filter_status":"paid", …}
POST /users (JSON body)  -> {"extracted_by":"c.ShouldBindJSON (request body)","name":"Alice","password_len":6}
กับดักคู่กับ ?status=paid
$ curl -s -w ' | HTTP %{http_code}\n' 'http://127.0.0.1:18080/orders'
{"filter_status":"","orders":[{"id":"o1","status":"paid"},{"id":"o2","status":"pending"},
 {"id":"o3","status":"paid"},{"id":"o4","status":"cancelled"}]} | HTTP 200

ลืมใส่ ?status= แล้ว c.Query คืน "" เงียบ ๆ ไม่ error ไม่ 400 — ได้ order ทั้งหมดรวม cancelled กลับมา แทนที่จะได้ list ว่าง · ใช้ c.GetQuery (หัวข้อ 9) ถ้าต้องแยก “ไม่ส่งมา” ออกจาก “ส่งมาว่าง”

c.Param คืนค่าที่ URL-decode แล้ว — ส่ง /users/abc%20xyz เข้าไปได้ "abc xyz" (มีช่องว่างจริง)

ตอนลองเองแล้วเจอ 404 งง ๆ — ยิง POST /users/123 (route มีแค่ GET กับ DELETE) ได้ 404 ไม่ใช่ 405 เพราะ gin.New() ตั้ง HandleMethodNotAllowed = false เป็นค่าเริ่มต้น · เปิดเป็น true แล้วจะได้ 405 พร้อม header Allow: GET — ดูหัวข้อ 8 และ 9

อ้างอิง