1. 程式人生 > >軟體設計是怎樣煉成的——Gregory T. Brown

軟體設計是怎樣煉成的——Gregory T. Brown

作者:Gregory T. Brown,期刊 Practicing Ruby 出版人;非常流行的 PDF 生成庫 Prawn PDF 的原作者;IT 諮詢顧問,幫助過各種規模的公司確定核心業務問題,力求以最少的程式碼解決問題。

一、謹記自底向上,優化軟體設計

假設你是一門軟體設計課程的客座講師,並且你希望縮小理論與實踐的差距

這門課程是你的朋友 Nasir 開設的,但他目前的教學效果不太理想。於是,他請你來幫忙。

在進行案例分析的時候,Nasir 的學生很容易就能抓住要點,並能提出富有創意的問題,從而引出精彩的討論。但是一旦涉及在自己的專案中運用設計概念,大部分學生都很難把理論和實踐聯絡起來。

目前的問題是,大部分學生並沒有構建軟體系統的實戰經驗。這使得學生把軟體設計看成是抽象的練習,而不是具象且必要的技能。

課本上的例子強化了自頂向下的軟體設計方式,其中的設計理念出現得很突兀。真正的設計不同於此,但是學生經常照葫蘆畫瓢,進而產生氣餒情緒。

為了揭示設計決策的來龍去脈,你將搭建一個小型的實時專案,並在此過程中與學生進行討論。通過這樣的形式,學生將有機會參與迭代設計過程,發揮主動性,一磚一瓦地完成系統的構建。

1. 找出關鍵詞,認清問題

在第一堂課上,Nasir 簡單地介紹了你將要搭建的系統:一個即時制生產工作流的小型模擬。

Nasir 沒有用理論引入這個主題,而是描述了即時配送如何使網上購物變得更加可行。

  • 當顧客從網上購買商品後,只要其住處與出貨點的距離不超過 160 公里,貨物一般都可以在一天內送達,最多也不超過兩天。

  • 地方倉庫的庫存量會在防止產品脫銷的前提下保持最低水平。補貨會持續進行:每當地方倉庫向顧客賣出一件商品,緊接著就會有相應的訂單發往更大的倉儲中心,以便及時為此倉庫補貨。

  • 倉儲中心到地方倉庫的貨流是持續不斷的,所以任何一個需要補充的物品可以立即被扔到卡車、飛機或火車上,前往目的地。

  • 一旦貨物從倉儲中心運抵地方倉庫,補貨訂單便會自動提交至第三方供應商。很多供應商會使用即時制生產工作流,這樣就可以進行小批量的補貨。

  • 雖然整個訂貨流程從頭至尾可能會需要幾周的時間,但由於有效設定了貨流,顧客能夠從離其最近的地方倉庫接收商品,而且倉庫永遠都有庫存。同時,製造商提供的產品數量和實際賣出的數量大致相同。

在這一模式下,貨物即時流向需要它們的地方,這樣能使整個生產系統中的浪費和等待時間最小化。這種工作方式在現在已經很常見了,但在幾十年前卻被看作開創性的行業創新。

Nasir 花了一點時間進行介紹,然後示意你開始上課。為了跟上節奏,你給學生講了一個小趣聞,以此引入今天所講內容的幾個細節。

:我父親一生都工作在流水線上,見證了他所在的公司從大批量生產到即時制工作流的變遷過程。

學生:那變化一定很大!好像是完全不同的兩種工作方式。

:對,就是這樣。公司在業務層面經歷了巨大變遷,但生產層面幾乎沒什麼變化。

公司轉型之前,器件要一箱一箱地從上游供應方運過來,然後由工人進行加工,再被運往生產線下游。

當公司轉型為即時制生產後,這一過程幾乎和以前一樣——只有一個很小的變化。工作流轉向了:只有當空箱子從下游返回時,才會加工新的器件。

學生:所以換句話說,你父親只有在下一個站點需要貨物時才會開始工作?

:沒錯!從單個站點看不出什麼變化,但是整個系統從一開始最簡單的部件到最後的成品都被串了起來。

