[系列] Gin框架 - 自定義錯誤處理

新亮筆記發表於2019-07-25

概述

很多讀者在後臺向我要 Gin 框架實戰系列的 Demo 原始碼,在這裡再說明一下,原始碼我都更新到 GitHub 上,地址:https://github.com/xinliangnote/Go

開始今天的文章,為什麼要自定義錯誤處理?預設的錯誤處理方式是什麼?

那好,我們們就先說下預設的錯誤處理。

預設的錯誤處理是 errors.New("錯誤資訊"),這個資訊通過 error 型別的返回值進行返回。

舉個簡單的例子:

func hello(name string) (str string, err error) {
    if name == "" {
        err = errors.New("name 不能為空")
        return
    }
    str = fmt.Sprintf("hello: %s", name)
    return
}

當呼叫這個方法時:

var name = ""
str, err :=  hello(name)
if err != nil {
    fmt.Println(err.Error())
    return
}

這就是預設的錯誤處理,下面還會用這個例子進行說。

這個預設的錯誤處理,只是得到了一個錯誤資訊的字串。

然而...

我還想得到發生錯誤時的 時間檔名方法名行號 等資訊。

我還想得到錯誤時進行告警,比如 簡訊告警郵件告警微信告警 等。

我還想呼叫的時候,不那麼複雜,就和預設錯誤處理類似,比如:

alarm.WeChat("錯誤資訊")
return

這樣,我們就得到了我們想要的資訊(時間檔名方法名行號),並通過 微信 的方式進行告警通知我們。

同理,alarm.Email("錯誤資訊")alarm.Sms("錯誤資訊") 我們得到的資訊是一樣的,只是告警方式不同而已。

還要保證,我們業務邏輯中,獲取錯誤的時候,只獲取錯誤資訊即可。

上面這些想出來的,就是今天要實現的,自定義錯誤處理,我們就實現之前,先說下 Go 的錯誤處理。

錯誤處理

package main

import (
    "errors"
    "fmt"
)

func hello(name string) (str string, err error) {
    if name == "" {
        err = errors.New("name 不能為空")
        return
    }
    str = fmt.Sprintf("hello: %s", name)
    return
}

func main() {
    var name = ""
    fmt.Println("param:", name)

    str, err := hello(name)
    if err != nil {
        fmt.Println(err.Error())
        return
    }

    fmt.Println(str)
}

輸出:

param: Tom
hello: Tom

當 name = "" 時,輸出:

param:
name 不能為空

建議每個函式都要有錯誤處理,error 應該為最後一個返回值。

我們們一起看下官方 errors.go

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package errors implements functions to manipulate errors.
package errors

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

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

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

上面的程式碼,並不複雜,參照上面的,我們們進行寫一個自定義錯誤處理。

自定義錯誤處理

我們們定義一個 alarm.go,用於處理告警。

廢話不多說,直接看程式碼。

package alarm

import (
    "encoding/json"
    "fmt"
    "ginDemo/common/function"
    "path/filepath"
    "runtime"
    "strings"
)

type errorString struct {
    s string
}

type errorInfo struct {
    Time     string `json:"time"`
    Alarm    string `json:"alarm"`
    Message  string `json:"message"`
    Filename string `json:"filename"`
    Line     int    `json:"line"`
    Funcname string `json:"funcname"`
}

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

func New (text string) error {
    alarm("INFO", text)
    return &errorString{text}
}

// 發郵件
func Email (text string) error {
    alarm("EMAIL", text)
    return &errorString{text}
}

// 發簡訊
func Sms (text string) error {
    alarm("SMS", text)
    return &errorString{text}
}

// 發微信
func WeChat (text string) error {
    alarm("WX", text)
    return &errorString{text}
}

// 告警方法
func  alarm(level string, str string) {
    // 當前時間
    currentTime := function.GetTimeStr()

    // 定義 檔名、行號、方法名
    fileName, line, functionName := "?", 0 , "?"

    pc, fileName, line, ok := runtime.Caller(2)
    if ok {
        functionName = runtime.FuncForPC(pc).Name()
        functionName = filepath.Ext(functionName)
        functionName = strings.TrimPrefix(functionName, ".")
    }

    var msg = errorInfo {
        Time     : currentTime,
        Alarm    : level,
        Message  : str,
        Filename : fileName,
        Line     : line,
        Funcname : functionName,
    }

    jsons, errs := json.Marshal(msg)

    if errs != nil {
        fmt.Println("json marshal error:", errs)
    }

    errorJsonInfo := string(jsons)

    fmt.Println(errorJsonInfo)

    if level == "EMAIL" {
        // 執行發郵件

    } else if level == "SMS" {
        // 執行發簡訊

    } else if level == "WX" {
        // 執行發微信

    } else if level == "INFO" {
        // 執行記日誌
    }
}

