告訴你如何做好Code Review

點擊上方“程序猿技術大咖”,關注加羣討論

作者丨cheaterlin,騰訊 PCG 後臺開發工程師

譯者丨http://dockone.io/article/10467

前言

作爲公司代碼委員會 golang 分會的理事,我 review 了很多代碼,看了很多別人的 review 評論。發現不少同學 code review 與寫出好代碼的水平有待提高。在這裏,想分享一下我的一些理念和思路。

爲什麼技術人員包括 leader 都要做 code review

諺語曰: 'Talk Is Cheap, Show Me The Code'。知易行難,知行合一難。嘴裏要講出來總是輕鬆,把別人講過的話記住,組織一下語言,再講出來,很容易。絕知此事要躬行。設計理念你可能道聽途說了一些,以爲自己掌握了,但是你會做麼?有能力去思考、改進自己當前的實踐方式和實踐中的代碼細節麼?不客氣地說,很多人僅僅是知道並且認同了某個設計理念,進而產生了一種虛假的安心感---自己的技術並不差。但是,他根本沒有去實踐這些設計理念,甚至根本實踐不了這些設計理念,從結果來說,他懂不懂這些道理/理念,有什麼差別?變成了自欺欺人。

代碼,是設計理念落地的地方,是技術的呈現和根本。同學們可以在 review 過程中做到落地溝通,不再是空對空的討論,可以在實際問題中產生思考的碰撞,互相學習,大家都掌握團隊裏積累出來最好的實踐方式!當然,如果 leader 沒時間寫代碼,僅僅是 review 代碼,指出其他同學某些實踐方式不好,要給出好的實踐的意見,即使沒親手寫代碼,也是對最佳實踐要有很多思考。

爲什麼同學們要在 review 中思考和總結最佳實踐

我這裏先給一個我自己的總結:所謂架構師,就是掌握大量設計理念和原則、落地到各種語言及附帶工具鏈(生態)下的實踐方法、垂直行業模型理解,定製系統模型設計和工程實踐規範細則。進而控制 30+萬行代碼項目的開發便利性、可維護性、可測試性、運營質量。

厲害的技術人,主要可以分爲下面幾個方向:

  • 奇技淫巧

掌握很多技巧,以及發現技巧一系列思路,比如很多編程大賽,比的就是這個。但是,這個對工程,用處好像並不是很大。

  • 領域奠基

比如約翰*卡馬克,他創造出了現代計算機圖形高效渲染的方法論。不論如果沒有他,後面會不會有人發明,他就是第一個發明了。1999 年,卡馬克登上了美國時代雜誌評選出來的科技領域 50 大影響力人物榜單,並且名列第 10 位。但是,類似的殿堂級位置,沒有幾個,不夠大家分,沒我們的事兒。

  • 理論研究

八十年代李開復博士堅持採用隱含馬爾可夫模型的框架,成功地開發了世界上第一個大詞彙量連續語音識別系統 Sphinx。我輩工程師,好像擅長這個的很少。

  • 產品成功

小龍哥是標杆。

  • 最佳實踐

這個是大家都可以做到,按照上面架構師的定義。在這條路上走得好,就能爲任何公司組建技術團隊,組織建設高質量的系統。

從上面的討論中,可以看出,我們普通工程師的進化之路,就是不斷打磨最佳實踐方法論、落地細節。

代碼變壞的根源

在討論什麼代碼是好代碼之前,我們先討論什麼是不好的。計算機是人造的學科,我們自己製造了很多問題,進而去思考解法。

重複的代碼

