本文是 2021 年 12 月 26 日,第三十五屆 - 前端早早聊【前端搞 Node.js】專場,來自塗鴉的大前端基礎建設團隊 —— 龍野的分享。感謝 AI 的發展,藉助 GPT 的能力,最近我們終於可以非常高效地將各位講師的精彩分享文本化後,分享給大家。(完整版含演示請看錄播視頻和 PPT):https://www.zaozao.run/video/c35
正文如下
大家好,我是塗鴉智能的龍野,目前在團隊中主要負責前端網關和 Node 這個語言基礎方向的一些建設。今天我分享的主題是《如何用 Node 建設企業級應用網關》。
背景
前端網關的背景可以追溯到幾年前,當微服務剛剛出現時,我們在 Java 技術體系中有一張架構圖。
整個微服務大致是這樣的,外部請求從用户端進來後,先經過負載均衡器,再到達一個外部網關,最後被轉發到集羣內部的各種服務,不論是 Java 還是其他語言的服務。在集羣內部可能存在許多各式各樣的服務,服務之間的上下層劃分並不是非常明確。因此,外部網關到內部服務之間的邊界並不是特別清晰。
當某個服務出現問題時,外部網關路由層代理到內部時,我們需要定位或排查問題時可能會比較複雜。而且,如果這個服務出現問題後,需要迅速發佈一個修復版本,由於各個公司的發佈系統穩定性依賴於整個流程,可能需要較長的時間。
所以,我們希望能夠在外部網關到內部服務之間架設一個業務方向的網關,這就是現在 BFF 網關產生的背景。類似於現在的前置 BFF 網關,它是掛在外部網關之後,並且掛在內部服務集羣的業務之前的一中間的代理層。這層代理層也就是聚合服務,即 BFF 網關最初產生的背景,它的目的是想要將不同的上游服務的數據聚合後反饋給用户。
在這個基礎服務層中,我們可以擴展其功能。
- 當上遊的某個服務出現問題時,可以在 BFF 網關中進行兜底操作,避免請求到達出現問題的服務。
- BFF 網關可以統計針對某個業務方向的流量,同時對惡意請求進行過濾。
- 引入了 BFF 網關,用於在常用的服務中進行封裝和泛化,以處理不同的調用方法。業務方只需在網關中配置一個通用的接口,並使用通用的配置方式,即可請求不同的服務。
- BFF 網關還具有聚合接口數據的功能,以減少前端瀏覽器發起的請求數量,提高頁面的流暢度。
- BFF 網關可以通過配置實現多接口的聚合,使前端瀏覽器只需通過一次請求即可獲得多個接口的數據。
- 我們封裝了一些公共能力,通過網關插件,允許對業務有更深瞭解的人直接編寫網關插件以解決業務問題,降低業務開發接入的複雜度。
- 除此之外,使用這種網關還有一些其他附加價值。
a. 例如,具有數據偽造功能,網關可以通過配置直接提供假數據,在雲端或服務端暫時無法提供實際數據環境下進行聯調;
b. 自帶服務發現能力,用户只需知道網關地址,即可請求網關,無需關心網關內部的路由和數據來源;
c. 緩存能力,如果在特殊場景下這些服務出現了問題,我們可以返回上一次正常響應的頁面。
今天要講的內容一共分為以下幾個部分,後面我們將按照這個順序進行簡要講解。
網關基本架構
我們簡單聊一下 BFF 網關的架構。
作為內部用户,對於網關,我希望它能實現請求到達網關後代理到特定的服務,從而完成我要做的事情。但是業務的網關需求較為複雜,不僅僅是簡單的代理。如果只是簡單的代理,那麼直接部署一個 Nginx 不就好了嗎?
我們希望做的是最初的設想,即具備動態化配置 Nginx 的能力。默認情況下,Nginx 的規則是寫在 Nginx 的 conf 文件中的,但是如果要動態修改這些規則怎麼辦呢?瞭解過 Nginx 配置的同學應該知道,修改完後執行 reload 命令就可以使新的配置生效。
那麼作為一個開發人員而不是運維人員,我應該如何修改這個 Nginx 配置呢?社區中有很多類似的實踐,其中一個典型的例子就是將 Nginx 的配置寫入到 Consul 中,Consul 會以文件的形式將配置下發到 Nginx 特定的目錄。這樣,Nginx 可以通過執行 Nginx 的 reload 命令來加載新的配置。我們最初設計網關時的想法實際上就是這樣的。我們希望用户能夠自定義他們的規則,並將這些規則下發到線上的各種環境中。因此,我們先開發了一個網關後台系統,內部用户可以在後台系統中配置代理規則。然後,我們對這些規則進行了抽象,引入了項目的概念,項目下面包含了路由,這些路由函數的邏輯由我們自己來定義。用户可以將這些配置和數據寫入線上特定環境的持久化存儲中。
在這種情況下,我們已經解決了內部鏈路的問題。當外部請求進入 BFF 網關時,它應該執行怎樣的代理邏輯呢?
直接從 MySQL 中讀取數據顯然不合適,因為網關通常會處理大量的流量,而依賴於一個 IO 型的數據庫會導致性能問題。因此,我們進行了發佈操作,將數據從 MySQL 直接發送到線上的 Redis 中,因為 Redis 的讀寫速度非常高,可以支持足夠的業務請求。因此,網關的所有依賴配置都是從 Redis 中讀取的,這樣可以確保在高併發的情況下,網關仍然能夠處理足夠的業務請求。
對於正常的外部請求,我們這裏有一個簡單的流程圖。
在這種情況下,外部請求首先經過你的 DNS 解析,DNS 解析會將其轉發到特定的 LB(負載均衡)。根據這個 LB,請求將被代理到特定的 Nginx 和 Ingress。
一旦請求被代理到 Nginx,Nginx 將根據配置規則進行處理。在這個配置中,你可以決定將這種請求代理到集羣內部的網關服務,或者是其他服務,因為有些業務方可能希望將請求代理到自己的服務,而不需要經過網關服務。這種情況通常發生在前端業務或者與前端相關的業務中。如果我是一個雲端業務,我肯定不會特意走網關。一旦你的業務請求到達網關,網關將在運行時執行特定的邏輯,匹配到之前存儲在 Redis 中的配置,並根據規則進行處理。規則可以包括直接代理到像 Nginx 這樣的代理服務,或者請求雲端服務,以及從請求中獲取哪些數據等。這些規則可以通過使用 Node.js 編寫代碼的方式由用户自定義,這也是我們網關的一個重要目標,即賦予前端雲端服務端開發的能力。
接下來,我們將簡單地看一下接下來要關注的幾個要點。
- 用户在後台可以進行配置項目的管理,包括路由的配置和插件相關的配置。而這些配置的管理流程是怎樣的?
- 在運行時,我們需要解析路由代碼和插件代碼,運行時的執行流程是怎樣的?
- 插件的管理和正常代碼的管理可能存在差異,需要關注運行時如何處理插件代碼。
- 為了提供更好的用户體驗,目前我們使用 JSON 作為用户編寫路由函數的替代方式。我們需要關注如何將 JSON 解析為 TS 代碼的邏輯。
- Docker 出現後的好處之一是隔離性,即多個實例運行在虛擬機中環境是獨立的。在網關場景下,我們也需要關注這一點,甚至在未來的其他場景下,都需要關注代碼的動態執行能力。如果涉及到動態執行代碼,需要特別關注安全性和資源使用情況。
後台管理系統
在我們公司的網關後台管理頁面中,可以看到項目的域名配置。這個域名會被解析到 Nginx,然後再到網關。域名的解析方式可以使用泛解析,例如 *.xxx.com 的方式。默認情況下,請求會解析到 fast 網關,也就是我們的 BFF 網關。
這些路由像你寫代碼的時候,Controller 層的一個一個的方法,它把這些方法寫到運行時裏面,運行時就是 BFF 網關,運行時會解析請求,然後根據配置進行代理。我們簡單地新建一條路由,試一試這種配置方式。
路由的規則是默認的,用户需要查看網關相關的文檔,才能知道這些東西應該如何配置。比如説在這個地方配置一條 GET 請求的路由,我正常訪問的時候,就可以響應一個 "hello"。
至於這裏面的這些代碼運行時,我們也提供了一些依賴服務供給用户使用,包括一些鏈路相關的數據,還有一些全局變量和一些工具類的函數。這樣用户在寫這些路由的時候相對來説會比較方便。
接下來,我們簡單看一下項目的列表頁面。
需要提一下,由於我們公司是一個全球化的業務場景,為了讓用户在使用時不需要訪問多個系統,以降低使用成本,我們開發了一個功能,讓用户可以在一個系統上將數據配置分發到各個國家的數據庫或 Redis 中。這種方式在安全性方面需要考慮一些因素,需要與公司的安全部門商討,考慮各國家的政策,以確定數據是否能夠傳輸到不同地區,具體根據各公司的情況而定。
除了之前提到的列表模式之外,我們還為用户提供了一個基於 Web 的 IDE 編輯模式。
這種編輯模式類似於使用 Web IDE 進行編輯,編寫路由、網關和配置信息。通過這種方式,用户可以在不同的區域內同步數據,並且系統會生成 diff 操作,方便用户瞭解修改的內容。
目前我們主要使用微軟提供的 monaco-editor 的 npm 包來實現,該包專門用於實現類似於 VS Code 在線編輯器的功能。
關於後台系統的核心功能主要有以下幾點。
路由的增加、刪除、修改和查詢是我們的主要關注點。另外我們還提到了一個配置發佈的流程,儘管在我們的實際使用中沒有提到,但為了提升用户的使用體驗,我們在日常開發和預發佈環境中採用了一些技術手段來簡化發佈流程,以減少繁瑣的操作。
此外我們還支持路由的引用功能,允許其他業務方引用我們的路由信息,從而避免重複維護代碼的問題。這一功能可以提高開發效率,同時也需要與其他業務方進行充分的溝通和協調,以確保數據的一致性和安全性。
當涉及到企業級應用時,除了之前提到的一些功能外,還需要考慮權限管理。用户在操作某種數據時需要具備相應的權限,並且這些權限需要有記錄。另外,還需要一些插件、日誌鏈路以及接口的統計等能力,來保障網關的健壯性。
運行時要如何設計
有了後台系統,我們就可以把這個數據寫到 MySQL 數據庫,也可以把這個數據從 MySQL 這種持久化數據庫裏面去寫到 Redis 裏。那麼在運行時怎麼從 Redis 裏面去讀這個數據,並且響應給用户呢?
我們借鑑了 Koa 框架的核心思想,即通過在請求或響應階段的任何一層,附加一個類似於 AOP 的思想,在任何一層進行攔截和處理。
使用這個框架時,一個常見問題是如何管理上下文數據。這個框架未能提供足夠的建議,導致許多用户在上下文中掛載大量不明數據。這會導致後續用户不知道上下文中到底掛載了哪些數據。
為了解決這個問題,我們要求所有的數據只能掛載到 context.state 對象上,並對這個對象的類型進行約束。這樣我可以通過這個 context 對象瞭解所有後續請求的信息。
另外,Koa 框架的請求,進來就是全部進來了,出去就是全部出去了,沒有某個請求進來時可以停止執行的功能。因此,基於 Koa 框架,我們封裝了 Cube 和 CubeFlow 這兩個概念,其中 Cube 的概念實際上就是 Koa 的中間件,只是在這個中間件裏面我們引入了 enable 或者 disable 的概念。這樣,在請求執行到這個中間件時,我們可以直接進行 next 跳轉。通過這樣的方式,我們將多個 Cube 進行了串聯,形成了一條 flow,這個就是請求的執行鏈路。
在圖片左邊我們還可以看到,main.ts 是請求進入的執行入口,在這個入口裏面主要做了兩件事情,啓動運行時的監聽端口,啓動另外一個實例。這主要用於監聽另外一個端口以暴露一些指標,例如當前網關服務的健壯性和運行時的歷史記錄統計信息等,供其他服務進行採集和告警。
我們簡單來看一下這個目錄。適配器是一個用於處理路由規則的組件。這種適配器主要指的是路由規則的處理方式,我們默認情況下將路由規則寫入 Redis 中,但是後面我們可能會對其進行擴展。因 Redis 不適合存儲較大的數據,在直接讀取較大數據時可能會導致阻塞。對於一些私有化部署的場景,可能沒有 Redis 這樣的需求。那麼我們就提供一種基於文件配置的方式,預先將客户的一些路由規則寫入文件中,然後從文件中讀取。
很多人在項目中可能使用的是本地文件存儲的方式,這種方式確實簡便,但也帶來了一個很大的隱患。如果將一些賬號、密碼等數據寫入文件中,一旦文件泄露,對公司來説將是一個很大的安全問題。尤其是很多人喜歡將代碼存放在類似 GitHub 這樣的地方,相關的密鑰的泄露影響會非常嚴重。此外,還包括一些工具函數的暴露,以及關於日誌的處理。
我們為了便於後續的日誌查找,需要對日誌進行標準化處理,包括為日誌添加等級和類型字段。這樣,我們可以通過這些字段來索引和查找當前請求經過的所有日誌的類型。我們還使用了日誌鏈路和指標工具來觀測網關的健壯性和風險,以保證網關的可觀測性。
在鏈路方面,目前最流行且主要使用的工具有 SkyWalking 和 Jaeger。Jaeger是一款用 Golang 編寫的工具,符合雲原生架構的標準,具有良好的擴展性。我們同時在前端和雲端都使用了這兩套工具。
經過標準化處理後,我們目前在日誌鏈路中採用了 B3 數據傳輸規範。雖然它的官方提供了不同的部署模式,但不論哪種部署模式,由於缺乏前置隊列,在 span 數量過多時,可能會導致數據丟失。因此日誌鏈路雖然方便,但並不完全可靠。如果需要更可靠的鏈路追蹤,還需依賴日誌記錄。
在常用的中間件中,由於暴露了 HTTP 服務,需要注意處理請求中的 body 數據,否則無法解析 POST 請求的 body。網關還有一個核心功能,即關於登錄 session 的保存和 cookie 的處理。在後面的基礎中間件中,還涉及到 CSRF 安全中間件,這將在後續詳細討論。
現在,讓我們來看一下請求進入代理流程的具體步驟。
對於熟悉 Node.js 並且有前端插件編寫經驗的人來説,可能會對這一過程更加了解。 通過 http-proxy 將上游請求代理到指定的下游服務,核心代碼如上所示,官方文檔中還包含其他用法,其實都大同小異。
在封裝時,我們需要關注運行時的幾個重要點。例如,如果監聽失敗、用户自動取消、代理超時以及上游異常應該如何處理?應該返回什麼樣的信息給用户?
我們主要做了以下處理,當請求到來時,我們首先判斷這是否為代理請求還是 Ajax 接口請求。如果是代理請求失敗,我們會直接通過模板引擎進渲染一個失敗頁。如果是接口請求,我們會直接返回錯誤信息,並攜帶網關指定的錯誤碼。這些錯誤碼會在文檔中進行記錄,以便用户在看到錯誤時能夠了解發生了什麼問題。
關於運行時的代碼執行方式,我們需要查看下面的圖表。
用户配置的數據會被寫入 router 對象,並在運行時同步到 Redis 中。當請求到來時,我會先從 Redis 中獲取這個數據。獲取到的數據實際上是一個字符串。然後我們將 JS 字符串丟到 Node 提供的 vm 模塊中,然後在 vm 內部運行的代碼中,考慮到用户可能使用我們提供的 context 或者外部依賴 RPC 的情況,需要先將這些能力替換為特定的函數,這樣 vm 才能正確識別這些數據。
當然,需要額外提及的是官方的 vm 模塊存在一些安全性問題。動態執行代碼有很多方式,比如 function 等,但為什麼在最佳實踐中,這些方式基本上都沒有被使用呢?最大的問題就是,vm 直接將一些敏感的運行環境數據暴露給了用户,用户可以在虛擬機內部進行修改,從而涉及到了較大的安全性問題。因此,社區現在基本上都在使用 vm2 來替代官方的 vm 模塊,處理一些動態執行的場景。
目前,我們使用最普遍且隔離性最好的工具就是 Docker。Docker 在線程級別或進程級別,對不同的服務和資源進行了隔離。剛剛提到的 vm2,其安全性和隔離性方面還不夠。因此,如果我們想從線程層面對隔離進行更加嚴格的控制,我們可以考慮在 Docker 基礎上進行封裝,或者自行基於 vm 進行更上一層的線程級別封裝。
需要額外提到的是,Docker 在容器隔離方面的安全性可能還不夠。尤其是在涉及底層 Linux 文件描述符等配置操作時,容器可能會泄露一些數據到外部。社區目前也在致力於開發更加安全的容器隔離技術,建議大家密切關注相關的最新進展。如果你從事與阿里雲或其他雲服務商相關的產品開發,更需要關注容器隔離技術的安全性。
接下來,簡單地使用 JSON 來描述這段代碼。
與之前的流程類似,首先從配置中提取這些數據,提取到了這些數據之後,我們會對一些前置的變量和工具進行初始化,並在運行時,當解析到需要執行的路由時,進行編譯。這個編譯的過程實際上就是將 JSON 對象提取出來,然後進行替換的工作。由於這樣的替換涉及到了無限的層級配置,我們不方便在代碼中進行適配。因此,我們做了一個妥協,只允許接受 JSON 的第一層數據。
在代碼中,核心的部分其實是 router.run(),實際上執行的是類似於以下的步驟。
首先創建一個虛擬機,然後將代碼或者插件放入其中。最後,通過運行虛擬機實例來運行需要的腳本,並獲取代碼的返回值。
Drone 是一個基於 Docker 的、更方便隔離的工具。它的上層可以使用 vm2 來封裝,但具體使用的方式取決於團隊的需求和能力。作者寫的內容很不錯,有興趣的讀者可以在 GitHub 上了解更多。
插件的加載和管理
業務方認為在後台配置數據和工具很麻煩,我們為他們提供了一份文檔,告訴他們如何進行插件開發,讓他們可以根據自己的能力自行編寫插件,減少溝通成本。平台允許用户新增插件,默認使用內網的 GitLab 進行管理。代碼保存方式可能需要變更,不必保存在倉庫,可以直接存儲在 MySQL 數據庫或對象存儲中。
目前的插件源碼放在 GitLab 上進行管理。後續規劃中,我們計劃使用文件存儲的方式,直接將對象存儲為文件。這是因為類似於 vm2 這樣的工具可以直接加載文件並執行其中的代碼。關於數據同步功能,大家可以自行查看。
插件開發的模版如下圖所示,需要先定義輸入參數,並暴露一個函數作為插件的執行邏輯。業務方自己實現具體的業務邏輯,可以通過函數請求業務依賴的服務,也可以根據登錄相關的數據來修改上下文中的內容。插件是作用在全局的整個項目中,配置某個插件後,每個路由都會默認使用該插件。如果特定路由不使用插件,則插件需提供路由白名單。
在插件的使用上,有以下建議:
- 項目級和路由級配置,最終運行時在路由級識別最終插件代碼,並在鏈路上體現。
- 一般配項目級插件即可,特殊路由通過白名單跳過插件邏輯,或配置路由級插件。
- 不建議過度使用插件,就好比不建議 Koa 中間件過多,導致請求上下文過於複雜。
- 使用插件前需閲讀插件文檔,明確插件用途再來使用;如有疑問可聯繫插件開發者。
- 網關插件有版本功能,插件版本號需要遵循 semver 規範。
網關的安全性加固
最後要提到的一個功能點是網關的安全性,這在之前的分享中也提到過,安全性是一個不可忽視的問題。網關的安全性可能會導致公司陷入法律訴訟等嚴重問題,因此需要引起足夠的重視。
網關上可以實現一些在安全性方面的增強功能。例如,如果我經常暴露這種接口,那麼我的接口是否支持防止請求的重放攻擊呢?
防重攻擊
在接口防重攻擊方面,有很多種方案可供選擇。
我們這邊主要是通過左邊四個點的認證來確定請求是否合法。只要請求能夠通過這個認證,我們就認為該接口具備了防重攻擊的能力。不過,實際的防重攻擊力度應該達到什麼級別,這需要根據各個公司的安全委員會的要求來確定。具體而言,每個請求都必須能夠相互區別,並且可以拒絕重複的請求,防止請求內容被篡改,同時容許容錯。
在我們的場景中,我們發現大多數業務方在使用接口時,先從網關加載到前端的頁面,然後在頁面上發起 Ajax 請求。在這種情況下,當文檔加載完成時,網關會返回一個 nonce 作為響應。後續的請求中,限制前端必須使用自己的框架去發起 HTTP 請求,框架會在發起請求時進行封裝,將當前時間戳和 nonce 一同攜帶過去。當請求到達網關時,網關會解析參數,並判斷當前時間差是否在容錯範圍內。然後,網關會在 Redis 檢查請求中攜帶的 nonce 字段是否合法。
防 XSS 攻擊
關於常見的防 Web 安全的方法,其中涉及到的一個問題是 XSS 攻擊。
這種攻擊主要分為兩種類型,一種是反射型 XSS 攻擊。這種攻擊方式是用户在提交表單數據中攜帶一些惡意腳本,當後端存儲了這些數據後,就會出現存儲型 XSS 攻擊。當後面用户的請求從後端取出這些數據時,前端的腳本會在瀏覽器中執行,從而導致網站被攻擊。
可以通過配置兩種常見的包來配置 XSS 防護參數,一個是 XSS 包,另外一個是 Hamlet 包。在前端的場景中,一般會封裝一個函數來使用這些包。在安全防護上,主要是增加安全攻擊的難度,而不是完全避免。
防 CSRF
CSRF 這個跨站腳本偽造的問題和 XSS 攻擊可能是當前 Web 領域中最常見且危害較大的兩個問題。
與之前提到的防重攻擊方案類似,網關在渲染時會返回一個 token 值,然後前端框架在發起請求時會攜帶這個 token 值。通常情況下,前端會把 token 放在 cookie 裏與登錄態一起使用。
另外,網關主要對 post、put 和 delete 等可能對數據庫數據造成影響的接口做一些合法性校驗,對 get 等獲取請求大多數情況下直接放行。
內存級別和 IP 級別的限流
最後,安全性還涉及到內存級別和 IP 級別的限流。
當你的流量請求較大時,你服務的穩定性或響應時間可能會受到一定影響。如果你的網關服務多個業務方,類似之前提到的默認情況,多個業務方通過你的網關代理請求,那麼如果某個業務方發起惡意攻擊,一分鐘內發送成千上萬個請求,導致你的網關負載能力增加,QPS 上升,從而導致每個響應的延遲增加到四五百毫秒甚至一兩秒,其他業務方也會受到影響。
因此,首先我們需要對你的網關在特定配置下進行壓力測試,瞭解你的 QPS 大約能夠支持多少,響應延遲、內存、CPU 等資源是否在可控範圍內。可以在代碼中添加一個限流插件來進行限流。一個簡單的限流插件可以基於 map 來實現,即請求進來時進行計數,超過限定值時進行限制,然後請求出去時將計數減少。如果您想要更加健壯的限流方案,可以考慮社區上的一些限流 npm 包。我們現在在網關上實現 Mesh 化,當某個服務出現問題,不會影響其他服務。同時,在這種場景下,業務方到網關之間的網絡帶寬和延遲也可以得到性能優化。
其他功能
這個後台系統的功能在很多系統中都很常見,主要包括安全性合規、數據加密存儲、日誌輸出時避免敏感信息泄露以及配置中心接入。
結束語
最後需要關注的是,我們正在開發一個業務服務的 BFF 網關,這將幫助解決一些痛點。隨着業務的不斷變化,我們還需要對這個 BFF 網關進行優化,因為業務需求千變萬化。例如,剛才提到的管理方式的匹配化、後續的可觀測性提升,提供低代碼化或可視化的編排能力以提升用户體驗等。此外,在線配置路由等功能時,需要提供在線和線下的單元測試,並對路由信息的相關參數進行完善的校驗,這些都是我們需要嘗試實現的一些目標。
最後
以上就是我的全部分享內容。最後,推薦一本講經濟的書《未來二十年,經濟大趨勢》。