前端比較簡單,不需要架構?
可能一些同學會認為前端比較簡單而不需要架構,或者因為前端交互細節雜而亂難以統一抽象,所以沒辦法進行架構設計。這個理解是片面的,雖然一些前端項目是沒有仔細考慮架構就堆起來的,但這不代表不需要架構設計。任何業務程序都可以通過代碼堆砌的方式實現功能,但背后的可維護性、可拓展性自然也就千差萬別了。
為什么前端項目也要考慮架構設計?有如下幾點原因:
- 從必要性看,前后端應用都跑在計算機上,計算機從硬件到操作系統,再到上層庫都是有清晰架構設計與分層的,應用程序作為最上層的一環也是嵌入在整個大架構圖里的。
- 從可行性看,交互雖然多而雜,但這不構成不需要架構設計的理由。對計算機基礎設計來說,也面臨著多種多樣的輸入設備與輸出設備,進而產生的標準輸入輸出的抽象,那么前端也應當如此。
- 從廣義角度看,大部分通用的約定與模型早已沉淀下來了,如編程語言,前端框架本身就是業務架構的一部分,用 React 哪怕寫個 “Hello World” 也使用了數據驅動的設計理念。
從必要性看,雖然操作系統和各類基礎庫屏蔽了底層實現,讓業務可以僅關心業務邏輯,大大解放了生產力,但一款應用必然是底層操作系統與業務層代碼協同才能運行的,從應用程序往下有一套邏輯井然的架構分層設計,如果到了業務層沒有很好的架構設計,技術抽象是一團亂麻,很難想象這樣形成的整體運行環境是健康的。
業務模塊的架構設計應當類似計算機基礎的架構設計,從需求分析出發,設計有哪些業務子模塊,并定義這些子模塊的職責與子模塊之間的關系。子模塊的設計取決于業務的特性,子模塊間的分層取決于業務的拓展能力。
比如一個繪圖軟件設計時只要需要組件子系統與布局子系統,它們之間互相獨立,也能無縫結合。對于 BI 軟件來說,就增加了篩選聯動與通用數據查詢的概念,因此對應的也會增加篩選聯動模型、數據模型、圖形語法這幾個子模塊,并按照其作用關系上下分層:
如果分層清晰而準確,可以看出這兩個業務上層具有相同的抽象,即最上層都是組件與布局的結合,而篩選聯動與數據查詢,以及從數據模型映射到圖元關系的映射功能都屬于附加項,這些項移除了也不影響系統的運行。如果不這么設計,可能就理不清系統之間的相似點與差異點,導致功能耦合,要維護一個大系統可能要時刻關系各模塊之間的相互影響,這樣的系統即不清晰,也不夠可拓展,關鍵是要維護它的理解成本也高。
從可行性看,前端的特點在于用戶輸入的觸點非常多,但這不妨礙我們抽象標準輸入接口,比如用戶點擊按鈕或者輸入框是輸入,那鍵盤快捷鍵也是一種輸入方式,URL 參數也是一種輸入方式,在業務前置的表單配置也是一種輸入方式,如果輸入方式很多,對標準輸入的抽象就變得重要,使業務代碼的實際復雜度不至于真的膨脹到用戶使用的復雜度那么高。
不止輸入觸點多,前端系統的功能組合也非常多,比如圖形繪制軟件,畫布可以放任意數量的組件,每個組件有任意多的配置,組件之間還可以相互影響。這種系統屬于開放式系統,用戶很容易試出開發者都未曾想到過的功能組合,有些時候開發者都驚嘆這些新的組合竟然能一起工作!用戶會感嘆軟件能力的強大,但開發者不能真的把這些功能組合一一嘗試來解決沖突,必須通過合理的分層抽象來保證功能組合的穩定性。
其實這種挑戰也是計算機面臨的問題,如何設計一個通用架構的計算機,使上面可以運行任何開發者軟件,且軟件之間可以相互獨立,也可以相互調用,系統還不容易產生 BUG。從這個角度來看,計算機的底層架構設計對前端架構設計是有參考意義的,大體上來說,計算機通過硬件、操作系統、軟件這個三個分層解決了要計算一切的難題。
馮·諾依曼體系就解決了硬件層面的問題。為了保證軟件層的可拓展性,通過 CPU、存儲、輸入輸出設備的抽象解決了計算、存儲、拓展的三個基本能力。再細分來看,CPU 也僅僅支持了三個基本能力:數學計算、條件控制、子函數。這使得計算機底層設計既是穩定的,設計因素也是可枚舉的,同時擁有了強大的拓展能力。
操作系統也一樣,它不需要知道軟件具體是怎么執行的,只需要給軟件提供一個安全的運行環境,使軟件不會受到其他軟件的干擾;提供一些基本范式統一軟件的行為,比如多窗口系統,防止軟件同時在一塊區域繪圖而相互影響;提供一些基礎的系統調用封裝給上層的語言進行二次封裝,而考慮到這些系統調用封裝可能會隨著需求而拓展,進而采用動態鏈接庫的方式實現,等等。操作系統為了讓自身功能穩定與可枚舉,對自己與軟件定義了清晰的邊界,無論軟件怎么拓展,操作系統不需要拓展。
回到前端業務,想要保障一個復雜的繪圖軟件代碼清晰與好的可維護性,一樣需要從最底層穩定的模塊開始網上,一步步構建模塊間依賴關系,只有這樣,模塊內邏輯才能可枚舉,模塊與模塊間才敢大膽的組合,各自設計各自的拓展點,使整個系統最終擁有強大的拓展能力,但細看每個子模塊又都是簡單清晰、可枚舉可測試的代碼邏輯。
以 BI 系統舉例,劃分為組件、篩選、布局、數據模型四個子系統的話:
- 對組件系統來說,任何組件實現都可接入,這就使這個 BI 系統不僅可以展示報表,也可以展示普通的按鈕,甚至表單,可以搭建任意數據產品,或者可以搭建任意的網站,能力拓展到哪完全由業務決定。
- 對篩選系統來說,任何組件之間都能關聯,不一定是篩選器到圖表,也可以是圖表到圖表,這樣就支持了圖表聯動。不僅是 BI 聯動場景,即便是做一個表單聯動都可以復用這個篩選能力,使整個系統實現統一而簡單。
- 對布局系統來說,不關心布局內的組件是什么,有哪些關聯能力,只要做好布局就行。這樣畫布系統容易拓展為任何場景,比如生產效率工具、儀表盤、ppt 或者大屏,而對其他系統無影響。
- 對數據模型系統來說,其承擔了數據配置到 sql 查詢,最后映射到圖形通道展示的過程,它本身是對組件系統中,統計圖表類型的抽象實現,因此雖然邏輯復雜,但也不影響其他子系統的設計。
從廣義角度看,前端業務代碼早就處于一系列架構分層中,也就是編程語言與前端框架。編程語言與前端框架會自帶一些設計模式,以減少混用代碼范式帶來的溝通成本,其實架構設計本身也要解決代碼一致性問題,所以這些內容都是架構設計的一環。
前端框架帶來的數據驅動特性本身就很大程度上解決了前端代碼在復雜應用下可維護問題,大大降低了過程代碼帶來的復雜度。React 或 Vue 框架本身也起到了類似操作系統的操作,即定義上層組件(軟件規格)的規格,為組件渲染和事件響應抹平瀏覽器差異(硬件差異),并提供組件渲染調度功能(軟件調度)。同時也提供了組件間變量傳遞(進程通信),讓組件與組件間通信符合統一的接口。
但是沒有必要把每個組件都類比到進程來設計,也就是說,組件與組件之間不用都通過通信方式工作。比較合適的類比粒度是模塊,把一個大模塊抽象為組件,模塊與模塊間互相不依賴,用數據通信來交流。小粒度組件就做成狀態無關的元件,注意相似功能的組件接口盡量保持一致,這樣就能體驗到類似多態的好處。
所以話說回來,遵循前端框架的代碼規范不是一件可有可無的事情,業務架構設計從編程語言和前端框架時就已經開始了,如果一個組件不遵循框架的最佳實踐,就無法參與到更上層的業務架構規劃里,最終可能導致項目混亂,或者無架構可言。所以重視架構設計從代碼規范就要開始。
所以前端架構設計是必要的,那怎么做好前端架構設計呢?這個話題太過于龐大,本次就從操作系統借鑒一些靈感,先談一談對分層與抽象的理解。
沒有絕對的分層
分層是架構設計的重點,但一個模塊在分層的位置可能會隨著業務迭代而變化,類比到操作系統舉兩個例子:
語音輸入現在由各個軟件自行提供,背后的語音識別與 NLP 能力可能來自各大公司的 AI 中臺,或者一些提供 AI 能力的云服務。但語音輸入能力成熟后,很可能會成為操作系統內置能力,因為語音輸入與鍵盤輸入都屬于標準輸入,只是語音輸入難度更大,操作系統短期難以內置,所以目前發展在各個上層應用里。
Go 語言的協程實現在編程語言層,但其對標的線程實現在操作系統層,協程運行在用戶態,而線程運行在內核態。但如果哪天操作系統提供了更高效的線程,內存占用也采用動態遞增的邏輯,說不定協程就不那么必要了。
按理說語音輸入屬于標準輸入的一部分,應該實現在操作系統的通用輸入層,協程也屬于多任務處理的一部分,應該實現在操作系統多任務處理層,但它們都被是現在了更上層,有的在編程語言層,有的在業務服務層。之所以產生了這些意外,是因為通用輸入輸出層與多任務處理層的需求并沒有想象中那么穩定,隨著技術的迭代,需要對其拓展時,因為內置在底層不方便拓展,只能在更上層實現了。
當然我們也要注意到的是,即便這些拓展點實現在更上層,但對軟件工程師來說并沒有特別大的侵入性影響,比如 goroutine,程序員并不接觸操作系統提供的 API,所以編程語言層對操作系統能力的拓展對程序員是透明的;語音輸入就有一點影響了,如果由操作系統來實現,可能就變成與鍵盤輸出保持一致的事件結構了,但由業務層實現就有無數種 API 格式了,業務流程可能也更加復雜,比如增加鑒權。
從計算機操作系統的例子我們可以學習到兩點:
- 站在分層合理性視角對輸入做進一步的抽象與整合。比如將語音識別封裝到標準的輸入事件,讓其邏輯上成為標準輸入層。
- 業務架構的設計必然也會遇到分層不滿足業務拓展性的場景。
業務分層與硬件、操作系統不同的是,業務分層中,幾乎所有層都方便修改與拓展,因此如果遇到分層不合理的設計,最好將其移動到應該歸屬的層。操作系統與硬件層不方便隨意拓展的原因是版本更新的頻率和軟件更新的頻率不匹配。
同時,也要意識到分層需要一個演進過程,等新模塊穩定后再移動到其歸屬所在層可能更好,因為從上層挪到底層意味著更多被模塊共享使用,就像我們不會輕易把軟件層某個包提供的函數內置到編程語言一樣,也不會隨意把編程語言實現的函數內置到操作系統內置的系統調用。
在前端領域的一個例子是,如果一個搭建平臺項目中已經有了一套組件元信息描述,最好先讓其在業務代碼里跑一段時間,觀察一下元信息定義的屬性哪些有缺失,哪些是不必要的,等業務穩定一段時間后,再把這套元信息運行時代碼抽成一個通用包提供給本業務,甚至其他業務使用。但即便這個能力沉淀到了通用包,也不代表它就是永遠不能被迭代的,操作系統的多任務管理都有協程來挑戰,何況前端一個抽象包的能力呢?所以要慎重抽象,但抽象后也要敢于質疑挑戰。
沒有絕對的抽象
抽象粒度永遠是架構設計的難題。
計算機把一切都理解為數據。計算結果是數據,執行程序的代碼也是數據,所以 CPU 只要專注于對數據的計算,再加上存儲與輸入輸出,就可以完成一切工作。想一想這樣抽象的偉大之處:所有程序最終對計算機來說都是這三個概念,CPU 在計算時無需關心任何業務含義,這也使得它可以計算任何業務。
另一個有爭議的抽象是 Unix 一切皆文件的抽象,該抽象使文件、進程、線程、socket 等管理都抽象為文件的 API,且都擁有特定的 “文件路徑”,比如你甚至可以通過 /proc 訪問到進程文件夾, ls 可以看到所有運行的進程。當然進程不是文件,這只是說明了 Unix 的一種抽象哲學,即 “文件” 本身就是一種抽象,開發和可以用理解文件的方式理解一切事物,這帶來了巨大的理解成本降低,也使許多代碼模式可以不關心具體資源類型。但這樣做的爭議點在于,并不是一切資源都適合抽象成文件,比如輸入輸出中的顯示器,它作為一個呈現五彩繽紛像素點的載體,實在難以用文件系統來統一描述。
計算機設計與操作系統設計已經給了我們很明顯的啟發,即一切能抽象的都要盡可能的抽象,如此才能提高系統各模塊內的穩定性。但從如 Unix 一切皆文件的抽象來看,有時候的技術抽象難免被當時的業務需求所局限,當輸入輸出設備的種類增加后,這種極致的抽象未必能永遠合適。但永遠要相信抽象,因為假若所有資源都可以被文件抽象所描述,且使用起來沒有不便捷的地方,為什么還要造其他的抽象概念呢?如無必要勿增實體。
比如 BI 場景的篩選、聯動、下鉆場景是否都能抽象為組件與組件間的聯動關系呢?如果一套標準聯動設計可以解決這三個場景,那自然不需要為某個具體場景單獨引入概念。從原始場景來看,無論篩選、聯動還是下鉆場景都是修改組件的取數參數以改變查詢條件,我們就可以抽象出一種組件間聯動的規范,使其可以驅動取數參數的變化,但未來需求可能引入更多的可能性,如在篩選時觸發一些額外的追加分析查詢,此時之前的抽象就收到了挑戰,我們需要權衡維持統一性的收益與通用接口不適用于特殊場景帶來成本之間的平衡。
抽象的方式是無數的,哪種更好取決于業務如何變化,不用過于糾結完美的抽象,就連 Unix 一切皆文件的最基礎抽象都備受爭議,業務抽象的穩定性肯定會更差,也更需要隨著需求變化而調整。
總結
我們從計算機與操作系統的架構設計出發,探討了前端架構設計的必要性,并從分層與抽象兩個角度分析了架構設計時的考量,希望你在架構設計遇到拿捏不定的問題時,可以向下借助計算機的架構設計獲得一些靈感或支持。