บทที่ 7 · Part 2 — Opinionated Go Style Guide
Errors, Concurrency and Testing Style
กำหนด house style สำหรับ error, context, goroutine และ test โดยไม่ซ่อน failure หรือ lifecycle
style guide มีค่ามากที่สุดตรงจุดที่ code compile และ test อาจผ่าน แต่ production ยังเสียหายได้ Error เดียวอาจถูก log ซ้ำ 5 ชั้น, goroutine อาจค้างหลัง request จบ, timeout อาจถูกตัดทิ้งกลาง call chain และ table test อาจซ่อน scenario ที่แตกต่างกันไว้ใน branch จำนวนมาก เรื่องเหล่านี้ต้องมี default ร่วมกันเพื่อให้ reviewer เห็น risk เร็ว
บทนี้รวบรวมคำแนะนำของ Google และ Uber เรื่อง error, panic, context, goroutine และ tests แล้วผูกเข้ากับ toolchain ปี 2026 เนื้อหานี้เป็น style layer ที่ใช้ review ได้ในหน้าเดียว ส่วน semantics และเครื่องมือเชิงลึกมี บทเฉพาะต่อจากนี้
จบบทนี้คุณจะ
- สร้าง, wrap, inspect, translate และ log error โดยไม่เปิด contract เกินตั้งใจ
- ส่ง context และออกแบบ goroutine ให้มี owner, stop, wait และ error path
- เลือก channel, buffer และ asynchronous API จาก lifecycle/backpressure
- เขียน failure message, table test, helper และ comparison ที่ช่วยหาเหตุจริง
Error เป็นทั้ง Control Flow และ Public Contract
error string สำหรับประกอบเป็น chain ควรเริ่ม lowercase และไม่มี punctuation ท้ายโดยไม่จำเป็น เพราะ layer บน
อาจเติม context อีกชั้น อย่าเริ่มด้วย failed to ทุกครั้ง; บอก operation และข้อมูล low-cardinality ที่ช่วยตามเหตุ:
entry, err := store.Load(ctx, id)
if err != nil {
return Entry{}, fmt.Errorf("loading ledger entry %q: %w", id, err)
}
%w ไม่ได้เป็นเพียง formatting มันเปิด underlying error ให้ caller ตรวจด้วย errors.Is/errors.As และจึงเป็น
API commitment ถ้า boundary ไม่ควรเปิด database driver detail ให้ translate เป็น domain error หรือใช้ %v แล้ว
ตัด chain อย่างตั้งใจ:
if errors.Is(err, sql.ErrNoRows) {
return Entry{}, ErrNotFound
}
return Entry{}, fmt.Errorf("loading ledger entry: %v", err)
Course default คือ sentinel error สำหรับ condition ที่ caller แค่แยกประเภท เช่น ErrNotFound; custom error
type เมื่อ caller ต้องอ่าน structured fields เช่น retry delay หรือ invalid field อย่าให้ caller parseข้อความ
ใช้ errors.Is แทน == เมื่อ error อาจถูก wrap และใช้ errors.As หรือ type-safe API ของ baseline ปัจจุบันเมื่อ
ต้องการ type ใน chain Direct type assertion ตรวจเฉพาะ outermost error และพังง่ายเมื่อมี context เพิ่ม
อย่าส่ง failure แบบ in-band ด้วย zero value ถ้า zero เป็นข้อมูลที่ valid เช่น Lookup ไม่ควรคืน "" เพื่อแปลว่า
ไม่พบเมื่อ empty string เป็นค่าจริงได้ ใช้ (value, ok) สำหรับ absence ที่ไม่ใช่ failure หรือ (value, error)
เมื่อ caller ต้องรู้เหตุ วิธีนี้ทำให้ type signature บังคับการตัดสินแทน comment ที่ลืมอ่านได้
Handle 1 ครั้ง ไม่ใช่ Log ทุก Layer
แต่ละ layer เลือก 1 บทบาท: เพิ่ม context แล้ว return, translate เป็น vocabulary ของ boundary, retry/recover จนสำเร็จ หรือ log แล้วจบการจัดการ ถ้า log แล้ว return โดยไม่มี ownership ชัด เหตุการณ์เดียวจะปรากฏซ้ำและ alert grouping แย่
func (s *Service) Execute(ctx context.Context, id string) error {
if err := s.store.Save(ctx, id); err != nil {
return fmt.Errorf("saving entry: %w", err)
}
return nil
}
// HTTP boundary เป็นจุด translate และบันทึกครั้งเดียว
if err := service.Execute(r.Context(), id); err != nil {
logger.ErrorContext(r.Context(), "request failed", "error", err, "entry_id", id)
writeProblem(w, err)
}
นี่เป็น default ไม่ใช่ prohibition แบบตายตัว Layer กลางอาจ log retry attempt เป็น event แยกได้ถ้ามี event name และระดับที่เหมาะ แต่ต้องไม่ทำเหมือน error ถูก handle สุดท้ายแล้ว
ข้อความ log ควร stable และเก็บ ID/path/count เป็น structured attributes เพื่อ grouping อย่าใส่ secret, token, SQL parameters หรือ personal data ลง error chain เพราะ chain อาจเดินข้ามหลายระบบ
Panic และ Recover ต้องมี Boundary
network failure, invalid request, file missing และ config จาก environment เป็น expected failures จึงคืน error
panic เหมาะกับ programmer invariant ที่ถ้าฝ่าฝืนแล้ว state ต่อไปไม่น่าเชื่อถือ หรือ Must* ที่รับ literal ซึ่ง
developer ควบคุม
recover ไม่ใช่ alternative ของ error handling ใช้ที่ HTTP server, job runner หรือ goroutine boundary เพื่อหยุด failure ไม่ให้ล้มทั้ง process, บันทึก stack และจบ unit of work นั้น ห้าม recover แล้วคืน zero valueเหมือนสำเร็จ ถ้า goroutine ใหม่อาจ panic ต้องรู้ว่า runtime ใด recover ให้; panic ไม่ข้าม goroutine ไปหา caller
รายละเอียด error taxonomy และ structured errors อยู่ใน Errors as Public Contracts
Context เดินตาม Unit of Work
operation ที่ข้าม process/resource boundary รับ ctx context.Context เป็น parameter แรก ส่ง context เดิมผ่าน
HTTP, service, database และ external API ไม่สร้าง context.Background() กลาง request เพราะนั่นตัด cancellation,
deadline และ trace
func (s *Service) Load(ctx context.Context, id string) (Entry, error) {
return s.store.Load(ctx, id)
}
ไม่เก็บ context ใน struct และไม่รับ *context.Context Interface value ส่งเป็น value อยู่แล้วและ context เป็น
request-scoped lifetime ไม่ใช่ dependency ของ service Caller เป็นเจ้าของ budget; layer ล่างอาจทำให้ deadline
สั้นลงเพื่อสงวนเวลา cleanup แต่ไม่ยืดหรือทิ้ง deadline ของ parent
ทุก WithCancel, WithTimeout หรือ WithDeadline ต้องมีเส้นทางเรียก cancel โดยปกติ defer cancel() อยู่ติดกับ
creation ถ้า ownership ถูกโอนไปให้ object/handle ต้อง document ว่าใครเรียกเมื่อไร Context values ใช้เฉพาะ
metadata ที่เดินตาม request เช่น trace ID ไม่ใช่ optional function parameters
context.WithoutCancel ใช้ได้เมื่องานต้อง outlive request จริง เช่น bounded audit flush แต่ไม่ทำให้งานมี owner
โดยอัตโนมัติ ต้องสร้าง deadline ใหม่, จำกัด queue และผูก wait เข้ากับ process shutdown
ทุก Goroutine ต้องตอบ 4 คำถาม
ก่อนเขียน go f() ให้ตอบ:
- ใครเป็น owner และเริ่มมันทำไม
- มันหยุดอย่างไรเมื่อ success, error หรือ cancellation
- ใคร wait จน resource ถูกคืนครบ
- error/panic และ backpressure ถูกส่งไปไหน
ถ้าตอบไม่ได้ให้เริ่มด้วย synchronous function แล้วให้ caller ตัดสิน concurrency
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, job := range jobs {
job := job
g.Go(func() error {
return process(ctx, job)
})
}
return g.Wait()
สำหรับ module Go 1.22+ loop variable semantics แก้ capture pitfall หลายกรณีแล้ว แต่การประกาศ local copy ยัง
ช่วย compatibility หรือเน้น ownership ได้ อย่าคัดลอก ritual โดยไม่ดู go directive
Go 1.25+ มี WaitGroup.Go สำหรับงาน fire-and-wait ที่ไม่ต้องส่ง error ส่วนงานที่ต้อง cancel siblings, จำกัด
concurrency หรือคืน first error ยังเหมาะกับ errgroup เลือก primitive จาก contract ไม่ใช่ความใหม่
Channel, Mutex และ Buffer คือคนละการตัดสิน
channel เหมาะกับส่ง ownership/event และ coordination, mutex เหมาะกับปกป้อง invariant ของ shared state, atomic เหมาะกับค่าเดี่ยวที่ operation/memory ordering ชัด อย่าบังคับ slogan “share memory by communicating” จนสร้าง actor/goroutine สำหรับ map ธรรมดาที่ mutex อ่านง่ายกว่า
sender หรือ owner ที่สร้าง channel เป็นผู้ close; receiver ไม่ close channel ที่ยังมี sender ได้ ระบุ direction
ใน signature เมื่อช่วยป้องกัน misuse และใส่ ctx.Done() ใน select ของ operation ที่ต้อง cancel ได้
คอร์สไม่รับกฎ “channel ต้อง unbuffered หรือ size 1” เป็น universal default Buffer คือ queue capacity และต้อง มาจาก producer rate, consumer latency, memory budget และ behavior เมื่อเต็ม:
| เมื่อ queue เต็ม | เหมาะเมื่อ | ความเสี่ยง |
|---|---|---|
| block producer | backpressure ต้องไหลย้อน | อาจ deadlock ถ้า lifecycle ผิด |
| reject/error | caller retry หรือ degrade ได้ | ต้องมี observable rejection |
| drop newest/oldest | telemetry บางชนิด | สูญข้อมูล ต้องวัด drop |
| spill durable queue | งานห้ามหาย | latency และ operational cost สูงขึ้น |
ส่ง pointer ผ่าน channel ได้เมื่อ ownership transfer ชัดและ sender ไม่แตะอีก ไม่จำเป็นต้องห้ามทุกกรณี แต่ถ้า ทั้ง 2 ฝั่ง mutate pointer เดียวกัน channel ไม่ได้ลบ data race
อ่าน lifecycle เชิงลึกใน Goroutine Ownership และ synchronization ใน Synchronization and Backpressure
Test Failure ต้องช่วยวินิจฉัย
failure message ควรบอก function/scenario, input สำคัญ, got และ want โดยวาง got ก่อน want ให้รูปแบบสม่ำเสมอ:
if got != want {
t.Errorf("Normalize(%q) = %q, want %q", input, got, want)
}
ใช้ t.Fatal เมื่อ assertion ล้มแล้ว test ต่อไม่ได้ เช่น setup คืน nil ใช้ t.Error เมื่อยังตรวจ independent
properties ต่อได้ แต่ห้ามเรียก Fatal, FailNow หรือ require.* จาก goroutine อื่น เพราะมันออกเฉพาะ goroutine
นั้นและอาจทำให้ test ค้าง ส่ง result กลับ channel แล้ว assert ใน test goroutine
comparison ควรตรวจ object ทั้งก้อนเมื่อ contract คือค่าทั้งก้อน เพื่อไม่ลืม field ใหม่ cmp.Diff(want, got)
เหมาะกับ nested values และ custom options ส่วน reflect.DeepEqual มี semantics บางชนิดที่อาจไม่ตรง domain
อย่าเทียบ field ทีละตัวเพียงเพื่อหลีกเลี่ยง dependency ถ้า failure output แย่กว่า
assertion library เช่น Testify ใช้ได้เมื่อเพิ่ม readability และ diff ที่ดี แต่ control flow สำคัญควรมองเห็น
require ใน helper ต้องผูกกับ *testing.T ของ subtest ปัจจุบัน ไม่สร้าง assertion object จาก parent แล้ว reuse
ใน subtests เพราะ failure attribution จะผิด
Table Tests ใช้เมื่อ Cases มี Shape เดียวกัน
table-driven test เหมาะเมื่อ setup, action และ assertion เหมือนกัน ต่างเพียง input/expected แต่ไม่ใช่เป้าหมาย ว่าทุก test ต้องเป็น table ถ้าแต่ละ case มี branch, mock graph หรือ failure contract ต่างกัน ให้แยก test เพื่อให้ ชื่อและ body เล่า scenario ตรง ๆ
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want ID
}{
{name: "canonical", in: "A-42", want: ID("A-42")},
{name: "trims spaces", in: " A-42 ", want: ID("A-42")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.in)
if err != nil {
t.Fatalf("Parse(%q): %v", tt.in, err)
}
if got != tt.want {
t.Errorf("Parse(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
ชื่อ case บอก behavior ไม่ใช่เลขลำดับ ใช้ map เป็น test table เฉพาะเมื่อต้องการยืนยัน independence จาก order เพราะ iteration order ไม่ stable และทำ debug/reproduce ยากขึ้น
comparison ต้องรักษาเฉพาะ contract ถ้า order ไม่สำคัญให้ sort/normalize copy ก่อนเทียบ แต่ถ้า production ควร
deterministic อย่า normalize จน test ปิดบัง bug ถ้า error string เป็นข้อความสำหรับมนุษย์ ให้ทดสอบ
errors.Is, errors.As หรือ structured fields แทนการจับทั้งข้อความ เว้นแต่ข้อความนั้นเป็น external contract จริง
helper ที่รับ *testing.T เรียก t.Helper() เพื่อให้ failure ชี้ call site ใช้ t.Cleanup() คืน temp files,
servers, environment หรือ fakes แบบ LIFO และให้ test แต่ละตัวเป็นเจ้าของ setup ของตน นี่คือ fixture ใน Go:
function/helper/builder/harness ที่สร้างโลกขั้นต่ำและคืน cleanup ไม่จำเป็นต้องมี framework หรือ suite object
เสมอไป
test ใน package เดียวเหมาะเมื่อจำเป็นต้องตรวจ unexported behavior; package _test เหมาะกับมุม client และช่วย
เปิด coupling ของ public API ไม่มีแบบใดชนะทุกครั้ง Test helper package ต้องมี capability เฉพาะ เช่น
clocktest หรือ ledgertest ไม่สร้าง testutil ที่กลายเป็น global fixture รวมทุกอย่าง
ใช้ TestMain เมื่อมี package-wide resource ราคาแพงและ teardown จริงเท่านั้น Setup เฉพาะ test ควรอยู่ใกล้ test
เพื่อให้ -run ยังทำงานอิสระและ parallel ได้ สำหรับ HTTP/gRPC boundary ให้พิจารณา httptest หรือ in-process
real transport แทนการ mock request/response เอง เพราะ protocol behavior เป็นส่วนที่ต้องทดสอบ แต่ external
paid/slow service ยังควรใช้ fake server หรือ integration suite แยกตาม cost
parallel test ใช้เมื่อ state independent จริง หลีกเลี่ยง package global, shared environment และ fixed port ก่อน
เติม t.Parallel() Race detector ต้องอยู่ใน CI และ concurrency/time-based tests ใช้ testing/synctest บน Go
1.25+ เมื่อเหมาะ แทน sleep เพื่อหวัง timing
อ่านเต็มใน Unit Testing in Go และ Testing Concurrent Code
Review Lab: Failure และ Lifecycle ที่ซ่อนอยู่
func (s *Service) Start(ctx context.Context) {
go func() {
if err := s.poll(context.Background()); err != nil {
slog.Error("poll failed", "error", err)
}
}()
}
func TestStart(t *testing.T) {
s := newService(t)
s.Start(context.Background())
time.Sleep(100 * time.Millisecond)
require.True(t, s.Ready())
}
review ควรพบ async API ที่คืน startup error ไม่ได้, ตัด parent context, ไม่มี stop/wait, log อยู่ใน layer ที่
owner อาจ log ซ้ำ, unbounded poll lifecycle, test พึ่ง wall-clock และ fixture ไม่เห็น cleanup Contract ใหม่อาจเป็น
Run(ctx) error; production owner เรียกผ่าน errgroup; test ใช้ cancellable context และ wait handle หรือ
synctest, แล้ว cleanup ยืนยันว่า goroutine จบ
Checklist สำหรับ Errors, Concurrency และ Tests
%wถูกใช้เมื่อยอมเปิด chain และ boundary translate implementation error แล้ว- failure ถูก handle/log สุดท้าย 1 แห่ง ไม่มี swallowed หรือ duplicate error
- context เดิมเดินถึง I/O และ derived context ถูก cancel
- goroutine ทุกตัวมี owner, stop, wait, error/panic และ overload policy
- channel capacity มาจาก backpressure contract ไม่ใช่เลขตาม style guide
- failure message บอก input, got, want และชี้ scenario ได้
- table test มี shape เดียวกัน; fixture/helper มี cleanup และไม่แชร์ state โดยไม่ตั้งใจ
- version-specific advice ตรวจ
godirective ก่อนนำมาใช้
บทนี้เรียบเรียงใหม่โดยอิง Google Go Style Decisions, Google Go Best Practices, Uber Go Style Guide, Go Blog: Pipelines และ Go Testing Package