🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

圖片描述

我花了近十年寫程式碼,當時自認為寫得精彩絕倫,結果六個月後才意識到,那不過是我自身不安全感的紀念碑。你肯定也遇到過這種情況——那種程式碼庫,你需要一個博士學位才能理解為什麼一個簡單的功能需要三層抽象、兩種你從一篇部落格文章裡學到的設計模式,以及一個比實際實現程式碼還長的設定檔。

學習程式設計時沒人會告訴你:目標不是寫最優雅、理論上最完美的系統。目標是為用戶解決實際問題,搶在競爭對手之前發布產品,並賺取足夠的收入來維持運營,同時思考下一步該開發什麼。其他的一切都不過是披著工程卓越外套的自娛自樂罷了。

當然,我是吃了不少苦頭才明白這個道理的。我們都一樣。諷刺的是,我寫過的最糟糕的程式碼,恰恰是我花最多時間試圖「清理」的程式碼。而我寫過的最好的程式碼呢?通常是我一下午匆匆忙忙寫出來的,因為客戶在等著,我沒時間耍什麼花招。

這篇文章不會像其他部落格文章那樣教你如何寫單元測試或使用有意義的變數名稱。這些你一定都懂。這篇文章要探討的是一個更難的問題:何時應該停止——你的程式碼何時已經足夠好到可以發布了,還是你只是因為害怕真實用戶使用你精心打造的系統時會發生什麼而拖延時間。

過度設計在實際應用上究竟是什麼樣子

讓我來跟你說說我三年前參與的一個計畫。我們當時正在開發一個功能,讓使用者可以將資料匯出為 CSV 檔案。很簡單,對吧?從資料庫下載幾行資料,格式化成逗號分隔值,然後傳送到瀏覽器。任何一個初級開發人員都能在一下午的時間內搞定。

但我們是“高速成長型新創公司”裡的“專業工程團隊”,這意味著我們不能僅僅寫一個簡單的函數把資料匯出到文件。不,我們需要考慮未來。如果我們想支援 JSON 匯出怎麼辦?如果我們需要 XML 怎麼辦?如果是包含多個工作表的 Excel 檔案怎麼辦?如果我們需要支援匯出數十億行資料怎麼辦?

因此,我們建構了一個抽象層。我們建立了一個 ExportStrategyFactory,它會傳回 IExportStrategy 介面的不同實作。我們有一個配置系統,用於將文件類型映射到策略類別。我們編寫了一個自訂串流框架來處理大型資料集,即使我們最大的客戶可能只有五萬行資料。我們花了三週時間開發這個功能。

更糟的是,兩年過去了,除了 CSV 匯出功能之外,竟然沒有人提出其他需求。一次也沒有。所有這些基礎設施、所有這些巧妙的架構、所有這些針對從未出現過的邊緣情況編寫的單元測試——全都白費了。純粹的、毫無價值的浪費。而當我們最終確實需要為一家大型企業客戶加入 PDF 導出功能時,我們建立的抽象層卻過於依賴我們最初的假設,以至於我們不得不另闢蹊徑。

這就是過度設計。這並非指編寫糟糕的程式碼或偷懶,而是指解決根本不存在的問題,為永遠不會到來的未來進行建置,以及將理論上的優雅置於實際應用之上。這就好比建造一個樹屋和建造一個理論上可以在我們需要撤離地球時改裝成太空船的樹屋之間的區別。

這裡還有一個可能更貼近我們生活的例子。我的一個朋友正在為一家本地麵包店開發一個網頁應用程式。他們需要追蹤訂單、管理庫存並發送電子郵件收據。都是些常規功能。但我的朋友剛讀完一本關於微服務的書,他堅信這是應用所學的最佳時機。

他將應用程式拆分成七個不同的服務。一個用於用戶身份驗證,一個用於訂單管理,一個用於庫存跟踪,一個用於電子郵件通知,一個用於支付處理,一個用於生成報表。還有一個編排服務來協調所有這些服務。每個服務都有自己的資料庫、部署管道和監控機制。所有服務都透過訊息佇列進行通信,因為,你知道,這是為了實現鬆散耦合。

這家麵包店最終沒開業。我朋友花了九個月搭建基礎設施,卻始終無法實現麵包店真正需要的功能。我最後一次聽說,店主轉投了競爭對手,後者三週就推出了可用的產品。競爭對手的程式碼可能一團糟——一個龐大的 Rails 單體應用,所有業務邏輯都塞進了臃腫的控制器裡。但你知道嗎?它能用。它上線了。它賺了錢。

令人痛心的事實是,過度設計表面上看起來不像是糟糕的程式碼。事實上,它往往看起來像是非常優秀的程式碼——清晰的抽象、明確的焦點分離、各種設計模式都恰到好處。問題在於,所有這些優點都是為了解決尚未存在、甚至可能永遠不會存在的問題。

為什麼聰明的開發者會開發出沒人需要的東西?

接下來我必須坦誠地談談我們為什麼會這樣做。過度設計通常並非源自於能力不足或懶惰,而是源自於恐懼、自負,以及對自身工作本質的根本誤解。

讓我們先從恐懼說起。當你盯著空白的編輯器,需要在周五之前發布一個功能時,腦海中會響起一個聲音:「如果這段程式碼不夠好怎麼辦?如果別人看了之後覺得我是個糟糕的開發者怎麼辦?如果這個設計無法擴展怎麼辦?如果六個月後我不得不重寫一遍怎麼辦?」這個聲音很響亮,也很有說服力,它告訴你唯一極其安全的方法就是讓你建立一個完美的理論。

所以你又增加了一層抽象。你讓一切都可配置。你為尚未存在的功能建立擴展點。你寫的測試程式碼是生產程式碼的兩倍。而每增加一層,你都會感覺更安全一點,更專業一點,更像在做真正的工程™。

問題在於,這一切都是一種過早的優化,但優化的物件不是你的實際效能,而是你的自尊心。你不是在為使用者開發,而是在為你腦海中那個假想的程式碼審查員開發,他會評判你所做的每一個決定。而這個審查員比任何真人都要苛刻得多。

然後就是自負。咱們就別拐彎抹角了。我們成為開發者是因為我們聰明,喜歡解決難題,也希望別人知道我們擅長解決難題。寫一個功能單一但執行得好的簡單函數,並不能讓人覺得很了不起。它無法展現你的技能,也無法證明你已經讀過所有相關的書籍和博客,並理解了所有模式。

但是,建構一個優雅的多層系統,每一層都展現不同的設計原則?這才是真正的工程。這才是真正能區分資深工程師和初級工程師的工作。先別管初級工程師因為沒有陷入抽象的泥潭,所以發布功能的速度是資深工程師的兩倍。

我在程式碼審查中見過這種情況幾十次了。有人提交了一個簡單直接的解決方案,立刻就冒出一堆評論:「你有沒有考慮過在這裡使用策略模式?」「這其實應該做成一個獨立的服務。」「我們應該讓它更通用一些,以便復用。」十有八九,這些評論實際上並沒有改進程式碼——只是有人想藉此證明自己懂行而已。