// BatchGetQQTinyWithAdmin 獲取QQ uin的tinyID, 需要主uin的tiny和登錄態
// friendUins 可以是空列表, 只要admin uin的tiny
func BatchGetQQTinyWithAdmin(ctx context.Context, adminUin uint64, friendUin []uint64) (
 adminTiny uint64, sig []byte, frdTiny map[uint64]uint64, err error) {
 var friendAccountList []*basedef.AccountInfo
 for _, v := range friendUin {
  friendAccountList = append(friendAccountList, &basedef.AccountInfo{
   AccountType: proto.String(def.StrQQU),
   Userid:      proto.String(fmt.Sprint(v)),
  })
 }

 req := &cmd0xb91.ReqBody{
  Appid:       proto.Uint32(model.DocAppID),
  CheckMethod: proto.String(CheckQQ),
  AdminAccount: &basedef.AccountInfo{
   AccountType: proto.String(def.StrQQU),
   Userid:      proto.String(fmt.Sprint(adminUin)),
  },
  FriendAccountList: friendAccountList,
 }

因爲最開始協議設計得不好,第一個使用接口的人,沒有類似上面這個函數的代碼,自己實現了一個嵌入邏輯代碼的填寫請求結構結構體的代碼,一開始,挺好的。但當有第二個人,第三個人幹了類似的事情,我們將無法再重構這個協議,必須做到麻煩的向前兼容。而且每個同學,都要理解一遍上面這個協議怎麼填,理解有問題,就觸發 bug。或者,如果某個錯誤的理解,普遍存在,我們就得找到所有這些重複的片段,都修改一遍。

當你要讀一個數據,發現兩個地方有,不知道該選擇哪個。當你要實現一個功能,發現兩個 rpc 接口、兩個函數能做到,你不知道選哪一個。你有面臨過這樣的'人生難題'麼?其實怎麼選並不重要了,你寫的這個代碼已經在走向 shit 的道路上邁出了堅實的一步。

但是,A little copying is better than a little dependency。這裏提一嘴,不展開。

這裏,我必須額外說一句。大家使用 trpc。感覺自己被鼓勵'每個服務搞一個 git'。那,你這個服務裏訪問 db 的代碼,rpc 的代碼,各種可以複用的代碼,是用的大家都複用的 git 下的代碼麼?每次都重複寫一遍,db 字段細節改了,每個使用過 db 的 server 對應的 git 都改一遍?這個通用 git 已經寫好的接口應該不知道哪些 git 下的代碼因爲自己不向前兼容的修改而永遠放棄了向前不兼容的修改?

早期有效的決策不再有效

很多時候,我們第一版代碼寫出來,是沒有太大的問題的。比如,下面這個代碼

// Update 增量更新
func (s *FilePrivilegeStore) Update(key def.PrivilegeKey,
 clear, isMerge bool, subtract []*access.AccessInfo, increment []*access.AccessInfo,
 policy *uint32, adv *access.AdvPolicy, shareKey string, importQQGroupID uint64) error {
 // 獲取之前的數據
 info, err := s.Get(key)
 if err != nil {
  return err
 }

 incOnlyModify := update(info, &key, clear, subtract,
  increment, policy, adv, shareKey, importQQGroupID)
 stat := statAndUpdateAccessInfo(info)
 if !incOnlyModify {
  if stat.groupNumber > model.FilePrivilegeGroupMax {
   return errors.Errorf(errors.PrivilegeGroupLimit,
    "group num %d larger than limit %d",
    stat.groupNumber, model.FilePrivilegeGroupMax)
  }
 }

 if !isMerge {
  if key.DomainID == uint64(access.SPECIAL_FOLDER_DOMAIN_ID) &&
   len(info.AccessInfos) > model.FilePrivilegeMaxFolderNum {
   return errors.Errorf(errors.PrivilegeFolderLimit,
    "folder owner num %d larger than limit %d",
    len(info.AccessInfos), model.FilePrivilegeMaxFolderNum)
  }
  if len(info.AccessInfos) > model.FilePrivilegeMaxNum {
   return errors.Errorf(errors.PrivilegeUserLimit,
    "file owner num %d larger than limit %d",
    len(info.AccessInfos), model.FilePrivilegeMaxNum)
  }
 }

 pbDataSt := infoToData(info, &key)
 var updateBuf []byte
 if updateBuf, err = proto.Marshal(pbDataSt); err != nil {
  return errors.Wrapf(err, errors.MarshalPBError,
   "FilePrivilegeStore.Update Marshal data error, key[%v]", key)
 }
 if err = s.setCKV(generateKey(&key), updateBuf); err != nil {
  return errors.Wrapf(err, errors.Code(err),
   "FilePrivilegeStore.Update setCKV error, key[%v]", key)
 }
 return nil
}

現在看,這個代碼挺好的,長度沒超過 80 行,邏輯比價清晰。但是當 isMerge 這裏判斷邏輯,如果加入更多的邏輯,把局部行數撐到 50 行以上,這個函數,味道就壞了。出現兩個問題:

1)函數內代碼不在一個邏輯層次上,閱讀代碼,本來在閱讀着頂層邏輯,突然就掉入了長達 50 行的 isMerge 的邏輯處理細節,還沒看完,讀者已經忘了前面的代碼講了什麼,需要來回看,挑戰自己大腦的 cache 尺寸。

2)代碼有問題後,再新加代碼的同學,是改還是不改前人寫好的代碼呢?出 bug 誰來背?這是一個靈魂拷問。

過早的優化

這個大家聽了很多了,這裏不贅述。

對合理性沒有苛求

'兩種寫法都 ok,你隨便挑一種吧','我這樣也沒什麼吧',這是我經常聽到的話。

// Get 獲取IP
func (i *IPGetter) Get(cardName string) string {
 i.l.RLock()
 ip, found := i.m[cardName]
 i.l.RUnlock()

 if found {
  return ip
 }

 i.l.Lock()
 var err error
 ip, err = getNetIP(cardName)
 if err == nil {
  i.m[cardName] = ip
 }

  i.l.Unlock()
 return ip
}

i.l.Unlock()可以放在當前的位置,也可以放在 i.l.Lock()下面,做成 defer。兩種在最初構造的時候,好像都行。這個時候,很多同學態度就變得不堅決。實際上,這裏必須是 defer 的。

  i.l.Lock()
 defer i.l.Unlock()

 var err error
 ip, err = getNetIP(cardName)
 if err != nil {
  return "127.0.0.1"
 }

 i.m[cardName] = ip
 return ip

這樣的修改,是極有可能發生的,它還是要變成 defer,那,爲什麼不一開始就是 defer,進入最合理的狀態?不一開始就進入最合理的狀態,在後續協作中,其他同學很可能犯錯!

總是面向對象/總喜歡封裝

我是軟件工程科班出身。學的第一門編程語言是 c++。教材是這本 。當時自己讀完教材,初入程序設計之門,對於裏面講的'封裝',驚爲天人,多麼美妙的設計啊,面向對象,多麼智慧的設計啊。但是,這些年來,我看到了大牛'雲風'對於'畢業生使用 mysql api 就喜歡搞個 class 封裝再用'的嘲諷;看到了各種莫名其妙的 class 定義;體會到了經常要去看一個莫名其妙的繼承樹,必須要把整個繼承樹整體讀明白才能確認一個細小的邏輯分支;多次體會到了我需要辛苦地壓抑住自己的牴觸情緒,去細度一個自作聰明的被封裝的代碼,確認我的 bug。除了 UI 類場景,我認爲少用繼承、多用組合。

template<class _PKG_TYPE>
class CSuperAction : public CSuperActionBase {
  public:
    typedef _PKG_TYPE pkg_type;
    typedef CSuperAction<pkg_type> this_type;
    ...
}

這是 sspp 的代碼。CSuperAction 和 CSuperActionBase,一會兒 super,一會兒 base,Super 和 SuperBase 是在怎樣的兩個抽象層次上,不通讀代碼,沒人能讀明白。我想確認任何細節,都要把多個層次的代碼都通讀了,有什麼封裝性可言?

好,你說是作者沒有把 class name 取得好。那,問題是,你能取得好麼?一個剛入職的 T1.2 的同學能把 class name、class 樹設計得好麼?即使是對簡單的業務模型,也需要無數次'壞'的對象抽象實踐,才能培養出一個具有合格的 class 抽象能力的同學,這對於大型卻鬆散的團隊協作,不是破壞性的?已經有了一套繼承樹,想要添加功能就只能在這個繼承樹裏添加,以前的繼承樹不再適合新的需求,這個繼承樹上所有的 class,以及使用它們的地方,你都去改?不,是個正常人都會放棄,開始堆屎山。

