良好的(乾淨的) Golang 編碼 (Clean Go code)(4)

返回值(Return values)

返回固定的錯誤碼(Returning Defined Errors)

我們從要怎麼比較清楚的的描述錯誤訊息來說明關於返回值,如前面所說的,乾淨的程式碼的目標是讓我們的程式具備可讀性、可測試性及可維護性,如果能設計好的錯誤訊息說明將會對上述的目標有著極大的幫助。

我們先來看看一般的返回一個特定錯誤訊息的方式,這是一個假設性的例子,描述關於一個執行序安全(thread-safe)、命名為Store 的map的實作:

package smelly

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return Item{}, errors.New("item could not be found in the store") 
    }
    return item, nil
}

單獨來看,這樣寫沒有甚麼問題: 我們查詢在Store struct中的items map,看看某id的item是否已經存在,如果item存在,返回該item,如果不存在,我們返回一個錯誤訊息”item could not be found in the store”。程式看來沒甚麼問題,直接返回string的錯誤訊息有甚麼問題嗎? 嗯,讓我們來看看當我們於其他package中使用此函數時可能會被怎麼使用:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := smelly.GetItem("123")
    if err != nil {
        if err.Error() == "item could not be found in the store" {
            http.Error(w, err.Error(), http.StatusNotFound)
	        return
        }
        http.Error(w, errr.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

程式本身沒甚麼問題,但有一個情況看起來有點刺眼: 因為golang對於error的定義是一個interface,只要包含了Error()這個函數並返回string即可,而我們使用了hardcoding string 來作為if子句的判斷 ( if err.Error() == “item could not be found in the store”{}),這樣的hadrcoding string是一串魔術字串 (magic string),使用這個魔術字串就可以通關,但這樣的魔術字串的問題就是程式的彈性問題: 當我們有一天稍微修改了一下字串的內容,我們的程式就出現問題了,除非我們也同步修改其他使用這個魔術字串的所有地方,這表示我們的程式是高度耦合的,程式的不同地方更改時需要進行連動才不會出問題。更糟的是如果是外部客戶使用我們的這個函數,可以想像有一天我們修改我們的package並將此串魔術字串修改得說明更清楚,客戶的程式就馬上出現問題了,這當然是我們希望能避免的,也不難:

package clean

var (
    NullItem = Item{}

    ErrItemNotFound = errors.New("item could not be found in the store") 
)

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return NullItem, ErrItemNotFound
    }
    return item, nil
}

我們將error 設為一個變數(例子中的ErrItemNotFound),任何其他程式使用這個package時可以用這個變數來判斷而不再判斷實際返回的string內容:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := clean.GetItem("123")
    if err != nil {
        if errors.Is(err, clean.ErrItemNotFound) {
           http.Error(w, err.Error(), http.StatusNotFound)
	        return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

這樣的方式比較好也安全多了,對某些人來說這樣甚至更容易讀程式碼,如果說明的字串落落長,對於開發者而言,讀 ErrItemNotFound當然比讀那段很長的說明字串容易多了,也能順利理解程式發生了甚麼錯誤。其實,不只是錯誤訊息,其他的返回值也可以使用這樣的方式,例如,我們會返回NullItem這樣的變數,而不直接返回 Item{} 這個物件,在許多的情況下,我們也比較喜歡返回一個定義好的物件,而非在返回時初始化它。

上例中返回NullItem同時也是比較安全的,舉例說明,函數的使用者可能會忘了判斷函數返回是否是errors,而直接將一個空的struct的返回值塞給一個新的變數,而這個空的struct中的所有變數目前都是nil,之後使用者可能呼叫到這些nil變數而導致程式的panic;相反的,當我們的返回值是一個預設的變數,我們將這個變數初始化過而能避免其中包含nil的情況,也就避免了上述使用者會造成程式panic的情況了。

不僅對其它使用者,對我們也是有好處的,考量以下的情況: 假設我們一樣希望達到上述的避免panic的情況,如果我們返回的是空物件,那麼我們在使用這個函數的每個地方都要處理這個空物件,但如果我們返回的是一個變數NullItem,那我們只要在NullItem定義的地方修改一下code就收工了。

var NullItem = Item{
    itemMap: map[string]Item{},
}

附註: 有時候引起系統的panic反而能提醒忘了檢查是否有error return

返回變動的錯誤碼 (Returning Dynamic Errors)

上面固定的錯誤碼、錯誤變數很好,但一定會有返回固定錯誤變數無法處理的情況,例如在返回錯誤訊息中需要帶入特定變動說明的情況,譬如特定的數字、類型等等,如下:

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return NullItem, fmt.Errorf("Could not find item with ID: %s", id)
    }
    return item, nil
}

