grpc-gateway
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”gateway/cmd/main.go — binary ตัวที่สี่ ควบคู่ไปกับ services/catalog/cmd และ services/order/cmd แต่เป็น process คนละแบบโดยสิ้นเชิง เพราะไม่ใช่ gRPC server ด้วยซ้ำ หน้าที่คือสร้าง runtime.ServeMux จาก grpc-gateway ลงทะเบียน gateway handler ที่ generate มาให้ของ Catalog กับ Order service กับ gRPC endpoint ของแต่ละตัว ห่อด้วย http.Server ธรรมดาที่มี timeout ที่สมเหตุสมผล แล้ว serve HTTP/JSON บน GATEWAY_HTTP_ADDR — พร้อม graceful shutdown เมื่อได้รับ SIGINT/SIGTERM รูปแบบเดียวกับที่ cmd/main.go ทุกตัวในคอร์สนี้ใช้มาตั้งแต่ The gRPC Server →
grpc-gateway คือ reverse proxy ที่ generate มาจาก annotation ใน .proto — ไม่ใช่ REST framework ทั่วไปที่คุณเขียน handler เอง Code Generation → สร้าง catalog.pb.gw.go กับ order.pb.gw.go ไปแล้ว แต่ละไฟล์ export ฟังก์ชัน Register*ServiceHandlerFromEndpoint ที่สร้างขึ้นทั้งหมดจาก option google.api.http ที่ The Contracts → เขียนไว้บนทุก RPC ฟังก์ชันนั้นทำหน้าที่เดียว: รับ HTTP request แล้ว decode เป็น gRPC request message ที่ตรงกันโดยใช้ path template และ body mapping ที่ annotation กำหนดไว้ เรียก gRPC service จริงผ่าน network แบบเดียวกับที่ Order service เรียก Catalog อยู่แล้ว แล้ว encode response กลับเป็น JSON ไม่มีส่วนไหนของ REST surface ที่ต้องเขียนมือเลย ทั้งหมดตกออกมาจากการตัดสินใจที่ Module 2 ทำไปแล้วโดยตรง ที่เป็นแก่นทั้งหมดของ schema-first contract: ไฟล์ .proto ไฟล์เดียวขับเคลื่อนทั้ง Go type, gRPC stub, และ REST API โดยไม่มี artifact ที่สี่ให้ต้องคอย sync มือ
ผลลัพธ์คือจุดเข้า API เดียว: client ที่ไม่อยากพูด gRPC — browser, mobile app, curl — ได้ HTTP/JSON ธรรมดาบน host และ port เดียว ส่วน Catalog กับ Order ยังคุยกันด้วย gRPC เหมือนเดิม ทั้งกับกันเองและกับ gateway ที่อยู่ข้างล่าง โดยไม่ต้องรู้เลยว่า REST มีอยู่จริง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”grpc-gateway (reverse proxy ที่ generate มาจาก annotation ใน .proto) — สิ่งที่คอร์สนี้ใช้
- Pros: ไม่มี REST handler ที่เขียนมือเลย — mapping ทั้งหมดมีอยู่แล้วในรูปข้อมูลใน
catalog.proto/order.proto; REST กับ gRPC ไม่มีทางเพี้ยนออกจากกันได้เลย เพราะทั้งคู่ generate มาจาก annotation ชุดเดียวกัน; consumer อื่นที่อยากใช้ gRPC ตรง ๆ (เซอร์วิสในอนาคต, internal tool) ก็ยังเรียก Catalog หรือ Order ได้โดยไม่ต้องผ่าน gateway เลย - Cons: รูปแบบ REST ถูกจำกัดอยู่แค่สิ่งที่
google.api.httpแสดงออกมาได้อย่างสะอาด — idiom อย่าง partialPATCHsemantics หรือ bulk endpoint ที่ไม่ map กับ RPC เดียวนั้นทำได้ยากมาก; ทุก REST request ตอนนี้มี hop เพิ่มขึ้น (HTTP → gateway process → gRPC → service) แทนที่จะชน handler ตรง ๆ เพิ่ม latency และอีกหนึ่ง process ที่ต้อง deploy และ monitor; การปรับแต่ง error body หรือพฤติกรรมเฉพาะของ REST หมายถึงต้องเรียนรู้ extension point ของ grpc-gateway เอง (REST Mapping →) ไม่ใช่แค่เขียนโค้ด Go handler ธรรมดา
REST layer ที่เขียนมือ เรียกเข้า gRPC client (หรือเข้า business logic ตรง ๆ)
- Pros: มีอิสระเต็มที่เหนือรูปแบบ REST และ versioning ของฝั่ง REST เอง แยกขาดจาก gRPC contract โดยสิ้นเชิง เพิ่มฟีเจอร์เฉพาะของ REST ได้ตามใจ — cursor pagination, bulk endpoint, รูปแบบ response ที่ตัดเย็บให้ frontend ตัวใดตัวหนึ่ง — ที่ไม่จำเป็นต้องตรงกับ RPC เดียวเลย
- Cons: อีกหนึ่ง surface ที่ต้องคอย sync มือกับ gRPC contract — ปัญหา implicit-contract แบบเดียวกับที่ The Contracts → ปฏิเสธไปแล้วสำหรับ gRPC layer เอง เพียงแต่โผล่กลับมาอีกชั้นหนึ่ง; เพิ่มโค้ด (และ test) เป็นสองเท่าเพื่อ expose functionality เดิมออกมาสองครั้ง
GraphQL BFF (Backend-For-Frontend) วางไว้หน้าทั้งสองเซอร์วิส
- Pros: ทีม frontend สร้าง query เดียวข้ามข้อมูล Catalog และ Order ได้ใน round trip เดียว แทนที่จะเรียก REST หนึ่งครั้งต่อหนึ่ง resource; ความยืดหยุ่นที่ client เป็นคนกำหนดสูงมาก โดยไม่ต้องจัดการ REST versioning เลย
- Cons: เทคโนโลยีสแต็กใหม่ทั้งชุด — schema language, resolver layer, query executor — ที่ต้องสร้าง เรียนรู้ และดูแล หนักกว่า reverse proxy ที่ generate มาให้มาก; ปัญหา N+1 query กับ caching กลายเป็นปัญหาของ GraphQL layer เองไปเลย ซ้อนทับกับทุกอย่างที่ Catalog กับ Order ทำอยู่แล้ว; สำหรับคอร์สที่สอนเรื่องขอบเขตของ microservice การเอา GraphQL มาใช้ตรงนี้คือ scope creep ที่ไม่เกี่ยวกับบทเรียนตรงหน้าเลย
คอร์สนี้เลือกทางแรก: annotation ใน .proto มีอยู่แล้ว buf generate สร้างโค้ด gateway ให้แล้ว และการตั้งขึ้นมาใช้ก็แค่ main.go ไม่กี่สิบบรรทัด — REST front door ที่ถูกที่สุดเท่าที่จะเป็นไปได้สำหรับสองเซอร์วิสที่ contract จริงคือ gRPC อยู่แล้ว
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. gateway/cmd/main.go
หัวข้อที่มีชื่อว่า “1. gateway/cmd/main.go”// Command gateway runs the HTTP/JSON reverse proxy in front of the Catalog// and Order gRPC services, generated from their google.api.http// annotations.package main
import ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" orderv1 "github.com/avetavos/shopmicro/gen/shopmicro/order/v1" "github.com/avetavos/shopmicro/pkg/config" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure")
func main() { ctx := context.Background()
httpAddr := config.Get("GATEWAY_HTTP_ADDR", ":8080") catalogAddr := config.Get("CATALOG_GRPC_ADDR", ":50051") orderAddr := config.Get("ORDER_GRPC_ADDR", ":50052")
mux := runtime.NewServeMux() opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := catalogv1.RegisterCatalogServiceHandlerFromEndpoint(ctx, mux, catalogAddr, opts); err != nil { log.Fatalf("gateway: register catalog handler: %v", err) } if err := orderv1.RegisterOrderServiceHandlerFromEndpoint(ctx, mux, orderAddr, opts); err != nil { log.Fatalf("gateway: register order handler: %v", err) }
root := http.NewServeMux() root.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) root.Handle("/", mux)
srv := &http.Server{ Addr: httpAddr, Handler: root, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, }
go func() { log.Printf("gateway: HTTP listening on %s", httpAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("gateway: serve: %v", err) } }()
stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop
log.Println("gateway: shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Fatalf("gateway: shutdown: %v", err) }}บันทึกไฟล์นี้เป็น gateway/cmd/main.go มีรายละเอียดสองสามอย่างที่ควรพูดถึง:
runtime.NewServeMux()แบบไม่มี option สร้าง*runtime.ServeMuxที่ใช้ marshaler default ของ grpc-gateway คือruntime.JSONPb— JSON เข้าออกที่เข้ากันได้กับ protojson REST Mapping → จะอธิบายเป๊ะ ๆ ว่า default นี้ให้อะไรออกมา (ชื่อ field แบบ camelCase,int64เป็น JSON string) และ extension point (runtime.WithErrorHandler,runtime.WithIncomingHeaderMatcher) ที่มีให้ใช้ถ้าวันหนึ่ง default ต้อง overrideRegister*ServiceHandlerFromEndpoint(ctx, mux, endpoint, opts)dial connection ของตัวเอง แต่ละ call — ตัวหนึ่งสำหรับcatalogAddrตัวหนึ่งสำหรับorderAddr— เปิดgrpc.ClientConnอิสระไปยังเซอร์วิสนั้นโดยใช้grpc.DialOptionslice ที่ส่งเข้าไป รูปแบบเดียวกับgrpc.WithTransportCredentials(insecure.NewCredentials())ที่ The gRPC Server → ใช้ไปแล้วสำหรับ client connection ของ Order เองไปยัง Catalog สุดท้าย gateway ก็กลายเป็น gRPC client ของทั้งสองเซอร์วิส เหมือนกับที่ Order เป็น gRPC client ของ Catalog ไม่มีอะไรใหม่ในเชิงสถาปัตยกรรมเลย แค่ผู้เรียกคนที่สามhttp.NewServeMux()ธรรมดา (root) ห่อmuxของ grpc-gateway ไว้ ไม่ใช่กลับกัน นี่คือสิ่งที่ทำให้/healthzเป็นไปได้: request ไปที่/healthzไม่มีทางไปถึง grpc-gateway เลย เพราะhttp.ServeMuxจับ pattern/healthzที่เฉพาะเจาะจงกว่าก่อนที่จะตกไปหา catch-all/ที่ลงทะเบียนไว้กับ mux ของ grpc-gateway OpenAPI Documentation → จะเพิ่ม route เข้าไปในrootmux ตัวเดียวกันนี้อีก เพื่อ serve เอกสาร OpenAPI ที่ generate มาให้ กับ Swagger UIhttp.Serverที่มีReadTimeout/WriteTimeout/IdleTimeoutชัดเจน ไม่ใช่http.ListenAndServe(addr, mux)ตรง ๆhttp.Serverค่า zero-value ที่ shortcut พวกนี้สร้างขึ้นภายในไม่มี timeout เลยสักตัว — client ที่ช้าหรือประสงค์ร้ายสามารถถือ connection ค้างไว้ได้ไม่มีที่สิ้นสุด timeout ที่ชัดเจนตรงนี้คือวินัยแบบเดียวกับที่คอร์สนี้ใช้กับ database connection กับ gRPC dial อยู่แล้ว: ไม่มีทาง resource ไหนที่ไม่มีขอบเขตว่าจะถูกถือครองได้นานแค่ไหน- Graceful shutdown เรียก
srv.Shutdown(shutdownCtx)ไม่ใช่grpcServer.GracefulStop()net/httpกับgoogle.golang.org/grpcมี API สำหรับ graceful shutdown ที่ต่างกันสำหรับ idea เดียวกัน —Shutdownหยุดรับ connection ใหม่แล้วรอ (จนถึง deadline ของ context) ให้ request ที่กำลังทำอยู่เสร็จก่อน สะท้อนสิ่งที่GracefulStopทำอยู่แล้วสำหรับ gRPC server ในservices/catalog/cmdกับservices/order/cmd errors.Is(err, http.ErrServerClosed)ไม่ใช่การเทียบ==ตรง ๆListenAndServeคืนค่า error ที่ไม่ใช่ nil เสมอ และhttp.ErrServerClosedคือ error ที่คาดไว้เมื่อShutdownถูกเรียกไปแล้ว การเช็คด้วยerrors.Isคือวินัย sentinel-error แบบเดียวกับที่คอร์สนี้ใช้กับpgx.ErrNoRowsอยู่แล้ว
2. ตัวแปร environment ที่บทนี้แนะนำ
หัวข้อที่มีชื่อว่า “2. ตัวแปร environment ที่บทนี้แนะนำ”| ตัวแปร | ค่า default | ใช้โดย |
|---|---|---|
GATEWAY_HTTP_ADDR | :8080 | http.Server สำหรับ HTTP listener ของ gateway เอง |
CATALOG_GRPC_ADDR | :50051 | dial target ของ RegisterCatalogServiceHandlerFromEndpoint |
ORDER_GRPC_ADDR | :50052 | dial target ของ RegisterOrderServiceHandlerFromEndpoint |
CATALOG_GRPC_ADDR กับ ORDER_GRPC_ADDR คือตัวแปรสองตัวเดียวกับที่ The gRPC Server → แนะนำไปแล้วสำหรับ Catalog client ของ Order เอง gateway ต้องใช้ทั้งคู่ เพราะยืนอยู่หน้าทั้งสองเซอร์วิส
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”รันทั้งสาม process แยก terminal:
go run ./services/catalog/cmdgo run ./services/order/cmdgo run ./gateway/cmdผลลัพธ์ที่คาดไว้จาก terminal ที่สาม:
gateway: HTTP listening on :8080จาก terminal ที่สี่ ยิงเข้า REST surface ตรง ๆ — ไม่ต้องใช้ grpcurl ไม่ต้องรู้จัก .proto เลย:
curl -s localhost:8080/v1/products{}{} ว่าง ๆ นี่ถูกต้องแล้ว ไม่ใช่บั๊ก: marshaling default ของ runtime.JSONPb — เหมือนกับ protojson เอง — ละ field ที่ยังเป็นค่า zero value อยู่ทิ้งไป และ ListProductsResponse ที่ว่างเปล่ามี products เป็น repeated field ที่เป็น zero value กับ total เป็น 0 ทั้งสองเลยไม่ถูกเขียนออกมาเลย REST Mapping → จะพูดถึง default นี้ให้ลึกกว่านี้
สร้าง product ผ่าน gateway แล้วยืนยันว่า round-trip กลับมาเป็น JSON ได้จริง:
curl -s -X POST localhost:8080/v1/products \ -H 'Content-Type: application/json' \ -d '{"name":"Coffee Mug","description":"350ml ceramic mug","price_cents":1299,"stock":50}'{ "id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "name": "Coffee Mug", "description": "350ml ceramic mug", "priceCents": "1299", "stock": 50}request นี้เดินทาง curl → http.Server ของ gateway → handler ที่ generate มาให้ของ grpc-gateway → gRPC call จริงไปยัง Catalog service → PostgreSQL แล้วกลับมา — โดยไม่มีอะไรระหว่างทางเขียนมือเลย ยืนยันว่า /healthz ถูกตอบโดย root mux ตรง ๆ ไม่ได้ proxy ไปเป็น gRPC call เลย:
curl -s localhost:8080/healthzokหยุดทั้งสาม process ด้วย Ctrl-C และยืนยันว่า gateway log บรรทัด shutdown ก่อนที่จะจบการทำงาน:
gateway: shutting downสุดท้าย ยืนยันว่าทั้ง module ยัง build ผ่านสะอาด:
go build ./...ไม่มี output แปลว่าสำเร็จ
gateway/cmd/main.go สร้าง runtime.ServeMux ด้วย JSON marshaling default ของ grpc-gateway ลงทะเบียน catalogv1.RegisterCatalogServiceHandlerFromEndpoint กับ orderv1.RegisterOrderServiceHandlerFromEndpoint กับ CATALOG_GRPC_ADDR/ORDER_GRPC_ADDR ด้วย grpc.DialOption แบบ insecure สำหรับ local dev เดียวกับที่ Order ใช้เรียก Catalog อยู่แล้ว และห่อ mux นั้นไว้ใน http.NewServeMux() ธรรมดาควบคู่กับ route /healthz — ทั้งหมด serve โดย http.Server ที่มี timeout ชัดเจน และ shutdown อย่าง graceful ผ่าน srv.Shutdown เมื่อได้รับ SIGINT/SIGTERM ไม่มีส่วนไหนของ REST surface ที่เขียนมือเลย ทั้งหมดเป็นผลเชิงกลไกจาก annotation google.api.http ที่ The Contracts → เขียนไว้ และ Code Generation → compile ไปแล้ว เลือกใช้แทน REST layer ที่เขียนมือหรือ GraphQL BFF เพราะไม่ต้องเขียนโค้ด server ใหม่เลยสำหรับสองเซอร์วิสที่ contract จริงคือ gRPC อยู่แล้ว curl localhost:8080/v1/products กับการ POST สร้าง product ทั้งคู่ยืนยันว่าทั้งเส้นทาง — HTTP เข้า gRPC อยู่ข้างใน JSON ออกมา — ทำงานได้ end-to-end บทถัดไป REST Mapping → จะดูให้ละเอียดว่า annotation google.api.http ตัดสินใจอย่างไรว่า JSON นั้นหน้าตาเป็นแบบไหน และ gRPC error กลายเป็น HTTP status code อะไร