天天看點

還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》

點選上方藍色字型,選擇“設定星标”

優質文章,第一時間送達

還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》

來源:騰訊技術工程

前言

作為公司代碼委員會 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:

還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》

logging:

還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》

原則 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 及更進階别的同學。你們已經積累了大量的代碼實踐,亟需對'工程性'做思考總結。很多工程方法論都過時了,這本書的内容,是例外中的例外。它所表達出的内容沒有因為軟體技術的不斷更替而過時。

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

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

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

往期精彩回顧

讓人又愛又恨的 Lombok,到底該不該用

Delombok 是個啥?居然可破 Lombok?

跳槽的必要條件是有一份好的履歷

時候為自己的後半生考慮了——緻奔三的網際網路人

還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》
還敢亂寫代碼??騰訊 Code Review 規範出爐!前言為什麼技術人員包括 leader 都要做 code review為什麼同學們要在 review 中思考和總結最佳實踐代碼變壞的根源必須形而上的思考model 設計UNIX 設計哲學具體實踐點主幹開發《unix 程式設計藝術》

點個贊呗

繼續閱讀