GraphQL及元數據驅動架構在後端BFF中的實踐

GraphQL是Facebook提出的一種數據查詢語言,核心特性是數據聚合和按需索取,目前被廣泛應用於前後端之間,解決客戶端靈活使用數據問題。本文介紹的是GraphQL的另一種實踐,我們將GraphQL下沉至後端BFF層之下,結合元數據技術,實現數據和加工邏輯的按需查詢和執行。這樣不僅解決了後端BFF層靈活使用數據的問題,這些字段加工邏輯還可以直接複用,大幅度提升了研發的效率。本文介紹的實踐方案已經在美團部分業務場景中落地,並取得不錯效果,希望這些經驗能夠對大家有幫助。

1 BFF的由來

BFF一詞來自Sam Newman的一篇博文《Pattern:Backends For Frontends》,指的是服務於前端的後端。BFF是解決什麼問題的呢?據原文描述,隨着移動互聯網的興起,原適應於桌面Web的服務端功能希望同時提供給移動App使用,而在這個過程中存在這樣的問題:

  • 移動App和桌面Web在UI部分存在差異。
  • 移動App涉及不同的端,不僅有iOS、還有Android,這些不同端的UI之間存在差異。
  • 原有後端功能和桌面Web UI之間已經存在了較大的耦合。

因爲端的差異性存在,服務端的功能要針對端的差異進行適配和裁剪,而服務端的業務功能本身是相對單一的,這就產生了一個矛盾——服務端的單一業務功能和端的差異性訴求之間的矛盾。那麼這個問題怎麼解決呢?這也是文章的副標題所描述的"Single-purpose Edge Services for UIs and external parties",引入BFF,由BFF來針對多端差異做適配,這也是目前業界廣泛使用的一種模式。

圖1 BFF示意圖

在實際業務的實踐中,導致這種端差異性的原因有很多,有技術的原因,也有業務的原因。比如,用戶的客戶端是Android還是iOS,是大屏還是小屏,是什麼版本。再比如,業務屬於哪個行業,產品形態是什麼,功能投放在什麼場景,面向的用戶羣體是誰等等。這些因素都會帶來面向端的功能邏輯的差異性。

在這個問題上,筆者所在團隊負責的商品展示業務有一定的發言權,同樣的商品業務,在C端的展示功能邏輯,深刻受到商品類型、所在行業、交易形態、投放場所、面向羣體等因素的影響。同時,面向消費者端的功能頻繁迭代的屬性,更是加劇並深化了這種矛盾,使其演化成了一種服務端單一穩定與端的差異靈活之間的矛盾,這也是商品展示(商品展示BFF)業務系統存在的必然性原因。本文主要在美團到店商品展示場景的背景下,介紹面臨的一些問題及解決思路。

2 BFF背景下的核心矛盾

BFF這層的引入是解決服務端單一穩定與端的差異靈活訴求之間的矛盾,這個矛盾並不是不存在,而是轉移了。由原來後端和前端之間的矛盾轉移成了BFF和前端之間的矛盾。筆者所在團隊的主要工作,就是和這種矛盾作鬥爭。下面以具體的業務場景爲例,結合當前的業務特點,說明在BFF的生產模式下,我們所面臨的具體問題。下圖是兩個不同行業的團購貨架展示模塊,這兩個模塊我們認爲是兩個商品的展示場景,它們是兩套獨立定義的產品邏輯,並且會各自迭代。

圖2 展示場景

在業務發展初期,這樣的場景不多。BFF層系統“煙囪式”建設,功能快速開發上線滿足業務的訴求,在這樣的情況下,這種矛盾表現的不明顯。而隨着業務發展,行業的開拓,形成了許許多多這樣的商品展示功能,矛盾逐漸加劇,主要表現在以下兩個方面:

  • 業務支撐效率:隨着商品展示場景變得越來越多,API呈爆炸趨勢,業務支撐效率和人力成線性關係,系統能力難以支撐業務場景的規模化拓展。
  • 系統複雜度高:核心功能持續迭代,內部邏輯充斥着if…else…,代碼過程式編寫,系統複雜度較高,難以修改和維護。

那麼這些問題是怎麼產生的呢?這要結合“煙囪式”系統建設的背景和商品展示場景所面臨的業務,以及系統特點來進行理解。

特點一:外部依賴多、場景間取數存在差異、用戶體驗要求高

圖例展示了兩個不同行業的團購貨架模塊,這樣一個看似不大的模塊,後端在BFF層要調用20個以上的下游服務才能把數據拿全,這是其一。在上面兩個不同的場景中,需要的數據源集合存在差異,而且這種差異普遍存在,這是其二,比如足療團購貨架需要的某個數據源,在麗人團購貨架上不需要,麗人團購貨架需要的某個數據源,足療團購貨架不需要。儘管依賴下游服務多,同時還要保證C端的用戶體驗,這是其三。