封裝,就是我可以不關心實現。但是,做一個穩定的系統,每一層設計都可能出問題。abi,總有合適的用法和不合適的用法,真的存在我們能完全不關心封裝的部分是怎麼實現的?不,你不能。bug 和性能問題,常常就出現在,你用了錯誤的用法去使用一個封裝好的函數。即使是 android、ios 的 api,golang、java 現成的 api,我們常常都要去探究實現,才能把 api 用好。那,我們是不是該一上來,就做一個透明性很強的函數,才更爲合理?使用者想知道細節,進來吧,我的實現很易讀,你看看就明白,使用時不會迷路!對於邏輯複雜的函數,我們還要強調函數內部工作方式'可以讓讀者在大腦裏想象呈現完整過程'的可現性,讓使用者輕鬆讀懂,有把握,使用時,不迷路!

根本沒有設計

這個最可怕,所有需求,上手就是一頓擼,'設計是什麼東西?我一個文件 5w 行,一個函數 5k 行,幹不完需求?'從第一行代碼開始,就是無設計的,隨意地踩着滿地的泥坑,對於旁人的眼光沒有感覺,一個人獨舞,產出的代碼,完成了需求,毀滅了接手自己代碼的人。這個就不舉例了,每個同學應該都能在自己的項目類發現這種代碼。

必須形而上的思考

常常,同學們聽演講,公開課,就喜歡聽一些細枝末節的'幹活'。這沒有問題。但是,你幹了幾年活,學習了多少乾貨知識點?構建起自己的技術思考'面',進入立體的'工程思維',把技術細節和系統要滿足的需求在思考上連接起來了麼?當聽一個需求的時候,你能思考到自己的 code package 該怎麼組織,函數該怎麼組織了麼?

那,技術點要怎麼和需求連接起來呢?答案很簡單,你需要在時間裏總結,總結出一些明確的原則、思維過程。思考怎麼去總結,特別像是在思考哲學問題。從一些瑣碎的細節中,由具體情況上升到一些原則、公理。同時,大家在接受原則時,不應該是接受和記住原則本身,而應該是結構原則,讓這個原則在自己這裏重新推理一遍,自己完全掌握這個原則的適用範圍。

再進一步具體地說,對於工程最佳實踐的形而上的思考過程,就是:

把工程實踐中遇到的問題,從問題類型和解法類型,兩個角度去歸類,總結出一些有限適用的原則,就從點到了面。把諸多總結出的原則,組合應用到自己的項目代碼中,就是把多個面結合起來構建了一套立體的最佳實踐的方案。當你這套方案能適應 30w+行代碼的項目,超過 30 人的項目,你就架構師入門了!當你這個項目,是多端,多語言,代碼量超過 300w 行,參與人數超過 300 人,代碼質量依然很高,代碼依然在高效地自我迭代,每天消除掉過時的代碼,填充高質量的替換舊代碼和新生的代碼。恭喜你,你已經是一個很高級的架構師了!再進一步,你對某個業務模型有獨到或者全面的理解,構建了一套行業第一的解決方案,結合剛纔高質量實現的能力,實現了這麼一個項目。沒啥好說的,你已經是專家工程師了。級別再高,我就不瞭解了,不在這裏討論。

那麼,我們要重頭開始積累思考和總結?不,有一本書叫做《unix 編程藝術》,我在不同的時期分別讀了 3 遍,等一會,我講一些裏面提到的,我覺得在騰訊尤其值得拿出來說的原則。這些原則,正好就能作爲 code review 時大家判定代碼質量的準繩。但,在那之前,我得講一下另外一個很重要的話題,模型設計。

model 設計

沒讀過 oauth2.0 RFC,就去設計第三方授權登陸的人,終歸還要再發明一個撇腳的 oauth。

2012 年我剛畢業,我和一個去了廣州聯通公司的華南理工畢業生聊天。當時他說他工作很不開心,因爲工作裏不經常寫代碼,而且認爲自己有 ACM 競賽金牌級的算法熟練度+對 CPP 代碼的熟悉,寫下一個個指針操作內存,什麼程序寫不出來,什麼事情做不好。當時我覺得,挺有道理,編程工具在手,我什麼事情做不了?

現在,我會告訴他,複雜如 linux 操作系統、Chromium 引擎、windows office,你做不了。原因是,他根本沒進入軟件工程的工程世界。不是會搬磚就能修出港珠澳大橋。但是,這麼回答並不好,舉證用的論據離我們太遙遠了。見微知著。我現在會回答,你做不了,簡單如一個權限系統,你知道怎麼做麼?堆積一堆邏輯層次一維展開的 if else?簡單如一個共享文件管理,你知道怎麼做麼?堆積一堆邏輯層次一維展開的 ife lse?你聯通有上萬臺服務器,你要怎麼寫一個管理平臺?堆積一堆邏輯層次一維展開的 ife lse?

上來就是幹,能實現上面提到的三個看似簡單的需求?想一想,亞馬遜、阿里雲折騰了多少年,最後才找到了容器+Kubernetes 的大殺器。這裏,需要谷歌多少年在 BORG 系統上的實踐,提出了優秀的服務編排領域模型。權限領域,有 RBAC、DAC、MAC 等等模型,到了業務,又會有細節的不同。如 Domain Driven Design 說的,沒有良好的領域思考和模型抽象,邏輯複雜度就是 n^2 指數級的,你得寫多少 ifelse,得思考多少可能的 if 路徑,來 cover 所有的不合符預期的情況。你必須要有 Domain 思考探索、model 拆解/抽象/構建的能力。有人問過我,要怎麼有效地獲得這個能力?這個問題我沒能回答,就像是在問我,怎麼才能獲得 MIT 博士的學術能力?我無法回答。唯一回答就是,進入某個領域,就是首先去看前人的思考,站在前人的肩膀上,再用上自己的通識能力,去進一步思考。至於怎麼建立好的通識思考能力,可能得去常青藤讀個書吧:)或者,就在工程實踐中思考和鍛鍊自己的這個能力!

