Reviewbot 開源 | 這些寫 Go 程式碼的小技巧,你都知道嗎?

Changjun Ji發表於2024-12-12

Reviewbot 是七牛雲開源的一個專案,旨在提供一個自託管的程式碼審查服務, 方便做 code review/靜態檢查, 以及自定義工程規範的落地。


自從上了 Reviewbot 之後,我發現有些 lint 錯誤,還是很容易出現的。比如

dao/files_dao.go:119:2: `if state.Valid() || !start.IsZero() || !end.IsZero()` has complex nested blocks (complexity: 6) (nestif)
cognitive complexity 33 of func (*GitlabProvider).Report is high (> 30) (gocognit)

這兩個檢查,都是圈複雜度相關。

圈複雜度(Cyclomatic complexity)是由 Thomas McCabe 提出的一種度量程式碼複雜性的指標,用於計算程式中線性獨立路徑的數量。它透過統計程式控制流中的判定節點(如 if、for、while、switch、&&、|| 等)來計算。圈複雜度越高,表示程式碼路徑越多,測試和維護的難度也就越大。

圈複雜度高的程式碼,往往意味著程式碼的可讀性和可維護性差,非常容易出 bug。

為什麼這麼說呢?其實就跟人腦處理資訊一樣,一件事情彎彎曲曲十八繞,當然容易讓人暈。

所以從工程實踐角度,我們希望程式碼的圈複雜度不能太高,畢竟絕大部分程式碼不是一次性的,是需要人來維護的。

那該怎麼做呢?

這裡我首先推薦一個簡單有效的方法:Early return

Early return - 邏輯展平,減少巢狀

Early return, 也就是提前返回,是我個人認為最簡單,日常很多新手同學容易忽視的方法。

舉個例子:

func validate(data *Data) error {
    if data != nil {
        if data.Field != "" {
            if checkField(data.Field) {
                return nil
            }
        }
    }
    return errors.New("invalid data")
}

這段程式碼的邏輯應該挺簡單的,但巢狀層級有點多,如果以後再複雜一點,就容易出錯。

這種情況就可以使用 early return 模式改寫,把這個巢狀展平:

func validate(data *Data) error {
    if data == nil {
        return errors.New("data is nil")
    }
    if data.Field == "" {
        return errors.New("field is empty")
    }
    if !checkField(data.Field) {
        return errors.New("field validation failed")
    }
    return nil
}

是不是清晰很多,看著舒服多了?

記住這裡的訣竅:如果你覺得順向思維寫出的程式碼有點繞,且巢狀過多的話,就可以考慮使用 early return 來反向展平。

當然,嚴格意義上講,early return 只能算是一種小技巧。要想寫出高質量的程式碼,最重要的還是理解 分層、組合、單一職責、高內聚低耦合、SOLID 原則等 這些核心設計理念 和 設計模式了。

Functional Options 模式 - 引數解耦

來看一個場景: 方法引數很多,怎麼辦?

比如這種:

func (s *Service) DoSomething(ctx context.Context, a, b, c, d int) error {
    // ...
}

有一堆引數,而且還是同型別的。如果在呼叫時,一不小心寫錯了引數位置,就很麻煩,因為編譯器並不能檢查出來。

當然,即使不是同型別的,引數多了可能看著也不舒服。

怎麼解決?

這種情況,可以選擇將引數封裝成一個結構體,這樣在使用時就會方便很多。封裝成結構體後還有一個好處,就是以後增刪引數時(結構體的屬性),方法簽名不需要修改。避免了以前需要改方法簽名時,呼叫方也需要跟著到處改的麻煩。

不過,在 Go 語言中,還有一種更優雅的解決方案,那就是Functional Options 模式

不管是 Rob Pike 還是 Dave Cheney 以及 uber 的 go guides 中都有專門的推薦。

  • https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
  • https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
  • https://github.com/uber-go/guide/blob/master/style.md#functional-options

這種模式,本質上就是利用了閉包的特性,將引數封裝成一個匿名函式,有諸多妙用。

