Penanganan kesalahan dan Go

Andrew Gerrand
12 Juli 2011

Pendahuluan

Jika anda pernah menulis kode Go, Anda mungkin pernah menjumpai tipe error bawaan. Kode Go menggunakan nilai error untuk mengindikasikan keadaan abnormal. Misalnya, fungsi os.Open mengembalikan nilai error yang bukan nil saat gagal membuka sebuah berkas.

func Open(name string) (file *File, err error)

Kode berikut menggunakan os.Open untuk membuka sebuah berkas. Jika sebuah kesalahan terjadi, ia akan memanggil log.Fatal untuk mencetak pesan kesalahan dan berhenti.

f, err := os.Open("filename.ext")
if err != nil {
	log.Fatal(err)
}
// Lakukan operasi apa pun dengan *File f yang sudah dibuka.

Kita dapat menyelesaikan banyak hal dalam Go hanya dengan mengetahui tipe error ini, namun dalam artikel ini kita akan melihat lebih dekat dan mendiskusikan beberapa cara praktis tentang penanganan kesalahan dalam Go.

Tipe error

Tipe error adalah sebuah tipe interface. Sebuah variabel error merepresentasikan nilai apa pun yang dapat mendeskripsikan dirinya sendiri sebagai sebuah string. Berikut deklarasi interface dari error:

type error interface {
	Error() string
}

Tipe error, seperti halnya dengan tipe-tipe bawaan lainnya, telah dideklarasikan dalam blok universal.

Implementasi error yang paling sering digunakan yaitu tipe errorString dari paket errors yang tidak diekspor.

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

Kita dapat membuat nilai errorType dengan fungsi errors.New. Fungsi tersebut menerima sebuah string yang kemudian dikonversi ke errors.errorString dan mengembalikan sebuah nilai error.

// New returns an error that formats as the given text.
func New(text string) error {
	return &errorString{text}
}

Berikut cara menggunakan errors.New:

func Sqrt(f float64) (float64, error) {
	if f < 0 {
		return 0, errors.New("math: square root of negative number")
	}
	// implementasi
}

Fungsi Sqrt() yang menerima argumen sebuah nilai negatif akan mengembalikan sebuah nilai error (yang merupakan representasi dari sebuah nilai errors.errorString). Yang memanggil fungsi Sqrt() dapat mengakses pesan kesalahan ("math: square root of…​") dengan memanggil method Error, atau cukup dengan mencetaknya:

f, err := Sqrt(-1)
if err != nil {
	fmt.Println(err)
}

Paket fmt memformat nilai error dengan memanggil method string Error() dari nilai err tersebut.

Adalah tanggung jawab pengimplementasi eror untuk menyediakan konteks dari kesalahan. Pesan kesalahan yang dikembalikan oleh os.Open memiliki format "open /etc/passwd: permission denied", bukan "permission denied" saja. Pesan kesalahan yang dikembalikan oleh fungsi Sqrt() kita tidak memiliki informasi tentang argumen yang tidak valid.

Untuk menambahkan informasi tersebut, salah satu fungsi yang berguna yaitu Errorf dari paket fmt. Fungsi Errorf memformat string menurut aturan Printf dan mengembalikan sebuah error yang dibuat oleh errors.New.

if f < 0 {
	return 0, fmt.Errorf("math: square root of negative number %g", f)
}

Pada kebanyakan kasus, menggunakan fmt.Errorf sudah cukup bagus, namun karena error adalah sebuah interface, kita dapat menggunakan struktur data apa pun sebagai nilai error, untuk membolehkan pemanggil menginspeksi detail dari kesalahan yang terjadi.

Misalnya, pemanggil dari Sqrt mungkin ingin memperbaiki argumen tidak valid yang telah dikirim. Kita dapat melakukan hal tersebut dengan mendefinisikan sebuah implementasi error yang baru, bukan dengan menggunakan errors.errorString:

type NegativeSqrtError float64

