Go 代碼整潔之道

痛點:

  1. 工程剛開始非常整潔,隨著時間的流逝,逐漸變得不太好維護了..
  2. 多人開發(fā)同一工程時,架構層次不清晰,重復造輪子?
  3. 接手了一個舊工程,如何快速理解架構與設計,從而快速上手做需求?

有規(guī)范的好處:

  1. 利于多人合作開發(fā)&理解同一模塊/工程。
  2. 降低團隊成員之間的代碼溝通成本。
  3. 架構&代碼規(guī)范明確,有效提高編碼效率。

前言:

讀這本書的時,第一個想到的問題就是:“什么是整潔的代碼?”
書中列舉了各位程序員鼻祖的名言,我整理總結了下,大概有下面幾條:

  • 邏輯直截了當,令缺陷難以隱藏 。
  • 減少依賴關系,便于維護。
  • 合理分層,完善錯誤處理 。
  • 只做好一件事。沒有重復代碼。

代碼是團隊溝通的一種方式

工作的溝通,不只是每天lark拉群或者開會交流,代碼也是我們很重要的溝通方式之一。

用代碼寫完需求,只是萬里長征的第一步。我們要用代碼表達自己的設計思想。如果我們團隊大部分人都能按照一定規(guī)范、思路去寫代碼。那么,工作溝通成本會降低許多。
比如:某位同學之前負責的一個模塊,被另一位同事接手了,或者隨著業(yè)務的擴張,我們多個同學共同開發(fā)同一個工程/模塊。如果我們的代碼結構大同小異,分層清晰、注釋合理,就會降低很多溝通成本。

因此,我們需要為團隊創(chuàng)造整潔的代碼。

一是降低團隊內的代碼溝通成本,二是便于今后項目需求的維護與迭代。

讓營地比來時更整潔

隨著需求的不斷迭代,保持代碼整潔、工程更易理解。

有時候,我們會維護一些老項目,或者交接過來的項目。代碼可能不太美觀,工程可能不太好理解。

一般我們會面臨兩種選擇:

  1. 重構
  2. 優(yōu)化迭代

重構的成本比較高,得先理解原有邏輯,再進行重新設計落地。代價大,周期長,短期看不到效果。

在人力有限的情況下。我們一般會先選擇“優(yōu)化迭代”。

這時候,我們每做一個新需求 / 修復一個bug時,我們要盡可能的去小范圍“重構”。

每一次Merge,代碼都比之前更干凈,工程變得更好理解。那么,我們的工程就不會變的更糟。

清理不一定要花多少功夫。也許只是改一個更加容易理解的命名;抽象一個函數(shù),消除一點重復/冗余代碼;處理一下嵌套的 if / else 等等。

一、有意義的命名

名副其實:
起有意義的名字,讓人一目了然。
一看這個變量,就能知道它存儲的是什么對象。
一看這個方法,就能知道它處理的是什么事。
一看這個包名,就能知道它負責處理哪個模塊。

看看反例:

var array []int64
var theList []int64
var num int64

看看正例:

var mrList []*MRInfo
var buildNum int64

避免誤導:
不要用太長或者很偏僻的單詞來命名,也不要用拼音代替英文。
更不要用容易混淆的字母(字母+數(shù)字)。尤其是lO兩個字母,和數(shù)字1和0太像了。

看看反例:

func getDiZhi() string {
   // ..
}

func modifyPassword(password1, password2 string) string {
   // ..
}

看看正例:

func getAddress() string {
   // ..
}

func modifyPassword(oldPassword, newPassword string) string {
   // ..
}

有意義的區(qū)分:
聲明兩個同類型的變量/函數(shù),需要用有明確意義的命名加以區(qū)分。

看看反例:

var accountData []*Account
var account []*Account

func Account(id int) *Account {
    // ...
}

func AccountData(id int) *Account {
    // ...
}

可讀可搜索:
起可讀的,可以被搜索的名字。

看看反例:

var ymdhms = "2021-08-04 01:55:55"
var a = 1

