程序員進(jìn)階指南:文檔團(tuán)隊(duì)Golang最佳實(shí)踐和CR案例集分享(程序員文檔工具)
作者:cheaterlin,騰訊 PCG 后臺(tái)開發(fā)工程師
綜述
我寫過一篇《Code Review 我都 CR 些什么》,講解了 Code Review 對(duì)團(tuán)隊(duì)有什么價(jià)值,我認(rèn)為 CR 最重要的原則有哪些。最近我在團(tuán)隊(duì)工作中還發(fā)現(xiàn)了:
- 原則不清晰。對(duì)于代碼架構(gòu)的原則,編碼的追求,我的骨干員工對(duì)它的認(rèn)識(shí)也不是很全面。當(dāng)前還是在 review 過程中我對(duì)他們口口相傳,總有遺漏。
- 從知道到會(huì)做需要時(shí)間。我需要反復(fù)跟他們補(bǔ)充 review 他們漏掉的點(diǎn),他們才能完成吸收、內(nèi)化,在后續(xù)的 review 過程中,能自己提出這些 review 的點(diǎn)。
過度文檔化是有害的,當(dāng)過多的內(nèi)容需要被閱讀,工程師們最終就會(huì)選擇不去讀,讀了也僅僅能吸收很少一部分。在 google,對(duì)于代碼細(xì)節(jié)的理解,更多還是口口相傳,在實(shí)踐中去感受和理解。但是,適當(dāng)?shù)奈臋n、文字宣傳,是必要的。特此,我就又輸出了這一篇文章,嘗試從'知名架構(gòu)原則'、'工程師的自我修養(yǎng)'、'不能上升到原則的幾個(gè)常見案例'三大模塊,把我個(gè)人的經(jīng)驗(yàn)系統(tǒng)地輸出,供其他團(tuán)隊(duì)參考。
知名架構(gòu)原則
后面原則主要受《程序員修煉之道: 通向務(wù)實(shí)的最高境界》、《架構(gòu)整潔之道》、《Unix 編程藝術(shù)》啟發(fā)。我不是第一個(gè)發(fā)明這些原則的人,甚至不是第一個(gè)總結(jié)出來的人,別人都已經(jīng)寫成書了!務(wù)實(shí)的程序員對(duì)于方法的總結(jié),總是殊途同歸。
細(xì)節(jié)即是架構(gòu)
(下面是原文摘錄, 我有類似觀點(diǎn), 但是原文就寫得很好, 直接摘錄)
一直以來,設(shè)計(jì)(Design)和架構(gòu)(Architecture)這兩個(gè)概念讓大多數(shù)人十分迷惑–什么是設(shè)計(jì)?什么是架構(gòu)?二者究竟有什么區(qū)別?二者沒有區(qū)別。一丁點(diǎn)區(qū)別都沒有!"架構(gòu)"這個(gè)詞往往適用于"高層級(jí)"的討論中,這類討論一般都把"底層"的實(shí)現(xiàn)細(xì)節(jié)排除在外。而"設(shè)計(jì)"一詞,往往用來指代具體的系統(tǒng)底層組織結(jié)構(gòu)和實(shí)現(xiàn)的細(xì)節(jié)。但是,從一個(gè)真正的系統(tǒng)架構(gòu)師的日常工作來看,這些區(qū)分是根本不成立的。以給我設(shè)計(jì)新房子的建筑設(shè)計(jì)師要做的事情為例。新房子當(dāng)然是存在著既定架構(gòu)的,但這個(gè)架構(gòu)具體包含哪些內(nèi)容呢?首先,它應(yīng)該包括房屋的形狀、外觀設(shè)計(jì)、垂直高度、房間的布局,等等。
但是,如果查看建筑設(shè)計(jì)師使用的圖紙,會(huì)發(fā)現(xiàn)其中也充斥著大量的設(shè)計(jì)細(xì)節(jié)。譬如,我們可以看到每個(gè)插座、開關(guān)以及每個(gè)電燈具體的安裝位置,同時(shí)也可以看到某個(gè)開關(guān)與所控制的電燈的具體連接信息;我們也能看到壁爐的具體位置,熱水器的大小和位置信息,甚至是污水泵的位置;同時(shí)也可以看到關(guān)于墻體、屋頂和地基所有非常詳細(xì)的建造說明。總的來說,架構(gòu)圖里實(shí)際上包含了所有的底層設(shè)計(jì)細(xì)節(jié),這些細(xì)節(jié)信息共同支撐了頂層的架構(gòu)設(shè)計(jì),底層設(shè)計(jì)信息和頂層架構(gòu)設(shè)計(jì)共同組成了整個(gè)房屋的架構(gòu)文檔。
軟件設(shè)計(jì)也是如此。底層設(shè)計(jì)細(xì)節(jié)和高層架構(gòu)信息是不可分割的。他們組合在一起,共同定義了整個(gè)軟件系統(tǒng),缺一不可。所謂的底層和高層本身就是一系列決策組成的連續(xù)體,并沒有清晰的分界線。
我們編寫、review 細(xì)節(jié)代碼,就是在做架構(gòu)設(shè)計(jì)的一部分。我們編寫的細(xì)節(jié)代碼構(gòu)成了整個(gè)系統(tǒng)。我們就應(yīng)該在細(xì)節(jié) review 中,總是帶著所有架構(gòu)原則去審視。你會(huì)發(fā)現(xiàn),你已經(jīng)寫下了無數(shù)讓整體變得丑陋的細(xì)節(jié),它們背后,都有前人總結(jié)過的架構(gòu)原則。
把代碼和文檔綁在一起(自解釋原則)
寫文檔是個(gè)好習(xí)慣。但是寫一個(gè)別人需要咨詢老開發(fā)者才能找到的文檔,是個(gè)壞習(xí)慣。這個(gè)壞習(xí)慣甚至?xí)o工程師們帶來傷害。比如,當(dāng)初始開發(fā)者寫的文檔在一個(gè)犄角旮旯(在 wiki 里,但是閱讀代碼的時(shí)候沒有在明顯的位置看到鏈接),后續(xù)代碼被修改了,文檔已經(jīng)過時(shí),有人再找出文檔來獲取到過時(shí)、錯(cuò)誤的知識(shí)的時(shí)候,閱讀文檔這個(gè)同學(xué)的開發(fā)效率必然受到傷害。所以,如同 golang 的 godoc 工具能把代碼里'按規(guī)范來'的注釋自動(dòng)生成一個(gè)文檔頁面一樣,我們應(yīng)該:
- 按照 godoc 的要求好好寫代碼的注釋。
- 代碼首先要自解釋,當(dāng)解釋不了的時(shí)候,需要就近、合理地寫注釋。
- 當(dāng)小段的注釋不能解釋清楚的時(shí)候,應(yīng)該有 doc.go 來解釋,或者,在同級(jí)目錄的 ReadMe.md 里注釋講解。
- 文檔需要強(qiáng)大的富文本編輯能力,Down 無法滿足,可以寫到 wiki 里,同時(shí)必須把 wiki 的簡單描述和鏈接放在代碼里合適的位置。讓閱讀和維護(hù)代碼的同學(xué)一眼就看到,能做到及時(shí)的維護(hù)。
以上,總結(jié)起來就是,解釋信息必須離被解釋的東西,越近越好。代碼能做到自解釋,是最棒的。
讓目錄結(jié)構(gòu)自解釋
ETC 價(jià)值觀(easy to change)
ETC 是一種價(jià)值觀念,不是一條原則。價(jià)值觀念是幫助你做決定的: 我應(yīng)該做這個(gè),還是做那個(gè)?當(dāng)你在軟件領(lǐng)域思考時(shí),ETC 是個(gè)向?qū)?,它能幫助你在不同的路線中選出一條。就像其他一些價(jià)值觀念一樣,你應(yīng)該讓它漂浮在意識(shí)思維之下,讓它微妙地將你推向正確的方向。
敏捷軟件工程,所謂敏捷,就是要能快速變更,并且在變更中保持代碼的質(zhì)量。所以,持有 ETC 價(jià)值觀看待代碼細(xì)節(jié)、技術(shù)方案,我們將能更好地編寫出適合敏捷項(xiàng)目的代碼。這是一個(gè)大的價(jià)值觀,不是一個(gè)基礎(chǔ)微觀的原則,所以沒有例子。本文提到的所有原則,或者接,或間接,都要為 ETC 服務(wù)。
DRY 原則(don not repeat yourself)
在《Code Review 我都 CR 些什么》里面,我已經(jīng)就 DRY 原則做了深入闡述,這里不再贅述。我認(rèn)為 DRY 原則是編碼原則中最重要的編碼原則,沒有之一(ETC 是個(gè)觀念)。不要重復(fù)!不要重復(fù)!不要重復(fù)!
正交性原則(全局變量的危害)
'正交性'是幾何學(xué)中的術(shù)語。我們的代碼應(yīng)該消除不相關(guān)事物之間的影響。這是一給簡單的道理。我們寫代碼要'高內(nèi)聚、低耦合',這是大家都在提的。
但是,你有為了使用某個(gè) class 一堆能力中的某個(gè)能力而去派生它么?你有寫過一個(gè) helper 工具,它什么都做么?在騰訊,我相信你是做過的。你自己說,你這是不是為了復(fù)用一點(diǎn)點(diǎn)代碼,而讓兩大塊甚至多塊代碼耦合在一起,不再正交了?大家可能并不是不明白正交性的價(jià)值,只是不知道怎么去正交。手段有很多,但是首先我就要批判一下 OOP。它的核心是多態(tài),多態(tài)需要通過派生/繼承來實(shí)現(xiàn)。繼承樹一旦寫出來,就變得很難 change,你不得不為了使用一小段代碼而去做繼承,讓代碼耦合。
你應(yīng)該多使用組合,而不是繼承。以及,應(yīng)該多使用 DIP(Dependence Inversion Principle),依賴倒置原則。換個(gè)說法,就是面向 interface 編程,面向契約編程,面向切面編程,他們都是 DIP 的一種衍生。寫 golang 的同學(xué)就更不陌生了,我們要把一個(gè) struct 作為一個(gè) interface 來使用,不需要顯式 implement/extend,僅僅需要持有對(duì)應(yīng) interface 定義了的函數(shù)。這種 duck interface 的做法,讓 DIP 來得更簡單。AB 兩個(gè)模塊可以獨(dú)立編碼,他們僅僅需要一個(gè)依賴一個(gè) interface 簽名,一個(gè)剛好實(shí)現(xiàn)該 interface 簽名。并不需要顯式知道對(duì)方 interface 簽名的兩個(gè)模塊就可以在需要的模塊、場景下被組合起來使用。代碼在需要被組合使用的時(shí)候才產(chǎn)生了一點(diǎn)關(guān)系,同時(shí),它們依然保持著獨(dú)立。
說個(gè)正交性的典型案例。全局變量是不正交的!沒有充分的理由,禁止使用全局變量。全局變量讓依賴了該全局變量的代碼段互相耦合,不再正交。特別是一個(gè) pkg 提供一個(gè)全局變量給其他模塊修改,這個(gè)做法會(huì)讓 pkg 之間的耦合變得復(fù)雜、隱秘、難以定位。
全局 map case
單例就是全局變量
這個(gè)不需要我解釋,大家自己品一品。后面有'共享狀態(tài)就是不正確的狀態(tài)'原則,會(huì)進(jìn)一步講到。我先給出解決方案,可以通過管道、消息機(jī)制來替代共享狀態(tài)/使用全局變量/使用單例。僅僅能獲取此刻最新的狀態(tài),通過消息變更狀態(tài)。要拿到最新的狀態(tài),需要重新獲取。在必要的時(shí)候,引入鎖機(jī)制。
可逆性原則
可逆性原則是很少被提及的一個(gè)原則??赡嫘?,就是你做出的判斷,最好都是可以被逆轉(zhuǎn)的。再換一個(gè)容易懂的說法,你最好盡量少認(rèn)為什么東西是一定的、不變的。比如,你認(rèn)為你的系統(tǒng)永遠(yuǎn)服務(wù)于,用 32 位無符號(hào)整數(shù)(比如 QQ 號(hào))作為用戶標(biāo)識(shí)的系統(tǒng)。你認(rèn)為,你的持久化存儲(chǔ),就選型 SQL 存儲(chǔ)了。當(dāng)這些一開始你認(rèn)為一定的東西,被推翻的時(shí)候,你的代碼卻很難去 change,那么,你的代碼就是可逆性做得很差。書里有一個(gè)例證,我覺得很好,直接引用過來。
與其認(rèn)為決定是被刻在石頭上的,還不如把它們想像成寫在沙灘的沙子上。一個(gè)大浪隨時(shí)都可能襲來,卷走一切。騰訊也確實(shí)在 20 年內(nèi)經(jīng)歷了'大鐵塊'到'云虛擬機(jī)換成容器'的幾個(gè)階段。幾次變化都是傷筋動(dòng)骨,浪費(fèi)大量的時(shí)間。甚至總會(huì)有一些上一個(gè)時(shí)代殘留的服務(wù)。就機(jī)器數(shù)量而論,還不小。一到裁撤季,就很難受。就最近,我看到某個(gè) trpc 插件,直接從環(huán)境變量里讀取本機(jī) IP,僅僅因?yàn)?STKE(Tencent Kubernetes Engine)提供了這個(gè)能力。這個(gè)細(xì)節(jié)設(shè)計(jì)就是不可逆的,將來會(huì)有人為它買單,可能價(jià)格還不便宜。
我今天才想起一個(gè)事兒。當(dāng)年 SNG 的很多部門對(duì)于 metrics 監(jiān)控的使用。就潛意識(shí)地認(rèn)為,我們將一直使用'模塊間調(diào)用監(jiān)控'組件。使用它的 API 是直接把上報(bào)通道 DCLog 的 API 裸露在業(yè)務(wù)代碼里的。今天(2020.12.01),該組件應(yīng)該已經(jīng)完全沒有人維護(hù)、完全下線了,這些核心業(yè)務(wù)代碼要怎么辦?有人能對(duì)它做出修改么?那,這些部門現(xiàn)在還有 metrics 監(jiān)控么?答案,可能是悲觀的。有人已經(jīng)已經(jīng)嘗到了可逆性之痛。
依賴倒置原則(DIP)
DIP 原則太重要了,我這里單獨(dú)列一節(jié)來講解。我這里只是簡單的講解,講解它最原始和簡單的形態(tài)。依賴倒置原則,全稱是 Dependence Inversion Principle,簡稱 DIP??紤]下面這幾段代碼:
package dippackage diptype Botton interface { TurnOn() TurnOff()}type UI struct { botton Botton}func NewUI(b Botton) *UI { return &UI{botton: b}}func (u *UI) Poll() { u.botton.TurnOn() u.botton.TurnOff() u.botton.TurnOn()}
package Javaimplimport "fmt"type Lamp struct {}func NewLamp() *Lamp { return &Lamp{}}func (*Lamp) TurnOn() { fmt.Println("turn on java lamp")}func (*Lamp) TurnOff() { fmt.Println("turn off java lamp")}
package pythonimplimport "fmt"type Lamp struct {}func NewLamp() *Lamp { return &Lamp{}}func (*Lamp) TurnOn() { fmt.Println("turn on python lamp")}func (*Lamp) TurnOff() { fmt.Println("turn off python lamp")}
package mainimport ( "javaimpl" "pythonimpl" "dip")func runPoll(b dip.Botton) { ui := NewUI(b) ui.Poll()}func main() { runPoll(pythonimpl.NewLamp()) runPoll(javaimpl.NewLamp())}
看代碼,main pkg 里的 runPoll 函數(shù)僅僅面向 Botton interface 編碼,main pkg 不再關(guān)心 Botton interface 里定義的 TurnOn、TurnOff 的實(shí)現(xiàn)細(xì)節(jié)。實(shí)現(xiàn)了解耦。這里,我們能看到 struct UI 需要被注入(inject)一個(gè) Botton interface 才能邏輯完整。所以,DIP 經(jīng)常換一個(gè)名字出現(xiàn),叫做依賴注入(Dependency Injection)。
從這個(gè)依賴圖觀察。我們發(fā)現(xiàn),一般來說,UI struct 的實(shí)現(xiàn)是要應(yīng)該依賴于具體的 pythonLamp、javaLamp、其他各種 Lamp,才能讓自己的邏輯完整。那就是 UI struct 依賴于各種 Lamp 的實(shí)現(xiàn),才能邏輯完整。但是,我們看上面的代碼,卻是反過來了。pythonLamp、javaLamp、其他各種 Lamp 是依賴 Botton interface 的定義,才能用來和 UI struct 組合起來拼接成完整的業(yè)務(wù)邏輯。變成了,Lamp 的實(shí)現(xiàn)細(xì)節(jié),依賴于 UI struct 對(duì)于 Botton interface 的定義。這個(gè)時(shí)候,你發(fā)現(xiàn),這種依賴關(guān)系被倒置了!依賴倒置原則里的'倒置',就是這么來的。在 golang 里,'pythonLamp、javaLamp、其他各種 Lamp 是依賴 Botton interface 的定義',這個(gè)依賴是隱性的,沒有顯式的 implement 和 extend 關(guān)鍵字。代碼層面,pkg dip 和 pkg pythonimpl、javaimpl 沒有任何依賴關(guān)系。他們僅僅需要被你在 main pkg 里組合起來使用。
在 J2EE 里,用戶的業(yè)務(wù)邏輯不再依賴低具體低層的各種存儲(chǔ)細(xì)節(jié),而僅僅依賴一套配置化的 Java Bean 接口。Object 落地存儲(chǔ)的具體細(xì)節(jié),被做成了 Java Bean 配置,注入到框架里。這就是 J2EE 的核心科技,并不復(fù)雜,其實(shí)也沒有多么'高不可攀'。反而,在'動(dòng)態(tài)代碼'優(yōu)于'配置'的今天,這種通過配置實(shí)現(xiàn)的依賴注入,反而有點(diǎn)過時(shí)了。
將知識(shí)用純文本來保存
這也是一個(gè)生僻的原則。指代碼操作的數(shù)據(jù)和方案設(shè)計(jì)文稿,如果沒有充分的必要使用特定的方案,就應(yīng)該使用人類可讀的文本來保存、交互。對(duì)于方案設(shè)計(jì)文稿,你能不使用 office 格式,就不使用(office 能極大提升效率,才用),最好是原始 text。這是《Unix 編程藝術(shù)》也提到了的 Unix 系產(chǎn)生的設(shè)計(jì)信條。簡而言之一句話,當(dāng)需要確保有一個(gè)所有各方都能使用的公共標(biāo)準(zhǔn),才能實(shí)現(xiàn)交互溝通時(shí),純文本就是這個(gè)標(biāo)準(zhǔn)。它是一個(gè)接受度最高的通行標(biāo)準(zhǔn)。如果沒有必要的理由,我們就應(yīng)該使用純文本。
契約式設(shè)計(jì)
如果你對(duì)契約式設(shè)計(jì)(Design by Contract, DBC)還很陌生,我相信,你和其他端的同學(xué)(web、client、后端)聯(lián)調(diào)需求應(yīng)該是一件很花費(fèi)時(shí)間的事情。你自己編寫接口自動(dòng)化,也會(huì)是一件很耗費(fèi)精力的事情。你先看看它的wiki 解釋吧。grpc grpc-gateway swagger 是個(gè)很香的東西。
代碼是否不多不少剛好完成它宣稱要做的事情,可以使用契約加以校驗(yàn)和文檔化。TDD 就是全程在不斷調(diào)整和履行著契約。TDD(Test-Driven Development)是自底向上地編碼過程,其實(shí)會(huì)耗費(fèi)大量的精力,并且對(duì)于一個(gè)良好的層級(jí)架構(gòu)沒有幫助。TDD 不是強(qiáng)推的規(guī)范,但是同學(xué)們可以用一用,感受一下。TDD 方法論實(shí)現(xiàn)的接口、函數(shù),自我解釋能力一般來說比較強(qiáng),因?yàn)樗褪且粋€(gè)實(shí)現(xiàn)契約的過程。
拋開 TDD 不談。我們的函數(shù)、api,你能快速抓住它描述的核心契約么?它的契約簡單么?如果不能、不簡單,那你應(yīng)該要求被 review 的代碼做出調(diào)整。如果你在指導(dǎo)一個(gè)后輩,你應(yīng)該幫他思考一下,給出至少一個(gè)可能的簡化、拆解方向。
盡早崩潰
Erlang 和 Elixir 語言信奉這種哲學(xué)。喬-阿姆斯特朗,Erlang 的發(fā)明者,《Erlang 程序設(shè)計(jì)》的作者,有一句反復(fù)被引用的話: "防御式編程是在浪費(fèi)時(shí)間,讓它崩潰"。
盡早崩潰不是說不容錯(cuò),而是程序應(yīng)該被設(shè)計(jì)成允許出故障,有適當(dāng)?shù)墓收媳O(jiān)管程序和代碼,及時(shí)告警,告知工程師,哪里出問題了,而不是嘗試掩蓋問題,不讓程序員知道。當(dāng)最后程序員知道程序出故障的時(shí)候,已經(jīng)找不到問題出現(xiàn)在哪里了。
特別是一些 recover 之后什么都不做的代碼,這種代碼簡直是毒瘤!當(dāng)然,崩潰,可以是早一些向上傳遞 error,不一定就是 panic。同時(shí),我要求大家不要在沒有充分的必要性的時(shí)候 panic,應(yīng)該更多地使用向上傳遞 error,做好 metrics 監(jiān)控。合格的 golang 程序員,都不會(huì)在沒有必要的時(shí)候無視 error,會(huì)妥善地做好 error 處理、向上傳遞、監(jiān)控。一個(gè)死掉的程序,通常比一個(gè)癱瘓的程序,造成的損害要小得多。
崩潰但是不告警,或者沒有補(bǔ)救的辦法,不可取.盡早崩潰的題外話是,要在問題出現(xiàn)的時(shí)候做合理的告警,有預(yù)案,不能掩蓋,不能沒有預(yù)案:
解耦代碼讓改變?nèi)菀?/strong>
這個(gè)原則,顯而易見,大家自己也常常提,其他原則或多或少都和它有關(guān)系。但是我也再提一提。我主要是描述一下它的癥狀,讓同學(xué)們更好地警示自己'我這兩塊代碼是不是耦合太重,需要額外引入解耦的設(shè)計(jì)了'。癥狀如下:
- 不相關(guān)的 pkg 之間古怪的依賴關(guān)系
- 對(duì)一個(gè)模塊進(jìn)行的'簡單'修改,會(huì)傳播到系統(tǒng)中不相關(guān)的模塊里,或是破壞了系統(tǒng)中的其他部分
- 開發(fā)人員害怕修改代碼,因?yàn)樗麄儾淮_定會(huì)造成什么影響
- 會(huì)議要求每個(gè)人都必須參加,因?yàn)闆]有人能確定誰會(huì)受到變化的影響
只管命令不要詢問
看看如下三段代碼:
func applyDiscount(customer Customer, orderID string, discount float32) { customer. Orders. Find(orderID). GetTotals(). ApplyDiscount(discount)}
func applyDiscount(customer Customer, orderID string, discount float32) { customer. FindOrder(orderID). GetTotals(). ApplyDiscount(discount)}
func applyDiscount(customer Customer, orderID string, discount float32) { customer. FindOrder(orderID). ApplyDiscount(discount)}
明顯,最后一段代碼最簡潔。不關(guān)心 Orders 成員、總價(jià)的存在,直接命令 customer 找到 Order 并對(duì)其進(jìn)行打折。當(dāng)我們調(diào)整 Orders 成員、GetTotals()方法的時(shí)候,這段代碼不用修改。還有一種更嚇人的寫法:
func applyDiscount(customer Customer, orderID string, discount float32) { total := customer. FindOrder(orderID). GetTotals() customer. FindOrder(orderID). SetTotal(total*discount)}
它做了更多的查詢,關(guān)心了更多的細(xì)節(jié),變得更加 hard to change 了。我相信,大家寫過類似的代碼也不少。特別是客戶端同學(xué)。
最好的那一段代碼,就是只管給每個(gè) struct 發(fā)送命令,要求大家做事兒。怎么做,就內(nèi)聚在和 struct 關(guān)聯(lián)的方法里,其他人不要去操心。一旦其他人操心了,當(dāng)需要做修改的時(shí)候,就要操心了這個(gè)細(xì)節(jié)的人都一起參與進(jìn)修改過程。
不要鏈?zhǔn)秸{(diào)用方法
看下面的例子:
func amount(customer Customer) float32 { return customer.Orders.Last().Totals().Amount}
func amount(totals Totals) float32 { return totals.Amount}
第二個(gè)例子明顯優(yōu)于第一個(gè),它變得更簡單、通用、ETC。我們應(yīng)該給函數(shù)傳入它關(guān)心的最小集合作為參數(shù)。而不是,我有一個(gè) struct,當(dāng)某個(gè)函數(shù)需要這個(gè) struct 的成員的時(shí)候,我們把整個(gè) struct 都作為參數(shù)傳遞進(jìn)去。應(yīng)該僅僅傳遞函數(shù)關(guān)心的最小集合。傳進(jìn)去的一整條調(diào)用鏈對(duì)函數(shù)來說,都是無關(guān)的耦合,只會(huì)讓代碼更 hard to change,讓工程師懼怕去修改。這一條原則,和上一條關(guān)系很緊密,問題常常同時(shí)出現(xiàn)。還是,特別是在客戶端代碼里。
繼承稅(多用組合)
繼承就是耦合。不僅子類耦合到父類,以及父類的父類等,而且使用子類的代碼也耦合到所有祖先類。 有些人認(rèn)為繼承是定義新類型的一種方式。他們喜歡設(shè)計(jì)圖表,會(huì)展示出類的層次結(jié)構(gòu)。他們看待問題的方式,與維多利亞時(shí)代的紳士科學(xué)家們看待自然的方式是一樣的,即將自然視為須分解到不同類別的綜合體。 不幸的是,這些圖表很快就會(huì)為了表示類之間的細(xì)微差別而逐層添加,最終可怕地爬滿墻壁。由此增加的復(fù)雜性,可能使應(yīng)用程序更加脆弱,因?yàn)樽兏赡茉谠S多層次之間上下波動(dòng)。 因?yàn)橐恍┲档蒙倘兜脑~義消歧方面的原因,C 在20世紀(jì)90年代玷污了多重繼承的名聲。結(jié)果,許多當(dāng)下的OO語言都沒有提供這種功能。
因此,即使你很喜歡復(fù)雜的類型樹,也完全無法為你的領(lǐng)域準(zhǔn)確地建模。
Java 下一切都是類。C 里不使用類還不如使用 C。寫 Python、PHP,我們也肯定要時(shí)髦地寫一些類。寫類可以,當(dāng)你要去繼承,你就得考慮清楚了。繼承樹一旦形成,就是非常 hard to change 的,在敏捷項(xiàng)目里,你要想清楚'代價(jià)是什么',有必要么?這個(gè)設(shè)計(jì)'可逆'么?對(duì)于邊界清晰的 UI 框架、游戲引擎,使用復(fù)雜的繼承樹,挺好的。對(duì)于 UI 邏輯、后臺(tái)邏輯,可能,你僅僅需要組合、DIP(依賴反轉(zhuǎn))技術(shù)、契約式編程(接口與協(xié)議)就夠了。寫出繼承樹不是'就應(yīng)該這么做',它是成本,繼承是要收稅的!
在 golang 下,繼承稅的煩惱被減輕了,golang 從來說自己不是 OO 的語言,但是你 OO 的事情,我都能輕松地做到。更進(jìn)一步,OO 和過程式編程的區(qū)別到底是什么?
面向過程,面向?qū)ο?,函?shù)式編程。三種編程結(jié)構(gòu)的核心區(qū)別,是在不同的方向限制程序員,來做到好的代碼結(jié)構(gòu)(引自《架構(gòu)整潔之道》):
- 結(jié)構(gòu)化編程是對(duì)程序控制權(quán)的直接轉(zhuǎn)移的限制。
- 面向?qū)ο笫菍?duì)程序控制權(quán)的間接轉(zhuǎn)移的限制。
- 函數(shù)式編程是對(duì)程序中賦值操作的限制。
SOLID 原則(單一功能、開閉原則、里氏替換、接口隔離、依賴反轉(zhuǎn),后面會(huì)講到)是 OOP 編程的最經(jīng)典的原則。其中 D 是指依賴倒置原則(Dependence Inversion Principle),我認(rèn)為,是 SOLID 里最重要的原則。J2EE 的 container 就是圍繞 DIP 原則設(shè)計(jì)的。DIP 能用于避免構(gòu)建復(fù)雜的繼承樹,DIP 就是'限制控制權(quán)的間接轉(zhuǎn)移'能繼續(xù)發(fā)揮積極作用的最大保障。合理使用 DIP 的 OOP 代碼才可能是高質(zhì)量的代碼。
golang 的 interface 是 duck interface,把 DIP 原則更進(jìn)一步,不需要顯式 implement/extend interface,就能做到 DIP。golang 使用結(jié)構(gòu)化編程范式,卻有面向?qū)ο缶幊谭妒降暮诵膬?yōu)點(diǎn),甚至簡化了。這是一個(gè)基于高度抽象理解的極度精巧的設(shè)計(jì)。google 把 abstraction 這個(gè)設(shè)計(jì)理念發(fā)揮到了極致。曾經(jīng),J2EE 的 container(EJB, Java Bean)設(shè)計(jì)是國內(nèi) Java 程序員引以為傲'架構(gòu)設(shè)計(jì)'、'厲害的設(shè)計(jì)'。
在 golang 里,它被分析、解構(gòu),以更簡單、靈活、統(tǒng)一、易懂的方式呈現(xiàn)出來。寫了多年垃圾 C 代碼的騰訊后端工程師們,是你們?cè)俅螌徱?OOP 的時(shí)候了。我大學(xué)一年級(jí)的時(shí)候看的 C 教材,終歸給我描述了一個(gè)美好卻無法抵達(dá)的世界。目標(biāo)我沒有放棄,但我不再用 OOP,而是更多地使用組合(Mixin)。寫 golang 的同學(xué),應(yīng)該對(duì) DIP 和組合都不陌生,這里我不再贅述。如果有人自傲地說他在 golang 下搞起了繼承,我只能說,'同志,你現(xiàn)在站在了廣大 gopher 的對(duì)立面'?,F(xiàn)在,你站在哲學(xué)的云端,鳥瞰了 Structured Programming 和 OOP。你還愿意再繼續(xù)支付繼承稅么?
共享狀態(tài)是不正確的狀態(tài)
你坐在最喜歡的餐廳。吃完主菜,問男服務(wù)員還有沒有蘋果派。他回頭一看-陳列柜里還有一個(gè),就告訴你"還有"。點(diǎn)到了蘋果派,你心滿意足地長出了一口氣。與此同時(shí),在餐廳的另一邊,還有一個(gè)顧客也問了女服務(wù)員同樣的問題。她也看了看,確認(rèn)有一個(gè),讓顧客點(diǎn)了單??傆幸粋€(gè)顧客會(huì)失望的。
問題出在共享狀態(tài)。餐廳里的每一個(gè)服務(wù)員都查看了陳列柜,卻沒有考慮到其他服務(wù)員。你們可以通過加互斥鎖來解決正確性的問題,但是,兩個(gè)顧客有一個(gè)會(huì)失望或者很久都得不到答案,這是肯定的。
所謂共享狀態(tài),換個(gè)說法,就是: 由多個(gè)人查看和修改狀態(tài)。這么一說,更好的解決方案就浮出水面了: 將狀態(tài)改為集中控制。預(yù)定蘋果派,不再是先查詢,再下單。而是有一個(gè)餐廳經(jīng)理負(fù)責(zé)和服務(wù)員溝通,服務(wù)員只管發(fā)送下單的命令/消息,經(jīng)理看情況能不能滿足服務(wù)員的命令。
這種解決方案,換一個(gè)說法,也可以說成"用角色實(shí)現(xiàn)并發(fā)性時(shí)不必共享狀態(tài)"。對(duì),上面,我們引入了餐廳經(jīng)理這個(gè)角色,賦予了他職責(zé)。當(dāng)然,我們僅僅應(yīng)該給這個(gè)角色發(fā)送命令,不應(yīng)該去詢問他。前面講過了,'只管命令不要詢問',你還記得么。
同時(shí),這個(gè)原則就是 golang 里大家耳熟能詳?shù)闹V語: "不要通過共享內(nèi)存來通信,而應(yīng)該通過通信來共享內(nèi)存"。作為并發(fā)性問題的根源,內(nèi)存的共享備受關(guān)注。但實(shí)際上,在應(yīng)用程序代碼共享可變資源(文件、數(shù)據(jù)庫、外部服務(wù))的任何地方,問題都有可能冒出來。當(dāng)代碼的兩個(gè)或多個(gè)實(shí)例可以同時(shí)訪問某些資源時(shí),就會(huì)出現(xiàn)潛在的問題。
緘默原則
如果一個(gè)程序沒什么好說,就保持沉默。過多的正常日志,會(huì)掩蓋錯(cuò)誤信息。過多的信息,會(huì)讓人根本不再關(guān)注新出現(xiàn)的信息,'更多信息'變成了'沒有信息'。每人添加一點(diǎn)信息,就變成了輸出很多信息,最后等于沒有任何信息。
- 不要在正常 case 下打印日志。
- 不要在單元測試?yán)锸褂?fmt 標(biāo)準(zhǔn)輸出,至少不要提交到 master。
- 不打不必要的日志。當(dāng)錯(cuò)誤出現(xiàn)的時(shí)候,會(huì)非常明顯,我們能第一時(shí)間反應(yīng)過來并處理。
- 讓調(diào)試的日志停留在調(diào)試階段,或者使用較低的日志級(jí)別,你的調(diào)試信息,對(duì)其他人根本沒有價(jià)值。
- 即使低級(jí)別日志,也不能泛濫。不然,日志打開與否都沒有差別,日志變得毫無價(jià)值。
緘默
錯(cuò)誤傳遞原則
我不喜歡 Java 和 C 的 exception 特性,它容易被濫用,它具有傳染性(如果代碼 throw 了 excepttion, 你就得 handle 它,不 handle 它,你就崩潰了??赡苣悴幌M罎?,你僅僅希望報(bào)警)。但是 exception(在 golang 下是 panic)是有價(jià)值的,參考微軟的文章:
Exceptions are preferred in modern C for the following reasons:* An exception forces calling code to recognize an error condition and handle it. Unhandled exceptions stop program execution.* An exception jumps to the point in the call stack that can handle the error. Intermediate functions can let the exception propagate. They don't have to coordinate with other layers.* The exception stack-unwinding mechanism destroys all objects in scope after an exception is thrown, according to well-defined rules.* An exception enables a clean separation between the code that detects the error and the code that handles the error.
Google 的 C 規(guī)范在常規(guī)情況禁用 exception,理由包含如下內(nèi)容:
Because most existing C code at Google is not prepared to deal with exceptions, it is comparatively difficult to adopt new code that generates exceptions.
從 google 和微軟的文章中,我們不難總結(jié)出以下幾點(diǎn)衍生的結(jié)論:
- 在必要的時(shí)候拋出 exception。使用者必須具備'必要性'的判斷能力。
- exception 能一路把底層的異常往上傳遞到高函數(shù)層級(jí),信息被向上傳遞,并且在上級(jí)被妥善處理。可以讓異常和關(guān)心具體異常的處理函數(shù)在高層級(jí)和低層級(jí)遙相呼應(yīng),中間層級(jí)什么都不需要做,僅僅向上傳遞。
- exception 傳染性很強(qiáng)。當(dāng)代碼由多人協(xié)作,使用 A 模塊的代碼都必須要了解它可能拋出的異常,做出合理的處理。不然,就都寫一個(gè)丑陋的 catch,catch 所有異常,然后做一個(gè)沒有針對(duì)性的處理。每次 catch 都需要加深一個(gè)代碼層級(jí),代碼常常寫得很丑。
我們看到了異常的優(yōu)缺點(diǎn)。上面第二點(diǎn)提到的信息傳遞,是很有價(jià)值的一點(diǎn)。golang 在 1.13 版本中拓展了標(biāo)準(zhǔn)庫,支持了Error Wrapping也是承認(rèn)了 error 傳遞的價(jià)值。
所以,我們認(rèn)為錯(cuò)誤處理,應(yīng)該具備跨層級(jí)的錯(cuò)誤信息傳遞能力,中間層級(jí)如果不關(guān)心,就把 error 加上本層的信息向上透傳(有時(shí)候可以直接透傳),應(yīng)該使用 Error Wrapping。exception/panic 具有傳染性。大量使用,會(huì)讓代碼變得丑陋,同時(shí)容易滋生可讀性問題。我們應(yīng)該多使用 Error Wrapping,在必要的時(shí)候,才使用 exception/panic。每一次使用 exception/panic,都應(yīng)該被認(rèn)真審核。需要 panic 的地方,不去 panic,也是有問題的。參考本文的'盡早崩潰'。
額外說一點(diǎn),注意不要把整個(gè)鏈路的錯(cuò)誤信息帶到公司外,帶到用戶的瀏覽器、native 客戶端。至少不能直接展示給用戶看到。
錯(cuò)誤鏈
SOLID
SOLID 原則,是由以下幾個(gè)原則的集合體:
- SRP: 單一職責(zé)原則
- OCP: 開閉原則
- LSP: 里氏替換原則
- ISP: 接口隔離原則
- DIP: 依賴反轉(zhuǎn)原則
這些年來,這幾個(gè)設(shè)計(jì)原則在很多不同的出版物里都有過詳細(xì)描述。它們太出名了,我這里就不更多地做詳解了。我這里想說的是,這 5 個(gè)原則環(huán)環(huán)相扣,前 4 個(gè)原則,要么就是同時(shí)做到,要么就是都沒做到,很少有說,做到其中一點(diǎn)其他三點(diǎn)都不滿足。ISP 就是做到 LSP 的常用手段。ISP 也是做到 DIP 的基礎(chǔ)。只是,它剛被提出來的時(shí)候,是主要針對(duì)'設(shè)計(jì)繼承樹'這個(gè)目的的?,F(xiàn)在,它們已經(jīng)被更廣泛地使用在模塊、領(lǐng)域、組件這種更大的概念上。
SOLI 都顯而易見,DIP 原則是最值得注意的一點(diǎn),我在其他原則里也多次提到了它。如果你還不清楚什么是 DIP,一定去看明白。這是工程師最基礎(chǔ)、必備的知識(shí)點(diǎn)之一了。
要做到 OCP 開閉原則,其實(shí),就是要大家要通過后面講到的'不要面向需求編程'才能做好。如果你還是面向需求、面向 UI、交互編程,你永遠(yuǎn)做不到開閉,并且不知道如何才能做到開閉。
如果你對(duì)這些原則確實(shí)不了解,建議讀一讀《架構(gòu)整潔之道》。該書的作者 Bob 大叔,就是第一個(gè)提出 SOLID 這個(gè)集合體的人(20 世紀(jì) 80 年代末,在 USENET 新聞組)。
一個(gè)函數(shù)不要出現(xiàn)多個(gè)層級(jí)的代碼
// IrisFriends 拉取好友func IrisFriends(ctx iris.Context, app *app.App) { var rsp sdc.FriendsRsp defer func() { var buf bytes.Buffer _ = (&jsonpb.Marshaler{EmitDefaults: true}).Marshal(&buf, &rsp) _, _ = ctx.Write(buf.Bytes()) }() common.AdjustCookie(ctx) if !checkCookie(ctx) { return } // 從cookie中拿到關(guān)鍵的登陸態(tài)等有效信息 var session common.BaseSession common.GetBaseSessionFromCookie(ctx, &session) // 校驗(yàn)登陸態(tài) err := common.CheckLoginSig(session, app.ConfigStore.Get().OIDBCmdSetting.PTLogin) if err != nil { _ = common.ErrorResponse(ctx, errors.PTSigErr, 0, "check login sig error") return } if err = getRelationship(ctx, app.ConfigStore.Get().OIDBCmdSetting, NewAPI(), &rsp); err != nil { // TODO:日志 } return}
上面這一段代碼,是我隨意找的一段代碼。邏輯非常清晰,因?yàn)槌俗钌厦?defer 寫回包的代碼,其他部分都是頂層函數(shù)組合出來的。閱讀代碼,我們不會(huì)掉到細(xì)節(jié)里出不來,反而忽略了整個(gè)業(yè)務(wù)流程。同時(shí),我們能明顯發(fā)現(xiàn)它沒寫完,以及 common.ErrorResponse 和 defer func 兩個(gè)地方都寫了回包,可能出現(xiàn)發(fā)起兩次 http 回包。TODO 也會(huì)非常顯眼。
想象一下,我們沒有把細(xì)節(jié)收歸進(jìn) checkCookie()、getRelationship()等函數(shù),而是展開在這里,但是總函數(shù)行數(shù)沒有到 80 行,表面上符合規(guī)范。但是實(shí)際上,閱讀代碼的同學(xué)不再能輕松掌握業(yè)務(wù)邏輯,而是同時(shí)在閱讀功能細(xì)節(jié)和業(yè)務(wù)流程。閱讀代碼變成了每個(gè)時(shí)刻心智負(fù)擔(dān)都很重的事情。
顯而易見,單個(gè)函數(shù)里應(yīng)該只保留某一個(gè)層級(jí)(layer)的代碼,更細(xì)化的細(xì)節(jié)應(yīng)該被抽象到下一個(gè) layer 去,成為子函數(shù)。
Unix 哲學(xué)基礎(chǔ)
《Code Review 我都 CR 些什么》講解了很多 Unix 的設(shè)計(jì)哲學(xué)。這里不再贅述,僅僅列舉一下。大家自行閱讀和參悟,并且運(yùn)用到編碼、review 活動(dòng)中。
- 模塊原則: 使用簡潔的接口拼合簡單的部件
- 清晰原則: 清晰勝于技巧
- 組合原則: 設(shè)計(jì)時(shí)考慮拼接組合
- 分離原則: 策略同機(jī)制分離,接口同引擎分離
- 簡潔原則: 設(shè)計(jì)要簡潔,復(fù)雜度能低則低
- 吝嗇原則: 除非確無它法,不要編寫龐大的程序
- 透明性原則: 設(shè)計(jì)要可見,以便審查和調(diào)試
- 健壯原則: 健壯源于透明與簡潔
- 表示原則: 把知識(shí)疊入數(shù)據(jù)以求邏輯質(zhì)樸而健壯
- 通俗原則: 接口設(shè)計(jì)避免標(biāo)新立異
- 緘默原則: 如果一個(gè)程序沒什么好說,就保持沉默
- 補(bǔ)救原則: 出現(xiàn)異常時(shí),馬上退出并給出足量錯(cuò)誤信息
- 經(jīng)濟(jì)原則: 寧花機(jī)器一分,不花程序員一秒
- 生成原則: 避免手工 hack,盡量編寫程序去生成程序
- 優(yōu)化原則: 雕琢前先得有原型,跑之前先學(xué)會(huì)走
- 多樣原則: 絕不相信所謂"不二法門"的斷言
- 擴(kuò)展原則: 設(shè)計(jì)著眼未來,未來總比預(yù)想快
工程師的自我修養(yǎng)
下面,是一些在 review 細(xì)節(jié)中不能直接使用的原則。更像是一種信念和自我約束。帶著這些信念去編寫、review 代碼,把這些信念在實(shí)踐中傳遞下去,將是極有價(jià)值的。
偏執(zhí)
對(duì)代碼細(xì)節(jié)偏執(zhí)的觀念,是我自己提出的新觀點(diǎn)。在當(dāng)下研發(fā)質(zhì)量不高的騰訊,是很有必要普遍存在的一個(gè)觀念。在一個(gè)系統(tǒng)不完善、時(shí)間安排荒謬、工具可笑、需求不可能實(shí)現(xiàn)的世界里,讓我們安全行事吧。就像伍迪-艾倫說的:"當(dāng)所有人都真的在給你找麻煩的時(shí)候,偏執(zhí)就是一個(gè)好主意。"
對(duì)于一個(gè)方案,一個(gè)實(shí)現(xiàn),請(qǐng)不要說出"好像這樣也可以"。你一定要選出一個(gè)更好的做法,并且一直堅(jiān)持這個(gè)做法,并且要求別人也這樣做。既然他來讓你 review 了,你就要有自己的偏執(zhí),你一定要他按照你覺得合適的方式去做。當(dāng)然,你得有說服得了自己,也說服得了他人的理由。即使,只有一點(diǎn)點(diǎn)。偏執(zhí)會(huì)讓你的世界變得簡單,你的團(tuán)隊(duì)的協(xié)作變得簡單。特別當(dāng)你身處一個(gè)編碼質(zhì)量低下的團(tuán)隊(duì)的時(shí)候。你至少能說,我是一個(gè)務(wù)實(shí)的程序員。
控制軟件的熵是軟件工程的重要任務(wù)之一
熵是個(gè)物理學(xué)概念,大家可能看過諾蘭的電影《信條》。簡單來說,熵可以理解為'混亂程度'。我們的項(xiàng)目,在剛開始的幾千行代碼,是很簡潔的。但是,為什么到了 100w 行,我們常常就感覺'太復(fù)雜了'?比如 QQ 客戶端,最近終于在做大面積重構(gòu),但是發(fā)現(xiàn)無數(shù) crash。其中一個(gè)重要原因,就是'混亂程度'太高了。'混亂程度',理解起來還是比較抽象,它有很多其他名字。'hard code 很多'、'特殊邏輯很多'、'定制化邏輯很多'。再換另一個(gè)抽象的說法,'我們面對(duì)一類問題,采取了過多的范式和特殊邏輯細(xì)節(jié)去實(shí)現(xiàn)它'。
熵,是一點(diǎn)點(diǎn)堆疊起來的,在一個(gè)需求的 2000 行代碼更改中,你可能就引入了一個(gè)不同的范式,打破了之前的通用范式。在微觀來看,你覺得你的代碼是'整潔干凈'的。就像一個(gè)已經(jīng)穿著好看的紅色風(fēng)衣的人,你隔一天讓他接著穿上一條綠色的褲子,這還干凈整潔么?熵,在不斷增加,我們需要做到以下幾點(diǎn),不然你的團(tuán)隊(duì)將在希望通過重構(gòu)來降低項(xiàng)目的熵的時(shí)候嘗到惡果,甚至放棄重構(gòu),讓熵不斷增長下去。
- 如果沒有充分的理由,始終使用項(xiàng)目規(guī)范的范式對(duì)每一類問題做出解決方案。
- 如果業(yè)務(wù)發(fā)展發(fā)現(xiàn)老的解決方案不再優(yōu)秀,做整體重構(gòu)。
- 項(xiàng)目級(jí)主干開發(fā),對(duì)重構(gòu)很友好,讓重構(gòu)變得可行。(客戶端很容易實(shí)現(xiàn)主干開發(fā))。
- 務(wù)實(shí)地講,重構(gòu)已經(jīng)不可能了。那么,你們可以謹(jǐn)慎地提出新的一整套范式。重建它。
- 禁止 hardcode,特殊邏輯。如果你發(fā)現(xiàn)特殊邏輯容易實(shí)現(xiàn)需求,否則很難。那么,你的架構(gòu)已經(jīng)出現(xiàn)問題了,你和你的團(tuán)隊(duì)?wèi)?yīng)該深入思考這個(gè)問題,而不是輕易加上一個(gè)特殊邏輯。
為測試做設(shè)計(jì)
現(xiàn)在我們?cè)谧?#039;測試左移',讓工程師編寫自動(dòng)化測試來保證質(zhì)量。測試工程師的工作更多的是類似 google SET(Software Engineer In Test, 參考《google 軟件測試之道》)的工作。工作重心在于測試編碼規(guī)范、測試編碼流程、測試編碼工具、測試平臺(tái)的思考和建設(shè)。測試代碼,還是得工程師來做。
為方法寫一個(gè)測試的考慮過程,使我們得以從外部看待這個(gè)方法,這讓我們看起來是代碼的客戶,而不是代碼的作者。很多同學(xué),就感覺很難受。對(duì),這是必然的。因?yàn)槟愕拇a設(shè)計(jì)的時(shí)候,并沒有把'容易測試'考慮進(jìn)去,可測試性不強(qiáng)。如果工程師在開發(fā)邏輯的過程中,就同時(shí)思考著這段代碼怎樣才能輕松地被測試。那么,這段寫就的代碼,同時(shí)可讀性、簡單性都會(huì)得到保障,經(jīng)過了良好的設(shè)計(jì),而不僅僅是'能工作'。
我覺得,測試獲得的主要好處發(fā)生在你考慮測試及編寫測試的時(shí)候,而不是在運(yùn)行測試的時(shí)候!在編碼的時(shí)候同時(shí)讓思考怎么測試的思維存在,會(huì)讓編寫高質(zhì)量的代碼變得簡單,在編碼時(shí)就更多地考慮邊界條件、異常條件,并且妥善處理。僅僅是抱有這個(gè)思維,不去真地編寫自動(dòng)化測試,就能讓代碼的質(zhì)量上升,代碼架構(gòu)的能力得到提升。
硬件工程出 bug 很難查,bug 造成的成本很高,每次都要重新做一套模具、做模具的工具。所以硬件工程往往有層層測試,極早發(fā)現(xiàn)問題,盡量保證簡單且質(zhì)量高。我們可以在軟件上做同樣的事情。與硬件工程師一樣,從一開始就在軟件中構(gòu)建可測試性,并且嘗試將每個(gè)部分連接在一起之前,對(duì)他們進(jìn)行徹底的測試。
這個(gè)時(shí)候,有人就說,TDD 就是這樣,讓你同時(shí)思考編碼架構(gòu)和測試架構(gòu)。我對(duì) TDD 的態(tài)度是: 它不一定就是最好的。測試對(duì)開發(fā)的驅(qū)動(dòng),絕對(duì)有幫助。但是,就像每次驅(qū)動(dòng)汽車一樣,除非心里有一個(gè)目的地,否則就可能會(huì)兜圈子。TDD 是一種自底向上的編程方法。但是,適當(dāng)?shù)臅r(shí)候使用自頂向下設(shè)計(jì),才能獲得一個(gè)最好的整體架構(gòu)。很多人處理不好自頂向下和自底向上的關(guān)系,結(jié)果在使用 TDD 的時(shí)候發(fā)現(xiàn)舉步維艱、收效甚微。
以及,如果沒有強(qiáng)大的外部驅(qū)動(dòng)力,"以后再測"實(shí)際上意味著"永遠(yuǎn)不測"。大家,務(wù)實(shí)一點(diǎn),在編碼時(shí)就考慮怎么測試。不然,你永遠(yuǎn)沒有機(jī)會(huì)考慮了。當(dāng)面對(duì)著測試性低的代碼,需要編寫自動(dòng)化測試的時(shí)候,你會(huì)感覺很難受。
盡早測試, 經(jīng)常測試, 自動(dòng)測試
一旦代碼寫出來,就要盡早開始測試。這些小魚的惡心之處在于,它們很快就會(huì)變成巨大的食人鯊,而捕捉鯊魚則相當(dāng)困難。所以我們要寫單元測試,寫很多單元測試。
事實(shí)上,好項(xiàng)目的測試代碼可能會(huì)比產(chǎn)品代碼更多。生成這些測試代碼所花費(fèi)的時(shí)間是值得的。從長遠(yuǎn)來看,最終的成本會(huì)低得多,而且你實(shí)際上有機(jī)會(huì)生產(chǎn)出幾乎沒有缺陷的產(chǎn)品。
另外,知道通過了測試,可以讓你對(duì)代碼已經(jīng)"完成"產(chǎn)生高度信心。
項(xiàng)目中使用統(tǒng)一的術(shù)語
如果用戶和開發(fā)者使用不同的名稱來稱呼相同的事物,或者更糟糕的是,使用相同的名稱來代指不同的事物,那么項(xiàng)目就很難取得成功。
DDD(Domain-Driven Design)把'項(xiàng)目中使用統(tǒng)一的術(shù)語'做到了極致,要求項(xiàng)目把目標(biāo)系統(tǒng)分解為不同的領(lǐng)域(也可以稱作上下文)。在不同的上下文中,同一個(gè)術(shù)語名字意義可能不同,但是要項(xiàng)目內(nèi)統(tǒng)一認(rèn)識(shí)。比如證券這個(gè)詞,是個(gè)多種經(jīng)濟(jì)權(quán)益憑證的統(tǒng)稱,在股票、債券、權(quán)證市場,意義和規(guī)則是完全不同的。當(dāng)你第一次聽說'渦輪(港股特有金融衍生品,是一種股權(quán))'的時(shí)候,是不是瞬間蒙圈,搞不清它和證券的關(guān)系了。買'渦輪'是在買什么鬼證劵?
在軟件領(lǐng)域是一樣的。你需要對(duì)股票、債券、權(quán)證市場建模,你就得有不同的領(lǐng)域,在每個(gè)領(lǐng)域里有一套詞匯表(實(shí)體、值對(duì)象),在不同的領(lǐng)域之間,同一個(gè)概念可能會(huì)換一個(gè)名字,需要映射。如果你們既不區(qū)分領(lǐng)域,甚至在同一個(gè)領(lǐng)域還對(duì)同一個(gè)實(shí)體給出不同的名字。那,你們?cè)趺创_保自己溝通到位了?寫成代碼,別人如何知道你現(xiàn)在寫的'證券'這個(gè) struct 具體是指的什么?
不要面向需求編程
需求不是架構(gòu);需求無關(guān)設(shè)計(jì),也非用戶界面;需求就是需要的東西。需要的東西是經(jīng)常變化的,是不斷被探索,不斷被加深認(rèn)識(shí)的。產(chǎn)品經(jīng)理的說辭是經(jīng)常變化的。當(dāng)你面向需求編程,你就是在依賴一個(gè)認(rèn)識(shí)每一秒都在改變的女/男朋友。你將身心俱疲。
我們應(yīng)該面向業(yè)務(wù)模型編程。我在《Code Review 我都 CR 些什么》里也提到了這一點(diǎn),但是我當(dāng)時(shí)并沒有給出應(yīng)該怎么去設(shè)計(jì)業(yè)務(wù)模型的指導(dǎo)。我的潛臺(tái)詞就是,你還是僅僅能憑借自己的智力和經(jīng)驗(yàn),沒有很多方法論工具。
現(xiàn)在,我給你推薦一個(gè)工具,DDD(Domain-Driven Design),面向領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)。它能讓你對(duì)業(yè)務(wù)更好地建模,讓對(duì)業(yè)務(wù)建模變成一個(gè)可拆解的執(zhí)行步驟,僅僅需要少得多的智力和經(jīng)驗(yàn)。區(qū)分好領(lǐng)域上下文,思考明白它們之間的關(guān)系,找到領(lǐng)域下的實(shí)體和值對(duì)象,找到和模型貼合的架構(gòu)方案。這些任務(wù),讓業(yè)務(wù)建模變得簡單。
當(dāng)我們面向業(yè)務(wù)模型編程,變更的需求就變成了–提供給用戶他所需要的業(yè)務(wù)模型的不同部分。我們不再是在不斷地 change 代碼,而是在不斷地 extend 代碼,逐漸做完一個(gè)業(yè)務(wù)模型的填空題。
寫代碼要有對(duì)于'美'的追求
google 的很多同學(xué)說(至少 hankzheng 這么說),軟件工程=科學(xué) 藝術(shù)。當(dāng)前騰訊,很多人,不講科學(xué)。工程學(xué),計(jì)算機(jī)科學(xué),都不管。就喜歡搞'巧合式編程'。剛好能工作了,打完收工,交付需求。絕大多數(shù)人,根本不追求編碼、設(shè)計(jì)的藝術(shù)。對(duì)細(xì)節(jié)的好看,毫無感覺。對(duì)于一個(gè)空格、空行的使用,毫無邏輯,毫無美感。用代碼和其他人溝通,連基本的整潔、合理都不講。根本沒想過,別人會(huì)看我的代碼,我要給代碼'梳妝打扮'一下,整潔大方,美麗動(dòng)人,還極有內(nèi)涵。'窈窕淑女,君子好逑',我們應(yīng)該對(duì)別人慷慨一點(diǎn),你總是得閱讀別人的代碼的。大家都對(duì)美有一點(diǎn)追求,就是互相都慷慨一些。
很無奈,我把對(duì)美的追求說得這么'卑微'。必須要由'務(wù)實(shí)的需要'來構(gòu)建必要性。而不是每個(gè)工程師發(fā)自內(nèi)心的,像對(duì)待漂亮的異性、好的音樂、好的電影一樣的發(fā)自內(nèi)心的需要它。認(rèn)為代碼也是取悅別人、取悅自己的東西。
如果我們想做一個(gè)有尊嚴(yán)、有格調(diào)的工程師,我們就應(yīng)該把自己的代碼、勞動(dòng)的產(chǎn)物,當(dāng)成一件藝術(shù)品去雕琢。務(wù)實(shí)地追求效率,同時(shí)也追求美感。效率產(chǎn)出價(jià)值,美感取悅自己。不僅僅是為了一口飯,同時(shí)也把工程師的工作當(dāng)成自己一個(gè)快樂的源頭。工作不再是 overhead,而是 happiness。此刻,你做不到,但是應(yīng)該有這樣的追求。當(dāng)我們都有了這樣的追求,有一天,我們會(huì)能像 google 一樣做到的 。
換行
換行
換行
應(yīng)用程序框架是實(shí)現(xiàn)細(xì)節(jié)
以下是《整潔架構(gòu)之道》的原文摘抄:
對(duì),DIP 大發(fā)神威。我覺得核心做法就是:
- 核心代碼應(yīng)該通過 DIP 來讓它不要和具體框架綁定!它應(yīng)該使用 DIP(比如代理類),抽象出一個(gè)防腐層,讓自己的核心代碼免于腐壞。
- 選擇一個(gè)框架,你不去做防腐層(主要通過 DIP),你就是單方面領(lǐng)了結(jié)婚證,你只有義務(wù),沒有權(quán)利。同學(xué)們要想明白。同學(xué)們應(yīng)該對(duì)框架本身是否優(yōu)秀,是否足夠組件化,它本身能否在項(xiàng)目里做到可插拔,做出思考和設(shè)計(jì)。
trpc-go 對(duì)于插件化這事兒,做得還不錯(cuò),大家會(huì)積極地使用它。trpc-cpp 離插件化非常遠(yuǎn),它自己根本就成不了一個(gè)插件,而是有一種要強(qiáng)暴你的感覺,你能憑直覺明顯地感覺到不愿意和它訂終身。例如,trpc-cpp 甚至強(qiáng)暴了你構(gòu)建、編譯項(xiàng)目的方式。當(dāng)然,這很多時(shí)候是 c 語言本身的問題。
‘解耦’、'插件化’就是 golang 語言的關(guān)鍵詞。大家開玩笑說,c 已經(jīng)被委員會(huì)玩壞了,加入了太多特性。less is more, more means nothing。c 從來都是讓別的工具來解決自己的問題,trpc-cpp 可能把自己松綁定到 bazel 等優(yōu)秀的構(gòu)建方案。尋求優(yōu)秀的組件去軟綁定,提供解決方案,是可行的出路。我個(gè)人喜歡 rust。但是大家還是熟悉 cpp,我們確實(shí)需要一個(gè)投入更多人力做得更好的 trpc-cpp。
一切都應(yīng)該是代碼(通過代碼去顯式組合)
Unix 編程哲學(xué)告訴我們: 如果有一些參數(shù)是可變的,我們應(yīng)該使用配置,而不是把參數(shù)寫死在代碼里。在騰訊,這一點(diǎn)做得很好。但是,大人,現(xiàn)在時(shí)代又變了。
J2EE 框架讓我們看到,組件也可以是通過配置 Java Bean 的形式注入到框架里的。J2EE 實(shí)現(xiàn)了把組件也配置化的壯舉。但是,時(shí)代變了!你下載一個(gè) golang 編譯器,你進(jìn)入你下載的文件里去看,會(huì)發(fā)現(xiàn)你找不到任何配置文件。這是為什么?兩個(gè)簡單,但是很多年都被人們忽略的道理:
- 配置即隱性耦合。配置只有和使用配置的代碼組合使用,它才能完成它的工作。它是通過把'一個(gè)事情分開兩個(gè)步驟'來換取動(dòng)態(tài)性。換句話說,它讓兩個(gè)相隔十萬八千里的地方產(chǎn)生了耦合!作為工程師,你一開始就要理解雙倍的復(fù)雜度。配置如何使用、配置的處理程序會(huì)如何解讀配置。
- 代碼能夠有很強(qiáng)的自解釋能力,工程師們更愿意閱讀可讀性強(qiáng)的代碼,而不是編寫得很爛的配置文檔。配置只能通過厚重的配置說明書去解釋。當(dāng)你缺乏完備的配置說明書,配置變成了地獄。
golang 的編譯器是怎么做的呢?它會(huì)在代碼里給你設(shè)定一個(gè)通用性較強(qiáng)的默認(rèn)配置項(xiàng)。同時(shí),配置項(xiàng)都是集中管理的,就像管理配置文件一樣。你可以通過額外配置一個(gè)配置文件或者命令行參數(shù),來改變編譯器的行為。這就變成了,代碼解釋了每一個(gè)配置項(xiàng)是用來做什么的。只有當(dāng)你需要的時(shí)候,你會(huì)先看懂代碼,然后,當(dāng)你有需求的時(shí)候,通過額外的配置去改變一個(gè)你有預(yù)期的行為。
邏輯變成了。一開始,所有事情都是解耦的。一件事情都只看一塊代碼就能明白。代碼有較好的自解釋性和注解,不再需要費(fèi)勁地編寫撇腳的文檔。當(dāng)你明白之后,你需要不一樣的行為,就通過額外的配置來實(shí)現(xiàn)。關(guān)于怎么配置,代碼里也講明白了。
對(duì)于 trpc-go 框架,以及一眾插件,優(yōu)先考慮配置,然后才是代碼去指定,部分功能還只能通過配置去指定,我就很難受。我接受它,就得把一個(gè)事情放在兩個(gè)地方去完成:
- 需要在代碼里 import 插件包。
- 需要在配置文件里配置插件參數(shù)。
既然不能消滅第一步,為什么不能是顯式 import,同時(shí)通過代碼 其他自定義配置管理方案去完成插件的配置?當(dāng)然,插件,直接不需要任何配置,提供代碼 Option 去改變插件的行為,是最香的。這個(gè)時(shí)候,我就真的能把 trpc 框架本身也當(dāng)成一個(gè)插件來使用了。
封裝不一定是好的組織形式
封裝(Encapsulation),是我上學(xué)時(shí)剛接觸 OOP,驚為天人的思想方法。但是,我工作了一些年頭了,看過了不知道多少腐爛的代碼。其中一部分還需要我來維護(hù)。我看到了很多莫名其妙的封裝,讓我難受至極。封裝,經(jīng)常被濫用。封裝的時(shí)候,我們一定要讓自己的代碼,自己就能解釋自己是按照下面的哪一種套路在做封裝:
- 按層封裝
- 按功能封裝
- 按領(lǐng)域封裝
- 按組件封裝
或者,其他能被命名到某種有價(jià)值的類型的封裝。你要能說出為什么你的封裝是必要的,有價(jià)值的。必要的時(shí)候,你必須要封裝。比如,當(dāng)你的 golang 函數(shù)達(dá)到了 80 行,你就應(yīng)該對(duì)邏輯分組,或者把一塊過于細(xì)節(jié)化卻功能單一的較長的代碼獨(dú)立到一個(gè)函數(shù)。同時(shí),你又不能胡亂封裝,或者過度封裝。是否過度,取決于大家的共識(shí),要 reviwer 能認(rèn)可你這個(gè)封裝是有價(jià)值的。當(dāng)然,你也會(huì)成為 reviewer,別人也需要獲得你的認(rèn)可。缺乏意圖設(shè)計(jì)的封裝,是破壞性的。這會(huì)使其他人在面對(duì)這段代碼時(shí),畏首畏尾,不敢修改它。形成一個(gè)腐爛的肉塊,并且,這種腐爛會(huì)逐漸蔓延開來。
所以,所有細(xì)節(jié)都是關(guān)鍵的。每一塊磚頭都被精心設(shè)計(jì),才能構(gòu)建一個(gè)漂亮的項(xiàng)目!
所有細(xì)節(jié)都應(yīng)該被顯式處理
這是一個(gè)顯而易見的道理。但是很多同學(xué)卻毫無知覺。我為需要深入閱讀他們編寫的代碼的同學(xué)默哀一秒。當(dāng)有一個(gè)函數(shù) func F() error,我僅僅是用 F(),沒有用變量接收它的返回值。你閱讀代碼的時(shí)候,你就會(huì)想,第一開發(fā)者是忘記了 error handling 了,還是他思考過了,他決定不關(guān)注這個(gè)返回值?他是設(shè)計(jì)如此,還是這里是個(gè) bug?他人即地獄,維護(hù)代碼的苦難又多了一分。
我們對(duì)于自己的代碼可能會(huì)給別人帶來困擾的地方,都應(yīng)該顯式地去處理。就像寫了一篇不會(huì)有歧義的文章。如果就是想要忽略錯(cuò)誤,'_ = F()'搞定。我將來再處理錯(cuò)誤邏輯,'_ = F() // TODO 這里需要更好地處理錯(cuò)誤'。在代碼里,把事情講明白,所有人都能快速理解他人的代碼,就能快速做出修改的決策。'猜測他人代碼的邏輯用意'是很難受且困難的,他人的代碼也會(huì)在這種場景下,產(chǎn)生被誤讀。
不能上升到原則的一些常見案例
合理注釋一些并不'通俗'的邏輯和數(shù)值
和'所有細(xì)節(jié)都應(yīng)該被顯式處理'一脈相承。所有他人可能要花較多時(shí)間猜測原因的細(xì)節(jié),都應(yīng)該在代碼里提前清楚地講明白。請(qǐng)慷慨一點(diǎn)。也可能,三個(gè)月后的將來,是你回來 eat your own dog food。
習(xí)慣留下 TODO
要這么做的道理很簡單。便于所有人能接著你開發(fā)。極有可能就是你自己接著自己開發(fā)。如果沒有標(biāo)注 TODO 把沒有做完的事情標(biāo)示出來??赡?,你自己都會(huì)搞忘自己有事兒沒做完了。留下 TODO 是很簡單的事情,我們?yōu)槭裁床蛔瞿兀?/span>
不要丟棄錯(cuò)誤信息
即'錯(cuò)誤傳遞原則'。這里給它換個(gè)名字–你不應(yīng)該主動(dòng)把很多有用的信息給丟棄了。
自動(dòng)化測試要快
在 google,自動(dòng)化測試是硬性要求在限定時(shí)間內(nèi)跑完的。這從細(xì)節(jié)上保障了自動(dòng)化測試的速度,進(jìn)而保障了自動(dòng)化測試的價(jià)值和可用性。你真的需要 sleep 這么久?應(yīng)該認(rèn)真考量??剂壳宄税言?qū)懴聛怼.?dāng)大家發(fā)現(xiàn)總時(shí)長太長的時(shí)候,可以選擇其中最不必要的部分做優(yōu)化。
歷史有問題的代碼, 發(fā)現(xiàn)了問題要及時(shí) push 相關(guān)人主動(dòng)解決
這是'控制軟件的熵是軟件工程的重要任務(wù)之一'的表現(xiàn)之一。我們是團(tuán)隊(duì)作戰(zhàn),不是無組織無記錄的部隊(duì)。發(fā)現(xiàn)了問題,就及時(shí)拋出和解決。讓傷痛更少,跑得更快。
less is more
less is more. 《Code Review 我都 CR 些什么》強(qiáng)調(diào)過了,這里不再強(qiáng)調(diào)。
less is more
如果打了錯(cuò)誤日志, 有效信息必須充足, 且不過多
和'less is more'一脈相承。同時(shí),必須有的時(shí)候,就得有,不能漏。
日志
注釋要把問題講清楚, 講不清楚的日志等于沒有
是個(gè)簡單的道理,和'所有細(xì)節(jié)都應(yīng)該被顯式處理'一脈相承。
日志
MR 要自己先 review, 不要浪費(fèi) reviewer 的時(shí)間
你也會(huì)成為 reviewer,節(jié)省他人的時(shí)間,他人也節(jié)省你的時(shí)間??s短交互次數(shù),提升 review 的愉悅感。讓他人提的 comment 都是'言之有物'的東西,而不是一些反反復(fù)復(fù)的最基礎(chǔ)的細(xì)節(jié)。會(huì)讓他人更愉悅,自己在看 comment 的時(shí)候,也更愉悅,更愿意去討論、溝通。讓 code review 成為一個(gè)技術(shù)交流的平臺(tái)。
時(shí)間
要尋找合適的定語
這個(gè)顯而易見。但是,同學(xué)們就是愛放縱自己?
定語
不要出現(xiàn)特定 IP,或者把什么可變的東西寫死
這個(gè)和'ETC'一脈相承,我覺得也是顯而易見的東西。但是很多同學(xué)還是喜歡放縱自己?
寫死
使用定語, 不要 1、2、3、4
這個(gè)存粹就是放縱自己了。當(dāng)然,也會(huì)有只能用 1、2、3、4 的時(shí)候。但是,你這里,是么?多數(shù)時(shí)候,都不會(huì)是。
數(shù)字
有必要才使用 init
這,也顯而易見。init 很方便,但是,它也會(huì)帶來心智負(fù)擔(dān)。
init
要關(guān)注 shadow write
這個(gè)很重要,看例子就知道了。但是大家常常忽略,特此提一下。
shadow
能不耦合接收器就別耦合
減少耦合是我們保障代碼質(zhì)量的重要手段。請(qǐng)把 ETC 原則放在自己的頭上漂浮著,時(shí)刻帶著它思考,不要懶惰。熟能生巧,它并不會(huì)成為心智負(fù)擔(dān)。反而常常會(huì)在你做決策的時(shí)候幫你快速找到方向,提升決策速度。
接收器
空實(shí)現(xiàn)需要注明空實(shí)現(xiàn)就是實(shí)現(xiàn)
這個(gè)和'所有細(xì)節(jié)都應(yīng)該被顯式處理'一脈相承。這個(gè)理念,我見過無數(shù)種形式表現(xiàn)出來。這里就是其中一種。列舉這個(gè) case,讓你印象再深刻一點(diǎn)。
空實(shí)現(xiàn)
看錯(cuò)題集沒多少有用, 我們需要教練和傳承
上面我列了很多例子。是我能列出來的例子中的九牛一毛。但是,我列一個(gè)非常龐大的錯(cuò)題集沒有任何用。我也不再例舉更多。只有當(dāng)大家信奉了敏捷工程的美。認(rèn)可好的代碼架構(gòu)對(duì)于業(yè)務(wù)的價(jià)值,才能真正地做到舉一反三,理解無數(shù)例子,能對(duì)更多的 case 自己做出合理的判斷。同時(shí),把好的判斷傳播起來,做到"群體免疫",最終做好 review,做好代碼質(zhì)量。
展望
希望本文能幫助到需要做好 CR、做好編碼,需要培養(yǎng)更多 reviwer 的團(tuán)隊(duì)。讓你門看到很多原則,吸收這些原則和理念。去理解、相信這些理念。在 CR 中把這些理念、原則傳播出去。成為別人的臨時(shí)教練,讓大家都成為合格的 reviwer。加強(qiáng)對(duì)于代碼的交流,飛輪效應(yīng),讓團(tuán)隊(duì)構(gòu)建好的人才梯度和工程文化。
寫到最后,我發(fā)現(xiàn),我上面寫的這些東西都不那么重要了。你有想把代碼寫得更利于團(tuán)隊(duì)協(xié)作的價(jià)值觀和態(tài)度,反而是最重要的事情。上面講的都僅僅是寫高質(zhì)量代碼的手段和思想方法。當(dāng)你認(rèn)可了'應(yīng)該編寫利于團(tuán)隊(duì)協(xié)作的高質(zhì)量代碼',并且擁有對(duì)'不利于團(tuán)隊(duì)代碼質(zhì)量的代碼'嫉惡如仇的態(tài)度。你總能找到高質(zhì)量代碼的寫法。沒有我?guī)湍憧偨Y(jié),你也總會(huì)掌握!
拾遺
如果你深入了解 DDD,就會(huì)了解到'六邊形架構(gòu)'、'CQRS(Command Query Responsibility Segregation,查詢職責(zé)分離)架構(gòu)'、'事件驅(qū)動(dòng)架構(gòu)'等關(guān)鍵詞。這是 DDD 構(gòu)建自己體系的基石,這些架構(gòu)及是細(xì)節(jié)又是頂層設(shè)計(jì),也值得了解一下。