我們過度設計的第三個原因是,我們真的不理解我們正在做的權衡取捨。當你閱讀部落格文章、文件和會議演講時,你會發現它們幾乎總是關於大公司如何解決重大問題。例如Google如何擴展到數十億用戶,Netflix 如何解釋他們的微服務架構,亞馬遜 如何描述他們的部署管道。所有這些都令人印象深刻,聽起來也很聰明,所以我們想當然地認為我們也應該這樣建立軟體。

但這些部落格文章沒有告訴你的是:這些公司並非一開始就採用這種架構。它們是在擁有數百萬用戶、數百名工程師以及需要這些解決方案的實際問題之後,經過多年的痛苦演進才最終形成的。 Google並非一開始就使用 Kubernetes。 Netflix 並非一開始就採用微服務。亞馬遜並非一開始就採用服務導向的架構。它們最初編寫的程式碼足夠簡單,可以快速發布,然後隨著實際問題的出現而進行重構。

當你大規模複製那些沒有遇到大規模問題的公司的架構時,你並非在學習他們的成功經驗,而是在盲目崇拜他們現在的狀態,卻忽略了他們一路走來的歷程。這就像看到一個跑了十個馬拉鬆的人,就決定自己也應該用和他們一樣的方式訓練,卻忽略了他們最初只是繞著街區慢跑。

簡潔程式碼 vs. 巧妙程式碼:二者之間存在差異,而且這種差異至關重要

這裡有一個我花了很長時間才明白的區別:簡潔的程式碼和巧妙的程式碼並非同一回事,事實上,它們往往是相反的。簡潔的程式碼是指其他人能夠輕鬆閱讀、理解和修改,而不會感到頭痛的程式碼。而巧妙的程式碼是指讓你自己因為寫出它而感到自豪的程式碼。

我以前常寫一些很巧妙的程式碼。我會想辦法用一行程式碼而不是五行程式碼來實現某個功能。我會使用大多數人都不了解的語言特性。我會把程式碼結構設計得非常優雅,但需要讀者記住很多上下文才能理解。我對這些程式碼感到非常自豪。我會把它們展示給其他開發者,看著他們絞盡腦汁地研究,而我會把他們的困惑解讀為對我技術的欽佩。

有一天,我必須修復六個月前自己寫的一段程式碼中的一個 bug。我看了看,完全不懂自己當時是怎麼想的。當時覺得顯而易見的巧妙設計,現在卻變得晦澀難懂。我花了兩個小時才理清自己設計的抽象邏輯,只是為了修改一個條件語句。就在那時,我終於明白了:程式碼不是只寫一次的,它會被閱讀數百次。別人花在理解你那看似巧妙的解決方案上的每一分鐘,都是他們少了一分鐘用來真正解決問題的時間。

另一方面,簡潔的程式碼清晰得近乎乏味。它言簡意賅,功能明確。函數名直接表明其作用。程式碼結構與問題領域完美契合,邏輯清晰。閱讀時,你不會被作者的技術技巧所折服——你幾乎不會注意到作者,只會專注於解決實際問題本身。

我舉個具體的例子。這是我幾年前寫的一段巧妙的程式碼:

const processUsers = users => users
  .filter(u => u.active && !u.deleted && u.email)
  .map(u => ({...u, normalized: u.email.toLowerCase().trim()}))
  .reduce((acc, u) => ({...acc, [u.id]: u}), {});

這段程式碼很糟糕嗎?其實不然。它很簡潔,遵循函數式程式設計原則,而且沒有副作用。當時我為此相當自豪。但問題是:當其他人需要在這個流程中加入新的轉換時,他們必須理解整個操作鏈。而且,當我們除錯某些用戶無法顯示的原因時,我們必須拆解程式碼,找出是哪個步驟將他們過濾掉了。

這是乾淨的版本:

function processUsers(users) {
  const activeUsers = users.filter(user => {
    return user.active && !user.deleted && user.email;
  });

  const normalizedUsers = activeUsers.map(user => {
    return {
      ...user,
      normalized: user.email.toLowerCase().trim()
    };
  });

  const usersById = {};
  for (const user of normalizedUsers) {
    usersById[user.id] = user;
  }

  return usersById;
}

程式碼更長嗎?是的。程式碼行數更多嗎?當然。但你知道嗎?當有人晚上 11 點需要修改這段程式碼,因為生產環境出現了 bug 時,他們可以獨立理解每個步驟。他們可以在步驟之間新增日誌記錄。他們可以修改程式碼的某個部分,而無需考慮整個流程。這比節省幾行程式碼更有價值。

簡潔和巧妙的區別也體現在命名上。巧妙的程式碼會使用類似d (代表資料)或ctx代表上下文)這樣的變數名稱。簡潔的程式碼則會使用完整的名稱: userAccountDatavalidationContext 。巧妙的程式碼會使用縮寫和內部術語。簡潔的程式碼則會使用與業務部門相同的術語,即使這表示名稱會更長。

我曾經和一位開發人員共事,他沉迷於把所有程式碼都寫得盡可能簡潔。他會寫出像pUsr(u)這樣的函數,而不是processUser(user) 。當我問他為什麼時,他說多打幾個字是浪費時間。但你知道什麼更浪費時間嗎?那就是我花在試圖理解pUsr函數作用上的三十分鐘,乘以所有接觸過這段程式碼的開發人員所花費的時間。

簡潔程式碼的有趣之處在於,它通常看起來足夠簡潔,以至於人們認為編寫起來很容易。有時確實如此。但通常情況下,編寫真正簡潔的程式碼比編寫巧妙的程式碼更難,因為它要求你深入理解問題並找到最簡單的解決方案。任何人都能把東西弄複雜——這幾乎是軟體的預設狀態。而把東西弄簡單就需要自律和品味。

過度設計如何扼殺動力並摧毀團隊

過度設計的代價遠不止於編寫不必要的程式碼所花費的時間。它會扼殺開發勢頭,而對於軟體開發來說,勢頭至關重要。當團隊快速推進、發布功能、獲取用戶回饋並快速迭代時,奇蹟才會發生。那時,你才能真正弄清楚自己在建造什麼,以及它的目標用戶是誰。過度設計就像是給這個過程添堵。

我看過這種情況毀掉團隊。一開始,你有一小群充滿熱情、渴望打造新產品的開發者。每個人都幹勁十足,創意源源不絕,每天都能看到明顯的進展。然後,有人突然覺得在發布之前必須「把事情做好」。也許是技術主管想要打好基礎,或許是技術長擔心可擴展性,或許只是一個看了太多Medium架構文章的開發者。

所以,你停止發布新功能,開始建立基礎架構。你需要一個完善的部署管線,你需要服務發現,你需要分散式追踪,你需要一套全面的測試策略,你需要規範你的 API 模式。所有這些聽起來都非常負責和專業,所以大家都認為這是必要的。

三個月過去了。你們搭建了大量的基礎設施,包括服務、管道、配置管理以及成熟工程組織應有的所有配套設施。但是,你們還沒有發布任何用戶可見的產品。市場團隊開始焦躁不安,因為他們沒有東西可以向投資者展示。銷售團隊也感到沮喪,因為他們眼睜睜地看著訂單被那些擁有成熟產品的競爭對手搶走。而開發團隊也開始感受到他們所建造的這些基礎設施所帶來的壓力──現在,每個新功能都需要修改多個服務、更新配置,還需要團隊間的協調。

