鵝廠練習 13 年 Coding 後,我悟了

點擊鏈接瞭解詳情

img


導讀

本文主要受《程序員修煉之道: 通向務實的最高境界》、《架構整潔之道》、《Unix 編程藝術》啓發。我不是第一個發明這些原則的人,甚至不是第一個總結出來的人,別人都已經寫成書了!務實的程序員對於方法的總結,總是殊途同歸。

目錄

1 細節即是架構

2 把代碼和文檔綁在一起

3 ETC 價值觀

4 DRY 原則

……

19 SOLID

20 一個函數不要出現多個層級的代碼

21 Unix 哲學基礎

01

細節即是架構

下面是原文摘錄,我有類似觀點,但是原文就寫得很好,直接摘錄。

一直以來,設計(Design)和架構(Architecture)這兩個概念讓大多數人十分迷惑--什麼是設計?什麼是架構?二者究竟有什麼區別?二者沒有區別。一丁點區別都沒有!"架構"這個詞往往適用於"高層級"的討論中,這類討論一般都把"底層"的實現細節排除在外。而"設計"一詞,往往用來指代具體的系統底層組織結構和實現的細節。但是,從一個真正的系統架構師的日常工作來看,這些區分是根本不成立的。以給我設計新房子的建築設計師要做的事情爲例。新房子當然是存在着既定架構的,但這個架構具體包含哪些內容呢?首先,它應該包括房屋的形狀、外觀設計、垂直高度、房間的佈局,等等。

但是,如果查看建築設計師使用的圖紙,會發現其中也充斥着大量的設計細節。譬如,我們可以看到每個插座、開關以及每個電燈具體的安裝位置,同時也可以看到某個開關與所控制的電燈的具體連接信息;我們也能看到壁爐的具體位置,熱水器的大小和位置信息,甚至是污水泵的位置;同時也可以看到關於牆體、屋頂和地基所有非常詳細的建造說明。總的來說,架構圖裏實際上包含了所有的底層設計細節,這些細節信息共同支撐了頂層的架構設計,底層設計信息和頂層架構設計共同組成了整個房屋的架構文檔。

軟件設計也是如此。底層設計細節和高層架構信息是不可分割的。他們組合在一起,共同定義了整個軟件系統,缺一不可。所謂的底層和高層本身就是一系列決策組成的連續體,並沒有清晰的分界線。

我們編寫、review 細節代碼,就是在做架構設計的一部分。我們編寫的細節代碼構成了整個系統。我們就應該在細節 review 中,始終帶着所有架構原則去審視。你會發現,你已經寫下了無數讓整體變得醜陋的細節,它們背後,都有前人總結過的架構原則。

02

把代碼和文檔綁在一起(自解釋原則)

寫文檔是個好習慣。但是寫一個別人需要諮詢老開發者才能找到的文檔,是個壞習慣。這個壞習慣甚至會給工程師們帶來傷害。比如,當初始開發者寫的文檔在一個犄角旮旯(在 wiki 裏,但是閱讀代碼的時候沒有在明顯的位置看到鏈接),後續代碼被修改了,文檔已經過時,有人再找出文檔來獲取到過時、錯誤的知識的時候,閱讀文檔這個同學的開發效率必然受到傷害。所以,如同 Golang 的 godoc 工具能把代碼裏“按規範來”的註釋自動生成一個文檔頁面一樣,我們應該:

▶︎ 按照 godoc 的要求好好寫代碼的註釋。

▶︎ 代碼首先要自解釋,當解釋不了的時候,需要就近、合理地寫註釋。

▶︎ 當小段的註釋不能解釋清楚的時候,應該有 doc.go 來解釋,或者在同級目錄的 ReadMe.md 裏註釋講解。

▶︎ 文檔需要強大的富文本編輯能力,Down 無法滿足,可以寫到 wiki 裏,同時必須把 wiki 的簡單描述和鏈接放在代碼裏合適的位置。讓閱讀和維護代碼的同學一眼就看到,能做到及時的維護。

