基於 GraphQL 的雲音樂 BFF 建設實踐

雲音樂技術團隊發表於2022-12-28
圖片來自:https://bz.zzzmh.cn/
本文作者: cgt

背景: 如何解耦大前端與服務端的適配層依賴

談到 BFF,相信大家都不會太陌生,過去在雲音樂,前後端的協作架構一直維持比較傳統的前後端協作模式。各個端所需要的介面完全依賴服務端提供,服務端同學除了需要完成微服務的業務邏輯外,還需要針對前端頁面排程各個領域的微服務,根據前端的資料訴求進行一定程度的組裝和適配。

在年初,我們計劃針對雲音樂的P0頁面,個人主頁進行改版,雲音樂的個人主頁聚合了來自各個頁面領域的資料,比如使用者個人資訊,Mlog,雲圈,K歌等等,這些資料來自於各個不同的服務端團隊。對大前端同學來說,我們期望能透過儘量少的介面呼叫獲取到這些資料,以保證頁面效能,同時,我們期望獲取的資料介面能和頁面 UI 高度適配,不需要在端上進行太多的資料轉換。因此,服務端同學為我們抽離了一層獨立的中間服務,負責聚合各個業務的介面資料,同時,我們需要各個業務服務端將業務領域的 DTO 轉換為 VO,保證能和 UI 進行適配。

在改版過程中,我們發現了這個模式的一些問題:

  • 大前端所需介面的契約定義,對服務端有深度依賴,很多時候一個頁面欄位的變更就需要平臺服務端以及業務服務端進行評估和排期,由於職能差異,中間會產生大量的溝通成本。
  • 由於前端UI的多變性,各個業務服務端針對該場景提供的介面,很難具備複用性,一旦更換了其他場景,服務端同學又不得不封裝新的介面。

而針對這些問題,我們發現業界其實已經給出了比較成熟的解決方案,就是透過在架構上引入 BFF 層。

BFF 的全稱是「Backend For Frontend」,顧名思義就是面向前端的後端。它的主要職責就是針對頁面的資料訴求,進行微服務的排程以及資料的組裝和適配,這一部分原先我們透過微服務去完成,但現在它從微服務拆解出來得到了獨立。

在 BFF 的架構裡,我們不再需要平臺服務端為我們提供資料聚合,這解決了我們之前提到的問題:

  • 大前端同學可以開始自行完成這一層的資料組裝工作,從而與服務端在適配層完成解耦,大部分欄位的變更都可以由前端同學閉環完成,再沒有大量的溝通成本。
  • 服務端同學無須再進行從 DTO 到 VO 的資料轉換,從而可以提供複用性更強的介面,微服務的職責也會更加明確。

在雲音樂,存在大量類似的場景,我們期望在這些場景下都能落地 BFF 架構,最終,隨著可複用介面的沉澱,以及溝通成本的降低,可以幫助我們提升整體的業務吞吐量。

那麼,問題來了,如何在大量類似的場景中,讓大前端同學來承接 BFF 層呢?

為什麼我們選擇GraphQL?

Faas VS GraphQL

目前業界比較主流的兩種 BFF 實現方案。

首先是基於 NodeJS + Faas 的形式,這種模式是基於大部分 Web 前端同學對 NodeJS 有一定基礎,可以快速上手,同時它的編排非常靈活,基本能滿足所有 BFF 訴求,甚至能超出 BFF 的邊界,最初,我們也期望依靠這種模式落地 BFF ,但很快,我們就發現這種模式面臨的一些挑戰:

  • 基礎建設要求高:這種模式對團隊的 Node 基礎設施和雲原生基礎設施有一定要求,畢竟掌握 NodeJS 開發是一方面,針對服務的監控,運維,部署,線上問題除錯都需要有對應的解決方案,並且我們需要保證這些保障能覆蓋到所有 NodeJS 服務
  • 存在一定學習成本:除去 Web 前端的同學,對原生客戶端的同學來說,儘管 NodeJS 比較輕量,也是一門全新的語言。

