GraphQL java工程化實踐

rockswang發表於2018-05-11

因為自己寫過基於react的前端應用,因此一看到GraphQL就被深深吸引,真是直擊痛點啊!
服務端開發一直是基於java, Spring的,因此開始研究如何在現有工程框架下加入graphql的支援。
本文屬於隨筆性質,學到哪裡,用到哪裡,就寫到哪裡,觀點為個人理解,僅供參考。

GraphQL基本概念

  • Schema: 指一個特定GraphQL型別系統的定義,也指具體的包含型別系統定義的文字檔案。在型別定義中,schema {…} 這樣的程式碼塊定義的是入口型別,入口型別有三種,即查詢,變更和訂閱。值得說明的是,查詢,變更和訂閱也都是普通的型別而已,和其它物件型別語法上沒有任何區別,只不過它們作為入口型別被定義在schema程式碼塊中。
  • 查詢(query):定義為入口的物件型別;和變更、訂閱語法上並無不同,不過語義上對應的是讀操作。
  • 變更(mutation): 定義和語法同上,但語義上對應增/刪/改操作。
  • 訂閱(subscription): 定義和語法同上,語義上對應的是一個訂閱操作以及隨後伺服器對客戶端的0~N次主動推送操作。
  • 內省(introspection): 可以通過特殊的graphql查詢獲取到整個型別系統的詳細定義。這可能帶來資料模型過度暴露的問題,以後會專門說明。
  • 型別(type): 沒什麼好說,就是物件型別,和標量型別相對應。
  • 標量(scalar): 非物件的簡單資料型別,比如內建的String, Int, ID等。可以自己定義新的標量型別,只要為它編寫序列化/反序列化方法即可,具體在graphql-java中對應的類是Coercing。
  • 欄位(field): 物件型別的成員,可以是物件型別或者標量型別。和java類裡的field不同的是,GraphQL的field都是可以有引數的,因此有引數的field也可以理解成java中有特定型別返回值的方法。
  • 介面(interface): 和java裡的介面差不多,定義型別的公共欄位,java實現中可以直接對應寫一個interface。有點麻煩的是在每個interface的實現類中都必須重複書寫公共欄位。
  • 聯合(union): 和介面類似,但是不要求任何公共欄位。為了方便可以在java實現中使用無方法的interface實現。
  • 片段(fragment): 這是個查詢時的概念,和schema定義無關,用於預定義型別上的若干個欄位組合,後面的查詢語句中可以反覆引用,可避免重複書寫這些欄位組合。
  • 內聯片段(inline fragment):片段還只是個簡化查詢的可有可無的東西,但內聯片段則更重要,對於返回interface或union型別的欄位,需要使用內聯片段來根據結果的實際型別獲取不同的欄位。
  • 別名(alias): 在查詢中可為特定欄位的查詢增加別名,用來在返回的結果中加以區分,比如一次查詢了兩個特定使用者,因為型別相同,欄位也相同,如果不用別名,則無法在結果中區分彼此。
  • 型別擴充套件(extend): 在schema中,可以使用extend給任意型別(包括interface/union)增加欄位;這看似自找麻煩的機制實際上有很大用處,可以把高許可權角色的特定欄位使用extend寫在另外的schema檔案中,執行時可合併解析,不同角色的使用者使用不同的schema。這樣可以通過加法來控制型別系統的可見性,避免內省機制過度暴露型別系統。
  • DataLoader: 用於批量查詢,見後文介紹。
  • Relay: Facebook的另一個框架,應該是基於GraphQL的,解決一些更高層的實際應用問題,比如通用的分頁機制等。

graphql-java特定術語

  • DataFetcher: 資料獲取器,即用以獲取Field實際值的物件。
  • Data Class: 資料類,這是graphql-java-tools中的概念,對應schema中的同名物件型別。

    • 可以在資料類上按照約定格式編寫DataFetcher方法用於獲取簡單欄位值(比如無需另外查詢資料庫的欄位)。
    • 我在工程實踐中直接使用資料庫實體類作為資料類。
  • GraphQLResolver: 這是graphql-java-tools中的介面,帶有一個資料類的型別引數。

    • 對該資料類定義部分或所有欄位值的獲取方法,需要基於約定命名方法。
    • 注意Resolver中的DataFetcher方法的優先順序高於DataClass中的方法。
    • 我在工程實踐中直接使用Dao類作為對應實體類的GraphQLResolver。
  • ExecutionInput: graphql-java中用來包裝一個完整查詢輸入的類,包括:

    • query – 查詢字串;
    • operationName: 操作名; 可選;可用於在查詢中的多個操作中僅選擇特定名稱的予以執行。
    • variables: 變數; 可選;一個Map,用於替換查詢字串中形如`$value`的變數。
    • context – 上下文; 可選;任意Object型別,會被傳遞給DataFetcher;可用於傳遞當前登入使用者等。
    • root – 根物件; 可選;任意Object型別,會被傳遞給DataFetcher,語義上是被查詢的根物件。
  • ExecutionStrategy(執行策略): 定義查詢的具體執行策略。

    • 比如是否非同步執行,多個子查詢是依次執行,還是用執行緒池併發執行等。
  • Instrumentation(攔截器): 比較像Servlet容器中的Filter,在查詢執行前後各有一次執行機會。

    • 可用於對輸入和結果進行額外處理;
    • 支援鏈式執行;
    • 需要指出的是DataLoader使用攔截器與核心系統耦合。
  • GraphqlFieldVisibility: 可以程式設計控制schema中各個欄位的可見性。

    • 和extend對應,相當於用減法來控制型別系統的可見性。

