「全棧 Web 開發」在位元組跳動的實踐

語言: CN / TW / HK

:point_up_2: 點選藍字 關注我們,不錯過後續精彩內容推送~

10 月 27-28 日的稀土開發者大會上,位元組跳動 Web Infra 正式發起 Modern.js 開源專案,並第一次正式介紹了 Modern.js。
11 月 7 日,在第十屆全球軟體案例研究峰會中。孔嘉聰分享了《全棧 Web 開發在位元組跳動的實踐》。分享圍繞 Modern.js 展開,並專注於服務端的能力及收益,從不同的角度進一步介紹了 Modern.js。本文是該分享的文字版本,期待大家有所收穫。

大家好,我是來自位元組跳動 Web Infra 的孔嘉聰,在位元組跳動,我們部門負責打造「Web 技術中臺」和發展「前端研發體系」。

今年 10 月底,位元組跳動 Web Infra 正式發起 Modern.js 開源專案,第一次正式介紹 Modern.js。

在那次的分享中,我們普及了現代 Web 開發正規化,也介紹了在這種新正規化下,Modern.js 提供的能力和帶來的收益。

1. Modern.js 的機遇與職責

今天的分享依舊圍繞 Modern.js 展開,主要分為四部分。我們首先介紹 Modern.js 出現的原因。

然後會我們專注於 Modern.js 中的服務端部分,列舉 Web 應用中常見的服務端需求。這裡的 Web 應用不包含純 Node.js 應用。

接著,我們介紹 Modern.js 中是如何利用一體化開發去解決需求的。

最後會回顧一下在 Modern.js 開源之前,我們在位元組內部遇到的典型業務場景。

1.1 JS新時代帶來的機遇

當前業界的兩個背景,是支撐與催化 Modern.js 出現的主要原因。

其一是隨著 Serverless 的出現,前端開發者正在向全棧發展。這是令人興奮的事情,因為我們可以從頭到尾的去做我們想做的事情。但即使如此,前端開發者在參與全棧開發依然像這張圖一樣,在前端部分有豐富的知識和經驗,而在服務端部分卻相對薄弱。

https://css-tricks.com/ooooops-i-guess-were-full-stack-developers-now/

這讓我們確信,的確需要一個框架,能夠進一步助力前端開發者開發全棧應用。

其二是 Javascript 進入新的階段。近兩年,開發工具又出現新一輪更新換代的徵兆,有人把這種趨勢稱作「JS 的第三紀元」。

https://www.swyx.io/js-third-age/

其中很重要的一點,是各個框架都有明確的職責範圍。這種趨勢帶來的結果,就是工具層的合併。意思是從「很多工具做同一件事」向「同一種工具做很多事」發展,例如 Deno 整合了測試、Lint、Bundle。

這一趨勢允許 Modern.js 框架在有限的範圍內,為應用選擇,並提供完整的方案。

1.2 Modern.js的職責與願景

我們知道,完整的應用除了 UI 框架,還需要 Node.js CLI 和應用執行時的服務端。前者負責應用工程化相關工作,後者託管應用產物與其他請求邏輯。

然而整合 CLI 功能、開發服務端都是非常繁瑣,不同型別的應用又不一定能夠複用,需要重頭再來。

Modern.js 的最大願景就是希望為前端開發者遮蔽這些內容,讓開發者能專注於產品本身,成為產品開發者。當然其中也包括在「全棧開發」時能變得足夠簡單。

最終我們希望做一個「漸進式」的應用框架,不論是在 Node.js CLI 還是伺服器端,開發者一開始可以不使用 Modern.js 的任何功能,如 CSS In JS、TS、路由、SSR。只需要引入 src/App.jsx 就可以自包含的在開發、生產環境執行。

當需要更復雜的功能時,可以直接在框架內找到解決方案。框架從整體上考慮需求,更好的結合各個部分,產生 1 + 1 > 2 的效果。在框架內無法找到合適的方案時,也可以使用自己的方案。

