The Postgres Repository
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ตาราง products ผ่าน migration ของ golang-migrate และ services/catalog/internal/repo/products.go — ProductRepo ที่มีสามเมธอด (List, Get, Create) คุยกับตารางนี้ด้วย SQL ดิบผ่าน pgx นี่คือชั้นคั่นระหว่างฐานข้อมูลกับส่วนอื่นทั้งหมด placeholder ของ The gRPC Server → ยังไม่ต้องใช้ชั้นนี้ แต่ The Product API → เรียกเข้ามาที่นี่โดยตรงสำหรับ RPC ทั้งสามตัว
การแยกทุกคำสั่ง SQL ไว้หลัง ProductRepo type เล็ก ๆ ที่สร้างมาเพื่อจุดประสงค์นี้โดยเฉพาะ — แทนที่จะกระจายการเรียก pool.Query ไปทั่วโค้ด handler ของ gRPC — ทำให้ชั้น gRPC พูดคุยกันด้วย Go struct เท่านั้น (repo.Product เข้า, repo.Product ออก) และไม่เคยต้องพูดถึงชื่อคอลัมน์ เลขคณิต LIMIT/OFFSET หรือ type ของ pgx เลย นี่คือเหตุผลเดียวกับที่ The Product API → จะ map repo.Product ไปเป็น catalogv1.Product อย่างชัดเจน แทนที่จะใช้ struct เดียวกันสำหรับทั้งสองฝั่ง: รูปร่างของแถวในฐานข้อมูลกับรูปร่างบน wire ได้รับอนุญาตให้วิวัฒนาการแยกจากกัน และวันนี้ทั้งสองก็ต่างกันอยู่แล้วหนึ่ง field (CreatedAt ซึ่ง .proto ยังไม่ได้เปิดเผยออกมา)
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Repository pattern (ProductRepo type ที่ห่อ SQL ไว้)
- Pros: ทุก query ที่เจาะจง
productsอยู่ในไฟล์เดียวเท่านั้น ดังนั้นการเปลี่ยน schema (เปลี่ยนชื่อคอลัมน์ หรือรูปร่าง query ใหม่ที่เป็นมิตรกับ index) มีที่ลงเพียงที่เดียว; โค้ด gRPC server ที่เรียกrepo.Get(ctx, id)ไม่จำเป็นต้องรู้เลยว่าเบื้องหลังคือ Postgres ซึ่งทำให้ชั้น server ทดสอบแบบ unit test ง่ายด้วยProductRepoปลอม parameterized query ($1,$2, …) เป็นทางเดียวที่ SQL ถูกสร้างขึ้นที่นี่ ซึ่งตัดปัญหา SQL injection ออกไปด้วยโครงสร้าง ไม่ใช่แค่ด้วยวินัย - Cons: สำหรับตารางที่เรียบง่ายขนาดนี้ repository เป็นแค่ตัวห่อบาง ๆ เกือบจะเชิงกลไกรอบ ๆ คำสั่ง SQL สามคำสั่ง — คุณค่าจริงจะเห็นได้เมื่อตารางมีรูปร่าง query มากกว่าหนึ่งสองแบบ มี join ที่ซับซ้อนขึ้น หรือ storage backend ที่สลับได้จริง ซึ่งยังไม่มีข้อไหนเลยที่ใช้กับตาราง
productsเดียวที่มีแค่สาม operation นี้
SQL ดิบผ่าน pgx เทียบกับ ORM
- Pros: SQL ที่คุณอ่านคือ SQL ที่รันจริง — ไม่มี query builder หรือชั้น ORM ที่แปล method call ของ Go เป็น SQL ที่คุณต้อง reverse-engineer ตอนที่อะไรบางอย่างช้า;
pgxเป็น native PostgreSQL driver ระดับ first-class (ไม่ใช่database/sqlที่มี driver ทั่วไปอยู่ข้างใต้) จึงเร็วและเข้าถึง type และฟีเจอร์เฉพาะของ PostgreSQL ได้เต็มที่ - Cons: ทุกคอลัมน์ต้อง scan ด้วยมือลงใน field ของ struct ให้ถูกต้องตามลำดับ — ความไม่ตรงกันระหว่างรายการคอลัมน์ใน
selectกับรายการ argument ในScanเป็นบั๊กจริงที่เกิดง่ายและ compiler จับไม่ได้; ไม่มีการสร้าง migration อัตโนมัติจากนิยาม struct ของ Go แบบที่ ORM บางตัวมีให้ นั่นคือเหตุผลที่บทนี้เขียน migration SQL ด้วยมือแทน
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. Migration
หัวข้อที่มีชื่อว่า “1. Migration”create extension if not exists pgcrypto;
create table products ( id uuid primary key default gen_random_uuid(), name text not null, description text not null default '', price_cents bigint not null, stock int not null default 0, created_at timestamptz not null default now());บันทึกไฟล์นี้เป็น migrations/catalog/0001_init.sql pgcrypto ให้ฟังก์ชัน gen_random_uuid() ที่เป็นตัวสร้าง id ของแต่ละสินค้า — แอปพลิเคชันไม่ต้องสร้างหรือส่ง id เองเลย description มีค่า default เป็น '' แทนที่จะเป็น nullable เพื่อให้ทุกคนที่อ่านค่านี้ใช้เป็น string ธรรมดาได้เลยโดยไม่ต้องจัดการกรณี NULL ส่วน price_cents ไม่มีค่า default เพราะสินค้าต้องสร้างขึ้นพร้อมราคาจริงเสมอ และเลือกเป็น bigint (Go int64) แทนชนิดจุดทศนิยม ด้วยเหตุผลเดียวกับที่ เงินถูกเก็บเป็นจำนวนเต็มหน่วยสตางค์/เซนต์ ไม่ใช่ float: คณิตศาสตร์สกุลเงินแบบจุดทศนิยมสูญเสียความแม่นยำในแบบที่สะสมกันอย่างเงียบ ๆ stock มีค่า default เป็น 0 — สินค้าที่เพิ่งสร้างโดยไม่ระบุสต็อกคือสินค้าหมดสต็อก ไม่ใช่ error
2. รันด้วย golang-migrate
หัวข้อที่มีชื่อว่า “2. รันด้วย golang-migrate”Go Module & Dependencies → ติดตั้ง CLI migrate ผ่าน Homebrew ไว้แล้ว เมื่อตั้งค่า CATALOG_DB_URL ใน shell ของคุณแล้ว (จาก .env):
migrate -path migrations/catalog -database "$CATALOG_DB_URL" upgolang-migrate ติดตามว่า migration ไหนรันไปแล้วบ้างในตาราง schema_migrations ที่ดูแลเอง การรัน up ซ้ำหลังสำเร็จแล้วจึงไม่ทำอะไร และไม่ใช่ error — ปลอดภัยที่จะรันซ้ำ ๆ ได้ รวมถึงจาก CI ก่อนการทดสอบทุกครั้ง
3. ProductRepo
หัวข้อที่มีชื่อว่า “3. ProductRepo”// Package repo is the PostgreSQL-backed store for the Catalog service.package repo
import ( "context" "fmt" "time"
"github.com/jackc/pgx/v5/pgxpool")
// Product is the row shape of the products table.type Product struct { ID string Name string Description string PriceCents int64 Stock int32 CreatedAt time.Time}
// ProductRepo is the PostgreSQL-backed store for products.type ProductRepo struct { db *pgxpool.Pool}
// New returns a ProductRepo backed by db.func New(db *pgxpool.Pool) *ProductRepo { return &ProductRepo{db: db}}
// List returns the page'th page (1-indexed) of up to pageSize products,// ordered by creation time, along with the total row count so callers can// compute how many pages exist.func (r *ProductRepo) List(ctx context.Context, page, pageSize int) ([]Product, int, error) { var total int if err := r.db.QueryRow(ctx, `select count(*) from products`).Scan(&total); err != nil { return nil, 0, fmt.Errorf("repo: count products: %w", err) }
offset := (page - 1) * pageSize rows, err := r.db.Query(ctx, ` select id, name, description, price_cents, stock, created_at from products order by created_at limit $1 offset $2`, pageSize, offset) if err != nil { return nil, 0, fmt.Errorf("repo: list products: %w", err) } defer rows.Close()
var products []Product for rows.Next() { var p Product if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.PriceCents, &p.Stock, &p.CreatedAt); err != nil { return nil, 0, fmt.Errorf("repo: scan product: %w", err) } products = append(products, p) } if err := rows.Err(); err != nil { return nil, 0, fmt.Errorf("repo: iterate products: %w", err) }
return products, total, nil}
// Get returns the product with the given id. The returned error wraps// pgx.ErrNoRows (checkable with errors.Is) when no such product exists.func (r *ProductRepo) Get(ctx context.Context, id string) (*Product, error) { var p Product err := r.db.QueryRow(ctx, ` select id, name, description, price_cents, stock, created_at from products where id = $1`, id).Scan(&p.ID, &p.Name, &p.Description, &p.PriceCents, &p.Stock, &p.CreatedAt) if err != nil { return nil, fmt.Errorf("repo: get product %s: %w", id, err) } return &p, nil}
// Create inserts a new product and returns the row PostgreSQL produced,// including the generated id and created_at.func (r *ProductRepo) Create(ctx context.Context, name, description string, priceCents int64, stock int32) (*Product, error) { var p Product err := r.db.QueryRow(ctx, ` insert into products (name, description, price_cents, stock) values ($1, $2, $3, $4) returning id, name, description, price_cents, stock, created_at`, name, description, priceCents, stock, ).Scan(&p.ID, &p.Name, &p.Description, &p.PriceCents, &p.Stock, &p.CreatedAt) if err != nil { return nil, fmt.Errorf("repo: create product: %w", err) } return &p, nil}บันทึกไฟล์นี้เป็น services/catalog/internal/repo/products.go มีรายละเอียดสองสามอย่างที่ควรพูดถึง:
Listไปฐานข้อมูลสองรอบ การนับจำนวนแถวทั้งหมดกับการดึงข้อมูลหนึ่งหน้าเป็นคนละ query กัน —count(*)ไม่สามารถคำนวณได้จาก result set ของLIMIT/OFFSETเพราะ result set นั้นมีแค่แถวของหน้าปัจจุบันเท่านั้น ทั้งสอง query ใช้ctxเดียวกัน ดังนั้นถ้าผู้เรียกยกเลิก request จะยกเลิก query ที่กำลังทำงานอยู่ตัวไหนก็ตามrows.Close()ผ่านdeferทันทีหลังตรวจ error ของQueryการลืมบรรทัดนี้จะทำให้ connection ที่อยู่ข้างใต้รั่วไหลกลับไปที่ pool ช้าเกินไป (หรือไม่กลับไปเลยในบาง failure path) —deferทันทีหลังตรวจ error คือตำแหน่งที่ถูกต้องตามธรรมเนียม ตรงกับการเรียกpgx/database/sqlอื่น ๆ ทุกจุดใน codebase นี้rows.Err()หลัง looprows.Next()ที่คืนค่าfalseหมายถึง “ไม่มีแถวเหลือแล้ว” หรือ “error มาขัดจังหวะการวนซ้ำ” — สองกรณีนี้แยกไม่ออกจากตัว loop เอง การตรวจrows.Err()ทันทีหลัง loop เป็นทางเดียวที่จะแยกแยะทั้งสองกรณีนี้ได้- clause
returningของCreateแทนที่จะ insert แล้วยิงselectรอบสองเพื่ออ่านidและcreated_atที่ถูกสร้างขึ้น คำสั่งinsert ... returning ...เดียวทำทั้งสองอย่างในรอบเดียวและ statement เดียว — เรียก network น้อยลง และไม่มีช่วงเวลาที่ process อื่นที่ทำงานพร้อมกันจะเห็นแถวนี้ก่อนที่ function นี้จะเห็น - ทุกค่าถูก bind เป็น
$1,$2, … — ไม่เคยถูกแทรกเข้าไปใน SQL string เลย นี่คือสิ่งที่ทำให้ SQL injection เป็นไปไม่ได้ด้วยโครงสร้าง ไม่ใช่แค่ไม่น่าจะเกิดขึ้น Getห่อทุกอย่างที่Scanคืนกลับมาด้วยfmt.Errorf("...: %w", err)ไม่เคยreturn nil, errเปล่า ๆ เพราะ%wรักษา error ที่อยู่ข้างใต้ไว้ The Product API → จึงยังเรียกerrors.Is(err, pgx.ErrNoRows)กับผลลัพธ์ที่ถูกห่อไว้ได้และได้คำตอบที่ถูกต้อง — การห่อ error เพิ่ม context โดยไม่สูญเสียความสามารถในการเช็ค error ค่าเฉพาะ
4. pgx.ErrNoRows อธิบาย
หัวข้อที่มีชื่อว่า “4. pgx.ErrNoRows อธิบาย”QueryRow(...).Scan(...) ของ pgx คืนค่า pgx.ErrNoRows — ไม่ใช่ Product ที่เป็น nil พร้อม error ว่างเปล่า — เมื่อ query ไม่ตรงกับแถวไหนเลย Get ด้านบนไม่ได้แยกกรณีนี้ออกมา แค่ห่อ error ที่ได้กลับมาไม่ว่าจะเป็นอะไร รวมถึง pgx.ErrNoRows แล้วส่งต่อขึ้นไป
เราตั้งใจให้เป็นแบบนี้ เพราะการตัดสินว่า error “ไม่พบ” หมายถึงอะไรสำหรับผู้เรียก ซึ่งในที่นี้คือ gRPC status codes.NotFound ไม่ใช่หน้าที่ของ repository แต่เป็นการตัดสินใจของชั้น presentation ส่วน repository ควรรายงานแค่ข้อเท็จจริงเกี่ยวกับฐานข้อมูล The Product API → คือจุดที่ errors.Is(err, pgx.ErrNoRows) ถูกตรวจสอบจริงแล้วแปลงเป็น gRPC status
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ยืนยันว่าตารางมีอยู่และมีรูปร่างที่ถูกต้อง:
psql "$CATALOG_DB_URL" -c '\d products'คุณควรเห็นครบทั้งหกคอลัมน์ โดย id เป็น primary key ที่มี gen_random_uuid() เป็นค่า default จากนั้นยืนยันว่าทั้ง module ยัง build ผ่าน repo.go เป็นโค้ด Go จริงตั้งแต่วินาทีที่บันทึกไฟล์ go build ./... จึง compile ไฟล์นี้ไปพร้อมกับทุกอย่าง:
go build ./...ไม่มี output แปลว่าสำเร็จ
migrations/catalog/0001_init.sql สร้าง products ด้วย primary key แบบ UUID ที่สร้างโดย pgcrypto, price_cents bigint ที่ไม่ nullable สำหรับเงินหน่วยเซนต์ที่แม่นยำ และคอลัมน์ created_at ที่ query ทุกตัวถัดไปใช้เรียงลำดับ โดย golang-migrate apply ไฟล์นี้แบบ idempotent ด้วย migrate -path migrations/catalog -database "$CATALOG_DB_URL" up ProductRepo ใน services/catalog/internal/repo/products.go ห่อคำสั่ง SQL แบบ parameterized สามคำสั่ง — List (สองรอบ: count(*) กับหน้าแบบ LIMIT/OFFSET), Get (select แถวเดียว), และ Create (insert ... returning ... เดียว) — ไว้หลัง interface สไตล์ Go ที่ไม่เคยรั่วชื่อคอลัมน์หรือ type ของ pgx ออกไปนอกขอบเขตตัวเอง ยกเว้น pgx.ErrNoRows ที่จงใจปล่อยให้ถูกห่อแต่ยังเช็คได้ ให้ชั้นข้างบนแปลงเป็น gRPC status ต่อไป The Product API → จะต่อสาย ProductRepo นี้เข้ากับ CatalogServiceServer implementation ตัวจริง แทนที่ placeholder จากบทแรก