同時,基於 model 設計的代碼,能更好地適應產品經理不斷變更的需求。比如說,一個 calendar(日曆)應用,簡單來想,不要太簡單!以'userid_date'爲 key 記錄一個用戶的每日安排不就完成了麼?只往前走一步,設計了一個任務,上限分發給 100w 個人,創建這麼一個任務,是往 100w 個人下面添加一條記錄?你得改掉之前的設計,換 db。再往前走一步,要拉出某個用戶和某個人一起要參與的所有事務,是把兩個人的所有任務來做 join?好像還行。如果是和 100 個人一起參與的所有任務呢?100 個人的任務來 join?不現實了吧。好,你引入一個羣組 id,那麼,你最開始的'userid_date'爲 key 的設計,是不是又要修改和做數據遷移了?經常來一個需求,你就得把系統推翻重來,或者根本就只能拒絕用戶的需求,這樣的戰鬥力,還好意思叫自己工程師?你一開始就應該思考自己面對的業務領域,思考自己的日曆應用可能的模型邊界,把可能要做的能力都拿進來思考,構建一個 model,設計一套通用的 store 層接口,基於通用接口的邏輯代碼。當產品不斷髮展,就是不停往模型裏填內容,而不是推翻重來。這,思考模型邊界,構建模型細節,就是兩個很重要的能力,也是絕大多數騰訊產品經理不具備的能力,你得具備,對整個團隊都是極其有益的。你面對產品經理時,就聽取他們出於對用戶體驗負責思考出的需求點,到你自己這裏,用一個完整的模型去涵蓋這些零碎的點。

model 設計,是形而上思考中的一個方面,一個特別重要的方面。接下來,我們來抄襲抄襲 unix 操作系統構建的實踐爲我們提出的前人實踐經驗和'公理'總結。在自己的 coding/code review 中,站在巨人的肩膀上去思考。不重複地發現經典力學,而是往相對論挺進。

UNIX 設計哲學

不懂 Unix 的人註定最終還要重複發明一個撇腳的 Unix。--Henry Spenncer, 1987.11

下面這一段話太經典,我必須要摘抄一遍(自《UNIX 編程藝術》):“工程和設計的每個分支都有自己的技術文化。在大多數工程領域中,就一個專業人員的素養組成來說,有些不成文的行業素養具有與標準手冊及教科書同等重要的地位(並且隨着專業人員經驗的日積月累,這些經驗常常會比書本更重要)。資深工程師們在工作中會積累大量的隱性知識,他們用類似禪宗'教外別傳'的方式,通過言傳身教傳授給後輩。軟件工程算是此規則的一個例外:技術變革如此之快,軟件環境日新月異,軟件技術文化暫如朝露。然而,例外之中也有例外。確有極少數軟件技術被證明經久耐用,足以演進爲強勢的技術文化、有鮮明特色的藝術和世代相傳的設計哲學。“

接下來,我用我的理解,講解一下幾個我們常常做不到的原則。

Keep It Simple Stuped!

KISS 原則,大家應該是如雷貫耳了。但是,你真的在遵守?什麼是 Simple?簡單?golang 語言主要設計者之一的 Rob Pike 說'大道至簡',這個'簡'和簡單是一個意思麼?

首先,簡單不是面對一個問題,我們印入眼簾第一映像的解法爲簡單。我說一句,感受一下。"把一個事情做出來容易,把事情用最簡單有效的方法做出來,是一個很難的事情。"比如,做一個三方授權,oauth2.0 很簡單,所有概念和細節都是緊湊、完備、易用的。你覺得要設計到 oauth2.0 這個效果很容易麼?要做到簡單,就要對自己處理的問題有全面的瞭解,然後需要不斷積累思考,才能做到從各個角度和層級去認識這個問題,打磨出一個通俗、緊湊、完備的設計,就像 ios 的交互設計。簡單不是容易做到的,需要大家在不斷的時間和 code review 過程中去積累思考,pk 中觸發思考,交流中總結思考,才能做得愈發地好,接近'大道至簡'。

兩張經典的模型圖,簡單又全面,感受一下,沒看懂,可以立即自行 google 學習一下:RBAC:

logging:

原則 3 組合原則: 設計時考慮拼接組合

關於 OOP,關於繼承,我前面已經說過了。那我們怎麼組織自己的模塊?對,用組合的方式來達到。linux 操作系統離我們這麼近,它是怎麼架構起來的?往小裏說,我們一個串聯一個業務請求的數據集合,如果使用 BaseSession,XXXSession inherit BaseSession 的設計,其實,這個繼承樹,很難適應層出不窮的變化。但是如果使用組合,就可以拆解出 UserSignature 等等各種可能需要的部件,在需要的時候組合使用,不斷添加新的部件而沒有對老的繼承樹的記憶這個心智負擔。

使用組合,其實就是要讓你明確清楚自己現在所擁有的是哪個部件。如果部件過於多,其實完成組合最終成品這個步驟,就會有較高的心智負擔,每個部件展開來,琳琅滿目,眼花繚亂。比如 QT 這個通用 UI 框架,看它的Class 列表,有 1000 多個。如果不用繼承樹把它組織起來,平鋪展開,組合出一個頁面,將會變得心智負擔高到無法承受。OOP 在'需要無數元素同時展現出來'這種複雜度極高的場景,有效的控制了複雜度 。'那麼,古爾丹,代價是什麼呢?'代價就是,一開始做出這個自上而下的設計,牽一髮而動全身,每次調整都變得異常困難。

實際項目中,各種職業級別不同的同學一起協作修改一個 server 的代碼,就會出現,職級低的同學改哪裏都改不對,根本沒能力進行修改,高級別的同學能修改對,也不願意大規模修改,整個項目變得愈發不合理。對整個繼承樹沒有完全認識的同學都沒有資格進行任何一個對繼承樹有調整的修改,協作變得寸步難行。代碼的修改,都變成了依賴一個高級架構師高強度監控繼承體系的變化,低級別同學們束手束腳的結果。組合,就很好的解決了這個問題,把問題不斷細分,每個同學都可以很好地攻克自己需要攻克的點,實現一個 package。產品邏輯代碼,只需要去組合各個 package,就能達到效果。