看看正例:

var date = "2021-08-04 01:55:55"
var buildNum = 1

命名規(guī)范(重點)

package

  • 同一項目下,不允許出現(xiàn)同名的package。
  • 只由小寫字母組成。不包含大寫字母和下劃線等字符。
  • 簡短并包含一定的上下文信息。例如time、http等。
  • 不能是含義模糊的常用名,或者與標準庫同名。例如不能使用util或者strings。
  • 包名能夠作為路徑的 base name,在一些必要的時候,需要把不同功能拆分為子包。(例如應該使用encoding/base64而不是encoding_base64或者encodingbase64。)

以下規(guī)則按照先后順序盡量滿足

  • 不使用常用變量名作為包名。
  • 使用單數(shù)而不是復數(shù)。(關鍵字除外,例如consts
  • 謹慎地使用縮寫,保證理解。

文件名

  • 文件名都使用小寫字母,且使用單數(shù)形式,如需要可使用下劃線分割。

函數(shù)和方法

Function 的命名應該遵循如下原則:

  • 對于可導出的函數(shù)使用大寫字母開頭,對于內部使用的函數(shù)使用小寫字母開頭。
  • 若函數(shù)或方法為判斷類型(返回值主要為 bool 類型),則名稱應以 has, is, can 等判斷性動詞開頭。
// HasPrefix tests whether the string s begins with prefix.
func HasPrefix(s, prefix string) bool {...
  • 函數(shù)采用駝峰命名,不能使用下劃線,不能重復包名前綴。例如使用http.Server而不是http.HTTPServer,因為包名和函數(shù)名總是成對出現(xiàn)的。
// WriteRune appends the UTF-8 encoding of Unicode code point r to b's buffer.
// It returns the length of r and a nil error.
func (b *Builder) WriteRune(r rune) (int, error) {...
  • 遵守簡單的原則,不應該像 ToString 這類的方法名,而直接使用 String 代替。
// String returns the accumulated string.
func (b *Builder) String() string {...
  • Receiver 要盡量簡短并有意義
    • 不要使用面向對象編程中的常用名。例如不要使用selfthis、me等。
    • 一般使用 1 到 2 個字母的縮寫代表其原來的類型。例如類型為Client,可以使用c、cl等。
    • 在每個此類型的方法中使用統(tǒng)一的縮寫。例如在其中一個方法中使用了c代表了Client,在其他的方法中也要使用c而不能使用諸如cl的命名。
func (r *Reader) Len() int {...

常量

  • 常量使用駝峰形式。(盡量不要用下劃線)
const AppVersion = "1.1.1"
  • 如果是枚舉類型的常量,需要先創(chuàng)建相應類型:
type Scheme string 

 const ( 
    HTTP  Scheme = "http" 
    HTTPS Scheme = "https" 
 )

變量

  • 變量命名基本上遵循相應的英文表達或簡寫。
  • 采用駝峰命名,不能使用下劃線。首字母是否大寫根據(jù)是否需要外部訪問來定。
  • 遇到專有名詞時,可以不改變原來的寫法。例如:
{ 
    "API":   true, 
    "ASCII": true, 
    "CPU":   true, 
    "CSS":   true, 
    "DNS":   true, 
    "EOF":   true, 
    "GUID":  true, 
    "HTML":  true, 
    "HTTP":  true, 
    "HTTPS": true, 
    "ID":    true, 
    "IP":    true, 
    "JSON":  true, 
    "LHS":   true, 
    "QPS":   true, 
    "RAM":   true, 
    "RHS":   true, 
    "RPC":   true, 
    "SLA":   true, 
    "SMTP":  true, 
    "SSH":   true, 
    "TLS":   true, 
    "TTL":   true, 
    "UI":    true, 
    "UID":   true, 
    "UUID":  true, 
    "URI":   true, 
    "URL":   true, 
    "UTF8":  true, 
    "VM":    true, 
    "XML":   true, 
    "XSRF":  true, 
    "XSS":   true, 
}

二、函數(shù)

短小

盡可能的縮短每個函數(shù)的長度。能抽象就抽象。
任何一個函數(shù)都不應該超過50行。甚至,20行封頂最佳。(PS:16寸mac滿屏是60多行)
想象下,如果有個幾百行,甚至上千行的函數(shù)。后面維護得多困難。

單參數(shù)

每個函數(shù)最理想應該是有0或1個入?yún)ⅰ?br> 盡量不要超過三個入?yún)?。如果超過,建議封裝成結構體。

只做一件事

函數(shù)應該只做一件事,做好這件事,只做這一件事。

抽象層級

按順序,自頂向下讀代碼/寫代碼。

看看反例:

// 更新組件升級結果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
   // 更新組件核心表,寫了20行

   // 更新歷史,寫了40行

   // 更新構建產(chǎn)物,寫了20行

   // ...代碼越來越多,越來越不好維護。

   return nil
}

看看正例:

// 更新組件升級結果
func UpdatePodUpgradeResult(ctx context.Context, req *UpdatePodReq) error {
   // 更新組件
   err = updatePodMain(ctx, req)
   if err != nil {
      return err
   }

   // 更新歷史
   err = updatePodHistory(ctx, req)
   if err != nil {
      return err
   }

   // 更新Builds
   err = updatePodBuilds(ctx, req)
   if err != nil {
      return err
   }

   return nil
}

func updatePodMain(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

func updatePodHistory(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

func updatePodBuilds(ctx context.Context, req *UpdatePodReq) error {
   // ...
}

盡量少嵌套 if / else

看看反例:

func GetItem(extension string) (Item, error) {
    if refIface, ok := db.ReferenceCache.Get(extension); ok {
        if ref, ok := refIface.(string); ok {
            if itemIface, ok := db.ItemCache.Get(ref); ok {
                if item, ok := itemIface.(Item); ok {
                    if item.Active {
                        return Item, nil
                    } else {
                      return EmptyItem, errors.New("no active item found in cache")
                    }
                } else {
                  return EmptyItem, errors.New("could not cast cache interface to Item")
                }
            } else {
              return EmptyItem, errors.New("extension was not found in cache reference")
            }
        } else {
          return EmptyItem, errors.New("could not cast cache reference interface to Item")
        }
    }
    return EmptyItem, errors.New("reference not found in cache")
}

看看正例:

func GetItem(extension string) (Item, error) {
    refIface, ok := db.ReferenceCache.Get(extension)
    if !ok {
        return EmptyItem, errors.New("reference not found in cache")
    }

    ref, ok := refIface.(string)
    if !ok {
        // return cast error on reference 
    }

    itemIface, ok := db.ItemCache.Get(ref)
    if !ok {
        // return no item found in cache by reference
    }

    item, ok := itemIface.(Item)
    if !ok {
        // return cast error on item interface
    }

    if !item.Active {
        // return no item active
    }

    return Item, nil
}

安全并發(fā)處理(SafeGo)

建議:開協(xié)程的地方,盡量使用SafeGo(內部有 recover 以及打印 panic 堆棧日志)

func SafeGo(ctx context.Context, f func()) {
   go func() {
      defer func() {
         if err := recover(); err != nil {
            content := fmt.Sprintf("Safe Go Capture Panic In Go Groutine\n%s", string(debug.Stack())){
               logs.CtxFatal(ctx, content)
            }
         }
      }()

      f()
   }()
}

For 循環(huán)并發(fā)處理(Routine Pool)

for 循環(huán)開協(xié)程時,優(yōu)先考慮使用封裝的 Routine Pool(協(xié)程池)控制并發(fā)量。

好處:

  1. 避免協(xié)程創(chuàng)建過多,導致程序崩潰。(對服務本身)
  2. 控制流量速度,防止把下游服務打雪崩。(對下游服務)

參考代碼:

type content struct {
    work func() error
    end  *struct{}
}

func work(w func() error) content {
    return content{work: w}
}

func end() content {
    return content{end: &struct{}{}}
}

// Goroutine routine_pool
type RoutinePool struct {
    capacity uint
    ch       chan content
}

func NewRoutinePool(ctx context.Context, capacity uint) *RoutinePool {
    ch := make(chan content)
    pool := RoutinePool{
        capacity: capacity,
        ch:       ch,
    }

    for i := uint(0); i < capacity; i++ {
        SafeGo(ctx, func() {
            for {
                select {
                case cont := <-ch:
                    if cont.end != nil {
                        return
                    }

                    if cont.work != nil {
                        if err := cont.work(); err != nil {
                            LogCtxError(ctx, "run work failed: %v", err)
                        }
                    }
                }
            }
        })
    }

    return &pool
}

func (pool *RoutinePool) Submit(w func() error) {
    pool.ch <- work(w)
}

func (pool *RoutinePool) Shutdown() {
    defer close(pool.ch)
    for i := uint(0); i < pool.capacity; i++ {
        pool.ch <- end()
    }
}

Copy 傳入?yún)f(xié)程的 Context

Gin:直接調用context.Copy()即可。

三、注釋與格式

注釋

  • 所有可導出的函數(shù)、類型、變量等都應該有注釋,注釋以函數(shù)名、類型名、變量名打頭,函數(shù)注釋建議同時包含參數(shù)和返回值的說明。
  • 每行注釋不超過100個字符。
  • 包、函數(shù)、方法和類型的注釋說明都是一個完整的句子。
  • 有具體方案文檔,在對應地方留下文檔鏈接注釋。便于后續(xù)快速了解這部分需求。

格式

這部分只要我們打開 Goland 相關配置,即可完成。

推薦配置

File Watcher 開啟 go fmt、go imports:

image

配置可以參考:https://www.jetbrains.com/help/go/using-file-watchers.html#enableFileWatcher

垂直格式:

每個文件從上到下的代碼規(guī)范。

一個文件,盡量不要超過 400 行。(超過可讀性會降低)

  1. 垂直方向的間隔

package聲明、導入聲明和每個函數(shù)之間都要有一個空行隔開。

  1. 垂直方向的靠近:

靠的越近的代碼,關系越緊密。

  1. 垂直距離:

變量聲明:盡可能靠近其使用的位置。
局部變量,聲明在函數(shù)頂部。
實體變量,聲明在類的頂部。

相關函數(shù):盡節(jié)能互相靠近,保證順序。

首先,應該放到一起。
其次,“調用”函數(shù)應該放到“被調用”函數(shù)的上面。

概念相關:做某類事情的函數(shù),應該放一起。

比如,一個 interface,它有 read/write 方法,他們應該放一起

  1. 垂直順序:

“調用”函數(shù)應該放到“被調用”函數(shù)的上面。
建立了一種自頂向下貫穿源代碼的良好信息流。

橫向格式:

每一行代碼從左到右的代碼規(guī)范。

每一行代碼,盡量不要超過 120 個字。(超過150字,一個屏幕就看不全了)

  1. 水平方向的間隔與靠近

操作符周圍加上空格。

  1. 水平對齊
type PodType string

const (
   PodTypeIOS      PodType = "iOS"
   PodTypeAndroid  PodType = "Android"
   PodTypeFlutter  PodType = "Flutter"
)
  1. 縮進

這部分 go-fmt 幫我們做了,只要集成 go-fmt 即可。

四、對象與數(shù)據(jù)結構

數(shù)據(jù)抽象成對象

以組件升級為例,將組件升級流程抽象成對象。不關心底層的數(shù)據(jù)結構與實現(xiàn)。

分析,組件升級流程需要:

  • ValidateParam(校驗參數(shù))
  • FormatParam(格式化參數(shù))
  • SendUpgradeRequest(觸發(fā)升級)
  • GenerateHistory(生成歷史)
  • UpdateHistory(更新歷史)
type mpaasRepoUpgradeHandlerType interface {
   ValidateParam(ctx context.Context) error                                                   //判斷某個升級請求,是否合法
   FormatUpgradeParam(ctx context.Context) error                                              //處理參數(shù),補充額外信息或者補上默認信息等等
   SendUpgradeRequest(ctx context.Context, history *podHistory) (int, error) //各 Handler 自行發(fā)送升級請求
   UpgradeHistory(ctx context.Context) *podHistory                           //生成升級歷史
   UpdateHistoryInfo(ctx context.Context) *podHistory                        //重試的時候要更新的組件升級歷史字段
   baseHandler() *podUpgradeBaseHandler                                                 //獲取 baseHandler
}

組件升級會分為多種:iOS、AndroidFlutter、Custom(構建腳本)、RubyGem等等..

不論哪種組件升級只要實現(xiàn)這套 interface,即可完成組件升級流程。

數(shù)據(jù) vs. 對象

對象:把數(shù)據(jù)隱藏于抽象之后,暴露操作數(shù)據(jù)的方法。

數(shù)據(jù):通過數(shù)據(jù)結構暴露處理。

面向過程(直接使用數(shù)據(jù)結構):
好處:在不改動既有數(shù)據(jù)結構的前提下,新增新函數(shù)。
壞處:難以增刪改數(shù)據(jù)結構。

面向對象(抽象):
好處:方便增刪改數(shù)據(jù)結構。
壞處:難以新增函數(shù),必須所有類改。

兩者沒有絕對的優(yōu)劣比較,需要 case by case 在具體場景下的應用。

得墨忒(tuī)耳律
模塊不應該了解它所操作對象的內部結構。
對象需要隱藏數(shù)據(jù),暴露操作。

五、錯誤處理

常規(guī)流程

  • 先看看反例:
package smelly
func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return Item{}, errors.New("item could not be found in the store") 
    }
    return item, nil
}

handler里如果要對特殊錯誤做特殊處理:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := smelly.GetItem("123")
    if err != nil {
        if err.Error() == "item could not be found in the store" {
            http.Error(w, err.Error(), http.StatusNotFound)
                return
        }
        http.Error(w, errr.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

  • 再看看正例:

提前在包里,定義好錯誤類型。

package clean

var (
    ErrItemNotFound = errors.New("item could not be found in the store") 
)

func (store *Store) GetItem(id string) (Item, error) {
    store.mtx.Lock()
    defer store.mtx.Unlock()

    item, ok := store.items[id]
    if !ok {
        return nil, ErrItemNotFound
    }
    return item, nil
}

handler里如果要對特殊錯誤做特殊處理:

func GetItemHandler(w http.ReponseWriter, r http.Request) {
    item, err := clean.GetItem("123")
    if err != nil {
        if errors.Is(err, clean.ErrItemNotFound) {
           http.Error(w, err.Error(), http.StatusNotFound)
                return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    } 
    json.NewEncoder(w).Encode(item)
}

好處:方便拓展,增加代碼可讀性。

六、邊界

我們的系統(tǒng)都微服務化了。

每個子服務都會存在自己的邊界。

我們需要盡量保證我們的服務邊界整潔。

邊界整潔

我們依賴的服務、庫、代碼是要可控的。
假如,我們依賴了一個不可控的庫。
如果他有一天被檢測出有安全問題、亦或 bug。
我們就很被動,導致服務需要大改。

簡單來說,依賴我們能控制的東西,好過依賴我們控制不了的東西。
免得日后被控制,導致重寫或修改。

層級架構明確

屬于同一層的服務,最好只依賴下層服務。
理論上來說,不該依賴同層服務,更不應該依賴上層服務。

每個團隊/業(yè)務的架構圖應該要梳理出來。

模塊職責明確

其實,不光服務于服務之間要有層級架構。
我們服務內部應該也需要按照層級來寫代碼。
另外,每個工程的 ReadMe,最好能闡述下大概設計思路和架構,便于協(xié)作開發(fā)。

參考資料:

clean-go-article

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容