2025 年初,我在 Gaiwan 公司接手了一個客戶專案,它是個維護 legacy code 的專案且該專案使用了 re-frame 。在接手的過程之中,我一邊設法讓自己對 codebase 產生熟悉度,一邊著手修理客戶請我修理的 bugs 。這個過程一點也不輕鬆,而這點刺激我思考,什麼是接手 legacy code 的有效作法?
接手 legacy code 的一般性作法我還沒有結論,另一方面,接手前端是 re-frame 的 legacy code 的第一步,我有結論了:「安裝 re-frame-10x,因為它可以大幅提昇系統的可檢視性 (inspectability)。」
這邊以我修復該專案的一個前端 bug 的過程來做個探討:
我知道按下某一個按鈕之後,它會送出事件 (event),而該事件會被某個 handler 處理。然而,因為這些程式碼已經高度抽象化了,按鈕會送出的事件,我無法直接從程式碼看出。事件是由某個依賴注入的區段程式碼所決定。由於我看了很多的程式碼,卻無法回答一個小小問題,很快地程式碼的總量超過了我的工作記憶的上限,這讓我開始寸步難行。附帶一提,那個專案的 UI 是荷蘭文,這個部分又消耗了我一部分的工作記憶。
當我安裝了 re-frame-10x 之後,前端的事件部分變成可檢視的。我可以輕易地透過瀏覽器看出按下按鈕送出的事件。於是,回答相同的問題,我節省了大量的閱讀程式碼的時間,也不用在程式碼裡埋入一堆的
prn
,總算我的心理負擔 (mental load) 開始顯著地下降,而我的開發效率也因此大幅地改進。
不知道你是否也有類似上述的經驗?這個經驗讓我重新發現了『可檢視性』的重要性。
這邊提出一個可檢視性的定義:
系統提供某些機制,使開發者能夠在運行時或開發過程中觀察其內部狀態、事件、行為與錯誤,或從觀察到的錯誤對應到運作中的區段程式碼,且此機制幾乎不需要修改原始碼或配置,以利於理解與診斷。
依照這個定義,可檢視性帶來的好處是:
節省編修成本。當開發者發現他們必須檢視系統的內部狀態,才能前進時,如果系統沒有輔助檢視的機制,開發者往往得自行埋入
prn
,tap>
之類的程式碼。邊改邊執行地緩步前進。降低心理負擔 (mental load) 。快速看到系統內部的狀態、事件、行為,可以讓開發者節省大量的工作記憶,因為開發者不再需要透過記憶大量程式碼片段,來推理系統的運作。
減少「迷宮效應」。在沒有檢視機制的 legacy code 裡,如果開發者只依賴目前已知的片段知識,這些知識往往是一種單向的對應,即開發者由程式碼出發去推斷系統的行為。然而,一個系統的行為卻有可能由分布在不同檔案裡卻相似的程式碼所觸發,這就造成了一種「迷宮效應」,即開發者面對的一堆分岔路,難以判斷哪一條 code branch 是正確的道路。
有一件事既矛盾又有趣:「可檢視性的重要性,往往會是對於某個系統不熟悉的人,感受特別強烈。」對於系統熟悉的人,無論怎麼開發,很自然地就會走到 happy path 之上,就算偶然因為粗心寫了錯誤,往往離 happy path 也不遠。真的要除錯時,也可以基於自己對系統的整體性理解,很快地猜出概略的方向。與之相對的,如果是某位要接手維護這個系統的人,系統的可檢視性就會非常的重要。
有鑑於此,當你在開發系統時,不妨考慮積極地改善系統的可檢視性,即使你此時因為對眼前的系統有足夠的熟悉度,並沒有強烈地感受到其必要性。
以下討論幾個提昇可檢視性的方向與思考。
輔助工具
先前在談可檢視性的定義時,我們提到了『此機制幾乎不需要修改原始碼或配置』,所以如果我們可以做一些設計,可以減少需要加入的輔助程式碼,這也算是提昇了可檢視性。
在除錯時,最基本且常用的技巧是 prn
與 inline def。然而,有時候我們會遇到一些程式碼,不改寫或是不使用特殊工具就無法檢視。比方說,如下方的程式碼,我們無法不改寫、直接套用 prn
就取得運算過程之中,(reduce + xs)
與 (count xs)
的結果。
(defn mean [xs]
(/ (double (reduce + xs)) (count xs)))
在眾多的 debugging tool 之中,我認為,hashp 對原始碼的改動可說是極少,又極度容易理解。在下方的程式碼裡,#p
會列印出它後方的括弧表示式 (expression) 運算完的值。
(defn mean [xs]
(/ (double #p (reduce + xs)) #p (count xs)))
不同層級的可檢視性
多數的時候,設法改善可檢視性時,我們所做的第一步是改善系統的 log ,這當然是很重要的一步。 log 算是 developer level 的可檢視性,但是,還有另外兩個 level ,也很值得注意:一個是 library developer level ,另一個是 user level 。
在 library developer level ,Clojure 提供了後設資料 (metadata) 的機制,它可以為資料加上額外資訊,通常用於:
資料標記 (Tagging & Tracing)
函式標註 (Documentation & Metadata)
特定函式行為控制 (Aspect-Oriented Programming)
考慮 EmbedKit 裡的這段程式碼,它是一個資料標記的例子:在送完 request 並且回傳 request 的結果時,同時利用 vary-meta
把 req
標記到結果的後設資料裡。在這個例子,後設資料機制可以協助 library developer 把重要的資訊放到對的位置。
(defn- do-request [req]
(vary-meta (http/request req)
assoc
::request req))
同樣的, user level 可檢視性也極為重要。 user 在使用軟體時,只能依賴於文件,無法透過 source code 來理解系統。一旦系統有了可檢視性,user 就可以透過觀察系統內部的狀態來判斷自己的操作是否正確。
常見的 user level 可檢視性是:
/health
API/metrics
API
事件與行為
在開發 Clojure 程式時,我們使用 live development ,而 live development 提供的檢視體驗,至少等同於使用其它語言並且開啟著 Debugger 來開發程式一樣,因為我們可以輕易去存取幾乎任何系統運行之中的變數。換言之,在典型的 Clojure 的開發體驗裡,狀態總是可檢視的。
另一方面,事件與行為就不總是如此,這兩個部分就值得多思考一下。
以 re-frame 為例子,我要安裝了 re-frame-10x 之後,才得到了事件的可檢視性。
那行為的可檢視性呢?有什麼比較好的例子嗎?在多數的 Gaiwan 的專案裡,我們都會準備一個 bin/dev 的程式,它的功能主要是用來布署,或者說,你也可以把它看成是一種管理進程。
bin/dev
的標準設計,總是會有一個空白執行 (--dry-run)
選項。任何的子指令附加了空白執行選項來修飾之後,就可以讓指令不會真的執行但是會顯示在螢幕上。如此一來,即將要發生的『布署行為』就變得可以檢視。當我們不確定時某個子指令會產生什麼行為時,可以先快速跑個空白執行,了解一下到底會發生什麼事,再真的執行它。
改善 Exception Message
相比於二十年前我在開發 C++ 的時候的體驗,Clojure/Java 有 Exception Message ,這實在是太好了。至少當例外發生時,我可以從 stack trace 出發開始尋找錯誤的根源。
最近我遇到一個 Exception Message ,它的完整的 stack trace 對初學者難以閱讀,有整整 126 行,不過多數都是雜訊。其中可以快速導向錯誤的根源的則只有五行。這五行分別由以下兩者構成:
最上方的一行:
Incorrect string value: ...
。這行暗示錯誤與 String 有關。其它包含
APPLICATION_NAMESPACE
的四行。多數時候,錯誤可以在這邊找出。
而最後我修正這個 exception 的程式碼,位於 src/clj/APPLICATION_NAMESPACE/services/files.clj
的 45 行。修正的方式是把 path
改成 (str path)
。
更困難的情況裡,包含 APPLICATION_NAMESPACE
會遠遠超過四行,在這種情況之下,可檢視性顯然不太好,因為對於 developer 來講,每個包含 APPLICATION_NAMESPACE
位置都有可能是錯誤的源頭。如果發生這種情況時,可以考慮在 APPLICATION_NAMESPACE
的抽象層 (layer) 與抽象層 (layer) 之間,做一些 Clojure spec 的檢查,讓 Exception 可以提早被 spec 的檢查攔截下來。Eric Normand 曾對此議題做過一些探討與分析。
[clojure] java.sql.BatchUpdateException: Incorrect string value: '\xAC\xED\x00\x05sr...' for column 'path' at row 1
[clojure] at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
[clojure] at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
[clojure] at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486)
[clojure] at com.mysql.jdbc.Util.handleNewInstance(Util.java:404)
[clojure] at com.mysql.jdbc.Util.getInstance(Util.java:387)
...
[clojure] at APPLICATION_NAMESPACE.db.access.files$update_file_BANG_.invokeStatic(files.clj:15)
[clojure] at APPLICATION_NAMESPACE.db.access.files$update_file_BANG_.doInvoke(files.clj:15)
[clojure] at clojure.lang.RestFn.invoke(RestFn.java:428)
[clojure] at APPLICATION_NAMESPACE.services.files$upload_file_handler.invokeStatic(files.clj:44)
[clojure] at APPLICATION_NAMESPACE.services.files$upload_file_handler.invoke(files.clj:19)
總結
我們探討了可檢視性 (inspectability) 在維護與開發軟體系統時的重要性,並且由可檢視性的定義出發,強調或是延伸定義的一小部分 (從 developer level 延伸到 user level) 並且提出了幾個在開發軟體時提昇可檢視性的方向。
最後,別忘了,想知道你開發的系統的可檢視性好不好,問問接手維護專案的人吧。我相信他可以給你非常有用的反饋。