這是 golang 標準庫裏 http request 的定義,它就是 Http 請求所有特性集合出來的結果。其中通用/異變/多種實現的部分,通過 duck interface 抽象,比如 Body io.ReadCloser。你想知道哪些細節,就從組合成 request 的部件入手,要修改,只需要修改對應部件。[這段代碼後,對比.NET 的 HTTP 基於 OOP 的抽象]

// A Request represents an HTTP request received by a server
// or to be sent by a client.
//
// The field semantics differ slightly between client and server
// usage. In addition to the notes on the fields below, see the
// documentation for Request.Write and RoundTripper.
type Request struct {
	// Method specifies the HTTP method (GET, POST, PUT, etc.).
	// For client requests, an empty string means GET.
	//
	// Go's HTTP client does not support sending a request with
	// the CONNECT method. See the documentation on Transport for
	// details.
	Method string

	// URL specifies either the URI being requested (for server
	// requests) or the URL to access (for client requests).
	//
	// For server requests, the URL is parsed from the URI
	// supplied on the Request-Line as stored in RequestURI.  For
	// most requests, fields other than Path and RawQuery will be
	// empty. (See RFC 7230, Section 5.3)
	//
	// For client requests, the URL's Host specifies the server to
	// connect to, while the Request's Host field optionally
	// specifies the Host header value to send in the HTTP
	// request.
	URL *url.URL

	// The protocol version for incoming server requests.
	//
	// For client requests, these fields are ignored. The HTTP
	// client code always uses either HTTP/1.1 or HTTP/2.
	// See the docs on Transport for details.
	Proto      string // "HTTP/1.0"
	ProtoMajor int    // 1
	ProtoMinor int    // 0

	// Header contains the request header fields either received
	// by the server or to be sent by the client.
	//
	// If a server received a request with header lines,
	//
	//	Host: example.com
	//	accept-encoding: gzip, deflate
	//	Accept-Language: en-us
	//	fOO: Bar
	//	foo: two
	//
	// then
	//
	//	Header = map[string][]string{
	//		"Accept-Encoding": {"gzip, deflate"},
	//		"Accept-Language": {"en-us"},
	//		"Foo": {"Bar", "two"},
	//	}
	//
	// For incoming requests, the Host header is promoted to the
	// Request.Host field and removed from the Header map.
	//
	// HTTP defines that header names are case-insensitive. The
	// request parser implements this by using CanonicalHeaderKey,
	// making the first character and any characters following a
	// hyphen uppercase and the rest lowercase.
	//
	// For client requests, certain headers such as Content-Length
	// and Connection are automatically written when needed and
	// values in Header may be ignored. See the documentation
	// for the Request.Write method.
	Header Header

	// Body is the request's body.
	//
	// For client requests, a nil body means the request has no
	// body, such as a GET request. The HTTP Client's Transport
	// is responsible for calling the Close method.
	//
	// For server requests, the Request Body is always non-nil
	// but will return EOF immediately when no body is present.
	// The Server will close the request body. The ServeHTTP
	// Handler does not need to.
	Body io.ReadCloser

	// GetBody defines an optional func to return a new copy of
	// Body. It is used for client requests when a redirect requires
	// reading the body more than once. Use of GetBody still
	// requires setting Body.
	//
	// For server requests, it is unused.
	GetBody func() (io.ReadCloser, error)

	// ContentLength records the length of the associated content.
	// The value -1 indicates that the length is unknown.
	// Values >= 0 indicate that the given number of bytes may
	// be read from Body.
	//
	// For client requests, a value of 0 with a non-nil Body is
	// also treated as unknown.
	ContentLength int64

	// TransferEncoding lists the transfer encodings from outermost to
	// innermost. An empty list denotes the "identity" encoding.
	// TransferEncoding can usually be ignored; chunked encoding is
	// automatically added and removed as necessary when sending and
	// receiving requests.
	TransferEncoding []string

	// Close indicates whether to close the connection after
	// replying to this request (for servers) or after sending this
	// request and reading its response (for clients).
	//
	// For server requests, the HTTP server handles this automatically
	// and this field is not needed by Handlers.
	//
	// For client requests, setting this field prevents re-use of
	// TCP connections between requests to the same hosts, as if
	// Transport.DisableKeepAlives were set.
	Close bool

	// For server requests, Host specifies the host on which the
	// URL is sought. For HTTP/1 (per RFC 7230, p 5.4), this
	// is either the value of the "Host" header or the host name
	// given in the URL itself. For HTTP/2, it is the value of the
	// ":authority" pseudo-header field.
	// It may be of the form "host:port". For international domain
	// names, Host may be in Punycode or Unicode form. Use
	// golang.org/x/net/idna to convert it to either format if
	// needed.
	// To prevent DNS rebinding attacks, server Handlers should
	// validate that the Host header has a value for which the
	// Handler considers itself authoritative. The included
	// ServeMux supports patterns registered to particular host
	// names and thus protects its registered Handlers.
	//
	// For client requests, Host optionally overrides the Host
	// header to send. If empty, the Request.Write method uses
	// the value of URL.Host. Host may contain an international
	// domain name.
	Host string

	// Form contains the parsed form data, including both the URL
	// field's query parameters and the PATCH, POST, or PUT form data.
	// This field is only available after ParseForm is called.
	// The HTTP client ignores Form and uses Body instead.
	Form url.Values

	// PostForm contains the parsed form data from PATCH, POST
	// or PUT body parameters.
	//
	// This field is only available after ParseForm is called.
	// The HTTP client ignores PostForm and uses Body instead.
	PostForm url.Values

	// MultipartForm is the parsed multipart form, including file uploads.
	// This field is only available after ParseMultipartForm is called.
	// The HTTP client ignores MultipartForm and uses Body instead.
	MultipartForm *multipart.Form

	// Trailer specifies additional headers that are sent after the request
	// body.
	//
	// For server requests, the Trailer map initially contains only the
	// trailer keys, with nil values. (The client declares which trailers it
	// will later send.)  While the handler is reading from Body, it must
	// not reference Trailer. After reading from Body returns EOF, Trailer
	// can be read again and will contain non-nil values, if they were sent
	// by the client.
	//
	// For client requests, Trailer must be initialized to a map containing
	// the trailer keys to later send. The values may be nil or their final
	// values. The ContentLength must be 0 or -1, to send a chunked request.
	// After the HTTP request is sent the map values can be updated while
	// the request body is read. Once the body returns EOF, the caller must
	// not mutate Trailer.
	//
	// Few HTTP clients, servers, or proxies support HTTP trailers.
	Trailer Header

	// RemoteAddr allows HTTP servers and other software to record
	// the network address that sent the request, usually for
	// logging. This field is not filled in by ReadRequest and
	// has no defined format. The HTTP server in this package
	// sets RemoteAddr to an "IP:port" address before invoking a
	// handler.
	// This field is ignored by the HTTP client.
	RemoteAddr string

	// RequestURI is the unmodified request-target of the
	// Request-Line (RFC 7230, Section 3.1.1) as sent by the client
	// to a server. Usually the URL field should be used instead.
	// It is an error to set this field in an HTTP client request.
	RequestURI string

	// TLS allows HTTP servers and other software to record
	// information about the TLS connection on which the request
	// was received. This field is not filled in by ReadRequest.
	// The HTTP server in this package sets the field for
	// TLS-enabled connections before invoking a handler;
	// otherwise it leaves the field nil.
	// This field is ignored by the HTTP client.
	TLS *tls.ConnectionState

	// Cancel is an optional channel whose closure indicates that the client
	// request should be regarded as canceled. Not all implementations of
	// RoundTripper may support Cancel.
	//
	// For server requests, this field is not applicable.
	//
	// Deprecated: Set the Request's context with NewRequestWithContext
	// instead. If a Request's Cancel field and context are both
	// set, it is undefined whether Cancel is respected.
	Cancel <-chan struct{}

	// Response is the redirect response which caused this request
	// to be created. This field is only populated during client
	// redirects.
	Response *Response

	// ctx is either the client or server context. It should only
	// be modified via copying the whole Request using WithContext.
	// It is unexported to prevent people from using Context wrong
	// and mutating the contexts held by callers of the same request.
	ctx context.Context
}