以上總結起來就是,解釋信息必須離被解釋的東西越近越好。代碼能做到自解釋,是最棒的。

img

03

ETC 價值觀(easy to change)

ETC 是一種價值觀念,不是一條原則。價值觀念是幫助你做決定的: 我應該做這個,還是做那個?當你在軟件領域思考時,ETC 是個嚮導,它能幫助你在不同的路線中選出一條。就像其他一些價值觀念一樣,你應該讓它漂浮在意識思維之下,讓它微妙地將你推向正確的方向。

敏捷軟件工程,所謂敏捷,就是要能快速變更,並且在變更中保持代碼的質量。所以,持有 ETC 價值觀看待代碼細節、技術方案,我們將能更好地編寫出適合敏捷項目的代碼。這是一個大的價值觀,不是一個基礎微觀的原則,所以沒有例子。本文提到的所有原則,或直接,或間接,都要爲 ETC 服務。

04

DRY 原則(don not repeat yourself)

我認爲 DRY 原則是編碼原則中最重要的編碼原則,沒有之一(ETC 是個觀念)。不要重複!不要重複!不要重複!

img

05

正交性原則(全局變量的危害)

“正交性”是幾何學中的術語。我們的代碼應該消除不相關事物之間的影響。這是一個簡單的道理。我們寫代碼要“高內聚、低耦合”,這是大家都在提的。

但是,你有爲了使用某個 class 一堆能力中的某個能力而去派生它麼?你有寫過一個 helper 工具,它什麼都做麼?在騰訊,我相信你是做過的。你自己說,你這是不是爲了複用一點點代碼,而讓兩大塊甚至多塊代碼耦合在一起,不再正交了?大家可能並不是不明白正交性的價值,只是不知道怎麼去正交。手段有很多,但是首先我就要批判一下 OOP。它的核心是多態,多態需要通過派生/繼承來實現。繼承樹一旦寫出來,就變得很難 change,你不得不爲了使用一小段代碼而去做繼承,讓代碼耦合。

你應該多使用組合,而不是繼承。以及,應該多使用 DIP(Dependence Inversion Principle),依賴倒置原則。換個說法,就是面向 interface 編程,面向契約編程,面向切面編程,他們都是 DIP 的一種衍生。寫 Golang 的同學就更不陌生了,我們要把一個 struct 作爲一個 interface 來使用,不需要顯式 implement/extend,僅僅需要持有對應 interface 定義了的函數。這種 duck interface 的做法,讓 DIP 來得更簡單。AB 兩個模塊可以獨立編碼,他們僅僅需要一個依賴 interface 簽名,一個剛好實現該 interface 簽名。並不需要顯式知道對方 interface 簽名的兩個模塊就可以在需要的模塊、場景下被組合起來使用。代碼在需要被組合使用的時候才產生了一點關係,同時,它們依然保持着獨立。

說個正交性的典型案例。全局變量是不正交的!沒有充分的理由,禁止使用全局變量。全局變量讓依賴了該全局變量的代碼段互相耦合,不再正交。特別是一個 pkg 提供一個全局變量給其他模塊修改,這個做法會讓 pkg 之間的耦合變得複雜、隱祕、難以定位。

img

06

單例就是全局變量

後面有“共享狀態就是不正確的狀態”原則,會進一步講到。我先給出解決方案,可以通過管道、消息機制來替代共享狀態/使用全局變量/使用單例。僅僅能獲取此刻最新的狀態,通過消息變更狀態。要拿到最新的狀態,需要重新獲取。在必要的時候,引入鎖機制。

07

可逆性原則

可逆性原則是很少被提及的一個原則。可逆性,就是你做出的判斷,最好都是可以被逆轉的。再換一個容易懂的說法,你最好儘量少認爲什麼東西是一定的、不變的。比如,你認爲你的系統永遠服務於用 32 位無符號整數(比如 QQ 號)作爲用戶標識的系統。你認爲你的持久化存儲就選型 SQL 存儲了。當這些一開始你認爲一定的東西,被推翻的時候,你的代碼卻很難去 change,那麼,你的代碼就是可逆性做得很差。書裏有一個例證,我覺得很好,直接引用過來。

