Go 服務端開發,請牢記這幾條

服務端開發一般是指業務的接口編寫,對大部分系統來說,接口中 CURD 的操作佔了絕大部分。然而,網絡上總有調侃 “CURD 工程師” 的梗,以說明此類開發技術並不複雜。但我個人認爲,如果僅僅爲了找個框架填充點代碼完成任務,確實是簡單,但是人類貴在是一根“會思考的蘆葦”,如果深入的思考下去,在開發過程中還是會碰到很多通用的問題的。

我們就用 go 的開發框架舉例子,它有兩種分化形式:一種以 beego 爲代表的,goframe 繼續發揚廣大的框架類型,它們的特點就是大而全,提供各種各樣的功能,你甚至不需要做多少選擇,反正按照文檔使用就是了。

它們的問題也就在於此,很多時候因爲封裝的太好了,很多問題都已經被無形地解決了(但不一定是最適合的解決方式)。另一種則以 gin、go-mirco 等框架爲代表,它們只解決特定一部分問題,使用它們雖然還有很多額外的工作要做,但是在之中也能學到更多的東西。接下來,詳細地看看 go 的服務端開發可能會碰到哪些問題:

1. 項目結構


無論是大項目還是小管理系統,萬里長征第一步,都是如何組織自己的項目結構。在項目結構這方面,go 其實沒有一個固定的準則,因此可以根據實際情況,靈活的組織。但我覺得,還是需要知道一些需要注意的點:

 1. 包名簡單,但要注意見名知意

這點在這篇文章中已經提到過了,用精煉的縮寫代替冗長的包名,並且 go 中也經常出現fmtstrconv等常用縮寫包,還有pkgcmd等。但是我覺得,相比於簡單,見名知意更重要。舉個例子,我曾接手一個項目,它的根目錄下就有一個mdw包,我開始還不知道這是幹嘛的,看到裏面放着一些 gin 的中間件才知道原來是middleware的縮寫。所以儘管 go 官方是推薦用一些約定俗成的、簡潔的包名,但是應該要加個前提,那就是在註釋中說明一下本包的作用,而註釋卻是在國內環境中,非常缺少的。所以與其生造一些縮寫,又不寫註釋,那還不如把包名寫的清楚一些。

 2. 使用 internal

使用 internal 有助於強制人思考,什麼應該放在公共包,什麼應該放在私有包,從而是項目結構更加清晰。而且 go 本身提供的包訪問權限沒有 java 那麼詳細,只有公開和私有這兩種狀態,更應該用 internal 來補充一下。

 3. 不要隨便使用 init

說實話,我對爲什麼沒有對 init 做任何限制還是有些疑慮的,這也就是說,你依賴的某些庫可以先於你的程序代碼運行,你也不知道它會做什麼事(任何代碼都可以在 init 中執行)。這在那種依賴非常多,又有很多間接依賴的大型項目中體現的很明顯。

儘管 go 官方要求不要在 init 中執行任何複雜的邏輯,但是這沒有任何約束力。最簡單的例子就是單元測試,我有時候跑單元測試經常會碰到 panic 跑不起來,究其原因就是某些依賴庫 init 中做了一些騷操作。但問題是:我是依賴的依賴(間接依賴)了這個庫,我也沒法控制它的代碼(沒有修改權限)。

碰到這種情況,也只能在單元測試中完成它的要求才能繼續運行。所以把代碼放在 init 中,一定要三思。就我來看,很多用 init 的代碼確實在做初始化,但它們內部隱式依賴了文件、路徑、資源等。這種情況要想一想,是不是可以用 NewXX() \ InitXX() 這種函數來替代。

 4. 慎用 util \ common 這種包名