以客戶訂單為準,從後往前進行生產。整條生產線只需要通過保持相鄰站點一致,就能確定需要生產多少元件,以及何時生產。

這種過程深深吸引著我,因為它很有趣,看似簡單的基礎單元也具有實時性需要。出於這個原因,我覺得從無到有地對這種行為進行建模會很有意思,而且在此過程中我們也可以討論一些有趣的軟體設計原則。

Nasir 問學生是否通過剛才的故事理解了即時制生產工作流並已做好模擬的準備。學生尷尬地笑了,好像不知道他到底是在開玩笑還是認真的。但他隨即又問了一個更清楚的問題:這個故事中的關鍵詞是什麼?

過了好幾分鐘,學生終於找出了和模擬相關的很多關鍵詞,如器件(widget)、箱子(crate)、供應方(supplier)、訂單(order),以及生產(produce)。

然後,你讓學生從這些詞中選出兩個,組成一個比較容易實現的簡單句子。沉思了一會兒後,其中一個學生喊出了他的建議:

“我知道了!我們來做一個箱子,然後往裡面放一個器件!”

從這點著手很好,你謝過了那名學生,然後開始工作。

2. 從實現最小化功能入手

開始演示時,你準備了幾個極小的 UI 元素,包含的全都是簡單的幾何圖形。你梳理了幾個基本邏輯,同時學生看著你工作。

幾分鐘之內,你就在螢幕上做出一個小矩形,其中有一個圓形,初步代表“箱子中的器件”。

當你按下膝上型電腦上的空格鍵時,圓形消失了。再次按下時,圓形又出現了。唯恐學生不理解,你重複演示了好幾遍……

再次集中注意力後,你列了一個圖表,用以描述物件 Crate 的 API。

{90%}

這第一步就需要你做幾個設計決策。儘管都是一些小事,但它們會影響到專案剩餘部分的設計。

Nasir:我來簡要概括一下到目前為止你所做的工作。現在系統中有兩個物件:箱子和器件。箱子是可以裝器件的容器,而且箱子是可以被裝滿的。器件還幾乎沒有被定義,我猜它可以用來表示任何產品吧?

:沒錯。更進一步來說,我的建模物件是即時制生產系統中材料的流動情況,而真正被處理的內容並不重要。重點是這些箱子的情況,因為我們要確定是否需要生產新的材料。

學生:哦,我感覺有點明白了。你打算把這些箱子作為定製器件的訊號,就和你父親的工廠裡的那些一樣。

:沒錯。現在來具體聊一聊這個功能。我們已經做好了箱子,並且可以檢視是否需要補貨。但是器件是我們憑空造出來的。這裡缺了什麼模組呢?

學生:某種供應來源?這是整個問題的重點,對嗎?我們希望看到從箱子中移除器件的動作可以觸發自動生產新器件的動作。所以,每一個箱子都要有一個供應方,並且供應方應該能夠察覺箱子何時需要補貨。

Nasir:聽起來你是在暗示供應方應該監聽箱子的狀態,這不完全正確。不應該要求供應方主動檢查箱子是否需要補貨;相反,當器件從箱子中被移除時,供應方應該收到通知。

學生:為供應方加一個監聽器,每當 pop() 被呼叫時就呼叫這個監聽器,如何?

:這些點子都非常好,但是有點太超前了。現在我們縮小範圍,然後思考:“好,我們已經收到一個補貨請求了。這種情況需要哪些物件的協同參與?”

Nasir:這是個好主意。弄清楚系統中的事件流,與知道事件發生時需要做什麼是兩回事。我們一步一步地慢慢來吧。

學生開始發現,自底向上進行系統設計的一個難點是將物件之間的紐帶解開,以便一小部分一小部分地進行實現,而不是一次實現一整塊。這個技能非常重要,因為它有助於實現增量式設計。

你快速畫了一張草圖來展示填充箱子的過程。其中,你引入了物件 Order,用來將供應方與箱子聯絡起來。

{95%}