這幾個特點給技術帶來了不小的難題:1)聚合大小難控制,聚合功能是分場景建設?還是統一建設?如果分場景建設,必然存在不同場景重複編寫類似聚合邏輯的問題。如果統一建設,那麼一個大而全的數據聚合中必然會存在無效的調用。2)聚合邏輯的複雜性控制問題,在這麼多的數據源的情況下,不僅要考慮業務邏輯怎麼寫,還要考慮異步調用的編排,在代碼複雜度未能良好控制的情況下,後續聚合的變更修改將會是一個難題。

特點二:展示邏輯多、場景之間存在差異,共性個性邏輯耦合

我們可以明顯地識別某一類場景的邏輯是存在共性的,比如團單相關的展示場景。直觀可以看出基本上都是展示團單維度的信息,但這只是表象。實際上在模塊的生成過程中存在諸多的差異,比如以下兩種差異:

  • 字段拼接邏輯差異:比如以上圖中兩個團購貨架的團購標題爲例,同樣是標題,在麗人團購貨架中的展示規則是:[類型] + 團購標題,而在足療團購貨架的展示規則是:團購標題
  • 排序過濾邏輯差異:比如同樣是團單列表,A場景按照銷量倒排序,B場景按照價格排序,不同場景的排序邏輯不同。

諸如此類的展示邏輯的差異性還有很多。類似的場景實際上在內部存在很多差異的邏輯,後端如何應對這種差異性是一個難題,下面是最常見的一種寫法,通過讀取具體的條件字段來做判斷實現邏輯路由,如下所示:

if(category == "麗人") {
  title = "[" + category + "]" + productTitle;
} else if (category == "足療") {
  title = productTitle;
}

這種方案在功能實現方面沒有問題,也能夠複用共同的邏輯。但是實際上在場景非常多的情況下,將會有非常多的差異性判斷邏輯疊加在一起,功能一直會被持續迭代的情況下,可以想象,系統將會變得越來越複雜,越來越難以修改和維護。

總結:在BFF這層,不同商品展示場景存在差異。在業務發展初期,系統通過獨立建設的方式支持業務快速試錯,在這種情況下,業務差異性帶來的問題不明顯。而隨着業務的不斷髮展,需要搭建及運營的場景越來越多,呈規模化趨勢。此時,業務對技術效率提出了更高的要求。在這種場景多、場景間存在差異的背景下,如何滿足場景拓展效率同時能夠控制系統的複雜性,就是我們業務場景中面臨的核心問題

3 BFF應用模式分析

目前業界針對此類的解決方案主要有兩種模式,一種是後端BFF模式,另一種是前端BFF模式。

3.1 後端BFF模式

後端BFF模式指的是BFF由後端同學負責,這種模式目前最廣泛的實踐是基於GraphQL搭建的後端BFF方案,具體是:後端將展示字段封裝成展示服務,通過GraphQL編排之後暴露給前端使用。如下圖所示:

圖3 後端BFF模式

這種模式最大的特性和優勢是,當展示字段已經存在的情況下,後端不需要關心前端差異性需求,按需查詢的能力由GraphQL支持。這個特性可以很好地應對不同場景存在展示字段差異性這個問題,前端直接基於GraphQL按需查詢數據即可,後端不需要變更。同時,藉助GraphQL的編排和聚合查詢能力,後端可以將邏輯分解在不同的展示服務中,因此在一定程度上能夠化解BFF這層的複雜性。

但是基於這種模式,仍然存在幾個問題:展示服務顆粒度問題、數據圖劃分問題以及字段擴散問題,下圖是基於當前模式的具體案例:

圖4 後端BFF模式(案例)

1)展示服務顆粒度設計問題

這種方案要求展示邏輯和取數邏輯封裝在一個模塊中,形成一個展示服務(Presentation Service),如上圖所示。而實際上展示邏輯和取數邏輯是多對多的關係,還是以前文提到的例子說明:

背景:有兩個展示服務,分別封裝了商品標題和商品標籤的查詢能力。 情景:此時PM提了一個需求,希望商品在某個場景的標題以“[類型]+商品標題”的形式展示,此時商品標題的拼接依賴類型數據,而此時類型數據商品標籤展示服務中已經調用了。 問題:商品標題展示服務自己調用類型數據還是將兩個展示服務合併到一起?

以上描述的問題的是展示服務顆粒度把控的問題,我們可以懷疑上述的示例是不是因爲展示服務的顆粒度過小?那麼反過來看一看,如果將兩個服務合併到一起,那麼勢必又會存在冗餘。這是展示服務設計的難點,核心原因在於,展示邏輯和取數邏輯本身是多對多的關係,結果卻被設計放在了一起

2)數據圖劃分問題

通過GraphQL將多個展示服務的數據聚合到一張圖(GraphQL Schema)中,形成一個數據視圖,需要數據的時候只要數據在圖中,就可以基於Query按需查詢。那麼問題來了,這個圖應該怎麼組織?是一張圖還是多張圖?圖過大的話,勢必帶來複雜的數據關係維護問題,圖過小則將會降低方案本身的價值。

