2023-05-20|閱讀時間 ‧ 約 10 分鐘

閒談軟體設計:來煮碗拉麵吧

前言

為什麼是煮拉麵呢?主題是來自前同事在問我為什麼有人的程式好像常常會歪掉,或是變得難維護,後續的討論中,他用的例子就是拉麵,所以... 今天就用程式來煮拉麵吧!
說真的,要煮出一碗好吃的拉麵,其實超級複雜的,網路上可以找到很多影片,下面是我找了一個蠻完整的影片,從熬製湯頭開始,滷製叉燒,到製麵,最後煮麵到上桌,步驟相當繁複。所以,這次我們只從影片的 13 分 35 秒開始,也就是真的煮一碗拉麵開始。

從簡單版開始

這次,雖然反而讓我想超久的,但我刻意不加入任何物件的概念,希望都是由 function 來完成這次的例子,另外,我知道可以用 SOLID、DIP 或是很多聽起來很專業的術語,但只要對解釋幫助不大的,我也盡量不用。先從第一個版本開始,我們用 cookRamen 來製作一碗拉麵:
一般來說,剛開始學程式,這樣寫沒什麼問題,甚至在正式工作上,能先用最簡單的方式將「功能」做對,我覺得也可以,醜但是對的程式,比起花俏但錯的程式有用。只是這樣的程式有什麼問題?
這沒有標準答案,全看用什麼角度來看,以我來說,這程式的抽象度不太夠,滿滿的細節,並沒有將 domain term 使用進去 (等等會解釋,如果不是很懂可以先暫時忽略這些術語)。另外,這個 cookRamen 只能煮豚骨拉麵,沒法煮鹽味拉麵、味增拉麵或是魚介拉麵。

加上變化

假設,先不管美醜,因為需求馬上又進來了,現在希望 cookeRamen 也可以煮鹽味拉麵,加上時間又很趕,於是有人可能選擇,讓 cookRamen 帶個參數,由參數決定煮什麼拉麵,如果是鹽味拉麵,那就是加入鹽醬汁以及雞高湯,完美,不用幾分鐘就改完上線了,因為速度很快,老闆也很開心。
那不是很好嗎?但軟體開發都是這樣的,後面會有越來越多的調整,除了少數只提供一種拉麵的店,大多都提供多種口味,麵的硬度、湯的濃淡都可以調整,像是我蠻喜歡的凪 Nagi,就有五種口味,以及很多可以調整的項目:
另外一個我也蠻喜歡的一幻,有三種口味搭四種湯頭,另外有一個特殊口味只使用雞白湯。
也就是說,如果繼續使用 if-else 或是 switch-case,即便將參數細化,最後只會讓程式變成一個超恐怖,難以維護的怪物,如果又因為沒時間沒有寫測試案例支撐,之後連修改都會膽顫心驚。

建立抽象

所以,也許打從一開始,我們提供的抽象就是錯的,事實上,這回到一開始我說的,抽象程度不夠,缺少 domain 的 term,如果去問拉麵師傅,或是問拉麵迷,問他們拉麵是怎麼煮的,他們也不會說的這麼細,相反,他們可能會這樣說 (這只是個範例,我不會煮拉麵,只是看了很多漫畫得到的冷知識,想學美食相關的知識,可以看《美味大挑戰》,第 38 集有很多拉麵相關的知識):
  • 加熱碗 (冷的碗會讓高湯變冷,喝起來會覺得比較鹹)
  • 調製醬汁 (或叫湯底)
  • 調製風味油 (決定湯頭風味的關鍵)
  • 加入高湯 (濃淡也是在這個步驟調整)
  • 煮麵 (時間決定硬度,越硬越地道,最好麵心還有點生)
  • 加入煮好的麵跟配料
會發現,這個說法不像我們第一個版本那樣充滿細節,這就是一種抽象,將原本很複雜很細瑣的東西,用更高層次的方式去描述,使事情更容易理解,而抽象化的過程中,就會看到 domain expert 用哪些詞彙描述一件事,而這些詞彙便是 domain term。
因此,我們可以用這些 domain term 改寫 (refactor) 第一個版本的程式碼,得到一個更容易讀的版本:
然後,會發現另一件事,即便是要同時能煮豚骨拉麵、味增拉麵、鹽味拉麵或是魚介拉麵,雖然不同的拉麵在某些步驟裡的細節可能不同,但主流程都是類似的,這時要設計彈性的組合也會覺得便容易了,例如:
到目前為止,透過將流程抽象化,搭配參數,已經讓 cookeRamen 變成一個還不錯的程式,要增加味增拉麵,在不需要改動主流程的情況下,能夠很輕鬆地就完成。

優化抽象

但現實有時候會更加複雜一點,而且雖然能做出豚骨拉麵、醬油拉麵和味增拉麵,非常有彈性,但也有一些問題:
  • 程式看不出來這家店有提供什麼拉麵,也可以解讀成,我們仍然是用接近程式的語言在描述問題,而不是接近 domain 的語言在描述問題。
  • 如果要限制醬汁與湯頭的組合,例如味增不能搭配雞高湯 (我隨便說說的,這組合應該還是很好吃),似乎在不改動主流程的情況下做不到?
  • 為了滿足不同喜好的客人,高湯要能有不同的準備方式,例如兌水降低濃度,或是用 1.5 倍的湯煮成更濃的湯,這是行為上的不同,無法簡單用參數就可以替換。
