軟件設計雜談
disclaimer: 本文所講的設計,非UI/UE的設計,單單指軟件代碼/功能本身在技術上的設計。UI/UE的主題請出門右轉找特贊(Tezign)。
在如今這個Lean/Agile橫掃一切的年代,設計似乎有了被邊緣化的傾向,做事的周期如此之快,似乎已容不下人們更多的思考。MVP(Minimal Viable Produce)在很多團隊里演化成一個形而上的圖騰,于是工程師們找到了一個完美的借口:我先做個MVP,設計的事,以后再說。
如果純屬個人玩票,有個點子,hack out還說得過去;但要嚴肅做一個項目,還是要下工夫設計一番,否則,沒完沒了的返工會讓你無語淚千行。
設計首先得搞懂要解決的問題
工程師大多都是很聰明的人,聰明人有個最大的問題就是自負。很多人拿到一個需求,還沒太搞明白其外延和內涵,代碼就已經在腦袋里流轉。這樣做出來的系統,縱使再精妙,也免不了承受因需求理解不明確而導致的返工之苦。
搞懂需求這事,說起來簡單,做起來難。需求有正確的但表達錯誤的需求,有正確的但沒表達出來的需求,還有過度表達的需求。所以,拿到需求后,先不忙尋找解決方案,多問問自己,工作伙伴,客戶follow up questions來澄清需求模糊不清之處。
搞懂需求,還需要了解需求對應的產品,公司,以及(潛在)競爭對手的現狀,需求的上下文,以及需求的約束條件。人有二知二不知:
-
I know that I know
-
I know that I don’t know
-
I don’t know that I know
-
I don’t know that I don’t know
澄清需求的過程,就是不斷驅逐無知,掌握現狀,上下文和約束條件的過程。
這個主題講起來很大,且非常重要,但畢竟不是本文的重點,所以就此帶過。
尋找(多個)解決方案
如果對問題已經有不錯的把握,接下來就是解決方案的發現之旅。這是個考察big picture的活計。同樣是滿足孩子想要個汽車的愿望,你可以:
-
去玩具店里買一個現成的
-
買樂高積木,然后組裝
-
用紙糊一個,或者找塊木頭,刻一個
這對應軟件工程問題的幾種解決之道:
-
購買現成軟件(acuquire or licensing),二次開發之(如果需要)
-
尋找building blocks,組裝之(glue)
-
自己開發(build from scratch, or DIY)
大部分時候,如果a或b的TCO [1] 合理,那就不要選擇c。做一個產品的目的是為客戶提供某種服務,而不是證明自己能一行行碼出出來這個產品。
a是個很重要的點,可惜大部分工程師腦袋里沒有錢的概念,或者出于job security的私心,而忽略了。工程師現在越來越貴,能用合理的價格搞定的功能,就不該雇人去打理(自己打臉)。一個產品,最核心的部分不超過整個系統的20%,把人力資源鋪在核心的部分,才是軟件設計之道。
b我們稍后再講。
對工程師而言,DIY出一個功能是個極大的誘惑。一種DIY是源自工程師的不滿。任何開源軟件,在處理某種特定業務邏輯的時候總會有一些不足,眼里如果把這些不足放在,卻忽略了人家的好處,是大大的不妥。前兩天我聽到有人說 "consul sucks, …, I’ll build our own service discovery framework…",我就苦笑。我相信他能做出來一個簡單的service discovery tool,這不是件特別困難的事情。問題是值不值得去做。如果連處于consul這個層次的基礎組件都要自己去做,那要么是心太大,要么是沒有定義好自己的軟件系統的核心價值(除非系統的核心價值就在于此)。代碼一旦寫出來,無論是5000行還是50行,都是需要有人去維護的,在系統的生命周期里,每一行自己寫的代碼都是一筆債務,需要定期不定期地償還利息。
另外一種DIY是出于工程師的無知。「無知者無畏」在某些場合的效果是正向的,有利于打破陳規。但在軟件開發上,還是知識和眼界越豐富越開闊越好。一個無知的工程師在面對某個問題時(比如說service discovery),如果不知道這問題也許有現成的解決方案(consul),自己鉚足了勁寫一個,大半會有失偏頗(比如說沒做上游服務的health check,或者自己本身的high availability),結果bug不斷,辛辛苦苦一個個都啃下來,才發現,自己走了很多彎路,費了大半天勁,做了某個開源軟件的功能的子集。當然,對工程師而言,這個練手的價值還是很大的,但對公司來說,這是一筆沉重的無意義的支出。
眼界定義了一個人的高度,如果你每天見同類的人,看同質的書籍/視頻,(讀)寫隸屬同一domain的代碼,那多半眼界不夠開闊。互聯網的發展一日千里,變化太快,如果把自己禁錮在一方小天地里,很容易成為陶淵明筆下的桃花源中人:乃不知有漢,無論魏晉。
構建靈活且有韌性的系統
如果說之前說的都是廢話,那么接下來的和真正的軟件設計能扯上些關系。
分解和組合
軟件設計是一個把大的問題不斷分解,直至原子級的小問題,然后再不斷組合的過程。這一點可以類比生物學:原子(keyword/macro)組合成分子(function),分子組合成細胞(module/class),細胞組合成組織(micro service),組織組合成器官(service),進而組合成生物(system)。
一個如此組合而成系統,是滿足關注點分離(Separation of Concerns)的。大到一個器官,小到一個細胞,都各司其職,把自己要做的事情做到極致。心臟不必關心腎臟會干什么,它只需要做好自己的事情:把新鮮血液通過動脈排出,再把各個器官用過的血液從靜脈回收。
分解和組合在軟件設計中的作用如此重要,以至于一個系統如果合理分解,那么日后維護的代價就要小得多。同樣講關注點分離,不同的工程師,分離的方式可能完全不同。但究其根本,還有有一些規律可循。
總線(System Bus)
首先我們要把系統的總線定義出來。人體的總線,大的有幾條:血管(動脈,靜脈),神經網絡,氣管,輸尿管。它們有的完全負責與外界的交互(氣管,輸尿管),有的完全是內部的信息中樞(血管),有的內外兼修(神經網絡)。
總線把生產者和消費者分離,讓彼此互不依賴。心臟往外供血時,把血壓入動脈血管就是了。它并不需要知道誰是接收者。
同樣的,回到我們熟悉的計算機系統,CPU訪問內存也是如此:它發送一條消息給總線,總線通知RAM讀取數據,然后RAM把數據返回給總線,CPU再獲取之。整個過程中CPU只知道一個內存地址,毋須知道訪問的具體是哪個內存槽的哪塊內存 —— 總線將二者屏蔽開。
學過計算機系統的同學應該都知道,經典的PC結構有幾種總線:數據總線,地址總線,控制總線,擴展總線等;做過網絡設備的同學也都知道,一個經典的網絡設備,其軟件系統的總線分為:control plane和data plane。
路由(routing)
有了總線的概念,接下來必然要有路由。我們看人體的血管:
每一處分叉,就涉及到一次路由。
路由分為外部路由和內部路由。外部路由處理輸入,把不同的輸入dispatch到系統里不同的組件。做web app的,可能沒有意識到,但其實每個web framework,最關鍵的組件之一就是url dispatch。HTTP的偉大之處就是每個request,都能通過url被dispatch到不同的handler處理。而url是目錄式的,可以層層演進 —— 就像分形幾何,一個大的系統,通過不斷重復的模式,組合起來 —— 非常利于系統的擴展。遺憾的是,我們自己做系統,對于輸入既沒有總線的考量,又無路由的概念,if-else下去,久而久之,代碼便繞成了意大利面條。
再舉一例:DOM中的event bubble,在javascript處理起來已然隱含著路由的概念。你只需定義當某個事件(如onclick)發生時的callback函數就好,至于這事件怎么通過eventloop抵達回調函數,無需關心。好的路由系統剝繭抽絲,把繁雜的信息流正確送到處理者手中。
外部路由總還有「底層」為我們完成,內部路由則需工程師考慮。service級別的路由(數據流由哪個service處理)可以用consul等service discovery組件,service內部的路由(數據流到達后怎么處理)則需要自己完成。路由的具體方式有很多種,pattern matching最為常見。
無論用何種方式路由,數據抵達總線前為其定義Identity(ID)非常重要,你可以管這個過程叫data normalization,data encapsulation等,總之,一個消息能被路由,需要有個用于路由的ID。這ID可以是url,可以是一個message header,也可以是一個label(想象MPLS的情況)。當我們為數據賦予一個個合理的ID后,如何路由便清晰可見。
隊列(Queue)
對于那些并非需要立即處理的數據,可以使用隊列。隊列也有把生產者和消費者分離的功效。隊列有:
-
single producer single consumer(SPSC)
-
single producer multiple consumers(SPMC)
-
multiple producers single consumer(MPSC)
-
multiple producers multiple consumers(MPMC)
仔細想想,隊列其實就是總線+路由(可選)+存儲的一個特殊版本。一般而言,system bus之上是系統的各個service,每個service再用service bus(或者queue)把micro service chain起來,然后每個micro service內部的組件間,再用queue連接起來。
有了隊列,有利于提高流水線的效率。一般而言,流水線的處理速度取決于最慢的組件。隊列的存在,讓慢速組件有機會運行多份,來彌補生產者和消費者速度上的差距。
Pub/Sub
存儲在隊列中的數據,除路由外,還有一種處理方式:pub/sub。和路由相似,pub/sub將生產者和消費者分離;但二者不同之處在于,路由的目的地由路由表中的表項控制,而pub/sub一般由publisher控制 [2]:任何subscribe某個數據的consumer,都會到publisher處注冊,publisher由此可以定向發送消息。
協議(protocol)
一旦我們把系統分解成一個個service,service再分解成micro service,彼此之間互不依賴,僅僅通過總線或者隊列來通訊,那么,我們就需要協議來定義彼此的行為。協議聽起來很高大上,其實不然。我們寫下的每個function(或者每個class),其實就是在定義一個不成文的協議:function的arity是什么,接受什么參數,返回什么結果。調用者需嚴格按照協議調用方能得到正確的結果。
service級別的協議是一份SLA:服務的endpoint是什么,版本是什么,接收什么格式的消息,返回什么格式的消息,消息在何種網絡協議上承載,需要什么樣的authorization,可以正常服務的最大吞吐量(throughput)是什么,在什么情況下會觸發throttling等等。
頭腦中有了總線,路由,隊列,協議等這些在computer science 101中介紹的基礎概念,系統的分解便有跡可尋:面對一個系統的設計,你要做的不再是一道作文題,而是一道填空題:在若干條system bus里填上其名稱和流進流出的數據,在system bus之上的一個個方框里填上服務的名稱和服務的功能。然后,每個服務再以此類推,直到感覺毋須再細化為止。
組成系統的必要服務
有些管理性質的服務,盡管和業務邏輯直接關系不大,但無論是任何系統,都需要考慮構建,這里羅列一二。
代謝(sweeping)
一個活著的生物時時刻刻都進行著新陳代謝:每時每刻新的細胞取代老的細胞,同時身體中的「垃圾」通過排泄系統排出體外。一個運轉有序的城市也有新陳代謝:下水道,垃圾場,污水處理等維持城市的正常功能。沒有了代謝功能,生物會凋零,城市會荒蕪。
軟件系統也是如此。日志會把硬盤寫滿,軟件會失常,硬件會失效,網絡會擁塞等等。一個好的軟件系統需要一個好的代謝系統:出現異常的服務會被關閉,同樣的服務會被重新啟動,恢復運行。
代謝系統可以參考erlang的supervisor/child process結構,以及supervision tree。很多軟件,都運行在簡單的supervision tree模式下,如nginx。
高可用性(HA)
每個人都有兩個腎。為了apple watch賣掉一個腎,另一個還能保證人體的正常工作。當然,人的兩個腎是Active-Active工作模式,內部的腎元(micro service)是 N(active)+M(backup) clustering 工作的(看看人家這service的做的),少了一個,performance會一點點有折扣,但可以忽略不計。
大部分軟件系統里的各種服務也需要高可用性:除非完全無狀態的服務,且服務重啟時間在ms級。服務的高可用性和路由是息息相關的:高可用性往往意味著同一服務的冗余,同時也意味著負載分擔。好的路由系統(如consul)能夠對路由至同一服務的數據在多個冗余服務間進行負載分擔,同時在檢測出某個失效服務后,將數據路只由至正常運作的服務。
高可用性還意味著非關鍵服務,即便不可恢復,也只會導致系統降級,而不會讓整個系統無法訪問。就像壁虎的尾巴斷了不妨礙壁虎逃命,人摔傷了手臂還能吃飯一樣,一個軟件系統里統計模塊的異常不該讓用戶無法訪問他的個人頁面。
安保(security)
安保服務分為主動安全和被動安全。authentication/authorization + TLS + 敏感信息加密 + 最小化輸入輸出接口可以算是主動安全,防火墻等安防系統則是被動安全。
繼續拿你的腎來比擬 —— 腎臟起碼有兩大安全系統:
-
輸入安全。腎器的厚厚的器官膜,保護器官的輸入輸出安全 —— 主要的輸入輸出只能是腎動脈,腎靜脈和輸尿管。
-
環境安全。腎器里有大量脂肪填充,避免在撞擊時對核心功能的損傷。
除此之外,人體還提供了包括免疫系統,皮膚,骨骼,空腔等一系列安全系統,從各個維度最大程度保護一個器官的正常運作。如果我們仔細研究生物,就會發現,安保是個一攬子解決方案:小到細胞,大到整個人體,都有各自的安全措施。一個軟件系統也需如此考慮系統中各個層次的安全。
透支保護(overdraft protection)
任何系統,任何服務都是有服務能力的 —— 當這能力被透支時,需要一定的應急計劃。如果使用擁有auto scaling的云服務(如AWS),動態擴容是最好的解決之道,但受限于所用的解決方案,它并非萬靈藥,AWS的auto scaling依賴于load balancer,如Amazon自有的ELB,或者第三方的HAProxy,但ELB對某些業務,如websocket,支持不佳;而第三方的load balancer,則需要考慮部署,與Amazon的auto scaling結合(需要寫點代碼),避免單點故障,保證自身的capacity等一堆頭疼事。
在無法auto scaling的場景最通用的做法是back pressure,把壓力反饋到源頭。就好像你不斷熬夜,最后大腦受不了,逼著你睡覺一樣。還有一種做法是服務降級,停掉非核心的service/micro-service,如analytical service,ad service,保證核心功能正常。
把設計的成果講給別人聽
完成了分解和組合,也嚴肅對待了諸多與業務沒有直接關系,但又不得不做的必要功能后,接下來就是要把設計在白板上畫下來,講給任何一個利益相關者聽。聽他們的反饋。設計不是一個閉門造車的過程,全程都需要和各種利益相關者交流。然而,很多人都忽視了設計定型后,繼續和外界交流的必要性。很多人會認為:我的軟件架構,設計結果和工程有關,為何要講給工程師以外的人聽?他們懂么?
其實pitch本身就是自我學習和自我修正的一部分。當著一個人或者幾個人的面,在白板上畫下腦海中的設計的那一刻,你就會有直覺哪個地方似乎有問題,這是很奇特的一種體驗:你自己畫給自己看并不會產生這種直覺。這大概是面對公眾的焦灼產生的腎上腺素的效果。:)
此外,從聽者的表情,或者他們提的聽起來很傻很天真的問題,你會進一步知道哪些地方你以為你搞通了,其實自己是一知半解。太簡單,太基礎的問題,我們take it for granted,不屑去問自己,非要有人點出,自己才發現:啊,原來這里我也不懂哈。這就是破解 "you don’t know what you don’t know" 之法。
記得看過一個video,主講人大談企業文化,有個哥們傻乎乎發問:so what it culture literally? 主講人愣了一下,拖拖拉拉講了一堆自己都不能讓自己信服的廢話。估計回頭他就去查韋氏詞典了。
最后,總有人在某些領域的知識更豐富一些,他們會告訴你你一些你知道自己不懂的事情。填補了 "you know that you don’t know" 的空缺。
設計時的tradeoff
Rich hickey(clojure作者)在某個演講中說:
everyone says design is about tradeoffs, but you need to enumerate at least two or more possible solutions, and the attributes and deficits of each, in order to make tradeoff.
所以,下回再腆著臉說:偶做了些tradeoff,先確保自己做足了功課再說。
設計的改變不可避免
設計不是一錘子買賣,改變不可避免。我之前的一個老板,喜歡把 change is your friend 掛在口頭。軟件開發的整個生命周期,變更是家常便飯,以至于變更管理都生出一門學問。軟件的設計期更是如此。人總會犯錯,設計總有缺陷,需求總會變化,老板總會指手畫腳,PM總有一天會亮出獠牙,不再是貼心大哥,或者美萌小妹。。。所以,據理力爭,然后接受必要的改變即可。連凱恩斯他老人家都說:
What do you do, sir?