知乎社區(qū)核心業(yè)務(wù) Golang 化實(shí)踐

背景
眾所周知,知乎社區(qū)后端之前的主力編程語言是 Python。
隨著知乎用戶的迅速增長和業(yè)務(wù)復(fù)雜度的持續(xù)增加,核心業(yè)務(wù)的流量在過去一年內(nèi)增長了好幾倍,對(duì)應(yīng)的服務(wù)端的壓力也越來越大。隨著業(yè)務(wù)發(fā)展,我們發(fā)現(xiàn) Python 作為動(dòng)態(tài)解釋型語言,較低的運(yùn)行效率和較高的后期維護(hù)成本帶來的問題逐漸暴露出來:
運(yùn)行效率較低。知乎目前機(jī)房機(jī)柜空間已經(jīng)不足,按照目前的用戶和流量增長速度,可預(yù)見將在短期內(nèi)服務(wù)器資源告急(針對(duì)這一點(diǎn),知乎正在由單機(jī)房架構(gòu)升級(jí)為異地多活架構(gòu));
Python 過于靈活的語言特性,導(dǎo)致多人協(xié)作和項(xiàng)目維護(hù)成本較高。受益于近些年開源社區(qū)的發(fā)展和容器等關(guān)鍵技術(shù)的普及,知乎的基礎(chǔ)平臺(tái)技術(shù)選型一直較為開放。在開放的標(biāo)準(zhǔn)之上,各個(gè)語言都有成熟的開源的中間件可供選擇。這使得業(yè)務(wù)做選型時(shí)可以根據(jù)問題場(chǎng)景選擇更合適的工具,語言也是一樣。
基于此,為了解決資源占用問題和動(dòng)態(tài)語言的維護(hù)成本問題,我們決定嘗試使用靜態(tài)語言對(duì)資源占用極高的核心業(yè)務(wù)進(jìn)行重構(gòu)。
為什么選擇 Golang
如上所述,知乎在后端技術(shù)選型上比較開放。在過去幾年里,除了 Python 作為主力語言開發(fā),知乎內(nèi)部也不乏 Java、Golang、NodeJS 和 Rust 等語言開發(fā)的項(xiàng)目。
通過 ZAE(Zhihu App Engine)新建一個(gè)應(yīng)用時(shí),提供了多門語言的支持
Golang 是目前知乎內(nèi)部討論交流最活躍的編程語言之一,考慮到以下幾點(diǎn),我們決定嘗試用 Golang 重構(gòu)內(nèi)部高并發(fā)量的核心業(yè)務(wù):
天然的并發(fā)優(yōu)勢(shì),特別適合 IO 密集應(yīng)用 知乎內(nèi)部基礎(chǔ)組件的 Golang 版生態(tài)比較完善 靜態(tài)類型,多人協(xié)作開發(fā)和維護(hù)更加安全可靠 構(gòu)建好后只需一個(gè)可執(zhí)行文件即可,方便部署 學(xué)習(xí)成本低,且開發(fā)效率較 Python 沒有明顯降低
相比另一門也很優(yōu)秀的待選語言—— Java,Golang 在知乎內(nèi)部生態(tài)環(huán)境、部署的方便程度和工程師的興趣上都更勝一籌,最終我們決定,選擇 Golang 作為開發(fā)語言。
改造成果
截至目前,知乎社區(qū) member(RPC,高峰數(shù)十萬 QPS)、評(píng)論(RPC + HTTP)、問答(RPC + HTTP)服務(wù)已經(jīng)全部通過 Golang 重寫。同時(shí)因?yàn)樵?Golang 化過程中我們對(duì) Golang 基礎(chǔ)組件的進(jìn)一步完善,目前一些新的業(yè)務(wù)在開發(fā)之初就直接選擇了 Golang 來實(shí)現(xiàn),Golang 已經(jīng)成為知乎內(nèi)部新項(xiàng)目技術(shù)選型的推薦語言之一。
相比改造前,目前得到改進(jìn)的點(diǎn)有以下:
1、節(jié)約了超過 80% 的服務(wù)器資源。由于我們的部署系統(tǒng)采用藍(lán)綠部署,所以之前占用服務(wù)器資源最高的幾個(gè)業(yè)務(wù)會(huì)因?yàn)槿萜髻Y源原因無法同時(shí)部署,需要排隊(duì)依次部署。重構(gòu)后,服務(wù)器資源得到優(yōu)化,服務(wù)器資源問題得到了有效解決。
2、多人開發(fā)和項(xiàng)目維護(hù)成本大幅下降。想必大家維護(hù)大型 Python 項(xiàng)目都有經(jīng)常需要里三層、外三層確認(rèn)一個(gè)函數(shù)的參數(shù)類型和返回值。而 Golang 里,大家都面向接口定義,然后根據(jù)接口來實(shí)現(xiàn),這使得編碼過程更加安全,很多 Python 代碼運(yùn)行時(shí)才能發(fā)現(xiàn)的問題可以在編譯時(shí)即可發(fā)現(xiàn)。3、完善了內(nèi)部 Golang 基礎(chǔ)組件。前面提到,知乎內(nèi)部基礎(chǔ)組件的 Golang 版比較完善,這是我們選擇 Golang 的前提之一。不過,在重構(gòu)的過程中,我們發(fā)現(xiàn)仍有部分基礎(chǔ)組件不夠完善甚至缺少。所以,我們也完善和提供了不少基礎(chǔ)組件,為之后其它項(xiàng)目的 Golang 化改造提供了便利。
過去 10 個(gè)月問答服務(wù)的 CPU 核數(shù)占用變化趨勢(shì)
實(shí)施過程
得益于知乎微服務(wù)化比較徹底,每個(gè)獨(dú)立的微服務(wù)想要更換語言非常方便,我們可以方便地對(duì)單個(gè)業(yè)務(wù)進(jìn)行改造,且?guī)缀蹩梢宰龅酵獠恳蕾嚪綗o感知。
知乎內(nèi)部,每個(gè)獨(dú)立的微服務(wù)有自己獨(dú)立的各種資源,服務(wù)間是沒有資源依賴的,全部通過 RPC 請(qǐng)求交互,每個(gè)對(duì)外提供服務(wù)(HTTP or RPC)的容器組,都通過獨(dú)立的 HAProxy 地址代理對(duì)外提供服務(wù)。一個(gè)典型的微服務(wù)結(jié)構(gòu)如下:
知乎內(nèi)部一個(gè)典型的微服務(wù)組成,服務(wù)間沒有資源依賴
所以,我們的 Golang 化改造分為了以下幾步:
Step 1. 用 Golang 重構(gòu)邏輯
首先,我們會(huì)新起一個(gè)微服務(wù),通過 Golang 來重構(gòu)業(yè)務(wù)邏輯,但是:
1、新服務(wù)對(duì)外暴露的協(xié)議(HTTP 、RPC 接口定義和返回?cái)?shù)據(jù))與之前保持一致(保持協(xié)議一致很重要,之后遷移依賴方會(huì)更方便)
2、新的服務(wù)沒有自己的資源,使用待重構(gòu)服務(wù)的資源:
新服務(wù)(下)使用待重構(gòu)服務(wù)(上)的資源,短期內(nèi)資源混用
Step 2. 驗(yàn)證新邏輯正確性
當(dāng)代碼重構(gòu)完成后,在將流量切換到新邏輯之前,我們會(huì)先驗(yàn)證新服務(wù)的正確性。
針對(duì)讀接口,由于其是冪等的,多次調(diào)用沒有副作用,所以當(dāng)新版接口實(shí)現(xiàn)完成后,我們會(huì)在老服務(wù)收到請(qǐng)求的同時(shí),起一個(gè)協(xié)程請(qǐng)求新服務(wù),并對(duì)比新老服務(wù)的數(shù)據(jù)是否一致:
1、當(dāng)請(qǐng)求到達(dá)老服務(wù)后,會(huì)立即啟一個(gè)協(xié)程請(qǐng)求新的服務(wù),與此同時(shí)老服務(wù)的主邏輯會(huì)正常執(zhí)行。
2、當(dāng)請(qǐng)求返回后,會(huì)比較老服務(wù)與新實(shí)現(xiàn)的服務(wù)返回?cái)?shù)據(jù)是否相同,如果不同,會(huì)打點(diǎn)記錄 + 日志記錄。
3、工程師根據(jù)打點(diǎn)指標(biāo)和日志,發(fā)現(xiàn)新實(shí)現(xiàn)邏輯的錯(cuò)誤,改正后繼續(xù)驗(yàn)證(其實(shí)這一步,我們也發(fā)現(xiàn)了不少原本 Python 實(shí)現(xiàn)的錯(cuò)誤)。
服務(wù)請(qǐng)求兩邊數(shù)據(jù),并對(duì)比結(jié)果,但返回老服務(wù)的結(jié)果
而對(duì)于寫接口,大部分并不是冪等的,所以針對(duì)寫接口不能像上面這樣驗(yàn)證。對(duì)于寫接口,我們主要會(huì)通過以下手段保證新舊邏輯等價(jià):
1、單元測(cè)試保證
2、開發(fā)者驗(yàn)證
3、QA 驗(yàn)證
Step 3. 灰度放量
當(dāng)一切驗(yàn)證通過之后,我們會(huì)開始按照百分比轉(zhuǎn)發(fā)流量。
此時(shí),請(qǐng)求依然會(huì)被代理到老的服務(wù)的容器組,但是老服務(wù)不再處理請(qǐng)求,而是轉(zhuǎn)發(fā)請(qǐng)求到新服務(wù)中,并將新服務(wù)返回的數(shù)據(jù)直接返回。
之所以不直接從流量入口切換,是為了保證穩(wěn)定性,在出現(xiàn)問題時(shí)可以迅速回滾。
服務(wù)請(qǐng)求 Golang 實(shí)現(xiàn)
Step 4. 切流量入口
當(dāng)上一步的放量達(dá)到 100% 后,請(qǐng)求雖然依然會(huì)被代理到老的容器組,但返回的數(shù)據(jù)已經(jīng)全部是新服務(wù)產(chǎn)生的。此時(shí),我們可以把流量入口直接切換到新服務(wù)了。
請(qǐng)求直接打到新的服務(wù),舊服務(wù)沒有流量了
Step 5. 下線老服務(wù)
到這里重構(gòu)已經(jīng)基本接近尾聲了。不過新服務(wù)的資源還在老服務(wù)中,以及老的沒有流量的服務(wù)其實(shí)還沒有下線。
到這里,直接把老服務(wù)的資源歸屬調(diào)整為新服務(wù),并下線老服務(wù)即可。
Goodbye,Python
至此,重構(gòu)完成。
Golang 項(xiàng)目實(shí)踐
在重構(gòu)的過程中,我們踩了不少坑,這里摘其中一些與大家分享一下。如果大家有類似重構(gòu)需求,可簡單參考。
換語言重構(gòu)的前提是了解業(yè)務(wù)
不要無腦翻譯原來的代碼,也不要無腦修復(fù)原本看似有問題的實(shí)現(xiàn)。在重構(gòu)的初期,我們發(fā)現(xiàn)一些看似可以做得更好的點(diǎn),悶頭一頓修改之后,卻產(chǎn)生了一些奇怪的問題。后面的經(jīng)驗(yàn)是,在重構(gòu)前一定要了解業(yè)務(wù),了解原本的實(shí)現(xiàn)。最好整個(gè)重構(gòu)的過程有對(duì)應(yīng)業(yè)務(wù)的工程師也參與其中。
項(xiàng)目結(jié)構(gòu)
關(guān)于合適的項(xiàng)目結(jié)構(gòu),其實(shí)我們也走過不少彎路。
一開始,我們根據(jù)在 Python 中的實(shí)踐經(jīng)驗(yàn),層與層之間直接通過函數(shù)提供交互接口。但是,迅速發(fā)現(xiàn) Golang 很難像 Python 一樣,方便地通過 monkey patch 完成測(cè)試。
經(jīng)過逐漸演進(jìn)和參考各種開源項(xiàng)目,目前,我們的代碼結(jié)構(gòu)大致是這樣:
├──?bin?????????-->?構(gòu)建生成的可執(zhí)行文件
├──?cmd?????????-->?各種服務(wù)的?main?函數(shù)入口(?RPC、Web?等)
│?├──?service
│?│?└──?main.go
│?├──?web
│?└──?worker
├──?gen-go????????-->?根據(jù)?RPC?thrift?接口自動(dòng)生成
├──?pkg?????????-->?真正的實(shí)現(xiàn)部分(下面詳細(xì)介紹)
│?├──?controller
│?├──?dao
│?├──?rpc
│?├──?service
│?└──?web
│?├──?controller
│?├──?handler
│?├──?model
│?└──?router
├──?thrift_files?????-->?thrift?接口定義
│?└──?interface.thrift
├──?vendor????????-->?依賴的第三方庫(?dep?ensure?自動(dòng)拉取)
├──?Gopkg.lock?-->?第三方依賴版本控制
├──?Gopkg.toml
├──?joker.yml??????-->?應(yīng)用構(gòu)建配置
├──?Makefile?-->?本項(xiàng)目下常用的構(gòu)建命令
└──?[README.md](http://readme.md/?"README.md")
分別是:
bin:構(gòu)建生成的可執(zhí)行文件,一般線上啟動(dòng)就是 bin/xxxx-service cmd:各種服務(wù)(RPC、Web、離線任務(wù)等)的 main 函數(shù)入口,一般從這里開始執(zhí)行 gen-go:thrift 編譯自動(dòng)生成的代碼,一般會(huì)配置 Makefile,直接 make thrift 即可生成(這種方式有一個(gè)弊端:很難升級(jí) thrift 版本) pkg:真正的業(yè)務(wù)實(shí)現(xiàn)(下面詳細(xì)介紹) thrift_files:定義 RPC 接口協(xié)議 vendor:依賴的第三方庫
其中,pkg 下放置著項(xiàng)目的真正邏輯實(shí)現(xiàn),其結(jié)構(gòu)為:
pkg/
├──?controller????
│?├──?ctl.go??????-->?接口
│?├──?impl???????-->?接口的業(yè)務(wù)實(shí)現(xiàn)
│?│?└──?ctl.go
│?└──?mock???????-->?接口的?mock?實(shí)現(xiàn)
│?└──?mock_ctl.go
├──?dao???????
│?├──?impl
│?└──?mock
├──?rpc???????
│?├──?impl
│?└──?mock
├──?service??????-->?本項(xiàng)目?RPC?服務(wù)接口入口
│?├──?impl
│?└──?mock
└──?web????????-->?Web?層(提供?HTTP?服務(wù))
?├──?controller????-->?Web?層?controller?邏輯
?│?├──?impl
?│?└──?mock
?├──?handler??????-->?各種?HTTP?接口實(shí)現(xiàn)
?├──?model???????-->
?├──?formatter?????-->?把?model?轉(zhuǎn)換成輸出給外部的格式
?└──?router??????-->?路由
如上結(jié)構(gòu),值得關(guān)注的是我們?cè)诿恳粚又g一般都有 impl、mock 兩個(gè)包。