這種一般是 java 程序員轉過來的用的比較多,但其實在 go 中,是推薦有意義的包名來替代這種無意義的包名的。比如:util.NewTimeHelper() 就不好,應該寫成time_helper.New() 這樣可讀性強一點。但是我覺得具體情況還得具體分析,所以標題是慎用,而不是不用。因爲有些時候,你的 util \ common 也就幾個幫助函數,沒多少東西。再細分成幾個包感覺有點得不償失了,等 util \ common 再攢多一點再重構也不遲。所以還是回到開頭提到的,多思考,靈活處理。但是這裏又要注意了,如果是那種被很多人依賴的公共 util \ common,最好還是早點拆分,不然後期可能拆不動了。

2. 代碼結構


代碼結構上能說道的東西就更多了,這可能是見軟件設計功底的地方,我在這方面也是初學者,所以總結出來的可能對,可能不對,僅供參考。

 1. c \ s \ d 層的劃分

得益於 MVC 的流行,即使現在已經普及了前後端分離的架構,大部分項目也仍然在內部存在 controllerorhandlerserviceorsvcdaoorrepository這樣的劃分。用於隔離數據展示、邏輯處理、數據存取的邏輯。這裏記錄下我對這三層劃分的理解:controller:一般是入口控制器,做參數接收、轉換,返回值處理、協議處理等工作。這一層一般不會太厚,也就是不會有太多的邏輯。這裏要注意其與網關(Gateway)的區別,網關要做的事和能做的事會比它多很多。

然後就是有些項目會把參數校驗放在這一層,個人認爲參數校驗應該使用一些框架如validator來做,不要重複造輪子,如果需要訪問數據庫來校驗參數,就應該放在service層做。service:這層可能會比較重,也是很考驗設計功力的地方,一不留神,就容易把這層變得耦合性極高。我也曾見過在service層中直接寫 sql 查詢的操作,十分讓人頭疼。

總的來說,因爲這一層承上啓下,儘量讓它成爲一個粘合劑,而不是全能選手。dao:這層就是跟數據相關了,其實就是把 service 層對數據的直接操作(操作數據庫、redis),變成對方法的調用。以屏蔽數據庫的差異,同時也可以做一些統一的數據處理。

一般來說我們的項目會使用 orm,這層也可以對 orm 進行一次封裝,從而更易使用。由於這層更多的是對數據的通用化處理,所以一般通過代碼生成器生成比較方便,比如:gormt。

 2. 依賴的傳遞

這裏的依賴指的是controllerservicedao層三者的依賴,一般來說,controller需要調用serviceservice需要調用dao。最忌諱的事是,因爲上層需要下層,所以在上層中調用創建下層的代碼,比如在controller的構造函數(就是 NewXX,Go 中沒有專門設置構造函數)中調用NewService,這顯然不符合單一職責的設計原則。所以一般有兩種處理方式:一、 設立全局變量

var XX *XXService = &XXService{}

type XXService struct{
}

func (x *XXService) XX() {
}

這樣,其他層直接調用這個全局變量即可。方便固然是方便,也容易帶來兩個問題:
1 \ 隨意調用 這樣不但上層可以調用,下層也可以調用。其實到處都能調的到,這樣就很容易導致無法管理,尤其是在多人共同協作的項目中。2. 不能在 XXService 中持有字段 持有字段必然涉及到如何初始化,要是放在 init 中,上面已經講過,跑在 init 中是不太好的。如果設置一個NewXX()函數,那就不必設置這個全局變量了。二、 設置 NewXX() 函數,通過依賴注入框架管理

type XXService struct{
    xRepo XXRepo
}

func NewXXService(r *XXRepo) *XXService {

}

然後通過依賴注入框架管理這些構造函數

// wire.Build(repo.NewGoodsRepo, svc.NewGoodsSvc, controller.NewGoodsController)
// wire 框架自動生成
func initControllers() (*Controllers, error) {
    goodsRepo := repo.NewGoodsRepo()
    goodsSvc := svc.NewGoodsSvc( goodsRepo)
    goodsController := controller.NewGoodsController(goodsSvc)
    return goodsController, nil
}