func (f NegativeSqrtError) Error() string {
	return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

Pemanggil kemudian dapat menggunakan tipe assertion untuk memeriksa apakah error adalah sebuah NegativeSqrtError dan melakukan penanganan secara khusus, sementara itu pengguna fungsi Sqrt() yang menangani nilai error dengan mengirim ke fmt.Println atau log.Fatal tidak mengalami atau melihat perubahan perilaku apa pun.

Contoh lainnya, paket json menspesifikasikan tipe SyntaxError yang dikembalikan oleh fungsi json.Decode saat menemukan kesalahan sintaksis dari penguraian sebuah blob JSON.

type SyntaxError struct {
	msg    string // deskripsi dari error
	Offset int64  // Offset saat kesalahan pembacaan terjadi
}

func (e *SyntaxError) Error() string { return e.msg }

Field Offset tidak ditampilkan dalam format pesan kesalahan, namun pemanggil dari json.Decode dapat menggunakannya untuk menambahkan informasi berkas dan baris pada pesan kesalahan mereka:

if err := dec.Decode(&val); err != nil {
	if serr, ok := err.(*json.SyntaxError); ok {
		line, col := findLine(f, serr.Offset)
		return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
	}
	return err
}

(Contoh kode ini merupakan versi sederhana dari kode sebenarnya dari proyek Camlistore.)

Interface error hanya membutuhkan sebuah method Error; implementasi kesalahan yang khusus bisa saja punya method-method tambahan. Misalnya, paket net mengembalikan kesalahan bertipe error, mengikuti konvensi, namun beberapa implementasi kesalahan memiliki method tambahan yang didefinisikan oleh interface net.Error:

package net

type Error interface {
	error
	Timeout() bool   // Apakah kesalahan karena waktu telah habis?
	Temporary() bool // Apakah kesalahan sementara?
}

Kode dari klien dapat memeriksa net.Error dengan tipe assertion dan membedakan antara kesalahan karena jaringan atau permanen. Misalnya, sebuah web crawler bisa menunggu dan mencoba kembali saat mengalami sebuah kesalahan sementara dan berhenti setelah mencoba beberapa kali.

if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
	time.Sleep(1e9)
	continue
}
if err != nil {
	log.Fatal(err)
}

Menyederhanakan penanganan error yang berulang

Dalam Go, penanganan kesalahan sangat penting. Konvensi dan rancangan bahasa Go mendorong kita untuk secara eksplisit memeriksa kesalahan-kesalahan saat ia terjadi (yang berbeda dengan konvensi pada bahasa pemrograman lainnya yang menggunakan "pelemparan" exception dan terkadang "menangkap"-nya). Pada beberapa kasus hal ini membuat kode Go menjadi panjang, namun untungnya ada beberapa teknik yang dapat kita gunakan untuk mengurangi penanganan kesalahan yang berulang.

Anggaplah kita punya sebuah aplikasi App Engine dengan handler HTTP yang menerima sebuah record dari datastore dan memformatnya dengan sebuah template.

func init() {
	http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
	c := appengine.NewContext(r)
	key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
	record := new(Record)
	if err := datastore.Get(c, key, record); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	if err := viewTemplate.Execute(w, record); err != nil {
		http.Error(w, err.Error(), 500)
	}
}

Fungsi tersebut mengurus kesalahan-kesalahan yang dikembalikan oleh fungsi datastore.Get dan method viewTemplate.Execute. Pada kedua kasus tersebut, kode di atas menampilkan sebuah pesan kesalahan sederhana kepada user dengan HTTP status kode 500 ("Internal Server Error"). Jumlah baris pada kode tersebut tampak cukup bisa di-maintain, sampai kita menambahkan beberapa handler HTTP lainnya dan akhirnya kita punya banyak salinan kode penanganan error yang identik.

Untuk mengurangi duplikasi kita dapat mendefinisikan tipe appHandler sendiri yang mengembalikan nilai error:

type appHandler func(http.ResponseWriter, *http.Request) error

Kemudian kita ganti fungsi viewRecord supaya mengembalikan error:

func viewRecord(w http.ResponseWriter, r *http.Request) error {
	c := appengine.NewContext(r)
	key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
	record := new(Record)
	if err := datastore.Get(c, key, record); err != nil {
		return err
	}
	return viewTemplate.Execute(w, record)
}

Kode tersebut lebih sederhana dari yang awalnya, namun paket http tidak mengenal fungsi-fungsi yang mengembalikan error. Untuk memperbaiki hal tersebut kita dapat mengimplementasikan interface http.Handler yaitu dengan menambahkan method ServeHTTP() pada tipe appHandler:

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := fn(w, r); err != nil {
		http.Error(w, err.Error(), 500)
	}
}