寫下這段文字的時間是2019年。自世紀之交以來,我們看到了以下“服務端架構的最佳實踐”:

* 大鐵塊

* 大鐵塊的組合

* 帶負載均衡的商用硬件集羣大

* 將程序運行在雲虛擬機中大

* 把服務運行在雲虛擬機中

* 把雲虛擬機換成容器再來一遍

* 基於雲的無服務器架構大

* 最後,無可避免的,有些任務又回到了大鐵塊

...(省略了文字)... 你能爲這種架構的變化提前準備嗎? 做不到。你能做的就是讓修改更容易一點。將第三方 API 隱藏在自己的抽象層之後。將代碼分解成多個組件:即使最終會把它們部署到單個大型服務器上,這種方法也比一開始做成龐然大物,然後再切分要容易得多。

與其認爲決定是被刻在石頭上的,還不如把它們想像成寫在沙灘的沙子上。一個大浪隨時都可能襲來,捲走一切。騰訊也確實在 20 年內經歷了“大鐵塊”到“雲虛擬機換成容器”的幾個階段。幾次變化都是傷筋動骨,消耗大量的時間,甚至總會有一些上一個時代殘留的服務。就機器數量而論,還不小,一到裁撤季,就很難受。就最近,我看到某個 trpc 插件,直接從環境變量裏讀取本機 IP,僅僅因爲 STKE(Tencent Kubernetes Engine)提供了這個能力。這個細節設計就是不可逆的,將來會有人爲它買單,可能價格還不便宜。

08

依賴倒置原則(DIP)

DIP 原則太重要了,我這裏單獨列一節來講解。依賴倒置原則,全稱是 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 函數僅僅面向 Botton interface 編碼,main pkg 不再關心 Botton interface 裏定義的 TurnOn、TurnOff 的實現細節。實現瞭解耦。這裏,我們能看到 struct UI 需要被注入(inject)一個 Botton interface 才能邏輯完整。所以,DIP 經常換一個名字出現,叫做依賴注入(Dependency Injection)。

img

從這個依賴圖觀察。我們發現,一般來說,UI struct 的實現是要應該依賴於具體的 PythonLamp、JavaLamp、其他各種 Lamp,才能讓自己的邏輯完整。那就是 UI struct 依賴於各種 Lamp 的實現,才能邏輯完整。但是,我們看上面的代碼,卻是反過來了。PythonLamp、JavaLamp、其他各種 Lamp 是依賴 Botton interface 的定義,才能用來和 UI struct 組合起來拼接成完整的業務邏輯。變成了,Lamp 的實現細節,依賴於 UI struct 對於 Botton interface 的定義。這個時候,你發現,這種依賴關係被倒置了!依賴倒置原則裏的“倒置”,就是這麼來的。在 Golang 裏,'PythonLamp、JavaLamp、其他各種 Lamp 是依賴 Botton interface 的定義',這個依賴是隱性的,沒有顯式的 implement 和 extend 關鍵字。代碼層面,pkg dip 和 pkg pythonimpl、javaimpl 沒有任何依賴關係。他們僅僅需要被你在 main pkg 裏組合起來使用。

在 J2EE 裏,用戶的業務邏輯不再依賴低具體低層的各種存儲細節,而僅僅依賴一套配置化的 Java Bean 接口。Object 落地存儲的具體細節,被做成了 Java Bean 配置,注入到框架裏。這就是 J2EE 的核心科技,並不複雜,其實也沒有多麼“高不可攀”。在“動態代碼”優於“配置”的今天,這種通過配置實現的依賴注入,反而有點過時了。

09

將知識用純文本來保存