3)展示服務內部複雜性 + 模型擴散問題

上文提到過一個商品標題的展示存在不同拼接邏輯的情況,在商品展示場景,這種邏輯特別普遍。比如同樣是價格,A行業展示優惠後價格,B行業展示優惠前價格;同樣是標籤位置,C行業展示服務時長,而D行業展示商品特性等。那麼問題來了,展示模型如何設計?以標題字段爲例,是在展示模型上放個title字段就可以,還是分別放個titletitleWithCategory?如果是前者那麼服務內部必然會存在if…else…這種邏輯,用於區分title的拼接方式,這同樣會導致展示服務內部的複雜性。如果是多個字段,那麼可以想象,展示服務的模型字段也將會不斷擴散。

總結:後端BFF模式能夠在一定程度上化解後端邏輯的複雜性,同時提供一個展示字段的複用機制。但是仍然存在未決問題,如展示服務的顆粒度設計問題,數據圖的劃分問題,以及展示服務內部的複雜性和字段擴散問題。目前這種模式實踐的代表有Facebook、愛彼迎、eBay、愛奇藝、攜程、去哪兒等等。

3.2 前端BFF模式

前端BFF模式在Sam Newman的文章中的"And Autonomy"部分有特別的介紹,指的是BFF本身由前端團隊自己負責,如下示意圖所示:

圖5 前端BFF模式

這種模式的理念是,本來能一個團隊交付的需求,沒必要拆成兩個團隊,兩個團隊本身帶來較大的溝通協作成本。本質上,也是一種將“敵我矛盾”轉化爲“人民內部矛盾”的思路。前端完全接手BFF的開發工作,實現數據查詢的自給自足,大大減少了前後端的協作成本。但是這種模式沒有提到我們關心的一些核心問題,如:複雜性如何應對、差異性如何應對、展示模型如何設計等等問題。除此之外,這種模式也存在一些前提條件及弊端,比如較爲完備的前端基礎設施;前端不僅僅需要關心渲染、還需要了解業務邏輯等。

總結:前端BFF模式通過前端自主查詢和使用數據,從而達到降低跨團隊協作的成本,提升BFF研發效率的效果。目前這種模式的實踐代表是阿里巴巴。

4 基於GraphQL及元數據的信息聚合架構設計

4.1 整體思路

通過對後端BFF和前端BFF兩種模式的分析,我們最終選擇後端BFF模式,前端BFF這個方案對目前的研發模式影響較大,不僅需要大量的前端資源,而且需要建設完善的前端基礎設施,方案實施成本比較高昂。

前文提到的後端GraphQL BFF模式代入我們的具體場景雖然存在一些問題,但是總體有非常大的參考價值,比如展示字段的複用思路、數據的按需查詢思路等等。在商品展示場景中,有80%的工作集中在數據的聚合和集成部分,並且這部分具有很強的複用價值,因此信息的查詢和聚合是我們面臨的主要矛盾。因此,我們的思路是:基於GraphQL+後端BFF方案改進,實現取數邏輯和展示邏輯的可沉澱、可組合、可複用,整體架構如下示意圖所示:

圖6 基於GraphQL BFF的改進思路

從上圖可看出,與傳統GraphQL BFF方案最大的差別在於我們將GraphQL下放至數據聚合部分,由於數據來源於商品領域,領域是相對穩定的,因此數據圖規模可控且相對穩定。除此之外,整體架構的核心設計還包括以下三個方面:1)取數展示分離;2)查詢模型歸一;3)元數據驅動架構。

我們通過取數展示分離解決展示服務顆粒度問題,同時使得展示邏輯和取數邏輯可沉澱、可複用;通過查詢模型歸一化設計解決展示字段擴散的問題;通過元數據驅動架構實現能力的可視化,業務組件編排執行的自動化,這能夠讓業務開發同學聚焦於業務邏輯的本身。下面將針對這三個部分逐一展開介紹。

4.2 核心設計

4.2.1 取數展示分離

上文提到,在商品展示場景中,展示邏輯和取數邏輯是多對多的關係,而傳統的基於GraphQL的後端BFF實踐方案把它們封裝在一起,這是導致展示服務顆粒度難以設計的根本原因。思考一下取數邏輯和展示邏輯的關注點是什麼?取數邏輯關注怎麼查詢和聚合數據,而展示邏輯關注怎麼加工生成需要的展示字段,它們的關注點不一樣,放在一起也會增加展示服務的複雜性。因此,我們的思路是將取數邏輯和展示邏輯分離開來,單獨封裝成邏輯單元,分別叫取數單元和展示單元。在取數展示分離之後,GraphQL也隨之下沉,用於實現數據的按需聚合,如下圖所示:

圖7 取數展示分離+元數據描述