Reviewbot 自身的程式碼中,就有相關的使用場景 (https://github.com/qiniu/reviewbot/blob/c354fde07c5d8e4a51ddc8d763a2fac53c3e13f6/internal/lint/providergithub.go#L263),比如:

// GithubProviderOption allows customizing the provider creation.
type GithubProviderOption func(*GithubProvider)
func NewGithubProvider(ctx context.Context, githubClient *github.Client, pullRequestEvent github.PullRequestEvent, options ...GithubProviderOption) (*GithubProvider, error) {
    // ...
    for _, option := range options {
        option(p)
    }
    // ...
    if p.PullRequestChangedFiles == nil {
        // call github api to get changed files
    }
    // ...
}

這裡的 options 就是 functional options 模式,可以靈活地傳入不同的引數。

當時之所以選擇這種寫法,一個重要的原因是方便單測書寫。

為什麼這麼說呢?

看上述程式碼能知道,它需要呼叫 github api 去獲取 changed files, 這種實際依賴外部的場景,在單測時就很麻煩。但是,我們用了 functional options 模式之後,就可以透過 p.PullRequestChangedFiles 是否為 nil 這個條件,靈活的繞過這個問題。

Functional Options 模式的優點還有很多,總結來講 (from dave.cheney):

  • Functional options let you write APIs that can grow over time.
  • They enable the default use case to be the simplest.
  • They provide meaningful configuration parameters.
  • Finally they give you access to the entire power of the language to initialize complex values.

現在大模型相關的程式碼,能看到很多 functional options 的影子。比如
https://github.com/tmc/langchaingo/blob/238d1c713de3ca983e8f6066af6b9080c9b0e088/llms/ollama/options.go#L25

type Option func(*options)
// WithModel Set the model to use.
func WithModel(model string) Option {
    return func(opts *options) {
        opts.model = model
    }
}
// WithFormat Sets the Ollama output format (currently Ollama only supports "json").
func WithFormat(format string) Option {
    // ...
}
//  If not set, the model will stay loaded for 5 minutes by default
func WithKeepAlive(keepAlive string) Option {
    // ...
}

所以建議大家在日常寫程式碼時,也多有意識的嘗試下。

善用 Builder 模式/策略模式/工廠模式,消弭複雜 if-else

Reviewbot 目前已支援兩種 provider(github 和 gitlab),以後可能還會支援更多。

而因為不同的 Provider 其鑑權方式還可能不一樣,比如:

  • github 目前支援 Github APP 和 Personal Access Token 兩種方式
  • gitlab 目前僅支援 Personal Access Token 方式

當然,還有 OAuth2 方式,後面 reviewbot 也也會考慮支援。

那這裡就有一個問題,比如在 clone 程式碼時,該使用哪種方式?程式碼該怎麼寫?使用 token 的話,還有個 token 過期/重新整理的問題,等等。

如果使用 if-else 模式來實現,程式碼就會變得很複雜,可讀性較差。類似這種:

if provider == "github" {
    // 使用 Github APP 方式
    if githubClient.AppID != "" && githubClient.AppPrivateKey != "" {
        // 使用 Github APP 方式
        // 可能需要呼叫 github api 獲取 token
    } else if githubClient.PersonalAccessToken != "" {
        // 使用 Personal Access Token 方式
        // 可能需要呼叫 github api 獲取 token
    } else {
        return err
    }
} else if provider == "gitlab" {
    // 使用 Personal Access Token 方式
    if gitlabClient.PersonalAccessToken != "" {
        // 使用 Personal Access Token 方式
        // 可能需要呼叫 gitlab api 獲取 token
    } else {
        return errors.New("gitlab personal access token is required")
    }
}

但現在 Reviewbot 的程式碼中,相關程式碼僅兩行:

func (s *Server) handleSingleRef(ctx context.Context, ref config.Refs, org, repo string, platform config.Platform, installationID int64, num int, provider lint.Provider) error {
    // ...
    gb := s.newGitConfigBuilder(ref.Org, ref.Repo, platform, installationID, provider)
    if err := gb.configureGitAuth(&opt); err != nil {
        return fmt.Errorf("failed to configure git auth: %w", err)
    }
    // ...
}

怎麼做到的呢?

其實是使用了 builder 模式,將 git 的配置和建立過程封裝成一個 builder,然後根據不同的 provider 選擇不同的 builder,從而消弭了複雜的 if-else 邏輯。

當然內部細節還很多,不過核心思想都是將複雜的邏輯封裝起來,在主互動邏輯中,只暴露簡單的使用介面,這樣程式碼的可讀性和可維護性就會大大提高。

最後

到底如何寫出高質量的程式碼呢?這可能是很多有追求的工程師,一直在思考的問題。

在我看來,可能是沒有標準答案的。不過呢,知道一些技巧,並能在實戰中靈活運用,總歸是好的。

你說是吧?

相關文章