這也是一個生僻的原則。指代碼操作的數據和方案設計文稿,如果沒有充分的必要使用特定的方案,就應該使用人類可讀的文本來保存、交互。對於方案設計文稿,你能不使用 office 格式,就不使用(office 能極大提升效率才用),最好是原始 text。這是《Unix 編程藝術》也提到了的 Unix 系產生的設計信條。簡而言之一句話,當需要確保有一個所有各方都能使用的公共標準,才能實現交互溝通時,純文本就是這個標準。它是一個接受度最高的通行標準,如果沒有必要的理由,我們就應該使用純文本。

10

契約式設計

如果你對契約式設計(Design by Contract, DBC)還很陌生,我相信,你和其他端的同學(web、client、後端)聯調需求應該是一件很花費時間的事情。你自己編寫接口自動化,也會是一件很耗費精力的事情。你先看看它的 wiki 解釋吧。grpc + grpc-gateway + swagger 是個很香的東西。

代碼是否不多不少剛好完成它宣稱要做的事情,可以使用契約加以校驗和文檔化。TDD 就是全程在不斷調整和履行着契約。TDD(Test-Driven Development)是自底向上的編碼過程,其實會耗費大量的精力,並且對於一個良好的層級架構沒有幫助。TDD 不是強推的規範,但是同學們可以用一用,感受一下。TDD 方法論實現的接口、函數,自我解釋能力一般來說比較強,因爲它就是一個實現契約的過程。

拋開 TDD 不談。我們的函數、API,你能快速抓住它描述的核心契約麼?它的契約簡單麼?如果不能、不簡單,那你應該要求被 review 的代碼做出調整。如果你在指導一個後輩,你應該幫他思考一下,給出至少一個可能的簡化、拆解方向。

11

儘早崩潰

Erlang 發明者、《Erlang 程序設計》作者喬·阿姆斯特朗有一句反覆被引用的話:“防禦式編程是在浪費時間,讓它崩潰。”

儘早崩潰不是說不容錯,而是程序應該被設計成允許出故障,有適當的故障監管程序和代碼,及時告警,告知工程師,哪裏出問題了,而不是嘗試掩蓋問題,不讓程序員知道。當最後程序員知道程序出故障的時候,已經找不到問題出現在哪裏了。

特別是一些 recover 之後什麼都不做的代碼,這種代碼簡直是毒瘤!當然,崩潰,可以是早一些向上傳遞 error,不一定就是 panic。同時,我要求大家不要在沒有充分的必要性的時候 panic,應該更多地使用向上傳遞 error,做好 metrics 監控。合格的 Golang 程序員,都不會在沒有必要的時候無視 error,會妥善地做好 error 處理、向上傳遞、監控。一個死掉的程序,通常比一個癱瘓的程序,造成的損害要小得多。

崩潰但是不告警,或者沒有補救的辦法,不可取。儘早崩潰的題外話是,要在問題出現的時候做合理的告警,有預案,不能掩蓋,不能沒有預案:

img

12

解耦代碼讓改變容易

這個原則,顯而易見,大家自己也常常提,其他原則或多或少都和它有關係。但是我也再提一提。我主要是描述一下它的症狀,讓同學們更好地警示自己“我這兩塊代碼是不是耦合太重,需要額外引入解耦的設計了”。症狀如下:

▶︎ 不相關的 pkg 之間古怪的依賴關係;

▶︎ 對一個模塊進行的“簡單”修改,會傳播到系統中不相關的模塊裏,或是破壞了系統中的其他部分;

▶︎ 開發人員害怕修改代碼,因爲他們不確定會造成什麼影響;

▶︎ 會議要求每個人都必須參加,因爲沒有人能確定誰會受到變化的影響。

13

只管命令不要詢問

看看如下三段代碼:

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)}

明顯,最後一段代碼最簡潔。不關心 Orders 成員、總價的存在,直接命令 customer 找到 Order 並對其進行打折。當我們調整 Orders 成員、GetTotals()方法的時候,這段代碼不用修改。還有一種更嚇人的寫法:

