基於 GraphQL 平臺化 BFF 構建及微服務治理

01 什麼是 BFF

Backend For Frontend,即服務於前端的後端。

面對越來越複雜的多端應用的需求,後端提供的 RESTful 接口形式難以應對多變的頁面需求,這時候需要一層專門的 BFF 層來彌合這部分差異。

例如同樣一個商品詳情頁,在 App 端上和 PC 端上,兩者的展示樣式就有很多的不同。以往前後端分離的方式可能有幾種做法。

針對這樣的場景,現在一般會引入 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 版本發佈之後,對應的接口一般不會做大的調整,特別是兩三個版本以前的代碼,調整的概率更低。因此我們引入了路由的能力來解決這個問題。

這樣 BFF 裏的邏輯可以始終保持相對清晰,不同版本的邏輯都可以相互解耦。雖然會存在一定的代碼拷貝的問題,但是長期的維護上來說更加清晰了,而且也可以通過增加字段審計的能力來緩解代碼拷貝所帶來的問題。

03 平臺化構建 BFF 層

針對上面一節裏提到的三個問題和對應的解決方案,下面分別做詳細的介紹

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 對象進行查詢了。它的查詢語句有幾個特性:

而在 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 本身的開發工作,通過前面的拆解之後,我們發現 BFF 的開發工作其實比較模板化

基於這樣的項目開發流程,我們把整個 BFF 層構建成了一個平臺。開發同學只需要在平臺裏的三個表單裏輸入上面的內容,就可以得到想要的 API 接口。

整合完成後,我們的整體架構如下

通過上述幾個步驟,我們的 BFF 平臺可以支持非常快速的實現一個 API 來對外提供服務

BFF 平臺由後端負責開發和維護,保證服務的性能和穩定性。前端主要的工作使用 BFF 平臺寫 query 和模板,完成頁面的數據拼裝。通過這樣的方式,前端和後端都能夠最大化的發揮自己的擅長的能力,優化團隊研發效率。

04 GraphQL 網關架構及微服務治理

前面的架構裏可以看到,我們是把 GraphQL 當做一個網關來處理,負責對接底層的微服務。在一些 GraphQL 應用的場景裏,隨着接入的業務越來越多,GraphQL 的服務會逐步的變成一個非常龐大的單體應用,維護起來會越來越困難。另外所有的業務都聚合到這一個 GraphQL 的出口,可能光 Schema 定義就需要上萬行。這樣不論是維護還是使用上都很難進行下去,而且與現在主流的微服務架構體系相矛盾

業界目前最主流的解決方案是 Apollo GraphQL 提供的 GraphQL Federation 功能,並且 Netflix 在此基礎上構建了一套 DGS (Domain GraphQL Service) 的架構來進行治理的。這裏做一個簡單的介紹:

但是這樣的做法只是解決了 GraphQL 服務的單體應用的問題,最終聚合出來的 GraphQL Schema 還是可能會非常的龐大,使用起來還是會很困難。而且整個架構其實是做了 2 層的 GraphQL 處理,一層在 DSG 上,一層在 Gateway 上,會有一定性能的重複開銷,服務穩定性上也有更多的挑戰。

針對這樣的問題,結合前文提到的註解方式構建的 GraphQL Gateway,我們設計瞭如下的架構

這樣,GraphQL 的使用方只需要選擇自己關心的模塊來生成 Schema 即可。比如我們的網關現在集成了十幾個領域,而某個頁面只使用到了其中的 3 個,只需要選擇這三個生成自己的 Schema 使用即可。而另外一個頁面可能用到了另外 5 個領域,也可以單獨生成 Schema。通過這樣的方式,可以把 Schema 的大小控制在可控的範圍內,維護起來也相對容易

另外由於在 RPC 的調用上減少了一層,而且 GraphQL 的處理都還是集中在網關內部一次性進行,在服務的穩定性和性能上的提升相對更容易一些。

05 應用場景

目前我們的最主要的應用場景是在我們內部的前端低代碼平臺上。

現在市面上的低代碼平臺大多數只考慮了前端的頁面如何快速生成,而對於後端的接口的實現上考慮的很少,一般都是生成模板代碼或者僅限於特殊場景的後端代碼生成。極端的情況需要後端針對每個頁面單獨再開發 API 接口進行對接,這樣對後端來說工作量其實更多了。

我們將內部的低代碼平臺和這套 BFF 平臺進行了整合,構建了一整套低代碼開發流程,幫助需求方能夠快速應用上線。

通過這樣的整合,構建了整個從前端到後端完整的低代碼平臺,來實現業務需求的快速上線

以我們現在線上的一個房價頁面爲例子

這個頁面由於多端上都存在,而且邏輯有部分差異,以前是寫了一個大而全的接口把所有端的邏輯都合到了一起,存在着維護困難和性能拖慢等問題

而在遷移到了 BFF 平臺之後,近期針對不同城市對頁面的不同需求的項目,開發工作量相比原來少了 50%。而且由於切換到 GraphQL 之後可以並行的按需取數據,頁面的接口性能也從之前的近 100ms 降低到 20ms 左右。

06 總結

隨着團隊規模、業務複雜的逐漸上升,傳統 BFF 模式實際上面臨了一個代碼可維護性、性能、個性化頁面的不可能三角。針對這樣的場景,我們構建了一套平臺化的 BFF,結合 GraphQL 、 JSON 模板以及微服務治理,來儘可能的解耦各個需求間的相互依賴,提升團隊研發效率,更加高效快速的滿足業務需求。

未來我們會在以下幾個方面進行更進一步的迭代以滿足我們的業務需求

參考資料:

作者簡介: 董菲:58 安居客後端架構師,負責過安居客 C 端二手房整體業務架構迭代,目前主要負責 58 安居客 B 端業務架構。

參考閱讀:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TKv9rsPSnJHcT18Euokr1g