LEARNING GO
ตอนที่ 9: Errors
On this page
ตอนที่แล้วเราพูดถึง Generics ที่ช่วยให้เขียนโค้ดชุดเดียวกับหลาย type ได้ บทนี้จะกลับมาดูเรื่องที่ทุกโปรแกรมหนีไม่พ้น: ถ้าทำงานไม่สำเร็จ เราจะบอก caller อย่างไร?
Go ไม่มี try/catch แบบภาษาที่ใช้ exception แต่ให้ function คืน error กลับมาเป็นค่าธรรมดา แล้วให้ caller ตรวจด้วย if err != nil เอง ฟังดูเหมือนต้องเขียน if เยอะนิดหนึ่ง แต่ข้อดีคือ failure path เปิดเผยมาก ไม่มีเส้นทางลับที่จู่ ๆ กระโดดข้ามโค้ดไปหา catch ที่อยู่ไกลออกไป
ลองคิดว่า error เป็นใบแจ้งปัญหาที่ function ส่งคืนมาพร้อมผลลัพธ์ ถ้าใบนี้เป็น nil ก็แปลว่างานผ่าน ถ้าไม่ใช่ nil ก็แปลว่าต้องอ่านและตัดสินใจว่าจะจัดการ, เพิ่ม context หรือส่งต่อ
สิ่งที่จะได้ตอนจบบทนี้:
- สร้างและตรวจ
errorด้วยerrors.New,fmt.Errorfและif err != nil - คืน zero value ที่เหมาะสมเมื่อ function ทำงานไม่สำเร็จ
- ใช้ sentinel error และเข้าใจว่าทำไม error ที่ถูก wrap ต้องตรวจด้วย
errors.Is - สร้าง custom error type เพื่อแนบ field เพิ่ม แล้วดึงข้อมูลด้วย
errors.As - เพิ่ม context ด้วย
%wและเดินทางใน error tree โดยไม่ทำข้อมูลต้นฉบับหาย - รวม validation errors หลายตัวด้วย
errors.Join - ลดโค้ด wrap ซ้ำด้วย
deferและ named return value อย่างมีสติ - แยกให้ออกว่าเมื่อไหร่ควรคืน error และเมื่อไหร่
panic/recoverถึงจะเหมาะ - ใช้
-trimpathเมื่อไม่ต้องการเผย full path จาก stack trace
graph TD
A["function ทำงาน"] --> B{"err == nil?"}
B -->|ใช่| C["golden path เดินต่อ"]
B -->|ไม่ใช่| D["จัดการหรือเพิ่ม context"]
D --> E{"ต้องตรวจแบบไหน?"}
E -->|sentinel หรือค่า| F["errors.Is"]
E -->|custom type| G["errors.As"]
D --> H["return ... err"]
H --> I["caller ได้ failure path ที่ชัดเจน"]
วิธีทำตามบทนี้
เราจะสร้าง project ใหม่ชื่อ go-errors เพื่อทดลองโค้ด โดยใช้ Go 1.21 ขึ้นไป แล้วรันคำสั่งนี้:
mkdir go-errors
cd go-errors
go mod init go-errors
touch main.go
ในแต่ละ Step ให้แทนที่โค้ดใน main.go ด้วยตัวอย่างของ Step นั้น แล้วรัน:
go run .
ตัวอย่างที่ตั้งใจให้ compile ไม่ผ่านหรือ panic จะแยกจากโปรแกรมที่รันได้ชัดเจน อย่านำไปวางรวมกับตัวอย่างปกติ ไม่อย่างนั้นโปรแกรมจะพังตามชื่อบทจริง ๆ
Step 1: error คือค่าที่บอกว่า function ทำงานสำเร็จไหม
เริ่มจาก function ง่าย ๆ ที่หารเลข ถ้า denominator เป็นศูนย์ เราไม่ควรปล่อยให้โปรแกรมเดินต่อด้วยผลลัพธ์มั่ว ๆ แต่ควรคืน error ให้ caller ตัดสินใจ:
package main
import (
"errors"
"fmt"
)
func divide(numerator, denominator int) (int, error) {
if denominator == 0 {
return 0, errors.New("cannot divide by zero")
}
return numerator / denominator, nil
}
func main() {
for _, denominator := range []int{4, 0} {
result, err := divide(20, denominator)
if err != nil {
fmt.Printf("20 / %d failed: %v\n", denominator, err)
continue
}
fmt.Printf("20 / %d = %d\n", denominator, result)
}
}
ผลลัพธ์คือ:
20 / 4 = 5
20 / 0 failed: cannot divide by zero
ใน Go error เป็น interface ที่มี method เดียวคือ Error() string ส่วน nil คือ zero value ของ interface ที่ใช้สื่อว่า “ไม่มี error”
เวลาสำเร็จ function จึงคืนผลลัพธ์ตามปกติและคืน nil เป็น error ส่วนเวลาไม่สำเร็จให้คืนผลลัพธ์อื่นเป็น zero value ที่เหมาะสม เช่น 0, "", nil slice หรือ nil pointer พร้อม error ที่อธิบายปัญหา
การวาง error เป็น return value ตัวสุดท้ายเป็น convention ไม่ใช่กฎที่ compiler บังคับ แต่เป็น convention ที่แข็งแรงมากจนควรทำตาม เพราะ caller จะอ่าน signature แล้วรู้ได้ทันทีว่าต้องตรวจ failure ตรงไหน
ทำไมต้องตรวจ error ทันที?
เพราะถ้าเราใช้ค่าต่อก่อนตรวจ error อาจกำลังใช้ข้อมูลที่ไม่สมบูรณ์อยู่ เช่น:
ตัวอย่างนี้เป็น fragment เพื่อชี้จุด อย่าวางรันเดี่ยว ๆ เพราะต้องมี divide จากตัวอย่างก่อนหน้าอยู่ด้วย:
result, err := divide(20, 0)
fmt.Println(result + 10) // อย่าใช้ result ก่อนตรวจ err
โค้ดด้านบนอาจ compile ผ่านและพิมพ์ 10 ออกมา แต่ 10 ไม่ได้แปลว่าการหารสำเร็จ มันเป็นแค่ zero value ที่ function คืนมาเพื่อให้ signature ครบเท่านั้น
อ่าน
errก่อนใช้ผลลัพธ์เสมอ แล้ว failure path จะไม่แอบปนอยู่ใน golden path ของเรา
Step 2: errors.New หรือ fmt.Errorf ดี?
ถ้า error message เป็นข้อความคงที่ ใช้ errors.New ได้เลย:
นี่เป็น syntax สั้น ๆ สำหรับดูรูปแบบการสร้าง error:
errors.New("cannot divide by zero")
แต่ถ้าต้องแทรกข้อมูล runtime เช่นค่าที่ผิดหรือชื่อไฟล์ ให้ใช้ fmt.Errorf:
fmt.Errorf("%d is not an even number", value)
ลองรันตัวอย่างนี้:
package main
import "fmt"
func doubleEven(value int) (int, error) {
if value%2 != 0 {
return 0, fmt.Errorf("%d is not an even number", value)
}
return value * 2, nil
}
func main() {
for _, value := range []int{4, 3} {
result, err := doubleEven(value)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("result:", result)
}
}
ผลลัพธ์คือ:
result: 8
error: 3 is not an even number
เลือกใช้แบบนี้จำง่าย ๆ:
| วิธี | เหมาะกับ | ตัวอย่าง |
|---|---|---|
errors.New | ข้อความคงที่ ไม่ต้องใส่ค่าจาก runtime | errors.New("user not found") |
fmt.Errorf | ข้อความที่ต้องแทรกค่า runtime | fmt.Errorf("user %d not found", id) |
ตาม convention ของ Go ข้อความ error ควรขึ้นต้นด้วยตัวพิมพ์เล็ก และไม่ควรลงท้ายด้วยจุด, newline หรือเครื่องหมายตกแต่งที่ไม่จำเป็น เพราะ caller อาจนำ error ไป wrap ต่อหรือเอาไปแสดงในประโยคอื่น
Step 3: Sentinel error ใช้สื่อสถานะที่ caller สนใจ
บาง error ไม่ได้มีไว้บอกข้อความอย่างเดียว แต่มีไว้บอกสถานะที่ caller ต้องตัดสินใจ เช่น “หา user ไม่เจอ” หรือ “permission ไม่พอ” เราสามารถประกาศ error กลางระดับ package เรียกว่า sentinel error ได้:
package main
import (
"errors"
"fmt"
)
var ErrUserNotFound = errors.New("user not found")
func findUser(id string) (string, error) {
if id != "1" {
return "", ErrUserNotFound
}
return "Mina", nil
}
func greeting(id string) (string, error) {
name, err := findUser(id)
if err != nil {
return "", fmt.Errorf("load greeting for user %q: %w", id, err)
}
return "Hello, " + name, nil
}
func main() {
message, err := greeting("1")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(message)
_, err = greeting("404")
fmt.Println(err)
fmt.Println("== sentinel:", err == ErrUserNotFound)
fmt.Println("Is sentinel:", errors.Is(err, ErrUserNotFound))
}
ผลลัพธ์คือ:
Hello, Mina
load greeting for user "404": user not found
== sentinel: false
Is sentinel: true
ทำไม err == ErrUserNotFound ถึงเป็น false? เพราะ greeting เพิ่ม context ด้วย %w แล้ว error ตัวที่ caller ได้ไม่ใช่ sentinel ตรง ๆ แต่เป็น error ที่ห่อ sentinel ไว้อีกชั้น
errors.Is จะเดินเข้าไปดู error ที่ถูก wrap อยู่ข้างในให้ จึงใช้ตรวจได้แม้ error จะถูกห่อกี่ชั้นก็ตาม
ชื่อ sentinel มักขึ้นต้นด้วย Err และควรปฏิบัติต่อมันเหมือน read-only ถึงแม้ตัวแปรระดับ package จะถูก reassign ได้ในทางเทคนิคก็ตาม การแก้ค่า ErrUserNotFound จากที่อื่นจะทำให้ contract ของ package พังและทำให้ test เดายาก
sentinel error ควรใช้เมื่อ caller จำเป็นต้องแยกสถานะนั้นจริง ๆ ถ้า caller สนใจแค่ข้อความสำหรับ log และไม่มี decision อะไรต่อ ก็สร้าง error ธรรมดาใน function ได้ ไม่ต้องประกาศ public API เพิ่มทุกครั้ง
Step 4: Custom error type เอาข้อมูลออกมาใช้
บางครั้งข้อความอย่าง "invalid input" ไม่พอ เราอยากรู้ด้วยว่า field ไหนผิดและผิดเพราะอะไร ให้สร้าง struct ที่ implement Error() string:
package main
import (
"errors"
"fmt"
"strings"
)
type ValidationError struct {
Field string
Problem string
}
func (e ValidationError) Error() string {
return e.Field + ": " + e.Problem
}
func validateUsername(username string) error {
if strings.TrimSpace(username) == "" {
return ValidationError{
Field: "username",
Problem: "must not be empty",
}
}
return nil
}
func main() {
err := validateUsername(" ")
if err == nil {
fmt.Println("valid")
return
}
fmt.Println("error:", err)
var validationErr ValidationError
if errors.As(err, &validationErr) {
fmt.Println("field:", validationErr.Field)
fmt.Println("problem:", validationErr.Problem)
}
}
ผลลัพธ์คือ:
error: username: must not be empty
field: username
problem: must not be empty
ถึงเราจะสร้าง ValidationError เอง แต่ function ยังควรประกาศ return type เป็น error ไม่ใช่ ValidationError เพราะอนาคต function อาจคืน error หลายรูปแบบ และ caller ไม่ควรถูกผูกกับ implementation เฉพาะตัว
ระวัง custom error ที่ทำให้ nil หาย
โค้ดชุดนี้ตั้งใจให้เห็น bug เป็น fragment ที่ต่อจาก ValidationError ด้านบน อย่าวางรันเดี่ยว ๆ และอย่านำไปใช้เป็น pattern:
func broken(flag bool) error {
var validationErr ValidationError
if flag {
validationErr = ValidationError{Field: "username"}
}
return validationErr
}
ถ้าเรียก broken(false) จะได้ err != nil เป็น true ทั้งที่เราไม่ได้สร้าง error จริง ๆ เพราะตอน return ค่า ValidationError เข้า interface แล้ว interface มี dynamic type ติดไปด้วย
ทางที่ปลอดภัยคือ return nil ตรง ๆ เมื่อไม่มี error ตัวอย่างนี้เป็น fragment ที่ใช้ ValidationError จากด้านบน:
func validate(flag bool) error {
if flag {
return ValidationError{Field: "username"}
}
return nil
}
หรือถ้าจำเป็นต้องมีตัวแปรกลาง ให้ประกาศเป็น error ตั้งแต่แรก:
var err error
if flag {
err = ValidationError{Field: "username"}
}
return err
เรื่องนี้ต่อเนื่องจากตอนที่แล้วที่เราคุยเรื่อง interface กับ typed nil เลย เป็นจุดเล็ก ๆ ที่หลอกตาได้เก่งมาก
Step 5: Wrapping เพิ่ม context โดยไม่ทิ้ง error เดิม
เวลาส่ง error ผ่านหลาย function ข้อความต้นฉบับอย่าง file not found อาจไม่บอกว่าเกิดตอน operation ไหน เราจึงเพิ่ม context ด้วย wrapping
ใช้ %w ใน fmt.Errorf เพื่อเก็บ error ต้นฉบับไว้ใน error ใหม่:
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
return nil
}
func loadApp(path string) error {
if err := readConfig(path); err != nil {
return fmt.Errorf("load app: %w", err)
}
return nil
}
func main() {
err := loadApp("missing.json")
if err == nil {
fmt.Println("loaded")
return
}
fmt.Println(err)
fmt.Println("is missing:", errors.Is(err, os.ErrNotExist))
}
ผลลัพธ์จะมีหน้าตาประมาณนี้ ข้อความจากระบบปฏิบัติการอาจต่างกันเล็กน้อย:
load app: read config "missing.json": open missing.json: no such file or directory
is missing: true
มอง error ที่ถูก wrap เป็นต้นไม้ได้แบบนี้:
load app
└── read config "missing.json"
└── open missing.json: no such file or directory
ทุกชั้นเพิ่ม context ของตัวเอง แต่ยังเก็บรากเดิมไว้ ทำให้คนอ่าน log เข้าใจเส้นทาง และทำให้ errors.Is(err, os.ErrNotExist) ยังตรวจสถานะต้นฉบับได้
ถ้าเขียน %v แทน %w จะได้แค่ข้อความที่เอามาต่อกัน แต่จะไม่สร้างความสัมพันธ์ให้ errors.Is หรือ errors.As เดินเข้าไปหา error ข้างในได้:
ตัวอย่างนี้เป็นบรรทัดที่ใช้แทนใน function เดิม ไม่ใช่ไฟล์เต็ม:
return fmt.Errorf("load app: %v", err) // เห็นข้อความ แต่ไม่ wrap
โดยทั่วไป convention คือใส่ : %w ท้าย format string และวาง error ที่จะ wrap เป็น argument ตัวสุดท้าย เพื่อให้ข้อความอ่านต่อกันเป็นธรรมชาติ
errors.Unwrap(err) มีไว้ดึง error ชั้นถัดไปตรง ๆ แต่ในโค้ด application มักใช้ errors.Is และ errors.As มากกว่า เพราะสองตัวนี้เดินทั้ง error tree และบอกเจตนาของเราได้ชัดกว่า
Step 6: รวมหลาย error ด้วย errors.Join
validation เป็นงานที่เจอกรณี “ผิดหลายจุดพร้อมกัน” บ่อยมาก ถ้า return แค่ error แรก ผู้ใช้จะต้องแก้ทีละรอบเหมือนเกมที่ให้บอสโผล่มาทีละตัว น่าหงุดหงิดโดยใช่เหตุ
Go มี errors.Join สำหรับรวมหลาย error เป็น error เดียว:
package main
import (
"errors"
"fmt"
"strings"
)
type Person struct {
FirstName string
LastName string
Age int
}
func validatePerson(person Person) error {
var errs []error
if strings.TrimSpace(person.FirstName) == "" {
errs = append(errs, errors.New("first name cannot be empty"))
}
if strings.TrimSpace(person.LastName) == "" {
errs = append(errs, errors.New("last name cannot be empty"))
}
if person.Age < 0 {
errs = append(errs, errors.New("age cannot be negative"))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func main() {
err := validatePerson(Person{Age: -1})
if err != nil {
fmt.Println(err)
return
}
fmt.Println("valid")
}
ผลลัพธ์คือ:
first name cannot be empty
last name cannot be empty
age cannot be negative
ข้อควรจำคือ errors.Join จะคืน nil ถ้าไม่มี error ที่ส่งเข้าไป ดังนั้น function จึงคืน nil เองเมื่อ errs ว่าง เพื่อให้เจตนาอ่านง่ายและไม่ต้องพึ่งพารายละเอียด implementation
error ที่ได้จาก errors.Join ก็ยังตรวจด้วย errors.Is และ errors.As ได้เหมือน error ที่ wrap ตัวเดียว เพราะข้างในเป็น error tree ที่มีลูกหลายตัว เพียงแต่อย่าใช้ errors.Unwrap คาดหวังว่าจะได้ลูกทั้งหมด เพราะ Unwrap แบบหลาย error คืน []error และ helper ตัวเก่าไม่ได้เดินรูปแบบนั้นให้เรา
ถ้าการ validate มีหลายจุดผิด การคืนทุกปัญหาในรอบเดียวมักเป็นมิตรกับ caller มากกว่าคืน error แรกแล้วให้เขาเดาเองว่ามีอะไรอีก
Step 7: จำง่าย ๆ ว่าใช้ errors.Is หรือ errors.As
สอง function นี้ชื่อคล้ายกันจนมือใหม่มีสิทธิ์สลับได้ ลองจำด้วยคำถามนี้:
| คำถามที่เราอยากรู้ | Function | สิ่งที่หา |
|---|---|---|
| error นี้คือสถานะหรือ instance ที่สนใจไหม? | errors.Is | sentinel หรือค่าเฉพาะ |
| error นี้เป็น type ที่มีข้อมูลให้หยิบใช้ไหม? | errors.As | custom error type หรือ interface |
ตัวอย่างการใช้ errors.As หลัง wrap:
package main
import (
"errors"
"fmt"
)
type HTTPError struct {
StatusCode int
URL string
}
func (e HTTPError) Error() string {
return fmt.Sprintf("request %s failed with status %d", e.URL, e.StatusCode)
}
func request(url string) error {
return fmt.Errorf("fetch resource: %w", HTTPError{
StatusCode: 503,
URL: url,
})
}
func main() {
err := request("https://example.com/data")
var httpErr HTTPError
if errors.As(err, &httpErr) {
fmt.Println("status:", httpErr.StatusCode)
fmt.Println("url:", httpErr.URL)
}
}
ผลลัพธ์คือ:
status: 503
url: https://example.com/data
สังเกตว่า argument ตัวที่สองของ errors.As คือ &httpErr ซึ่งเป็น pointer ไปยังตัวแปรที่เราต้องการให้ function เติมค่าให้ ถ้าเขียน errors.As(err, httpErr) ในตัวอย่างนี้จะ compile ผ่าน เพราะ target รับเป็น any แต่จะ panic ตอน runtime เพราะ target ไม่ใช่ pointer ที่ถูกต้อง
เราสามารถหา interface ได้ด้วย เช่นถ้า error หลาย type มี method StatusCode() int เราก็ประกาศตัวแปรเป็น interface แล้วส่ง address เข้า errors.As ได้ ไม่จำเป็นต้องรู้ชื่อ concrete type ทุกตัว
อย่าใช้ type assertion ตรง ๆ แบบ err.(HTTPError) กับ error ที่อาจถูก wrap เพราะมันมองแค่ type ที่อยู่ชั้นนอกสุด ให้ใช้ errors.As เพื่อเดินเข้าไปใน tree แทน
Step 8: ลดโค้ด wrap ซ้ำด้วย defer
ถ้า function หนึ่งเรียกหลาย function แล้วทุกจุดต้องเติมข้อความเดิม เช่น in load report: ... โค้ดจะซ้ำจนเริ่มมองไม่เห็น logic หลัก เราสามารถใช้ defer ร่วมกับ named return value เพื่อ wrap ก่อน function return:
package main
import (
"errors"
"fmt"
)
func fetchReport(id string) (string, error) {
if id == "404" {
return "", errors.New("report not found")
}
return "report data", nil
}
func loadReport(id string) (report string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("load report %q: %w", id, err)
}
}()
report, err = fetchReport(id)
if err != nil {
return "", err
}
return report, nil
}
func main() {
_, err := loadReport("404")
fmt.Println(err)
}
ผลลัพธ์คือ:
load report "404": report not found
เหตุผลที่ defer เห็นและแก้ err ได้ เพราะ err เป็น named return value ที่ function ถืออยู่ตลอดจนถึงจังหวะ return ถ้า function สำเร็จ err เป็น nil จึงไม่ wrap อะไร ถ้าล้มเหลว deferred function จะเปลี่ยน error ให้มี context เพิ่ม
ถ้าตั้งชื่อ return value แค่ตัวเดียว Go จะบังคับให้ตั้งชื่อทุกตัวใน result list ด้วย ในตัวอย่างนี้จึงใช้ (report string, err error) ไม่ใช่ผสม named กับ unnamed return แบบตามใจ
pattern นี้เหมาะเมื่อเราต้อง wrap ทุก failure ด้วยข้อความเดียวกัน ถ้าแต่ละจุดต้องการ context คนละแบบ เขียน fmt.Errorf ตรงจุดนั้นจะอ่านง่ายและแม่นกว่า อย่าใช้ defer เพียงเพราะลดจำนวนบรรทัด ถ้ามันทำให้คนอ่านต้องกระโดดไปดูโค้ดท้าย function เพื่อรู้ว่า error ถูกเปลี่ยนอย่างไร
Step 9: panic และ recover ใช้เมื่อไปต่อไม่ได้จริง ๆ
panic ไม่ใช่เวอร์ชันที่เขียนสั้นกว่า error มันเป็นสัญญาณจาก runtime หรือโปรแกรมว่า state ตอนนี้ไม่ควรเดินหน้าต่อ เช่น access slice เกินขอบเขต, ส่งขนาดติดลบให้ make หรือเกิด bug ที่ผู้เขียนไม่ได้จัดการไว้
ลองดู recover ใน action กัน ตัวอย่างนี้ตั้งใจให้ divide60(0) panic แล้วใช้ deferred function จับไว้เพื่อให้ loop ทำงานต่อ:
package main
import "fmt"
func divide60(value int) {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("recovered:", recovered)
}
}()
fmt.Println(60 / value)
}
func main() {
for _, value := range []int{1, 2, 0, 6} {
divide60(value)
}
}
ผลลัพธ์คือ:
60
30
recovered: runtime error: integer divide by zero
10
ทันทีที่ panic เกิด function ปัจจุบันจะหยุดทำงาน แล้ว deferred function เริ่มทำงาน ถ้าไม่มีใคร recover panic จะไล่ย้อนกลับผ่าน defer ของ caller ไปจนถึง main และโปรแกรมจบพร้อม stack trace
recover ต้องถูกเรียกจากภายใน deferred function และจับ panic ได้เฉพาะ goroutine เดียวกัน ถ้า goroutine อื่น panic แล้วเราไป recover จาก goroutine นี้จะช่วยอะไรไม่ได้
สำหรับ input ที่คาดเดาได้ เช่นตัวหารเป็นศูนย์ ควรตรวจแล้วคืน error แบบนี้มากกว่า:
นี่เป็น function fragment ที่นำไปใส่ในไฟล์เดียวกับ import errors ได้:
func divide60Safely(value int) (int, error) {
if value == 0 {
return 0, errors.New("cannot divide by zero")
}
return 60 / value, nil
}
error บอก caller ล่วงหน้าว่า function ล้มเหลวได้อย่างไร ส่วน panic ซ่อน failure path และบังคับให้คนอ่านต้องรู้รายละเอียด runtime ถึงจะเดาได้ว่าจะเกิดอะไรขึ้น
แล้ว library ควรใช้ recover ไหม?
ถ้าเราเขียน public library และมี panic ที่อาจหลุดออกนอก public API ได้ เราอาจ recover ที่ boundary แล้วแปลงเป็น error ให้ caller ตัดสินใจ:
package main
import "fmt"
func runSafely(action func()) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("action panicked: %v", recovered)
}
}()
action()
return nil
}
func main() {
err := runSafely(func() {
panic("broken state")
})
fmt.Println(err)
}
ผลลัพธ์คือ:
action panicked: broken state
ใช้ pattern นี้ตรงขอบเขตที่เราควบคุมได้ เช่น adapter หรือ public entry point ไม่ใช่เอา recover ไปครอบ business logic ทั้งโปรแกรมแล้วทำเป็นไม่มีอะไรเกิดขึ้น เพราะการกลืน panic อาจทำให้ข้อมูลที่ควรหยุดกลับถูกใช้งานต่อ
Step 10: อยากได้ stack trace ต้องคิดเรื่องข้อมูลที่เปิดเผยด้วย
error ปกติของ Go ไม่ได้แนบ stack trace มาให้อัตโนมัติ เราจึงมักเพิ่ม context ด้วย wrapping เช่น load app: read config: ... เพื่อสร้างเส้นทางการทำงานด้วยมือ
ถ้าใช้ error type จาก third-party library ที่รองรับ stack trace อาจพิมพ์รายละเอียดด้วย verbose format %+v ได้ แต่ต้องอ่าน documentation ของ library นั้นก่อน เพราะ fmt จะรู้จัก stack trace ก็ต่อเมื่อ error type implement behavior ที่รองรับ
stack trace บางแบบอาจเผย full path บนเครื่องที่ build เช่น /Users/name/project/internal/... ถ้าไม่ต้องการเปิดเผย path ให้ build ด้วย -trimpath:
go build -trimpath -o go-errors .
นี่ไม่ใช่การทำให้ error ปลอดภัยขึ้นทั้งหมด แต่ช่วยลดข้อมูลภายในเครื่องที่หลุดไปใน binary หรือ output ของ stack trace ได้
Log ให้พอวิเคราะห์ปัญหา แต่ไม่ควรเผลอแถมโครงสร้างเครื่อง, token หรือข้อมูลส่วนตัวไปพร้อมกับ error
แบบฝึกหัด
ลองทำโจทย์ต่อไปนี้ใน main.go โดยไม่เปิดเฉลยก่อน:
- สร้าง sentinel error ชื่อ
ErrInvalidIDและเขียนfindOrder(id string) (Order, error)ให้คืน error นี้เมื่อidว่างหรือไม่อยู่ในข้อมูล ทดลอง wrap error ใน function ชั้นบน แล้วให้mainใช้errors.Isตรวจว่าต้นเหตุคือ ID ไม่ถูกต้องหรือไม่ กรณีสำเร็จต้องคืนOrderที่พบคู่กับnil - สร้าง custom error type
FieldErrorที่มีField stringและProblem stringแล้วเขียนvalidateEmail(email string) errorให้คืนFieldErrorเมื่อ email ว่าง จากนั้นใช้errors.Asดึงชื่อ field และข้อความออกมา กรณี valid ต้องคืนnil - เขียน
validateUser(User) errorให้ตรวจName,EmailและAgeให้ครบทุก field แล้วรวม error ทั้งหมดด้วยerrors.Joinถ้าผ่านทุกเงื่อนไขให้คืนnilและถ้าผิดหลายจุดต้องรายงานได้มากกว่าหนึ่งข้อความในครั้งเดียว - เขียน
loadSettings() (Settings, error)ที่เรียก helper อย่างน้อยสองตัว แล้วใช้ named return value กับdeferเพื่อ wrap error ทุกจุดด้วย context เดียวกัน กรณีสำเร็จต้องคืนSettingsกับnilและห้ามใช้panicแทน error - เขียน
runSafely(action func()) (err error)ที่ recover panic จาก callback แล้วแปลงเป็น error ทดลองทั้ง callback ที่ทำงานปกติและ callback ที่panicโดยต้องไม่ปล่อย panic หลุดออกจากmain
ตรวจโค้ดหลังทำเสร็จ:
go fmt ./...
go vet ./...
go build ./...
Common Pitfalls — ข้อผิดพลาดที่พบบ่อย
- ใช้ผลลัพธ์ก่อนตรวจ
err— function อาจคืน zero value ที่ดูเหมือนข้อมูลจริง ตรวจif err != nilทันทีหลังเรียก function - คืน non-zero value คู่กับ error โดยไม่มีเหตุผล — โดยทั่วไปคืน zero value ของผลลัพธ์เมื่อ error ไม่ใช่
nilเพื่อไม่ให้ caller เผลอใช้ค่าที่ไม่สมบูรณ์ - เขียนข้อความ error ขึ้นต้นด้วยตัวใหญ่หรือลงท้ายด้วยจุด — ผิด convention ของ Go และทำให้ข้อความที่ถูก wrap อ่านสะดุด
- ใช้
==เทียบ sentinel ที่ถูก wrap — จะไม่ match เมื่อมี context เพิ่ม ใช้errors.Is - ใช้ type assertion กับ custom error ที่ถูก wrap — assertion ตรง ๆ มองไม่ทะลุ error tree ใช้
errors.As - คืน custom error type ที่เป็น zero value เข้า
error— interface จะไม่เป็นnilเพราะมี dynamic type ติดอยู่ ให้ returnnilตรง ๆ หรือใช้ตัวแปรชนิดerror - ส่ง target ผิดให้
errors.As— argument ตัวที่สองต้องเป็น pointer ไปยัง error type หรือ interface ไม่เช่นนั้นจะ panic ตอน runtime - ใช้
%vทั้งที่ต้องการ wrap —%vต่อแค่ข้อความและทำให้errors.Is/errors.Asเดินต่อไม่ได้ ใช้%wเมื่ออยากเก็บต้นเหตุ - เรียก
errors.Unwrapเพื่อจัดการ error tree ทุกแบบ — มันไม่เหมาะกับ multi-error ใช้errors.Isและerrors.Asเป็นหลัก - คืนแค่ error แรกจาก validation — ถ้าตรวจได้หลาย field ให้สะสมแล้วใช้
errors.Joinเพื่อให้ caller แก้ได้ครบในรอบเดียว - ใช้
deferwrap จนซ่อน data flow — ใช้เมื่อทุก failure ต้องเติม context เดียวกัน ถ้าแต่ละจุดต่างกันให้ wrap ตรงจุดนั้น - ใช้
panic/recoverเป็น exception handling ปกติ — input ที่คาดเดาได้ควรคืน error ส่วน panic ควรสงวนไว้สำหรับ state ที่โปรแกรมไปต่อไม่ได้จริง ๆ หรือ boundary ของ library - recover แล้วกลืนปัญหาเงียบ ๆ — อย่างน้อยต้อง log, แปลงเป็น error หรือหยุดโปรแกรมตามความรุนแรง อย่าทำเหมือนไม่มีอะไรเกิดขึ้น
- เผย full path จาก stack trace — ใช้
go build -trimpathเมื่อต้องแจก binary หรือไม่ต้องการเปิดเผยโครงสร้างเครื่อง
สรุป
- Go ใช้
errorเป็น return value ไม่ใช้ exception ทำให้ caller เห็น failure path และต้องตัดสินใจอย่างชัดเจน errorเป็น interface ที่มีError() stringและnilหมายถึงไม่มี error- ใช้
errors.Newกับข้อความคงที่ และfmt.Errorfเมื่อข้อความต้องแทรกค่า runtime - ตรวจ
errทันทีหลังเรียก function และคืน zero value คู่กับ error เมื่อ operation ไม่สำเร็จ - sentinel error ใช้สื่อสถานะที่ caller สนใจ แต่ต้องระวังเพราะมันกลายเป็นส่วนหนึ่งของ public API
- custom error type ช่วยแนบข้อมูลอย่าง field, status หรือ code และควรคืนออกมาเป็น
error - ใช้
%wเพื่อ wrap error และเพิ่ม context โดยยังเก็บต้นเหตุไว้ใน error tree - ใช้
errors.Isหา sentinel หรือ instance และใช้errors.Asหา custom type หรือ interface - ใช้
errors.Joinเมื่อ function ต้องคืน validation errors หลายตัวพร้อมกัน deferกับ named return value ช่วยลดโค้ด wrap ซ้ำได้ แต่ไม่ควรใช้จนทำให้ data flow อ่านยากpanic/recoverไม่ใช่ exception handling ทั่วไป ใช้กับ programming error, state ที่ไปต่อไม่ได้ หรือ boundary ของ public library อย่างระมัดระวัง- ถ้ามี stack trace ให้คิดถึงข้อมูลที่เปิดเผย และใช้
-trimpathเมื่อเหมาะสม
จำประโยคเดียวพอ:
คืน error ให้ชัด ตรวจให้ไว เพิ่ม context โดยไม่ทำต้นเหตุหาย และอย่าใช้ panic เพื่อหนีการออกแบบ failure path
บทนี้ทำให้เราเห็นว่า error handling ไม่ได้เป็นภาระที่ต้องเขียนให้ผ่าน ๆ ไป มันคือส่วนหนึ่งของ API ที่บอกคนใช้ function ว่า “อะไรผิดได้ และคุณควรทำอะไรต่อ”
ตอนถัดไปเราจะไปดู package และ module — วิธีจัดระเบียบโค้ด, import third-party code และแบ่งขอบเขตของ package ให้โปรเจกต์โตได้โดยไม่พันกัน
Glossary
error— built-in interface ที่มี method เดียวคือError() string; zero value คือnil- Error as value — แนวคิดที่ให้ function คืน error เป็นค่าแทนการโยน exception
- Sentinel error — error ที่ประกาศระดับ package เพื่อสื่อสถานะเฉพาะและให้ caller ตรวจได้
- Custom error type — type ที่ implement
Error()และแนบข้อมูลเพิ่มเติม เช่น field หรือ status code - Wrapping — การเพิ่ม context ให้ error โดยยังเก็บ error ต้นฉบับไว้ด้วย
%w - Error tree — โครงสร้างของ error ที่ถูก wrap ซ้อนกันหลายชั้น หรือมีหลายลูกจาก
errors.Join errors.Is— ตรวจว่า error tree มี error ที่ match instance หรือค่าที่ระบุหรือไม่errors.As— ตรวจว่า error tree มี error ที่ match type หรือ interface ที่ระบุ แล้วดึงข้อมูลออกมาerrors.Join— รวมหลาย error เป็น error เดียวที่ยังตรวจด้วยIsและAsได้- Named return value — return value ที่ตั้งชื่อตั้งแต่ function signature และถูกอ้างถึงจาก
deferได้ panic— กลไก runtime ที่หยุด function ปัจจุบันและ unwind stack ผ่าน deferred functionrecover— built-in ที่เรียกจากdeferเพื่อจับ panic และเลือกว่าจะคืน error หรือทำงานต่อ-trimpath— build flag ที่ลดการเผย full path ของไฟล์ใน binary หรือ stack trace
Related
- ตอนที่ 8: Generics — บทก่อนหน้า; generic utility และ container มักออกแบบให้คืน error คู่กับผลลัพธ์
- ตอนที่ 7: Types, Methods, and Interfaces —
errorเป็น interface และ custom error ใช้ method set ของ Go - ตอนที่ 6: Pointers — zero value,
nilและการแยก no value ออกจากค่าที่มีอยู่ - ตอนที่ 5: Functions — multiple return values, named return และ
deferที่ใช้ใน error handling - ตอนที่ 10: Modules, Packages, and Imports — บทถัดไป การจัดระเบียบ package, ใช้ third-party code และ publish module