最初為了「以正確的方式」打造產品而做出的努力,如今卻演變成了一場官僚主義的鬧劇。團隊的活力逐漸消磨。那些曾經滿懷熱情地開發產品的開發者,如今卻只能疲於維護基礎設施。由於每一次改變都會影響眾多系統,新功能的發布速度已經慢得像蝸牛爬行。而最糟糕的是,你甚至還不知道是否有人需要你開發的產品,因為你還沒有把它展示給使用者看。

這就是過度設計的隱性成本:它不僅會拖慢你的速度,還會改變你的最佳化目標。你不再優化學習能力——也就是弄清楚用戶真正需要什麼——而是優化了理論上的正確性。你不再優化靈活性——也就是在學習新知識後能夠快速調整——而是優化了過於複雜系統中的一致性。

我親眼目睹一家新創公司因此倒閉。他們花了一年時間打造一個平台,旨在徹底改變人們做些什麼(我故意說得比較含糊)的方式。他們的架構非常出色。前端、API層、業務邏輯服務和資料層之間實現了清晰的分離。他們在每個層面都進行了全面的測試。他們擁有監控、警告、功能開關和A/B測試等基礎設施。他們擁有現代軟體系統所能提供的一切。

他們資金耗盡時,客戶也只有三個。事實證明,他們解決的問題並非目標使用者真正面臨的問題。但他們在架構上投入了太多,並且對自己建置的一切「完美」感到無比自豪,以至於轉型就意味著要放棄大部分心血。因此,他們沒有做出調整,而是加倍投入,最終帶著一套精美卻解決不了錯誤問題的程式碼庫走向了失敗。

相較之下,我認識的另一家新創公司就截然不同。他們只花了兩週就做出了第一個版本。那個版本簡直一團糟——一個巨大的文件,沒有測試,配置都是硬編碼的,所有最佳實踐都被違反了。但它居然能用,而且他們立刻就把產品推給了用戶。用戶告訴他們哪裡出了問題,缺少什麼功能,以及他們真正需要什麼。於是他們就修復了這些問題。之後他們又獲得了更多用戶,這些用戶又提出了不同的意見。他們不斷迭代,每隔幾天就會發布一個新版本,並持續學習改進。

六個月後,他們擁有了數千名活躍用戶,並且對正在建立的產品有了清晰的認識。此時,隨著實際收入的到來和實際問題的出現,他們開始進行重構。他們根據實際情況拆分了單體應用,並為那些經常出錯的部分加入了測試。他們也針對實際遇到的擴展性問題建構了相應的架構。由於所有這些工作都是為了解決實際問題,因此他們加入的每項複雜性都是合理的。

這種勢頭在個人層面也同樣適用。當你建造某個東西並進入心流狀態時——你清楚地知道下一步該做什麼,你取得了可見的進展,你能看到終點線——那時你才能發揮出最佳水平。但如果你陷入抽象的泥潭,試圖在編寫任何實際功能的程式碼之前就設計出一個完美的系統,那麼這種心流狀態就會消失殆盡。你把時間都花在架構討論和設計文件上,一週下來卻什麼也沒交付。這種每週一無所獲的感覺,簡直令人崩潰。

編寫程式碼是為了解決當下的問題,而不是為了實現未來的幻想

這或許是我職涯中學到的最重要的一課:你無法預測未來,所以別再試圖為未來做規劃了。你花在為尚未出現的需求增加彈性上的每一小時,都是你少花在滿足實際需求上的時間。而且,當那些未來的需求最終出現時——如果它們真的會出現的話——它們也永遠不會和你想像的完全一樣。

我以前在這方面做得非常糟糕。每次開發功能時,我都會考慮它未來可能需要的所有擴展方式。例如,如果我們需要支援多種貨幣怎麼辦?如果我們需要針對不同地區進行在地化怎麼辦?如果我們需要為每位客戶進行個人化客製化怎麼辦?為了以防萬一,我會提前把所有這些靈活性都考慮進去。

結果是,程式碼的複雜度遠遠超過了實際當前需求。簡單的功能都需要配置,簡單的邏輯也被抽象層層包裹。而當我所設想的未來從未實現——或以與我預測截然不同的方式實現——所有這些額外的複雜性都成了累贅,反而讓真正的變更變得更加困難。

這裡有個例子,至今仍讓我感到尷尬。當時我在為一個SaaS產品建構支付處理系統。那時,我們只接受Stripe的信用卡付款。但我心想:「以後我們可能還需要支援其他支付服務商。最好讓它更通用一些!」於是我建立了一整套支付網關抽象層。我為支付處理器、支付方式、交易結果等等都設計了介面。我還建立了一個工廠,可以根據配置選擇合適的處理器。此外,我還編寫了適配器、映射器以及所有相關的模式。

Stripe 整合的實際程式碼大概只有 100 行,而圍繞它的抽象層卻有 500 行。你知道嗎?我們之後再也沒有再增加過其他支付處理器。至少在我參與產品開發的三年裡是如此。唯一一次需要修改付款流程是為了加入訂閱暫停功能,而我精心設計的抽象層對此毫無幫助,因為我根本沒預料到會有這個需求。所以最終我們還是只能想辦法繞過它。

如果可以重來,我會直接寫 Stripe 整合程式碼。不搞抽象,不搞接口,就寫出最直接的信用卡扣款程式碼。然後,如果——如果——我們需要加入 PayPal、Apple Pay 或其他付款方式,到時候再重構程式碼。有了對第二個支付處理器實際運作方式的了解,我可以建立一個真正合理的抽象層,而不是基於我的想像。

這項原則適用於所有方面。除非你需要配置某些東西,而且修改程式碼非常麻煩,否則不要建立配置系統。除非你有真正想要支援的插件,否則不要建立插件架構。除非你有值得擴展的東西,否則不要考慮擴展性。除非你有國際用戶,否則不要考慮國際化。

我常聽到一種反駁觀點:「但如果我們現在不建構這種彈性,以後加入起來就難多了!我們得重寫所有程式碼!」沒錯,有時候重構確實比一開始就做對更難。但這種觀點忽略了一點:也許你根本不需要這種彈性。也許你對未來的假設是錯的。也許公司會轉型。也許這個功能會被砍掉。也許需求會發生變化,導致你的抽像根本沒用。

即使最終確實需要重構,那又怎樣?重構是軟體開發過程中的正常環節。這並非失敗,而是你學到了之前不知道的東西。否則,你就會建構出不需要的彈性,永遠維護下去,最終讓每個接觸程式碼的開發者都疑惑,為什麼這麼簡單的事情會變得如此複雜。

這麼說吧:開發不需要的東西的成本是預先支付的,而且之後每天都會因為維護這些不必要的程式碼而產生費用。而當你真正需要重構時,成本只需支付一次,那時你已經對實際需求有了最全面的了解。哪個聽起來比較划算?

解決眼前的問題本身就有一種解脫感。你無需設想所有可能的未來,也無需預測需求會如何演變。你只要審視眼前的狀況,然後問自己:最簡單的可行方案是什麼?然後就著手實現它。如果以後需要其他方案,到時候再根據這段時間累積的經驗去實現。

過早抽象的真正代價