在這樣的理念下,有了目前 Modern.js 的設計。

Modern.js 包含了一個極簡的核心、一體化 Web Server 以及完善的外掛體系。

我們提供了由框架與內建外掛結合的三種「標準工程方案」,開發者也可以在標準工程方案上,通過追加或者替換外掛實現「自定義工程方案」。

Modern.js 幾乎所有的能力都是通過外掛實現的,並且任何開放給內建外掛的功能,也同樣可以在自定義外掛中使用。

2.  Web 應用常見的服務端需求

到目前為止,我們大致介紹了 Modern.js 出現的原因、願景和設計思路。

接著我們著重介紹一下在 Modern.js 中,一體化開發的部分。首先我梳理了 Web 應用在服務端有代表性的需求。

2.1 Web Server

Web Server 指應用在本地、生產環境執行 Web 應用時需要的伺服器。常見的情況下,在本地開發會使用 webpack-dev-server、在生產環境會使用 express/koa 等 Node.js 框架搭建的服務。

2.1.1 入口與路由

最常見的 Web Server 需求就是「路由」。

在多頁應用中,入口對應的頁面都應該擁有獨立的訪問路由。

通常會有以下需求:自定義路由字首,自定義每個入口的路由,同一入口能通過不同路由訪問,不同入口能通過同一個路由、不同的請求條件(例如 user-agent )訪問,路由專屬的服務端響應頭以及動態服務端路由。

除了入口和路由的關係,每個入口同時又可以是 SPA 頁面。會存在以下內容需要考慮,例如伺服器端如何正確匹配到 SPA 路由對應的入口,單入口多路由、動態路由在瀏覽器端執行的一致性,以及隨著應用迭代,頁面路由結構的切換。

2.1.2 執行前置與同構

除了「路由」這類基本需求外,業界的一個發展趨勢,就是讓程式碼的執行儘可能前置,優先在編譯時執行,其次在伺服器端執行,最後在瀏覽器裡執行。例如下圖中的 SSG/SSR/CSR。

在 SSR 中,首先要關注的是服務端渲染資料的重用以及渲染的一致性。其次需要考慮例如 Helmet、Loadable、style-components 等庫在不同環境的協作執行。

如果要實現執行時能夠手動降級(例如 QPS 超過某一閾值)的需求,那就需要保證 SSR、CSR 同構,並且能快速切換。在效能方面,需要調研流式渲染、渲染快取、邊緣渲染。

在 SSG 中,有涉及到渲染與編譯結合,編譯與執行時資料隔離,再往後又會遇到按照入口渲染,或者 SSG/SSR/CSR 混合渲染的需求。

2.1.3 通用服務與定製邏輯

另外,Web 應用在服務執行邏輯上也同樣有許多需求。

通用需求中,例如基於 UA 的自動 polyfill,分發不同打包方式的 Javascript 資源。在我們公司內也會有微前端注入、i18n 注入等。

每個應用也會有獨立的執行時需求,像鑑權,引數預處理,路由重定向,機器人訪問攔截等。

2.2 API 服務

另外,部分 Web 應用,通常是中後臺應用,有應用專屬的 API 服務。比較常見的型別是 BFF 函式,也有趨近於完整 Node 應用的 API 服務設計。

搭建服務時,首先需要考慮如何在開發環境、生產環境執行服務,與 Web Server 如何共同啟動、熱更新,以及執行框架的選擇與 API 寫法設計。

在易用性方面,要考慮函式引數型別校驗,響應資料結構定義,自動選擇最佳請求方式(程序間呼叫、內網 ip 呼叫)等問題。

最後,針對使用場景也需要額外支援,實時場景下是否可以使用 WebSocket,或是使用跟客戶端結合更緊密的模式:GraphQL(Apollo 模式)。

2.3 應用部署

應用部署也是算是服務端的需求之一。

在現代 Web 開發中,很多優化是要靠「平臺層面」才能更好實現的需求。在位元組內部,這些問題由 Modern.js 和前端部署平臺協作解決。