func applyDiscount(customer Customer, orderID string, discount float32) { total := customer.  FindOrder(orderID).  GetTotals() customer.  FindOrder(orderID).  SetTotal(total*discount)}

它做了更多的查詢,關心了更多的細節,變得更加 hard to change 了。我相信大家,特別是客戶端同學,寫過不少類似的代碼。

最好的那一段代碼,就是隻管給每個 struct 發送命令,要求大家做事兒。怎麼做,就內聚在和 struct 關聯的方法裏,其他人不要去操心。一旦其他人操心了,當需要做修改的時候,就要操心了這個細節的人都一起參與進修改過程。

14

不要鏈式調用方法

看下面的例子:

func amount(customer Customer) float32 { return customer.Orders.Last().Totals().Amount}
func amount(totals Totals) float32 { return totals.Amount}

第二個例子明顯優於第一個,它變得更簡單、通用、ETC。我們應該給函數傳入它關心的最小集合作爲參數。而不是我有一個 struct,當某個函數需要這個 struct 的成員的時候,我們把整個 struct 都作爲參數傳遞進去。應該僅僅傳遞函數關心的最小集合。傳進去的一整條調用鏈對函數來說,都是無關的耦合,只會讓代碼更 hard to change,讓工程師懼怕去修改。這一條原則,和上一條關係很緊密,問題常常同時出現。同樣,特別容易出現在客戶端代碼裏。

15

繼承稅(多用組合)

繼承就是耦合。不僅子類耦合到父類,以及父類的父類等,而且使用子類的代碼也耦合到所有祖先類。有些人認爲繼承是定義新類型的一種方式。他們喜歡設計圖表,會展示出類的層次結構。他們看待問題的方式,與維多利亞時代的紳士科學家們看待自然的方式是一樣的,即將自然視爲須分解到不同類別的綜合體。不幸的是,這些圖表很快就會爲了表示類之間的細微差別而逐層添加,最終可怕地爬滿牆壁。由此增加的複雜性,可能使應用程序更加脆弱,因爲變更可能在許多層次之間上下波動。因爲一些值得商榷的詞義消歧方面的原因,C++在20世紀90年代玷污了多重繼承的名聲。結果許多當下的 OO 語言都沒有提供這種功能。

因此,即使你很喜歡複雜的類型樹,也完全無法爲你的領域準確地建模。

Java 下一切都是類。C++裏不使用類還不如使用 C。寫 Python、PHP,我們也肯定要時髦地寫一些類。寫類可以,當你要去繼承,你就得考慮清楚了。繼承樹一旦形成,就是非常 hard to change 的,在敏捷項目裏,你要想清楚“代價是什麼”,有必要麼?這個設計“可逆”麼?對於邊界清晰的 UI 框架、遊戲引擎,使用複雜的繼承樹,挺好的。對於 UI 邏輯、後臺邏輯,可能,你僅僅需要組合、DIP(依賴反轉)技術、契約式編程(接口與協議)就夠了。寫出繼承樹不是“就應該這麼做”,它是成本,繼承是要收稅的!

在 Golang 下,繼承稅的煩惱被減輕了,Golang 從來說自己不是 OO 的語言,但是你 OO 的事情,我都能輕鬆地做到。更進一步,OO 和過程式編程的區別到底是什麼?

面向過程,面向對象,函數式編程。三種編程結構的核心區別,是在不同的方向限制程序員,來做到好的代碼結構(引自《架構整潔之道》):

▶︎ 結構化編程是對程序控制權的直接轉移的限制。

▶︎ 面向對象是對程序控制權的間接轉移的限制。

▶︎ 函數式編程是對程序中賦值操作的限制。

SOLID 原則(單一功能、開閉原則、里氏替換、接口隔離、依賴反轉,後面會講到)是 OOP 編程的最經典的原則。其中 D 是指依賴倒置原則(Dependence Inversion Principle),我認爲,是 SOLID 裏最重要的原則。J2EE 的 container 就是圍繞 DIP 原則設計的。DIP 能用於避免構建複雜的繼承樹,DIP 就是'限制控制權的間接轉移'能繼續發揮積極作用的最大保障。合理使用 DIP 的 OOP 代碼纔可能是高質量的代碼。