那麼取數和展示邏輯的封裝顆粒度是怎麼樣的呢?不能太小也不能太大,在顆粒度的設計上,我們有兩個核心考量:1)複用,展示邏輯和取數邏輯在商品展示場景中,都是可以被複用的資產,我們希望它們能沉澱下來,被單獨按需使用;2)簡單,保持簡單,這樣容易修改和維護。基於這兩點考慮,顆粒度的定義如下:

  • 取數單元:儘量只封裝1個外部數據源,同時負責對外部數據源返回的模型進行簡化,這部分生成的模型我們稱之爲取數模型。
  • 展示單元:儘量只封裝1個展示字段的加工邏輯。

分開的好處是簡單且可被組合使用,那麼具體如何實現組合使用呢?我們的思路是通過元數據來描述它們之間的關係,基於元數據由統一的執行框架來關聯運行,具體設計下文會展開介紹。通過取數和展示的分離,元數據的關聯和運行時的組合調用,可以保持邏輯單元的簡單,同時又滿足複用訴求,這也很好地解決了傳統方案中存在的展示服務的顆粒度問題

4.2.2 查詢模型歸一

展示單元的加工結果通過什麼樣的接口透出呢?接下來,我們介紹一下查詢接口設計的問題。

1)查詢接口設計的難點

常見查詢接口的設計模式有以下兩種:

  • 強類型模式:強類型模式指的是查詢接口返回的是POJO對象,每一個查詢結果對應POJO中的一個明確的具有特定業務含義的字段。
  • 弱類型模式:弱類型模式指的是查詢結果以K-V或JSON模式返回,沒有明確的靜態字段。

以上兩種模式在業界都有廣泛應用,且它們都有明確的優缺點。強類型模式對開發者友好,但是業務是不斷迭代的,與此同時,系統沉澱的展示單元會不斷豐富,在這樣的情況下,接口返回的DTO中的字段將會愈來愈多,每次新功能的支持,都要伴隨着接口查詢模型的修改,JAR版本的升級。而JAR的升級涉及數據提供方和數據消費兩方,存在明顯效率問題。另外,可以想象,查詢模型的不斷迭代,最終將會包括成百上千個字段,難以維護。

而弱類型模式恰好可以彌補這一缺點,但是弱類型模式對於開發者來說非常不友好,接口查詢模型中有哪些查詢結果對於開發者來說在開發的過程中完全沒有感覺,但是程序員的天性就是喜歡通過代碼去理解邏輯,而非配置和文檔。其實,這兩種接口設計模式都存在着一個共性問題——缺少抽象,下面兩節,我們將介紹在接口返回的查詢模型設計方面的抽象思路及框架能力支持。

2)查詢模型歸一化設計

回到商品展示場景中,一個展示字段有多種不同的實現,如商品標題的兩種不同實現方式:1)商品標題;2)[類目]+商品標題。商品標題和這兩種展示邏輯的關係本質上是一種抽象-具體的關係。識別這個關鍵點,思路就明瞭了,我們的思路是對查詢模型做抽象。查詢模型上都是抽象的展示字段,一個展示字段對應多個展示單元,如下圖所示:

圖8 查詢模型歸一化 + 元數據描述

在實現層面同樣基於元數據描述展示字段和展示單元之間的關係,基於以上的設計思路,可以在一定程度上減緩模型的擴散,但是還不能避免擴展。比如除了價格、庫存、銷量等每個商品都有的標準屬性之外,不同的商品類型一般還會有這個商品特有的屬性。比如密室主題拼場商品纔有“幾人拼”這樣的描述屬性,這種字段本身抽象的意義不大,且放在商品查詢模型中作爲一個單獨的字段會導致模型擴張,針對這類問題,我們的解決思路是引入擴展屬性,擴展屬性專門承載這類非標準的字段。通過標準字段 + 擴展屬性的方式建立查詢模型,能夠較好地解決字段擴散的問題。

4.2.3 元數據驅動架構

到目前爲止,我們定義瞭如何分解業務邏輯單元以及如何設計查詢模型,並提到用元數據描述它們之間的關係。基於以上定義實現的業務邏輯及模型,都具備很強的複用價值,可以作爲業務資產沉澱下來。那麼,爲什麼用元數據描述業務功能及模型之間的關係呢?

我們引入元數據描述主要有兩個目的:1)代碼邏輯的自動編排,通過元數據描述業務邏輯之間的關聯關係,運行時可以自動基於元數據實現邏輯之間的關聯執行,從而可以消除大量的人工邏輯編排代碼;2)業務功能的可視化,元數據本身描述了業務邏輯所提供的功能,如下面兩個示例:

團單基礎售價字符串展示,例:30元。 團單市場價展示字段,例:100元。

這些元數據上報到系統中,可以用於展示當前系統所提供的功能。通過元數據描述組件及組件之間關聯關係,通過框架解析元數據自動進行業務組件的調用執行,形成了如下的元數據架構:

圖9 元數據驅動架構