上述的問題中,第三個相較是比較容易解決的,既然無法用單純的參數,那乾脆就把製作的方式當作參數:
如此一來,我們就可以煮出一碗濃的豚骨拉麵和一碗正常濃度的鹽味拉麵,這裡稍微用了 JavaScript closure 的小技巧,其實用跟不用都可以,只是這裡使用 closure 的用意是,提供一個對 cookRamen 來說,不論之後因為其他原因要改變實作,仍然一致的 function signature:一個沒有參數的 function 。如此一來,cookRamen 永遠只需呼叫 prepareSoup 即可。有人猜出來這是什麼了嗎?

讓程式說行話

解決了第三個問題,再回頭看第一個問題,以目前第五個版本來說,我覺得已經是相當不錯了,硬要雞蛋裡挑骨頭的話,就是 options 這個字,不太像是廚房裡會用的詞彙,如果真要選一個字,也許 Recipe 食譜會合適一點。
所以只是把 options 換成 recipe?當然不是,一般來說聽到食譜,會覺得食譜是什麼?指定的食材加上有一定順序的執行步驟,聽起來,好像可以搭配一起解決第二個問題,加入食譜的概念:
在這個版本中,除了 recipe 之外,也加入了 order 的概念,order 就是消費者填寫的那張紙,上面可以選擇口味 (flavor)、醬汁(部分口味可選)、麵的硬度、湯的濃度、配料等等。此外,我們也在程式中可以知道這家店目前提供兩種口味:豚骨拉麵與醬油拉麵,以及這兩種口味各別的食譜,若仔細看食譜會發現,醬油拉麵並沒有加豬背脂,而是在最後配料加完後,淋上滾燙的蔥油,且醬汁跟湯頭都是固定的。
如此,我們在不用 if-else 與 switch-case 的情況下 (個人對 switch-case 的想法可參考《閒談軟體架構:Switch 壞味道》),完成了能夠煮出不同口味、麵的硬度、湯的濃度等各式組合的程式,某種程度上,每個 function 應該都非常易懂,在每個層級上都提供了合適的抽象:
  • 將訂單轉成食譜
  • 然後 cookRamen 照著食譜煮
  • 每個食譜都是用合乎餐廳慣用語的步驟
  • 每個步驟則是由可執行的細節組成
要新增一個新口味時,只需要組合既有的步驟即可,不會影響到其他既有的口味。
到這裡為止,我覺得目前的版本算是一個還蠻不錯的寫法。裡面其實用了很多可以拿來說嘴的東西,像是 strategy pattern (大家覺得哪邊是 strategy pattern 呢?),又或者是 dependency injection (這裡使用哪幾種 injection) 等等,但我如果先講 strategy pattern 然後再講怎麼使用在例子中,似乎幫助不大,畢竟光講完 intent、context 等理論,很多人就已經受不了想睡覺了。
反而這次,不斷地用拆解加組合,慢慢一步一步 refactor 的方式,寫出我心目中的樣子,我自己覺得反而更容易解說為什麼要這樣寫,即便沒有剛剛那一段的描述,應該還是可以看得懂怎麼去改善一段程式。

結論

至於所謂資深的工程師是否第一次就要寫出最終版的樣子,我覺得倒未必,視已知的需求而定,但最起碼,要能寫到 v3 的樣子,然後能夠依需求的變化,改寫到 v6 或是更適合新需求的樣子。如果需求就是只煮一種拉麵,花很多時間寫出 v6 其實效益不大,但我個人不會寫出 v1,因為不好讀。切記,只要是有一定複雜度的需求,就沒有一次就寫好的程式,重點是如何隨著需求變化,不斷地改善程式。

番外篇

好了,時間到這邊也差不多該收尾了,這次雖然用 JavaScript 設計例子,但真的要用 Java 或是語言改寫成由 interface/class 組成的程式也很簡單,有興趣的可以自己試試。最後,煮拉麵的程式不只有一種寫法,上面有六種不同的版本,我相信有其他更好的版本,如果看官覺得你的方式更好,也歡迎來煮碗拉麵並分享吧!
大概用了半小時,把 Java 的版本寫出來,注意,主要用 interface、enum 和 record,盡可能簡潔寫,但行數確實比 JavaScript 的版本多一些。
夜深了,來碗雞白湯拉麵當消夜吧!

後記

這是首篇從 Medium 搬過來的文章,整體體驗跟 Medium 還蠻像的,但有幾點比較可惜,貼上 gist 和 YouTube 連結時,似乎無法像 Medium 自動轉換,最可惜的應該是程式碼區塊吧,Medium 最近改版的程式碼區塊很好用,在新文章中我都懶得用 gist,但這邊的程式碼區塊還是得嵌入 gist。
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.