那,怎麼辦?並沒有標準的作法來處理這樣變動的錯誤訊息,個人的偏好是返回一個新的interface,並加上一些函數:

type ErrorDetails interface {
    Error() string
    Type() string
}

type errDetails struct {
    errtype error
    details interface{}
}

func NewErrorDetails(err error, details ...interface{}) ErrorDetails {
    return &errDetails{
        errtype: err,
        details: details,
    }
}

func (err *errDetails) Error() string {
    return fmt.Sprintf("%v: %v", err.errtype, err.details)
}

func (err *errDetails) Type() error {
    return err.errtype
}

這樣的寫法跟我們一般標準的error處理相容,我們仍可以比較err != nil,因為它是個interface的實作,我們仍然可以呼叫 .Error(),所以相容於目前的error判斷及處理的程式,而好處則是我們可以檢查error type,同時錯誤訊息包含了動態的資訊,函數傳回錯誤如下這樣寫:

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return NullItem, NewErrorDetails(
            ErrItemNotFound,
            fmt.Sprintf("could not find item with id: %s", id))
    }
    return item, nil
}

呼叫的函數處理錯誤像這樣寫(以Http Handler為例):

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := clean.GetItem("123")
    if err != nil {
        if errors.Is(err.Type(), clean.ErrItemNotFound) {
            http.Error(w, err.Error(), http.StatusNotFound)
	        return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

空值( Nil value)

一個關於golang的爭議就是關於nil,這個值的概念對應到C語言是Null,本質上是一個未初始化的指標(pointer),我們前面已經有討論到一些nil可能造成的問題,總的來說: 當試圖去呼叫一個其值為nil的方法或變數,程式會出問題;因此,建議盡可能避免返回nil值,這樣,使用者比較不會不小心呼叫執行到nil。其他狀況下也有可能因為nil造成不必要的困擾,一個例子是不正確的初始化struct,導致包含了nil 的屬性 (properties),如果呼叫的這個struct的nil屬性變數就會導致panic,如下例:

type App struct {
	Cache *KVCache
}

type KVCache struct {
  mtx sync.RWMutex
  store map[string]string
}

func (cache *KVCache) Add(key, value string) {
  cache.mtx.Lock()
  defer cache.mtx.Unlock()
  
  cache.store[key] = value
}

這段 code沒甚麼問題,但是,危險的部分在於App struct 可能沒有被正確的初始化,沒有初始化裡面的Cache這個變數,所以例如下面這段code執行時,就會導致panic:

app := App{}
app.Cache.Add("panic", "now")

Cache 從來沒有被初始化!因此你可以想像它目前就是個nil指標,當呼叫這個nil的Add方法當然就引起了panic,compiler會返回以下的錯誤訊息:

panic: runtime error: invalid memory address or nil pointer dereference

相反的,我們可以將Cache設為私有變數(小寫開頭),並寫一個類似getter的函數來處理它,這樣的做法能給我們更多的保障及控制,至少不會返回一個nil 值:

type App struct {
    cache *KVCache
}

func (app *App) Cache() *KVCache {
	if app.cache == nil {
        app.cache = NewKVCache()
	}
	return app.cache
}

前面那段出問題的code會改寫成這樣(Cache變成Cache(),呼叫變數變成呼叫函數):

app := App{}
app.Cache().Add("panic", "now")

這樣的做法使我們package的使用者不需要擔心是否有正確的初始化一個struct,他們可以專注於自己的clean code。

繼續閱讀: 良好的(乾淨的) Golang 編碼 (Clean Go code)(5)