整體架構由三個核心部分組成:

  • 業務能力:標準的業務邏輯單元,包括取數單元、展示單元和查詢模型,這些都是關鍵的可複用資產。
  • 元數據:描述業務功能(如:展示單元、取數單元)以及業務功能之間的關聯關係,比如展示單元依賴的數據,展示單元映射的展示字段等。
  • 執行引擎:負責消費元數據,並基於元數據對業務邏輯進行調度和執行。

通過以上三個部分有機的組合在一起,形成了一個元數據驅動風格的架構。

5 針對GraphQL的優化實踐

5.1 使用簡化

1)GraphQL直接使用問題

引入GraphQL,會引入一些額外的複雜性,比如會涉及到GraphQL帶來的一些概念如:Schema、RuntimeWiring,下面是基於GraphQL原生Java框架的開發過程:

圖10 原生GraphQL使用流程

這些概念對於未接觸過GraphQL的同學來說,增加了學習和理解的成本,而這些概念和業務領域通常沒有什麼關係。而我們僅僅希望使用GraphQL的按需查詢特性,卻被GraphQL本身拖累了,業務開發同學的關注點應該聚焦在業務邏輯本身才對,這個問題如何解決呢?

著名計算機科學家David Wheeler說了一句名言,"All problems in computer science can be solved by another level of indirection"。沒有加一層解決不了的問題,本質上是需要有人來對這事負責,因此我們在原生GraphQL之上增加了一層執行引擎層來解決這些問題,目標是屏蔽GraphQL的複雜性,讓開發人員只需要關注業務邏輯。

2)取數接口標準化

首先要簡化數據的接入,原生的DataFetcherDataLoader都是處在一個比較高的抽象層次,缺少業務語義,而在查詢場景,我們能夠歸納出,所有的查詢都屬於以下三種模式:

  • 1查1:根據一個條件查詢一個結果。
  • 1查N:根據一個條件查詢多個結果。
  • N查N:一查一或一查多的批量版本。

由此,我們對查詢接口進行了標準化,業務開發同學基於場景判斷是那種,按需選擇使用即可,取數接口標準化設計如下:

圖11 查詢接口標準化

業務開發同學按需選擇所需要使用的取數器,通過泛型指定結果類型,1查1和1查N比較簡單,N查N我們對其定義爲批量查詢接口,用於滿足"N+1"的場景,其中batchSize字段用於指定分片大小,batchKey用於指定查詢Key,業務開發只需要指定參數,其他的框架會自動處理。除此之外,我們還約束了返回結果必須是CompleteFuture,用於滿足聚合查詢的全鏈路異步化。

3)聚合編排自動化

取數接口標準化使得數據源的語義更清晰,開發過程按需選擇即可,簡化了業務的開發。但是此時業務開發同學寫好Fetcher之後,還需要去另一個地方去寫Schema,而且寫完Schema還要再寫SchemaFetcher的映射關係,業務開發更享受寫代碼的過程,不太願意寫完代碼還要去另外一個地方取配置,並且同時維護代碼和對應配置也提高了出錯的可能性,能否將這些冗雜的步驟移除掉?

SchemaRuntimeWiring本質上是想描述某些信息,如果這些信息換一種方式描述是不是也可以,我們的優化思路是:在業務開發過程中標記註解,通過註解標註的元數據描述這些信息,其他的事情交給框架來做。解決思路示意圖如下:

圖12 註解元數據描述Schema和RuntimeWiring

5.2 性能優化

5.2.1 GraphQL性能問題

雖然GraphQL已經開源了,但是Facebook只開源了相關標準,並沒有給出解決方案。GraphQL-Java框架是由社區貢獻的,基於開源的GraphQL-Java作爲按需查詢引擎的方案,我們發現了GraphQL應用方面的一些問題,這些問題有部分是由於使用姿勢不當所導致的,也有部分是GraphQL本身實現的問題,比如我們遇到的幾個典型的問題:

  • 耗CPU的查詢解析,包括Schema的解析和Query的解析。
  • 當查詢模型比較複雜特別是存在大列表時候的延時問題。
  • 基於反射的模型轉換CPU消耗問題。
  • DataLoader的層級調度問題。

於是,我們對使用方式和框架做了一些優化與改造,以解決上面列舉的問題。本章着重介紹我們在GraphQL-Java方面的優化和改造思路。

5.2.2 GraphQL編譯優化

1)GraphQL語言原理概述

GraphQL是一種查詢語言,目的是基於直觀和靈活的語法構建客戶端應用程序,用於描述其數據需求和交互。GraphQL屬於一種領域特定語言(DSL),而我們所使用的GraphQL-Java客戶端在語言編譯層面是基於ANTLR 4實現的,ANTLR 4是一種基於Java編寫的語言定義和識別工具,ANTLR是一種元語言(Meta-Language),它們的關係如下:

圖13 GraphQL語言基本原理示意圖

