用Go寫業(yè)務(wù)系統(tǒng)需要制造哪些輪子?

如果之前主要是用Java做業(yè)務(wù)系統(tǒng) ,那么想用go重寫的話還是比較痛苦的,最主要的原因就是你會發(fā)現(xiàn)要啥沒啥,需要自己重寫(造輪子)。下面列舉了一些需要施工的基礎(chǔ)設(shè)施。

錯誤處理

在Java中,只要你沒有刻意的使用4參數(shù)的Exception構(gòu)造方法去定義自己的異常類,那么默認情況下都是會記錄調(diào)用棧的,這樣基本上就能馬上定位到事故第一現(xiàn)場,排查效率很高。Go則不然,如果使用默認的error機制,那么在報錯的時候你得到的只是一個簡單的字符串,沒有任何現(xiàn)場信息。我在調(diào)試的時候最大的痛苦也是如此,報錯了,但一時很難快速定位到出錯的代碼,如果是比較陳舊的項目,那就更不知道這個錯誤是在哪返回的了。不僅如此,因為go里如果遇到panic且沒有被"捕獲",那么就會直接導致進程退出,整個服務(wù)直接崩潰,這也是不可接受的。
為了解決錯誤現(xiàn)場的問題,我們可以自己定義一個結(jié)構(gòu)體,它在實現(xiàn)error接口的同時,再添加一個PrevError的字段用于記錄上層錯誤,類似于Java Exception的cause()方法:

type Error struct {
    Message string
    PrevError error
}

然后定義一個Wrap()方法,在遇到錯誤時,先將先前的錯誤傳進去,然后再填寫一條符合本層邏輯的描述信息:

// prevError: 原始錯誤
// src: 可以填寫源文件名
// desp: 新error對象的錯誤描述
func Wrap(prevError error, src string, desp string) error {
    var msg string
    if "" != src {
        msg = "[" + src + "] " + desp
    } else {
        msg = desp
    }

    err := &Error{
        Message: msg,
        PrevError: prevError,
    }

    return err
}
if nil != err {
    return er.Wrap(err, sourceFile, "failed to convert id")
}

注意第二個參數(shù)src, 這里可以直接通過硬編碼的形式將當前源文件名傳進去,這樣日志中就會出現(xiàn)

[xxxx.go] failed to convert id

方便錯誤排查。相比較標準庫的runtime.Call()方法我更傾向于自己手動把文件名傳進來,由于行號會經(jīng)常變動就不傳了,而文件名很少改動,因此這是開銷最低的記錄現(xiàn)場的方法。
有了自定義的錯誤以后,在最上層(一般是你的HTTP框架的Handler函數(shù))獲取到error后還需要把這個錯誤鏈條打印出來,如:

func Message(e error) string {
    thisErr := e

    strBuilder := bytes.Buffer{}
    nestTier := 0
    for {
        for ix := 0; ix < nestTier; ix++ {
            strBuilder.WriteString("\t")
        }
        strBuilder.WriteString(thisErr.Error())
        strBuilder.WriteString("\n")

        myErrType, ok := thisErr.(*Error)
        if !ok || nil == myErrType.PrevError {
            break
        }

        thisErr = myErrType.PrevError
        nestTier++
    }

    return strBuilder.String()
}

直接使用Message()函數(shù)打印錯誤鏈:

// 調(diào)用用戶邏輯
        resp, err := handlerFunc(ctx)
        if nil != err {

            log.Println(er.Message(err))
            return
        }

效果如下:

2019/07/26 17:28:48 failed to query task
    [query_task.go] failed to parse record
        [db.go] failed to parse record
            [query_task.go] failed to convert id
                strconv.Atoi: parsing "": invalid syntax

嗯,是不是有點意思了?對于業(yè)務(wù)錯誤這樣是可以的,因為類似于參數(shù)格式不對、參數(shù)不存在這樣的問題是會經(jīng)常發(fā)生的,使用這種方式能以最小的開銷將問題記錄下來。但對于panic來說,我們需要在最上層使用recover()debug.Stack()函數(shù)拿到更加詳細的錯誤信息:

        // 處理panic防止進程退出
        defer func() {
            if err := recover(); err != nil {
                log.Println(err)
                log.Println(string(debug.Stack()))
                                // ... ...
            }
        }()