這樣做是因?yàn)?Golang 中不能像 Python 那樣方便地動(dòng)態(tài) mock 掉一個(gè)實(shí)現(xiàn),不能方便地測(cè)試。我們很看重測(cè)試,Golang 實(shí)現(xiàn)的測(cè)試覆蓋率也保持在 85% 以上。所以我們將層與層之間先抽象出接口(如上 ctl.go),上層對(duì)下層的調(diào)用通過接口約定。在執(zhí)行的時(shí)候,通過依賴注入綁定 impl 中對(duì)接口的實(shí)現(xiàn)來運(yùn)行真正的業(yè)務(wù)邏輯,而測(cè)試的時(shí)候,綁定 mock 中對(duì)接口的實(shí)現(xiàn)來達(dá)到 mock 下層實(shí)現(xiàn)的目的。
同時(shí),為了方便業(yè)務(wù)開發(fā),我們也實(shí)現(xiàn)了一個(gè) Golang 項(xiàng)目的腳手架,通過腳手架可以更方便地直接生成一個(gè)包含 HTTP & RPC 入口的 Golang 服務(wù)。這個(gè)腳手架已經(jīng)集成到 ZAE(Zhihu App Engine),在創(chuàng)建出 Golang 項(xiàng)目后,默認(rèn)的模板代碼就生成好了。對(duì)于使用 Golang 開發(fā)的新項(xiàng)目,創(chuàng)建好就有了一個(gè)開箱即用的框架結(jié)構(gòu)。
靜態(tài)代碼檢查,越早越好
我們?cè)陂_發(fā)的后期才意識(shí)到引入靜態(tài)代碼檢查,其實(shí)最好的做法是在項(xiàng)目開始時(shí)就及時(shí)使用,并以較嚴(yán)格的標(biāo)準(zhǔn)保證主分支的代碼質(zhì)量。
在開發(fā)后期才引入的問題是,已經(jīng)有太多代碼不符合標(biāo)準(zhǔn)。所以我們不得不短期內(nèi)忽略了很多檢查項(xiàng)。
很多非常基礎(chǔ)甚至愚蠢的錯(cuò)誤,人總是無法 100% 避免的,這正是 linter 存在的價(jià)值。
實(shí)際實(shí)踐中,我們使用 gometalinter[1]。gometalinter 本身不做代碼檢查,而是集成了各種 linter,提供統(tǒng)一的配置和輸出。我們集成了 vet、golint 和 errcheck 三種檢查。
降級(jí)
降級(jí)的粒度究竟是什么?這個(gè)問題一些工程師的觀點(diǎn)是 RPC 調(diào)用,而我們的答案是「功能」。
在重構(gòu)過程中,我們按照「如果這個(gè)功能不可用,對(duì)用戶的影響該是什么」的角度,將所有可降級(jí)的功能點(diǎn)都做了降級(jí),并對(duì)所有降級(jí)加上對(duì)應(yīng)的指標(biāo)點(diǎn)和報(bào)警。最終的效果是,如果問答所有的外部 RPC 依賴全部掛了(包括 member 和鑒權(quán)這樣的基礎(chǔ)服務(wù)),問答本身仍然可以正常瀏覽問題和回答。
我們的降級(jí)是在 circuit[2] 的基礎(chǔ)上,封裝指標(biāo)收集和日志輸出等功能。Twitch 也在生產(chǎn)環(huán)境中使用了這個(gè)庫,且我們超過半年的使用中,還沒有遇到什么問題。
anti-pattern: panic - recover
大部分人開始使用 Golang 開發(fā)后,一個(gè)非常不習(xí)慣的點(diǎn)就是它的錯(cuò)誤處理。一個(gè)簡單的 HTTP 接口實(shí)現(xiàn)可能是這樣:
func?(h?*AnswerHandler)?Get(w?http.ResponseWriter,?r?*http.Request)?{
??ctx?:=?r.Context()
??loginId,?err?:=?auth.GetLoginID(ctx)
?if?err?!=?nil?{
??zapi.RenderError(err)
??return
?}
?answer,?err?:=?h.PrepareAnswer(ctx,?r,?loginId)
?if?err?!=?nil?{
??zapi.RenderError(err)
??return
?}
?formattedAnswer,?err?:=?h.ctl.FormatAnswer(ctx,?loginId,?answer)
?if?err?!=?nil?{
??zapi.RenderError(err)
??return
?}
?
?zapi.RenderJSON(w,?formattedAnswer)
}
如上,每行代碼后有緊跟著一個(gè)錯(cuò)誤判斷。繁瑣只是其次,主要問題在于,如果錯(cuò)誤處理后面的 return 語句忘寫,那么邏輯并不會(huì)被阻斷,代碼會(huì)繼續(xù)向下執(zhí)行。在實(shí)際開發(fā)過程中,我們也確實(shí)犯過類似的錯(cuò)誤。
為此,我們通過一層 middleware,在框架外層將 panic 捕獲,如果 recover 住的是框架定義的錯(cuò)誤則轉(zhuǎn)換為對(duì)應(yīng)的 HTTP Error 渲染出去,反之繼續(xù)向上層拋出去。改造后的代碼成了這樣:
func?(h?*AnswerHandler)?Get(w?http.ResponseWriter,?r?*http.Request)?{
??ctx?:=?r.Context()
??loginId?:=?auth.MustGetLoginID(ctx)
??answer?:=?h.MustPrepareAnswer(ctx,?r,?loginId)
??formattedAnswer?:=?h.ctl.MustFormatAnswer(ctx,?loginId,?answer)
??zapi.RenderJSON(w,?formattedAnswer)
}
如上,業(yè)務(wù)邏輯中以前 RenderError 并直接緊接著返回的地方,現(xiàn)在再遇到 error 的時(shí)候,會(huì)直接 panic。這個(gè) panic 會(huì)在 HTTP 框架層被捕獲,如果是項(xiàng)目內(nèi)定義的 HTTPError,則轉(zhuǎn)換成對(duì)應(yīng)的接口 4xx JSON 格式返回給前端,否則繼續(xù)向上拋出,最終變成一個(gè) 5xx 返回前端。
這里提到這個(gè)實(shí)現(xiàn)并不是推薦大家這樣做,Golang 官方明確不推薦這樣使用。不過,這確實(shí)有效地解決了一些問題,這里提出來供大家多一種參考。
Goroutine 的啟動(dòng)
在構(gòu)建 model 的時(shí)候,很多邏輯其實(shí)相互之間沒有依賴是可以并發(fā)執(zhí)行的。這時(shí)候,啟動(dòng)多個(gè) goroutine 并發(fā)獲取數(shù)據(jù)可以極大降低響應(yīng)時(shí)間。
不過,剛使用 Golang 的人很容易踩到的一個(gè) goroutine 坑點(diǎn)是,一個(gè) goroutine 如果 panic 了,在它的父 goroutine 是無法 recover 的——嚴(yán)格來講,并沒有父子 goroutine 的概念,一旦啟動(dòng),就是一個(gè)獨(dú)立的 goroutine 了。
所以這里一定要非常注意,如果你新啟動(dòng)的 goroutine 可能 panic,一定需要本 goroutine 內(nèi) recover。當(dāng)然,更好的方式是做一層封裝,而不是在業(yè)務(wù)代碼裸啟動(dòng) goroutine。
因此我們參考了 Java 里面的 Future 功能,做了簡單的封裝。在需要啟動(dòng) goroutine 的地方,通過封裝的 Future 來啟動(dòng),F(xiàn)uture 來處理 panic 等各種狀況。
http.Response Body 沒有 close 導(dǎo)致 goroutine 泄露
一段時(shí)間內(nèi),我們發(fā)現(xiàn)服務(wù) goroutine 數(shù)量隨著時(shí)間不斷上漲,并會(huì)隨著重啟容器立刻掉下來。因此我們猜測(cè)代碼存在 goroutine 泄露。
Goroutine 數(shù)量隨運(yùn)行時(shí)間逐漸增長,并在重啟后掉下來
通過 goroutine stack 和在依賴庫打印日志,最終定位到的問題是某個(gè)內(nèi)部的基礎(chǔ)庫使用了 http.Client,但是沒有 resp.Body.Close(),導(dǎo)致發(fā)生 goroutine 泄露。
這里的一個(gè)經(jīng)驗(yàn)教訓(xùn)是生產(chǎn)環(huán)境不要直接用 http.Get,自己生成一個(gè) http client 的實(shí)例并設(shè)置 timeout 會(huì)更好。
修復(fù)這個(gè)問題后就正常了:
resp.Body.Close()
雖然簡單幾句話介紹了這個(gè)問題,但實(shí)際定位問題的步驟耗費(fèi)了我們不少時(shí)間,后面可以新起一篇文章專門介紹下 goroutine 泄露的排查過程。
最后
核心業(yè)務(wù)的 Golang 化重構(gòu)是由社區(qū)業(yè)務(wù)架構(gòu)團(tuán)隊(duì)與社區(qū)內(nèi)容技術(shù)團(tuán)隊(duì)的同學(xué)一起,經(jīng)過 2018 年 Q2/Q3 的努力達(dá)成的目標(biāo)。以下是兩個(gè)團(tuán)隊(duì)的部分成員:
@姚鋼強(qiáng)@Adam Wen@萬其平@陳錚@yetingsky@王志召@柴小喵@xlzd
社區(qū)業(yè)務(wù)架構(gòu)團(tuán)隊(duì)負(fù)責(zé)解決知乎社區(qū)后端的業(yè)務(wù)復(fù)雜度和并發(fā)規(guī)模快速提升帶來的問題和挑戰(zhàn)。隨著知乎業(yè)務(wù)規(guī)模和用戶的快速增長,以及業(yè)務(wù)復(fù)雜度的持續(xù)增加,我們團(tuán)隊(duì)面臨的技術(shù)挑戰(zhàn)也越來越大。目前我們正在實(shí)施知乎社區(qū)的多機(jī)房異地多活架構(gòu),同時(shí)也在努力保障和提升知乎后端的質(zhì)量和穩(wěn)定性。
作者:xlzd
原文鏈接:https://zhuanlan.zhihu.com/p/48039838
參考資料
gometalinter: https://github.com/alecthomas/gometalinter
[2]circuit: https://github.com/cep21/circuit
推薦閱讀