GraphQL執行引擎所接受的SchemaQuery都是基於GraphQL定義的語言所表達的內容,GraphQL執行引擎不能直接理解GraphQL,在執行之前必須由GraphQL編譯器翻譯成GraphQL執行引擎可理解的文檔對象。而GraphQL編譯器是基於Java的,經驗表明在大流量場景實時解釋的情況下,這部分代碼將會成爲CPU熱點,而且還佔用響應延遲,SchemaQuery越複雜,性能損耗越明顯。

2)Schema及Query編譯緩存

Schema表達的是數據視圖和取數模型同構,相對穩定,個數也不多,在我們的業務場景一個服務也就一個。因此,我們的做法是在啓動的時候就將基於Schema構造的GraphQL執行引擎構造好,作爲單例緩存下來,對於Query來說,每個場景的Query有些差異,因此Query的解析結果不能作爲單例,我們的做法是實現PreparsedDocumentProvider接口,基於Query作爲Key將Query編譯結果緩存下來。如下圖所示:

圖14 Query緩存實現示意圖

5.2.3 GraphQL執行引擎優化

1)GraphQL執行機制及問題

我們先一起了解一下GraphQL-Java執行引擎的運行機制是怎麼樣的。假設在執行策略上我們選取的是AsyncExecutionStrategy,來看看GraphQL執行引擎的執行過程:

圖15 GraphQL執行引擎執行過程

以上時序圖做了些簡化,去除了一些與重點無關的信息,AsyncExecutionStrategyexecute方法是對象執行策略的異步化模式實現,是查詢執行的起點,也是根節點查詢的入口,AsyncExecutionStrategy對對象的多個字段的查詢邏輯,採取的是循環+異步化的實現方式,我們從AsyncExecutionStrategyexecute方法觸發,理解GraphQL查詢過程如下:

  1. 調用當前字段所綁定的DataFetcherget方法,如果字段沒有綁定DataFetcher,則通過默認的PropertyDataFetcher查詢字段,PropertyDataFetcher的實現是基於反射從源對象中讀取查詢字段。
  2. 將從DataFetcher查詢得到結果包裝成CompletableFuture,如果結果本身是CompletableFuture,那麼不會包裝。
  3. 結果CompletableFuture完成之後,調用completeValue,基於結果類型分別處理。
    • 如果查詢結果是列表類型,那麼會對列表類型進行遍歷,針對每個元素在遞歸執行completeValue
    • 如果結果類型是對象類型,那麼會對對象執行execute,又回到了起點,也就是AsyncExecutionStrategy的execute

以上是GraphQL的執行過程,這個過程有什麼問題呢?下面基於圖上的標記順序一起看看GraphQL在我們的業務場景中應用和實踐所遇到的問題,這些問題不代表在其他場景也是問題,僅供參考:

問題1PropertyDataFetcherCPU熱點問題,PropertyDataFetcher在整個查詢過程中屬於熱點代碼,而其本身的實現也有一些優化空間,在運行時PropertyDataFetcher的執行會成爲CPU熱點。(具體問題可參考GitHub上的commit和Conversion:https://github.com/graphql-java/graphql-java/pull/1815

圖16 PropertyDataFetcher成爲CPU熱點

問題2:列表的計算耗時問題,列表計算是循環的,對於查詢結果中存在大列表的場景,此時循環會造成整體查詢明顯的延遲。我們舉個具體的例子,假設查詢結果中存在一個列表大小是1000,每個元素的處理是0.01ms,那麼總體耗時就是10ms,基於GraphQL的查機制,這個10ms會阻塞整個鏈路。

2)類型轉換優化

通過GraphQL查詢引擎拿到的GraphQL模型,和業務實現的DataFetcher返回的取數模型是同構,但是所有字段的類型都會被轉換成GraphQL內部類型。PropertyDataFetcher之所以會成爲CPU熱點,問題就在於這個模型轉換過程,業務定義的模型到GraphQL類型模型轉換過程示意圖如下圖所示:

圖17 業務模型到GraphQL模型轉換示意圖

當查詢結果模型中的字段非常多的時候,比如上萬個,意味着每次查詢有上萬次的PropertyDataFetcher操作,實際就反映到了CPU熱點問題上,這個問題我們的解決思路是保持原有業務模型不變,將非PropertyDataFetcher查詢的結果反過來填充到業務模型上。如下示意圖所示:

圖18 查詢結果模型反向填充示意圖

基於這個思路,我們通過GraphQL執行引擎拿到的結果就是業務Fetcher返回的對象模型,這樣不僅僅解決了因字段反射轉換帶來的CPU熱點問題,同時對於業務開發來說增加了友好性。因爲GraphQL模型類似JSON模型,這種模型是缺少業務類型的,業務開發直接使用起來非常麻煩。以上優化在一個場景上試點測試,結果顯示該場景的平均響應時間縮短1.457ms,平均99線縮短5.82ms,平均CPU利用率降低約12%。

3)列表計算優化