因為go里遇到panic如果沒有recover,整個進程都會直接退出 ,這顯然是不可接受的,因此上面的方式是必須的,我們不想因為一個空指針就讓整個服務(wù)直接掛掉。(聽起來有點像C++?)

HTTP請求路由

因為我用的HTTP框架fasthttp是不帶Router的,因此需要我們選擇一個第三方的Router實現(xiàn),比如fasthttprouter。這樣一來我們啟動在啟動的時候就要有一個注冊路由的過程,比如

router.GET("/a/b/c", xxxFunc)
router.POST("/efg/b", yyyFunc)

確實遠遠沒有SpringMVC里直接寫Controller來的方便。

請求參數(shù)綁定

想直接定義一個結(jié)構(gòu)體,然后請求來了參數(shù)就自動填寫到對應(yīng)字段上?不好意思,沒有。fasthttp中獲取參數(shù)的姿勢是這樣的:

func GetQueryArg(ctx *fasthttp.RequestCtx, key string) string {
    buf := ctx.QueryArgs().Peek(key)
    if nil == buf {
        return ""
    }

    return string(buf)
}

對,拿到以后還是個字節(jié)數(shù)據(jù),還需要你手動轉(zhuǎn)成string,不僅如此,你還得進行非空判斷,如果想獲取int類型,還需要調(diào)用轉(zhuǎn)換函數(shù)strconv.Atoi(),然后再判斷一下轉(zhuǎn)換是否成功,十分繁瑣。如果想實現(xiàn)像SpringMVC那樣的參數(shù)綁定,你需要自己寫一套通過反射創(chuàng)建對象并根據(jù)字段名設(shè)置參數(shù)值的邏輯。不過筆者認為這一步并不是必須的,寫幾個工具方法也能解決問題,比如上面。

數(shù)據(jù)庫查詢