第二種方式就是我們今天要聊的基於 GraphQL 的模式,GraphQL 定義來一套用於 API 的查詢語言,開發者甚於可以透過一些低程式碼的編排快速完成查詢語言的定義,這給我們帶來了以下優勢:

  • 與技術棧解耦:開發者只需要認知 GraphQL 的 DSL,而不用再多學習一門語言,而 GraphQL 的 DSL 相對來說要好上手得多。
  • 複雜度更加可控:我們可以統一實現 GraphQL 的執行引擎,開發者全部基於我們的引擎服務執行查詢,能夠自定義的僅僅是資料圖以及查詢語句,從而我們可以將服務開發的一些最佳實踐附著到引擎上面。

什麼是GraphQL?

好,那到底什麼是 GraphQL 呢?

GraphQL 總體分成兩部分:

  • 一套用於 API 的查詢 DSL:也可以稱為 GraphQL 語句,你可以在這套 DSL 中描述你的查詢所需要的欄位,以及需要呼叫的介面,所需傳遞的引數等等。
  • 一個基於圖狀資料的服務端執行時:來執行這套查詢 DSL,它的執行邏輯就是從一張完整的資料圖上,根據 GraphQL 語句的描述找到需要的節點,排程涉及的介面,最後返回符合查詢語句的資料。


比如:在上圖展示的案例中,我們在(圖左)編寫了查詢語句,(圖右)則是引擎執行該查詢語句後,在資料圖上命中的節點。

可以看出,落地 GraphQL 的關鍵就在於實現它的服務端執行時,而 GraphQL 的執行時整體也可以拆解為三個部分:

  • GraphQL 引擎:解析 GraphQL 語句,目前社群已經提供了各個開源版本的 GraphQL 引擎,包括 NodeJSJavaPython 等等,我們選定適合自己的版本即可。
  • 型別定義:GraphQL 的型別系統其實和其他型別系統大同小異,GraphQL 提供了一些基礎標量,你可以在這些基礎標量的基礎上不斷擴充套件自己的業務模型,最終生成圖狀資料結構。
  • 解析器:我們需要描述這些型別節點所需要執行的查詢,當然,並不是所有的節點都需要執行查詢,我們只需要保證查詢的結果和節點的型別定義一致即可。比如在上圖的節點中,我們分別給 song 節點和 album 節點執行了一次查詢,他們會調獲取歌曲詳情以及獲取專輯詳情的 RPC 介面返回相應的資料。

如何在雲音樂落地?

在瞭解 GraphQL 的執行機制後,我們開始考慮如何在雲音樂進行落地,在進行方案設計的階段,我們提出了一些問題:

  • 我們如何讓大前端同學能夠搭建穩定可靠的 GraphQL 執行時?大部分大前端同學並不具備服務端開發經驗,對服務的開發,部署,運維基本一無所知,從零開始搭建 GraphQL 執行時會帶來巨大的操作成本
  • 如何快速上手 GraphQL 語句?儘管 GraphQL 語句上手並不複雜,但它本身不在前端同學的知識體系內,上手依然存在一定的學習成本。
  • 如何與雲音樂現有研發體系的對接?
  • 如何儘可能擴大 GraphQL 的邊界?

針對前兩個問題,我們想到可以透過低程式碼的方式進行 GraphQL 的應用研發,低程式碼可以說是當前業界非常流行的,一種可以打破職能邊界的手段,很多團隊透過低程式碼讓服務端同學具備了搭建前端頁面的能力。那麼反過來思考,前端同學同樣可以透過低程式碼從而具備編排服務端邏輯的能力。考慮到這個方向後,我們發現 GraphQL 天然就非常適合採用低程式碼的形式進行搭建,其 DSL 的設計可以方便地轉換成結構化的資料,從而對映成介面的操作。

針對第三個問題,GraphQL 應用應該具備和雲音樂常規應用一致的釋出流程管控,避免無序釋出導致的線上事故,我們透過 Git 倉庫對 GraphQL 應用的語句,型別定義,解析器進行管理,並融入雲音樂前端研發平臺 Febase 進行釋出流程的管控。

最後一個問題,我們希望 GraphQL 能解決至少 70% 的 BFF 編排場景,如果僅僅依賴其自身的能力,會導致落地場景受限而意義不大,因此我們基於 GraphQL 的指令機制,對 GraphQL 的能力進行了擴充套件,從而能應對更多的場景。

分散式的架構設計

我們整體採用了分散式的架構設計:

從流量走向上看,前端依然透過 Restful 請求獲取頁面所需要的資料,這樣做的目的是我們的所有請求依然可以依賴雲音樂的通用 API 閘道器,具備流量控制,異常降級,靜態化的能力,從而極大地提升了介面穩定性。

