สารบัญ
- เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
- REST — method กับความหมาย
- Status code — เลือกตัวไหน
- กฎตั้งชื่อ 7 ข้อ
- query / body / header / path — ข้อมูลไปทางไหน
- net/http — โครงที่ก็อปไปใช้ได้
- net/http — ไวยากรณ์ pattern (Go 1.22+)
- Gin — โครงที่ก็อปไปใช้ได้
- Gin — Context ที่ใช้บ่อย
- Gin — binding tag + ข้อความ error จริง
- Gin — middleware & CORS
- httptest — โครงเทสที่ก็อปไปใช้ได้
- Swagger — คำสั่ง + annotation ครบชุด
- Redis — สองคำสั่งที่เพิ่มจาก Day 7
- Wongnok API spec
- ตารางกับดักรวมทั้งวัน
- ตารางแปล error / panic ที่เจอบ่อย
- Environment Variables — Getenv/LookupEnv · แปลง type · วิธีตั้งค่า · 12-Factor
- ตารางแปลง API ที่ออกแบบแย่ → RESTful
1 · เวอร์ชันที่ทุกอย่างในหน้านี้รันผ่าน
| ของ | เวอร์ชัน | ติดตั้งยังไง |
|---|---|---|
| Go | 1.26.5 darwin/arm64 | — |
github.com/gin-gonic/gin | v1.12.0 | go get github.com/gin-gonic/gin |
github.com/gin-contrib/cors | v1.7.7 | go get github.com/gin-contrib/cors |
github.com/go-playground/validator/v10 | v10.30.1 | ติดมากับ gin |
github.com/stretchr/testify | v1.12.1 | go get github.com/stretchr/testify |
swag (CLI) | v1.16.4 | go install github.com/swaggo/swag/cmd/swag@latest |
github.com/swaggo/swag (library) | v1.16.6 | go get github.com/swaggo/swag@latest ← ต้องทำ ดูหัวข้อ 13 |
github.com/swaggo/gin-swagger | v1.6.1 | go get -u github.com/swaggo/gin-swagger |
github.com/swaggo/files | v1.0.1 | go get -u github.com/swaggo/files |
พิมพ์ 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 ไม่ใช่ verb | GET /users | GET /getUsers |
| 2 | ชื่อ resource ต้องสม่ำเสมอทั้งระบบ | /users /orders /products | /user /orders /product-list |
| 3 | lowercase | /users | /Users /PRODUCTS |
| 4 | หลายคำคั่นด้วย - | /payment-methods | /paymentMethods /payment_methods |
| 5 | ความสัมพันธ์ใช้ path | /users/123/orders | /orders?userId=123 (ใช้ได้ แต่สื่อความเป็นเจ้าของน้อยกว่า) |
| 6 | filter ใช้ query | /orders?status=paid | /orders/status/paid |
| 7 | อย่าใส่ action ใน URI ถ้า method สื่อได้แล้ว | POST /users · DELETE /users/123 | POST /users/create · POST /users/123/delete |
สไลด์เขียน // ✅ แนะนำ ไว้เหนือ 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 ของการเชื่อมต่อ | header | Authorization: Bearer … |
?token=eyJhb… ไปโผล่ใน access log ของทุก proxy ที่ผ่าน,
ใน browser history, และใน Referer ที่ส่งต่อไปเว็บอื่น — ใส่ใน header เสมอ
Header สามตัวที่ต้องรู้จัก
| Header | ใครส่ง | บอกอะไร |
|---|---|---|
Authorization | client | credential เช่น Bearer <token> |
Content-Type | ทั้งคู่ | “body ที่ผมส่งมาเป็นรูปแบบนี้” |
Accept | client | “ผมอยากได้คำตอบเป็นรูปแบบนี้” |
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 param | r.PathValue("id") | c.Param("id") |
| ตอบ JSON | ต้อง set header เอง ไม่งั้นได้ text/plain | c.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 ในสตริงเดียว
Age int `binding:"required"` แล้วผู้ใช้ส่ง {"age":0} มา
→ ไม่ผ่าน เพราะ validator เช็ค “ไม่ใช่ zero value” ไม่ใช่ “มี key นี้”
เจอกับ 0, "", false เหมือนกันหมด
แก้ด้วย pointer: Age *int `binding:"required"` —
ส่ง 0 มาผ่าน แต่ไม่ส่งมาเลยไม่ผ่าน
| เมธอด | อ่านจาก | tag ที่ใช้ |
|---|---|---|
ShouldBindJSON | body | json + binding |
ShouldBindQuery | query string | form + binding |
ShouldBindUri | path param | uri + 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
ดู 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.Code | int เช่น 200 |
w.Body.String() | body ทั้งก้อนเป็นสตริง |
w.Header().Get("X") | header — ไม่ต้องเรียก w.Result() |
httptest.NewRequest | panic ถ้า 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 # ← สำคัญ ดูกล่องเตือนข้างล่าง
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 ไปใช้ต่อ
- GitLab — commit
docs/เข้า repo (อย่าใส่ใน.gitignore) แล้วเปิดไฟล์ กด Display rendered file · ชื่อไฟล์ต้องมีคำว่าswaggerหรือopenapiและนามสกุล.json/.yaml/.yml - Postman — Import → File →
swagger.jsonได้ collection ครบทุก endpoint - openapi-generator —
openapi-generator generate -i swagger.json -g typescript-axios -o ./client
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
| Resource | Endpoint |
|---|---|
| Recipes | POST /recipes — สร้างสูตรอาหาร |
GET /recipes — แสดงสูตรของทุกคน | |
GET /recipes/:id — รายละเอียดสูตรนั้น | |
PUT /recipes/:id — แก้ไขสูตร | |
DELETE /recipes/:id — ลบสูตร | |
| Favorite | POST /recipes/:id/favorite |
DELETE /recipes/:id/favorite | |
| Rating | POST /recipes/:id/rating |
| User | GET /users/:id |
PUT /users/:id | |
| Auth | GET /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 call | return ทันทีหลังตอบ error |
json.NewEncoder(w).Encode(…) โดยไม่ set Content-Type | client ได้ text/plain; charset=utf-8 | set เองก่อน WriteHeader |
mux.HandleFunc("GET/hello", …) | Go ตีเป็น host=GET → 404 ทุก request | เว้นวรรคหลัง method |
| Gin: ยิง method ผิด | ได้ 404 ไม่ใช่ 405 | r.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 | เบราว์เซอร์บล็อก Authorization | cors.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) · โปรแกรมเดินต่อได้ ไม่ panic | os.LookupEnv แล้วเช็ค ok — ดูหัวข้อ 18 |
ลืมตั้ง DSN แล้วเรียก sql.Open | sql.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' tag | validator: 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 directory | swag 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
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 ไม่ได้ตั้ง) | 0 | strconv.Atoi: parsing "": invalid syntax |
"8080 " (ช่องว่างท้าย) | 0 | strconv.Atoi: parsing "8080 ": invalid syntax |
" 8080" (ช่องว่างหน้า) | 0 | strconv.Atoi: parsing " 8080": invalid syntax |
"8080\n" | 0 | strconv.Atoi: parsing "8080\n": invalid syntax |
"8080.0" | 0 | strconv.Atoi: parsing "8080.0": invalid syntax |
"8_080" | 0 | strconv.Atoi: parsing "8_080": invalid syntax |
"99999999999999999999" | 9223372036854775807 | strconv.Atoi: parsing "99999999999999999999": value out of range |
แถวสุดท้าย: เลขล้น int64 ทำให้ Atoi คืน
9223372036854775807 (math.MaxInt64) พร้อม error
ไม่ใช่คืน 0 · ใครที่เผลอ _ = err แล้วใช้ค่าที่คืนมาต่อ
จะได้เลขมหาศาลแทนที่จะเป็นศูนย์ · และ error กรณีนี้เป็น value out of range
(strconv.ErrRange) ไม่ใช่ invalid syntax
strconv.ParseBool — รับได้ 12 ค่าเท่านั้น
ได้ true | ได้ false | error ทั้งหมด |
|---|---|---|
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> ← ข้อยกเว้นเดียวที่ไม่ต้องมีหน่วย |
"" | 0s | time: invalid duration "" |
"abc" | 0s | time: invalid duration "abc" |
" 30s" | 0s | time: invalid duration " 30s" |
"30" (ลืมหน่วย) | 0s | time: missing unit in duration "30" |
"30s " (ช่องว่างท้าย) | 0s | time: unknown unit "s " in duration "30s " |
"30S" (S ตัวใหญ่) | 0s | time: unknown unit "S" in duration "30S" |
TIMEOUT=30 ที่คนเขียนไฟล์คอนฟิกคิดว่า “สามสิบวินาที” พังทันทีด้วยข้อความ
missing unit · ส่วนช่องว่างท้ายบรรทัดไปโผล่เป็น unknown unit "s "
ซึ่งอ่านแล้วงงกว่ามาก — สังเกตช่องว่างในเครื่องหมายคำพูดให้ดี
strconv | time.ParseDuration | |
|---|---|---|
| error type | *strconv.NumError | *time.parseDurationError (ไม่ export) |
| แยกแยะเชิงโปรแกรม | errors.As แล้วอ่าน Func/Num/Err · เทียบกับ strconv.ErrSyntax · strconv.ErrRange | ทำได้แค่อ่านข้อความ |
| รูปแบบข้อความ | strconv.Atoi: parsing "…": invalid syntax | ไม่มีคำว่า parsing เลย คนละแบบสิ้นเชิง |
ทั้งสามฟังก์ชันคืนค่าที่ดูเหมือนค่าปกติตอน error —
Atoi ได้ 0 (เหมือนตั้ง port 0) · ParseBool ได้ false
(เหมือนตั้งใจปิด) · ParseDuration ได้ 0s (เหมือน timeout ทันที)
แอปจึงไม่ crash แต่ทำงานผิด — เป็นบั๊กชนิดที่หาที่มายากที่สุด
วิธีตั้ง env — ห้าที่ที่ต้องรู้จัก
| ที่ไหน | เขียนยังไง | ขอบเขต |
|---|---|---|
shell — export | export APP_PORT=8080 | ทุกคำสั่งที่รันหลังจากนี้ใน shell เดียวกัน |
shell — ไม่มี export | APP_PORT=8080 เฉย ๆ | shell เห็น แต่ process ลูกไม่เห็น |
| inline หน้าคำสั่ง | APP_PORT=9090 ./api | เฉพาะ process นั้น ไม่ค้างใน shell |
| docker — ระบุค่า | docker run -e LOG_LEVEL=INFO … | container นั้น |
| docker — ส่งต่อจาก host | docker run -e LOG_LEVEL … (ไม่มี =) | หยิบค่าจาก shell ปัจจุบันส่งเข้าไป — ค่าลับไม่โผล่ใน command line |
| docker — ไฟล์ | docker run --env-file app.env … | ทั้งไฟล์ · -e ชนะ --env-file |
| compose | environment: / env_file: | service นั้น |
| k8s | env: · configMapKeyRef · secretKeyRef | container นั้นใน 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
$ 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
อ้างอิง
- MDN — HTTP response status codes — คำนิยามทางการของทุก status
- MDN — CORS · Preflight request
- pkg.go.dev —
net/http.ServeMux— ไวยากรณ์ pattern และกฎ precedence ฉบับทางการ - pkg.go.dev —
net/http/httptest - Gin Documentation · pkg.go.dev
- go-playground/validator — รายการ
bindingtag ทั้งหมด - gin-contrib/cors
- swaggo/swag — Declarative Comments Format — annotation ครบทุกตัว
- OpenAPI 2.0 (Swagger 2.0) Specification
- The Twelve-Factor App — III. Config — ต้นทางของหัวข้อ 18
- pkg.go.dev —
os.Getenv/os.LookupEnv· strconv · time.ParseDuration - RFC 9110 §9.2.2 — Idempotent Methods — นิยามทางการที่หัวข้อ 19 อ้างถึง
- Docker —
docker run --env· Kubernetes — Define Environment Variables - Cheat sheet Day 7 — Mockery · Testcontainers · Goose · Redis