好吧,最痛苦的還是查數(shù)據(jù)庫。標準庫中定義的數(shù)據(jù)庫查詢接口非常難用,難用到發(fā)指,遠不如JDBC規(guī)范好使。里面最反人類的就是這個rows.Scan()方法,因為它接收interface{}類型的參數(shù),所以你還得把你的具體類型"轉(zhuǎn)換"成interface{}才參傳進去:

    values := make([]sql.RawBytes, len(columns))
    scanArgs := make([]interface{}, len(columns))
    for i := range columns {
        // 反人類的操作!!!
        scanArgs[i] = &values[i]
    }

    for rows.Next() {
        err = rows.Scan(scanArgs...)

此外,你肯定不想每次查數(shù)據(jù)都要把這一套Prepare... Query... Scan... Next寫一遍吧,所以需要做一下封裝,比如可以將結(jié)果集轉(zhuǎn)成一個map, 然后調(diào)用用戶自定義的傳進來的函數(shù)來處理,如:

// 執(zhí)行查詢語句;
// processor: 行處理函數(shù), 每讀取到一行都會調(diào)用一次processor
func ExecuteQuery(querySql string, processor func(resultMap map[string]string) error, args ...interface{}) error {}
    for rows.Next() {
        err = rows.Scan(scanArgs...)
        if nil != err {
            return err
        }

        // 行數(shù)據(jù)轉(zhuǎn)成map
        resultMap := make(map[string]string)
        for ix, val := range values {
            key := columns[ix]
            resultMap[key] = string(val)
        }

        // 調(diào)用用戶邏輯
        err = processor(resultMap)
        if nil != err {
            return er.Wrap(err, srcFile, "failed to parse record")
        }
    }

即便這樣,用戶的處理函數(shù)processor()也是非常丑陋的:

    err := db.ExecuteQuery(sql, func(result map[string]string) error {
        task := vo.PvTask{}

        taskIdStr, _ := result["id"]
        taskId, err := strconv.Atoi(taskIdStr)
        if nil != err {
            return er.Wrap(err, sourceFile, "failed to convert id")
        }
        task.TaskId = taskId

        taskName, _ := result["task_name"]
        task.TaskName = taskName

        status, _ := result["status"]
        task.Status = status

        createByStr, _ := result["create_by"]
        createBy, err := strconv.Atoi(createByStr)
        if nil != err {
            return er.Wrap(err, sourceFile, "failed to load create_by")
        }
        task.CreatedBy = createBy

        update, _ := result["update_time"]
        task.UpdateTime = update

        tasks = append(tasks, &task)

        return nil
    }, args...)

一個字段一個字段的讀,還得進行錯誤判斷,要死人的。
上面這個問題解決方案只有一個,那就是使用第三方的ORM框架。然而,現(xiàn)在三方ORM眼花繚亂,沒有一個公認的權(quán)威,這樣就為項目埋下很多隱患,比如日后你用的框架可能不維護了,可能要換框架,可能有奇怪的bug等等。筆者建議還是自己寫一套吧,遇到問題修改起來也方便。

數(shù)據(jù)庫事務(wù)

想在方法上標注@Transactional來開啟事務(wù)?不好意思,想多了。你要手動使用db.Start(), db.Commit(), db.Rollback()

日志框架問題

日志框架到底用哪個一直是非常讓我頭疼的問題。標準庫的log包缺乏自動切割文件的基本功能,github上star最多的logrus居然不能輸出人看著舒服的日志格式,還美其名曰鼓勵結(jié)構(gòu)化。你結(jié)構(gòu)化方便程序解析也好,關(guān)鍵是你也得提供一個正常的日志輸出格式吧?之前用過log4go,可惜已經(jīng)不維護了。
這個問題至今無解,實在不行,自己寫吧。

組件初始化順序問題

我們已經(jīng)被Spring給慣壞了,只管把@Component寫好,然后Spring會自己幫你初始化,尤其是順序也幫你安排好了。然而,go不行。因為沒有spring這樣的IoC框架,所以你必須自己手動觸發(fā)每個模塊的初始化工作,比如先初始化日志,加載配置文件,再初始化數(shù)據(jù)庫連接、Redis連接,然后是請求路由的注冊,等等等等,大概長這樣:

    // 初始化日志庫
    initLogger()

    // 加載配置文件
    log.Println("load config")
    config := config.LoadConfig("gopv.yaml")
    log.Println(config)

    // 加載SQL配置
    template.InitSqlMap("sql-template/pv-task.xml")

    // 初始化Router
    log.Println("init router")
    router := initRouter(config)

    // 初始化DB
    log.Println("init db")
    initDb(config)

而且順序要把握好,比如日志框架要放在所有模塊之前初始化,否則日志框架可能會有問題。

分包問題

在Java里,你A文件import B里定義的類,然后 B文件又import A文件定義的類,這是OK的。但go不行,編譯時會直接報循環(huán)引用錯誤。所以在包的定義上真的就不能隨心所欲了,每次創(chuàng)建新的package,你都要考慮好,不能出現(xiàn)循環(huán)引用,這有時候還是很隔應(yīng)人的。當然你可以說,如果出現(xiàn)A import B, B import A,那就是代碼有問題,從哲學上來看貌似沒問題。但現(xiàn)實是在Java中這種情況很普遍。

依賴問題

這個在go1.11以后可以說已經(jīng)不算是大問題了,使用官方的module即可。但是在此之前,go的依賴管理就是一場災(zāi)難。

或許有一天能出現(xiàn)一個權(quán)威的框架來一站式的解決上面這些問題,只有那時候,Go才能變成實現(xiàn)業(yè)務(wù)系統(tǒng)的好語言。在此之前,還是老老實實的做基礎(chǔ)應(yīng)用吧。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • ORA-00001: 違反唯一約束條件 (.) 錯誤說明:當在唯一索引所對應(yīng)的列上鍵入重復值時,會觸發(fā)此異常。 O...
    我想起個好名字閱讀 5,429評論 0 9
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,136評論 1 32
  • 點擊查看原文 Web SDK 開發(fā)手冊 SDK 概述 網(wǎng)易云信 SDK 為 Web 應(yīng)用提供一個完善的 IM 系統(tǒng)...
    layjoy閱讀 13,890評論 0 15
  • stream TARS 框架的編解碼工具 結(jié)構(gòu)體的使用示例我們演示結(jié)構(gòu)體在三個典型場景的使用方法:第一種場景:當結(jié)...
    宮若石閱讀 1,626評論 0 1
  • 1. 分布式系統(tǒng)核心問題 參考書籍:《區(qū)塊鏈原理、設(shè)計與應(yīng)用》 一致性問題例子:兩個不同的電影院買同一種電影票,如...
    molscar閱讀 929評論 0 0