當列表元素比較多的時候,默認的單線程遍歷列表元素計算的方式所帶來的延遲消耗非常明顯,對於響應時間比較敏感的場景這個延遲優化很有必要。針對這個問題我們的解決思路是充分利用CPU多核心計算的能力,將列表拆分成任務,通過多線程並行執行,實現機制如下:

圖19 列表遍歷多核計算思路

5.2.4 GraphQL-DataLoader調度優化

1)DataLoader基本原理

先簡單介紹一下DataLoader的基本原理,DataLoader有兩個方法,一個是load,一個是dispatch,在解決N+1問題的場景中,DataLoader是這麼用的:

圖20 DataLoader基本原理

整體分爲2個階段,第一個階段調用load,調用N次,第二個階段調用dispatch,調用dispatch的時候會真正的執行數據查詢,從而達到批量查詢+分片的效果。

2)DataLoader調度問題

GraphQL-Java對DataLoader的集成支持的實現在FieldLevelTrackingApproach中,FieldLevelTrackingApproach的實現會存在怎樣的問題呢?下面基於一張圖表達原生DataLoader調度機制所產生的問題:

圖21 GraphQL-Java對DataLoader調度存在的問題

問題很明顯,基於FieldLevelTrackingApproach的實現,下一層級的DataLoaderdispatch是需要等到本層級的結果都回來之後才發出。基於這樣的實現,查詢總耗時的計算公式等於:TOTAL = MAX(Level 1 Latency)+ MAX(Level 2 Latency)+ MAX(Level 3 Latency)+ … ,總查詢耗時等於每層耗時最大的值加起來,而實際上如果鏈路編排由業務開發同學自己來寫的話,理論上的效果是總耗時等於所有鏈路最長的那個鏈路所耗的時間,這個纔是合理的。而FieldLevelTrackingApproach的實現所表現出來的結果是反常識的,至於爲什麼這麼實現,目前我們理解可能是設計者基於簡單和通用方面的考慮。

問題在於以上的實現在有些業務場景下是不能接受的,比如我們的列表場景的響應時間約束一共也就不到100ms,其中幾十ms是因爲這個原因搭進去的。針對這個問題的解決思路,一種方式是對於響應時間要求特別高的場景獨立編排,不採用GraphQL;另一種方式是在GraphQL層面解決這個問題,保持架構的統一性。接下來,介紹一下我們是如何擴展GraphQL-Java執行引擎來解決這個問題的。

3)DataLoader調度優化

針對DataLoader調度的性能問題,我們的解決思路是在最後一次調用某個DataLoaderload之後,立即調用dispatch方法發出查詢請求,問題是我們怎麼知道哪一次的load是最後一次load呢?這個問題也是解決DataLoader調度問題的難點,以下舉個例子來解釋我們的解決思路:

圖22 查詢對象結果示意圖

假設我們查詢到的模型結構如下:根節點是Query下的字段,字段名叫subjectssubject引用的是個列表,subject下有兩個元素,都是ModelA的對象實例,ModelA有兩個字段,fieldAfieldBsubjects[0]fieldA關聯是ModelB的一個實例,subjects[0]fieldB關聯多個ModelC實例。

爲了方便理解,我們定義一些概念,字段、字段實例、字段實例執行完、字段實例值大小、字段實例值對象執行大小、字段實例值對象執行完等等:

  • 字段:具有唯一路徑,是靜態的,和運行時對象大小沒有關係,如:subjectssubjects/fieldA
  • 字段實例:字段的實例,具有唯一路徑,是動態的,跟運行時對象大小有關係,如:subjects[0]/fieldAsubjects[1]/fieldA是字段subjects/fieldA的實例。
  • 字段實例執行完:字段實例關聯的對象實例都被GraphQL執行完了。
  • 字段實例值大小:字段實例引用對象實例的個數,如以上示例,subjects[0]/fieldA字段實例值大小是1,subjects[0]/fieldB字段實例值大小是3。

除了以上定義之外,我們的業務場景還滿足以下條件:

  • 只有1個根節點,且根節點是列表。
  • DataLoader一定屬於某個字段,某個字段下的DataLoader應該被執行次數等於其下的對象實例個數。

基於以上信息,我們可以得出以下問題分析:

  • 在執行字段實例的時候,我們可以知道當前字段實例的大小,字段實例的大小等於字段關聯DataLoader在當前實例下需要執行load的次數,因此在執行load之後,我們可以知道當前對象實例是否是其所在字段實例的最後一個對象。
  • 一個對象的實例可能會掛在不同的字段實例下,所以僅噹噹前對象實例是其所在字段實例的最後一個對象實例的時候,不代表當前對象實例是所有對象實例中的最後一個,當且僅當對象實例所在節點實例是節點的最後一個實例的時候才成立。
  • 我們可從字段實例大小推算字段實例的個數,比如我們知道subjects的大小是2,那麼就知道subjects字段有兩個字段實例subjects[0]subjects[1],也就知道字段subjects/fieldA有兩個實例,subjects[0]/fieldAsubjects[1]/fieldA,因此我們從根節點可以往下推斷出某個字段實例是否執行完。