抽象的目的是讓我們的生活更輕鬆。它們應該隱藏複雜性,提供簡潔的接口,提高程式碼的複用性。如果抽象方法得當,它們確實可以做到這一切。問題在於,找到合適的抽象方法非常困難,而且你幾乎不可能第一次就找到正確的方法。

過早抽象化——在對問題理解不夠透徹,無法確定哪些部分需要抽象之前就建構抽象——是軟體開發中最昂貴的錯誤之一。它代價高昂的原因在於:抽象會增加間接性,而間接性會使程式碼更難理解;錯誤的抽像比完全沒有抽象更難使用;而且,一旦建構了抽象,即使它並不完全適用,也會面臨繼續使用它的壓力。

我從一個建構客戶關係管理系統的專案中吸取了這一課。在專案初期,團隊中有人注意到我們將要使用幾種不同類型的實體:公司、聯絡人、交易和任務。他們推斷所有這些實體都有一些共同點──它們都有名稱、都有時間戳,而且都需要進行增刪改查(CRUD)操作。因此,他們建構了一個通用的實體基類,所有這些實體都將繼承自該基類。

從理論上講,這似乎很明智。我們可以重複使用程式碼,保持一致性,並使擴充功能更加容易。但實際上,這卻是一場惡夢。 「公司」和「聯絡人」確實有一些重疊之處,但「交易」的運作方式卻截然不同——它們有階段、機率和金額,這些都無法映射到實體抽象概念中。 「任務」的情況更糟——它們的截止日期、分配物件和完成狀態都是完全不同的。

於是,我們開始在實體基類中加入越來越多的字段,其中大部分字段只適用於某些實體類型。我們新增了類型檢查,以跳過對不適用欄位的驗證。我們在使用者介面程式碼中新增了特殊情況,以隱藏不相關的欄位。原本旨在簡化問題的抽象,現在卻成了我們大部分複雜性的來源。

最後我們徹底推翻了先前的方案,讓每個實體類型都獨立出來。公司有自己的公司程式碼,聯絡人有自己的聯絡人程式碼。沒錯,確實存在一些重複程式碼。沒錯,每個實體類型都實作了自己的 CRUD 操作。但你知道嗎?程式碼更容易理解和修改。當有人需要在公司中加入欄位時,他們不必擔心會破壞交易。當我們想要更改任務的顯示方式時,我們也不必在通用渲染器中新增特殊情況。

過早抽象的困難在於,它當時看起來似乎是很好的工程設計。你遵循了 DRY(不要重複自己)原則,你考慮周全,你建立了可重複使用的元件。所有程式設計書籍都告訴你應該這樣做。但 DRY 的本質是消除知識的重複,而不是程式碼的重複。如果兩個事物現在看起來相似,但它們代表著截然不同的概念,那麼將它們抽像在一起是錯誤的。

我現在遵循一條經驗法則:至少要有三個具體的例子能從中受益,我才會建構抽象概念。一個例子是特例,兩個例子可能是巧合,三個例子代表了值得提取的模式。這迫使我等到真正理解問題領域夠透徹後再進行適當的抽象。

即便如此,我還是會盡量建立盡可能簡單的抽象層級。不搞花俏的設計模式,也不用複雜的類型層次結構。只是一個函數或類別,用來捕捉真正重複出現的功能,並具備清晰的介面和明確的用途。如果以後需要更多功能,我可以隨時重構。但從簡單入手意味著我建立錯誤抽象層級的可能性要小得多。

抽象的另一個缺點是它並非免費。每一層抽像都需要開發者理解並記住。當有人想要弄清楚某個功能是如何運作的,他們必須追溯所有抽象層才能找到實際實現該功能的程式碼。如果你的抽象層很深,間接層也很複雜,那麼這個追溯過程就會變成一個巨大的認知負擔。

我曾經參與過一個程式碼庫的開發,其中一個簡單的「發送郵件」操作竟然要經過七層抽象。首先是郵件服務(EmailService),它呼叫郵件提供者(EmailProvider),郵件提供者又封裝了郵件用戶端(EmailClient),郵件用戶端呼叫郵件適配器(EmailAdapter),郵件適配器呼叫訊息傳送器(MessageSender),訊息傳送器呼叫層(TransportLayer),郵件層傳送實際的郵件 API。每一層都加入了一些功能——日誌記錄、錯誤處理、重試等等——但最終的結果是,沒有人能真正理解郵件是如何發送的。當郵件發送失敗時,我們花了幾個小時才找出哪一層出了問題。

相較之下,另一個專案中的郵件發送功能只是一個直接呼叫郵件 API 的函數。它所做的一切——格式化郵件、處理錯誤、記錄結果——都集中在一個地方。一旦出現問題,你就能立即知道該去哪裡找。而且,當我們想要加入重試邏輯之類的功能時,我們也直接在顯眼的地方加入,而不是放在三層以上的抽象層中。

簡單易行的設計原則,在現實世界中行之有效

經過多年摸索,我總結了一些在實踐中行之有效的原則。這些原則並非建築書籍中常見的那些——它們更像是保持理智、確保產品不會因自身重量而坍塌的經驗法則。

先讓它能用,再讓它正確,最後才追求速度。這雖然是老生常談,但大多數人卻忽略了順序。他們一開始就想著既要正確又要快,結果反而錯過了讓它能用的這一步。應該從最簡單、最有可能解決問題的方案入手,先讓它能用,如果可以的話就發布。然後,在實際執行的基礎上進行改進。只有當出現真正的效能問題時,才應該優化效能,而不是憑空想像出來的問題。

我看過太多團隊花費數週時間優化每天只執行一次、耗時五秒的程式碼。同時,使用者每分鐘要造訪上千次的關鍵路徑卻慢得像蝸牛爬,因為沒人費心去衡量真正的瓶頸在哪裡。如果你先確保它能正常執行,你就能真正衡量時間都花在了哪裡,從而優化真正重要的東西,而不是那些你以為可能重要的東西。

先從資料入手。在編寫任何程式碼之前,在考慮抽象概念之前,先弄清楚你實際處理的是什麼資料。哪些資料流入?哪些資料流出?哪些資料需要儲存?哪些資料需要轉換?一旦你了解資料流,程式碼幾乎就能自動產生。但如果你先從抽象概念和模式入手,然後再去考慮資料,最終得到的架構將與問題不符。

我曾經參與過一個專案,在真正查看要處理的資料之前,我們花了整整兩週時間設計了一套服務、佇列和工作進程的系統。當我們最終查看資料時,才發現90%的記錄都是單一類型,可以用非常簡單的方式處理,只有10%需要特殊處理。如果我們一開始就專注於資料,我們只需要建立一個簡單的處理器,並加入一個特殊情況處理程序。然而,我們卻建構了一個複雜的事件驅動架構,這對於我們實際的需求來說完全是過度設計。

