基於 GraphQL 平臺化 BFF 構建及微服務治理
01 什麼是 BFF
Backend For Frontend,即服務於前端的後端。
面對越來越複雜的多端應用的需求,後端提供的 RESTful 接口形式難以應對多變的頁面需求,這時候需要一層專門的 BFF 層來彌合這部分差異。
例如同樣一個商品詳情頁,在 App 端上和 PC 端上,兩者的展示樣式就有很多的不同。以往前後端分離的方式可能有幾種做法。
-
後端提供完全獨立的 RESTful API,然後由前端來進行聚合。前端需要負責處理多個數據源的聚合和前後數據依賴關係,並且由於經過了多次的外網請求對頁面性能、原生 App 的兼容性上都很不友好。
-
由網關層來進行聚合處理。這種方式不太容易靈活的定製一些聚合或者頁面邏輯的處理。
-
後端把數據聚合處理後,提供一個 API 給到前端。這樣後端的微服務之間會存在橫向的調用,而這是後端微服務架構裏一般需要極力避免的做法。
針對這樣的場景,現在一般會引入 BFF 這一中間層,讓前端應用直接和 BFF 通信,BFF 再和後端 API 進行通信,獲取數據並且處理完以後返回給前端。這樣就能比較好的滿足前後端各自的需求。其實從本質上來說是前端面向頁面場景和後端面向業務領域之間的矛盾,由 BFF 這層來解決。
但是 BFF 也只是爲了解耦前端和後端間的依賴而增加的一層,BFF 內部還是存在的非常多的問題。
02 BFF 的主要職責和問題
BFF 最主要是爲了針對前端頁面進行定製化的處理,雖然可以針對每個頁面都開發一個單獨的接口,但是實際上爲了開發效率,我們還是會在很多代碼上做一些複用。而這些頁面可能有部分共有的邏輯,又會有部分差異。對 BFF 進行深入的分析我們發現,BFF 面臨最主要的問題有三個:
第一個問題是按需取數
例如同樣一個商品詳情頁,在 App 端上,完整的獲取數據可能需要 100 個字段,對應 10 個接口。而在 Mobile Web 上,這個頁面可能只需要 50 個字段,對應 6 個接口。但是實際開發的時候,工程師爲了方便很容易寫出來一個大而全的方法,包含了這 100 個字段並且調用 10 個接口,這樣後期維護反而會很困難,而且拖累的部分頁面的性能。
面對這樣的場景,如果希望代碼能夠優雅的複用,對工程師的能力的要求會特別高,需要設計一套非常精巧的代碼框架來實現。實際情況卻是很容易演變成上面例子中描述的樣子。
而 GraphQL 正是這樣一套精巧的框架,可以很方便的按我們需求,選擇性的對字段和數據進行獲取。並且對於不需要獲取的數據,GraphQL 也不會調用對應的數據接口,從而提升訪問性能。
第二個問題是頁面差異化兼容
同一個業務針對不同的端,前端可能也是不同的團隊來負責的,使用的技術棧也不相同,因此需要的數據結構、字段名稱可能都不同。比如 Web 端需要完全平鋪的字段結構,而 App 上可以接受結構化對象結構,或者前端使用了低代碼平臺來實現,字段結構是跟着 UI 組件來走的。
對於字段的映射,本質上其實一種 JSON 結構轉換成另外一種 JSON 結構,我們參考了很多 Node.js 生態裏的解決方案,發現通過 JSON 模板渲染的方式來實現 JSON 結構的轉換是比較可行的方案。
第三個問題是不同版本的差異化兼容
在原生的 APP 上,BFF 層需要針對不同的版本做不同的處理。甚至原生的 iOS/Android 兩端,有時候也要做一些不同的兼容邏輯處理。例如老版本展示 A 樣式,新版本展示 B 樣式。或者 iOS 的原生代碼在某個版本有 bug,只能 BFF 來兼容。時間久了以後代碼會越來越難以維護,代碼裏充斥着各種 if-else 的判斷邏輯。
考慮到絕大部分的情況,App 版本發佈之後,對應的接口一般不會做大的調整,特別是兩三個版本以前的代碼,調整的概率更低。因此我們引入了路由的能力來解決這個問題。
-
不同的版本或者 iOS/Android 端映射到不同的 API 接口上,API 內處理 GraphQL 的調用和 JSON 模板映射
-
每次需要開發新版本接口的時候,可以簡單的複製以前邏輯到新版本接口上,然後做適當的調整
-
需要處理歷史版本邏輯的時候,找到對應 API,進行調整即可
這樣 BFF 裏的邏輯可以始終保持相對清晰,不同版本的邏輯都可以相互解耦。雖然會存在一定的代碼拷貝的問題,但是長期的維護上來說更加清晰了,而且也可以通過增加字段審計的能力來緩解代碼拷貝所帶來的問題。
03 平臺化構建 BFF 層
針對上面一節裏提到的三個問題和對應的解決方案,下面分別做詳細的介紹
-
數據獲取:多領域的按需取數和數據聚合 —— 引入 GraphQL
-
數據轉換:一種 JSON 結構轉換成另外一種 —— 引入 JSON 模板
-
請求映射:多版本兼容 —— 引入路由能力
3.1 GraphQL 簡單介紹
我們先對 GraphQL 做一下簡單的介紹,關於 GraphQL 更詳細的內容可以瀏覽官網 graphql.org 瞭解。
GraphQL 從名字上就能看出來和 SQL 有些類似。它首先定義了一套類型系統。這裏以官方的例子說明。
type Query {
hero: Character
}
type Character {
name: String
friends: [Character]
homeWorld: Planet
}
type Planet {
name: String
climate: String
}
官方定義了一套以《星球大戰》背景的兩個類型,角色和角色所屬的星球。這裏 type 可以對應到 Java 語言中的 class, 初看起來和 Java 語言沒有太大的差別。
{
hero {
name
friends {
name
homeWorld {
name
climate
}
friends {
name
homeWorld {
name
climate
}
friends {
name
}
}
}
}
}
這是一段 GraphQL 的 query 語句,通過 Query 對象的入口,就可以開始對 GraphQL 對象進行查詢了。它的查詢語句有幾個特性:
-
按需取字段,不需要的字段可以不查詢,類似於 SQL 裏的 select
-
在類型定義的基礎上,可以關聯查詢多個類型的數據,類似於 SQL 裏的 join(但不完全一樣)
-
可以遞歸的對某些字段進行理論上無限深度的查詢 (上面例子裏的 friends,不過一般會限制深度)
而在 GraphQL 的實現裏,是通過實現 DataFetcher 的接口來獲取真正的數據的,例如調用 RESTful 接口或者調用 RPC 接口,都是封裝在這裏。DataFetcher 可以綁定在某個 type 的某個字段上,這樣當訪問到這個字段時, GraphQL 會自動調用這個 DataFetcher 來獲取數據,沒有使用到這個字段自然也不會請求。也是因爲綁定到字段的原因,我們實現 DataFetcher 的時候可以聚焦在單一數據類型的獲取上,而把多類型的數據關聯交給 GraphQL 自己來完成。
通過 GraphQL 這樣的能力,我們即可以按需選擇需要的數據字段,也可以讓 GraphQL 自動幫助我們組裝多個數據對象的數據。
3.2 引入 GraphQL
從前面的介紹裏可以發現 GraphQL 和 SQL 有很多相似之處,而且也很容易對應起來。所以業界之前對 GraphQL 也有個普遍的誤解,需要後端把數據庫直接暴露出來整合進 GraphQL,這樣對後端的架構、數據庫的性能都有非常大的侵入性。但是 GraphQL 實際的使用上,可以很方便的融入現在普遍應用的微服務和 DDD 的架構。
我們可以用 GraphQL 服務來替換原來的 BFF 層,這樣後端原有的架構體系都不需要進行改變,只需要在 GraphQL 中實現 RESTful API 到 GraphQL 的轉換功能即可。這也是業界目前大部分公司使用的方案。
在我們的方案裏,爲了方便後端同學更加快速的接入 GraphQL 以及兼容我們內部的服務治理框架,我們提供了一套 Java 註解的方式方便業務的同學快速構建出一個 GraphQL 服務出來。
針對 GraphQL 裏的 Type、Enum、Interface、Union、Query 等,我們定義了對應的註解進行轉換。
而針對字段擴展,我們單獨定義了一個註解來進行處理,可以參考如下形式:
@GraphQLFieldAttach(targetType = "Property", sourceFields = "communityId", targetFieldName = "community", batch = true)
public MapResponse<Long, Community> getCommunity(@GraphQLQueryKey Set<Long> communityId);
我們的房源 (Property) 和小區 (Community) 數據是屬於兩個不同的領域來對外提供服務的。實際的業務場景裏,房源屬於某一個小區,有個字段 (communityId) 保存着小區 id,因此需要將這兩個數據對象進行關聯。我們提供了一個查詢小區的接口 (CommunityService),再通過上面的註解,在 GraphQL 裏綁定到房源的對象的 Property.community 字段上。這樣當查詢請求處理到 Property.community 的時候,會自動請求這個接口,獲取小區數據,返回給調用方。
同時爲了適配大家已有的微服務體系,這裏以 Spring Cloud 爲例,把上面的接口定義打包成類似 FeighClient 這樣二方包的形式,集成到 Gateway 中的依賴裏。然後掃描 Jar 包自動生成 Schema 和 DataFetcher,在 DataFetcher 裏調用對應的 FeignClient。這樣就可以自動構建出一個完整的 GraphQL 服務。
在 GraphQL 網關裏我們會解析各個服務的二方包,自動生成 Schema 和對應的字段解析調用。當某個業務有需求的時候可以非常快速的集成到我們的 GraphQL 體系中
目前二方包的依賴還是靜態管理的,有更新後需要重新部署網關,後續迭代中我們會升級支持動態更新 Jar 包以實現動態生成 Schema 的能力。
3.3 引入 JSON 模板
前端頁面所需的 JSON 字段的結構和 GraphQL 查詢結果的 JSON 結構往往不相同,而且頁面上也存在一些 format、if-else 的判斷邏輯,這部分放在 GraphQL 裏的話其實很難實現。特別是現在的一些前端低代碼平臺,頁面的展現模塊可能在很多不同的頁面複用,這樣的字段定義和後端的數據字段定義是完全不一樣的,一定需要有人蔘與這部分轉換工作。參考 Node.js 生態的解決方案和以前後端模板的頁面渲染方式,我們採用 JSON 模板來對這兩個不同的 JSON 結構進行映射。
目前我們的平臺支持 JSLT 模板、Javascript 兩種方式來進行 JSON 結構的映射,下面以 JSLT 爲例 (JSLT 是一個開源的 JSON 模板引擎,基於 Java 語言,詳情可以參考 JSLT)。
//GraphQL 的結果,模板的輸入 JSON
{
"data": [
{
"id": 10000,
"title": "房子 1",
"roomNum": 2,
"hallNum": 2,
"area": 90.12
}, {
"id": 10001,
"title": "房子 2",
"roomNum": 3,
"hallNum": 2,
"area": 99.34
},
...
]
}
//JSLT 模板
{
"dataList": [
for( .data) {
"id": .id,
"title": .title,
"label1": "戶型",
"text1": .roomNum + "室" + .hallNum + "廳" ,
"label2": "面積",
"text2": .area +"㎡",
"link": URLRoute("HousePage", {"id": .id})
}
]
}
//輸出JSON
{
"dataList": [
{
"id": 10000,
"title": "房子 1",
"label1": "戶型",
"text1": "2室2廳",
"label2": "面積",
"text2": "90.12㎡",
"link": "https://anjuke.com/house.html?id=10000"
},
{
"id": 10001,
"title": "房子 2",
"label1": "戶型",
"text1": "3室2廳",
"label2": "面積",
"text2": "100.34㎡",
"link": "https://anjuke.com/house.html?id=10001"
}
]
上面這個例子可以發現,最終輸出的 JSON 結構和字段名稱和 GraphQL 請求返回的結構完全不同。通過這樣的映射處理,可以完全解耦前端頁面的展示邏輯和後端提供數據的取數邏輯,根據前端頁面對返回數據的結構要求,我們可以進行各種 JSON 結構的轉換來適配。後期隨着模板越來越複雜,也可以引入一些可複用的子模板方式來進行管理
3.4 引入路由能力
路由這部分比較簡單,主要就是根據不同的端、版本、iOS/Anroid 等參數,映射到對應的 GraphQL 請求和 JSON 模板上即可。
3.5 構建 BFF 平臺
BFF 由前端還是後端開發,其實在各家公司都有不同的實踐。但不管是誰來做,都會存在一定的問題
-
BFF 由前端負責,需要額外關注服務器的穩定性、性能,以及 RPC/HTTP 請求的容錯等等,對前端同學的能力要求較高
-
BFF 由後端負責,由於並不一定能很好的理解前端頁面的各種數據需求,對後端同學來說基本上是純工作量。如果是一個獨立的後端 BFF 團隊,工程師容易覺得沒有成長,人員也很難穩定
這個問題我們先放放,回到 BFF 本身的開發工作,通過前面的拆解之後,我們發現 BFF 的開發工作其實比較模板化
-
數據獲取:編寫 GraphQL query,調用 GraphQL 服務獲取數據
-
數據轉換:編寫 JSON 模板,轉換成前端需要的 JSON 結構
-
請求映射:編寫路由邏輯,映射到對應的 GraphQL 請求和 JSON 模板上
基於這樣的項目開發流程,我們把整個 BFF 層構建成了一個平臺。開發同學只需要在平臺裏的三個表單裏輸入上面的內容,就可以得到想要的 API 接口。
整合完成後,我們的整體架構如下
-
統一請求入口:BFF 平臺負責對外部統一的 API 接口
-
請求映射:根據請求參數和內部配置的路由規則,把請求映射到不同的配置模板上
-
獲取模板信息:單個配置模板裏, 保存着 GraphQL 的 query 語句和 JSON 映射模板
-
數據獲取:使用 GraphQL query 語句調用 GraphQL 網關,獲取數據結果
-
數據轉換:調用模板引擎,進行 JSON 結構的轉換,並將數據返回給調用方
通過上述幾個步驟,我們的 BFF 平臺可以支持非常快速的實現一個 API 來對外提供服務
BFF 平臺由後端負責開發和維護,保證服務的性能和穩定性。前端主要的工作使用 BFF 平臺寫 query 和模板,完成頁面的數據拼裝。通過這樣的方式,前端和後端都能夠最大化的發揮自己的擅長的能力,優化團隊研發效率。
04 GraphQL 網關架構及微服務治理
前面的架構裏可以看到,我們是把 GraphQL 當做一個網關來處理,負責對接底層的微服務。在一些 GraphQL 應用的場景裏,隨着接入的業務越來越多,GraphQL 的服務會逐步的變成一個非常龐大的單體應用,維護起來會越來越困難。另外所有的業務都聚合到這一個 GraphQL 的出口,可能光 Schema 定義就需要上萬行。這樣不論是維護還是使用上都很難進行下去,而且與現在主流的微服務架構體系相矛盾
業界目前最主流的解決方案是 Apollo GraphQL 提供的 GraphQL Federation 功能,並且 Netflix 在此基礎上構建了一套 DGS (Domain GraphQL Service) 的架構來進行治理的。這裏做一個簡單的介紹:
-
每個領域服務單獨構建一個對應的 GraphQL 領域服務 (DGS)
-
由集中式的 GraphQL Gateway 藉助 Federation 的能力來負責聚合多個 DGS,自動生成統一 Schema 對外提供服務
但是這樣的做法只是解決了 GraphQL 服務的單體應用的問題,最終聚合出來的 GraphQL Schema 還是可能會非常的龐大,使用起來還是會很困難。而且整個架構其實是做了 2 層的 GraphQL 處理,一層在 DSG 上,一層在 Gateway 上,會有一定性能的重複開銷,服務穩定性上也有更多的挑戰。
針對這樣的問題,結合前文提到的註解方式構建的 GraphQL Gateway,我們設計瞭如下的架構
-
針對每個領域服務,使用我們的 GraphQL 註解定義一套類型和接口,然後用類似於 FeignClient 的方式提供給網關和服務方分別使用
-
領域服務實現這部分接口,提供 RPC 的能力給到 GraphQL Gateway 使用
-
接口註冊到 GraphQL Gateway,網關會爲每個領域服務的接口定義生成一個定義模塊 (Module)。同時針對每個模塊,網關也生成了對應模塊的 RPC 請求的封裝
-
業務方在使用時,定義一個業務應用 (Application),選擇這個應用所需要的模塊,網關自動聚合所選擇的模塊,生成該應用所對應的 GraphQL Schema。在 query 執行時,處理到對應的模塊,會調用對應的 RPC 接口訪問底層服務獲取數據
-
業務方根據這個所生成的 Application Schema 來開發
這樣,GraphQL 的使用方只需要選擇自己關心的模塊來生成 Schema 即可。比如我們的網關現在集成了十幾個領域,而某個頁面只使用到了其中的 3 個,只需要選擇這三個生成自己的 Schema 使用即可。而另外一個頁面可能用到了另外 5 個領域,也可以單獨生成 Schema。通過這樣的方式,可以把 Schema 的大小控制在可控的範圍內,維護起來也相對容易
另外由於在 RPC 的調用上減少了一層,而且 GraphQL 的處理都還是集中在網關內部一次性進行,在服務的穩定性和性能上的提升相對更容易一些。
05 應用場景
目前我們的最主要的應用場景是在我們內部的前端低代碼平臺上。
現在市面上的低代碼平臺大多數只考慮了前端的頁面如何快速生成,而對於後端的接口的實現上考慮的很少,一般都是生成模板代碼或者僅限於特殊場景的後端代碼生成。極端的情況需要後端針對每個頁面單獨再開發 API 接口進行對接,這樣對後端來說工作量其實更多了。
我們將內部的低代碼平臺和這套 BFF 平臺進行了整合,構建了一整套低代碼開發流程,幫助需求方能夠快速應用上線。
通過這樣的整合,構建了整個從前端到後端完整的低代碼平臺,來實現業務需求的快速上線
以我們現在線上的一個房價頁面爲例子
這個頁面由於多端上都存在,而且邏輯有部分差異,以前是寫了一個大而全的接口把所有端的邏輯都合到了一起,存在着維護困難和性能拖慢等問題
而在遷移到了 BFF 平臺之後,近期針對不同城市對頁面的不同需求的項目,開發工作量相比原來少了 50%。而且由於切換到 GraphQL 之後可以並行的按需取數據,頁面的接口性能也從之前的近 100ms 降低到 20ms 左右。
06 總結
隨着團隊規模、業務複雜的逐漸上升,傳統 BFF 模式實際上面臨了一個代碼可維護性、性能、個性化頁面的不可能三角。針對這樣的場景,我們構建了一套平臺化的 BFF,結合 GraphQL 、 JSON 模板以及微服務治理,來儘可能的解耦各個需求間的相互依賴,提升團隊研發效率,更加高效快速的滿足業務需求。
未來我們會在以下幾個方面進行更進一步的迭代以滿足我們的業務需求
-
二方包和 Schema 的動態更新支持,無需重啓即可更新 Schema
-
字段使用審計和調用量監控,對於長時間無訪問的 query、模板和字段可以提示業務方做下線處理
-
GraphQL 內部性能優化
-
支持低代碼平臺以拖拽的方式自動綁定數據
參考資料:
-
graphql.org
-
Reconciling GraphQL and Thrift at Airbnb
-
How Netflix Scales its API with GraphQL Federation (Part 1)
-
How Netflix Scales its API with GraphQL Federation (Part 2)
-
GraphQL 及元數據驅動架構在後端 BFF 中的實踐
作者簡介: 董菲:58 安居客後端架構師,負責過安居客 C 端二手房整體業務架構迭代,目前主要負責 58 安居客 B 端業務架構。
參考閱讀:
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TKv9rsPSnJHcT18Euokr1g