而當請求透過 API 閘道器後,會轉發到 GraphQL 應用所在的叢集,GraphQL 應用的內建引擎會將介面 URL 轉換成 GraphQL 語句,從而執行 GraphQL 語句,排程服務端 RPC 介面進行資料組裝,最終返回頁面需要的資料。

我們會為每一個 GraphQL 應用分配獨立的雲原生容器,依託於雲音樂雲原生的基礎建設,我們可以靈活安排每一個 GraphQL 應用所需要的 Pod 數量,甚至能根據 CPU 進行容量的擴縮,從而減輕前端同學的運維負擔。

在 Febase 平臺,我們提供了低程式碼的 GraphQL 編輯器,Groovy 指令碼的編寫能力,釋出流程的管控,視覺化的資料圖編排能力,最終基於這些能力,平臺能夠輸出一份 GraphQL 應用配置,這份配置的內容包括:

  • 從 URL 到 GraphQL 語句的對映關係
  • 執行查詢語句所需要的資料圖
  • 查詢節點的解析器配置

引擎透過監聽 zookeeper 拿到這份配置,並進行更新,這個過程就是 GraphQL 應用的部署過程,由於整個部署過程不會涉及到服務的重啟,僅僅是一次配置檔案的熱更新,所以它的日常釋出也會非常快捷,幾秒就能完成,進一步提升我們的研發效率。

在這些基礎能力之外,我們也和雲音樂的一些基礎設施平臺進行了打通。比如:

  • 透過 Mock 平臺,我們允許開發者自由配置介面的 Mock 資料,只需要在請求頭中加入一個標誌位,就可以讓請求走 Mock 鏈路。
  • 所有 GraphQL 語句,資料圖,指令碼都會儲存在 Gitlab 進行管理,透過分支進行編輯。
  • 雲音樂的契約管理平臺為我們提供了 Java 服務端 RPC 的資料模型,使得我們可以以近乎零成本的方式來構建資料圖。
  • 基於 Serverless 進行應用容器的部署,保證我們的服務可以靈活地擴容縮容。
  • 打通了效能,日誌等各類服務監控平臺,具備完備的服務運維能力。

基於契約快速構建GraphQL Schema

在瞭解我們的整體架構後,我們繼續來看看 Febase 是如何以近乎零成本的方式構建 GraphQL 的資料圖的。下面是一張非常簡單資料圖的構建過程,GraphQL 構建資料圖的方式就是從根節點出發,錄入欄位以及欄位對應的模型,並且我們可以在任意模型下插入新的欄位,並定義該欄位的模型。在插入欄位的時候,我們需要定義欄位對應的解析器,也就是該如何獲取到欄位對應的資料。

我們發現,在傳統的 GraphQL 資料圖編排中,開發者需要自行定義模型和解析器,而事實上大部分時候,這個過程只是在搬運服務端的模型定義。因此在這裡我們約定了解析器做的事情僅僅只是呼叫服務端的 RPC 介面,那麼只要開發者選定 RPC 介面,我們就可以根據其響應的元資訊拉取到服務端的資料模型,從而建立資料圖。

比如在上面的例子裡,當我們要匯入 song 這個欄位時,系統實際上是在倉庫的約定路徑下建立了兩份檔案:

  • resolver.json:描述引擎該如何呼叫介面,比如 RPC 介面的類名,方法名等等
  • type.schema:儲存根據介面響應生成的 GraphQL 模型資訊

下面是一份最簡易的 resolver.json 示例:

{
  "type": "rpc", // 呼叫的協議型別
  "clzName": "com.netease.music.api.SongService", // RPC 類名
  "methodName": "getSongById", // RPC方法
  "params": [] // RPC引數型別列表
}

type.schema 其實就是 GraphQL 的模型定義:

type Query {
  song: Song
}
type Song {
  id: ID
  name: String
}

那麼,我們是如何生成這份模型資訊的呢?

透過研究 GraphQL 的引擎原始碼,我們發現,GraphQL 的模型定義,其實可以透過官方引擎提供的內建方法,等價轉換稱一份標準的 JSON 結構。那麼,相對於生成模型定義的原始碼,生成這份 JSON 結構要簡單得多,比如,上文提到的 Song 模型,就可以進行如下轉換:

import { introspectionFromSchema, buildSchema } from 'graphql';