看下如何呼叫:

package v1

import (
    "fmt"
    "ginDemo/common/alarm"
    "ginDemo/entity"
    "github.com/gin-gonic/gin"
    "net/http"
)

func AddProduct(c *gin.Context)  {
    // 獲取 Get 引數
    name := c.Query("name")

    var res = entity.Result{}

    str, err := hello(name)
    if err != nil {
        res.SetCode(entity.CODE_ERROR)
        res.SetMessage(err.Error())
        c.JSON(http.StatusOK, res)
        c.Abort()
        return
    }

    res.SetCode(entity.CODE_SUCCESS)
    res.SetMessage(str)
    c.JSON(http.StatusOK, res)
}

func hello(name string) (str string, err error) {
    if name == "" {
        err = alarm.WeChat("name 不能為空")
        return
    }
    str = fmt.Sprintf("hello: %s", name)
    return
}

訪問:http://localhost:8080/v1/product/add?name=a

{
    "code": 1,
    "msg": "hello: a",
    "data": null
}

未丟擲錯誤,不會輸出資訊。

訪問:http://localhost:8080/v1/product/add

{
    "code": -1,
    "msg": "name 不能為空",
    "data": null
}

丟擲了錯誤,輸出資訊如下:

{"time":"2019-07-23 22:19:17","alarm":"WX","message":"name 不能為空","filename":"絕對路徑/ginDemo/router/v1/product.go","line":33,"funcname":"hello"}

可能這會有同學說:“用上一篇分享的資料繫結和驗證,將傳入的引數進行 binding:"required" 也可以實現呀”。

我只能說:“同學呀,你不理解我的良苦用心,這只是個例子,大家可以在一些複雜的業務邏輯判斷場景中使用自定義錯誤處理”。

到這裡,報錯時我們收到了 時間錯誤資訊檔名行號方法名 了。

呼叫起來,也比較簡單。

雖然標記了告警方式,還是沒有進行告警通知呀。

我想說,在這裡儲存資料到佇列中,再執行非同步任務具體去消耗,這塊就不實現了,大家可以去完善。

讀取 檔名方法名行號 使用的是 runtime.Caller()

我們還知道,Go 有 panicrecover,它們是幹什麼的呢,接下來我們們就說說。

panic 和 recover

當程式不能繼續執行的時候,才應該使用 panic 丟擲錯誤。

當程式發生 panic 後,在 defer(延遲函式) 內部可以呼叫 recover 進行控制,不過有個前提條件,只有在相同的 Go 協程中才可以。

panic 分兩個,一種是有意丟擲的,一種是無意的寫程式馬虎造成的,我們們一個個說。

有意丟擲的 panic:

package main

import (
    "fmt"
)

func main() {

    fmt.Println("-- 1 --")

    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("panic: %s\n", r)
        }
        fmt.Println("-- 2 --")
    }()
    
    panic("i am panic")
}

輸出:

-- 1 --
panic: i am panic
-- 2 --

無意丟擲的 panic:

package main

import (
    "fmt"
)

func main() {

    fmt.Println("-- 1 --")

    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("panic: %s\n", r)
        }
        fmt.Println("-- 2 --")
    }()


    var slice = [] int {1, 2, 3, 4, 5}

    slice[6] = 6
}

輸出:

-- 1 --
panic: runtime error: index out of range
-- 2 --

上面的兩個我們都通過 recover 捕獲到了,那我們如何在 Gin 框架中使用呢?如果收到 panic 時,也想進行告警怎麼實現呢?

既然想實現告警,先在 ararm.go 中定義一個 Panic() 方法,當專案發生 panic 異常時,呼叫這個方法,這樣就實現告警了。

// Panic 異常
func Panic (text string) error {
    alarm("PANIC", text)
    return &errorString{text}
}

那我們怎麼捕獲到呢?