一個學生問物件 Order 的意義是什麼。如果讓 Supplier 直接操作 Crate,不是更好嗎?

這個問題問得很好,尤其是在專案的早期開發階段。在設計中引入的任何物件都會增加概念包袱,所以無疑需要避免引入多餘的物件。

但在此例中,如果不為 Order 建模,就很難區分系統的物理行為和邏輯行為。

在真實的生產車間裡,上游的供應方直接將材料裝入箱子,讓人覺得好像 Crate 才是需要操作的物件。然而,箱子本身只是容器,其傳達的資訊不過是容量。

關於箱子目的地的真實資訊可能儲存在工人腦中,也可能列印在一張紙上或箱子外的標籤上。這些資訊就是 Order 所代表的內容。這個物件很容易被忽略,因為它並不像箱子中進進出出的材料那麼明顯,但無論如何它仍是模型的一部分。

總結完關於 Order 的全部問題之後,你開始實現填充箱子的工作流。過了一小會兒,你的模擬中又增加了一個三角形和一條線,而且你已經準備好要講一堂基礎幾何課了。

{93%}

這些簡單圖形的含義遠比其外表有意思得多。因此,儘管看起來簡單,但它們卻標誌著專案有了實質性的進展。

你向學生解釋說,當按下空格鍵時,方法 order.submit() 就會被呼叫,從而觸發供應方生產器件。一旦生產完成,器件就會被送入目標箱子,從而完成訂單。學生開始明白,這些基本單元最終將以某種方式組合在一起,產生更有趣的模擬模型。

3. 避免物件間不必要的時間耦合

幾天之後,你該進行第 2 次演示了。自從上次見面之後,你對模擬程式碼所做的唯一的大改動就是放大箱子的尺寸,這樣就可以裝更多的器件了。

{%}

這個很小但很重要的改動使你的模型可以支援軟體設計中的 3 個至關重要的量:0、1 和“許多”。{1[這被稱為軟體設計的 0-1-無窮規則(Zero-One-Infinity Rule),由 Willem van der Poel 提出。]} 前面的例子只涉及前兩種情況,但是從現在開始,你需要考慮所有情況。

由於已經建立了箱子填充機制,因此你需要實現的便是,一有物品從箱子裡移出,就自動觸發填充動作。你問學生如何實現這一功能,一名學生建議在呼叫 crate.pop() 後立即呼叫 order.submit()

於是,你按照學生的建議做了細微的變更,然後啟動了模擬器。一個被裝滿的箱子出現了。你告訴學生,已經按照他們的建議設定好了空格鍵。隨後,你按下空格鍵,螢幕上沒有任何變化。你又按了一下,還是沒有任何變化。然後你狂按鍵盤,螢幕閃了一下,但那個被裝滿的箱子仍然沒有變化。

你加了幾處日誌記錄程式碼,以確定模擬器能接收鍵盤輸入,crate.pop()order.submit() 被成功呼叫,以及沒有產生死迴圈或遞迴呼叫等。看起來一切正常。你註釋掉 order.submit() 那一行,又按了幾下空格鍵,器件被一個一個地移除了。你從空箱子開始,註釋掉 crate.pop() 呼叫,然後器件又一個一個地填滿了箱子。

Nasir 問學生是否知道哪裡出了問題。一名學生很快指出,對器件的移除操作和填充操作發生在同一幀裡。因為兩個動作之間沒有間隔,所以箱子看起來沒有任何變化。

為了驗證此猜想,你暫時給生產出的器件隨機配了顏色。雖然演示結果亂七八糟,但它很好地證實了學生的猜想。

:現在我們已經知道哪裡出了問題,那麼怎樣修復呢?學生:在產出新的器件之前,讓 Supplier 暫停一秒,如何?

:這個想法很好,但我們現在的程式設計環境是非同步的,所以並沒有直接讓程序休眠的方法。需要設定某種回撥函式,令其在預設好的延遲之後執行。

學生:好的,那就這樣做吧。