2.3.1 一鍵預覽

一鍵預覽功能是在部署需求中重要的部分。這裡借用了 Vercel 官網的圖片,可以看到 Preview 是它最重要的三大要素之一。

一鍵預覽可以用於功能對比、驗收、提測、Bug 回溯等。通常需要考慮如何在本地完成部署,預覽部署與生產部署功能是否對齊,專屬預覽連結生成等問題。

2.3.2 應用部署優化

另一需求是前端應用的部署優化。如果前端部署平臺能夠認識產物,支援把 Web、SSR、API 不同部分等拆分,就可以在最適合的容器中部署,提供更安全穩定的執行環境。

在部署優化需求中,必須考慮框架層面和平臺層面如何協作,產物標記,請求轉發等問題。

圖上是我們部門同學在 GMTC 分享的內容,介紹了在位元組內部如何快速部署一個 SSR 應用。

3.Modern.js 的一體化開發

我們剛剛梳理了 Web 應用中常見的服務端需求,以及實現需求需要的成本。其實對很多的前端開發者來說,這部分是薄弱的,是很難展開整套工作的。即使花費大量時間完成了服務端的建設,也可能成為應用的專屬伺服器。

Modern.js 從一開始設計就考慮了這些因素,目的就是讓前端開發者能夠更簡單的開發服務端邏輯。我們提供頂層 API 支援和一致的抽象,避免成為某類業務的「專用伺服器」,達到節本提效的目的。接下來我們看看在 Modern.js 中,是如何通過一體化開發的方式來應對上述需求的。

3.1 自動路由

先說一下 Modern.js 中是如何自動處理路由的。

3.1.1 入口與服務端路由

Modern.js 的 Node.js CLI 以入口目錄結構與配置檔案為基礎,按約定生成路由協議。在使用者請求時,服務端會根據協議,匹配正確的處理邏輯。

路由部分的需求,都可以通過路由協議來解決。例如兩條指向相同 HTML 檔案的路由,就可以解決多路由訪問同入口的問題。定義了「頭部路由」的匹配規則,就能允許同一路由按需訪問多個入口。

另外,服務端會根據路由協議計算出正確的 basename,並下發瀏覽器端,保持 SPA 頁面的執行一致性。

3.1.2 路由模式切換

另外,Modern.js 中,提供了一系列入口規範,並且內建支援了單入口、多入口等。只要簡單的修改入口檔案,就可以在 MPA/SPA、基於檔案/程式碼的路由之間快速切換。

在這種模式下,即使在產品層面發生頁面組織結構的變化,開發者也無需關心路由的內部實現,只要對這個專案做些小調整,就能實現傳統開發中需要很多配置和樣板程式碼,或是需要不同腳手架才能實現的效果。

3.2 渲染一體化

接下來我們說說 Modern.js 中是如何通過一體化解決渲染模式上的需求。

3.2.1 同構渲染

Modern.js 提供的所有 API 都是服務端與瀏覽器端同構的。

例如有類似 useEffects 這樣的 Runtime Hook,叫做 useLoader。它會自動複用 SSR/SSG 資料,並在錯誤時降級。在瀏覽器端,useLoader 就相當於 useEffects。

因此,開發者只需要新增很少的程式碼,就可以切換使用框架提供的各種渲染模式,並得到不同模式帶來的收益。如開啟或關閉 server.ssr 配置就可以切換 CSR、SSR 兩種模式。

同樣,在配置檔案中開啟 output.ssg 也能直接開啟編譯時渲染。

這就是通過 Runtime API 與 Server 結合,一體化開發帶來的優勢。這種設計下,不論是執行前置,還是存量業務從 CSR 遷移到 SSR,或是一鍵降級都可以安全快速的實現。

3.2.2 按需渲染

SSR/SSG 同樣會標記在路由協議中,因此渲染模式也可以按入口決定。Node.js CLI 能夠識別這份協議完成編譯時渲染。其餘的頁面,也會在協議中標記出是否為 SSR,服務端在執行時會選擇最佳的執行邏輯。