重複程式碼勝過錯誤的抽象。我之前提到過這一點,但值得再次強調,因為它對開發者來說往往違反直覺。我們被教導重複程式碼不好,應該始終遵循 DRY(Don't Repeat Yourself,不要重複自己)原則來編寫程式碼。但易於理解的重複程式碼遠勝於難以修改的抽象。如果你發現重複程式碼,請克制住立即抽象的衝動。等到你有足夠多的例子,合適的抽象顯而易見時再進行抽象。如果你不確定,那就保留重複程式碼。

將相關程式碼放在一起。這聽起來顯而易見,但你會驚訝地發現,很多程式碼庫為了所謂的「關注點分離」而違背了這個原則。他們會把所有控制器放在一個目錄,所有模型放在另一個目錄,所有視圖放在第三個目錄,然後當你想了解某個功能是如何運作的時候,就得在五個不同的目錄之間來回跳躍。正確的做法是,圍繞功能或領域來組織程式碼。把所有與使用者身分驗證相關的程式碼放在一個地方,把所有與計費相關的程式碼放在另一個地方。這樣一來,就能更容易理解程式碼的工作原理,也能更安全地進行修改。

寫出只做一件事的函數。不是“在一個抽象層次上做一件事”,也不是“遵循單一職責原則做一件事”——而是字面上的只做一件事。一個驗證郵箱地址並發送歡迎訊息的函數做了兩件事,把它拆分成兩個函數。一個獲取資料、轉換資料並保存資料的函數做了三件事,把它拆分成三個函數。當函數只做一件事時,它們易於命名、易於測試且易於重複使用。當它們做多件事時,它們難以命名(因為名稱必須涵蓋多個概念)、難以測試(因為你必須測試多個路徑)和難以重複使用(因為你很少需要函數做的所有事情)。

避免寫一些自作聰明的程式碼。我以前說過,但值得再說一次。自作聰明的程式碼會讓你覺得自己很聰明,而顯而易見的程式碼會讓別人覺得自己很聰明。永遠選擇顯而易見的方案。使用標準模式,而不是自己發明模式。使用熟悉的語言特性,而不是晦澀難懂的特性。編寫看起來像別人以前見過的程式碼。在極少情況下,為了追求「聰明」而犧牲程式碼的可讀性是值得的。

不要建立框架,要建立應用程式。我見過太多團隊陷入建立自有框架的陷阱,因為現有的框架無法完全滿足他們的需求。也許這些框架確實不太合適,但你知道嗎?它們已經足夠好了,而且有專人全職維護,使用它們意味著你可以專注於建置實際的應用程式,而不是從頭開始重寫 Rails、Django 或 React。

可擴展性和簡易性之間的權衡

過度設計最常見的理由之一是可擴展性。 「我們需要把產品做好,萬一用戶達到一百萬怎麼辦?」沒錯,可擴充性確實很重要。但問題是:大多數產品根本不會面臨可擴展性成為主要問題的困境。大多數產品失敗的原因並非無法擴展以應對龐大的用戶群,而是無法快速發布產品以找到市場契合點。

我建置過每天處理數百萬個請求的系統,也建置過每天處理數十個請求的系統。我可以告訴你,適用於數百萬個請求的架構與適用於數十個請求的架構截然不同。但更重要的是,在了解實際負載之前,你無法預測需要哪一種架構。

人們普遍認為,可擴展性必須從一開始就建構在內,如果不預先設計可擴展性,以後就永遠無法擴展。但事實並非如此。 Twitter 最初是一個 Rails 單體應用程式。 Facebook 最初是一個 PHP 應用程式。 Instagram 在被以十億美元收購時,只是一個執行在少數伺服器上的 Django 應用程式。這些公司是透過隨著業務成長加入基礎設施和重構程式碼來實現擴展的,而不是提前預測擴展性問題。

真正扼殺公司的並非是架構過於簡單,而是因為忙於為尚未擁有的用戶建置可擴展架構而遲遲無法發布產品;或者是因為每個功能都需要更新多個服務並協調跨分佈式系統的資料庫遷移而導致產品延遲發布;又或是因為架構的複雜性使得每次變更耗時是預期的三倍,最終導致團隊精疲力竭。

關於可擴展性,我的建議是:不要在遇到擴展問題之前就考慮擴展性。從最簡單的方案開始,例如一台伺服器、一個資料庫、一個應用程式。只有當這些方案不再滿足需求時——例如真正遇到效能問題、可靠性問題或其他任何問題時——才考慮擴展。而且,擴展必須針對你實際遇到的具體、可衡量的問題。

當資料庫運作速度變慢時,新增索引;當伺服器過載時,新增快取層;當單體應用程式規模過大難以部署時,將需要獨立擴充的部分分割成一兩個服務。但所有這些操作都必須基於實際問題,並基於真實資料來確定瓶頸所在。

根據實際問題進行擴展的妙處在於,你更有可能以正確的方式擴展正確的內容。而當你為臆想的擴展問題進行建置時,你只是在猜測。你可能建立了一個分散式系統,而你真正需要的只是更好的資料庫索引。你可能會將所有東西拆分成微服務,而你真正需要的是為單體應用優化部署流程。你可能建立了一個複雜的快取層,而你真正需要的只是修復一個慢查詢。

這並不意味著你應該編寫不利於擴充的程式碼。不要硬編碼伺服器 URL。如果需要在請求間共享狀態,就不要將其放在記憶體中。不要預設那些在規模擴大後顯然會出錯的假設。但是,避免犯傻和試圖為理論上的未來規模進行建置之間有著巨大的區別。

可擴展性和簡潔性之間的權衡是真實存在的,而且需要你認真權衡。每一個提升系統可擴展性的架構決策,都會增加系統的複雜度。分散式系統比單體架構更具可擴展性,但除錯和測試難度也更大。微服務提供了獨立部署的能力,但也帶來了網路呼叫、最終一致性和分散式事務等問題。訊息佇列提供了解耦和彈性,但也帶來了除錯的噩夢和最終一致性問題。

你需要問自己:就我目前遇到的問題而言,可擴展性帶來的好處是否值得付出複雜性的代價?通常答案是否定的。如果答案是否定的,那就選擇簡單。以後需要的時候,你可以隨時加入複雜性。但是,移除複雜度遠比加入複雜性困難得多。

抽象何時才是真正合理的

我花了很多時間討論過早抽象的危險,但我不想給人留下抽象總是壞事的印象。事實並非如此。好的抽像是我們管理複雜性最有力的工具之一。關鍵在於如何區分好的抽象和只會讓事情變得更複雜的抽象。

那麼,抽象究竟何時才算合理呢?最明顯的標誌是,當同一個概念出現在多個地方,並且你能看出它們本質上是同一個東西,而不僅僅是程式碼碰巧相似的時候。我指的是知識和行為的真正重複,而不僅僅是表面上看起來相似的程式碼。

以下是我參與的一個專案中的例子。我們當時正在建立一個系統,需要驗證不同類型的使用者輸入——電子郵件地址、電話號碼、信用卡號、郵遞區號等等。最初,每個驗證都直接在需要的地方以內聯方式進行。但過了一段時間後,我們發現了一個規律:每個驗證都有相同的結構。首先檢查輸入是否為空,然後檢查其格式是否符合要求,接著檢查它是否在已知錯誤值清單中,如果出現任何問題,則傳回結構化的錯誤訊息。

那時,提取驗證抽象層就顯得有意義了。這並非因為我們遵循了某種抽象原則,而是因為我們有了具體的例子來展示抽象層應該是什麼樣子。我們建立了一個簡單的 Validator 類,它接收一個值和一組規則,按順序執行這些規則,並傳回成功或詳細的錯誤訊息。這並非什麼高明的設計,也並非過度設計,它只是捕捉我們反覆遇到的模式的一種直接方法。

這種抽象化為我們節省了時間。當我們需要新增的驗證類型時,只需定義規則,無需重構驗證邏輯。當我們需要更改驗證錯誤的顯示方式時,只需在一個地方進行修改,而無需在程式碼庫中到處尋找。此外,當新成員加入團隊並需要了解驗證機制時,他們只需查看 Validator 類別即可立即理解。

相較之下,我在另一個專案中建立了一個抽象層,當時我們有兩個不同的表單,它們都用於收集使用者資訊。我想:「它們很相似,我應該把它們抽像出來!」於是我建立了一個通用的 FormBuilder,它可以處理不同的欄位類型、驗證規則和提交處理程序。它可配置且靈活,當時看來非常巧妙。

問題在於這兩個表單表面上相似,但實際上並非如此。一個用於用戶註冊,另一個用於更新個人資料。它們的欄位不同,驗證要求不同,提交流程不同,錯誤處理方式也不同。每次需要修改其中一個表單時,我們都必須在 FormBuilder 中新增設定選項,以確保變更不會影響另一個表單。最終,FormBuilder 變得過於複雜,以至於不如直接從頭開始建立表單來得簡單,省去了重新配置的麻煩。

這兩個案例的差別是什麼?在第一個案例中,我有確切的證據顯示我處理的是同一個概念。多個相同模式的例子,都遵循相同的結構。在第二個案例中,我只是基於表面相似性進行抽象,而沒有理解其底層概念是否真的相同。

抽象的另一個合理之處在於,當你需要隱藏問題本身固有的複雜性,而不是你人為製造的複雜性。例如,如果你正在使用一個第三方 API,它有著複雜的身份驗證流程或奇怪的資料格式,那麼將其封裝在一個簡單的介面中就很有意義。你並沒有增加複雜性,而是將現有的複雜性隔離出來,防止它擴散到整個程式碼庫中。

我曾參與一個專案,需要整合一個老舊的 SOAP API,它需要 XML 模式、WSDL 檔案以及各種 2005 年企業級的繁瑣機制。我們用一個簡單的適配器將其封裝起來,該適配器公開了簡潔的 JavaScript 物件和非同步函數。使用時,使用者無需考慮 XML、SOAP 或其他任何細節—只需呼叫函數並取得資料即可。雖然這個封裝增加了一層間接性,但這是值得的,因為它避免了老舊 API 的複雜性蔓延到我們整個程式碼庫。

關鍵區別在於,我們隱藏了必要的複雜性,而不是製造了不必要的複雜性。 SOAP 相關的東西是必須存在的,因為 API 就是這樣運作的。封裝層的作用只是確保我們的團隊除了在實際與 API 互動的地方之外,無需考慮這些細節。

當您需要支持多種真正不同的事物的實現時,抽像也是合理的。這裡說的不是假設的未來實現,而是目前實際存在的、真實存在的實現。如果您正在建立一個支付系統,並且需要同時支援 Stripe 和 PayPal,那麼您可能需要一個支付抽象層。如果您正在建立儲存系統,並且需要同時支援 S3 和本機檔案系統,那麼您可能需要一個儲存抽象層。

但請注意這裡的關鍵字:需要。不是「將來可能需要」、「理論上可能需要」或「支持它會很棒」。你現在就需要支援它,因為有實際用戶需要它,而且建造它是不容商量的。在這種情況下,抽像是你的好幫手。但即便如此,我仍然建議先直接建構第一個實現,然後再直接建構第二個實現,只有在你能夠看出它們之間的共同點之後,再提取抽象層。

我最終確定的模式我稱之為「三法則」。當我看到某個東西第一次時,我會直接把它寫下來。當我看到第二次時,我會仔細觀察,看看它是否真的是同一個東西,還是只是巧合。當我看到第三次,並且確信它是同一個概念時,我才會提取一個抽象概念。這迫使我等到掌握足夠的資訊來建構正確的抽象概念,也避免了我抽象化那些僅僅因為看起來相似就去做抽象的事情。

另一個顯示抽象合理的跡像是:當不進行抽象的成本高於抽象錯誤的成本。如果重複程式碼意味著重複編寫可能隨時更改的複雜業務邏輯,而這些更改需要在所有地方保持一致,那麼即使你不能百分之百確定抽像是否正確,進行抽像也可能是值得的。但這並不常見。大多數情況下,重複程式碼比過早進行抽象更安全。

優秀工程師如何權衡利弊

區分優秀工程師和一般工程師的關鍵不在於技術知識、經驗年限,甚至不在於智力水平,而在於能否清楚權衡利弊。你所做的每一個技術決策都存在成本和收益,難點不在於了解最佳實踐是什麼,而是知道何時應該遵循最佳實踐,何時應該故意打破常規,因為此時成本大於收益。

我看到一些初級工程師解決問題的方式就像問題只有對錯。我應該使用微服務架構嗎?我應該編寫單元測試嗎?我應該使用 TypeScript 還是 JavaScript?他們渴望有人告訴他們正確答案,這樣他們才能做對。但事實並非如此。答案永遠是“視情況而定”,而“視情況而定”指的是你所處的具體環境。

優秀的工程師會提出不同的問題。他們不會問“我應該把它做成服務嗎?”,而是會問“把它做成服務能解決哪些問題?又會帶來哪些問題?考慮到我們目前的處境,它解決的問題是否比它帶來的問題更重要?”他們從成本和收益的角度思考問題,而不是從規則和最佳實踐的角度。

讓我舉個具體的例子。幾年前,我開發一個功能,需要在系統發生特定事件時發送電子郵件通知。 「正確」的實作方式是使用訊息佇列。將事件發佈到佇列中,讓工作流程消費這些事件並發送電子郵件,這樣就能保證郵件送達,並具備重試邏輯等等。

但我們當時還沒有搭建訊息隊列。建立訊息佇列意味著要選擇一種技術、部署它、學習如何操作它,以及圍繞它建構所有基礎架構。這至少需要一周,可能兩週。同時,這項功能需要在迭代周期結束前發布,延遲發布就意味著延遲收入。

所以,我乾脆直接在請求處理程序中發送郵件。我把郵件發送程式碼封裝在 try-catch 區塊裡,並記錄失敗的情況,以便必要時可以手動重試。這種方法不夠健壯,擴充性也不好,跟你在部落格文章裡讀到的事件驅動架構完全不一樣。但它確實有效,按時交付了,而且我們可以監控郵件發送是否失敗,從而判斷是否需要投入更多資源來開發更強大的方案。

六個月過去了,我們的郵件發送依然沒有出現問題。我們發送了數萬封郵件,可能只有極少數由於臨時 API 錯誤而發送失敗,這些錯誤我們都在日誌中發現了,並手動重試。最後我們確實實施了一套完善的佇列系統,但那時我們有了更重要的理由——我們需要確保支付通知郵件的送達,而不僅僅是那些可有可無的行銷郵件。

這就是權衡取捨。 「正確」的架構會延誤產品發布,並佔用我們原本就捉襟見肘的工程時間。 「錯誤」的架構雖然按時發布,但足以解決我們實際遇到的問題。初級工程師可能會對技術債感到內疚。而優秀的工程師則明白,技術債是一種工具──當收益大於成本時,你可以主動承擔它;而當它開始造成問題時,再逐步償還。

權衡取捨也意味著要坦誠面對自己的未知。在進行架構決策時,你實際上是在對未來進行預測。你預測系統將如何使用、如何擴展以及將加入哪些功能。而預測往往並不準確,尤其是在產品生命週期的早期階段,當你還在摸索自己要建造什麼的時候。

你對未來越不確定,就越應該追求靈活性而非完美。不要讓自己被難以改變的決定所束縛。不要建構那些假設你知道事物發展走向的抽像模型。要建立那些在你獲得新資訊時易於修改的模型。

我曾與一些工程師共事,他們過於執著於建立「正確」的架構,以至於無法交付任何產品,因為他們對未來的需求感到困惑。我也曾與一些工程師共事,他們過於追求交付,卻從不考慮未來,結果造成混亂,導致每個新功能的開發時間都越來越長。優秀的工程師能夠找到平衡點。他們交付的產品速度很快,但同時也具備可修改性。

權衡思考的另一個面向是理解系統的不同部分有不同的需求。你的身份驗證程式碼必須堅如磐石且經過充分測試,因為一旦出現故障,就會造成安全問題。而內部只有三個人使用的管理後台可以做得比較粗糙簡陋,因為即使出現故障,成本也微乎其微。將所有程式碼都視為同等重要是新手才會犯的錯誤。

我曾經參與過一個系統專案,我們對所有程式碼都採用相同的程式碼審查標準。處理資金的核心業務邏輯必須經過與產生報告的內部工具相同的嚴格審查。我們為所有程式碼編寫測試、文件和效能最佳化。這令人筋疲力盡,也大大拖慢了我們的開發速度。

最終我們吸取了教訓,開始區別對待系統的不同部分。關鍵路徑程式碼經過了嚴格的審查和全面的測試。內部工具只是快速瀏覽了一下,可能做了一些冒煙測試。原型程式碼則完全沒有審查——我們直接發布,然後根據出現的問題進行修復。這讓我們能夠在不增加缺陷率的情況下大幅提升開發速度,因為我們把精力都投入了最關鍵的地方。

權衡取捨也意味著要願意改變主意。今天正確的決定,六個月後,當情況改變時,可能就變成了錯誤決定。這很正常。優秀的工程師不會執著於他們的架構決策—他們執著於解決問題。如果某個決策不再奏效,他們就會做出改變。

我曾經為一個專案建立過以服務為導向的架構,當時看來很合理——我們有多個團隊負責產品的不同部分,而服務架構能讓我們清楚地劃分責任範圍。但後來團隊規模縮小,產品日趨成熟,維護多個服務的開銷突然變得不划算了。所以我們把其中一些服務合併回了單體架構。有些人可能會認為這是承認失敗,但我認為這是對環境變化的適當反應。

不同團隊規模的實際表現如何

我之前給的建議比較籠統,但實際上,適合獨立開發者的方法與適合十人團隊的方法不同,而適合百人公司的方法又有所不同。以下我將分別闡述我從每種情況中學到的經驗。

獨立開發者或極小團隊(1-3人):在這種情況下,你擁有最大的自由度,可以快速開發,但也最需要避免過度設計。你沒有時間建立基礎設施,因為你要包辦一切——編寫程式碼、與用戶溝通、修復bug、部署、市場推廣等等。唯一重要的是交付能夠解決使用者問題的功能。

保持單體架構。使用你已經熟悉的、枯燥乏味的技術。除非你已經編寫過至少三次相同的程式碼,否則不要建立抽象層。除非某些功能持續崩潰或你正在進行令你感到不安的更改,否則不要寫測試。在擁有用戶之前,不要考慮擴展性。在需要之前,不要建立管理面板-使用資料庫查詢代替。在部署頻率高到令人痛苦之前,不要建置部署自動化。盡可能以最簡單的方式完成所有事情。

我曾經把整個產品都打包成一個文件發布,HTML、CSS、JavaScript 和後端邏輯全都混在一起,因為當時只有我一個人在做,不用費心分離程式碼,速度會快得多。這樣寫醜嗎?當然醜。這樣能用嗎?能。這樣能賺錢嗎?也能。一旦產品開始獲利,證明了這個想法是可行的,我就可以花時間把它重構成一個易於維護的系統。

獨立開發者容易陷入的誤解是,他們認為自己需要像大公司一樣開發產品。他們閱讀谷歌的案例後試圖照搬,儘管谷歌擁有成千上萬的工程師,而他們只有一個人。千萬不要這樣做。你應該先開發一個盡可能簡單易用的產品,發佈出去,然後根據回饋不斷迭代改進。

小型團隊(4-10人):這時需要一些結構,但又不能過於繁瑣。你不能再把所有事情都記在腦子裡了,因為其他人也需要理解程式碼。你需要一些約定俗成的規則,這樣就不會總是因為程式風格而爭論不休。你需要一些測試,這樣就不會總是破壞彼此的工作。

但你們規模還小,不需要複雜的流程。你們不需要微服務——一個結構清晰、模組分明的單體應用程式更容易維護。你們不需要複雜的部署管線——一個執行測試並推送到生產環境的腳本就足夠了。你們不需要正式的架構評審——只需在合併程式碼之前讓其他人檢查一下即可。

在這個階段,你應該開始考慮程式碼組織和可維護性,但仍然要盡量保持簡潔。發現重複程式碼時,要提取函數。為經常出錯的程式碼編寫測試。記錄那些不明顯的細節。但不要為尚未出現的問題建造基礎設施。

小型團隊容易陷入的陷阱是過早擴展。他們不斷壯大,不斷招聘,然後有人讀到 Netflix 的做法,就決定在擴展之前把所有東西都重組成微服務。但他們不是 Netflix,他們只有十個人,分割成微服務只會讓他們的速度降低三倍。要等到單體架構真正出現問題——部署協調一團糟,系統的不同部分需要不同的擴展特性,團隊之間互相掣肘——再進行拆分。那時,要深思熟慮,而不是操之過急。

公司發展階段(10-50人):事情開始變得有趣。公司規模夠大,協調就成了真正的難題。你不能再讓所有人使用同一個程式碼庫,因為合併衝突會不斷發生,部署協調也會變成一場惡夢。你需要考慮團隊邊界和職責歸屬。

這時,架構決策就顯得格外重要了。你可能確實需要將單體應用程式拆分成多個服務,但拆分應該基於團隊邊界,而不是技術邊界。如果你有一個團隊負責計費,一個團隊負責核心產品,那就給每個團隊一個獨立的服務。如果某個元件的擴充方式與其他元件不同,那就把它拆分出來。

但即便規模如此小,簡單還是王道。除非真的遇到了網路問題,否則不要建立服務網格。除非簡單的部署都造成了真正的麻煩,否則不要建立複雜的 CI/CD 管線。不要讓所有團隊都採用標準化流程——只要能與其他所有元件無縫集成,就允許團隊自行決定如何處理自己的問題。

成長型公司容易陷入的陷阱是過早採用大公司的做法。他們開始要求每次變更都必須有設計文件,實施正式的 RFC 流程,並在所有團隊中統一使用特定技術。這不僅增加了額外的開銷,拖慢了整體進度,而且並沒有真正解決他們尚未遇到的問題。現階段的目標應該是讓團隊能夠獨立運作,而不是為了統一而統一。

大型組織(50人以上):我在這方面經驗較少,但就我所見,在這種規模下,你真正需要的是小型團隊認為他們只需要的那些實踐。你需要架構評審,因為錯誤的決策會影響太多團隊。你需要標準化,因為支援五十種不同的技術方案是不可能的。你需要正式的流程,因為非正式的溝通無法應付如此龐大的規模。

但即便如此,原則依然適用:根據實際遇到的問題進行建構。如果你的服務穩定且很少變更,就不需要複雜的部署自動化。如果你的流量可預測,就不需要複雜的自動擴展。如果你的團隊之間沒有衝突,就不需要繁重的協調工作。

發布後重構而非發布前重構

這或許是思維方式上最重要的轉變:不要試圖在發布前做到完美,而是要坦然接受發布後的重構。我曾浪費了職業生涯中的數年時間,試圖在一開始就建立完美的系統,我真希望有人早點告訴我,重構是開發過程中正常且健康的一部分。

上線後重構的好處是:你掌握了更多資訊。你知道使用者實際上如何使用你的功能。你知道哪些程式碼部分經常變動,哪些部分比較穩定。你知道效能瓶頸究竟在哪裡。你知道哪些抽象概念真正有用,而不是你想像中可能有用的東西。

我曾經發布過一個功能,當時我認為它主要面向執行複雜工作流程的高階使用者。所以我建立了一個靈活的配置系統,以及大量的選項、快捷鍵等等。結果發現,90% 的使用者只想點擊一個按鈕來完成最常用的操作。我之前設計的所有彈性反而讓他們感到困惑。如果我等到發布之後再重構,就能立即發現這個問題,並建立一個更簡潔的方案。

當然,人們擔心的是,如果發布的東西簡單但混亂,就永遠沒有時間回頭清理它。有時候確實如此。但你知道嗎?如果你永遠不需要修改這段程式碼,那麼它的混亂程度就無關緊要了。它能執行,解決了問題,你可以騰出手來做其他事情。唯一需要保持整潔的程式碼,就是那些你打算修改的程式碼。

我現在生產環境裡有一段三年前寫的、之後就沒動過的程式碼。它很醜陋,裡面有很多硬編碼的值,還有六層嵌套的條件語句。但你知道嗎?它運作完美,三年都沒出過 bug,而且也沒人需要理解或修改它。這段醜陋的程式碼比我寫過的許多漂亮的程式碼都成功得多,那些漂亮的程式碼後來被重寫了三遍,因為我當時試圖抽像出一些自己都沒理解的東西。

實現發布後重構的關鍵在於,對即將修改的部分進行充分的測試覆蓋。請注意,我說的是“即將修改的部分”,而不是“全部”。你不需要對整個程式碼庫進行 100% 的測試覆蓋。你只需要對那些頻繁修改或對業務至關重要的部分進行充分的測試覆蓋。至於其他部分,手動測試就足夠了。

重構時,務必循序漸進。不要一次重寫整個系統-每次只重構一部分,部署並確認其運作正常後再進行下一部分。大爆炸式重寫幾乎總是失敗的,因為一次性改動太多,導致無法隔離問題。小規模、增量式的重構更安全,如果優先權發生變化,可以隨時停止。

我曾經重構過一段非常複雜的身份驗證程式碼。我的做法是同時編寫新版本和舊版本,透過功能開關逐步將使用者遷移到新版本,監控錯誤,只有在確信新版本運作正常後才移除舊版本。雖然直接重寫只需要三天,但整個過程耗時三週,而且生產環境從未中斷,我可以隨時回滾。

發布後重構的另一個優點在於,你可以衡量重構是否真正有效。如果重構是為了提升效能,你可以衡量效能提升幅度。如果重構是為了讓程式碼更容易修改,你可以衡量新功能的開發速度是否真的更快。如果重構沒有效果,你也能從中了解哪些方法行不通。如果在發布前重構,你就沒有基準可以比較。

這並不意味著你應該先發布一堆垃圾程式碼,然後再去修復。你仍然應該編寫能夠執行、易於理解和除錯的程式碼。但是,「能夠執行但略顯混亂的程式碼」和「架構能夠滿足所有未來需求的程式碼」之間有著巨大的差別。先發布前者,只有當你確定哪些未來需求真正重要時,才重構為後者。

寄送簡單物品的勇氣

最後,我想談談或許是整個過程中最困難的部分:當你的直覺告訴你應該先做到完美時,如何鼓起勇氣發布一個簡單的版本。這是一種心理上的挑戰,而非技術上的難題。關鍵在於克服這種恐懼:如果你發布了不完美的產品,人們會認為你是個糟糕的工程師。

我學到的是:除了其他開發者之外,沒人會在乎你的程式碼有多優雅,而且他們大多忙於自己的工作,根本沒空評判你。使用者關心的是功能是否有效,能否解決他們的問題。你的老闆關心的是你交付的功能是否對業務有幫助。未來的你會關心,當需求改變時,程式碼是否容易修改。

你花額外一周時間為尚不存在的需求建立抽象概念,對任何利害關係人都沒有好處。你設計一個理論上可以擴展到數百萬用戶的系統,而實際上只有幾百個用戶,這對他們也沒有好處。你把你在那本書裡學到的所有設計模式都付諸實踐,對他們也沒有好處。

我以前認為發布簡單的程式碼是走捷徑、偷工減料、不專業的表現。現在我明白了,發布簡單的程式碼恰恰體現了良好的判斷力。它意味著理解你所處的限制條件,並做出明智的權衡。它意味著有條不紊地解決眼前的問題,而不是去想那些你可能永遠無法解決的假想問題。

我寫過的最好的程式碼,往往是寫的時候感覺過於簡單的程式碼。寫的時候我會想:「這肯定不對勁,太簡單了。」我必須克制住想要讓它更複雜的衝動,因為問題肯定比這複雜得多。然後我發布了程式碼,它運作正常,我就繼續做下一個專案了。

這種思維轉變改變了我的職業生涯:不再追求程式碼看起來多麼酷炫,而是追求盡快弄清楚自己是否在開發正確的東西。因為歸根究底,真正重要的程式碼是能夠為真實使用者解決實際問題的程式碼。其他一切都只是自我陶醉。

所以,先發布你的簡單解決方案,發布你的單體應用,發布你的硬編碼值、內聯程式碼和缺少的抽象層。發布它們,從中學習,並在真正需要的時候進行重構。因為你想像中的完美系統並不存在,你所建構的未來也不會以你預期的方式到來,而真正弄清楚該建構什麼的唯一方法就是建構一些東西,看看會發生什麼。

當有人告訴你你的程式碼還不適用於企業環境,或不符合最佳實踐,或無法擴展,或需要更多抽象時——微笑點頭,記住他們的程式碼可能也還沒發布。

關注我:

{% embed https://dev.to/thebitforge %}

{% embed https://hanzla-beig.netlify.app %}


原文出處:https://dev.to/thebitforge/stop-overengineering-how-to-write-clean-code-that-actually-ships-18ni


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝8   💬7   ❤️2
203
🥈
我愛JS
💬1  
6
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付