使用中介軟體進行捕獲,寫一個 recover 中介軟體。

package recover

import (
    "fmt"
    "ginDemo/common/alarm"
    "github.com/gin-gonic/gin"
)

func Recover()  gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                alarm.Panic(fmt.Sprintf("%s", r))
            }
        }()
        c.Next()
    }
}

路由呼叫中介軟體:

r.Use(logger.LoggerToFile(), recover.Recover())

//Use 可以傳遞多箇中介軟體。

驗證下吧,我們們先丟擲兩個異常,看看能否捕獲到?

還是修改 product.go 這個檔案吧。

有意丟擲 panic:

package v1

import (
    "fmt"
    "ginDemo/entity"
    "github.com/gin-gonic/gin"
    "net/http"
)

func AddProduct(c *gin.Context)  {
    // 獲取 Get 引數
    name := c.Query("name")

    var res = entity.Result{}

    str, err := hello(name)
    if err != nil {
        res.SetCode(entity.CODE_ERROR)
        res.SetMessage(err.Error())
        c.JSON(http.StatusOK, res)
        c.Abort()
        return
    }

    res.SetCode(entity.CODE_SUCCESS)
    res.SetMessage(str)
    c.JSON(http.StatusOK, res)
}

func hello(name string) (str string, err error) {
    if name == "" {
        // 有意丟擲 panic
        panic("i am panic")
        return
    }
    str = fmt.Sprintf("hello: %s", name)
    return
}

訪問:http://localhost:8080/v1/product/add

介面是空白的。

丟擲了異常,輸出資訊如下:

{"time":"2019-07-23 22:42:37","alarm":"PANIC","message":"i am panic","filename":"絕對路徑/ginDemo/middleware/recover/recover.go","line":13,"funcname":"1"}

很顯然,定位的檔名、方法名、行號不是我們想要的。

需要調整 runtime.Caller(2),這個程式碼在 alarm.go 的 alarm 方法中。

將 2 調整成 4 ,看下輸出資訊:

{"time":"2019-07-23 22:45:24","alarm":"PANIC","message":"i am panic","filename":"絕對路徑/ginDemo/router/v1/product.go","line":33,"funcname":"hello"}

這就對了。

無意丟擲 panic:

// 上面程式碼不變

func hello(name string) (str string, err error) {
    if name == "" {
        // 無意丟擲 panic
        var slice = [] int {1, 2, 3, 4, 5}
        slice[6] = 6
        return
    }
    str = fmt.Sprintf("hello: %s", name)
    return
}

訪問:http://localhost:8080/v1/product/add

介面是空白的。

丟擲了異常,輸出資訊如下:

{"time":"2019-07-23 22:50:06","alarm":"PANIC","message":"runtime error: index out of range","filename":"絕對路徑/runtime/panic.go","line":44,"funcname":"panicindex"}

很顯然,定位的檔名、方法名、行號也不是我們想要的。

將 4 調整成 5 ,看下輸出資訊:

{"time":"2019-07-23 22:55:27","alarm":"PANIC","message":"runtime error: index out of range","filename":"絕對路徑/ginDemo/router/v1/product.go","line":34,"funcname":"hello"}

這就對了。

奇怪了,這是為什麼?

在這裡,有必要說下 runtime.Caller(skip) 了。

skip 指的呼叫的深度。

為 0 時,列印當前呼叫檔案及行數。

為 1 時,列印上級呼叫的檔案及行數。

依次類推...

在這塊,呼叫的時候需要注意下,我現在還沒有好的解決方案。

我是將 skip(呼叫深度),當一個引數傳遞進去。

比如:

// 發微信
func WeChat (text string) error {
    alarm("WX", text, 2)
    return &errorString{text}
}

// Panic 異常
func Panic (text string) error {
    alarm("PANIC", text, 5)
    return &errorString{text}
}

具體的程式碼就不貼了。

但是,有意丟擲 Panic 和 無意丟擲 Panic 的呼叫深度又不同,怎麼辦?

1、儘量將有意丟擲的 Panic 改成丟擲錯誤的方式。

2、想其他辦法搞定它。

就到這吧。

裡面涉及到的程式碼,我會更新到 GitHub。

推薦閱讀

Gin 框架

基礎篇

本文歡迎轉發,轉發請註明作者和出處,謝謝!

相關文章