技術選型

github上graphql-java名下的庫不少,如果希望瞭解各自簡介的,可以看下awesome-graphql-java這個專案。
我自己評估了以下幾個:

  • graphql-java: 這個是核心庫,完全符合Facebook的spec,可以直接解析schema檔案,但是型別繫結需要使用RuntimeWiring來程式設計方式新增,用起來還是比較麻煩的。
  • graphql-java-annotations: 這是資料驅動的流派,使用註解直接在java型別上標註GraphQL型別以及DataFetcher等,不用寫schema檔案。評估了一陣,個人感覺非常麻煩,比如:對每個欄位都會建立新的DataFetcher例項來進行解析,十分低效;要編寫很多類來訪問不同欄位;過多的物件直接建立,難以託管到Spring容器;等等。因此我的結論是,此庫並不適用於我的工程實踐。
  • graphql-java-tools: 這是Schema驅動的流派,這個庫使用Antlr自己重寫了Schema解析器,使用GraphQLResolver例項和Data Class;基於方法名和引數的約定來定義DataFetching,使用起來很方便。這是我最終選定使用的庫。不太爽的地方有兩點:1) 當前版本基於graphql-java 7.0,遲滯於核心庫 2) 使用Kotlin編寫,我在MyEclipse裡面無法正常設定斷點進行跟蹤除錯……
  • graphql-java-servlet: GraphQL不像傳統的REST,需要寫一堆Controller,提供唯一的api介面即可,這個servlet就是幫你連這個都包辦的,不過我沒有用,自己基於SpringMVC寫一個也很簡單。

批量資料查詢(解決N+1問題)

graphql-java提供了兩種批量資料查詢的方案:

  1. BatchedDataFetcher: 用起來挺簡單的,普通的DataFetcher是給你一個ID讓你返回一個物件,批量版是給你一個ID列表,讓你返回對應的物件列表。不過這個不是Facebook推薦的方式,在新版本中會廢棄掉。
  2. DataLoader: 這個是Facebook官方推薦的方式,nodejs中的實現是基於js的非同步機制延遲查詢,把最近一個週期產生的多個查詢集中執行(沒詳細瞭解,看文件大概如此),java版實現方式則略有不同,下面詳細介紹。

關於DataLoader

graphql-java的dataloader是基於java8中新增的CompletableFeature類(大概相當於javascript裡面的Promise),實現非同步延遲批量獲取查詢結果。

大概原理(個人理解):

  1. 在DataFetcher方法中,並不直接返回實體類T,而是呼叫DataLoader.load()方法,返回一個CompletionStage<T>,這時並不立即進行實際查詢,而是把這些非同步階段物件快取起來。
  2. 在查詢告一段落後(即能夠立即獲取的Field值都已取得,只剩下非同步查詢未完成了),graphql-java會通過DataLoaderDispatcherInstrumentation.dispatch方法通知所有當前註冊的DataLoader去執行當前積壓的所有非同步階段物件,具體就是會使用DataLoader對應的BatchLoader一次性查詢一批物件。
  3. 這時候又有一批Field的值已經實際取得,繼續按查詢的請求向下層展開,如果有新的非同步階段物件產生,就繼續步驟2,直到所有非同步階段物件都獲得最終值。

工程實踐中對其應用方式的考慮:
在graphql-java的官方教程中建議針對每請求建立新的DataLoader例項,查詢請求結束則DataLoader例項們的生命週期結束。
這個實現方式比較簡單,不用考慮快取的更新問題,也不用考慮多個不同請求的快取物件是否可共用。
舉個例子,張三和李四併發查詢張三的資訊,他們獲取的”張三”使用者例項的結構可能是不同的,這種情況這兩個併發請求就不能共用快取,而應該各自有獨立的DataLoader例項。
不過在我的工程實踐中,服務端記憶體中的資料實體類都是客觀一致的,其結構可見性應在更上一層即DataFetcher甚至Schema級別中進行過濾。
因此我的想法是為每種實體類維護單例的DataLoader,和Dao物件一一對應。
這種情況下,就不能簡單的使用DataLoader內部預設的簡單記憶體快取了,因為此快取是不會自動定時清理的。
graphql-java是允許開發者提供自己的快取實現的,下一步我會結合專案中使用的Spring快取管理器來具體實現。

查詢的快取

graphql的查詢本身是有一定語法結構的特殊文字,對該文字進行解析也是有效能開銷的,因此graphql-java提供了快取機制方便開發者把查詢文字的解析後資料結構快取起來。
以下程式碼引自官方教程,我準備結合我們專案裡的EhCache來實作一下。

Cache<String, PreparsedDocumentEntry> cache = Caffeine.newBuilder().maximumSize(10_000).build();
GraphQL graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
        .preparsedDocumentProvider(cache::get)
        .build();

關於訂閱的實現

工程實踐中使用WebSocket實現訂閱。
無論是graphql還是graphql-java都未指定訂閱的具體實現機制,但WebSocket是現代瀏覽器普遍支援的,高效能低限制的伺服器推送機制。
SpringMVC支援WebSocket,同時支援在低版本瀏覽器中使用Sock.js作為相容備選方案。
另外,graphql-java體驗性支援的Defer資料獲取也可基於WebSocket實現。

未完待續

參考資料

基於spring和graphql-java-tools的寵物店例程
簡單的TODO例程,使用relay的思路解決分頁問題
基於WebSocket實現GraphQL訂閱

相關文章