天天看點

Golang 在即刻後端的實踐

作者:閃念基因

背景

随着業務變遷,即刻後端服務内積累了大量的陳舊代碼,維護成本較高,代碼重構甚至重寫被提上了日程。相比起 Node.js ,Golang 有着一定的優點。由于即刻後端已經較好地服務化了,其他業務在 Go 上也有了一定的實踐,直接使用 Go 重寫部分即刻服務是一個可行的選擇。在此過程中我們可以驗證在同一個業務上兩種語言的差異,并且可以完善 Go 相關的配套設施。

改造成果

截至目前,即刻部分非核心服務已經通過 Go 重寫并上線。相比原始服務,新版服務的開銷顯著降低:

接口響應時長降低 50%

Golang 在即刻後端的實踐

舊服務響應時間

Golang 在即刻後端的實踐

新服務響應時間

記憶體占用降低 95%

Golang 在即刻後端的實踐

服務替換前後記憶體消耗趨勢

CPU 占用降低 90%

Golang 在即刻後端的實踐

服務替換前後 CPU 消耗趨勢

注:以上性能資料以使用者篩選服務為例,這是一個讀遠大于寫、任務單一的服務。由于在重寫的過程中,對原有的實作也進行了一定的優化,是以以上資料僅供參考,不完全代表 Go 和 Node 真實性能比較。

改造方案

第一步:重寫服務

在保證對外接口不變的情況下,需要重寫一遍整個業務核心邏輯。不過在重寫的過程當中,還是碰到一些問題:

  1. 以往的 Node 服務大多沒有顯式聲明接口的輸入輸出類型,重寫的時候需要找到所有相關字段。
  2. 以往代碼絕大多數不包含單元測試,重寫之後需要了解業務需求并設計單元測試。
  3. 老代碼裡面大量使用了 any 類型,需要費一番功夫才能明确所有可能的類型。很多類型在 Node 裡面不需要非常嚴格,但是放到 Go 裡面就不容偏差。

總之,重寫不是翻譯,需要對業務深入了解,重新實作一套代碼。

第二步:正确性驗證

由于很多服務沒有完整的回歸測試,單純地依賴單元測試是遠遠不夠保證正确性的。

一般來說,隻讀的接口可以通過資料對拍來驗證接口正确性,即對比相同輸入的新舊服務的輸出。對于小規模的資料集,可以通過在本地啟動兩個服務進行測試。但是一旦資料規模足夠大,就沒辦法完全在本地測試,一個辦法就是流量複制測試。

Golang 在即刻後端的實踐

由于服務之間跨環境調用比較麻煩且影響性能,是以使用消息隊列複制請求異步對拍。

  1. 原始服務在每一次響應的時候,将輸入和輸出打包成消息發送至消息隊列。
  2. 在測試環境下的消費服務會接受消息,并将輸入重新發送至新版服務。
  3. 等到新版服務響應之後,消費服務會對比前後兩次響應體,如果結果不同則輸出日志。
  4. 最後,隻需要下載下傳日志到本地,根據測試資料逐一修正代碼即可。

第三步:灰階并逐漸替換舊服務

等到對業務正确性胸有成竹,就可以逐漸上線新版服務了。得益于服務拆分,我們可以在上下遊無感的情況下替換服務,隻需要将對應服務的逐漸替換為新的容器即可。

倉庫結構

項目結構是基于 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

           
  • app 目錄包含了所有服務代碼,可以自由劃分層級。
  • 所有服務共享的代碼放置于根目錄的 pkg 内。
  • 所有外部依賴在根目錄的 go.mod 内聲明。
  • 每一個服務或者一組服務,通過 internal 目錄,獨占下面的所有的代碼,避免被其他服務引用。

這種模式帶來的好處:

  • 開發時隻需要關心單一代碼倉庫,提高開發效率。
  • 所有服務的代碼都可以放在一起,大到一整個大功能的服務集,小到一個營運活動服務,通過合理的層級組織,都可以在 app 目錄下清晰維護。
  • 在修改公共代碼的時候,對所有依賴其的服務保證相容。即便是不相容,通過 IDE 提供的重構功能,能夠輕松地進行替換。

持續內建與建構

靜态檢查

項目使用 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(選擇任何喜歡的配置格式),并編寫基礎的配置,作為本地開發的時候使用。

# config.toml
port=3000
sentryDsn="https://[email protected]"


[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"`
}


//go:embed config.toml
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 對外暴露接口,每一個服務都由四部分組成:

  • 接口定義
  • 基于接口定義實作的具體調用代碼
  • 基于接口定義由 gomock 生成 mock 實作
  • 基于 proto 生成類型代碼

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.cn 拉取。
  • 針對私有倉庫,則可以通過代理直接從 GitHub 上拉取,并且 goproxy 也會代為處理好 GitHub 私有倉庫鑒權工作。

我們隻需要執行如下代碼即可通過内部 goproxy 下載下傳依賴:

GOPROXY="http://goproxy.infra:8081" \
GONOSUMDB="github.com/iftechio" \
go mod download           

Context

Context provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processes.

Context 是 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 内的上下文資訊。

Golang 在即刻後端的實踐

參考連結

  • 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

作者:sorcererxw

來源-微信公衆号:即刻技術團隊

出處:https://mp.weixin.qq.com/s/cepoYJR5Xeloan31-D1iQg