🧰 ของที่บทนี้ใช้จริง — ตัวเลข ผลรัน และข้อความ error ทุกบรรทัดในหน้านี้ มาจากการรันบนเครื่องจริง ไม่ได้พิมพ์เอาเอง
- Go 1.26.5 darwin/arm64
swagCLI v1.16.4 ·github.com/swaggo/swag(library ในgo.mod) v1.16.6github.com/gin-gonic/ginv1.12.0 ·github.com/swaggo/gin-swaggerv1.6.1 ·github.com/swaggo/filesv1.0.1- โปรเจกต์ตัวอย่างชื่อ module
wongnokโครงเดียวกับ บทเรียน 27:cmd/api/main.go·internal/user/·internal/httputil/
ถ้าเครื่องคุณได้ผลไม่ตรง อย่าเพิ่งคิดว่าตัวเองผิด — เวอร์ชันต่างกันนิดเดียวผลก็ต่างได้ หัวข้อ 9 จะสอนวิธีเช็คด้วยตัวเอง
1 · ปัญหาที่ Swagger เกิดมาแก้
ลองนึกภาพจริง ๆ: คุณเขียน POST /api/v1/users เสร็จ
แล้วเปิด Google Docs พิมพ์ว่า body ต้องมี name กับ email ส่ง link ให้ frontend
อีกสองสัปดาห์คุณเพิ่ม binding:"required" ที่ email —
แล้วลืมกลับไปแก้ Docs
ที่เกิดขึ้นต่อจากนั้นไม่ใช่ “เอกสารไม่อัปเดตนิดหน่อย” แต่คือ:
frontend ส่ง request ที่ตรงตามเอกสารเป๊ะ ๆ แล้วได้ 400 กลับไป
เขาจะไม่สงสัยเอกสารก่อน เขาจะสงสัยโค้ดตัวเองก่อนเป็นชั่วโมง
นี่คือเหตุผลที่ประโยค “เอกสารที่ไม่ตรงกับโค้ด อันตรายกว่าไม่มีเอกสาร” ไม่ใช่คำสวย ๆ —
เอกสารที่ผิดคือ bug ที่ไม่มีใครเห็นว่ามันเป็น bug
❌ เขียนเอกสารด้วยมือ
- อยู่คนละที่กับโค้ด → แก้โค้ดแล้วลืมแก้เอกสาร
- ไม่มีใครรู้ว่าเวอร์ชันไหนล่าสุด
- frontend ต้องเดา หรือเดินมาถาม
- ผิดได้เงียบ ๆ ไม่มีอะไรเตือน
✅ generate จากโค้ด
- คอมเมนต์อยู่ติดกับ handler (ฟังก์ชันที่รับงานของ URL นั้น) ในไฟล์เดียวกัน
- เข้า git ไปพร้อม commit เดียวกับโค้ด
- frontend เปิด Swagger UI กดยิงลองได้เอง
- ยังผิดได้อยู่ ถ้าลืม generate ใหม่ — หัวข้อ 10 จะพิสูจน์ให้ดู
⚠️ อย่าเพิ่งเชื่อคำโฆษณา
Swagger ไม่ได้ทำให้เอกสารตรงกับโค้ดโดยอัตโนมัติ
สิ่งที่มันทำคือ ย้ายเอกสารมาอยู่ข้าง ๆ โค้ด เพื่อให้คุณมีโอกาสแก้พร้อมกัน
ตัวที่ทำให้มันตรงจริง ๆ คือขั้นตอนรัน swag init ที่คุณต้องไม่ลืม —
และนั่นคือกับดักอันดับหนึ่งของบทนี้
2 · OpenAPI คือ “มาตรฐาน” · Swagger คือ “ชุดเครื่องมือ”
สองคำนี้ถูกใช้ปนกันจนคนใหม่งง แยกให้ขาดตั้งแต่ตอนนี้ แล้วทุกอย่างที่เหลือจะเข้าที่:
📄 OpenAPI Specification (OAS)
คือมาตรฐานว่า “จะอธิบาย REST API เป็นไฟล์ยังไงให้เครื่องอ่านรู้เรื่อง”
ผลลัพธ์คือไฟล์ JSON หรือ YAML หนึ่งไฟล์ —
เป็นแค่ข้อความ ไม่ใช่โปรแกรม รันไม่ได้ ไม่มีอะไรเกี่ยวกับ Go เลย
🧰 Swagger
คือชุดเครื่องมือที่กินไฟล์ spec นั้นเข้าไป แล้วเปลี่ยนเป็นของที่ใช้ได้จริง — หน้าเว็บ, โค้ด client, ตัวตรวจ spec
ทุกคนเคยส่งไฟล์ PDF ให้กัน · “PDF” คือข้อตกลงว่าไฟล์แบบนี้ต้องหน้าตายังไง
ตัวไฟล์เองเปิดตัวเองไม่ได้ ทำอะไรไม่ได้เลย มันเป็นแค่ก้อนข้อความ
ส่วนที่ทำให้เราได้ประโยชน์จริงคือโปรแกรมที่เปิดมัน — Preview, Chrome, Adobe Reader
คนละยี่ห้อ คนละบริษัท แต่เปิดไฟล์ใบเดียวกันได้หมด เพราะทุกตัวทำตามข้อตกลงเดียวกัน
เรื่องนี้ก็วางตัวแบบเดียวกันเป๊ะ:
· OpenAPI = ข้อตกลงว่า “ไฟล์อธิบาย API ต้องหน้าตายังไง” — ที่เดียวกับคำว่า “PDF”
· docs/swagger.json = ไฟล์หนึ่งใบที่เขียนตามข้อตกลงนั้น — ที่เดียวกับไฟล์ .pdf ของเรา
· Swagger UI · Postman · openapi-generator = โปรแกรมที่เปิดไฟล์ใบนั้นไปใช้ต่อ — ที่เดียวกับ Preview / Chrome
ที่ไม่เหมือนกัน: PDF ทำมาให้คนอ่านเป็นหลัก
แต่ swagger.json ทำมาให้เครื่องอ่านก่อน แล้วเครื่องค่อยเปลี่ยนเป็นหน้าเว็บให้คนดูอีกที
และ PDF ส่งไปแล้วก็จบ ส่วน swagger.json ต้องสร้างใหม่ทุกครั้งที่โค้ดเปลี่ยน
ไม่งั้นมันจะเก่ากว่าโค้ดทันที — ซึ่งเป็นปัญหาที่ทั้งบทนี้พูดถึง
| เครื่องมือ | ทำอะไร |
|---|---|
| Swagger UI | หน้าเว็บอ่านเอกสาร + กดยิง API ได้จริง ← ตัวที่บทนี้ใช้ |
| Swagger Editor | เขียน/ตรวจ spec แล้วเห็นผลทันที |
| Swagger Codegen | ร่างโค้ด client หรือ server จาก spec |
ส่วนเรื่องเวอร์ชัน เป็นจุดที่คนใหม่พลาดตอนไปหา Google บ่อยมาก เพราะชื่อมันเปลี่ยนกลางคัน:
| เวอร์ชัน | ชื่อที่คนเรียกกัน | เครื่องมือฝั่ง Go |
|---|---|---|
| Swagger 2.0 | = OpenAPI 2.0 | swaggo/swag v1 ← ที่คอร์สนี้ใช้ |
| OpenAPI 3.x | รุ่นใหม่กว่า | swaggo/swag v2 (ยังเป็น rc) |
นี่ไม่ใช่เกร็ดไร้สาระ — มันโผล่ในไฟล์ที่คุณจะได้จริง
บรรทัดแรกของ docs/swagger.json ที่ผมรันออกมาคือ:
{
"swagger": "2.0",
"info": {
"description": "API สำหรับจัดการข้อมูลผู้ใช้ของ Wongnok",
"title": "Wongnok API",
"contact": {},
"version": "1.0"
},
"host": "localhost:8080",
"basePath": "/api/v1",
"schemes": ["http", "https"],
...
มันเขียนว่า "swagger": "2.0" ไม่ใช่ "openapi": "3.x"
ถ้าวันหนึ่งคุณเอาไฟล์นี้ไปให้เครื่องมือที่รับแต่ OpenAPI 3 แล้วมันบ่น —
คุณจะรู้ทันทีว่าปัญหาอยู่ตรงไหน แทนที่จะงงอยู่ครึ่งวัน
คำพวกนี้คือชื่อของสิ่งที่คุณเพิ่งเข้าใจไป · ใช้ให้ตรงคำแล้วเวลาถาม senior หรือถาม AI จะได้คำตอบตรงขึ้นมาก
- spec (specification)
- ไฟล์ข้อตกลงที่อธิบายว่า API มีอะไรบ้าง — ในบทนี้คือ
swagger.json· เวลาถามให้พูดว่า “ขอ spec ของ API ตัวนี้หน่อย” จะสื่อกว่า “ขอเอกสารหน่อย” เพราะคำแรกหมายถึงไฟล์ที่เครื่องอ่านได้ - endpoint
- ทางเข้าหนึ่งช่องของ API = คู่ของ “method + path” หนึ่งคู่ เช่น
GET /users/{id}· คนละอย่างกับ API ทั้งตัว — API หนึ่งตัวมีหลาย endpoint · พูดว่า “endpoint ไหนพัง” ชัดกว่า “API พัง”
ข้อควรรู้ตอนไปเปิดswagger.jsonอ่านเอง: ในตัวไฟล์ spec เขาไม่ได้ใช้คำว่า endpoint แต่ไล่เป็นpaths→ path → method แล้วเรียกก้อนที่อยู่ใต้ method ว่า operation · ส่วน endpoint เป็นคำที่คนพูดกันในชีวิตจริง ความหมายกว้างกว่าเล็กน้อย (บางคนใช้หมายถึงแค่ตัว path เฉย ๆ) - OpenAPI 2.0 = Swagger 2.0
- ชื่อสองชื่อของมาตรฐานเวอร์ชันเดียวกัน (เขาเปลี่ยนชื่อกลางทาง) · เวลาไปหาใน Google ให้ระบุเลขเสมอ — ถามว่า “อันนี้เป็น OpenAPI 2.0 หรือ 3.0” ไม่ใช่แค่ “เป็น Swagger ใช่ไหม”
- machine-readable
- “เครื่องอ่านต่อได้” — ต่างจาก Google Docs ที่มีแต่คนอ่านได้ · นี่คือเหตุผลที่ Postman import ไฟล์นี้แล้วสร้าง request ให้ได้เอง
- source of truth
- “ตัวจริงที่ทุกอย่างต้องอ้างอิง” · ประโยคที่ dev ใช้กันคือ “ให้โค้ดเป็น source of truth ของเอกสาร” = เชื่อโค้ดเป็นหลัก แล้วสร้างเอกสารจากโค้ด ไม่ใช่เขียนเอกสารแยกไว้อีกที่
3 · flow ทั้งหมดมี 5 ขั้น และเราแตะแค่ขั้นเดียว
ก่อนอื่นขอนิยามคำที่จะเจอทั้งบท: annotation ในที่นี้คือ
คอมเมนต์ Go ธรรมดา ๆ ที่ขึ้นต้นบรรทัดด้วย @
เช่น // @Summary ดึงข้อมูล User
คอมไพเลอร์ Go มองว่ามันเป็นคอมเมนต์เปล่า ๆ ไม่มีความหมาย —
มีแต่โปรแกรม swag เท่านั้นที่อ่านมันแล้วเอาไปทำอะไรต่อ
วิธีอ่านแผนภาพ: ไล่จากซ้ายไปขวา ช่องแรกช่องเดียวคือของที่มือเราพิมพ์ ที่เหลือเครื่องสร้างให้หมด
1 2 3 4 5
เขียน annotation ─▶ รัน swag init ─▶ ได้โฟลเดอร์ docs/ ─▶ Swagger UI ─▶ เอา spec ไปใช้ต่อ
คอมเมนต์ // @... สแกนโค้ด docs.go /swagger/ GitLab viewer
เหนือ handler ทั้งโปรเจกต์ swagger.json index.html Postman
swagger.yaml openapi-generator
└─ เราเขียนเอง ─┘ └──────────── เครื่องสร้างให้อัตโนมัติ ────────────┘
จำโครงนี้ไว้ให้แม่น เพราะเกือบทุกอาการพังในบทนี้ คือ “ขั้นใดขั้นหนึ่งไม่ได้เกิดขึ้น”:
ขั้น 2 ไม่ได้รัน → เอกสารเป็นของเก่า ·
ขั้น 3 ไม่มีไฟล์ → UI ไม่มีอะไรอ่าน ·
ขั้น 4 route ผิด → เปิดหน้าไม่เจอ
4 · ติดตั้งสามตัว — และกับดักเวอร์ชันที่สไลด์ไม่ได้เตือน
ของที่ต้องลงมีสามชิ้น หน้าที่ต่างกันชัดเจน อย่าสับสน:
# 1) swag CLI — โปรแกรมที่อ่านคอมเมนต์ในโค้ดแล้วสร้าง docs/ (ไม่ได้อยู่ใน go.mod)
$ go install github.com/swaggo/swag/cmd/swag@latest
$ swag --version
swag version v1.16.4
# 2) + 3) library ฝั่ง Gin สองตัว — handler ที่เสิร์ฟหน้า Swagger UI (อยู่ใน go.mod)
$ go get -u github.com/swaggo/gin-swagger
$ go get -u github.com/swaggo/files
รันที่ไหน? คำสั่ง go install (ข้อ 1) รันจากโฟลเดอร์ไหนก็ได้
เพราะมันลงโปรแกรมลงเครื่อง ไม่เกี่ยวกับโปรเจกต์
แต่ go get (ข้อ 2) ต้องรันในโฟลเดอร์ root ของโปรเจกต์
— โฟลเดอร์ที่มีไฟล์ go.mod อยู่ — เพราะมันไปเขียนบรรทัดใหม่ลงใน go.mod
| ตัว | หน้าที่ | อยู่ที่ไหน |
|---|---|---|
swag (CLI) | อ่าน annotation แล้วสร้าง docs/ | เครื่องคุณ ($(go env GOPATH)/bin) |
gin-swagger | handler ที่เสิร์ฟหน้า Swagger UI | go.mod ของโปรเจกต์ |
files | ไฟล์ static (HTML/JS/CSS) ของ Swagger UI | go.mod ของโปรเจกต์ |
💡 พิมพ์ swag แล้วขึ้น command not found —
ไม่ได้แปลว่าลงไม่สำเร็จ go install วางไฟล์ไว้ที่
$(go env GOPATH)/bin ซึ่ง shell อาจยังไม่รู้จัก
แก้ด้วย export PATH=$PATH:$(go env GOPATH)/bin
(ใส่ไว้ใน ~/.zshrc จะได้ไม่ต้องพิมพ์ทุกครั้ง)
⚠️ กับดักที่สไลด์ไม่ได้พูดถึง — CLI ใหม่กว่า library แล้ว build พัง
อ่านสองคำสั่งข้างบนอีกรอบ แล้วสังเกตว่ามันไม่ได้คุยกัน:
go install ...@latest ลง CLI รุ่นล่าสุดลงเครื่อง ·
แต่ go get gin-swagger ลาก github.com/swaggo/swag
เวอร์ชันที่มันเลือกให้เข้ามาใน go.mod
บนเครื่องผม gin-swagger v1.6.1 ลาก swag library
v1.8.12 เข้ามา ขณะที่ CLI เป็น v1.16.4
docs/docs.go ที่ CLI รุ่นใหม่สร้าง จึงอ้าง field ที่ library รุ่นเก่ายังไม่มี
ผลคือ โปรเจกต์คอมไพล์ไม่ผ่าน ทั้งที่คุณยังไม่ได้เขียนโค้ดผิดสักบรรทัด:
เคยเจอไหม: เพื่อนใช้ Word รุ่นใหม่ ใส่ลูกเล่นที่รุ่นใหม่เพิ่งมี แล้วส่งไฟล์มาให้
พอเราเปิดด้วย Word รุ่นเก่าในเครื่องตัวเอง มันอ่านคำสั่งใหม่ ๆ พวกนั้นไม่ออก
ไฟล์เพี้ยนทั้งที่เพื่อนไม่ได้ทำอะไรผิดเลย
ที่เกิดขึ้นตรงนี้คือเรื่องเดียวกัน แค่เปลี่ยนตัวละคร:
· swag CLI ในเครื่อง = คนที่สร้างไฟล์ (Word รุ่นใหม่) — มันเขียน docs/docs.go ให้เรา
· github.com/swaggo/swag ใน go.mod = ตัวที่ต้องรู้จักคำสั่งในไฟล์นั้น (Word รุ่นเก่า)
· unknown field LeftDelim = “มีคำสั่งในไฟล์ที่ฉันไม่รู้จัก”
ที่ไม่เหมือนกัน: Word รุ่นเก่ามักจะเปิดได้แต่เพี้ยน คือพังเงียบ ๆ
แต่ Go คอมไพล์ไม่ผ่านไปเลยตั้งแต่ต้น — ซึ่งจริง ๆ แล้วดีกว่า เพราะเรารู้ทันที
และอีกจุด: Word สองรุ่นเป็นคนละโปรแกรมที่แยกกันอยู่ได้
แต่ CLI กับ library ตัวนี้เป็นของชุดเดียวกันที่ถูกแยกไว้คนละที่ จึงต้องอัปให้ตามกันเสมอ
จุดที่ต่างที่สุดและห้ามจำผิด: Word ต้องเปิดไฟล์ตอนเราสั่ง
แต่ตรงนี้ไม่มีใครไป “เปิด” docs.go เลย —
docs.go เป็นโค้ด Go ที่ import library ตัวนั้นแล้วประกาศ struct ที่มี field เกินมา ·
คนที่ฟ้องคือ compiler ตอนแปลโค้ด ไม่ใช่ library ตอนรัน
⏱️ อาการนี้จะยังไม่โผล่ตอนนี้ — มันโผล่หลังจากคุณรัน swag init
(หัวข้อ 9) ไปแล้วเท่านั้น เพราะต้องมีไฟล์ docs/docs.go อยู่ก่อน
ยกมาเล่าไว้ตรงนี้เพราะมันเป็นเรื่องเวอร์ชันตอนติดตั้ง ไม่ใช่เรื่องของ swag init
$ go test ./cmd/api/
# 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]
วิธีอ่าน error นี้ให้เป็น: มันชี้ไปที่ docs/docs.go
ซึ่งเป็นไฟล์ที่เครื่องสร้าง ไม่ใช่ไฟล์ที่เราเขียน —
พอเห็นบรรทัดแรกเป็น # wongnok/docs ให้คิดถึงเรื่องเวอร์ชันทันที
อย่าไปนั่งไล่อ่าน docs.go ทีละบรรทัด (มันไม่ใช่ความผิดของไฟล์นั้น)
ทางแก้ที่ได้ผลจริงคือดึง library ให้ใหม่พอที่จะรู้จัก field ที่ CLI สร้าง:
$ go get github.com/swaggo/swag@latest
go: upgraded github.com/swaggo/swag v1.8.12 => v1.16.6
สังเกตตัวเลขให้ดี: @latest ดึง library ไปถึง v1.16.6
ซึ่งใหม่กว่า CLI ที่เป็น v1.16.4 ด้วยซ้ำ — แล้ว build ก็ผ่านปกติ
เพราะทิศที่พังมีทิศเดียว คือฝั่ง CLI สร้าง field ที่ library ยังไม่รู้จัก ·
เป้าหมายจึงไม่ใช่ “ทำให้เลขตรงกันเป๊ะ” แต่คืออย่าปล่อยให้ library เก่ากว่า CLI
swag CLI กับ github.com/swaggo/swag ใน go.mod
ต้องเป็นรุ่นใกล้กันเสมอ
ถ้าทีมคุณมีคนหนึ่งรัน swag init แล้ว commit docs/ ขึ้นไป
แล้วอีกคน build ไม่ผ่าน — เช็ค swag --version ของทั้งสองเครื่องก่อนเสมอ
- CLI
- โปรแกรมที่สั่งด้วยการพิมพ์ใน terminal · ประโยคที่ต้องแยกให้ขาดคือ
“นี่เป็น CLI หรือเป็น library” เพราะสองอย่างนี้อัปเดตกันคนละคำสั่ง
ในเคสนี้swagถูกลงไว้ในเครื่อง (GOPATH/bin) ไม่ได้ถูก pin ไว้กับโปรเจกต์ — นี่แหละคือเหตุผลที่แต่ละเครื่องมีเวอร์ชันไม่ตรงกันได้
แต่อย่าจำเป็นกฎว่า “CLI ต้องอยู่นอกโปรเจกต์เสมอ” — Go รุ่นใหม่ pin CLI ไว้กับโปรเจกต์ได้ด้วยgo get -tool(แล้วจะได้บรรทัดtool ...โผล่ในgo.mod) เหมือนที่ฝั่ง JS ใช้devDependencies - generated code
- โค้ดที่เครื่องเขียนให้ ไม่ใช่เราเขียน —
docs/docs.goคืออันนี้ · ห้ามแก้ด้วยมือ เพราะ generate รอบหน้ามันจะทับทิ้ง · เวลาเจอ error ในไฟล์แบบนี้ให้พูดว่า “error อยู่ใน generated code” senior จะรู้ทันทีว่าต้องไปดูตัวที่สร้างมัน ไม่ใช่ไปแก้ไฟล์นั้น - version mismatch
- “เวอร์ชันไม่เข้ากัน” · แทนที่จะบอกว่า “build ไม่ผ่านครับ” ให้พูดว่า “น่าจะเป็น version mismatch ระหว่าง swag CLI กับ library ใน go.mod” — ประโยคเดียวนี้ตัดเวลาไล่หาไปได้เป็นชั่วโมง
- compile time / build ไม่ผ่าน
- พังตั้งแต่ตอนแปลโค้ด ยังไม่ทันได้รันเลย · ตรงข้ามกับ runtime ที่พังตอนโปรแกรมกำลังทำงานอยู่ · เคสนี้เป็น compile time ซึ่งถือว่าโชคดี
5 · ต่อ Swagger UI เข้ากับ Gin — สองบรรทัดที่คนพลาดมากที่สุด
โค้ดที่ต้องเติมใน cmd/api/main.go สั้นมาก แต่มีสองจุดที่ผิดแล้วไม่มีใครเตือน:
package main
import (
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "wongnok/docs" // ← จุดที่ 1: blank import
"wongnok/internal/user"
)
func main() {
router := gin.Default()
userHandler := user.NewHandler()
v1 := router.Group("/api/v1")
{
v1.POST("/users", userHandler.Create)
v1.GET("/users/:id", userHandler.FindByID)
}
// ← จุดที่ 2: ต้องมี *any ต่อท้าย
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
router.Run(":8080")
}
นี่คือ main.go เดิมจาก บทเรียน 38 /
บทเรียน 39 ที่เติมเข้าไปแค่สองบรรทัดที่มีคอมเมนต์ ← กำกับ
(บวก import อีกสามบรรทัด) — ไม่ต้องสร้างไฟล์ใหม่
เมื่อทุกอย่างครบแล้ว วิธีดูผลคือรัน go run ./cmd/api
แล้วเปิดเบราว์เซอร์ไปที่ http://localhost:8080/swagger/index.html
🤔 ทำไม URL เป็น /swagger/doc.json ทั้งที่ไฟล์ชื่อ docs/swagger.json?
เพราะ gin-swagger ไม่ได้อ่านไฟล์จากดิสก์เลย —
มันเรียก swag.ReadDoc() ไปเอา spec ที่ถูกลงทะเบียนไว้ในหน่วยความจำ
(ตัวที่ docs/docs.go ลงทะเบียนให้ตอน init() — จุดที่ 1 ข้างล่าง)
แล้วเสิร์ฟออกมาที่ path ชื่อ doc.json ซึ่งเป็นค่า default ของ library
สรุป: docs/swagger.json = ไฟล์บนดิสก์ไว้ให้คนอื่นเอาไปใช้ต่อ ·
/swagger/doc.json = ช่องที่หน้า Swagger UI ดึง spec ไปแสดง
จุดที่ 1 — _ "wongnok/docs" ทำไมต้อง import ของที่ไม่ได้ใช้
ตอน swag init ทำงาน มันสร้าง docs/docs.go ขึ้นมา
ข้างในไฟล์นั้นมี func init() ที่เอา spec ไปลงทะเบียนไว้ในตัวแปรกลาง
ของ library swaggo/swag
ทีนี้ Go มีกฎว่า package ที่ไม่มีใคร import จะไม่ถูกรวมเข้าไปในไบนารีเลย
— init() ก็เลยไม่เคยทำงาน
_ (blank identifier) แปลว่า “ฉันไม่ได้จะเรียกอะไรจาก package นี้
แต่ฉันต้องการผลข้างเคียงของ init()”
เป็นเทคนิคเดียวกับที่ database driver ใช้ —
ถ้าเคยเห็น _ "github.com/lib/pq" ก็คืออันนี้
ร้านอาหารร้านหนึ่งมีพนักงานคนหนึ่งที่งานของเขามีอย่างเดียว:
ตอนเช้าก่อนเปิดร้าน เอาแผ่นเมนูไปแปะบนบอร์ดหน้าร้าน
ทั้งวันเราไม่ได้สั่งงานเขาอีกเลย ไม่ได้เรียกใช้เขาสักครั้ง —
แต่ถ้าลืมเรียกเขาเข้ากะ ร้านก็ยังเปิดได้ตามปกติ ประตูเปิด ไฟติด โต๊ะพร้อม
มีอย่างเดียวที่ผิดคือ บอร์ดหน้าร้านว่างเปล่า ลูกค้าเดินเข้ามาแล้วไม่รู้ว่าร้านนี้ขายอะไร
ตรงกับผลรันในกล่อง “ทายก่อนรัน” ข้างล่างพอดี:
· พนักงานคนนั้น = package docs ·
เรียกเขาเข้ากะ = _ "wongnok/docs"
· ตอนเช้าก่อนเปิดร้าน = init() ที่รันเองอัตโนมัติ
· ร้านยังเปิดได้ = /swagger/index.html ได้ 200 ·
บอร์ดว่าง = /swagger/doc.json ได้ 500 body ว่าง
ที่ไม่เหมือนกัน: ในร้านจริง หัวหน้าเห็นทันทีว่าวันนี้พนักงานคนนั้นไม่มา
แต่ Go ไม่เตือนอะไรเลยสักคำ — คอมไพล์ผ่านสบาย ๆ เพราะไม่มีบรรทัดไหนผิดไวยากรณ์
และ _ ไม่ได้แปลว่า “ยืมของมาแล้วยังไม่ได้ใช้” — มันแปลว่า
เราเรียกอะไรจาก package นี้ไม่ได้เลยด้วยซ้ำ ขอแค่ให้มันได้เข้าไปอยู่ในโปรแกรมเท่านั้นพอ
ทายก่อนรัน — ผมเขียนเทสด้วย httptest (บทเรียน 40)
สามเคส ต่อ route แบบเดียวกันทั้งหมด ต่างกันแค่รายละเอียดเล็ก ๆ
// เคส A — route "/swagger/*any" + มี blank import ครบ
GET /swagger/index.html
GET /swagger/doc.json
// เคส B — route "/swagger" เฉย ๆ (ลืม *any)
GET /swagger/index.html
// เคส C — route "/swagger/*any" แต่ไฟล์ที่ตั้ง router ไม่ได้ import _ "wongnok/docs"
GET /swagger/index.html
GET /swagger/doc.json
แต่ละบรรทัดได้ status code อะไร? โดยเฉพาะเคส C สองบรรทัด — มันได้เลขเดียวกันหรือคนละเลข
// เคส A — ครบทุกอย่าง
[/swagger/*any] GET /swagger/index.html -> 200 body="\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>… <title>Swagger UI</title>…"
[/swagger/*any] GET /swagger/doc.json -> 200 body="{\n \"schemes\": [\"http\",\"https\"],\n \"swagger\": \"2.0\",\n \"info\": {…"
// เคส B — ลืม *any
[/swagger เฉย ๆ] GET /swagger/index.html -> 404 body="404 page not found"
// เคส C — ไม่มี blank import
ไม่มี blank import — GET /swagger/index.html -> 200 body="\n<!DOCTYPE html>… <title>Swagger UI</title>…"
ไม่มี blank import — GET /swagger/doc.json -> 500 body=""
เคส B ตรงกับที่สไลด์บอกเป๊ะ — ไม่มี *any แล้วเปิด
/swagger/index.html ได้ 404 page not found จริง
แต่เคส C คือจุดที่ผมอยากให้ดูให้ดี —
สไลด์ p.55 เขียนว่า “ไม่ใส่ blank import = เปิดหน้า Swagger แล้ว 404”
ผลรันจริงบอกว่าหน้า Swagger เปิดได้ 200 ตามปกติ
(เพราะไฟล์ HTML/JS ของ UI มากับ swaggo/files ซึ่ง import ไว้แล้ว)
แต่ /swagger/doc.json คืน 500 พร้อม body ว่างเปล่า
— ตรงกับที่โค้ดของ gin-swagger ทำ: พอ swag.ReadDoc() หา spec
ที่ลงทะเบียนไว้ไม่เจอ มันสั่ง AbortWithStatus(500) ซึ่งไม่เขียน body ใด ๆ
อาการที่คุณจะเห็นบนจอจริงจึงเป็น “หน้า Swagger เปิดขึ้นมาสวย ๆ
แต่ไม่มี endpoint สักอัน / ขึ้น error ตอนโหลด definition” ไม่ใช่ 404 ทั้งหน้า
หลักการของสไลด์ไม่ผิด — blank import จำเป็นจริง ๆ —
แค่อาการต่างจากที่บรรยายไว้ และการรู้อาการที่ถูก
คือความต่างระหว่าง “แก้ได้ใน 1 นาที” กับ “นั่งไล่ route อยู่ครึ่งชั่วโมง”
จุดที่ 2 — ทำไมต้อง /swagger/*any
จาก บทเรียน 38 เรารู้ว่า Gin มี path parameter สองแบบ:
:name จับหนึ่งช่วง · *name จับทุกอย่างที่เหลือรวม /
Swagger UI ไม่ใช่หน้าเดียว มันโหลดไฟล์ย่อยเป็นกอง —
index.html, doc.json, swagger-ui.css, ไฟล์ JS อีกหลายตัว
route /swagger เฉย ๆ ตรงกับ path เดียวคือ /swagger พอดิบพอดี
พอ browser ขอ /swagger/index.html Gin หาไม่เจอ → 404 ตามผลรันข้างบน
⚠️ ชื่อ any ไม่ใช่คำสงวน
*any คือชื่อตัวแปรที่เราตั้งเอง จะเขียน *filepath ก็ได้
ของสำคัญคือเครื่องหมาย * ไม่ใช่คำว่า any
แต่แนะนำให้ใช้ *any ตามคนอื่น เพราะเวลาไปลอกตัวอย่างจากอินเทอร์เน็ตจะได้ตรงกัน
- blank import
- การ import ด้วยขีดล่าง
_เพื่อเอาแค่ผลของinit()· เวลาถามให้พูดว่า “ตัวนี้ต้อง blank import ไหม” แทนที่จะถามว่า “ต้อง import ไหม” — คำถามแรกบอกไปในตัวว่าคุณรู้ว่ามันไม่ได้เอามาเรียกใช้ - side effect
- “ผลที่ไปเปลี่ยนของนอกตัวมันเอง” ไม่ใช่แค่คืนค่ากลับมา —
เช่น เขียนไฟล์ ยิง DB หรือไปแก้ตัวแปรกลางของแพ็กเกจอื่น ·
ไม่เกี่ยวกับว่ามีคนสั่งหรือไม่สั่ง — ห้ามจำผิดจุดนี้
เพราะเดี๋ยวไปเจอคำนี้อีกทีในเรื่อง pure function
เคสนี้ตรงนิยามเป๊ะ:init()ในแพ็กเกจdocsเรียกswag.Register(...)ซึ่งไปเขียน map กลางที่อยู่ในแพ็กเกจswaggo/swag— นั่นคือ side effect ที่เรายืมมา · ประโยคที่จะได้ยินคือ “import ตัวนี้เพื่อ side effect ของ init()” - register (ลงทะเบียน)
- เอาของไปฝากไว้ในที่กลางที่คนอื่นมาหยิบต่อได้ ·
docs.goregister spec ไว้ให้gin-swaggerมาหยิบ · ถ้าเห็น 500 ตอนโหลดdoc.jsonให้พูดว่า “spec น่าจะไม่ได้ถูก register” - catch-all parameter
- คำที่ Gin ใช้เรียก
*anyเอง — route ที่จับทุก path ที่เหลือรวม/ด้วย · ส่วน:idGin เรียกว่า named parameter · ถ้าเปิดหน้าย่อยแล้ว 404 ให้ถามว่า “route นี้เป็น catch-all หรือยัง” — ถามแบบนี้ตรงที่สุด
ระวังคำว่า wildcard: มันหลวมกว่านั้น ในโค้ดของ Gin เองนับทั้ง:nameและ*nameเป็น wildcard · ถามว่า “เป็น wildcard หรือยัง” เลยมีสิทธิ์ได้คำตอบคนละอย่างกับที่ต้องการ - static file
- ไฟล์สำเร็จรูปที่ส่งออกไปเฉย ๆ ไม่ต้องคำนวณอะไร (HTML/CSS/JS/รูป) ·
หน้าตา Swagger UI ทั้งหน้าคือ static file ที่มากับ
swaggo/files— เหตุผลที่หน้าเว็บเปิดได้แม้ยังไม่มี spec
6 · General API Info — ข้อมูลหัวเอกสาร เขียนครั้งเดียว
annotation ชุดนี้เขียนเหนือ func main() และมีที่เดียวในทั้งโปรเจกต์
มันอธิบาย API ทั้งตัว ไม่ใช่ endpoint ใดอันหนึ่ง
// @title Wongnok API
// @version 1.0
// @description API สำหรับจัดการข้อมูลผู้ใช้ของ Wongnok
// @host localhost:8080
// @BasePath /api/v1
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description ใส่ค่าเป็น "Bearer {token}"
func main() { ... }
มันมีสองก้อน แยกด้วยบรรทัดว่าง — ก้อนบนคือข้อมูลหัวเอกสาร (ตารางข้างล่างอธิบายทีละบรรทัด)
· ก้อนล่างสี่บรรทัด (@securityDefinitions.apikey / @in /
@name / @description) คือเรื่องการล็อกอิน
ข้ามไปก่อนได้ เดี๋ยวหัวข้อ 7.3 จะอธิบายว่ามันทำให้เกิดอะไรขึ้นบนหน้าจอ
| annotation | ความหมาย | จำเป็น? | ไปโผล่ที่ key ไหนใน swagger.json |
|---|---|---|---|
@title | ชื่อ API | ✓ | info.title |
@version | เวอร์ชัน API | ✓ | info.version |
@description | คำอธิบาย | – | info.description |
@host | host:port ที่ปุ่ม Try it out จะยิงไป | – | host |
@BasePath | path นำหน้าทุก endpoint | – | basePath |
@schemes | http / https | – | schemes |
🎯 @BasePath คือช่องที่คนใหม่ตั้งผิดบ่อยที่สุด
ในโค้ดเราลงทะเบียน route เป็น router.Group("/api/v1") แล้วข้างในเป็น /users
ในเอกสาร @Router จึงต้องเขียน /users เฉย ๆ
เพราะ @BasePath เติม /api/v1 ให้แล้ว
ถ้าเผลอเขียน @Router /api/v1/users ปุ่ม Try it out จะยิงไป
/api/v1/api/v1/users แล้วได้ 404 — โดยที่เอกสารดู “ถูก” ทุกอย่าง
7 · annotation ระดับ endpoint
ชุดนี้เขียนเหนือ handler แต่ละตัว นี่คือของจริงจาก internal/user/handler.go
ที่ผมรัน swag init ผ่านแล้ว:
// 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 400 {object} httputil.ErrorResponse
// @Failure 404 {object} httputil.ErrorResponse
// @Security BearerAuth
// @Router /users/{id} [get]
func (handler *Handler) FindByID(ctx *gin.Context) { ... }
| annotation | หน้าที่ |
|---|---|
@Summary | หัวข้อสั้น — บรรทัดที่ขึ้นบนสุดใน UI |
@Description | คำอธิบายยาว กางออกมาเมื่อกดเปิด endpoint |
@Tags | จัดกลุ่ม endpoint ใน UI — endpoint ที่ tag เดียวกันอยู่กล่องเดียวกัน |
@Accept / @Produce | content-type ที่รับเข้า / ส่งออก |
@Param | พารามิเตอร์ที่รับเข้า (หัวข้อ 7.1) |
@Success / @Failure | รูปแบบ response แต่ละ status |
@Security | endpoint นี้ต้องมี token |
@Router | path + method — ตัวเดียวที่ทำให้ endpoint “มีตัวตน” ในเอกสาร |
7.1 · @Param มี 6 ช่อง คั่นด้วยช่องว่าง
@Param <ชื่อ> <paramType> <dataType> <บังคับ?> "<คำอธิบาย>" <attribute>
│ │ │ │ │ │
│ │ │ │ │ └─ Enums(), default(), format(), minimum()
│ │ │ │ └─ ข้อความที่ขึ้นใน Swagger UI (ต้องมี " ")
│ │ │ └─ true / false
│ │ └─ int, string, bool หรือชื่อ struct
│ └─ path | query | header | body | formData
└─ ชื่อที่ client ส่งมา
ช่องที่คนสับสนคือช่องที่ 2 (paramType) กับช่องที่ 3 (dataType)
— ช่องที่ 2 บอกว่า “ค่านี้เดินทางมาทางไหน” ตรงกับที่เรียนไปใน
บทเรียน 35 เป๊ะ ๆ:
| paramType | ใช้เมื่อไหร่ | ตัวอย่าง |
|---|---|---|
path | ระบุ resource ด้วย ID | /users/{id} |
query | กรอง / เรียง / แบ่งหน้า | ?status=active |
header | token, API key, request id | Authorization |
body | ข้อมูลก้อนใหญ่ตอนสร้าง / แก้ | CreateUserRequest |
ผมเขียน endpoint ค้นหาเพิ่มหนึ่งอันเพื่อยิงทุกช่องพร้อมกัน แล้วรัน swag init ดูของจริง:
// @Param q query string false "คำค้นจากชื่อ"
// @Param status query string false "สถานะ" Enums(active, banned) default(active)
// @Param limit query int false "จำนวนต่อหน้า" minimum(1) maximum(100) default(20)
// @Param X-Request-ID header string false "id สำหรับ trace"
// @Success 200 {array} user.UserResponse
// @Router /users [get]
ได้ parameters ใน docs/swagger.json ออกมาแบบนี้ (ตัดมาจากไฟล์จริง):
[
{ "type": "string", "description": "คำค้นจากชื่อ", "name": "q", "in": "query" },
{ "enum": ["active", "banned"], "type": "string", "default": "active",
"description": "สถานะ", "name": "status", "in": "query" },
{ "maximum": 100, "minimum": 1, "type": "integer", "default": 20,
"description": "จำนวนต่อหน้า", "name": "limit", "in": "query" },
{ "type": "string", "description": "id สำหรับ trace", "name": "X-Request-ID", "in": "header" }
]
สังเกตสองอย่าง: Enums(active, banned) กลายเป็น "enum": [...]
ที่ Swagger UI จะเรนเดอร์เป็นช่อง dropdown ให้เลือกไม่ใช่ช่องพิมพ์เปล่า ·
และ minimum(1) maximum(100) โผล่ครบทั้งคู่
attribute พวกนี้เขียนเพิ่มไม่กี่ตัวอักษร แต่ประหยัดคำถามจาก frontend ไปได้เป็นสิบข้อความ
7.2 · @Success / @Failure — บอกว่าคืนอะไรกลับไป
@Success <httpCode> {<รูปแบบ>} <dataType>
| รูปแบบ | ความหมาย | ผลใน JSON |
|---|---|---|
{object} | คืน object ตัวเดียว | "schema": { "$ref": "#/definitions/..." } |
{array} | คืนเป็น array | "schema": { "type": "array", "items": { "$ref": ... } } |
{string} | คืนค่าพื้นฐาน | "schema": { "type": "string" } |
⚠️ ชื่อ type ต้องเป็น package.Struct เสมอ
เขียน user.UserResponse และ httputil.ErrorResponse —
เขียน UserResponse เฉย ๆ swag หาไม่เจอ
เหตุผลคือ swag สแกนทั้งโปรเจกต์ ไม่ได้อยู่ใน scope ของ package ใด package หนึ่ง
มันเลยไม่มีทางเดาได้ว่า UserResponse ที่คุณหมายถึงคือของ package ไหน
ทำไมต้องมี httputil.ErrorResponse แยกออกมาเป็น package กลาง?
เพราะทุก endpoint ในระบบควรคืน error รูปแบบเดียวกัน frontend จะได้เขียนโค้ดอ่าน error ครั้งเดียวใช้ได้ทั้งเว็บ —
ตรงกับเรื่อง response contract ใน บทเรียน 34
โครงมันเรียบง่ายมาก:
package httputil
// ErrorResponse คือรูปแบบ error ที่ทุก endpoint ใช้ร่วมกัน
type ErrorResponse struct {
Message string `json:"message" example:"user not found"`
}
7.3 · @Security BearerAuth — ปุ่ม Authorize
ทำงานเป็นคู่ 2 จุด ขาดจุดใดจุดหนึ่งก็ไม่เกิดผล:
1. ประกาศ @securityDefinitions.apikey BearerAuth เหนือ func main() (หัวข้อ 6) —
บอกว่า “ระบบนี้มีวิธีล็อกอินชื่อ BearerAuth ซึ่งส่งมาทาง header ชื่อ Authorization”
2. ใส่ @Security BearerAuth เหนือ handler ที่ต้องล็อกอิน —
บอกว่า “อันนี้แหละที่ต้องใช้”
ผลใน docs/swagger.json ที่ได้จริง แยกกันคนละที่ตามนั้น:
// ระดับเอกสาร มาจากข้อ 1
"securityDefinitions": {
"BearerAuth": {
"description": "ใส่ค่าเป็น \"Bearer {token}\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
// ระดับ endpoint มาจากข้อ 2 (อยู่ใต้ paths./users/{id}.get)
"security": [ { "BearerAuth": [] } ]
ผลที่ผู้ใช้เห็นบนหน้า Swagger UI: มีปุ่ม Authorize มุมขวาบน ·
ใส่ token ครั้งเดียวใช้ได้ทุก endpoint ·
endpoint ที่ล็อกอินจะมีรูปกุญแจ ·
กด Try it out แล้วมันแนบ header Authorization ให้เอง
ประโยชน์จริงคือ frontend ทดลอง endpoint ที่ต้องล็อกอินได้โดยไม่ต้องเปิด Postman
8 · struct tag ที่มีผลกับเอกสาร
จาก บทเรียน 13 เรารู้จัก json: tag
และจาก บทเรียน 39 รู้จัก binding: tag แล้ว
ข่าวดีคือ swag อ่านทั้งสองอันที่มีอยู่แล้ว โดยเราไม่ต้องเขียนอะไรซ้ำ —
มีของใหม่แค่ example: ตัวเดียว
type CreateUserRequest struct {
Name *string `json:"name" example:"สมชาย ใจดี"`
Email string `json:"email" binding:"required,email" example:"somchai@pea.co.th"`
}
type UserResponse struct {
ID string `json:"id" example:"3f0c1a7e-2b19-4c5e-9f3a"`
Name string `json:"name,omitempty" example:"สมชาย ใจดี"`
Email string `json:"email" example:"somchai@pea.co.th"`
}
รัน swag init แล้วเปิด docs/swagger.json ได้ของจริงแบบนี้:
"user.CreateUserRequest": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "example": "somchai@pea.co.th" },
"name": { "type": "string", "example": "สมชาย ใจดี" }
}
}
1. Name *string → เอกสารเขียนแค่ "type": "string" —
swag ถอด pointer ทิ้ง เพราะ JSON ไม่มีแนวคิดเรื่อง pointer
(ฝั่ง Go เราใช้ pointer เพื่อแยก “ไม่ได้ส่งมา” ออกจาก “ส่งมาเป็นค่าว่าง” —
แต่ในเอกสารมันคือ string ที่ไม่บังคับ เท่านั้น)
2. json:"name,omitempty" → ชื่อ field ในเอกสารคือ
name เฉย ๆ — swag ตัด ,omitempty ทิ้ง
เพราะ ,omitempty เป็นเรื่องของตอน marshal
(จังหวะที่ Go แปลง struct เป็น JSON แล้วตัด field ที่ค่าว่างทิ้ง — บทเรียน 13)
ไม่ใช่ชื่อ field
3. binding:"required,email" → โผล่เป็น
"required": ["email"] — กฎ validation กลายเป็นเอกสารเอง
ไม่ต้องมานั่งจดซ้ำ และไม่มีทางไม่ตรงกับโค้ด
ข้อ 3 คือหัวใจของทั้งเรื่อง: กลับไปดูสถานการณ์ในหัวข้อ 1 อีกที —
วันที่คุณเติม binding:"required" ที่ email
เอกสารเปลี่ยนตามไปเองในการ generate ครั้งถัดไป
ไม่มี Google Docs ให้ลืมแก้อีกแล้ว
9 · swag init — พิมพ์ตกไปนิดเดียว แล้วไม่ได้อะไรเลย
รันจากโฟลเดอร์ไหน? รันที่ root ของโปรเจกต์ เสมอ —
โฟลเดอร์ที่มี go.mod อยู่ ไม่ใช่ใน cmd/api/
ดูบรรทัด search dir:./ ในผลรันข้างล่างเป็นหลักฐาน:
./ คือโฟลเดอร์ที่คุณกำลังยืนอยู่ตอนกด Enter
โครง wongnok วาง main.go ไว้ที่ cmd/api/main.go
ตาม บทเรียน 27
ซึ่งเป็นเรื่องดี — แต่มันชนกับค่า default ของ swag ตรง ๆ
$ swag init
2026/08/20 22:15:46 Generate swagger docs....
2026/08/20 22:15:46 Generate general API Info, search dir:./
2026/08/20 22:15:46 warning: failed to get package name in dir: ./, error: execute go list command, exit status 1, stdout:, stderr:no Go files in <โปรเจกต์>
2026/08/20 22:15:46 cannot parse source files <โปรเจกต์>/main.go: open <โปรเจกต์>/main.go: no such file or directory
$ ls docs
ls: docs: No such file or directory
ไม่มีโฟลเดอร์ docs/ เกิดขึ้นเลยแม้แต่ไฟล์เดียว
สไลด์สรุปอาการนี้ว่า “หา @title ไม่เจอ” ซึ่งถูกในเชิงผลลัพธ์
แต่ข้อความจริงตรงกว่านั้นมาก — มันบอกตรง ๆ ว่าหาไฟล์
main.go ที่ root ไม่เจอ
เหตุผล: swag init --help เขียนไว้ว่า
--generalInfo value, -g value Go file path in which 'swagger general API Info'
is written (default: "main.go")
แปลว่า -g คือช่องบอกว่า “ไฟล์ไหนมี annotation ก้อนหัวเอกสารของหัวข้อ 6”
และค่า default ของมันคือ main.go — มันจึงไปหา ./main.go
ซึ่งโครงเราไม่มี
คำสั่งที่ถูกต้องคือชี้ไฟล์ให้มันดู:
$ swag init -g cmd/api/main.go
2026/08/20 22:15:53 Generate swagger docs....
2026/08/20 22:15:53 Generate general API Info, search dir:./
2026/08/20 22:15:53 warning: failed to get package name in dir: ./, ...
2026/08/20 22:15:53 Generating user.UserResponse
2026/08/20 22:15:53 Generating httputil.ErrorResponse
2026/08/20 22:15:53 Generating user.CreateUserRequest
2026/08/20 22:15:53 create docs.go at docs/docs.go
2026/08/20 22:15:53 create swagger.json at docs/swagger.json
2026/08/20 22:15:53 create swagger.yaml at docs/swagger.yaml
😌 บรรทัด warning: failed to get package name in dir: ./ ยังขึ้นอยู่ดี
แม้คำสั่งจะสำเร็จครบทุกไฟล์ — อันนี้ไม่ต้องตกใจ
วิธีดูว่าสำเร็จจริงไหมคือมองสามบรรทัด create ... at docs/... ตอนท้าย
ไม่ใช่มองว่ามี warning หรือเปล่า
ไฟล์ใน docs/ | คืออะไร | ใครอ่าน |
|---|---|---|
docs.go | โค้ด Go ที่ฝัง spec ไว้เป็น string | ตัวที่เรา blank import — โปรแกรมเรา |
swagger.json | spec มาตรฐาน เอาไปใช้ต่อได้ | GitLab / Postman / codegen |
swagger.yaml | spec เดียวกันแต่เป็น YAML | คน (อ่านง่ายกว่า) |
เรื่อง --parseDependency --parseInternal — ผลจริงต่างจากสไลด์
สไลด์ p.62 บอกว่าโครง wongnok ต้องใช้
swag init -g cmd/api/main.go --parseDependency --parseInternal
ไม่งั้น “definition หายหมด” เพราะโค้ดอยู่ใน internal/
คราวนี้ผมลองแยกทีละ flag แล้วเทียบไฟล์ด้วย md5 —
ผลคือ definition ไม่เคยหาย และตัวการที่ทำให้ไฟล์เปลี่ยน ไม่ใช่ flag ที่สไลด์ชี้
$ swag init -g cmd/api/main.go # A
$ swag init -g cmd/api/main.go --parseInternal # B
$ swag init -g cmd/api/main.go --parseDependency # C
$ swag init -g cmd/api/main.go --parseDependency --parseInternal # D
$ md5 -q sw_A.json sw_B.json sw_C.json sw_D.json
27d4b4c32eba7c375b7cd69a3808fce9 # A
27d4b4c32eba7c375b7cd69a3808fce9 # B ← เท่ากับ A ทุกไบต์
e7c184f840b49b6bde994728d8cb1a2e # C
e7c184f840b49b6bde994728d8cb1a2e # D ← เท่ากับ C ทุกไบต์
| รัน | flag ที่ใส่ | definition ที่ได้ | ขนาดไฟล์ |
|---|---|---|---|
| A | ไม่ใส่อะไรเลย | httputil.ErrorResponse · user.CreateUserRequest · user.UserResponse | 2,757 B |
| B | --parseInternal | เหมือน A ทุกตัว | 2,757 B |
| C | --parseDependency | httputil.ErrorResponse · internal_user.CreateUserRequest · internal_user.UserResponse | 2,793 B |
| D | ทั้งสอง flag | เหมือน C ทุกตัว | 2,793 B |
ตารางนี้บอกสองเรื่องที่สไลด์ไม่ได้บอก:
- definition ไม่เคยหาย — แถว A ไม่ใส่ flag อะไรเลยก็ได้ครบ 3 ตัว
แปลว่า swag เดินเข้า
internal/ของโปรเจกต์เราอยู่แล้วโดยค่าเริ่มต้น --parseInternalไม่ได้ทำอะไรเลยในโครงนี้ — A เท่ากับ B และ C เท่ากับ D ตัวที่เปลี่ยนชื่อ definition เป็นinternal_user.*คือ--parseDependencyต่างหาก
บทนี้เวอร์ชันแรกเขียนว่า “สิ่งที่ --parseInternal ทำจริงในเคสนี้คือทำให้ชื่อ definition ยาวขึ้น”
— ผิด เพราะตอนนั้นผมใส่สอง flag พร้อมกันแล้วยกความดีความชอบให้ flag ผิดตัว
บทเรียนที่ได้ตรงกับที่สอนไปทั้งคอร์ส — จะสรุปว่าตัวแปรไหนเป็นเหตุ ต้องเปลี่ยนทีละตัว เปลี่ยนสองอย่างพร้อมกันแล้วเห็นผลต่าง บอกไม่ได้เลยว่าอันไหนทำ
แล้ว --parseInternal มีไว้ทำอะไรกันแน่? เปิดซอร์สของ swaggo/swag v1.16.6 ดู — ตัวแปรนี้ถูกอ่านแค่ 2 ที่:
// golist.go:51 — ในฟังก์ชัน getAllGoFileInfoFromDepsByList
ignoreInternal := pkg.Goroot && !parser.ParseInternal
// parser.go:1868 — ในฟังก์ชัน getAllGoFileInfoFromDeps
ignoreInternal := pkg.Internal && !parser.ParseInternal
สังเกตชื่อฟังก์ชัน — ทั้งคู่ลงท้ายด้วย FromDeps คือเส้นทางเดินไฟล์ของ dependency
ไม่ใช่เส้นทางของโปรเจกต์เรา
คำว่า internal ตรงนี้จึงหมายถึง internal package ของ dependency และของ Go เอง (pkg.Goroot)
ไม่ใช่ ./internal ที่เราสร้าง
มันจะมีความหมายก็ต่อเมื่อใส่คู่กับ --parseDependency เท่านั้น — ลำพังตัวเดียวไม่มีทางเปลี่ยนอะไรได้เลย
ซึ่งตรงกับที่ md5 บอก
ส่วนโฟลเดอร์ของโปรเจกต์เราถูกเดินด้วย walkWith ที่ข้ามแค่ 4 อย่าง — ไม่มีคำว่า internal อยู่ในนั้นเลย:
// parser.go:1957
if !parseVendor && f.Name() == "vendor" || // vendor
f.Name() == "docs" || // docs
len(f.Name()) > 1 && f.Name()[0] == '.' { // โฟลเดอร์ที่ขึ้นต้นด้วยจุด
return filepath.SkipDir
}
// + path ที่ผู้ใช้สั่ง --exclude เอง
นี่คือหลักฐานตรง ๆ ว่า ./internal ไม่เคยถูกข้าม ตั้งแต่แรก
🤝 ยังไม่ต้องเชื่อผม — ตอบด้วยตัวเองใน 30 วินาที
ผมทดสอบกับโครงย่อที่มี 3 struct บน swag v1.16.4 (ไลบรารี v1.16.6)
โปรเจกต์จริงของคุณอาจต่างออกไป — เปลี่ยนทีละ flag แล้ว md5 เทียบเอง:
swag init -g cmd/api/main.go && cp docs/swagger.json /tmp/a.json
swag init -g cmd/api/main.go --parseInternal && md5 /tmp/a.json docs/swagger.json
ค่าเท่ากัน = flag นั้นไม่มีผลกับโปรเจกต์คุณ · ค่าต่างกัน = มีผล แล้วค่อยไปดูว่าต่างตรงไหน
ผลของเครื่องคุณคือคำตอบของโปรเจกต์คุณ
✨ เกร็ด: swag fmt — annotation ในบทนี้เว้นวรรคเป็นคอลัมน์สวย ๆ
ไม่ใช่เพราะผมนั่งเคาะ space
ผมพิมพ์แบบมั่ว ๆ // @Summary สวัสดี แล้วรัน swag fmt
มันจัดให้เป็น // @Summary สวัสดี (คั่นด้วย tab) พร้อมเว้นบรรทัด
// ใต้บรรทัด godoc ให้เอง
ประโยชน์ที่แท้จริงไม่ใช่ความสวย — มันคือทั้งทีมจะได้ diff ไม่ชนกันเพราะ whitespace
10 · หัวใจของบท — สองกับดักที่ทำให้เอกสารกลับไปโกหกเหมือนเดิม
ถึงตรงนี้ทุกอย่างทำงานหมดแล้ว และนี่คือจุดที่คนส่วนใหญ่หยุดอ่าน
แต่สองเรื่องข้างล่างนี้ต่างหากที่ตัดสินว่าเอกสารของคุณจะเชื่อถือได้หรือไม่
กับดัก 1 — แก้ annotation แล้วลืมรัน swag init
ทายก่อนรัน — ผมรันเทสที่ยิง GET /swagger/doc.json
แล้วพิมพ์ทุก path + summary ที่อยู่ในนั้นออกมา
(รันบนโปรเจกต์ตั้งต้นที่มีแค่สอง endpoint — ยังไม่ได้ใส่ endpoint ค้นหา
ที่ลองเล่นในหัวข้อ 7.1 เข้าไป) ครั้งแรกได้แบบนี้:
doc.json: get /users/{id} summary=ดึงข้อมูล User รายคน
doc.json: post /users summary=สร้าง User ใหม่
จากนั้นผมไปแก้ annotation ในโค้ด — เปลี่ยนข้อความ @Summary
และเพิ่ม endpoint ใหม่ทั้งอัน (@Router /users/{id} [delete])
แล้วสั่ง go test -count=1 ให้ build ใหม่ทั้งหมด
แต่ไม่ได้รัน swag init
เทสรอบสองจะพิมพ์อะไรออกมา?
# แก้ annotation + build ใหม่ (-count=1) แต่ยังไม่ swag init
doc.json: get /users/{id} summary=ดึงข้อมูล User รายคน
doc.json: post /users summary=สร้าง User ใหม่
↑ เหมือนเดิมทุกตัวอักษร ไม่มี delete โผล่มา
# รัน swag init -g cmd/api/main.go แล้ว build ใหม่
doc.json: delete /users/{id} summary=ลบ User
doc.json: get /users/{id} summary=ดึงข้อมูล User รายคน (แก้ข้อความแล้ว)
doc.json: post /users summary=สร้าง User ใหม่
build ใหม่ไม่ช่วยอะไรเลย เพราะ spec ถูกแช่แข็งไว้เป็น string
ใน docs/docs.go ตั้งแต่ตอน generate ครั้งที่แล้ว —
ตัวคอมไพเลอร์ Go ไม่เคยอ่าน @Summary ของคุณเลยสักตัว
มันเห็นแค่คอมเมนต์ธรรมดา
และนี่แหละคือความน่ากลัว: ไม่มี error · ไม่มี warning · เทสก็ยังเขียว
คุณ deploy ขึ้นไปได้เรียบร้อยพร้อมเอกสารเวอร์ชันเมื่อสองสัปดาห์ก่อน
เอกสารกลับไปโกหกเหมือน Google Docs ในหัวข้อ 1 เป๊ะ ๆ —
เครื่องมือใหม่ ปัญหาเดิม
สมมติคุณย้ายที่อยู่ ไปแจ้งที่อำเภอเรียบร้อย ทะเบียนบ้านตัวจริงเปลี่ยนแล้ว
แต่สำเนาที่ถ่ายเก็บไว้ในแฟ้มตั้งแต่ปีที่แล้ว ยังเป็นที่อยู่เดิมอยู่ดี
คุณจะเอาแฟ้มมาเปิดใหม่กี่รอบ จะเปลี่ยนเครื่องถ่ายเอกสารใหม่ก็ไม่ช่วย —
มีทางเดียวคือถ่ายสำเนาใหม่จากตัวจริง
ตรงกันทุกช่อง:
· ทะเบียนบ้านตัวจริง = คอมเมนต์ @Summary / @Router ในโค้ด
· สำเนาในแฟ้ม = spec ที่ถูกแช่ไว้เป็นข้อความใน docs/docs.go
· ถ่ายสำเนาใหม่ = รัน swag init
· เปิดแฟ้มใหม่ / เปลี่ยนเครื่องถ่าย = go build ใหม่, restart server
— ไม่เกี่ยวกันเลย
ที่ไม่เหมือนกัน: สำเนากระดาษยังมีวันที่ถ่ายเอกสารประทับอยู่ คนตรวจเลยพอสงสัยได้
แต่หน้า Swagger UI ไม่มีอะไรบอกเลยว่าข้อมูลข้างในเก่าแค่ไหน
มันขึ้นสวยเหมือนใหม่ตลอด — นี่แหละที่ทำให้กับดักนี้อันตรายกว่า
⚠️ กฎเหล็ก (สไลด์เขียนไว้เอง)
แก้ annotation แล้วต้องรัน swag init ใหม่ทุกครั้ง แล้ว restart server
ทางกันลืมที่ใช้ได้จริง: ใส่เป็น target ใน Makefile ให้มันมาคู่กับคำสั่งรัน
หรือถ้าจะเอาให้แน่จริง — ใส่ใน CI ว่าให้รัน swag init
แล้วเช็คว่า git diff ต้องว่าง
ถ้าไม่ว่างแปลว่ามีคนลืม generate แล้ว pipeline แดงทันที
- generate / regenerate
- “สร้างไฟล์ขึ้นมาใหม่จากต้นทาง” · เวลารายงานปัญหาให้พูดว่า “เอกสารไม่ตรง น่าจะลืม regenerate” ชัดกว่า “เอกสารมันไม่อัปเดต” เพราะคำหลังฟังเหมือนเครื่องมือพัง ทั้งที่ความจริงคือมีคนลืมสั่ง
- stale
- “ของเก่าค้างอยู่” · ใช้กับเอกสาร cache หรือข้อมูลที่ควรอัปแล้วแต่ยังไม่อัป · ประโยคที่ใช้ได้เลย: “docs/ มัน stale แล้ว”
- ฝังเป็น string constant (คนพูดสั้น ๆ ว่า embed)
- spec ถูกเขียนเป็นข้อความก้อนหนึ่งลงไปในโค้ด
docs/docs.goตั้งแต่ตอน generate · เปิดไฟล์ดูจะเห็นเป็นconst docTemplate = `{ ... }`ธรรมดา ๆ · พูดว่า “spec ถูกฝังไว้ตอน generate ไม่ได้อ่านจากไฟล์ตอน runtime” คือคำอธิบายทั้งกับดักนี้ในประโยคเดียว
กันสับสน: คำว่า embed ตรงนี้แปลตรงตัวว่า “ฝัง” เฉย ๆ — คนละเรื่องกับ//go:embedของ Go (directive ที่ฝังไฟล์จริงเข้าไปตอน build) และคนละเรื่องกับ struct embedding ·docs.goที่ swag สร้าง ไม่มี//go:embedสักบรรทัด — ถ้าไปบอก senior สาย Go ว่า “embed” เขาจะไปไล่หา directive ที่ไม่มีอยู่จริง - CI (Continuous Integration)
- เครื่องกลางที่รันคำสั่งตรวจงานให้อัตโนมัติทุกครั้งที่มีคนส่งโค้ดขึ้น repo · ประโยคที่เอาไปเสนอทีมได้: “ใส่ step ใน CI ให้รัน swag init แล้วเช็คว่า git diff ต้องว่าง”
- drift
- อาการที่ของสองอย่างซึ่งควรตรงกัน ค่อย ๆ ห่างกันไปเรื่อย ๆ โดยไม่มีใครรู้ · เอกสารกับโค้ดที่ห่างกันขึ้นทุกสัปดาห์ เรียกว่า “doc drift”
กับดัก 2 — ลืม @Router แล้ว endpoint หายเงียบ ๆ
ทายก่อนรัน — ผมเพิ่ม handler ใหม่พร้อม annotation ครบเกือบทุกช่อง
แต่ไม่มีบรรทัด @Router แล้วรัน swag init
// Ping godoc
//
// @Summary เช็คว่าเซิร์ฟเวอร์ยังอยู่
// @Tags health
// @Produce json
// @Success 200 {object} user.UserResponse
func (handler *Handler) Ping(ctx *gin.Context) { ... }
swag init จะล้มเหลว หรือเตือน
หรือเงียบ? และ paths ในไฟล์ที่ได้จะมีกี่อัน?
$ swag init -g cmd/api/main.go
2026/08/20 22:17:07 Generating user.UserResponse
2026/08/20 22:17:07 Generating httputil.ErrorResponse
2026/08/20 22:17:07 Generating user.CreateUserRequest
# ...create docs.go / swagger.json / swagger.yaml ครบตามปกติ
exit=0
paths: ['/users', '/users/{id}']
ops: ['delete /users/{id}', 'get /users', 'get /users/{id}', 'post /users']
สำเร็จเรียบร้อย exit code 0 ไม่มี warning สักบรรทัด
แต่ Ping ไม่โผล่ในเอกสารเลย —
ไม่มี path ไหนเพิ่มขึ้นมาจากเดิม
เหตุผลตรงไปตรงมา: @Router คือสิ่งเดียวที่บอก swag ว่า
“คอมเมนต์ก้อนนี้คือ endpoint ที่ path ไหน method อะไร”
ไม่มีมัน swag ก็ไม่รู้จะเอาไปวางตรงไหนใน paths เลยข้ามทิ้ง
อาการที่คุณจะเจอจริงคือ “เขียน annotation ไปตั้งเยอะ ทำไม Swagger UI ไม่ขึ้น”
— เช็ค @Router ก่อนเป็นอันดับแรกเสมอ
11 · ได้ swagger.json มาแล้ว เอาไปทำอะไรต่อได้บ้าง
ประเด็นที่หัวข้อ 2 ย้ำไว้กำลังจะออกดอกออกผลตรงนี้:
swagger.json เป็นไฟล์มาตรฐาน ไม่ใช่ของเฉพาะ Go
เครื่องมืออื่นในโลกจึงอ่านมันได้หมด
| ปลายทาง | ได้อะไร | ต้องทำอะไร |
|---|---|---|
| GitLab viewer | หน้า Swagger UI ในตัว repo ไม่ต้อง deploy อะไรเพิ่ม | commit docs/ เข้า repo → เปิดไฟล์ docs/swagger.json → กด Display rendered file |
| Postman | collection ครบทุก endpoint ไม่ต้องสร้าง request ทีละอัน | Import → File → swagger.json |
| openapi-generator | โค้ด client ให้ทีม frontend (TypeScript / Dart / Java …) | openapi-generator generate -i swagger.json -g typescript-axios -o ./client |
📌 เงื่อนไขของ GitLab viewer ที่คนพลาดบ่อย —
มันจะเรนเดอร์ให้ก็ต่อเมื่อชื่อไฟล์มีคำว่า openapi หรือ swagger
และนามสกุลเป็น .json / .yaml / .yml
swagger.json ✓ · swagger.yaml ✓ · openapi_wongnok.yml ✓ ·
api-spec.json ✗ (ไม่มีคำว่า swagger/openapi)
ข่าวดีคือ swag init ตั้งชื่อให้ตรงเงื่อนไขอยู่แล้ว —
ที่ต้องระวังคืออย่าเผลอใส่ docs/ ลงใน .gitignore
ไม่งั้นไม่มีไฟล์ให้ GitLab อ่าน
เขียน annotation ครั้งเดียวเหนือ handler → ได้ทั้งเอกสารบน GitLab,
collection ใน Postman และโค้ด client ให้ frontend
แต่ทั้งหมดนี้จะเป็นจริงก็ต่อเมื่อคุณไม่ลืม swag init —
เพราะเอกสารที่ generate แล้วลืม regenerate
ก็คือเอกสารที่ไม่ตรงกับโค้ด ซึ่งอันตรายกว่าไม่มีเอกสาร เหมือนเดิม
ทดสอบความเข้าใจ
1. OpenAPI กับ Swagger ต่างกันยังไง?
OpenAPI = ข้อตกลงว่าจะเขียนไฟล์อธิบาย API ยังไง (เป็นแค่ข้อความ รันไม่ได้)
· Swagger = โปรแกรมที่เอาไฟล์นั้นไปทำอะไรต่อ เช่น Swagger UI, Editor, Codegen
ตัวเลือกที่ 1 สลับกัน ·
ตัวเลือกที่ 2 ผิดเพราะถ้าใช้แทนกันได้จริง คำว่า “Swagger UI” คงไม่มีความหมาย —
และ docs/swagger.json ที่รันออกมาจริงก็เขียนว่า "swagger": "2.0"
ซึ่งเท่ากับ OpenAPI 2.0 คนละอย่างกับ OpenAPI 3.x ·
ตัวเลือกที่ 3 ผิดเพราะ OpenAPI ออกแบบมาสำหรับ REST/HTTP โดยเฉพาะ
(gRPC มี protobuf ของตัวเอง, GraphQL มี schema ของตัวเอง)
2. ลืมใส่ _ "wongnok/docs" ใน main.go แล้วเปิดเบราว์เซอร์ดู จะเจออะไร?
ผมเทสจาก package ที่ตั้งใจไม่ import docs เลย ได้ผลจริง:
GET /swagger/index.html -> 200 ·
GET /swagger/doc.json -> 500 body=""
เพราะไฟล์ static ของ UI มากับ swaggo/files ที่ import ไว้แล้ว — หน้าเว็บจึงเปิดได้
แต่ตัว spec ถูกลงทะเบียนผ่าน init() ใน docs/docs.go ซึ่งไม่เคยถูกรัน
ตัวเลือกที่ 1 ผิดเพราะ WrapHandler รับแค่ swaggerFiles.Handler —
คอมไพล์ผ่านสบาย ๆ นี่คือเหตุผลที่มันอันตราย ·
ตัวเลือกที่ 3 คืออาการของการลืม *any ต่างหาก (เทสได้ 404 จริงในเคสนั้น) ·
ตัวเลือกที่ 4 ผิด — ไม่มีใครอ่านคอมเมนต์ตอน runtime, spec เป็น string ที่ฝังไว้ตอน generate
3. แก้ @Summary ในโค้ด แล้ว go build ใหม่ แต่ไม่ได้รัน swag init — /swagger/doc.json จะเป็นยังไง?
ผมทดลองจริง: แก้ @Summary + เพิ่ม endpoint [delete] ทั้งอัน
แล้ว go test -count=1 (บังคับ build ใหม่ ไม่ใช้ cache) ได้ผลเดิมเป๊ะ
พอรัน swag init -g cmd/api/main.go แล้ว build อีกที
delete /users/{id} ถึงโผล่ และ summary ถึงเปลี่ยน
ตัวเลือกที่ 1 ผิดเพราะคอมไพเลอร์ Go มอง @Summary เป็นคอมเมนต์ธรรมดา ไม่มีความหมายใด ๆ ·
ตัวเลือกที่ 2 ผิด และนี่คือใจกลางของปัญหา — ไม่มีอะไรเตือนเลยสักอย่าง
ไม่ error ไม่ warning เทสยังเขียว ·
ตัวเลือกที่ 4 ผิดเพราะ swag ไม่ได้ทำงานตอน build มันเป็นโปรแกรมแยกที่เรารันเอง
ทางกันลืม: ใส่ swag init ไว้ใน Makefile หรือให้ CI เช็คว่า git diff ต้องว่าง
4. เขียน annotation ครบทุกช่องยกเว้น @Router แล้วรัน swag init — เกิดอะไรขึ้น?
ผมเพิ่ม Ping ที่มี @Summary/@Tags/@Produce/@Success ครบแต่ไม่มี @Router
แล้วรันจริง: exit=0 · สร้าง docs.go/swagger.json/swagger.yaml ครบ ·
paths: ['/users', '/users/{id}'] — เท่าเดิม ไม่มี Ping
@Router คือสิ่งเดียวที่บอกว่า “คอมเมนต์ก้อนนี้คือ endpoint ที่ path ไหน method อะไร”
ไม่มีมัน swag ก็ไม่รู้จะวางไว้ตรงไหนใน paths เลยข้ามเงียบ ๆ
ตัวเลือกที่ 2 ผิด และเป็นสิ่งที่เราอยากให้มันเป็น — ถ้ามันพังจะดีกว่านี้เยอะ เพราะเรารู้ทันที ·
ตัวเลือกที่ 3 ผิดเพราะ swag ไม่มีทางรู้ว่าเราลงทะเบียน route ไว้ที่ path ไหนใน Gin
(มันอ่านคอมเมนต์ ไม่ได้อ่าน router.GET(...)) ·
ตัวเลือกที่ 4 ผิดเพราะ endpoint ไม่ได้ขึ้นใน UI ตั้งแต่แรก
อาการที่จะเจอจริง: “เขียน annotation ตั้งเยอะ ทำไมไม่ขึ้นสักอัน” → เช็ค @Router ก่อนเสมอ
ทำต่อจากโปรเจกต์ Gin ที่คุณมีอยู่แล้วจาก บทเรียน 38 / บทเรียน 39 ได้เลย ไม่ต้องเริ่มใหม่ · เป้าหมายไม่ใช่ “ทำให้ Swagger UI ขึ้น” เฉย ๆ แต่คือพิสูจน์ว่าคุณจับได้เมื่อเอกสารเริ่มโกหก
- ลงเครื่องมือ + ยืนยันเวอร์ชัน:
go install github.com/swaggo/swag/cmd/swag@latestแล้วswag --version
จากนั้นgo get -u github.com/swaggo/gin-swaggerและgo get -u github.com/swaggo/files
เทียบswag --versionกับบรรทัดgithub.com/swaggo/swagในgo.modของคุณ — ห่างกันเยอะไหม? ถ้าเยอะgo get github.com/swaggo/swag@latestไว้ก่อนเลย - เขียน General API Info เหนือ
func main():@title @version @description @host @BasePath @schemes
ระวัง@BasePathให้ตรงกับrouter.Group()ของจริง - ต่อ Swagger UI: ใส่
_ "<module>/docs"+router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
รันswag init -g cmd/api/main.goแล้วเปิดhttp://localhost:8080/swagger/index.html - ทำให้พังสองแบบ แล้วจดอาการไว้:
(ก) ลบ*anyออกเหลือ/swagger→ เปิดหน้าแล้วได้อะไร
(ข) ลบ blank import ออก → หน้าเปิดขึ้นไหม, กด F12 ดู tab Network ว่าdoc.jsonได้ status อะไร
สองอาการนี้ต้องแยกออกจากกันให้ได้ด้วยตาเปล่า - เขียน annotation ให้ครบหนึ่ง endpoint ที่มี
@Paramทั้งแบบpathและquery(ลองEnums()กับdefault()ด้วย) +@Success+@Failure+@Security BearerAuth
แล้วเติมexample:tag ใน dto (dto = struct ที่ใช้รับ-ส่งข้อมูลกับ client เช่นCreateUserRequest) — เปิด Swagger UI ดูว่า example โผล่ตรงไหน - แยกให้ออกว่า flag ไหนเป็นคนทำ: รัน 4 แบบแล้วเก็บ
md5ของdocs/swagger.jsonทุกแบบ — ไม่ใส่ flag /--parseInternalอย่างเดียว /--parseDependencyอย่างเดียว / ใส่ทั้งคู่
ห้ามเปลี่ยนสอง flag พร้อมกันแล้วสรุป — นั่นคือวิธีที่บทนี้เคยสรุปผิดมาแล้ว
ตอบให้ได้ว่า โปรเจกต์ของคุณ ต้องใส่ flag ไหนบ้าง และตอบพร้อม md5 เป็นหลักฐาน - ลบ
@Routerออกหนึ่งอัน แล้วswag init— exit code เท่าไหร่, มี warning ไหม, endpoint ยังอยู่ในเอกสารไหม - เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 40):
เทสที่ใช้
httptestยิงGET /swagger/doc.jsonแล้ว unmarshal JSON ออกมา (unmarshal = แปลง JSON กลับเป็น struct ของ Go) ตรวจสามอย่าง — (ก) status ต้องเป็น 200 · (ข) ทุก path ที่คุณลงทะเบียนไว้ใน router ต้องมีอยู่ในpathsครบ · (ค) endpoint ที่ต้องล็อกอินต้องมี keysecurity
เทสนี้ต้อง fail ถ้าคุณแก้ annotation แล้วลืมรันswag init— ถ้าแก้แล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร
โครงที่ผมใช้ในบทนี้เอง: gin.New() → ลงทะเบียน
/swagger/*any → httptest.NewRecorder() +
httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
→ r.ServeHTTP(w, req)
แล้ว json.Unmarshal(w.Body.Bytes(), &spec) เข้า struct ที่มีแค่ field ที่สนใจ
เช่น Paths map[string]map[string]struct{ Summary string `json:"summary"` }
— ไม่ต้องแกะทั้ง spec แกะเฉพาะที่จะ assert ก็พอ
💡 จุดที่ต้องระวังตอนพิสูจน์ว่าเทส fail จริง:
Go cache ผลเทสไว้ ถ้าไม่ได้แก้ไฟล์ .go มันจะขึ้น (cached)
แล้วคุณจะเข้าใจผิดว่าเทสรันแล้ว
ใช้ go test -count=1 เสมอเวลาทดลองเรื่องนี้ —
ผมเองก็เกือบสรุปผิดตอนทำหัวข้อ 10 เพราะบรรทัดนี้
💬 ข้อ 6 กับข้อ 8 คือสองข้อที่ผมอยากเห็นผลที่สุด —
ข้อ 6 เพราะผลบนเครื่องผมขัดกับสไลด์ ผมอยากรู้ว่าโปรเจกต์ของคุณได้ diff ออกมาแบบไหน
· ข้อ 8 เพราะมันคือเส้นแบ่งระหว่าง “มีเอกสาร” กับ “มีเอกสารที่เชื่อได้”
รันแล้วได้ output ไม่ตรงกับในบทนี้ตรงไหน ส่ง output มาได้เลย
บอก swag --version กับบรรทัด swaggo/swag ใน go.mod มาด้วย
(ทั้งบทนี้รันบน Go 1.26.5 darwin/arm64 · swag CLI v1.16.4 · swag library v1.16.6 ·
gin v1.12.0 · gin-swagger v1.6.1 · files v1.0.1)
บทหน้าเราจะเลิกลอกตัวอย่าง แล้วออกแบบ API ของ Wongnok เองทั้งชุด
อ้างอิง
- swaggo/swag — repo · Declarative Comments Format — รายการ annotation ทั้งหมดอยู่หน้านี้หน้าเดียว ทั้ง General API Info และระดับ endpoint
- swaggo/swag — Attribute —
Enums(),default(),minimum(),format()ใช้ได้กับ type ไหนบ้าง - swaggo/gin-swagger — ที่มาของ
WrapHandlerและตัวอย่าง route/swagger/*any - pkg.go.dev — swaggo/files — ไฟล์ static ของ Swagger UI ที่ทำให้หน้าเว็บเปิดได้แม้ยังไม่มี spec
- Swagger 2.0 / OpenAPI 2.0 Specification — นิยามของ key ทุกตัวใน
swagger.json(paths,definitions,securityDefinitions) - OpenAPI Initiative · openapi-generator — รายชื่อภาษาที่ generate client ได้
- Gin — param in path — ที่มาของความต่างระหว่าง
:nameกับ*name - Cheat sheet Day 8 · บทเรียน 27 — project layout — เหตุผลที่
main.goไปอยู่cmd/api/ตั้งแต่แรก