:我會的,但是沒那麼容易。目前,呼叫 order.submit() 會立即觸發對 supplier.produce() 的呼叫,後者會返回一個 Widget 物件。該物件隨即會被送入 Crate。如果在 supplier.produce() 中使用非同步的回撥函式,就沒法得到有效的返回值,這樣整條供應鏈就會斷掉。

Nasir:所以現在的情況是典型的時間耦合。在 OrderSupplierCrate 這幾個物件之間,存在著時間依賴,這是由它們的設計方式導致的。如果要徹底解決這個問題,就需要重新設計,但現在暫時可以延遲訂單提交程序,讓它在系統接收到鍵盤輸入之後一秒左右再執行。

你根據 Nasir 的建議做了修改,然後又試了一次。果然,剛一按下空格鍵,就有一個器件從箱子中消失了。過了大約一秒鐘,箱子才被再次填充。隨後,你連續快速移除 3 個器件,把箱子清空。過了一會兒,箱子又滿了,而且所有新器件都幾乎同時出現在箱子中。

看到系統正常工作,學生都很高興。但你馬上提醒他們,這樣做治標不治本,其實有點投機取巧。為了使一切正常,需要改良工作流。

你繪製了一張順序圖,用來描述當有訂單提交時,系統中的新事件流。

{%}

實現這個改進後的工作流並不需要對原系統做很大更改。

首先劃分物件 Order 的責任,使提交訂單和完成訂單成為不同的事件。然後修改方法 supplier.produce(),允許它以回撥函式的形式進行通訊,而不是返回值。

在新的設計中,order.submit() 還是會立即呼叫 supplier.produce(),但現在是物件 Supplier 決定是否呼叫以及何時呼叫 order.fulfill(),從而完成對事件的處理。

Nasir 問了學生幾個問題,以確定他們理解了這個小型的重構。很明顯,學生能夠正確理解執行過程,但仍不清楚做此更改的動機是什麼。

你懷疑學生現在還沒有理解新工作流如何生成靈活的定時模型。為了說明這一點,你快速實現了 3 種不同的 supplier.produce()

  1. 同步模型

    直接呼叫 order.fulfill()。可以立即填充器件,也就是像最初設計的那樣。

  2. 非同步併發模型

    使用非同步定時器,讓 order.fulfill() 延遲一秒再執行,允許同時處理訂單物件。

  3. 非同步時序模型

    將新到的所有訂單物件全部放入佇列,以每秒一個訂單的速率相繼進行處理。

上述各種實現方式的表現大相徑庭,但都用了同一個 Order 介面。這證明已經去除了最初設計中的時間耦合,現在系統已經可以支援任何定時模型了。

全班簡短地討論了一下不同的定時模型以及它們各自的優缺點。

  • 同步模型在逐步實現的模擬中很好用,因為一個事件迴圈在單位時間內只執行一次。但這樣一來,要麼需要放棄與系統的實時互動,要麼得寫一堆亂七八糟的程式碼魚目混珠。
  • 非同步併發模型很有意思,但是如果不設計更復雜的 UI,那麼用它很難說清楚訂單的同步處理。
  • 非同步時序模型可以在其他可選項之間實現很好的平衡。它可以通過接受新訂單,在整體上與系統進行實時互動。但是,器件在系統中的流通過程會隨之產生連續且可預測的節奏。

你提出自己的建議:非同步時序模型應該能在“有趣”和“易實現”之間找到很好的平衡點。學生也同意你的決定。如果這是一個有預設條件的真實專案,你可能沒有條件自己做這個決定。但是,由於去掉了物件間的時間耦合,因此這個決定早晚還是要做的。

4. 逐步提取可複用的元件與協議

至此,你已經構建了供應方和箱子,並提出了按需填充箱子的機制。這些基本結構單元已經提供了執行即時制生產模擬器的大部分元件;剩下的任務只是構建一個“機器”(Machine),既作為器件的消費者,也作為其生產者。

和學生仔細討論了一些想法後,你決定,這個機器應該負責將兩個輸入源轉換為一個聯合的輸出流。為了使每個人都參與思考,你做了一個圖樣,展示模擬器在新增這一新功能之後的樣子。