Golang 的 interface 是 duck interface,把 DIP 原則更進一步,不需要顯式 implement/extend interface,就能做到 DIP。Golang 使用結構化編程範式,卻有面向對象編程範式的核心優點,甚至簡化了。這是一個基於高度抽象理解的極度精巧的設計。Google 把 abstraction 這個設計理念發揮到了極致。曾經,J2EE 的 container(EJB, Java Bean)設計是國內 Java 程序員引以爲傲“架構設計”、“厲害的設計”。

在 Golang 裏,它被分析、解構,以更簡單、靈活、統一、易懂的方式呈現出來。寫了多年 C++代碼的騰訊後端工程師們,是你們再次審視 OOP 的時候了。我大學一年級的時候看的 C++教材,給我描述了一個美好卻無法抵達的世界。目標我沒有放棄,但我不再用 OOP,而是更多地使用組合(Mixin)。寫 Golang 的同學,應該對 DIP 和組合都不陌生,這裏我不再贅述。如果有人自傲地說他在 Golang 下搞起了繼承,我只能說,“同志,你現在站在了廣大 Gopher 的對立面”。現在,你站在哲學的雲端,鳥瞰了 Structured Programming 和 OOP。你還願意再繼續支付繼承稅麼?

16

共享狀態是不正確的狀態

你坐在最喜歡的餐廳。喫完主菜,問男服務員還有沒有蘋果派。他回頭一看,陳列櫃裏還有一個,就告訴你“還有”。點到了蘋果派,你心滿意足地長出了一口氣。與此同時,在餐廳的另一邊,還有一個顧客也問了女服務員同樣的問題。她也看了看,確認有一個,讓顧客點了單。總有一個顧客會失望的。

問題出在共享狀態。餐廳裏的每一個服務員都查看了陳列櫃,卻沒有考慮到其他服務員。你們可以通過加互斥鎖來解決正確性的問題,但是,兩個顧客有一個會失望或者很久都得不到答案,這是肯定的。

所謂共享狀態,換個說法,就是: 由多個人查看和修改狀態。這麼一說,更好的解決方案就浮出水面了: 將狀態改爲集中控制。預定蘋果派,不再是先查詢,再下單。而是有一個餐廳經理負責和服務員溝通,服務員只管發送下單的命令/消息,經理看情況能不能滿足服務員的命令。

這種解決方案,換一個說法,也可以說成“用角色實現併發性時不必共享狀態”。我們引入了餐廳經理這個角色,賦予了他職責。當然,我們僅僅應該給這個角色發送命令,不應該去詢問他。前面講過了,“只管命令不要詢問”,你還記得麼。

同時,這個原則就是 golang 裏大家耳熟能詳的諺語: “不要通過共享內存來通信,而應該通過通信來共享內存”。作爲併發性問題的根源,內存的共享備受關注。但實際上,在應用程序代碼共享可變資源(文件、數據庫、外部服務)的任何地方,問題都有可能冒出來。當代碼的兩個或多個實例可以同時訪問某些資源時,就會出現潛在的問題。

17

緘默原則

如果一個程序沒什麼好說,就保持沉默。過多的正常日誌,會掩蓋錯誤信息。過多的信息,會讓人根本不再關注新出現的信息,“更多信息”變成了“沒有信息”。每人添加一點信息,就變成了輸出很多信息,最後等於沒有任何信息。

▶︎ 不要在正常 case 下打印日誌。

▶︎ 不要在單元測試裏使用 fmt 標準輸出,至少不要提交到 master。

▶︎ 不打不必要的日誌。當錯誤出現的時候,會非常明顯,我們能第一時間反應過來並處理。

▶︎ 讓調試的日誌停留在調試階段,或者使用較低的日誌級別,你的調試信息,對其他人根本沒有價值。

