แกนกลาง HTTP, Middleware Pipeline และการเชื่อมฐานข้อมูล
ภาพรวม
หัวข้อนี้อธิบาย "พื้น" ที่ทุก endpoint ของ webhook-go ยืนอยู่ ได้แก่ การประกอบ gin engine ลำดับของ middleware รูปแบบ error การทำ rate limit และการต่อ Postgres ในรูปแบบพิเศษ ที่แก้บั๊กสำคัญของการ port
โครงสร้างพื้นฐานมาจาก go-service-template ของบริษัท (ไฟล์ README.md และ CLAUDE.md ใน repo
ยังเป็นของ template และยังไม่ได้เขียนใหม่ เอกสารที่ตรงกับ service จริงคือ docs/PORTING_PLAN.md)
แต่ถูกเฉือนให้เหลือเฉพาะโหมด API มีเพียง cmd/api ไม่มี cmd/worker ไม่มี consumer
และไม่มี sample module
Business Flow
ลำดับ middleware ตาม server.New
MetricsMiddleware()— ชั้นนอกสุด นับ status code และ latency ของทุก request (RED metrics)gin.Logger()— ทำงานเฉพาะเมื่อไม่ได้อยู่ในโหมด productionmiddleware.CORSAllowAll()— อนุญาตทุก origin ทุก method และทุก header แบบไม่มีเงื่อนไข เพื่อคง parity กับต้นฉบับ ทั้งนี้ฟังก์ชันCORS(...)แบบ allowlist มีอยู่ในโค้ดแต่ไม่ได้ถูกเรียกใช้middleware.Helmet()— security header ชุดมาตรฐาน ยกเว้นCross-Origin-Resource-Policyเนื่องจากต้นฉบับตั้งค่าcrossOriginResourcePolicy: falsehttpx.ExceptionMiddleware(sentry, errLog)— แปลง error และ panic เป็น envelope มาตรฐาน
จากนั้นที่ระดับ group /api จะมีอีก 2 ชั้น
clsx.Middleware()— สร้าง request context ประกอบด้วยtransactionId(จากX-Request-Idหรือสุ่มเป็น hex 12 ตัว),sessionId(segment ที่ 3 ของ headerAuthorization) และlang(จากx-langหรือค่าCLS_LANG) แล้ว echoX-Request-Idกลับใน response headermiddleware.AppRateLimit(rdb, ttl, limit, block)— rate limit ต่อ IP
Rate limit
- ค่าเริ่มต้นคือ 200 request ต่อ 1000 มิลลิวินาที ต่อหนึ่ง IP กำหนดผ่าน
RATE_LIMIT_APP_TTL=1000,RATE_LIMIT_APP_LIMIT=200และRATE_LIMIT_BLOCK_DURATION=0 - สถานะถูกเก็บใน Redis ด้วย key
throttle:app:hits:{ip}และthrottle:app:block:{ip}ซึ่งต่างจากต้นฉบับ NestJS ที่เก็บใน memory ของแต่ละ pod การเก็บใน Redis ทำให้ควบคุมได้ ข้าม replica - ที่มาของ IP ไล่ลำดับจาก
x-forwarded-forตัวแรก แล้วเป็นx-real-ip,x-client-ipและสุดท้ายคือ socket - หาก Redis มีปัญหา ระบบจะ fail open คือปล่อยผ่าน ไม่มีการบล็อก traffic เพราะ storage เสียหาย
- เมื่อเกิน limit จะตอบ
429ในรูปแบบ envelope และหากตั้งค่าblockDurationไว้ จะบล็อก IP นั้นต่อเนื่องตามระยะเวลาที่กำหนด - มีฟังก์ชัน
RouteRateLimitสำหรับ override เป็นรายเส้นทาง แต่ไม่ได้ถูกใช้ใน service นี้
Error envelope
ทุก error ถูกส่งออกในรูปแบบมาตรฐานของ NestJS
{
"statusCode": 400,
"message": "string หรือ array ของ string",
"error": "Bad Request"
}
- route ที่ไม่มีอยู่จริงจะได้
404พร้อม{"statusCode":404,"message":"Cannot POST /xxx","error":"Not Found"} - error ที่ไม่ใช่
*httpx.Exceptionรวมถึง panic จะกลายเป็น500 "System Failure" httpx.NewException(status, code, message)จะทิ้งค่าcodeทิ้ง เพราะ envelope ของ NestJS ไม่มีฟิลด์codeซึ่งเป็นจุดที่ต้องระวังเวลาเปรียบเทียบกับ cms-api ที่ใช้ envelope คนละแบบ- validation error ใช้
httpx.BindAndValidateร่วมกับเมธอดRules()ซึ่งเป็น port ของ class-validator ครอบคลุมกฎอย่างIsArray,IsNotEmpty,IsBoolean, custom check และ transform
การต่อ Postgres — "the binding bug fix"
นี่คือประเด็นทางเทคนิคที่สำคัญที่สุดของการ port ทั้งชุด
- pgx ใช้ binary protocol เป็นค่าเริ่มต้น ซึ่งเข้มงวดเรื่อง type OID การส่ง Go string เข้า enum column, การส่ง int เข้า text หรือการส่ง JSON string เข้า jsonb จะทำให้ bind ล้มเหลว
- ในทางกลับกัน ไลบรารี
pgและ TypeORM ของ Node ส่งทุกอย่างเป็น text แล้วปล่อยให้ Postgres coerce ชนิดข้อมูลเอง - วิธีแก้คือเปิด pool ด้วย
connCfg.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocolที่internal/platform/db/db.goทำให้ได้ semantics เดียวกับ Node และยังปลอดภัยกับ PgBouncer ในโหมด transaction pooling
กฎที่ทุก repository ต้องปฏิบัติตาม
- id และ foreign key ทุกตัวต้องเป็น
int64ห้ามใช้int32 - คอลัมน์ที่ nullable ต้องประกาศเป็น pointer
- timestamp ต้องเป็น
timestamptz - คอลัมน์ jsonb ต้อง implement
sql.Scannerและdriver.Valuer - enum ต้อง bind เป็น string
- ต้องใช้ placeholder
$1,$2และระบุชื่อคอลัมน์ให้ชัดเจน ห้ามใช้SELECT * sql.ErrNoRowsต้องแปลงเป็น(nil, nil)เท่านั้น error อื่นต้องโยนขึ้นไปเสมอ ห้ามกลืน
Bootstrap
cmd/api/main.go เรียก app.New(ctx, cancel, service, "api") ซึ่งจะโหลด config และ validate
สร้าง logger ต่อ dependency ทุกตัว (sqlx pool, named Redis, AMQP publisher) เปิด admin port
:9100 และเริ่ม supervisor จากนั้นจึงกลับมาสร้าง gin engine แล้ว serve บน APP_PORT
ซึ่งมีค่าเริ่มต้นเป็น 6570
ไฟล์และฟังก์ชันหลัก
| ไฟล์ | ของสำคัญ |
|---|---|
cmd/api/main.go | main() — bootstrap, ประกอบ server.Deps, declare topology, serve และ graceful shutdown |
internal/app/app.go | app.New, struct App, App.Shutdown, App.ReloadLevel |
internal/server/server.go | server.New(deps, registers...), struct Deps, (*Deps).APIKeyAuth, type RegisterFunc |
internal/server/metrics_middleware.go | MetricsMiddleware() |
internal/middleware/cors.go | CORSAllowAll() (ตัวที่ใช้จริง), CORS(...), OriginGuard(...) |
internal/middleware/helmet.go | Helmet() |
internal/middleware/ratelimit.go | AppRateLimit(...), RouteRateLimit(...), tooManyRequests() |
internal/middleware/apikey.go | api-key auth ดูรายละเอียดที่ การยืนยันตัวตนด้วย API Key |
internal/clsx/clsx.go | Middleware(), Store, TransactionID/SessionID/Lang, LogFields |
internal/httpx/exception.go | Exception, New, BadRequest/Unauthorized/NotFound/..., SystemFailure, NewException |
internal/httpx/abort.go, validate.go, checks.go | Abort, BindAndValidate และชุด Rule / Check |
internal/platform/db/db.go | sqlx บน pgx stdlib พร้อม simple query protocol |
internal/platform/redisx/redisx.go | Manager สำหรับ Redis หลาย instance |
internal/config/config.go, api.go, api_load.go | typed config พร้อม Validate() แบบ fail-fast |
internal/logx/logx.go | slog แบบ JSON, LOG_LEVEL, reload ด้วย SIGHUP |
cmd/api/smoke_test.go, wiring_test.go | boot จริงแล้วยิง route เพื่อตรวจ contract |
Route ที่ platform mount ให้เอง
| Method | Route | ที่มา |
|---|---|---|
| GET | /api | health.Register — ตอบชื่อแอปจาก APP_NAME |
| GET | /api/health | health.Register — รูปแบบ Terminus |
| GET | /livez, /readyz, /healthz | server.New บน business port ดู Health Check และการเฝ้าระวัง Dependency |
จุดเชื่อมต่อกับ Service อื่น
- Postgres — ตาราง
api_client,api_key,audienceและline_oaschema จริงอยู่ที่docs/live_schema.tsv(5 ตาราง 102 คอลัมน์) ส่วนตารางline_userประกาศไว้แต่ไม่มีการ query - Redis — ใช้ทั้งเป็น cache (ดู Webhook Config Cache) และเป็น backend ของ rate limiter โดยแยก key space กัน
- RabbitMQ — ดู RabbitMQ Publisher และ Topology
- service boot ได้แม้ไม่มี backend เลย — typed dependency จะเป็น nil ทุก handler มี guard ของตัวเอง และ route ยังถูก mount ครบ ทำให้ทดสอบ wiring ได้โดยไม่ต้องมี infra
- เอกสารอ้างอิงภายในโค้ด:
docs/PORTING_PLAN.md(แผน port ฉบับสมบูรณ์พร้อมสถานะ) และdocs/live_schema.tsv