{%}

Nasir 想讓學生解釋一下怎樣實現這一新模型,但看起來他們都被這問題難住了。你思考了一會兒,尋找其中的原因。你發現學生的注意力都集中在尋找新系統和之前有什麼不同上,所以他們看不到二者的相同點。

你降低了要求,讓學生考慮如何使用他們已經熟悉的元件實現一個簡化的系統。

{%}

:這個例子有 3 個供應方和 3 個箱子。為了更容易理解,假設子系統是完全獨立的。如果我們從其中任意一個箱子中移除一個器件,會發生什麼呢?

學生:那樣會觸發提交一個填充訂單。過一會兒後,供應方會完成訂單,然後就會出現一個新器件。

:很正確!現在我們對系統做一個小小的改變。假設最右邊的這個供應方每次完成一個訂單時,會消耗其左邊每個箱子中的一個器件。這時會發生什麼呢?

學生:左邊的箱子就需要填充,所以訂單會被自動送到它們的供應方那裡。

:完全正確。現在如果回頭再去看之前的圖樣,就會更容易理解機器的工作方式。它和供應方一樣會產出器件,但在此過程中,也會消耗上游箱子中的器件,繼而觸發向上遊箱子的供應方提交訂單。一個麻雀雖小五臟俱全的即時制生產工作流就這樣產生了。

聽了你的解釋後,一名學生建議為物件 Supplier 建一個名為 Machine 的子類,這樣就可以複用現在的 Order。你沒有直接回應,而是請全班同學複查 Supplier 的實現,讓他們自己總結並得出結論。

學生現在明白,物件 Supplier 的工作其實很簡單:生成一個新器件,然後呼叫 order.fulfill() 完成填充操作。如果讓 Supplier 立即完成訂單,那麼一行程式碼就可以實現。但是定時模型使問題變得有些複雜。

Supplier 自己有一段程式碼用來實現初步的非同步時序工作佇列。Nasir 很快指出可以複用這段程式碼,因為機器也需要實現延時訂單處理,而且供應方已經實現了這部分功能。剩下的唯一問題是如何複用這段程式碼。

學生:如果建立一個子類會不會比較好?這樣一來,MachineSupplier 這兩個物件就可以共享不少程式碼。

Nasir:咱們現在先不考慮二者之間有什麼相同點,單純考慮這個工作佇列如何實現。它只是一個由函式構成的有序佇列,這些函式逐個執行,間隔時間固定。那麼這一過程對於 Supplier 的概念有什麼特別之處呢?

學生:我覺得應該沒什麼特別的吧。你是說這只是一個實現細節問題嗎?

Nasir:不完全是。我想說這是我們所用的工具鏈中缺失的一環。非同步工作佇列是極其普通的結構,但是因為我們用的語言沒有內建這一結構,所以需要從頭開始構建。

:我剛開始就想到給工作佇列建立自己的物件,但是之後意識到,如果再等一下,就會引出你們剛才的精彩對話。

Nasir:換句話說,你把選擇權給了大家?夠可以的啊!

雖然 Nasir 有些貧嘴,但是推遲決策確實是自底向上設計的重要部分。過早提取物件,然後嘗試去想象未來的使用情況,可能導致介面變得亂七八糟;一旦考慮實際需求,就能更

輕鬆地進行介面設計。

再回到手頭這個問題,你用了一點時間調整一些函式在程式碼庫中的位置,然後為新建的物件 Worker 編寫了 API 文件,如下所示。

{90%}

重構之後,物件 Supplier 沒剩多少程式碼了,所以不能把它當作基類。你從剩餘部分中複製並貼上了一些有用的程式碼,然後開始實現物件 Machine

你首先加入了幾個基本功能,使機器和上游箱子聯絡起來,這部分進展順利。但此後的工作就變得有些複雜了:需要對 Crate 進行一些調整,以支援新新增的 Machine 結構。