const schema = introspectionFromSchema(buildSchema(schema));

轉換後可以生成如下結構:

而在雲音樂,所有服務端的介面模型定義都會維護在雲音樂的契約管理平臺,同樣具備一份 JSON 結構來描述。

<img src="https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/23211534140/88f9/7d3c/9211/bb2d1ec275ba645f82ad16211e802b9b.png" width="40%"/>

這兩份資料在邏輯上幾乎完全等價,我們編寫了一個轉換器,定義一些從 Java 型別到 GraphQL 型別的對映關係,即可完成轉換,最終生成 GraphQL 需要的型別定義,並儲存在我們的 Git 倉庫中。

在資料圖的展示上,我們直接採用了 graphql-voyager 這個開源庫,透過一些擴充套件讓其具備了欄位編輯能力,欄位搜尋等開發過程中經常要用到的一些能力。

基於AST打造LowCode GraphQL編輯模式

有了型別定義之後,接下來,我們就開始考慮如何編寫 GraphQL 語句了,下圖是我們的編輯介面。

編輯器採用的實際是 LowCode 和 ProCode 雙重模式,大部分時候,開發者只需要進行欄位的篩選,以及一些指令表單的配置,即可完成 GraphQL 語句的編輯。

那麼,我們是如何實現這一效果的呢?

GraphQL 官方提供了語句編輯器 graphiql 已經非常強大,提供了語法提示,錯誤校驗,語句除錯等基本能力。但為了在團隊內部大規模推廣,這樣的使用方式還是相對來說比較原始,我們需要進一步降低開發者的使用成本,提供 LowCode 的編輯模式。

這裡我們就會提到為什麼說 GraphQL 天然適合 LowCode 的編輯模式,我們知道,所有低程式碼編輯模式都需要定義一套標準協議,而介面的大部分操作都可以對映成對該協議的操作變更。

而針對一段 GraphQL 語句,透過官方引擎提供的內建方法,我們可以輕鬆獲取到它的 AST 結構,並且這段 AST 結構非常容易閱讀和理解:

{
  song @param(from: "$query.id") {
    name
  }
}

透過呼叫轉換方法,可以得到如下結構:

import { parse } from 'graphql';

const ast = parse(query); 

針對這部分結構,我們可以和介面建立對映關係,比如當我們透過對資料圖文件進行欄位勾選時,實際是生成相應的 selection 結構,並將其插入到指定路徑的 selections 中。而當我們透過表單配置指令時,修改的就是相應路徑的 directives 結構。

並且由於我們操作的是 AST 本身,所以開發者同樣可以自行進行 GraphQL 語句的編寫,語句的變更同樣能夠在操作皮膚體現出來。

除去低程式碼編輯能力外,編輯器還提供了一些輔助功能,這些功能可以讓 GraphQL 介面的開發更加流暢便利,比如:

  • 自動生成介面文件 :GraphQL 的查詢結果屬於資料圖的子集,這樣我們完全可以根據開發者的 GraphQL 語句生成響應結構,並分析出依賴的引數,從而自動生成介面文件,讓 GraphQL 介面也能擁有清晰的定義。
  • 追溯請求鏈路:線上開發最大的難點就在於問題的定位和除錯,為了幫助開發者更輕鬆的定位問題,我們針對線下環境 GraphQL 語句的每一步操作都進行了打點,包括每一次 RPC 呼叫,指令碼執行,並且記錄了每一次操作的輸入和輸出,這樣,開發者在進行了一次查詢後,就可以檢視完整的請求鏈路,在請求出錯時進行問題的定位。

基於指令和指令碼強化原生GraphQL能力

剛剛我們提到,要用 GraphQL 滿足至少 70% 的 BFF 編排場景,如果只是用開源引擎,我們很快就發現了下面的問題。

第一個問題是,我們如何傳遞複雜的 RPC 引數?在實際的業務場景裡,由於 RPC 和 HTTP 介面已經解耦,我們往往需要透過一些邏輯構造才能構造出 RPC 需要的引數,比如在下面的 RPC 介面:

Class SearchDto {
  Integer pageSize;
  Integer cursor;
  Integer userId; // 需要獲取登陸使用者的 uesrId
  String search;
}
...
SongService.searchSongByUser(SearchDto params) { ... }