通過以上分析,我們可以得出,一個對象執行完的條件是其所在的字段實例以及其所在的字段所有的父親字段實例都執行完,且當前執行的對象實例是其所在字段實例的最後一個對象實例的時候。基於這個判斷邏輯,我們的實現方案是在每次調用完DataFetcher的時候,判斷是否需要發起dispatch,如果是則發起。另外,以上時機和條件存在漏發dispatch的問題,有個特殊情況,噹噹前對象實例不是最後一個,但是剩下的對象大小都爲0的時候,那麼就永遠不會觸發當前對象關聯的DataLoaderload了,所以在對象大小爲0的時候,需要額外再判斷一次。

根據以上邏輯分析,我們實現了DataLoader調用鏈路的最優化,達到理論最優的效果。

6 新架構對研發模式的影響

生產力決定生產關係,元數據驅動信息聚合架構是展示場景搭建的核心生產力,而業務開發模式和過程是生產關係,因此也會隨之改變。下面我們將會從開發模式和流程兩個角度來介紹新架構對研發帶來的影響。

6.1 聚焦業務的開發模式

新架構提供了一套基於業務抽象出的標準化代碼分解約束。以前開發同學對系統的理解很可能就是“查一查服務,把數據粘在一起”,而現在,研發同學對於業務的理解及代碼分解思路將會是一致的。比如展示單元代表的是展示邏輯,取數單元代表的是取數邏輯。同時,很多冗雜且容易出錯的邏輯已經被框架屏蔽掉了,研發同學能夠有更多的精力聚焦於業務邏輯本身,比如:業務數據的理解和封裝,展示邏輯的理解和編寫,以及查詢模型的抽象和建設。如下示意圖所示:

圖23 業務開發聚焦業務本身

6.2 研發流程升級

新架構不僅僅影響了研發的代碼編寫,同時也影響着研發流程的改進,基於元數據架構實現的可視化及配置化能力,現有研發流程和之前研發流程相比有了明顯的區別,如下圖所示:

圖24 基於開發框架搭建展示場景前後研發流程對比

以前是“一杆子捅到底”的開發模式,每個展示場景的搭建需要經歷過從接口的溝通到API的開發整個過程,基於新架構之後,系統自動具備多層複用及可視化、配置化能力。

情況一:這是最好的情況,此時取數功能和展示功能都已經被沉澱下來,研發同學需要做的只是創建查詢方案,基於運營平臺按需選擇需要的展示單元,拿着查詢方案ID基於查詢接口就可以查到需要的展示信息了,可視化、配置化界面如下示意圖所示:

圖25 可視化及文案按需選用

情況二:此時可能沒有展示功能,但是通過運營平臺查看到,數據源已經接入過,那麼也不難,只需要基於現有的數據源編寫一段加工邏輯即可,這段加工邏輯是非常爽的一段純邏輯的編寫,數據源列表如下示意圖所示:

圖26 數據源列表可視化

情況三:最壞的情況是此時系統不能滿足當前的查詢能力,這種情況比較少見,因爲後端服務是比較穩定的,那麼也無需驚慌,只需要按照標準規範將數據源接入進來,然後編寫加工邏輯片段即可,之後這些能力是可以被持續複用的。

7 總結

商品展示場景的複雜性體現在:場景多、依賴多、邏輯多,以及不同場景之間存在差異。在這樣的背景下,如果是業務初期,怎麼快怎麼來,採用“煙囪式”個性化建設的方式不必有過多的質疑。但是隨着業務的不斷髮展,功能的不斷迭代,以及場景的規模化趨勢,“煙囪式”個性化建設的弊端會慢慢凸顯出來,包括代碼複雜度高、缺少能力沉澱等問題。

本文以基於對美團到店商品展示場景所面臨的核心矛盾分析,介紹了:

  • 業界不同的BFF應用模式,以及不同模式的優勢和缺點。
  • 基於GraphQL BFF模式改進的元數據驅動的架構方案設計。
  • 我們在GraphQL實踐過程中遇到的問題及解決思路。
  • 新架構對研發模式產生的影響呈現。

目前,筆者所在團隊負責的核心商品展示場景都已遷入新架構,基於新的研發模式,我們實現了50%以上的展示邏輯複用以及1倍以上的效率提升,希望本文對大家能夠有所幫助。

8 參考文獻

9 招聘信息

美團到店綜合研發中心長期招聘前端、後端、數據倉庫、機器學習/數據挖掘算法工程師,座標上海,歡迎感興趣的同學發送簡歷至:[email protected](郵件標題註明:美團到店綜合研發中心—上海)。

閱讀美團技術團隊更多技術文章合集

前端 | 算法 | 後端 | 數據 | 安全 | 運維 | iOS | Android | 測試

| 在公衆號菜單欄對話框回覆【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行爲,請發送郵件至[email protected]申請授權。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章