看看.NET 裏對於 web 服務的抽象,僅僅看到末端,不去看完整個繼承樹的完整圖景,我根本無法知道我關心的某個細節在什麼位置。進而,我要往整個 http 服務體系裏修改任何功能,都無法拋開對整體完整設計的理解和熟悉,還極容易沒有知覺地破壞者整體的設計。

說到組合,還有一個關係很緊密的詞,叫插件化。大家都用 vscode 用得很開心,它比 visual studio 成功在哪裏?如果 vscode 通過添加一堆插件達到 visual studio 具備的能力,那麼它將變成另一個和 visual studio 差不多的東西,叫做 vs studio 吧。大家應該發現問題了,我們很多時候其實並不需要 visual studio 的大多數功能,而且希望靈活定製化一些比較小衆的能力,用一些小衆的插件。甚至,我們希望選擇不同實現的同類型插件。這就是組合的力量,各種不同的組合,它簡單,卻又滿足了各種需求,靈活多變,要實現一個插件,不需要事先掌握一個龐大的體系。體現在代碼上,也是一樣的道理。至少後端開發領域,組合,比 OOP,'香'很多。

原則 6 吝嗇原則: 除非確無它法, 不要編寫龐大的程序

可能有些同學會覺得,把程序寫得龐大一些纔好拿得出手去評 T11、T12。leader 們一看評審方案就容易覺得:很大,很好,很全面。但是,我們真的需要寫這麼大的程序麼?

我又要說了"那麼,古爾丹,代價是什麼呢?"。代價是代碼越多,越難維護,難調整。C 語言之父 Ken Thompson 說"刪除一行代碼,給我帶來的成就感要比添加一行要大"。我們對於代碼,要吝嗇。能把系統做小,就不要做大。騰訊不乏 200w+行的客戶端,很大,很牛。但是,同學們自問,現在還調整得動架構麼。手 Q 的同學們,看看自己代碼,曾經嘆息過麼。能小做的事情就小做,尋求通用化,通過 duck interface(甚至多進程,用於隔離能力的多線程)把模塊、能力隔離開,時刻想着刪減代碼量,才能保持代碼的可維護性和麪對未來的需求、架構,調整自身的活力。客戶端代碼,UI 渲染模塊可以複雜吊炸天,非 UI 部分應該追求最簡單,能力接口化,可替換、重組合能力強。

落地到大家的代碼,review 時,就應該最關注核心 struct 定義,構建起一個完備的模型,核心 interface,明確抽象 model 對外部的依賴,明確抽象 model 對外提供的能力。其他代碼,就是要用最簡單、平平無奇的代碼實現模型內部細節。

原則 7 透明性原則: 設計要可見,以便審查和調試

首先,定義一下,什麼是透明性和可顯性。

"如果沒有陰暗的角落和隱藏的深度,軟件系統就是透明的。透明性是一種被動的品質。如果實際上能預測到程序行爲的全部或大部分情況,並能建立簡單的心理模型,這個程序就是透明的,因爲可以看透機器究竟在幹什麼。

如果軟件系統所包含的功能是爲了幫助人們對軟件建立正確的'做什麼、怎麼做'的心理模型而設計,這個軟件系統就是可顯的。因此,舉例來說,對用戶而言,良好的文檔有助於提高可顯性;對程序員而言,良好的變量和函數名有助於提高可顯性。可顯性是一種主動品質。在軟件中要達到這一點,僅僅做到不晦澀是不夠的,還必須要盡力做到有幫助。"

