如果之前主要是用Java做業(yè)務(wù)系統(tǒng) 倚舀,那么想用go重寫(xiě)的話還是比較痛苦的忍宋,最主要的原因就是你會(huì)發(fā)現(xiàn)要啥沒(méi)啥,需要自己重寫(xiě)(造輪子)芯侥。下面列舉了一些需要施工的基礎(chǔ)設(shè)施乳讥。
錯(cuò)誤處理
在Java中,只要你沒(méi)有刻意的使用4參數(shù)的Exception構(gòu)造方法去定義自己的異常類唉工,那么默認(rèn)情況下都是會(huì)記錄調(diào)用棧的汹忠,這樣基本上就能馬上定位到事故第一現(xiàn)場(chǎng),排查效率很高谣膳。Go則不然铅乡,如果使用默認(rèn)的error機(jī)制,那么在報(bào)錯(cuò)的時(shí)候你得到的只是一個(gè)簡(jiǎn)單的字符串花履,沒(méi)有任何現(xiàn)場(chǎng)信息挚赊。我在調(diào)試的時(shí)候最大的痛苦也是如此,報(bào)錯(cuò)了掷漱,但一時(shí)很難快速定位到出錯(cuò)的代碼喂窟,如果是比較陳舊的項(xiàng)目举反,那就更不知道這個(gè)錯(cuò)誤是在哪返回的了。不僅如此火鼻,因?yàn)間o里如果遇到panic且沒(méi)有被"捕獲",那么就會(huì)直接導(dǎo)致進(jìn)程退出融撞,整個(gè)服務(wù)直接崩潰粗蔚,這也是不可接受的。
為了解決錯(cuò)誤現(xiàn)場(chǎng)的問(wèn)題致扯,我們可以自己定義一個(gè)結(jié)構(gòu)體当辐,它在實(shí)現(xiàn)error
接口的同時(shí),再添加一個(gè)PrevError
的字段用于記錄上層錯(cuò)誤耍群,類似于Java Exception的cause()
方法:
type Error struct {
Message string
PrevError error
}
然后定義一個(gè)Wrap()
方法找筝,在遇到錯(cuò)誤時(shí),先將先前的錯(cuò)誤傳進(jìn)去耘婚,然后再填寫(xiě)一條符合本層邏輯的描述信息:
// prevError: 原始錯(cuò)誤
// src: 可以填寫(xiě)源文件名
// desp: 新error對(duì)象的錯(cuò)誤描述
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")
}
注意第二個(gè)參數(shù)src
, 這里可以直接通過(guò)硬編碼的形式將當(dāng)前源文件名傳進(jìn)去沐祷,這樣日志中就會(huì)出現(xiàn)
[xxxx.go] failed to convert id
方便錯(cuò)誤排查攒岛。相比較標(biāo)準(zhǔn)庫(kù)的runtime.Call()
方法我更傾向于自己手動(dòng)把文件名傳進(jìn)來(lái),由于行號(hào)會(huì)經(jīng)常變動(dòng)就不傳了兢榨,而文件名很少改動(dòng),因此這是開(kāi)銷最低的記錄現(xiàn)場(chǎng)的方法凌那。
有了自定義的錯(cuò)誤以后吟逝,在最上層(一般是你的HTTP框架的Handler函數(shù))獲取到error后還需要把這個(gè)錯(cuò)誤鏈條打印出來(lái),如:
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ù)打印錯(cuò)誤鏈:
// 調(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
嗯励稳,是不是有點(diǎn)意思了囱井?對(duì)于業(yè)務(wù)錯(cuò)誤這樣是可以的,因?yàn)轭愃朴趨?shù)格式不對(duì)新翎、參數(shù)不存在這樣的問(wèn)題是會(huì)經(jīng)常發(fā)生的住练,使用這種方式能以最小的開(kāi)銷將問(wèn)題記錄下來(lái)。但對(duì)于panic來(lái)說(shuō)髓绽,我們需要在最上層使用recover()
和debug.Stack()
函數(shù)拿到更加詳細(xì)的錯(cuò)誤信息:
// 處理panic防止進(jìn)程退出
defer func() {
if err := recover(); err != nil {
log.Println(err)
log.Println(string(debug.Stack()))
// ... ...
}
}()
因?yàn)間o里遇到panic如果沒(méi)有recover顺呕,整個(gè)進(jìn)程都會(huì)直接退出 ,這顯然是不可接受的株茶,因此上面的方式是必須的图焰,我們不想因?yàn)橐粋€(gè)空指針就讓整個(gè)服務(wù)直接掛掉。(聽(tīng)起來(lái)有點(diǎn)像C++僵闯?)
HTTP請(qǐng)求路由
因?yàn)槲矣玫腍TTP框架fasthttp
是不帶Router的藤滥,因此需要我們選擇一個(gè)第三方的Router實(shí)現(xiàn),比如fasthttprouter
向图。這樣一來(lái)我們啟動(dòng)在啟動(dòng)的時(shí)候就要有一個(gè)注冊(cè)路由的過(guò)程,比如
router.GET("/a/b/c", xxxFunc)
router.POST("/efg/b", yyyFunc)
確實(shí)遠(yuǎn)遠(yuǎn)沒(méi)有SpringMVC里直接寫(xiě)Controller
來(lái)的方便榄攀。
請(qǐng)求參數(shù)綁定
想直接定義一個(gè)結(jié)構(gòu)體,然后請(qǐng)求來(lái)了參數(shù)就自動(dòng)填寫(xiě)到對(duì)應(yīng)字段上吕嘀?不好意思漠畜,沒(méi)有坞靶。fasthttp中獲取參數(shù)的姿勢(shì)是這樣的:
func GetQueryArg(ctx *fasthttp.RequestCtx, key string) string {
buf := ctx.QueryArgs().Peek(key)
if nil == buf {
return ""
}
return string(buf)
}
對(duì),拿到以后還是個(gè)字節(jié)數(shù)據(jù)瘾敢,還需要你手動(dòng)轉(zhuǎn)成string尿这,不僅如此,你還得進(jìn)行非空判斷碟摆,如果想獲取int類型叨橱,還需要調(diào)用轉(zhuǎn)換函數(shù)strconv.Atoi()
,然后再判斷一下轉(zhuǎn)換是否成功愉舔,十分繁瑣伙菜。如果想實(shí)現(xiàn)像SpringMVC那樣的參數(shù)綁定,你需要自己寫(xiě)一套通過(guò)反射創(chuàng)建對(duì)象并根據(jù)字段名設(shè)置參數(shù)值的邏輯火的。不過(guò)筆者認(rèn)為這一步并不是必須的,寫(xiě)幾個(gè)工具方法也能解決問(wèn)題馏鹤,比如上面踊淳。
數(shù)據(jù)庫(kù)查詢
好吧陕靠,最痛苦的還是查數(shù)據(jù)庫(kù)剪芥。標(biāo)準(zhǔn)庫(kù)中定義的數(shù)據(jù)庫(kù)查詢接口非常難用琴许,難用到發(fā)指,遠(yuǎn)不如JDBC規(guī)范好使榜田。里面最反人類的就是這個(gè)rows.Scan()
方法,因?yàn)樗邮?code>interface{}類型的參數(shù)净捅,所以你還得把你的具體類型"轉(zhuǎn)換"成interface{}
才參傳進(jìn)去:
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
寫(xiě)一遍吧废亭,所以需要做一下封裝,比如可以將結(jié)果集轉(zhuǎn)成一個(gè)map, 然后調(diào)用用戶自定義的傳進(jìn)來(lái)的函數(shù)來(lái)處理液兽,如:
// 執(zhí)行查詢語(yǔ)句;
// processor: 行處理函數(shù), 每讀取到一行都會(huì)調(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...)
一個(gè)字段一個(gè)字段的讀坏匪,還得進(jìn)行錯(cuò)誤判斷,要死人的敦迄。
上面這個(gè)問(wèn)題解決方案只有一個(gè)凭迹,那就是使用第三方的ORM框架。然而嗅绸,現(xiàn)在三方ORM眼花繚亂,沒(méi)有一個(gè)公認(rèn)的權(quán)威猛拴,這樣就為項(xiàng)目埋下很多隱患,比如日后你用的框架可能不維護(hù)了愉昆,可能要換框架,可能有奇怪的bug等等焊切。筆者建議還是自己寫(xiě)一套吧芳室,遇到問(wèn)題修改起來(lái)也方便。
數(shù)據(jù)庫(kù)事務(wù)
想在方法上標(biāo)注@Transactional
來(lái)開(kāi)啟事務(wù)嚎尤?不好意思,想多了诺苹。你要手動(dòng)使用db.Start()
, db.Commit()
, db.Rollback()
雹拄。
日志框架問(wèn)題
日志框架到底用哪個(gè)一直是非常讓我頭疼的問(wèn)題掌呜。標(biāo)準(zhǔn)庫(kù)的log包缺乏自動(dòng)切割文件的基本功能,github上star最多的logrus
居然不能輸出人看著舒服的日志格式势篡,還美其名曰鼓勵(lì)結(jié)構(gòu)化模暗。你結(jié)構(gòu)化方便程序解析也好,關(guān)鍵是你也得提供一個(gè)正常的日志輸出格式吧兑宇?之前用過(guò)log4go
,可惜已經(jīng)不維護(hù)了瓷产。
這個(gè)問(wèn)題至今無(wú)解枚驻,實(shí)在不行,自己寫(xiě)吧尔邓。
組件初始化順序問(wèn)題
我們已經(jīng)被Spring給慣壞了,只管把@Component
寫(xiě)好梯嗽,然后Spring會(huì)自己幫你初始化,尤其是順序也幫你安排好了慷荔。然而,go不行贷岸。因?yàn)闆](méi)有spring這樣的IoC框架磷雇,所以你必須自己手動(dòng)觸發(fā)每個(gè)模塊的初始化工作,比如先初始化日志螟蒸,加載配置文件崩掘,再初始化數(shù)據(jù)庫(kù)連接、Redis連接苞慢,然后是請(qǐng)求路由的注冊(cè),等等等等绍赛,大概長(zhǎng)這樣:
// 初始化日志庫(kù)
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)
而且順序要把握好辑畦,比如日志框架要放在所有模塊之前初始化,否則日志框架可能會(huì)有問(wèn)題蚯妇。
分包問(wèn)題
在Java里潦刃,你A文件import B里定義的類,然后 B文件又import A文件定義的類分扎,這是OK的胧洒。但go不行墨状,編譯時(shí)會(huì)直接報(bào)循環(huán)引用錯(cuò)誤菲饼。所以在包的定義上真的就不能隨心所欲了,每次創(chuàng)建新的package镐确,你都要考慮好饼煞,不能出現(xiàn)循環(huán)引用,這有時(shí)候還是很隔應(yīng)人的砖瞧。當(dāng)然你可以說(shuō),如果出現(xiàn)A import B, B import A荣堰,那就是代碼有問(wèn)題竭翠,從哲學(xué)上來(lái)看貌似沒(méi)問(wèn)題。但現(xiàn)實(shí)是在Java中這種情況很普遍屡拨。
依賴問(wèn)題
這個(gè)在go1.11以后可以說(shuō)已經(jīng)不算是大問(wèn)題了褥实,使用官方的module即可损离。但是在此之前,go的依賴管理就是一場(chǎng)災(zāi)難僻澎。
或許有一天能出現(xiàn)一個(gè)權(quán)威的框架來(lái)一站式的解決上面這些問(wèn)題十饥,只有那時(shí)候,Go才能變成實(shí)現(xiàn)業(yè)務(wù)系統(tǒng)的好語(yǔ)言秉氧。在此之前蜒秤,還是老老實(shí)實(shí)的做基礎(chǔ)應(yīng)用吧亚斋。