Skip to main content

แกนกลาง 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

  1. MetricsMiddleware() — ชั้นนอกสุด นับ status code และ latency ของทุก request (RED metrics)
  2. gin.Logger() — ทำงานเฉพาะเมื่อไม่ได้อยู่ในโหมด production
  3. middleware.CORSAllowAll() — อนุญาตทุก origin ทุก method และทุก header แบบไม่มีเงื่อนไข เพื่อคง parity กับต้นฉบับ ทั้งนี้ฟังก์ชัน CORS(...) แบบ allowlist มีอยู่ในโค้ดแต่ไม่ได้ถูกเรียกใช้
  4. middleware.Helmet() — security header ชุดมาตรฐาน ยกเว้น Cross-Origin-Resource-Policy เนื่องจากต้นฉบับตั้งค่า crossOriginResourcePolicy: false
  5. httpx.ExceptionMiddleware(sentry, errLog) — แปลง error และ panic เป็น envelope มาตรฐาน

จากนั้นที่ระดับ group /api จะมีอีก 2 ชั้น

  1. clsx.Middleware() — สร้าง request context ประกอบด้วย transactionId (จาก X-Request-Id หรือสุ่มเป็น hex 12 ตัว), sessionId (segment ที่ 3 ของ header Authorization) และ lang (จาก x-lang หรือค่า CLS_LANG) แล้ว echo X-Request-Id กลับใน response header
  2. middleware.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.gomain() — bootstrap, ประกอบ server.Deps, declare topology, serve และ graceful shutdown
internal/app/app.goapp.New, struct App, App.Shutdown, App.ReloadLevel
internal/server/server.goserver.New(deps, registers...), struct Deps, (*Deps).APIKeyAuth, type RegisterFunc
internal/server/metrics_middleware.goMetricsMiddleware()
internal/middleware/cors.goCORSAllowAll() (ตัวที่ใช้จริง), CORS(...), OriginGuard(...)
internal/middleware/helmet.goHelmet()
internal/middleware/ratelimit.goAppRateLimit(...), RouteRateLimit(...), tooManyRequests()
internal/middleware/apikey.goapi-key auth ดูรายละเอียดที่ การยืนยันตัวตนด้วย API Key
internal/clsx/clsx.goMiddleware(), Store, TransactionID/SessionID/Lang, LogFields
internal/httpx/exception.goException, New, BadRequest/Unauthorized/NotFound/..., SystemFailure, NewException
internal/httpx/abort.go, validate.go, checks.goAbort, BindAndValidate และชุด Rule / Check
internal/platform/db/db.gosqlx บน pgx stdlib พร้อม simple query protocol
internal/platform/redisx/redisx.goManager สำหรับ Redis หลาย instance
internal/config/config.go, api.go, api_load.gotyped config พร้อม Validate() แบบ fail-fast
internal/logx/logx.goslog แบบ JSON, LOG_LEVEL, reload ด้วย SIGHUP
cmd/api/smoke_test.go, wiring_test.goboot จริงแล้วยิง route เพื่อตรวจ contract

Route ที่ platform mount ให้เอง

MethodRouteที่มา
GET/apihealth.Register — ตอบชื่อแอปจาก APP_NAME
GET/api/healthhealth.Register — รูปแบบ Terminus
GET/livez, /readyz, /healthzserver.New บน business port ดู Health Check และการเฝ้าระวัง Dependency

จุดเชื่อมต่อกับ Service อื่น

  • Postgres — ตาราง api_client, api_key, audience และ line_oa schema จริงอยู่ที่ 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