我們要寫好程序,減少 bug,就要增強自己對代碼的控制力。你始終做到,理解自己調用的函數/複用的代碼大概是怎麼實現的。不然,你可能就會在單線程狀態機的 server 裏調用有 IO 阻塞的函數,讓自己的 server 吞吐量直接掉到底。進而,爲了保證大家能對自己代碼能做到有控制力,所有人寫的函數,就必須具備很高的透明性。而不是寫一些看了一陣看不明白的函數/代碼,結果被迫使用你代碼的人,直接放棄了對掌控力的追取,甚至放棄複用你的代碼,另起爐竈,走向了'製造重複代碼'的深淵。

透明性其實相對容易做到的,大家有意識地鍛鍊一兩個月,就能做得很好。可顯性就不容易了。有一個現象是,你寫的每一個函數都不超過 80 行,每一行我都能看懂,但是你層層調用,很多函數調用,組合起來怎麼就實現了某個功能,看兩遍,還是看不懂。第三遍可能才能大概看懂。大概看懂了,但太複雜,很難在大腦裏構建起你實現這個功能的整體流程。結果就是,閱讀者根本做不到對你的代碼有好的掌控力。

可顯性的標準很簡單,大家看一段代碼,懂不懂,一下就明白了。但是,如何做好可顯性?那就是要追求合理的函數分組,合理的函數上下級層次,同一層次的代碼纔會出現在同一個函數裏,追求通俗易懂的函數分組分層方式,是通往可顯性的道路。

當然,複雜如 linux 操作系統,office 文檔,問題本身就很複雜,拆解、分層、組合得再合理,都難建立心理模型。這個時候,就需要完備的文檔了。完備的文檔還需要出現在離代碼最近的地方,讓人'知道這裏複雜的邏輯有文檔',而不是其實文檔,但是閱讀者不知道。再看看上面 golang 標準庫裏的 http.Request,感受到它在可顯性上的努力了麼?對,就去學它。

原則 10 通俗原則: 接口設計避免標新立異

設計程序過於標新立異的話,可能會提升別人理解的難度。

一般,我們這麼定義一個'點',使用 x 表示橫座標,用 y 表示縱座標:

type Point struct {
 X float64
 Y float64
}

你就是要不同、精準:

type Point struct {
 VerticalOrdinate   float64
 HorizontalOrdinate float64
}

很好,你用詞很精準,一般人還駁斥不了你。但是,多數人讀你的 VerticalOrdinate 就是沒有讀 X 理解來得快,來得容易懂、方便。你是在刻意製造協作成本。

上面的例子常見,但還不是最小立異原則最想說明的問題。想想一下,一個程序裏,你把用'+'這個符號表示數組添加元素,而不是數學'加','result := 1+2' --> 'result = []int{1, 2}'而不是'result=3',那麼,你這個標新立異,對程序的破壞性,簡直無法想象。"最小立異原則的另一面是避免表象想死而實際卻略有不同。這會極端危險,因爲表象相似往往導致人們產生錯誤的假定。所以最好讓不同事物有明顯區別,而不要看起來幾乎一模一樣。" -- Henry Spencer。

你實現一個 db.Add()函數卻做着 db.AddOrUpdate()的操作,有人使用了你的接口,錯誤地把數據覆蓋了。

原則 11 緘默原則: 如果一個程序沒什麼好說的,就沉默

這個原則,應該是大家最經常破壞的原則之一。一段簡短的代碼裏插入了各種'log("cmd xxx enter")', 'log("req data " + req.String())',非常害怕自己信息打印得不夠。害怕自己不知道程序執行成功了,總要最後'log("success")'。但是,我問一下大家,你們真的耐心看過別人寫的代碼打的一堆日誌麼?不是自己需要哪個,就在一堆日誌裏,再打印一個日誌出來一個帶有特殊標記的日誌'log("this_is_my_log_" + xxxxx)'?結果,第一個作者打印的日誌,在代碼交接給其他人或者在跟別人協作的時候,這個日誌根本沒有價值,反而提升了大家看日誌的難度。

一個服務一跑起來,就瘋狂打日誌,請求處理正常也打一堆日誌。滾滾而來的日誌,把錯誤日誌淹沒在裏面。錯誤日誌失去了效果,簡單地 tail 查看日誌,眼花繚亂,看不出任何問題,這不就成了'爲了捕獲問題'而讓自己'根本無法捕獲問題'了麼?

沉默是金。除了簡單的 stat log,如果你的程序'發聲'了,那麼它拋出的信息就一定要有效!打印一個 log('process fail')也是毫無價值,到底什麼 fail 了?是哪個用戶帶着什麼參數在哪個環節怎麼 fail 了?如果發聲,就要把必要信息給全。不然就是不發聲,表示自己好好地 work 着呢。不發聲就是最好的消息,現在我的 work 一切正常!

"設計良好的程序將用戶的注意力視爲有限的寶貴資源,只有在必要時纔要求使用。"程序員自己的主力,也是寶貴的資源!只有有必要的時候,日誌才跑來提醒程序員'我有問題,來看看',而且,必須要給到足夠的信息,讓一把講明白現在發生了什麼。而不是程序員還需要很多輔助手段來搞明白到底發生了什麼。

每當我發佈程序 ,我抽查一個機器,看它的日誌。發現只有每分鐘外部接入、內部 rpc 的個數/延時分佈日誌的時候,我就心情很愉悅。我知道,這一分鐘,它的成功率又是 100%,沒任何問題!

原則 12 補救原則: 出現異常時,馬上退出並給出足夠錯誤信息

其實這個問題很簡單,如果出現異常,異常並不會因爲我們嘗試掩蓋它,它就不存在了。所以,程序錯誤和邏輯錯誤要嚴格區分對待。這是一個態度問題。