▶︎ 即使低級別日誌,也不能氾濫。不然,日誌打開與否都沒有差別,日誌變得毫無價值。

img

18

錯誤傳遞原則

我不喜歡 Java 和 C++的 exception 特性,它容易被濫用,它具有傳染性(如果代碼 throw 了 excepttion, 你就得 handle 它,不 handle 它,你就崩潰了。可能你不希望崩潰,你僅僅希望報警)。但是 exception(在 golang 下是 panic)是有價值的,參考微軟的文章:

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++規範在常規情況禁用 exception,理由包含如下內容:

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 和微軟的文章中,我們不難總結出以下幾點衍生的結論:

▶︎ 在必要的時候拋出 exception。使用者必須具備“必要性”的判斷能力。

▶︎ exception 能一路把底層的異常往上傳遞到高函數層級,信息被向上傳遞,並且在上級被妥善處理。可以讓異常和關心具體異常的處理函數在高層級和低層級遙相呼應,中間層級什麼都不需要做,僅僅向上傳遞。

▶︎ exception 傳染性很強。當代碼由多人協作,使用 A 模塊的代碼都必須要了解它可能拋出的異常,做出合理的處理。不然,就都寫一個醜陋的 catch,catch 所有異常,然後做一個沒有針對性的處理。每次 catch 都需要加深一個代碼層級,代碼常常寫得很醜。

我們看到了異常的優缺點。上面第二點提到的信息傳遞,是很有價值的一點。golang 在 1.13 版本中拓展了標準庫,支持了Error Wrapping也是承認了 error 傳遞的價值。

所以,我們認爲錯誤處理,應該具備跨層級的錯誤信息傳遞能力,中間層級如果不關心,就把 error 加上本層的信息向上透傳(有時候可以直接透傳),應該使用 Error Wrapping。exception/panic 具有傳染性,大量使用,會讓代碼變得醜陋,同時容易滋生可讀性問題。我們應該多使用 Error Wrapping,在必要的時候,才使用 exception/panic。每一次使用 exception/panic,都應該被認真審覈。需要 panic 的地方,不去 panic,也是有問題的。參考本文的“儘早崩潰”。

額外說一點,注意不要把整個鏈路的錯誤信息帶到公司外,帶到用戶的瀏覽器、native 客戶端。至少不能直接展示給用戶看到。

img

19

SOLID

SOLID 原則,是以下幾個原則的集合體:

▶︎ SRP:單一職責原則;

▶︎ OCP:開閉原則;

▶︎ LSP:里氏替換原則;

▶︎ ISP:接口隔離原則;

▶︎ DIP:依賴反轉原則。

這些年來,這幾個設計原則在很多不同的出版物裏都有過詳細描述。它們太出名了,我這裏就不做詳解了。我想說的是,這 5 個原則環環相扣,前 4 個原則,要麼就是同時做到,要麼就是都沒做到,很少有說,做到其中一點其他三點都不滿足。ISP 就是做到 LSP 的常用手段。ISP 也是做到 DIP 的基礎。只是,它剛被提出來的時候,是主要針對“設計繼承樹”這個目的的。現在,它們已經被更廣泛地使用在模塊、領域、組件這種更大的概念上。

SOLI 都顯而易見,DIP 原則是最值得注意的一點,我在其他原則裏也多次提到了它。如果你還不清楚什麼是 DIP,一定去看明白。這是工程師最基礎、必備的知識點之一了。

要做到 OCP 開閉原則,其實,就是要大家要通過後面講到的“不要面向需求編程”才能做好。如果你還是面向需求、面向 UI、交互編程,你永遠做不到開閉,並且不知道如何才能做到開閉。

如果你對這些原則確實不瞭解,建議讀一讀《架構整潔之道》。該書的作者 Bob 大叔,就是第一個提出 SOLID 這個集合體的人(20 世紀 80 年代末,在 USENET 新聞組)。

20

一個函數不要出現多個層級的代碼

