關於互動式開發
互動式開發是一種容易流失的開發體驗
小時候,我姊得了水痘,父母沒有刻意隔離我們,反而讓我們繼續一起玩,結果我也染上了。父親說:「趁現在得水痘,你不會有事的。小孩不是小一號的成人。」這句話源自兒科醫學,意思是:雖然孩童與成人外觀相似,內在生理運作卻有本質上的不同。因此,有些疾病對小孩影響輕微,對成人卻是承擔不起,水痘正是如此。
我經常想起這句話,尤其當我觀察 Clojure 的互動式開發機制時:互動式開發在小型系統中非常自然流暢,但如果不刻意維護,它往往會在系統成長後漸漸失效。就像小孩與成人不能一體看待,小型與大型系統在互動能力上的需求與設計也不能畫上等號。
這邊先提出一個互動式開發的定義:
互動式開發是一種開發機制,允許開發者在系統運行時,對系統之中的任意小區段程式碼進行即時的修改、執行與觀察。開發者可藉此取得該區段程式碼的輸入與輸出行為,並導覽其相關結構,從而補充或驗證透過閱讀原始碼所形成的理解。
而互動式開發帶來的好處是:
容易進入心流,因為開發者可以快速驗証新寫的程式碼,獲得有意義的反饋。
輔助思考——開發人員可以選擇透過「閱讀原始碼」或「以黑箱方式觀察輸入/輸出行為」來理解複雜的邏輯,這分別對應到《走出焦油坑》(Out of the Tar Pit) 中歸類為「非正式推理」和「測試」。
某種程度地取代單元測試。
某種程度地取代整合測試。
儘管好處是如此地明顯,互動式開發卻是在系統逐步發展時,容易意外流失的特性之一。
流失理由 1:元件 (component) 與系統的緊密耦合
沒有特別考慮互動式開發的話,整個系統的初始化往往會將元件與系統緊密地綁定在一起。例如,一個常見的作法是:在系統啟動的過程中,會載入所有設定檔、初始化資料庫、背景排程、建立所有 web route,並且將所有元件組裝成一個大型的物件。在這種情況下,任何一個元件的變動,都需要整個系統需要重新啟動,才能讓變動生效。
這就導致一個問題:如果我只是想要修改某一個元件的一些設定值,比方說,將某個元件的 port 從 8080 改成 8089,每一次修改後就得重啟整個系統,而整個系統的啟動往往慢到可以中斷心流。
解法是讓每一個元件的生命週期可以「局部控制」,為每一個服務提供單獨的啟動與停止函數。常見的 Clojure System/component lifecycle management 函式庫就是提供這種功能,比方說:mount, integrant, makina 等等。
這種設計的好處是:你可以在互動式開發時自由地停用某個元件(例如只關掉 :web),觀察修改後的行為,再單獨重新啟動它。
流失理由 2:商務邏輯 (business logic) 與服務 (service) 的緊密耦合
在系統裡,總是會有一些元件是服務,而且它們的重新啟動時間成本不低,往往需要數秒甚至數十秒。像是 web server、背景排程器(scheduler)。如果某段商務邏輯與這些服務寫得太緊密,哪怕這段程式碼已經透過 REPL 重新求值了,系統的行為也不會立刻反映這些改動,因為服務本身還在運行舊的邏輯,尚未重新啟動。
這類問題,可以透過 var 來「解耦」服務與商務邏輯來加以處理。
在很多的 web 程式裡,都能看到類似下面這樣的寫法:
(def web-handler ...)
(run-jetty #'web-handler {:port 8080})在 Clojure 中, #' 表示 var 的引用(var reference),它讓系統在執行期間保留對變數的「間接」存取。這表示,如果這樣撰寫 run-jetty,那麼日後即使重新定義 web-handler,Jetty 也會自動使用新的版本,無需關閉伺服器再重啟,因為伺服器綁定的是 web-handler 這個 var,而不是當時那個具體的函數。
這樣的技巧不只用於 web server,對於定時任務(cron job)也一樣適用。再舉個例子:
(defn job-handler [n] (prn "job " n))
(schedule-task! { :handler #'job-handler
:tab "/5 * * * * * *"})假設 schedule-task! 是一個啟動定時任務的函數,只要綁的是 #'job-handler 而非函數本體,那麼你就可以修改 job-handler 並重新求值,而不必重新註冊整個任務。這大大縮短了修改週期,提高了互動開發的效率。
流失理由 3:SQL queries
當應用程式使用 SQL 資料庫時,通常會有兩種情況需要互動式開發:
透過組裝來生成 SQL query。
從產生 SQL query 的函數,簡單得知其對應的 SQL query 是什麼
而如果不幸使用的函式庫是 YeSQL 的話,這兩點通常都難以做到。因為 YeSQL 的設計如下:
SQL 直接以字串的型式寫在外部檔案裡,無法組裝。
函數是透過 Macro 生成的,無法透過互動式開發的 go to definition 看到函數的實作,自然也無法透過函數名稱來跳轉對應的 SQL query 字串。
解法是更換為像 HoneySQL 這樣的函式庫,它讓 SQL query 的組裝變得資料導向。開發者可以將 query 寫成一個 Clojure map,透過 Clojure map 來得到可組裝性,例如:
(def q {:select [:id :name]
:from [:users]
:where [:= :status "active"]})此外,呼叫 (hsql/format q) ,就可以看到對應 SQL query 字串。
流失理由 4:web handler, router, interceptor
開發 Web Application 如果應用 Ring ,互動式開發是非常直覺的,因為:
Request 是 Clojure map
Response 也是 Clojure map
Handler 則是普通的 Clojure 函數,接受 Request ,傳回 Response
多數的情況,可以簡單地用以下的方式來測一個 web handler
(my-handler
{:uri "/new"
:request-method :get})然而,還是有些情況會相對複雜,比方說,當要開發 webhook handler 的時候,由於 webhook handler 通常需要從一個 (:body request) 讀取一些原始的 payload。在這種情況下,Request 的 Clojure map 就會需要建立型別是 java.io.InputStream 的 body ,而這種複雜性已經高到足已阻礙互動式開發。
這時,我們可以考慮使用 ring-mock 來讓快速生成 Request 。
(require '[ring.mock.request :as mock])
(def req
(-> (mock/request :post "/webhook")
(mock/header "Content-Type" "application/json")
(mock/header "x-signature" "sig-1234")
(mock/body json-body)))
;; 呼叫 handler
(webhook-handler req)其它在開發 Web Application 時,會妨礙互動式開發的情況,也跟 ring-mock 的例子一樣:「如果我們對函式庫不夠熟悉,就可能會忽略了一些很好用的函數,因而錯過了互動式開發。」比方說,如果使用了 reitit 做為 router ,想要知道某個 uri 是否可以順利被比對到特定的 web handler ,我們可以呼叫 match-by-path 來得出答案。又如果使用了 sieppari 做為 interceptor ,想要知道特定的一組 interceptors 它們對特定的 request 會如何反應,我們可以呼叫 execute 來得出答案。
流失理由 5:依賴熱重載 (new dependency hot-reload)
沒有做特別設置的話,即使我們在 deps.edn 裡加入新的函式庫,互動式開發還是無法存取新的函式庫。
這個問題,我推薦的解決方案是 Launchpad 。使用 Launchpad 的話,你可以完全不記憶任何 hot-reload 專用的函數:「修改完 deps.edn 之後,Launchpad 就自動幫你 hot-reload 了。」
流失理由 6:編輯器整合
如果你選擇的編輯器,它提供了很強大的 REPL 整合工具,又或是說,它甚至也提供了你用 Lisp 來修改編輯器,那你其實更積極想象一些將 REPL 與編輯器整合的可能性。
我一直到使用了 Conjure 很久之後,我才發現 Conjure 有極大的可能性。以測試為例子,我本來一直認為,測試就是得在 shell 的環境下進行。後來我才發現,Conjure 可以透過 <localleader> tc 的指令來 Run the specific test under the cursor.
另一個很容易忽略的 Conjure 小技巧是 pretty print 巢狀的 EDN 變數。如果直接呼叫 clojure.pprint/pprint 的話,Conjure 會自動在輸出的每一行前面加上 ; out 來區別,這個輸出是列印到螢幕上的。如果不想看到每一行附加的 ; out 的話,可以先 (tap> variable) 將變數送到 Queue 裡,然後 <localleader> vt 來顯示 Queue 裡所有的內容,這時候,Queue 裡的內容會自動 pretty print。
有兩類編輯器整合的指令,我認為它們還有極大的潛力可以讓互動式開發的體驗更好:
Do some task under the cursor.
Go to some place based on the information under the cursor.
結論
互動式開發不只是提升效率的技術手段,更是一種與系統保持活潑對話的工作方式。它讓我們不只是「寫」程式,而是在「玩」程式——透過一連串的嘗試與觀察,不斷調整理解與設計。而這種對話的品質,深深依賴於系統架構、開發工具、函式庫的選擇,甚至是編輯器的整合程度。
然而,正如「小孩並不是小一號的成人」所提醒的,互動式開發在小型系統中看似自然、順暢,一旦系統逐漸擴大,就必須有意識地維護這樣的開發體驗。否則,它將隨著耦合的增加、流程的複雜化與依賴的增生而悄然流失。
這樣的投資是值得的。它讓開發者更深入理解系統,讓心流更容易出現,讓為測試設計的函數有了新的用途,讓編輯器整合成為實用的開發介面,也讓我們更容易設計出真正高度解耦的系統。