'異常是互聯網服務器的常態'。邏輯錯誤通過 metrics 統計,我們做好告警分析。對於程序錯誤 ,我們就必須要嚴格做到在問題最早出現的位置就把必要的信息蒐集起來,高調地告知開發和維護者'我出現異常了,請立即修復我!'。可以是直接就沒有被捕獲的 panic 了。也可以在一個最上層的位置統一做好 recover 機制,但是在 recover 的時候一定要能獲得準確異常位置的準確異常信息。不能有中間 catch 機制,catch 之後丟失很多信息再往上傳遞。

很多 Java 開發的同學,不區分程序錯誤和邏輯錯誤,要麼都很寬容,要麼都很嚴格,對代碼的可維護性是毀滅性的破壞。"我的程序沒有程序錯誤,如果有,我當時就解決了。"只有這樣,才能保持程序代碼質量的相對穩定,在火苗出現時撲滅火災是最好的撲滅火災的方式。當然,更有效的方式是全面自動化測試的預防:)

具體實踐點

前面提了好多思考方向的問題。大的原則問題和方向。我這裏,再來給大家簡單列舉幾個細節執行點吧。畢竟,大家要上手,是從執行開始,然後纔是總結思考,能把我的思考方式抄過去。下面是針對 golang 語言的,其他語言略有不同。以及,我一時也想不全我所執行的 所有細則,這就是我強調'原則'的重要性,原則是可枚舉的。

  • 對於代碼格式規範,100%嚴格執行,嚴重容不得一點沙。

  • 文件絕不能超過 800 行,超過,一定要思考怎麼拆文件。工程思維,就在於拆文件的時候積累。

  • 函數對決不能超過 80 行,超過,一定要思考怎麼拆函數,思考函數分組,層次。工程思維,就在於拆文件的時候積累。

  • 代碼嵌套層次不能超過 4 層,超過了就得改。多想想能不能 early return。工程思維,就在於拆文件的時候積累。

if !needContinue {
 doA()
 return
} else {
 doB()
 return
}

if !needContinue {
 doA()
 return
}

doB()
return

下面這個就是 early return,把兩端代碼從邏輯上解耦了。

  • 從目錄、package、文件、struct、function 一層層下來 ,信息一定不能出現冗餘。比如 file.FileProperty 這種定義。只有每個'定語'只出現在一個位置,才爲'做好邏輯、定義分組/分層'提供了可能性。

  • 多用多級目錄來組織代碼所承載的信息,即使某一些中間目錄只有一個子目錄。

  • 隨着代碼的擴展,老的代碼違反了一些設計原則,應該立即原地局部重構,維持住代碼質量不滑坡。比如:拆文件;拆函數;用 Session 來保存一個複雜的流程型函數的所有信息;重新調整目錄結構。

  • 基於上一點考慮,我們應該儘量讓項目的代碼有一定的組織、層次關係。我個人的當前實踐是除了特別通用的代碼,都放在一個 git 裏。特別通用、修改少的代碼,逐漸獨立出 git,作爲子 git 連接到當前項目 git,讓 goland 的 Refactor 特性、各種 Refactor 工具能幫助我們快速、安全局部重構。

  • 自己的項目代碼,應該有一個內生的層級和邏輯關係。flat 平鋪展開是非常不利於代碼複用的。怎麼複用、怎麼組織複用,肯定會變成'人生難題'。T4-T7 的同學根本無力解決這種難題。

  • 如果被 review 的代碼雖然簡短,但是你看了一眼卻發現不咋懂,那就一定有問題。自己看不出來,就找高級別的同學交流。這是你和別 review 代碼的同學成長的時刻。

  • 日誌要少打。要打日誌就要把關鍵索引信息帶上。必要的日誌必須打。

  • 有疑問就立即問,不要怕問錯。讓代碼作者給出解釋。不要怕問出極低問題。

  • 不要說'建議',提問題,就是剛,你 pk 不過我,就得改!

  • 請積極使用 trpc。總是要和老闆站在一起!只有和老闆達成的對於代碼質量建設的共識,才能在團隊裏更好地做好代碼質量建設。

  • 消滅重複!消滅重複!消滅重複!

主幹開發

最後,我來爲'主幹開發'多說一句話。道理很簡單,只有每次被 review 代碼不到 500 行,reviewer 才能快速地看完,而且幾乎不會看漏。超過 500 行,reviewer 就不能仔細看,只能大概瀏覽了。而且,讓你調整 500 行代碼內的邏輯比調整 3000 行甚至更多的代碼,容易很多,降低不僅僅是 6 倍,而是一到兩個數量級。有問題,在剛出現的時候就調整了,不會給被 revew 的人帶來大的修改負擔。

關於 CI(continuous integration),還有很多好的資料和書籍,大家應該及時去學習學習。

《unix 編程藝術》

建議大家把這本書找出來讀一讀。特別是,T7 及更高級別的同學。你們已經積累了大量的代碼實踐,亟需對'工程性'做思考總結。很多工程方法論都過時了,這本書的內容,是例外中的例外。它所表達出的內容沒有因爲軟件技術的不斷更替而過時。

佛教禪宗講'不立文字'(不立文字,教外別傳,直指人心,見性成佛),很多道理和感悟是不能用文字傳達的,文字的表達能力,不能表達。大家常常因爲"自己聽說過、知道某個道理"而產生一種安心感,認爲"我懂了這個道理",但是自己卻不能在實踐中做到。知易行難,知道卻做不到,在工程實踐裏,就和'不懂這個道理'沒有任何區別了。

曾經,我面試過一個別的公司的總監,講得好像一套一套,代碼拉出來遛一遛,根本就沒做到,僅僅會道聽途說。他在工程實踐上的探索前路可以說已經基本斷絕了。我只能祝君能做好向上管理,走自己的純管理道路吧。請不要再說自己對技術有追求,是個技術人了!

所以,大家不僅僅是看看我這篇文章,而是在實踐中去不斷踐行和積累自己的'教外別傳'吧。

Software Engineering at Google也是一本必讀好書,可惜沒找到中文翻譯。

JDK1.8新特性(六):Stream的終極操作,輕鬆解決集合分組、彙總等複雜操作

微服務架構下的核心話題 (三):微服務架構的技術選型

喜歡就點個"在看"唄,留言、轉發朋友圈

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章