The Product API
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”services/catalog/internal/server/server.go — Server type ตัวจริงที่ implement ทั้งสามเมธอดของ CatalogServiceServer โดยผูกกับ ProductRepo จาก The Postgres Repository → พร้อมการอัปเดต services/catalog/cmd/main.go ที่ต่อสาย Server ตัวนี้เข้าไปแทน placeholderServer จาก The gRPC Server → เมื่อจบบทนี้ CreateProduct, GetProduct, และ ListProducts จะทำงานได้จริงแบบ end-to-end กับแถวข้อมูลจริงใน PostgreSQL
gRPC handler มีสองหน้าที่ที่มักจะปนกันได้ง่ายแต่จริง ๆ แล้วแยกกัน: การแปลระหว่างรูปร่างบน wire (catalogv1.Product) กับรูปร่างของ domain (repo.Product) และการตัดสินใจว่า error จาก repository หมายถึง อะไรสำหรับผู้เรียก ProductRepo.Get ที่คืนค่า pgx.ErrNoRows ที่ถูกห่อไว้เป็นข้อเท็จจริงเกี่ยวกับฐานข้อมูล; Server.GetProduct ที่ตัดสินใจว่าข้อเท็จจริงนั้นหมายถึง “คืนค่า codes.NotFound” เป็นการตัดสินใจเกี่ยวกับ API contract ซึ่งควรอยู่ที่ชั้นนี้ ไม่ใช่ใน repository เพราะผู้ใช้ ProductRepo รายอื่น เช่น batch job ในอนาคต อาจอยากปฏิบัติกับ “ไม่พบสินค้านี้” ต่างจาก gRPC client โดยสิ้นเชิง
การตรวจสอบ input ก็อยู่ที่นี่ด้วยเหตุผลเดียวกัน codes.InvalidArgument เป็นวิธีปฏิเสธ input ที่ไม่ถูกต้องเฉพาะของ gRPC และ repository ไม่ควรต้องรู้ด้วยซ้ำว่า gRPC มีอยู่
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”ฟังก์ชัน mapping toProto โดยเฉพาะ แทนที่จะใช้ repo.Product เป็น wire type ไปเลย
- Pros: รูปร่างของแถวในฐานข้อมูลกับรูปร่างบน wire เป็นอิสระที่จะแยกทางกันได้ —
repo.Product.CreatedAtไม่มีที่ไปในcatalogv1.Productตอนนี้ และก็ไม่เป็นไร เพราะฟังก์ชัน mapping เป็นจุดเดียวที่ตัดสินว่าอะไรข้ามขอบเขตไปได้; การเพิ่ม field ในฝั่งหนึ่งไม่ทำให้ output JSON/wire ของอีกฝั่งเปลี่ยนไปเงียบ ๆ - Cons: ต้องมีนิยาม struct ตัวที่สองบวกฟังก์ชันเล็ก ๆ ที่ต้องคอยดูแลให้ตรงกันด้วยมือ — สำหรับ resource ที่เรียบง่ายขนาดนี้ นี่คือ boilerplate เชิงกลไกไม่กี่บรรทัดที่ code generator น่าจะสร้างแทนได้
ตรวจสอบ input ในชั้น Server ไม่ใช่ชั้น ProductRepo
- Pros: กฎการตรวจสอบที่เจาะจงกับ API contract นี้ (
InvalidArgumentของ gRPC client) ไม่รั่วไหลเข้าไปใน repository ที่ผู้เรียกรายอื่นอาจนำไปใช้ซ้ำด้วยกฎที่ต่างกัน; SQL ของ repository ยังคงโฟกัสอยู่ที่ “อ่าน/เขียนตารางนี้ยังไง” ล้วน ๆ ไม่ใช่ “input นี้รับได้ไหม” - Cons: ถ้ามี entry point ที่สองเข้าสู่ตารางเดียวกันในอนาคต เช่น เครื่องมือ admin แบบ REST ที่เรียก
ProductRepoโดยตรง ทางนั้นจะต้อง implement การตรวจสอบชุดเดียวกันซ้ำอีกรอบ เพราะไม่มีจุดกลางที่ทั้งสองฝั่งใช้ร่วมกันได้อัตโนมัติ เว้นแต่การตรวจสอบนั้นจะถูกแยกออกไปเป็น package ของตัวเองในภายหลัง
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. Server
หัวข้อที่มีชื่อว่า “1. Server”// Package server implements catalogv1.CatalogServiceServer against a// ProductRepo.package server
import ( "context" "errors"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" "github.com/avetavos/shopmicro/services/catalog/internal/repo" "github.com/jackc/pgx/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status")
// Server implements catalogv1.CatalogServiceServer.type Server struct { catalogv1.UnimplementedCatalogServiceServer repo *repo.ProductRepo}
// New returns a Server backed by productRepo.func New(productRepo *repo.ProductRepo) *Server { return &Server{repo: productRepo}}
// ListProducts returns a page of products.func (s *Server) ListProducts(ctx context.Context, req *catalogv1.ListProductsRequest) (*catalogv1.ListProductsResponse, error) { page, pageSize := req.GetPage(), req.GetPageSize() if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 }
products, total, err := s.repo.List(ctx, int(page), int(pageSize)) if err != nil { return nil, status.Errorf(codes.Internal, "list products: %v", err) }
resp := &catalogv1.ListProductsResponse{Total: int32(total)} for _, p := range products { resp.Products = append(resp.Products, toProto(p)) } return resp, nil}
// GetProduct returns a single product by id.func (s *Server) GetProduct(ctx context.Context, req *catalogv1.GetProductRequest) (*catalogv1.Product, error) { if req.GetId() == "" { return nil, status.Error(codes.InvalidArgument, "id is required") }
p, err := s.repo.Get(ctx, req.GetId()) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, status.Error(codes.NotFound, "product not found") } return nil, status.Errorf(codes.Internal, "get product: %v", err) } return toProto(*p), nil}
// CreateProduct creates a new product.func (s *Server) CreateProduct(ctx context.Context, req *catalogv1.CreateProductRequest) (*catalogv1.Product, error) { if req.GetName() == "" { return nil, status.Error(codes.InvalidArgument, "name is required") } if req.GetPriceCents() < 0 { return nil, status.Error(codes.InvalidArgument, "price_cents must be non-negative") } if req.GetStock() < 0 { return nil, status.Error(codes.InvalidArgument, "stock must be non-negative") }
p, err := s.repo.Create(ctx, req.GetName(), req.GetDescription(), req.GetPriceCents(), req.GetStock()) if err != nil { return nil, status.Errorf(codes.Internal, "create product: %v", err) } return toProto(*p), nil}
func toProto(p repo.Product) *catalogv1.Product { return &catalogv1.Product{ Id: p.ID, Name: p.Name, Description: p.Description, PriceCents: p.PriceCents, Stock: p.Stock, }}บันทึกไฟล์นี้เป็น services/catalog/internal/server/server.go สังเกตว่า Server embed catalogv1.UnimplementedCatalogServiceServer แบบ value เหมือนที่ placeholder ทำไว้ทุกประการ — นั่นไม่ใช่ของที่หลงเหลืออยู่ แต่เป็นการรับประกัน forward-compatibility แบบเดียวกันจาก Code Generation →: ถ้ามีการเพิ่ม RPC ตัวที่สี่เข้าไปใน catalog.proto วันไหน Server นี้ก็ยัง compile ผ่านอยู่ (ตกลงไปที่ codes.Unimplemented สำหรับ method ใหม่) แทนที่จะ build ไม่ผ่านจนกว่า implementation ทุกตัวจะถูกอัปเดต
รายละเอียดเรื่อง mapping กับการตรวจสอบที่ควรพูดถึง:
ListProductsใส่ค่า default ให้page/pageSizeแทนที่จะปฏิเสธค่าศูนย์ client ที่ไม่ส่งทั้งสองค่ามา (ค่า zero value ของ fieldint32ที่ไม่ได้ตั้งค่าใน proto3) จะได้หน้า 1 ขนาด 20 แทนที่จะเจอ error — ค่า default ที่เป็นมิตรกว่าสำหรับ operation แบบอ่านอย่างเดียวที่ไม่ทำลายอะไร เมื่อเทียบกับการตรวจสอบที่เข้มงวดของCreateProductGetProductและCreateProductตรวจสอบ ก่อน ที่จะแตะ repository การเช็คreq.GetId() == ""ก่อน แปลว่า ID ว่างเปล่าจะไม่มีวันไปถึง SQL query เลย เพราะโดนปฏิเสธในฐานะ client error (InvalidArgument) ไม่ใช่ “ไม่พบแถว” แบบNotFoundซึ่งจะสื่อผิด ๆ ว่าสินค้าที่มี ID ว่างเปล่านั้นอาจมีอยู่จริงได้errors.Is(err, pgx.ErrNoRows)ไม่ใช่err == pgx.ErrNoRowsProductRepo.Getห่อ error ด้วยfmt.Errorf("...: %w", err)ดังนั้นการเทียบด้วย==ตรง ๆ จะเป็น false เสมอ —errors.Isแกะ chain เพื่อหา sentinel error ตัวจริงที่อยู่ข้างใน- error จาก repository ที่ไม่ใช่ “ไม่พบ” จะกลายเป็น
codes.Internalไม่ใช่codes.NotFoundฐานข้อมูลล่มจริง ๆ กับ “สินค้านี้ไม่มีอยู่จริง” เป็นความล้มเหลวคนละแบบที่มีความหมายต่างกันสำหรับ client — การรวมทั้งสองไว้ใน status code เดียวกันจะทำให้codes.NotFoundเป็นสัญญาณที่เชื่อถือไม่ได้
2. ต่อสาย Server เข้ากับ main.go
หัวข้อที่มีชื่อว่า “2. ต่อสาย Server เข้ากับ main.go”// Command catalog runs the Catalog gRPC server.package main
import ( "context" "log" "net" "os" "os/signal" "syscall"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" "github.com/avetavos/shopmicro/pkg/config" "github.com/avetavos/shopmicro/pkg/pg" "github.com/avetavos/shopmicro/services/catalog/internal/repo" "github.com/avetavos/shopmicro/services/catalog/internal/server" "google.golang.org/grpc" "google.golang.org/grpc/reflection")
func main() { ctx := context.Background()
dbURL := config.Get("CATALOG_DB_URL", "postgres://shopmicro:shopmicro@localhost:5432/catalog?sslmode=disable") grpcAddr := config.Get("CATALOG_GRPC_ADDR", ":50051")
pool, err := pg.NewPool(ctx, dbURL) if err != nil { log.Fatalf("catalog: connect to postgres: %v", err) } defer pool.Close()
lis, err := net.Listen("tcp", grpcAddr) if err != nil { log.Fatalf("catalog: listen on %s: %v", grpcAddr, err) }
productRepo := repo.New(pool) catalogServer := server.New(productRepo)
grpcServer := grpc.NewServer() catalogv1.RegisterCatalogServiceServer(grpcServer, catalogServer) reflection.Register(grpcServer)
go func() { log.Printf("catalog: gRPC server listening on %s", grpcAddr) if err := grpcServer.Serve(lis); err != nil { log.Fatalf("catalog: serve: %v", err) } }()
stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop
log.Println("catalog: shutting down") grpcServer.GracefulStop()}บันทึกทับ services/catalog/cmd/main.go การเปลี่ยนแปลงเดียวจากเวอร์ชันของ The gRPC Server →: productRepo := repo.New(pool) และ catalogServer := server.New(productRepo) ตอนนี้สร้าง dependency chain ตัวจริง และ RegisterCatalogServiceServer ลงทะเบียน catalogServer แทน &placeholderServer{} ส่วนที่เหลือทั้งหมด — listener, การลงทะเบียน reflection, goroutine, การจัดการ signal — ไม่เปลี่ยนแปลงเลย เพราะไม่มีส่วนไหนเลยที่เคยเกี่ยวข้องกับ placeholder ตั้งแต่แรก
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”รัน server ตัวจริง:
go run ./services/catalog/cmdจาก terminal อีกอัน สร้างสินค้าสักชิ้น:
grpcurl -plaintext -d '{"name":"Coffee Mug","description":"350ml ceramic mug","price_cents":1299,"stock":50}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/CreateProduct{ "id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "name": "Coffee Mug", "description": "350ml ceramic mug", "priceCents": "1299", "stock": 50}สังเกตว่า priceCents กลับมาเป็น JSON string "1299" ไม่ใช่ตัวเลข 1299 — นั่นคือ mapping มาตรฐานของ protobuf JSON สำหรับ int64 เพราะตัวเลข JSON ไม่สามารถแทนค่าจำนวนเต็ม 64 บิตได้ทุกค่าอย่างปลอดภัย id จะเป็น UUID ที่ต่างกันทุกครั้งที่รันคำสั่งนี้ คัดลอกเก็บไว้ใช้กับคำสั่งถัดไป
ดึงข้อมูลกลับมาด้วย id (แทนที่ด้วย id จาก response ของ CreateProduct ของคุณเอง):
grpcurl -plaintext -d '{"id":"8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21"}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/GetProduct{ "id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "name": "Coffee Mug", "description": "350ml ceramic mug", "priceCents": "1299", "stock": 50}List สินค้าและยืนยันว่าตัวที่คุณเพิ่งสร้างปรากฏอยู่ โดย total สะท้อนจำนวนแถวจริง:
grpcurl -plaintext -d '{"page":1,"pageSize":10}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/ListProducts{ "products": [ { "id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "name": "Coffee Mug", "description": "350ml ceramic mug", "priceCents": "1299", "stock": 50 } ], "total": 1}ยืนยัน codes.NotFound สำหรับ id ที่ไม่มีอยู่จริง:
grpcurl -plaintext -d '{"id":"00000000-0000-0000-0000-000000000000"}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/GetProductERROR: Code: NotFound Message: product not foundและ codes.InvalidArgument สำหรับ field ที่จำเป็นแต่ขาดหายไป:
grpcurl -plaintext -d '{"description":"no name given"}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/CreateProductERROR: Code: InvalidArgument Message: name is requiredสุดท้าย ยืนยันว่าทั้ง module ยัง build ผ่านอยู่:
go build ./...ไม่มี output แปลว่าสำเร็จ
Server ใน services/catalog/internal/server/server.go implement ทั้งสามเมธอดของ CatalogServiceServer โดยผูกกับ ProductRepo: ListProducts ใส่ค่า default ให้ field สำหรับ paging ที่ไม่ได้ตั้งค่ามาแทนที่จะปฏิเสธ, GetProduct และ CreateProduct ตรวจสอบ input ด้วย status.Error(codes.InvalidArgument, ...) ก่อนที่จะแตะ repository เลย และ GetProduct แปลง pgx.ErrNoRows ที่ถูกห่อไว้ (เช็คด้วย errors.Is) ให้เป็น status.Error(codes.NotFound, "product not found") — ส่วน error อื่นใดจาก repository จะกลายเป็น codes.Internal ทำให้ NotFound เป็นสัญญาณที่เชื่อถือได้ ฟังก์ชัน toProto เล็ก ๆ เป็นจุดเดียวที่ repo.Product กับ catalogv1.Product แตะกัน services/catalog/cmd/main.go ตอนนี้ต่อสาย repo.New(pool) เข้ากับ server.New(productRepo) แทนที่ placeholder ของ The gRPC Server → และ grpcurl ยืนยันว่า CreateProduct → GetProduct → ListProducts ทำงานได้จริงแบบ end-to-end กับแถวข้อมูลจริงใน PostgreSQL โดยเช็ค codes.NotFound กับ codes.InvalidArgument ทั้งคู่โดยตรง นั่นคือ Module 3 เสร็จสมบูรณ์ — Catalog service เป็น gRPC server ที่ใช้งานได้จริงพร้อมฐานข้อมูลของตัวเอง ต่อไป Order Service → จะสร้าง Order service ที่เรียกเข้าไปที่ Catalog service นี้ผ่าน gRPC โดยตรงเพื่อดูราคาปัจจุบันของสินค้า