用Go寫業務系統需要製造哪些輪子?

如果之前主要是用Java做業務系統 ,那麼想用go重寫的話還是比較痛苦的,最主要的原因就是你會發現要啥沒啥,需要自己重寫(造輪子)。下面列舉了一些需要施工的基礎設施。

錯誤處理

在Java中,只要你沒有刻意的使用4參數的Exception構造方法去定義自己的異常類,那麼默認情況下都是會記錄調用棧的,這樣基本上就能馬上定位到事故第一現場,排查效率很高。Go則不然,如果使用默認的error機制,那麼在報錯的時候你得到的只是一個簡單的字符串,沒有任何現場信息。我在調試的時候最大的痛苦也是如此,報錯了,但一時很難快速定位到出錯的代碼,如果是比較陳舊的項目,那就更不知道這個錯誤是在哪返回的了。不僅如此,因爲go裏如果遇到panic且沒有被"捕獲",那麼就會直接導致進程退出,整個服務直接崩潰,這也是不可接受的。
爲了解決錯誤現場的問題,我們可以自己定義一個結構體,它在實現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")
}

注意第二個參數src, 這裏可以直接通過硬編碼的形式將當前源文件名傳進去,這樣日誌中就會出現

[xxxx.go] failed to convert id

方便錯誤排查。相比較標準庫的runtime.Call()方法我更傾向於自己手動把文件名傳進來,由於行號會經常變動就不傳了,而文件名很少改動,因此這是開銷最低的記錄現場的方法。
有了自定義的錯誤以後,在最上層(一般是你的HTTP框架的Handler函數)獲取到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()函數打印錯誤鏈:

// 調用用戶邏輯
		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

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

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

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

HTTP請求路由

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

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

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

請求參數綁定

想直接定義一個結構體,然後請求來了參數就自動填寫到對應字段上?不好意思,沒有。fasthttp中獲取參數的姿勢是這樣的:

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

	return string(buf)
}

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

數據庫查詢

好吧,最痛苦的還是查數據庫。標準庫中定義的數據庫查詢接口非常難用,難用到髮指,遠不如JDBC規範好使。裏面最反人類的就是這個rows.Scan()方法,因爲它接收interface{}類型的參數,所以你還得把你的具體類型"轉換"成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...)

此外,你肯定不想每次查數據都要把這一套Prepare... Query... Scan... Next寫一遍吧,所以需要做一下封裝,比如可以將結果集轉成一個map, 然後調用用戶自定義的傳進來的函數來處理,如:

// 執行查詢語句;
// processor: 行處理函數, 每讀取到一行都會調用一次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
		}

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

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

即便這樣,用戶的處理函數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框架。然而,現在三方ORM眼花繚亂,沒有一個公認的權威,這樣就爲項目埋下很多隱患,比如日後你用的框架可能不維護了,可能要換框架,可能有奇怪的bug等等。筆者建議還是自己寫一套吧,遇到問題修改起來也方便。

數據庫事務

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

日誌框架問題

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

組件初始化順序問題

我們已經被Spring給慣壞了,只管把@Component寫好,然後Spring會自己幫你初始化,尤其是順序也幫你安排好了。然而,go不行。因爲沒有spring這樣的IoC框架,所以你必須自己手動觸發每個模塊的初始化工作,比如先初始化日誌,加載配置文件,再初始化數據庫連接、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不行,編譯時會直接報循環引用錯誤。所以在包的定義上真的就不能隨心所欲了,每次創建新的package,你都要考慮好,不能出現循環引用,這有時候還是很隔應人的。當然你可以說,如果出現A import B, B import A,那就是代碼有問題,從哲學上來看貌似沒問題。但現實是在Java中這種情況很普遍。

依賴問題

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

或許有一天能出現一個權威的框架來一站式的解決上面這些問題,只有那時候,Go才能變成實現業務系統的好語言。在此之前,還是老老實實的做基礎應用吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章