隨著業務變遷,即刻後端服務內積累了大量的陳舊代碼,維護成本較高,代碼重構甚至重寫被提上了日程。相比起 Node.js ,Golang 有著一定的優點。由於即刻後端已經較好地服務化了,其他業務在 Go 上也有了一定的實踐,直接使用 Go 重寫部分即刻服務是一個可行的選擇。在此過程中我們可以驗證在同一個業務上兩種語言的差異,並且可以完善 Go 相關的配套設施。
改造成果截至目前,即刻部分非核心服務已經通過 Go 重寫並上線。相比原始服務,新版服務的開銷顯著降低:
接口響應時長降低 50%
舊服務響應時間
新服務響應時間
內存佔用降低 95%
服務替換前後內存消耗趨勢CPU 佔用降低 90%
服務替換前後 CPU 消耗趨勢註:以上性能數據以用戶篩選服務為例,這是一個讀遠大於寫、任務單一的服務。由於在重寫的過程中,對原有的實現也進行了一定的優化,所以以上數據僅供參考,不完全代表 Go 和 Node 真實性能比較。
改造方案第一步:重寫服務
在保證對外接口不變的情況下,需要重寫一遍整個業務核心邏輯。不過在重寫的過程當中,還是碰到一些問題:
以往的 Node 服務大多沒有顯式聲明接口的輸入輸出類型,重寫的時候需要找到所有相關欄位。
以往代碼絕大多數不包含單元測試,重寫之後需要理解業務需求並設計單元測試。
老代碼裡面大量使用了 any 類型,需要費一番功夫才能明確所有可能的類型。很多類型在 Node 裡面不需要非常嚴格,但是放到 Go 裡面就不容偏差。
總之,重寫不是翻譯,需要對業務深入理解,重新實現一套代碼。
第二步:正確性驗證
由於很多服務沒有完整的回歸測試,單純地依賴單元測試是遠遠不夠保證正確性的。
一般來說,只讀的接口可以通過數據對拍來驗證接口正確性,即對比相同輸入的新舊服務的輸出。對於小規模的數據集,可以通過在本地啟動兩個服務進行測試。但是一旦數據規模足夠大,就沒辦法完全在本地測試,一個辦法就是流量複製測試。
由於服務之間跨環境調用比較麻煩且影響性能,所以使用消息隊列複製請求異步對拍。
原始服務在每一次響應的時候,將輸入和輸出打包成消息發送至消息隊列。
在測試環境下的消費服務會接受消息,並將輸入重新發送至新版服務。
等到新版服務響應之後,消費服務會對比前後兩次響應體,如果結果不同則輸出日誌。
最後,只需要下載日誌到本地,根據測試數據逐一修正代碼即可。
第三步:灰度並逐步替換舊服務
等到對業務正確性胸有成竹,就可以逐步上線新版服務了。得益於服務拆分,我們可以在上下遊無感的情況下替換服務,只需要將對應服務的逐步替換為新的容器即可。
倉庫結構項目結構是基於 Standard Go Project Layout 的 monorepo:
.├── build: 構建相關文件,可 symbolic link 至外部├── tools: 項目自定義工具├── pkg: 共享代碼│ ├── util│ └── ...├── app: 微服務目錄│ ├── hello: 示例服務│ │ ├── cmd│ │ │ ├── api│ │ │ │ └── main.go│ │ │ ├── cronjob│ │ │ │ └── main.go│ │ │ └── consumer│ │ │ └── main.go│ │ ├── internal: 具體業務代碼一律放在 internal 內,防止被其他服務引用│ │ │ ├── config│ │ │ ├── controller│ │ │ ├── service│ │ │ └── dao│ │ └── Dockerfile│ ├── user: 大業務拆分多個子服務示例│ │ ├── internal: 子業務間共享代碼│ │ ├── account:帳戶服務│ │ │ ├── main.go│ │ │ └── Dockerfile│ │ └── profile: 用戶主頁服務│ │ ├── main.go│ │ └── Dockerfile│ └── ...├── .drone.yml├── .golangci.yaml├── go.mod└── go.sum這種模式帶來的好處:
持續集成與構建靜態檢查
項目使用 golangci-lint 靜態檢查。每一次代碼 push,Github Action 會自動運行 golangci-lint,非常快且方便,如果發生了錯誤會將警告直接 comment 的 PR 上。
golangci-lint 本身不包含 lint 策略,但是可以集成各式 linter 以實現非常細緻的靜態檢查,把潛在錯誤扼殺在搖籃。
測試+構建鏡像
為了更快的構建速度,我們嘗試過在 GitHub Action 上構建鏡像,通過 matrix 特性可以良好地支持 monorepo。但是構建鏡像畢竟相對耗時,放在 GitHub Action 上構建會耗費大量的 GitHub Action 額度,一旦額度用完會影響正常開發工作。
最終選擇了自建的 Drone 來構建,通過 Drone Configuration Extension 也可以自定義複雜的構建策略。
通常來講,我們希望 CI 系統構建策略足夠智能,能夠自動分辨哪些代碼是需要構建,哪些代碼是需要測試的。在開發初期,我也深以為然,通過編寫腳本分析整個項目的依賴拓撲,結合文件變動,找到所有受到影響的 package,進而執行測試和構建。看上去非常美好,但是現實是,一旦改動公共代碼,幾乎所有服務都會被重新構建,簡直就是噩夢。這種方式可能更加適合單元測試,而不是打包。
於是,我現在選擇了一種更加簡單粗暴的策略,以 Dockerfile 作為構建的標誌:如果一個目錄包含 Dockerfile,那麼表示此目錄為「可構建「的;一旦此目錄子文件發生變動(新增或者修改),則表示此 Dockerfile 是「待構建「的。Drone 會為每一個待構建的 Dockerfile 啟動一個 pipeline 進行構建。
有幾點是值得注意的:
由於構建的時候不但需要拷貝當前服務的代碼,同時需要拷貝共享代碼,構建的時候就需要將上下文目錄設置在根目錄,並將服務目錄作為參數傳入方便構建:
docker build --file app/hello/Dockerfile --build-arg TARGET="./app/hello" .鏡像名會被默認命名為從內向外的文件夾名的拼接,如./app/live/chat/Dockerfile 在構建之後會生成 {registry}/chat-live-app:{branch}-{commitId} 形式的鏡像。
所有構建(包括下載依賴、編譯)由 Dockerfile 定義,避免在 CI 主流程上引入過多邏輯降低靈活度。通過 Docker 本身的緩存機制也能使構建速度飛快。
一個問題,一旦服務目錄之外的共享代碼發生變化,Drone 無法感知並構建受到影響的服務。解決方案是在 git commit message 內加上特定的欄位,告知 Drone 執行相應的構建。
配置管理在 Node 項目裡面,我們通常使用 node-config 來為不同環境配置不同的配置。Go 生態內並沒有現成的工具可以直接完成相同的工作,不過可以嘗試拋棄這種做法。
正如 Twelve-Factor 原則所推崇的,我們要儘可能通過環境變量來配置服務,而不是多個不同的配置文件。事實上,在 Node 項目當中,除開本地開發環境,我們往往也是通過環境變量動態配置,多數的 test.json/beta.json 直接引用了 production.json。
我們將配置分為兩部分:
單一配置文件
我們在服務內通過文件的方式,定義一份完整的配置,作為基礎配置,並且可以在本地開發的時候使用。動態環境變量
當服務部署到線上之後,在基礎配置的基礎上,我們將環境變量注入到配置當中。
我們可以在服務目錄中編寫一份 config.toml(選擇任何喜歡的配置格式),並編寫基礎的配置,作為本地開發的時候使用。
port=3000sentryDsn="https://project@sentry.io"
[mongodb]url="mongodb://localhost:27017"database="db"當在線上運行的時候,我們還需要在配置當中注入環境變量。可以使用 Netflix/go-env 將環境變量注入配置數據結構中:
type MongoDBConfig struct { URL string `toml:"url" env:"MONGO_URL,MONGO_URL_ACCOUNT"` Database string `toml:"database"`}
type Config struct { Port int `toml:"port" env:"PORT,default=3000"` SentryDSN string `toml:"sentryDsn"` MongoDB *MongoDBConfig `toml:"mongodb"`}
var configToml string
func ParseConfig() (*Config, error) { var cfg Config if _, err := toml.Decode(configToml, &cfg); err != nil { return nil, err } if _, err := env.UnmarshalFromEnviron(&cfg); err != nil { return nil, err } return &cfg, nil}上面代碼還使用了最新的 Go1.16 embed 功能,只需要一行 Compiler Directive 就可以將任意文件一併打包進入最終構建出來二進位文件內,構建鏡像只需要拷貝單個可執行文件即可,降低構建發布的複雜度。
服務調用代碼管理
即刻後端有多種語言的服務(Node/Java/Go),各個服務重複定義類型會造成人力浪費和不統一,故通過 ProtoBuf 定義類型,再用 protoc 生成對應的代碼,並在一個倉庫內維護各個語言的 client。
.├── go│ ├── internal: 內部實現,如 http client 封裝│ ├── service│ │ ├── user│ │ │ ├── api.go: 接口定義與實現│ │ │ ├── api_mock.go: 通過 gomock 生成的接口 mock│ │ │ └── user.pb.go: 通過 protoc 生成的類型文件│ │ ├── hello│ │ └── ...│ ├── go.mod│ ├── go.sum│ └── Makefile├── java├── proto│ ├── user│ │ └── user.proto│ ├── hello│ │ └── hello.proto│ └── ...└── Makefile每一個服務通過一個獨立的 package 對外暴露接口,每一個服務都由四部分組成:
ProtoBuf
正如上面所說,為了降低內部接口對接和維護成本,我們選擇使用 ProtoBuf 定義類型,並生成了 Go 類型。雖然使用 ProtoBuf 定義,但服務之間依然通過 JSON 傳遞數據,數據序列化和反序列化成了問題。
為了簡化 ProtoBuf 和 JSON 互相轉換,Google 提供了一個叫做 jsonpb 的包,這個包在原生 json 的基礎上實現了 Enum Name(string) 和 Value(int32) 互相轉換,以兼容傳統的 string enum;還支持了 oneof 類型。上面的能力都是 Go 原生的 json 所無法實現的。如果使用原生 json 序列化 proto 類型,將會導致 enum 無法輸出字符串和 oneof 完全無法輸出。
這麼說起來,是不是我們在代碼全部都使用 jsonpb 替換掉原生 json 就好了?並不是,jsonpb 只支持對 proto 類型序列化:
func Marshal(w io.Writer, m proto.Message) error
除非所有對外讀寫接口的類型都用 ProtoBuf 定義,否則就不能一路使用 jsonpb 。
不過天無絕人之路,Go 的原生 json 定義了兩個接口:
// Marshaler is the interface implemented by types that
// can marshal themselves into valid JSON.
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
// Unmarshaler is the interface implemented by types
// that can unmarshal a JSON description of themselves.
// The input can be assumed to be a valid encoding of
// a JSON value. UnmarshalJSON must copy the JSON data
// if it wishes to retain the data after returning.
//
// By convention, to approximate the behavior of Unmarshal itself,
// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}任何類型只要實現了這兩個接口,在被(反)序列化的時候就能調用自己的邏輯進行操作,類似 Hook 函數。那樣,只需要為所有的 proto 類型實現這兩個接口:當 json 嘗試(反)序列化自己,就轉而使用 jsonpb 進行。
func (msg *Person) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
err := (&jsonpb.Marshaler{
EnumsAsInts: false,
EmitDefaults: false,
OrigName: false,
}).Marshal(&buf, msg)
return buf.Bytes(), err
}
func (msg *Person) UnmarshalJSON(b []byte) error {
return (&jsonpb.Unmarshaler{
AllowUnknownFields: true,
}).Unmarshal(bytes.NewReader(b), msg)
}經過一番尋找,最後找到了一個 protoc 插件 protoc-gen-go-json :它可以在生成 proto 類型的同時,為所有類型實現 json.Marshaler 和 json.Unmarshaler。這樣一來就不需要為了序列化兼容而妥協,並且對代碼也沒有任何侵入性。
發布
由於是獨立維護的倉庫,需要以 Go module 的形式引入項目內使用。得益於 Go module 的設計,版本發布可以和 GitHub 無縫結合在一起,效率非常高。
測試版本
go mod 支持直接拉取對應的分支的代碼作為依賴,不需要手動發布 alpha 版本,只需要在調用方的代碼執目錄執行 go get -u github.com/iftechio/rpc/go@{branch} 就可以直接下載對應開發分支的最新版本了。正式版本
當改動合併進入主分支,只需通過 Github Release 就可以發布一個穩定版本(也可以在本地打 git tag),即可通過具體版本號拉到對應的倉庫快照:go get github.com/iftechio/rpc/go@{version}由於 go get 本質上就是下載代碼,我們的代碼託管在 GitHub 上,所以在國內阿里雲上構建代碼時可能因為網絡原因出現拉取依賴失敗的情況(private mod 無法通過 goproxy 拉取)。於是我們改造了 goproxy,在集群內部署了一個 goproxy:
我們只需要執行如下代碼即可通過內部 goproxy 下載依賴:
GOPROXY="http://goproxy.infra:8081" \
ContextContext provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processes.
GONOSUMDB="github.com/iftechio" \
go mod downloadContext 是 Go 當中一個非常特別的存在,可以像一座橋一樣將整個業務串起來,使得數據和信號可以在業務鏈路上下遊之間傳遞。在我們的項目當中,context 也有不少的應用:
取消信號
每一個 http 請求都會攜帶一個 context,一旦請求超時或者 client 端主動關閉連接,最外層會將一個 cancel 信號通過 context 傳遞到整個鏈路當中,所有下遊調用立即結束運行。如果整個鏈路都遵循這個規範,一旦上遊關閉請求,所有服務都會取消當前的操作,可以減少大量無謂的消耗。
在開發的時候就需要注意:
大多數任務被取消的同時,會拋出一個 context.ErrCancelled 錯誤,以使調用者能夠感知異常並退出。但是 RPC 斷路器也會捕獲這個錯誤並記錄為失敗。極端場景下,客戶端不斷發起請求並立刻取消,就能夠使服務的斷路器紛紛打開,造成服務的不穩定。解決方案就是改造斷路器,對於特定的錯誤依然拋出,但不記錄為失敗。
分布式場景下絕大多數數據寫入無法使用事務,需要考慮一個操作如果被中途取消,最終一致性還能否得到保證?對於一致性要求高的操作,需要在執行前主動屏蔽掉 cancel 信號:
// 返回一個僅僅實現了 Value 接口的 context
// 只保留 context 內的數據,但忽略 cancel 信號
func DetachedContext(ctx context.Context) context.Context {
return &detachedContext{Context: context.Background(), orig: ctx}
}
type detachedContext struct {
context.Context
orig context.Context
}
func (c *detachedContext) Value(key interface{}) interface{} {
return c.orig.Value(key)
}
func storeUserInfo(ctx context.Context, info interface{}) {
ctx = DetachedContext(ctx)
saveToDB(ctx, info)
updateCahce(ctx, info)
}上下文透傳
每一個請求進入的時候,http request context 都被攜帶上各種當前 request 的信息,比如 traceId、用戶信息,這些數據就能夠隨著 context 被一路透傳至業務整條鏈路,期間收集到的監控數據都會與這些數據進行關聯,便於監控數據聚合。
Context.Value should inform, not control.使用 context 傳遞數據最需要注意的就是:context 的數據僅僅用於監控,切勿用於業務邏輯。所謂「顯式優於隱式」,由於 context 不直接對外暴露任何內部數據,使用 context 傳遞業務數據會使程序非常不優雅,而且難以測試。換句話說,任何一個函數哪怕傳入了的是 emptyCtx 也不應該影響正確性。
錯誤收集Errors are just values.Go 的錯誤是一個普通的值(從外部看來就是一個字符串),這給收集錯誤帶來了一定的麻煩:我們收集錯誤不單需要知道那一行錯誤的內容,還需要知道錯誤的上下文信息。
Go1.13 引入了 error wrap 的概念,通過 Wrap/Unwrap 的設計, 就可以將一個 error 變成單向鍊表的結構,每一個節點上都能夠存儲自定義的上下文信息,並且可以使用一個 error 作為鍊表頭讀取後方所有錯誤節點。
對於單個錯誤來說,錯誤的 stacktrace 是最重要的信息之一。Go 通過 runtime.Callers 實現 stacktrace 收集:
Callers fills the slice pc with the return program counters of function invocations on the calling goroutine's stack.可以看到, Callers 只能收集單個 goroutine 內的調用棧,如果希望收集到完整的 error trace,則需要在跨 goroutine 傳遞錯誤的時候,將 stacktrace 包含在 error 內部。這個時候就可以使用第三方庫 pkg/errors 的 errors.WithStack 或者 errors.Wrap 來實現,它們會創建一個新的 error 節點,並存入當時的調用棧:
// WithStack annotates err with a stack trace at the point WithStack was called.
// If err is nil, WithStack returns nil.
func WithStack(err error) error {
if err == nil {
return nil
}
return &withStack{
err,
callers(),
}
}
func main() {
ch := make(chan error)
go func() {
err := doSomething()
ch <- errors.withStack(err)
}()
err := <-ch
fmt.Printf("%w", err)
}最終的錯誤收集(往往在根部的 web 中間件上),可以直接使用 Sentry:
sentry.CaptureException(errors.WithStack(err)) // 最終上傳的時候也不忘收集 stacktrace
Sentry 會基於 errors.Unwrap 接口,取出每一層的 error。Sentry 針對每一層 error 能夠自動導出錯誤棧。由於 stacktrace 並非正式標準,Sentry 主動適配了幾個主流的 Stacktrace 方案,其中就包括 pkg/errors 的。
這樣就可以通過 Sentry 後臺查看完整的報錯信息。如下圖,每一個大的 section 都是一層 error,每一個 section 內都包含這個 error 內的上下文信息。
參考連結TJ 談 Go 相比 Node 的生產力優勢 https://qr.ae/pNdNhU
Standard Go Project Layout https://github.com/golang-standards/project-layout
The Tweleve-Factor App https://12factor.net/
Go Wiki - Module: Releaseing Modules (V2 or Higher) https://github.com/golang/go/wiki/Modules#releasing-modules-v2-or-higher
How to correctly use context.Context in Go 1.7 https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
Don’t just check errors, handle them gracefully https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully