如何為GraphQL系統構建與Schema解耦的高效能運算層

語言: CN / TW / HK

作者介紹

杜艮魁,GraphQL Calculator作者,GraphQL Java 和GraphQL Spec Contributor。先後在美團、快手從事GraphQL的平臺化開發。

問題背景

GraphQL對於資料的聚合治理和按需查詢具有天然的優勢,資料平臺可將各個部門的資料對映到一張資料圖上、即GraphQL的Schema,客戶端可通過一次請求查詢資料圖中的多個資源。與傳統sql不同,graphql經常是面向業務的,旨在提供可直接在頁面展示的資料。

真實業務場景除了獲取基礎資料外,還會有業務定製的加工轉換、請求控制和依賴資料編排 。當前業界對資料的加工計算方案大致分為兩種:

  1. 計算邏輯由客戶端完成,或者在graphql之上構建計算層,本質上都是將計算任務交給其他系統模組負責;

  2. 使用schema指令加工後的資料對映為schema中的欄位,典型如graphql-tools社群給出的方案。

這兩種方案存在如下問題:

  1. 將計算邏輯交由其他模組使得業務資料的產出鏈路變長,且對於資料之間存在依賴的情況仍然需要對GraphQL模組多次呼叫,實際上並沒有解決GraphQL計算能力不足導致的硬編碼加工問題;

  2. 使用schema指令將加工後的資料定義為Schema中的欄位將導致業務計算和schema定義相耦合,資料圖會存在噪聲而變得難以維護。

本文從資料和演算法分離的角度出發,對問題進行了分析拆解,並對基於查詢指令的方案進行說明。

問題分析

GraphQL中的資料結構和演算法

電腦科學家Niklaus Wirth提出 程式=資料結構+演算法 ,GraphQL系統也不例外。

很多GraphQL使用者將查詢僅僅視為Schema的子圖、從資料圖中匹配出要獲取的資料,忽略了查詢更是 基於Schema資料結構的、對業務資料需求的演算法描述 ,包括引數驗證、資料聚合和計算處理等。GraphQL提供指令機制描述使用者自定義的計算和驗證行為,規範原文如下:

Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

指令按照可用位置可分為schema指令和query指令,前者是對schema額外資訊的描述,後者是對查詢的描述,同一個指令可以既是schema指令、又是query指令。

正如前文所述,使用schema指令對業務計算進行描述會使得Schema定義存在噪聲、增加Schema的維護難度。例如優惠價的展示,不同的業務場景需要轉換為不同的文案,例如“優惠價95.50元”、“限時優惠¥95.50”、“神祕價¥*5.50元”等,這些處理後的資料不應該作為Schema中的欄位存在。

我們通過query指令在查詢層面定義產品需求要求的資料計算行為,對Schema中的資料做個性化的處理。

資料計算行為的歸納總結

任何複雜的業務處理都是基於基本的資料結構組合和有限的操作行為組合。針對讀場景(也就是graphql的Query操作),我們對計算行為歸納如下:

  • 欄位加工:對結果資料進行加工處理,包括對列表的過濾、排序、去重。例如優惠價的不同展示文案、根據商品銷量對商品列表進行排序等;

  • 引數轉換:包括引數整體轉換,列表型別引數的過濾、轉換等。例如將userId拼接為redis的key,過濾掉userIdList中為0的引數元素;

  • 資料編排:當請求某一欄位的引數來自同一查詢其他欄位的查詢結果時,資料之間便存在需要編排的依賴關係。例如請求商品列表的引數來源於優惠券繫結的商品id列表,而grpahql查詢變數只有券id;

  • 控制流:根據請求變數或者其他欄位結果判斷是否請求某一欄位。

解題思路

確認業務計算行為應該放在query指令中完成,並對計算行為進行歸納總結後,我們在簡單分析GraphQL的執行引擎。

執行查詢的本質是從Query節點開始,對其子節點進行遍歷解析,並遞迴解析子孫節點,Query節點可理解為Schema資料圖的根節點。GraphQL規範中詳細描述了graphql的執行演算法,詳情參考sec-Execution。

GraphQL的java實現GraphQL Java基於 CompatableFuture 框架,對資料圖進行了並行、非同步的遍歷處理,其Instrumentationj介面可獲取查詢執行各個階段的執行時上下文、包括指令資訊,並具備更改查詢執行時行為和上下文資料的能力。可將其理解為GraphQL執行引擎的切面,其生效的位置包括查詢的解析、驗證以及每個資料節點的請求和完成過程。

綜上,我們對查詢計算問題做如下總結:

  • Schema作為GraphQL資料平臺的“資料結構”,只應存在複用性強的領域資料,不可為具體業務做擴充套件;

  • 查詢作為描述業務所需資料和計算行為的“演算法載體”,通過指令機制和Instrumentation系統為業務計算行為提供描述和實現;

  • 業務涉及的資料型別和計算行為是有限的,可對其進行總結歸納,抽象為對資料圖各元素的原子操作。

解決方案

以電商經典場景“優惠券去使用”為例,我們對基於查詢指令的解決方案進行說明,該方案框架已落地為開源專案GraphQL Calculator。

專案地址:http://github.com/graphql-calculator/graphql-calculator

問題描述

產品需求

當用戶點選店鋪優惠券時跳轉到優惠券承接頁,承接頁包括如下資料:

  1. 優惠券使用門檻描述文案,例如 threshold==5000(分);couponAmout=30000(分) 對應的描述文案為“以下商品可使用滿300減50的優惠券”;

  2. 優惠券繫結的商品列表,列表按照銷量降序排序;價格從“分”轉換到“元文案”,例如 price=18590分 ,則在客戶端展示為“¥185.90”;

  3. 只有版本大於v10的客戶端才展示商品標籤。

優惠券資訊是營銷部門的服務介面,商品詳情列表和商品標籤是商品部門的兩個服務介面。

傳統方案

基於原生的graphql系統,客戶端需要如下操作:

  1. 通過客戶端版本計算出是否跳過商品標籤獲取布林值引數,能力由graphql規範內建指令@skip支援;

  2. 獲取優惠券詳情,並解析優惠券繫結的商品id列表;

  3. 根據1、2結果同時請求商品基本資訊、商品標籤;

  4. 對資料做業務定製的處理,例如生成優惠券描述文案、商品排序、商品價格處理等。

業務方仍需要對資料進行繁雜的解析處理來彌補GraphQL原生查詢計算能力的不足。

方案詳情

基於 問題分析 ,graphql定義一組查詢指令用於資料的計算處理和編排控制,計算行為由計算引擎支援,預設使用aviatorscript。指令的名稱和語義參考 java.util.stream.Stream ,易於理解和使用。如何優雅地擴充套件 GraphQL 系統能力 對基礎指令的實現進行了說明。

引數處理&依賴編排

引數處理包括過濾掉無效引數,例如userIdList中為0的元素。而當需要將另外一個欄位的結果作為入參時,兩者存在依賴關係,例如該例中綁定了商品id列表的優惠券和商品詳情列表。

GraphQL Calculator使用 @argumentTransform@fetchSource 進行引數處理和編排依賴資料。 @argumentTransform 定義了對引數的加工轉換, @fetchSource 可將指定欄位的解析器獲取的結果作為其他計算指令可獲取的上下文,詳情可參考graphql-calculator#fetchsource。兩者定義如下:

directive @fetchSource(name: String!, sourceConvert:String) on FIELD

directive @argumentTransform(argumentName:String!, operateType:ParamTransformType = MAP, expression:String, dependencySources:[String!]) on FIELD


enum ParamTransformType{
    # 引數轉換
    MAP 
    # 列表型別引數過濾
    FILTER
    # 列表型別引數元素轉換
    LIST_MAP 
}

基於查詢指令的方案與傳統方案對比如下:前者省去了客戶端的硬編碼解析和二次呼叫。

技術上,GraphQL Calculator框架會對基於指令的查詢進行解析,識別 @fetchSource 註解的需要儲存的資料,並在 bindingItemIds 節點和 itemList 節點之間建立依賴關係。在執行階段會基於解析資訊,儲存上下文資料、並改變節點之間的排程關係。

GraphQL Calculator提供了校驗該指令集合法性的規則,對包括被編排的資料可能存在迴圈依賴的情況進行校驗。對於 @fetchSource 註解的節點,框架實際構造了對應的任務樹,該例中為 Query->coupon->bindingItemIds ,來描述 @fetchSource 節點可能存在於陣列中的情況,並解決父節點解析失敗時依賴其資料的節點空等的問題。

加工轉換&集合處理

資料的定製加工和列表的排序、過濾是產品需求中常見的計算邏輯。GraphQL Calculator參考 java.util.stream.Stream ,聲明瞭  @map@sortBy@filter@distinct 對資料進行加工轉換和對列表進行排序、過濾、去重。

以生成優惠券描述文案、對商品列表按照銷量降序排序為例,查詢如下:

query mapAndSortCase{
    
    coupon{
        threadHold
        couponAmount
        desc @map(mapper:"'滿'(threadHold/100)'減'(couponAmount/100)")
        
    }
    
    commodityList
    @sortBy(comparator:"soldAmount",isReversed:true)
    {
        name
        price
        soldAmount
    }
}

當產品需求微調迭代時,修改查詢指令表示式即可。如果出現兩個並存的業務需要對資料進行不同的處理時,也只需拷貝查詢語句、修改表示式,不用在開發計算邏輯。在實際應用中,查詢指令對業務的快速迭代具有明顯的幫助。

流程控制

有些需求隨著客戶端版本進行迭代,需要通過版本號決定是否請求某些欄位,例如該例中的商品標籤資訊。GraphQL Calculator實現了內建指令 @skip 和  @include 的擴充套件版本 @skipBy 和  @includeBy 。與內建指令只可將布林型別資料作為判斷是否請求被註解欄位的引數不同, @skipBy 和  @includeBy 可使用以查詢變數為引數的表示式計算結果判斷是否請求被註解的欄位。示例如下:

query mapAndSortCase($clientVersion:String, ...){
    
    # ...
    
    commodityList
    # ...
    {
        name
        price
        soldAmount
        
        # 客戶端版本號大於1.2.3時才會請求商品標籤列表
        tagList  @skipBy("greaterThan(clientVersion,'1.2.3')")
        {
            text
            icon
        }
    }
}

總結

相比於將計算邏輯交由其他模組系統,通過查詢指令定義計算的優勢如下:

  1. 快速響應:修改查詢dsl即可,即時生效,無需編碼、部署;

  2. 配置簡單:指令命名和語義簡單,基於結構化的查詢dsl使得計算表意更加方便明確;

  3. 效能優勢:通過查詢指令在GraphQL引擎層面完成資料的加工排程,不用等待整個查詢結束,且儘可能減少與客戶端的互動次數;

  4. 解耦業務計算行為和領域資料定義:查詢通過指令對要獲取的資料和進行的加工行為進行直觀的表示,Schema專注於領域資料治理。

在業務實踐中,查詢指令集可以輕易的覆蓋80%以上的計算需求,很大程度上減少了業務方因為業務定製邏輯產生的硬編碼解析計算工作。尤其是當產品需求沒有過於定製的複雜邏輯或者產品邏輯微調時,只需配置查詢語句即可滿足資料和計算需求,不必在編碼上線,實現了業務的快速迭代。

後記&感悟

重視提供能力

資料平臺經常會同時對接很多產品需求,該因素決定了平臺如果只是作為提供特定資料的部門存在,將會耗費大量時間精力進行業務的理解和對接。

建設者應關注到業務迭代時獲取預期資料時遇到的問題,並將這些問題及其處理方案進行歸納,抽象為一種通用的能力提供給平臺使用者。 相比於提供可複用的資料,有時候提供可複用的能力對資料平臺更加重要

將問題進行合理的抽象並實現為業務可用的通用能力,將能有效減少團隊對接具體業務的工作量。

二八原則

將問題範圍內80%工作的效率提升80%即是很有價值的提升,不必要求平臺100%滿足業務方的資料和能力需求。一味追求大而全可能導致過於複雜的系統設計、使得平臺的理解和使用成本更加高昂,團隊也可能要付出遠超20%的時間成本去實現維護“剩下20%的能力”。

平臺應該有明確的能力邊界,在嘗試對平臺做能力拓展之前應該進行審慎地分析評估。能力擴充套件經常意味著資料結構的變化和維護成本的增加。

忠於業務

不同於學術研究,工程領域專案的建設往往始於一定的業務背景,平臺的價值和意義最終都要回歸到具體的業務問題上進行評估。

參考資料

  • [1] http://spec.graphql.org

  • [2] http://tech.meituan.com/2021/05/06/bff-graphql.html

  • [3] http://www.infoq.cn/article/uqQ20tkA6eELUQec4o97

  • [4] http://www.graphql-tools.com/docs/schema-directives

  • [5] http://www.graphql-java.com/documentation/v17/instrumentation

  • [6] http://www.graphql-java.com/blog/threads

  • [7] http://github.com/graphql-calculator/graphql-calculator

參考閱讀:

技術原創及架構實踐文章,歡迎通過公眾號選單「聯絡我們」進行投稿。

高可用架構

改變網際網路的構建方式