在位元組內部,部署平臺提供的伺服器也運行了相同的 Node.js 邏輯,它會將匹配到的 SSR、API 路由的請求轉發到下游的 FaaS 函式中。此時,使用者無需任何運維,就得到了自動擴縮容的能力。這就是典型的框架層面與平臺層面結合得到的優化。

3.2.3 混合渲染

目前 Modern.js 支援全域性 SSG/SSR、區域性 CSR。例如頁面的頭部是內容固定的,就可以在編譯時優先渲染,而推薦列表的部分可以在瀏覽器端完成請求與渲染。

我們也在探索更復雜的渲染模式。如全域性 CSR + 區域性 SSR/SSG,以及 Server Component 的能力後續會逐步加入。

3.3 前後端一體化

Modern.js 中,前後端邏輯可以共同開發除錯,包括 API Server 與 Web Server。

3.3.1 一體化 API 呼叫

在 Modern.js 中可以像呼叫普通函式檔案一樣訪問 api/ 目錄下的 BFF 函式。框架基於 BFF 函式的路徑、引數等自動生成 API,並在渲染時請求,開發者完全不需要了解其中的網路細節。

此時,前後端可以共享資料型別,開發者能享受到函式呼叫時完整的 Lint 與程式碼補全功能,框架也能選擇最佳的呼叫方式進行請求。

在 BFF 的函式定義上,Modern.js 做了額外的約束,我們不允許自由定義函式的入參(即使這的確非常方便)。因為一旦這樣做,產出的路由必然由「 私有協議 」進行呼叫,而無法實現任意的 RESTful API。

當該服務僅用於應用本身時不存在問題,但「不標準的介面定義」無法融入更大的體系,會導致其他系統也需要遵循「私有協議」。另外,在專案 API 服務需要抽離為獨立服務時,也非常不方便其他應用接入。

Modern.js 中也提供了型別友好的函式定義方式,可以通過 Type Schema 實現執行時引數校驗,提供標準響應格式。

3.3.2 漸進式的 API 服務

API 服務的設計也是漸進式的。應用剛起步時,可能只需要一個函式,將遠端的資料聚合、裁剪。

應用迭代過程中,出現了例如鑑權的需求,Modern.js 支援使用者在鉤子檔案中定義中介軟體,通過自定義邏輯來解決這類需求。

隨著業務繼續迭代,函式寫法的檔案結構已經無法很好的管理程式碼。Modern.js 也支援使用框架寫法來啟動 API 服務。如上圖結構中,開發者可以使用 Egg 本身的能力。

3.3.3 定製 Web Server

Web Server 的需求也可以歸為前後端一體化的一部分。

Modern.js 內建支援了部分 Web Server 的通用需求,例如靜態資源差異化分發,以及根據 UA 的自動 polyfill。使用這些功能時,開發者只需要簡單的進行配置,或是引用某些外掛。

對於非通用的需求,Modern.js 也支援在 server 目錄的鉤子檔案裡,對框架自帶的 Web Server 新增邏輯,和 API Server 的鉤子檔案一樣,可以自由新增中介軟體,例如實現許可權識別和重定向。

3.4 可拔插的應用結構

在位元組跳動內部,會遇到希望從 Node.js 應用擴充套件為全棧應用的場景,也有需要將全棧應用中 API 服務部分抽離出去作為 Node.js 應用單獨部署的場景。

為了解決這類應用邊界變化的問題,在 Modern.js 應用中, api/ src``/ 都是可拔插的,在這種設計下,應用也可以快速的實現架構調整,並且前端部分與 API 服務共同部署、獨立部署或增量部署也能夠方便的實現。

4.業務場景

上面部分介紹了 Modern.js 是如何通過一體化開發的方式來解決伺服器端需求的。

最後我們來一起看一下,在位元組內部,Modern.js 都遇到了哪些業務場景。

4.1 使用渲染快取