這個介面的入參是一個結構化物件,其中其他 3 個引數來源於 HTTP 介面的查詢引數透傳,而 userId 則需要我們從請求的 cookie 中解析出來。

GraphQL 提供了 變數 的機制,用來進行一些引數的透傳,但如果要完成上述的引數構造,它的靈活度是不夠的。

第二個問題是,我們如何對響應結果做更靈活的資料轉換?GraphQL 的響應結果和必須和 Schema 結構保持嚴格一致,雖然我們可以進行一定的欄位裁剪和重新命名,但針對多樣的頁面,我們需要更加靈活的資料轉換,以便可以複用同一套 Schema 去面對更多場景。

上面兩個問題的共性是, GraphQL 預設的 DSL 表達難以滿足複雜場景的訴求。幸運的是,GraphQL 提供了一種名為指令的擴充套件機制。指令可以附著在欄位或者片段包含的欄位上,然後以任何服務端期待的方式來改變查詢的執行,下面是 GraphQL 引擎內建的 @skip 指令的使用示例。

{
  song {
    name @skip(if: true)
  }
}

上述指令的含義是,在判斷條件為 true 時,跳過此欄位的查詢。

GraphQL 允許我們自定義指令,我們可以在 GraphQL 的解析器中拿到查詢語句附著的指令描述,從而修改執行邏輯來完成指令的實現。

我們針對上面提到的問題提供了兩種自定義指令。

@param指令:傳遞複雜的 RPC 引數

directive @param(
  from: String
  dest: String
  scriptName: String
  scriptMethod: String
)

@param 指令主要在執行查詢操作之前執行,負責收集引數來源,並將多個引數來源傳入指令碼進行處理,最終將處理結果傳遞到 RPC 引數中,它的執行流程如下圖所示:

@convert指令:對響應結果做更靈活的資料轉換

directive @convert(
  from: String
  scriptName: String
  scriptMethod: String
)

@convert 指令主要在執行查詢操作之後執行,負責收集響應結果,同樣其輸入到指令碼進行處理,最終返回透過指令碼處理好的結果,它的執行流程如下圖所示:

在擴充套件了這兩種指令後,開發者可以在查詢操作的前後插入自定義指令碼進行引數的構造和響應結果的處理。

我們目前是基於 Java 實現的 GraphQL 引擎,因此指令碼語言上採用了 Groovy 語法,儘管不是前端同學熟悉的語言,但處理一些常規的資料轉換邏輯已經綽綽有餘。而在完成這一部分後,我們真正做到了幾乎能覆蓋大部分 BFF 場景。

標準的研發流程管控

我們期望 GraphQL 應用的研發流程應該和普通應用的研發流程一樣,當開發者接到需求時,他需要在平臺建立迭代,我們會為它分配分支和環境,當他測試迴歸完成後,需要進行一些卡點,我們會在卡點環節提供一些語法校驗以及變更的 Review,經過卡點流程後,開發者就會進入獨佔的上線通道,完成線上釋出和驗證後,開發者可以一鍵將開發分支合併到 master。

目前在雲音樂,所有前端側的應用研發都遵循這樣一套流程,這套流程極大保證了我們研發過程中的安全性和規範性。針對 GraphQL,我們在應用卡點環節提供了語法校驗,基於 graphql-language-service-interface 提供的 getDiagnostics,可以幫助我們快速定位到錯誤的位置。

const errList = getDiagnostics(query, schema);

小結

最後,總結一下,在本文中,我們簡單介紹了 GraphQL 以及在雲音樂落地的背景,並且介紹了雲音樂 Febase 平臺 GraphQL 研發能力的整體架構設計,一些關鍵模組(資料圖構造,低程式碼 GraphQL 編輯器)的實現思路,以及針對 GraphQL 引擎的擴充套件設計,GraphQL 應用的研發流程管控。後面我們也會考慮分享更多 GraphQL 引擎的實現細節以及 GraphQL 的應用案例。

目前,基於 GraphQL 的 BFF 研發模式已經在雲音樂實現了半年左右,期間也由大前端同學自主產出了 160+ 的資料介面,其中不乏一些高流量的核心場景。當然,針對 BFF 研發模式,我們確實也還處在起步的探索階段。未來,隨著 GraphQL 介面在雲音樂業務中的覆蓋度越來越高,我們期望能夠從中總結出一些資料圖模型的設計經驗,幫助前後端同學建立更高效的協作關係。

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章