這裏,wire 框架遠沒有 java 中的依賴注入框架那麼牛逼,實際上,就是幫我們省了自己編寫的麻煩。這樣 Controller 就持有了 Service 對象,Service 就持有了 Repo 對象。而且,只有註冊過的才能持有,避免了管理混亂的問題。

 3. 儘量避免全局變量

說到全局變量的問題,就不能不單獨拿出來仔細說說。全局變量最典型的例子,就是 logger 了,衆所周知,go 提供的 log 包不是很好用,所以一般我們都會用一些開源的 logger 實現,很多實現都會提供一個默認的 log(defaultLogger),方便使用,這本來也沒什麼。但是因爲 go 本身錯誤處理提倡就地處理,也就是熟悉if err != nil,這就要命了。在一些大型項目中,可能很多函數、庫的編寫者,判斷了錯誤後,順手來個 log 打印出來;又或者不規範的到處打印調試日誌。這就導致我們在看程序日誌時,可能會看到大量的垃圾日誌,比如:一個錯誤被打印多次;或者打印的淨是些沒用地廢話。每天的 log 數量可能就高達十幾個 G,不知道還以爲業務有多繁忙,實際上,全是些廢話日誌。在這方面做的比較好的就是zap,它就沒有這種全局 logger,你必須 New 一個對象出來,這就逼迫你思考,怎麼保存這個對象?怎麼把它傳到需要打印日誌的地方?但可惜,我也見過直接把 zap 日誌對象賦給全局變量,然後繼續瘋狂使用的。在使用 gorm 的時候,也會碰到這種問題,把返回的gorm.DB對象丟到全局變量,然後到處使用,這和 logger 會導致的問題也是一樣的。

3. 可觀察性的處理


可觀察性是指日誌、鏈路追蹤、監控這三者的有機結合,具體的知識可以見附錄的一些參考資料。可觀察性其實也是在 jaeger、prometheus 流行後爲大衆所知,雖然歷史不太久遠,但是其重要性是不言而喻的。有效的解決了當線上服務出現問題時,快速的發現和定位具體位置,無論是大項目、小項目,微服務架構或者單體架構,都是非常必要的一環。具體的配合上來說,首先可以通過 prometheus 這樣的監控服務,及時發現服務存在異常,然後通過 jaeger+log 配合,尋找問題發生時的上下文,從而快速定位。

以前我未用過鏈路追蹤、監控這些技術時,只靠打 log,很多線上錯誤不能及時發現或者不能復現,其實是比較可惜的。但是要實現可觀察性,多多少少都要改造一些代碼,完全無侵入的改造是不可能的,所以在項目設計階段,就可以考慮這方面的事情。

從侵入性來說,監控 <鏈路追蹤 < 日誌。監控的侵入性最小,如接入 prometheus,只要運行一個 sidecar 線程,處理 prometheus 的拉取請求即可,但是程序也要預先寫好收集監控指標的代碼;鏈路追蹤更多的是要深入項目的各個層,比如:controller->service->dao->db,這樣才能跟蹤到整條請求鏈路;日誌的侵入性肯定最大了,都是在業務代碼中打印的。

 1.  db\redis\log 鏈路追蹤處理

鏈路追蹤的核心就是 context 了,在請求的開頭生成一個追蹤的上下文,各層來處理和傳遞這個 context,如果是項目內部的代碼,可以從 context 中解析出 span,然後打印數據到 span 中。但是對於項目依賴的一些庫 (gorm\zap\redis 等),如果想把鏈路追蹤到這些庫的內部,這裏就有兩種處理方式:

  1. 庫本身就支持傳遞 context 比如:gorm 就可以傳遞 context 進去,它雖然不能幫你解析這個上下文,但是提供了 hook 能力,可以自己編寫一個 plugin 拿到這個上下文,自己處理即可。或者是 go-micro 這種框架,自動就處理了 context 的解析工作。
// gorm示例
    // 使用插件
    err = db.Use(NewPlugin(WithDBName(dbName)))
    if err != nil {
        return nil, err
    }

    // 查詢
    DB.WithContext(ctx).Find()
  1. 庫不支持傳遞 context 或者是庫支持傳遞 context,但是沒提供 hook 能力。這種由於我們不能修改庫的代碼,又不能 hook 它內部的關鍵操作,就需要通過代理模式來接管對庫的訪問,比如:go-redis