Method ServeHTTP() memanggil fungsi appHandler dan menampilkan pesan kesalahan yang dikembalikan (jika ada) kepada user. Perhatikan bahwa receiver method, fn, adalah sebuah fungsi. (Go bisa melakukan hal tersebut!) Method ServeHTTP akan memanggil fungsi fn dengan mengeksekusi ekspresi fn(w, r).

Sekarang saat melakukan registrasi viewRecord pada paket http kita menggunakan fungsi Handle (bukan HandleFunc) secara appHandler adalah sebuah http.Handler (bukan http.HandlerFunc).

func init() {
	http.Handle("/view", appHandler(viewRecord))
}

Dengan infrastruktur penanganan kesalahan dasar ini, kita dapat membuatnya lebih mudah digunakan. Tidak hanya menampilkan pesan kesalahan, akan lebih bagus memberikan pengguna sebuah pesan kesalahan dengan status kode HTTP yang sesuai, sementara tetap mencatat seluruh kesalahan ke App Engine developer console untuk tujuan pemeriksaan nantinya.

Untuk melakukan hal ini kita buat sebuah struct appError yang berisi sebuah error dan beberapa field lainnya:

type appError struct {
	Error   error
	Message string
	Code    int
}

Selanjutnya kita ubah tipe appHandler untuk mengembalikan nilai *appError:

type appHandler func(http.ResponseWriter, *http.Request) *appError

(Biasanya adalah sebuah kesalahan mengembalikan tipe konkret dari sebuah error bukan sebuah nilai interface dari error, dengan alasan yang telah didiskusikan dalam Tanya Jawab Go, namun untuk saat ini adalah pengecualian dan tepat untuk dilakukan karena method ServeHTTP satu-satunya tempat yang tahu nilai dari error dan cara menggunakan isinya.)

Selanjutnya kita buat method ServeHTTP dari appHandler supaya menampilkan appError.Message kepada pengguna dengan status kode HTTP yang sesuai dan mencatat keseluruhan Error ke developer console:

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if e := fn(w, r); e != nil { // e is *appError, not os.Error.
		c := appengine.NewContext(r)
		c.Errorf("%v", e.Error)
		http.Error(w, e.Message, e.Code)
	}
}

Terakhir, kita ubah viewRecord dengan penanda fungsi yang baru dan membuatnya mengembalikan konteks lebih informatif saat menemui sebuah kesalahan:

func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
	c := appengine.NewContext(r)
	key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
	record := new(Record)
	if err := datastore.Get(c, key, record); err != nil {
		return &appError{err, "Record not found", 404}
	}
	if err := viewTemplate.Execute(w, record); err != nil {
		return &appError{err, "Can't display record", 500}
	}
	return nil
}

Versi viewRecord di atas sama panjangnya dengan yang asli, namun sekarang setiap baris memiliki makna tersendiri dan kita menyediakan penanganan kesalahan yang lebih bersahabat.

Tidak hanya berakhir di sana; kita dapat meningkatkan lebih lanjut penanganan kesalahan dalam aplikasi kita. Berikut beberapa ide:

  • memberikan penanganan kesalahan sebuah template HTML

  • membuat debugging lebih mudah dengan menulis stack trace pada nilai kembalian HTTP saat user adalah administrator.

  • menulis sebuah fungsi constructor untuk appError yang menyimpan stack trace supaya dapat di debug lebih gampang.

  • pemulihan dari kondisi panik di dalam appHandler, mencatat kesalahan ke console sebagai "Critical", sementara menyampaikan kepada user bahwa "sebuah kesalahan kritis telah terjadi". Hal-hal tersebut adalah cara yang bagus untuk menghindari menampilkan kesalahan yang disebabkan oleh pemrograman kepada user. Lihat artikel Defer Panic dan Recover untuk lebih jelas.

Kesimpulan

Penanganan kesalahan yang baik adalah kebutuhan yang esensial dari perangkat lunak yang bagus. Dengan menggunakan teknik-teknik yang telah dijelaskan dalam artikel ini kita seharusnya dapat menulis kode Go yang lebih singkat dan dapat diandalkan.