在位元組內部,有名為「熱點大事件」的 SSR 業務。由於業務的特殊性,可能會出現流量在短時間內幾十倍甚至百倍的增加。

之前提到,在位元組內部我們 SSR 的服務是跑在 FaaS 上的。在流量突增過程中,函式冷啟動會帶來一定時間的 SSR 降級。解決這類問題的根本方法是函式預熱、優化冷啟動時間、提高 SSR 在單例項中的併發數,但這些方法都無法在短時間內得到量級上的提升。鑑於這種情況,應用選擇使用 SPR(渲染快取)來解決問題。

左上角圖中是開發者開啟渲染快取需要追加的程式碼。右邊圖中是接入渲染快取前後,兩次流量突增時,SSR 的降級資料。

可以看到,在接入渲染快取後有一次更大規模的流量突增,但是降級率卻和平時沒有任何變化。這是因為渲染快取攔截了大部分流量,所以流入 FaaS 中的流量也相應變少,冷啟動造成的降級也隨之減小。並且,左下角的圖中顯示,在相同業務流量下,需要的例項數僅僅是原來的十分之一,節省了大量機器資源。

同時,在接入渲染快取之後,應用整體的渲染耗時也大幅下降。

4.2 使用 SSG 代替 SSR

第二個想分享的場景是位元組官網,運營通過 CMS 管理資料。之前以 SSR 作為渲染模式,並在 Web Server 中自建了資料快取,使用獨立部署的方式執行服務。期間受到過一次 DDoS 攻擊,導致 SSR 服務執行中斷。

經過調研,我們發現 SSR 並不是這一場景最好的方案。一是因為官網上的資料更新並不需要達到秒級。二是因為 SSR 的特殊性,在服務端能承受的 QPS 肯定是遠小於純靜態 Web 的。

因此,在新的方案中,我們選擇了使用 SSG + CMS Web hook 的方式,並配合平臺層面,使用 v8 worker 來部署服務,得到快速擴容的能力。

在運營修改 CMS 資料後,會自動觸發應用的構建流水線,重新構建頁面與部署。在這之間,運營同學需要做的事情沒有任何變化,應用開發者也只需要簡單的開啟 SSG,但能承受的 QPS 至少提高了 20 倍。

4.3 自定義工程方案

第三個場景是位元組內部的火山引擎平臺。它有一套自建的子應用接入方式。

應用在接入時,常常會需要修改原本的專案結構,或是放棄原有的一些功能。同時,因為一些政策原由,應用有可能同時需要接入到海外版本、國內版本,有時還要兼顧單獨部署的模式,開發者會花費很多時間來顧及這些內容。

而對於 Modern.js 來說,因為我們提供了足夠的抽象,應用可以很方便的完成拓展。如圖,只需要接入已經開發好的外掛,就可以自動的適配各種部署方式,並且初始化的目錄結構沒有什麼變化。這樣建設出來的工程方案,既能滿足垂直場景的需求或自己的偏好,又能自動獲得 Modern.js 的能力和收益。

4.4 其他場景

另外,我們也遇到過使用 Go Server 搭建 SSR 服務的應用。光是環境配置的文件就有很長一篇,更不用說需要獨立維護 Golang 的服務端應用。這不管對於工作交接,還是個人成長上都是非常不友好的。

後來應用遷移到了 Modern.js 中,因為這些都是框架現成的功能,遷移並沒有花費特別多的時間,開發者只需要遷移業務程式碼即可。在切換到 Modern.js 的體系後,開發效率有了明顯的提升。

在以上場景中都可以發現,在 Modern.js 的設計下,應用開發者只需要追加些許程式碼,就可以獲得很大的收益。

最後,借用之前分享中的一張圖,這些部分也都算是 Modern.js 的重要功能,後續會陸續對外開放,歡迎大家持續關注。

最後,歡迎大家掃碼加入「Modern.js社群交流群」,也可以在官網上了解更多 Modern.js 的內容。

謝謝大家。

:point_down:  點選 閱讀原文 ,直達 Modern.js 官網