痛點:
- 工程剛開始非常整潔,隨著時間的流逝,逐漸變得不太好維護了..
- 多人開發(fā)同一工程時,架構層次不清晰,重復造輪子?
- 接手了一個舊工程,如何快速理解架構與設計,從而快速上手做需求?
有規(guī)范的好處:
- 利于多人合作開發(fā)&理解同一模塊/工程。
- 降低團隊成員之間的代碼溝通成本。
- 架構&代碼規(guī)范明確,有效提高編碼效率。
前言:
讀這本書的時,第一個想到的問題就是:“什么是整潔的代碼?”
書中列舉了各位程序員鼻祖的名言,我整理總結了下,大概有下面幾條:
- 邏輯直截了當,令缺陷難以隱藏 。
- 減少依賴關系,便于維護。
- 合理分層,完善錯誤處理 。
- 只做好一件事。沒有重復代碼。
代碼是團隊溝通的一種方式
工作的溝通,不只是每天lark拉群或者開會交流,代碼也是我們很重要的溝通方式之一。
用代碼寫完需求,只是萬里長征的第一步。我們要用代碼表達自己的設計思想。如果我們團隊大部分人都能按照一定規(guī)范、思路去寫代碼。那么,工作溝通成本會降低許多。
比如:某位同學之前負責的一個模塊,被另一位同事接手了,或者隨著業(yè)務的擴張,我們多個同學共同開發(fā)同一個工程/模塊。如果我們的代碼結構大同小異,分層清晰、注釋合理,就會降低很多溝通成本。
因此,我們需要為團隊創(chuàng)造整潔的代碼。
一是降低團隊內的代碼溝通成本,二是便于今后項目需求的維護與迭代。
讓營地比來時更整潔
隨著需求的不斷迭代,保持代碼整潔、工程更易理解。
有時候,我們會維護一些老項目,或者交接過來的項目。代碼可能不太美觀,工程可能不太好理解。
一般我們會面臨兩種選擇:
- 重構
- 優(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ù)字)。尤其是l和O兩個字母,和數(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 要盡量簡短并有意義
- 不要使用面向對象編程中的常用名。例如不要使用
self、this、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ā)量。
好處:
- 避免協(xié)程創(chuàng)建過多,導致程序崩潰。(對服務本身)
- 控制流量速度,防止把下游服務打雪崩。(對下游服務)
參考代碼:
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:
配置可以參考:https://www.jetbrains.com/help/go/using-file-watchers.html#enableFileWatcher
垂直格式:
每個文件從上到下的代碼規(guī)范。
一個文件,盡量不要超過 400 行。(超過可讀性會降低)
- 垂直方向的間隔:
package聲明、導入聲明和每個函數(shù)之間都要有一個空行隔開。
- 垂直方向的靠近:
靠的越近的代碼,關系越緊密。
- 垂直距離:
變量聲明:盡可能靠近其使用的位置。
局部變量,聲明在函數(shù)頂部。
實體變量,聲明在類的頂部。
相關函數(shù):盡節(jié)能互相靠近,保證順序。
首先,應該放到一起。
其次,“調用”函數(shù)應該放到“被調用”函數(shù)的上面。
概念相關:做某類事情的函數(shù),應該放一起。
比如,一個 interface,它有 read/write 方法,他們應該放一起
- 垂直順序:
“調用”函數(shù)應該放到“被調用”函數(shù)的上面。
建立了一種自頂向下貫穿源代碼的良好信息流。
橫向格式:
每一行代碼從左到右的代碼規(guī)范。
每一行代碼,盡量不要超過 120 個字。(超過150字,一個屏幕就看不全了)
- 水平方向的間隔與靠近
操作符周圍加上空格。
- 水平對齊
type PodType string
const (
PodTypeIOS PodType = "iOS"
PodTypeAndroid PodType = "Android"
PodTypeFlutter PodType = "Flutter"
)
- 縮進
這部分 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、Android、Flutter、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ā)。
