Mastering errors.Is, errors.As, Contextual Wrapping (%w), and Bare-Metal Error Tree Traversal
ในฐานะวิศวกรจากโลก JVM / Spring Boot สิ่งที่คุณคุ้นเคยที่สุดคือ Exception Handling: เมื่อเกิดปัญหาทางเทคนิค เช่น Database Timeout หรือ Validation ผิดพลาด คุณจะทำการ throw new PaymentProcessingException(...) และปล่อยให้ Framework คอยจับผ่าน @ExceptionHandler
ใน JVM เมื่อเรียก throw new Exception() ตัว Runtime ต้องรันเมธอด fillInStackTrace() แบบ Native System Call เพื่อเดินทางไต่ Thread Stack Frames ย้อนกลับขึ้นไป บันทึก Source Line Numbers, Method Names, และ Class References ทั้งหมด ซึ่งทำให้เกิด CPU Latency Spike อย่างรุนแรงในระดับ Microseconds (High TPS) และทำให้ Flow ของโค้ดขาดความโปร่งใส (GOTO ซ่อนรูป)
Go ตัดกลไก Exception ทิ้งเกือบทั้งหมดสำหรับ Control Flow ทั่วไป แล้วกำหนดให้ Error คือค่าธรรมดาตัวหนึ่ง (Values) ที่ส่งกลับมาทาง Explicit Multiple Return Values ตามแนวคิด: "Errors are just values. Values can be programmed." (Rob Pike)
| คำศัพท์ทางเทคนิค (Go) | นิยามและพฤติกรรมระดับ Runtime | แนวคิดเทียบเท่าใน Kotlin / Spring |
|---|---|---|
| Sentinel Error | ตัวแปรคงที่ (Package-level error constant/variable) นิยมตั้งชื่อขึ้นต้นด้วย Err... เก็บ Pointer หรือค่าที่ชัดเจนเพื่อใช้เปรียบเทียบความเท่ากัน (Equality check) |
Pre-defined Exception singleton เช่น NoSuchElementException หรือ Result Sealed Subclasses |
| Custom Error Type | Struct ที่ทำการ Implement เมธอด Error() string เพื่อเก็บ Context เพิ่มเติม (เช่น Ledger AccountID, Balance, Timestamp) |
Custom Checked/Unchecked Exception class เช่น InsufficientFundsException |
| Error Wrapping | การห่อหุ้ม Error ชั้นในไว้ใน Error ชั้นนอกด้วย fmt.Errorf("...: %w", err) โดยสร้าง Dynamic Node ซ้อนกันเป็น Linked List (Tree) เพื่อรักษาต้นตอ (Root Cause) |
Exception Chaining (new Exception("msg", causeException)) |
| Unwrap() Method | Interface contract ที่คืนค่า error ตัวถัดไป (หรือ []error ใน Go 1.20+) เพื่อให้ Runtime ใช้เดินทางสืบหาบรรพบุรุษ |
เมธอด throwable.getCause() |
| errors.Is | ฟังก์ชันตรวจเช็กว่า Tree ของ Error มี Node ใด Node หนึ่งตรงกับ Target Error หรือไม่ (Equality & Semantic Match) | e is SpecificException หรือการตรวจสอบ Exception Cause ซ้ำๆ |
| errors.As | ฟังก์ชันค้นหา Node ใน Error Tree ที่สามารถ Type-Cast ไปเป็น Target Pointer Type ได้ พร้อมดึงข้อมูลออกมาใช้งาน | catch (e: SpecificException) พร้อมเข้าถึง Field ภายใน |
ใน Go ไวยากรณ์ของ error ไม่ใช่ Class พิเศษ แต่เป็น Built-in Single-Method Interface ระดับรากฐานของภาษา:
// นิยามของ error ใน Built-in Package ของ Go
type error interface {
Error() string
}
เมื่อไหร่ก็ตามที่ Struct ใดก็ตาม implement เมธอด Error() string ค่าของ Struct ตัวนั้นจะถือเป็น error ทันทีตามกฎ Implicit Interface ของ Go (ไม่มีคีย์เวิร์ด implements เหมือน Java)
การสร้าง Error พื้นฐานด้วย Standard Library มี 2 วิธีหลัก:
package main
import (
"errors"
"fmt"
)
// 1. ประกาศ Package-level Sentinel Error (ตัวแปรแบบ immutable intent)
var (
ErrAccountFrozen = errors.New("ledger: account is frozen")
ErrInsufficientFunds = errors.New("ledger: insufficient funds")
)
func DeductBalance(balance, amount int64) (int64, error) {
if amount > balance {
// คืนค่า Sentinel Error โดยตรง (คืน 0, error)
return balance, ErrInsufficientFunds
}
return balance - amount, nil // nil หมายถึงไม่มี error (คล้าย null แต่เป็น Type interface)
}
%w Verb
เมื่อคุณเรียกใช้งานฟังก์ชันระดับล่าง (เช่น Storage/DB) แล้วต้องการแนบ Context เพิ่มเติม (เช่น Account ID) ห้ามใช้ %v หรือ %s หากต้องการให้ Caller ยังคงตรวจสอบต้นตอได้ ให้ใช้ %w (Wrap):
func ProcessWireTransfer(accID string, balance, amount int64) error {
_, err := DeductBalance(balance, amount)
if err != nil {
// %w จะเรียกสร้าง struct wrapError ที่ implement เมธอด Unwrap() อัตโนมัติ
return fmt.Errorf("transfer failed for account %s: %w", accID, err)
}
return nil
}
- fmt.Errorf("...: %v", err) แปลง Error ให้กลายเป็น Plain String ธรรมดา สายโซ่ (Chain) ขาดทันที ฟังก์ชัน errors.Is และ errors.As จะไม่สามารถแกะหา Root Cause ได้อีกต่อไป
- fmt.Errorf("...: %w", err) รักษา Reference ของ Struct เดิมไว้ใน Pointer ภายใน Caller สามารถใช้ errors.Is หรือ errors.As สืบย้อนกลับไปได้เสมอ
errors.Iserr := ProcessWireTransfer("ACC-9021", 100, 500)
if err != nil {
// เดิน Loop สืบหาใน Tree ว่าต้นตอคือ ErrInsufficientFunds หรือไม่
if errors.Is(err, ErrInsufficientFunds) {
// จัดการกรณีเงินไม่พอ (เช่น ส่งแจ้งเตือนหักเงินไม่สำเร็จ)
fmt.Println("Handle: Alert customer to top up funds")
return
}
// กรณีเป็น System Error อื่นๆ
fmt.Printf("Fatal transaction error: %v\n", err)
}
errors.As
จุดผิดพลาดอันดับ 1 ของมือใหม่คือ Argument ของ errors.As ต้องเป็น "Pointer to Pointer" หรือ "Pointer to Interface" เสมอ มิฉะนั้น Runtime จะสั่ง panic ทันที:
// ประกาศ Custom Struct สำหรับ Error ในระบบวิ่งมาราธอน
type RFIDScanError struct {
RunnerID string
AntennaID int
RawSignal int
}
func (e *RFIDScanError) Error() string {
return fmt.Sprintf("rfid: bad signal %ddBm on antenna %d for runner %s", e.RawSignal, e.AntennaID, e.RunnerID)
}
func IngestRFIDPacket(packet []byte) error {
// สมมติว่าเกิดข้อผิดพลาด
return fmt.Errorf("pipeline stage 1: %w", &RFIDScanError{
RunnerID: "BIB-42195",
AntennaID: 4,
RawSignal: -98,
})
}
func HandlePacket() {
err := IngestRFIDPacket(nil)
if err != nil {
// target ต้องเป็น Pointer ไปยัง Type ของ struct ที่ implement error
var scanErr *RFIDScanError
// ส่ง &scanErr (Pointer to Pointer) เข้าไปใน errors.As
if errors.As(err, &scanErr) {
// สำเร็จ! scanErr ถูก assign ค่า instance จากใน chain มาให้เรียบร้อย
fmt.Printf("Dropped packet for runner %s on antenna %d\n", scanErr.RunnerID, scanErr.AntennaID)
return
}
fmt.Println("Unknown ingestion error:", err)
}
}
ใน Go ทุก Interface ประกอบด้วย 2 Words บนสถาปัตยกรรม 64-bit (16 Bytes ทั้งหมด) สำหรับตัวแปร error ธรรมดา มันคือ iface ภายใน Runtime:
// runtime/runtime2.go (โครงสร้างระดับล่างของ Go Interface)
type iface struct {
tab *itab // 8 Bytes: ชี้ไปยัง Type Metadata และ Method Table (itab)
data unsafe.Pointer // 8 Bytes: ชี้ไปยังตัวข้อมูลจริง (Concrete Value / Pointer บน Heap)
}
type itab struct {
inter *interfacetype // ชี้ไปยังนิยามของ interface (error interface)
_type *_type // ชี้ไปยัง Dynamic Type ของ Concrete struct ที่ใส่เข้ามา
hash uint32 // Hash ของ _type สำหรับเปรียบเทียบ Type Assertion รวดเร็ว
_ [4]byte // Padding 4 Bytes ให้ Memory Aligned 8 Bytes
fun [1]uintptr // Array ของ Function Pointers (ตำแหน่งแรกคือ Error() method)
}
fmt.Errorf("%w")
เมื่อคุณสั่ง fmt.Errorf("context: %w", err) ตัวคอมไพเลอร์และแพ็กเกจ fmt จะสร้าง Instance ของ Struct ภายในชื่อว่า wrapError:
// fmt/errors.go
type wrapError struct {
msg string // 16 Bytes (8 Bytes Pointer + 8 Bytes Length)
err error // 16 Bytes (iface: tab 8 Bytes + data 8 Bytes)
}
func (e *wrapError) Error() string {
return e.msg
}
// เมธอด Unwrap คือหัวใจที่ errors.Is และ errors.As ใช้แกะกล่อง
func (e *wrapError) Unwrap() error {
return e.err
}
ตั้งแต่ Go 1.20 เป็นต้นมา มีฟังก์ชัน errors.Join(err1, err2) ที่สามารถรวม Error หลายตัวเข้าด้วยกันได้ โดยใช้ Struct ชื่อ joinError:
// errors/join.go
type joinError struct {
errs []error // 24 Bytes (Pointer 8B + Len 8B + Cap 8B)
}
func (e *joinError) Error() string {
// Concatenate ข้อความของทุก error ด้วย newline
...
}
func (e *joinError) Unwrap() []error {
return e.errs
}
ฟังก์ชัน errors.Is และ errors.As ทำงานอย่างไรในระดับลึก? พวกมันไม่ได้พึ่งพาเวทมนตร์ใดๆ แต่เป็น While-Loop Tree Traversal (สืบหาบรรพบุรุษตาม Interface Contract)
errors.Is(err, target)err == target หรือไม่ ถ้าเท่ากัน ให้คืนค่า true ทันทีerr ตัวปัจจุบัน implement เมธอด Is(target error) bool หรือไม่ หากมี ให้เรียกใช้เมธอดนั้นerr implement เมธอด Unwrap() error หรือไม่ หากมี ให้ขยับ Pointer ไปยัง Error ตัวใน แล้ววนกลับไปข้อ 1Unwrap() []error ให้แตกการท่อง Branch แบบ Depth-First Search (DFS) ไปยัง Error ทุกตัวใน SliceUnwrap() ได้อีกแล้ว ให้คืนค่า falseIs Method Pattern (สำหรับ Dynamic Fields)
ในงาน Banking ระบบมักมี Error Code ที่ซับซ้อน เช่น HTTP Status หรือ Error Code ของ Core Ledger ที่ต้องการให้ errors.Is ถือว่าเท่ากันถ้า Error Code ตรงกัน แม้ว่า Message หรือ Timestamp จะต่างกัน:
type BankingLedgerError struct {
Code string
Message string
Timestamp int64
}
func (e *BankingLedgerError) Error() string {
return fmt.Sprintf("[%s] %s (ts:%d)", e.Code, e.Message, e.Timestamp)
}
// Custom Is() ทำให้ errors.Is มีพฤติกรรมเทียบเฉพาะ Field ที่เรากำหนด
func (e *BankingLedgerError) Is(target error) bool {
t, ok := target.(*BankingLedgerError)
if !ok {
return false
}
return e.Code == t.Code // สนใจเฉพาะ Code ตรงกัน ถือว่า error ชนิดเดียวกัน!
}
ใน Go ค่า nil ของ Pointer กับค่า nil ของ Interface คือคนละสิ่งกัน! เมื่อคุณเก็บ (*MyCustomError)(nil) ลงในตัวแปร error ตัว iface.tab จะมีค่า (ชี้ไปยัง Metadata ของ MyCustomError) ขณะที่ iface.data เป็น nil ทำให้การเช็ก err != nil จะได้ผลลัพธ์เป็น TRUE เสมอ!
// ตัวอย่างโค้ดที่ทำให้เกิดหายนะในระบบหักเงิน
func ValidateDeposit(amount int64) error {
var err *BankingLedgerError = nil // Typed pointer มีค่า nil
if amount < 0 {
err = &BankingLedgerError{Code: "ERR_NEGATIVE", Message: "Negative deposit"}
}
return err // อันตรายมาก! คืนค่า *BankingLedgerError(nil) ห่อเข้า error interface
}
func TestValidation() {
err := ValidateDeposit(100)
// ตรงนี้จะถูกประเมินเป็น TRUE ทันที แม้ amount จะถูกต้อง!
if err != nil {
fmt.Println("PANIC: System thinks there is an error because iface.tab != nil!")
}
}
// วิธีแก้ที่ถูกต้อง (Idiomatic Pattern):
func ValidateDepositFixed(amount int64) error {
if amount < 0 {
return &BankingLedgerError{Code: "ERR_NEGATIVE", Message: "Negative deposit"}
}
return nil // คืนค่า Untyped nil เสมอ! (iface.tab = nil, iface.data = nil)
}
fmt.Errorf("failed: %w", err) ทุกๆ บรรทัดติดต่อกัน 5 ชั้นจนกลายเป็น Log ยาวเหยียดไร้ความหมาย (Spaghetti Message)%w ส่งออกไปหา Client ภายนอก ให้ใช้ %v หรือแมปเป็น User-facing Sentinel Error แทนทดสอบความเข้าใจเชิงลึกระดับ Runtime ทุกข้อมีผลคะแนนสะสมบนแถบด้านบน
เขียนโค้ด Go ให้ผ่านเกณฑ์สถาปัตยกรรม ตรวจสอบ Logic และไวยากรณ์ด้วย In-Browser Verification Engine
โจทย์: สร้าง Sentinel Error ชื่อ ErrInsufficientBalance ด้วย errors.New จากนั้นเขียนฟังก์ชัน Withdraw(balance, amount int64) error หากยอดเงินไม่พอ ให้ Wrap ด้วย fmt.Errorf พร้อมใส่ Prefix "ledger error: %w"
โจทย์: สร้าง Struct ChipDropError (มีฟิลด์ TagID string, ReaderID int) พร้อม Implement เมธอด Error() string และเขียนฟังก์ชัน InspectDrop(err error) (string, bool) โดยใช้ errors.As เพื่อดึง TagID ออกมา
โจทย์: เขียนฟังก์ชัน AuditTransaction(amount int64) error โดยหลีกเลี่ยงข้อผิดพลาด Typed Nil Interface Trap อย่างเคร่งครัด หากยอดเงินน้อยกว่าหรือเท่ากับ 0 ให้คืนค่า Pointer ของ BankingLedgerError แต่ถ้าปกติ ต้องคืนค่า nil ตัวเปล่าตรงๆ