你最終做的變更並不大,但很有代表性。當物件在與其設計初衷不同的場景中被複用時,經常需要這樣的變更。

  • 在只包含一個供應方和一個箱子的場景中,知道箱子是否空著並不重要。只要在器件從箱子中被移除時,能夠立即提交填充訂單即可。但機器只有在其上游箱子都裝有器件時才能完成訂單,所以你實現了方法 crate.inStock() 來獲取此資訊。

  • 每個訂單物件都會引用一個箱子物件,但反過來則不會。Crate 和與之對應的 Order 都已定義,因此係統在頂層執行良好。但在其中引入機器時,一切就變得亂七八糟了。為了讓機器在消耗上游器件的同時提交填充訂單,你用了一個涉及閉包的技巧,但解釋起來既不簡潔也不容易。{2[對這個問題的正確處理方法應該是回過頭,在物件 Crate 中給特定的 Order 加引用,但假設客座講師的時間非常有限,你不想再去仔細考慮設計決策了。而這時出現了一種補救方法,可以掩蓋種種細節,讓學生能夠將注意力集中在更重要的知識點上。]}

你坦言,這種意外出現在物件連線點上的設計瑕疵,其實正是自底向上進行設計的缺點。但為了讓大家重拾樂觀情緒,你給學生展示了機器的一個可用版本,其訂單數量可以實時更新。

{%}

為了實現這個新特性,你只在系統內部做了一些小改動,並增加了幾個幫助函式。除此之外,沒有對 API 進行大的變更。這表示,整體設計到目前為止效果良好。

5. 進行大量實驗,發掘隱藏抽象

目前,工作中最困難的部分已經完成。Nasir 給了學生一些時間,讓他們討論如何對這個模擬做一些小的變更,以便測試此設計的優缺點。

開始時,學生提出的建議正如你所料,比如使不同的供應方和機器擁有不同的生產速度和箱子大小。看著系統根據瓶頸限制動態平衡工作量,大家覺得很有意思。他們對這些想法又繼續探討了一段時間,但結果並沒有揭示任何與模擬設計相關的問題。

為了引導學生進行更有趣的討論,Nasir 讓他們實現一種新的機器。一名學生提議建立一個“純化”模型:機器接收單個輸入,並且產生單個輸出,但在此過程中改變器件的型別。

Nasir 開始迴應這名學生。但他還沒說完,你就已經實現了這種新機器,並使它運轉了起來。你將其輸出放進一個“合併器”中,讓這個例子更加生動。

{%}

剛開始,Nasir 以為你已經預料到學生可能會問這種問題,所以提前寫好了部分程式碼,但你很快指出並非如此。

實際上,這和你對合並器的定義有關:這種機器會從它的每一個供應箱中獲取一個器件,然後輸出一個器件。

根據這一定義,很容易實現純化器(即只有一個供應箱的合併器)。因此,你可以很快實現這一新特性,而不用寫任何新程式碼。

另一名學生甚至提出了更深一層的建議。他認為可以建立一種新機器,使其和物件 Supplier 的工作方式一樣,即沒有供應箱,因為任何集合與空集取並,其結果總為該集合本身。

這個提議令你很吃驚,因為你在建立物件 Supplier 時,從來沒想過這個問題。但是果然,這個想法行得通!

{%}

學生們就這一主題提出了很多別的設想,包括在機器之間建立環形依賴、單一輸入源為多個機器提供輸入,等等。所有設想都如願實現,雖然你在建立系統時並沒有明確地針對這些用例做計劃。以自底向上的方式進行設計的系統會有一些令人驚喜的性質。

Nasir 認為這節課該結束了,所以試著進行總結。他告訴學生,雖然這種實驗很有趣,但只是為了幫助探索一些抽象概念。至於這些抽象概念是否能被正式支援,還要看它們是否能被證明有用。這種實驗的目的並不是請大家去發現“隱藏特性”,然後不假思索地立即使用它。

學生似乎很好地理解了這一點。你對 Nasir 的提醒感到欣慰,因為你自己也時常忘記這一點。

alt text