type Repo interface {
    Set(ctx context.Context, key, value string, ttl time.Duration, options ...Option) error
    Get(ctx context.Context, key string, options ...Option) (string, error)
    TTL(ctx context.Context, key string) (time.Duration, error)
    Expire(ctx context.Context, key string, ttl time.Duration) bool
    ExpireAt(ctx context.Context, key string, ttl time.Time) bool
    Del(ctx context.Context, key string, options ...Option) bool
    Exists(ctx context.Context, keys ...string) bool
    Incr(ctx context.Context, key string, options ...Option) int64
    Close() error
}

type cacheRepo struct {
    client *redis.Client
}

cacheRepo 就是一個代理,內部持有私有的 redis.Client 對象,這樣就能在SetGet等時候,方便的做鏈路追蹤的處理。這裏展示一個Get方法的例子:

func (c *cacheRepo) Get(ctx context.Context, key string, options ...Option) (string, error) {
    var err error
    ts := time.Now()
    opt := newOption()
    defer func() {
        if opt.TraceRedis != nil {
            opt.TraceRedis.Timestamp = time_parse.CSTLayoutString()
            opt.TraceRedis.Handle = "get"
            opt.TraceRedis.Key = key
            opt.TraceRedis.CostSeconds = time.Since(ts).Seconds()
            opt.TraceRedis.Err = err

            addTracing(ctx, opt.TraceRedis)
        }
    }()

    for _, f := range options {
        f(opt)
    }

    value, err := c.client.Get(ctx, key).Result()
    if err != nil {
        err = werror.Wrapf(err, "redis get key: %s err", key)
    }
    return value, err
}

 2.  中間件

這裏的中間件指的是擴展 go 原生的 http 框架,從而支持請求方法鏈的三方框架,比如:gin、negroni 等,這樣我們就可以在一個請求的前後插入處理邏輯,比如:panic-recover、鑑權等。前面說到需要在請求的開頭生成追蹤的上下文,這個功能就可以在中間件來完成(如果是微服務架構,就應該在入口網關處就生成好)。生成的追蹤上下文,可以直接傳到 log 中,這樣後續請求鏈路中打印的 log,就全部帶上了 TraceID,方便追蹤。同時,請求的監控指標(QPS、響應時長等)也可以放在這個中間件中一起完成。

4. 錯誤處理


 1. Response Error 處理

一般來說,我們的接口返回都習慣返回一個錯誤碼,用來處理一些業務邏輯錯誤。這個錯誤碼有別於 HTTP 狀態碼,一般都是自己定義的一個錯誤碼錶,但是從標準性來考慮,我們在返回時還是要兼容一些常用的 HTTP 狀態碼的(400、404、500)等等。這樣,我們的 Response Error 就需要以下這些能力:

  1. 隱藏程序錯誤,尤其是 panic 程序錯誤,尤其是 panic,一旦拋出,容易讓人分析出系統內部的實現細節,所以要注意隱藏。尤其是很多 web 框架會自動 recover panic,然後打印出去。

  2. 可以方便的定義 HTTP 狀態碼、錯誤碼 這裏的方便指的是可以在 service 層指定返回的狀態碼、錯誤碼,因爲只有 service 層可以掌控全局。

實現起來很簡單,只需實現以下五個方法:

// 根據狀態碼、錯誤碼、錯誤描述創建一個Error
func NewError(httpCode, businessCode int, msg string) Error {}
// 狀態碼默認200,根據錯誤碼、錯誤描述創建一個Error
func NewErrorWithStatusOk(businessCode int, msg string) Error {}
// 狀態碼默認200,根據錯誤碼創建一個Error(錯誤描述從 錯誤碼錶 中獲取)
func NewErrorWithStatusOkAutoMsg(businessCode int) Error {}
// 根據狀態碼、錯誤碼創建一個Error
func NewErrorAutoMsg(httpCode, businessCode int) Error {}
// 把內部的err放到 Error 中
func (e *err) WithErr(err error) Error {}
複製代碼