// 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中拿到關鍵的登陸態等有效信息 var session common.BaseSession common.GetBaseSessionFromCookie(ctx, &session) // 校驗登陸態 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}

上面這一段代碼,是我隨意找的一段代碼。邏輯非常清晰,因爲除了最上面 defer 寫回包的代碼,其他部分都是頂層函數組合出來的。閱讀代碼,我們不會掉到細節裏出不來,反而忽略了整個業務流程。同時,我們能明顯發現它沒寫完,以及 common.ErrorResponse 和 defer func 兩個地方都寫了回包,可能出現發起兩次 http 回包。TODO 也會非常顯眼。

想象一下,我們沒有把細節收歸進 checkCookie()、getRelationship()等函數,而是展開在這裏,但是總函數行數沒有到 80 行,表面上符合規範。但是實際上,閱讀代碼的同學不再能輕鬆掌握業務邏輯,而是同時在閱讀功能細節和業務流程。閱讀代碼變成了每個時刻心智負擔都很重的事情。

顯而易見,單個函數裏應該只保留某一個層級(layer)的代碼,更細化的細節應該被抽象到下一個 layer 去,成爲子函數。

21

Unix 哲學基礎

這一句話改成:Unix 的設計哲學,值得大家深入閱讀學習。最後我也想挑幾條原則展開跟大家分享一下這些經典的智慧。

▶︎ 模塊原則:使用簡潔的接口拼合簡單的部件;

▶︎ 清晰原則:清晰勝於技巧;

▶︎ 組合原則:設計時考慮拼接組合;

▶︎ 分離原則:策略同機制分離,接口同引擎分離;

▶︎ 簡潔原則:設計要簡潔,複雜度能低則低;

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

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

▶︎ 健壯原則:健壯源於透明與簡潔;

▶︎ 表示原則:把知識疊入數據以求邏輯質樸而健壯;

▶︎ 通俗原則:接口設計避免標新立異;

▶︎ 緘默原則:如果一個程序沒什麼好說,就保持沉默;

▶︎ 補救原則:出現異常時,馬上退出並給出足量錯誤信息;

▶︎ 經濟原則:寧花機器一分,不花程序員一秒;

▶︎ 生成原則:避免手工 hack,儘量編寫程序去生成程序;

▶︎ 優化原則:雕琢前先得有原型,跑之前先學會走;

▶︎ 多樣原則:絕不相信所謂"不二法門"的斷言;

▶︎ 擴展原則:設計着眼未來,未來總比預想快。

*Keep It Simple Stupid!*

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

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

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

img

logging:

img

原則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, section 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 服務體系裏修改任何功能,都無法拋開對整體完整設計的理解和熟悉,還極容易沒有知覺地破壞者整體的設計。

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

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

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

我又要說了“那麼古爾丹,代價是什麼呢?”。代價是代碼越多,越難維護,難調整。C 語言之父 Ken Thompson 說“刪除一行代碼,給我帶來的成就感要比添加一行要大”。我們對於代碼,要吝嗇。能把系統做小,就不要做大。騰訊不乏 200w+行的客戶端,很大,很牛。但是,同學們自問,現在還調整得動架構麼。能小做的事情就小做,尋求通用化,通過 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 開發的同學,不區分程序錯誤和邏輯錯誤,要麼都很寬容,要麼都很嚴格,對代碼的可維護性是毀滅性的破壞。“我的程序沒有程序錯誤,如果有,我當時就解決了。”只有這樣,才能保持程序代碼質量的相對穩定,在火苗出現時撲滅火災是最好的撲滅火災的方式。當然,更有效的方式是全面自動化測試的預防:)


本文主要闡述了研發人員在日常工作和職業生涯中,或多或少都會去學習並運用的知名架構原則,並從我個人的角度去做了深入的發散闡述。在下一篇文章中,我將從程序員的自我修養和不能上升到原則的幾個常見案例來繼續闡述程序員修煉之道的未盡事宜。

-End-

原創作者|林強

img

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