這個 Error 結構,就封裝了狀態碼、錯誤碼、錯誤描述和真正的 error。使用時,示例代碼如下:

func (s *GoodsSvc) AddGoods(sctx core.SvcContext, param *model.GoodsAdd) error {
...
    if err != nil{
        return response.NewErrorAutoMsg(
            http.StatusInternalServerError,
            response.ServerError,
        ).WithErr(err)
    }
複製代碼

 2. Go 的錯誤處理

既然寫到了錯誤碼,就不得不提一下 Go 中的錯誤處理,錯誤處理一直是 Go 中爭議比較大的地方,就我們的日常開發而言,最常碰到的問題有三個,而 Go 的官方,也只是在 1.13 版本解決了其中一個。

  1. 錯誤如何在各層傳遞 由於在 Go 中,錯誤實際上只能包含一個字符串,所以對 Go 的程序員來說,如果要在錯誤中添加些額外信息,可能只能讓原來的錯誤消失掉。到了 1.13 版本,通過fmt.Errorf支持了錯誤包裝,纔算解決了這一需求。

  2. 如何獲取到錯誤當時的堆棧 但其實在日常開發中,還有一種是用的比較多的,那就是收集發生錯誤時的堆棧。這種堆棧信息的缺失對 Go 的新手們不太友好。試想兩種情況:

    func Set(key string, value string) error{
        ...
        return err 
    }
    複製代碼

    一個通用的 Set 函數,出現錯誤並返回時,如果它的上層調用者沒有處理好,這個錯誤直接再往上拋,很容易就導致這個錯誤無法追溯到源頭(就是知道這個錯是 Set 拋出來,但是不知道是誰調用 Set 的)。第二種情況就是在內部處理時:

    func DoSomething(key string, value string) error{
        ...
        err := io.Read()
        if err != nil{
            return fmt.Errors("Read: %w",err)
        }
        ...  
        err = io.Read()
        if err != nil{
            return fmt.Errors("Read2: %w",err)
        }
    }
    複製代碼

    可以看到,一個函數內調用了多次函數(比如,調用 Read 讀取文件),爲了區分 err,我們得包裝每個錯誤,不然,你根本不知道是哪次的 Read 拋出來的問題。但是,這樣寫代碼還是有些噁心,而且 Go 本身也沒有什麼好的處理方式。開源社區提供了很多錯誤包,比如:

    github.com/pkg/errors

    ,就是用來爲錯誤加入堆棧,從而方便的解決以上問題的。可惜,1.13 的錯誤處理雖然參考了開源實現,但沒有收錄堆棧這個特性。

  3. 如何聚合多個錯誤 在一些特殊的場景,如在一連串的處理邏輯中,雖然發生錯誤,但是並不想中斷邏輯,只是想記錄下錯誤,然後繼續。舉個例子:有一個需求,統計系統每天晚上需要訪問訂單、商品、用戶系統,從各個系統中拉取一些統計數據。我們可能會在統計系統中設置個定時任務,每晚去拉取即可,當拉取失敗時,記錄錯誤,並且繼續拉取下一個。

    func StartPull(){
        var errMulti error
        for i := range systems{
            if err := Pull(systems[i]); err != nil{
                errMulti = multierror.Append(errMulti, err)
            }
        }
    }
複製代碼

這裏我們使用了github.com/hashicorp/go-multierror 這個庫處理,其實就是個 []error 數組。這裏要注意的就是聚合多個錯誤和錯誤包裝的區別,聚合錯誤時,多個錯誤間可以沒有任何聯繫;錯誤包裝多個錯誤間有上下級的關聯。

5. dao 層的處理


上面提到了 c / s / d 層的劃分,以及各層的作用,這裏想詳細的講下 dao 層。

 1. 自動生成代碼

其實寫多了 dao 層的代碼就會發現,其中很多的代碼都是通用化的,比如 CURD 的代碼。無非是表的變動,邏輯都是一樣的。這種代碼非常適合使用生成器自動生成,比如:gormt就可以自動生成 gorm 的 CURD 操作,用來寫一些小系統非常輕鬆。

 2. 字段的隱藏

如果直接把數據庫的表名、字段名寫在代碼裏,肯定是不太好的實踐。一般我們都要用一些結構體、常量來代替直接寫在代碼中。這方面,也可以通過工具來做自動生成,無需手動編寫。比如:gormtgopo等。有了自動生成的字段名,加上強大的GORM V2框架,我們的 dao 層就能精簡的提供上層需要的功能。

func FindBy(id int, columns ...string){
    db.Select(colums).Find(&SomeTable{},id)
}
// 只取 ID Name 字段
bean := FindBy(1,dao.Colums.ID, dao.Colums.Name)
// 只取 Status 字段
bean := FindBy(2,dao.Colums.Status)
複製代碼

示例代碼舉了個簡單的例子,通過一個FindBy函數,上層調用可以自由的控制自己想要獲得的字段,並且不用暴露底層數據庫的字段名。對於CreateUpdate等,也可以起到同樣的控制效果,這裏不再贅述。

 3. 更新字段

由於 go 的零值規定(0\false""),字段的更新是一個容易出現問題的點。(我就經常在這上面寫出 bug)按道理說,我們提供的更新功能,應該滿足一下幾點:

  1. 數據庫零值跟 go 的零值對應 在數據庫的設計中,一般都提倡不使用 NULL 字段,而用默認值代替,這個默認值一般就是該類型的零值,這樣跟 go 也是對應的。

  2. 按需更新 這裏指的是接口設計時,規定傳來的字段一定是要更新的字段,不需要更新的字段就不要出現。舉個例子:

    // PUT /score
    {
        "id": 1,
        "name""張三",
        "score": 100,
        "create_time""2021-12-12"
    }

    通過以上 json,我們創建一條數據,這時該如何設計更新接口?

    // POST /score
    {    
        "id": 1,
        "name""不對,我叫李四",
        "score": 0,
        "create_time"""
    }

    如上,假如規定零值字段不更新,只有非零值的字段參與更新。那麼,如果用戶真的想把

    score

    字段更新爲 0 怎麼辦,實際上,零值和空值就產生了歧義。

    // POST /score
    {
        "id": 1,
        "name""我叫李四"
    }

    所以爲了避免歧義,我們規定不更新的字段禁止出現在 json 中。同樣的,在 json 轉爲 go 中的結構體時,我們也只能通過指針來接收:

    type UpdateScore struct{
        Id int 
        Name *string
        Score *int
        CreateTime *string
    }

    不用指針接收,你還是沒法判斷,結構體中的 Score 等於 0,到底是真的要設置爲 0,還是 json 中未傳。雖然用指針看着就有點複雜了,但沒辦法,零值和空值的衝突問題,只能這樣解決。或者,不用指針,我就指定更新接口的所有字段都必須傳,全部更新到數據庫(或者從數據庫中讀原始數據,對比有修改的字段就更新)。

    // POST /score
    {    
        "id": 1,
        "name""不對,我叫李四",
        "score": 100,
        "create_time""2021-12-12"
    }

    可這樣一來,還得讀一次數據來對比,而且這個更新 的 json 會變得很大,傳了很多冗餘數據,我個人覺得更加的不可行。

6. 附錄


雖然總結了 5 點事項,但我覺得,還有很多小知識點,其它的方面,可能不是一篇文章能寫的盡的。若後期遇到或者碰到,也會繼續總結出來。畢竟古人云,“學而時習之,不亦說乎”。沒有總結,就看不到問題的全貌。附上一些參考資料:

原文鏈接:https://juejin.cn/post/7043587400131411976
作者:FengY_HYY

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mNgRK56qNI2iLRaomrvqiA