Apollo GraphQL 在 webapp 中應用的思考

發表於2018-05-23

熟悉 Apollo GraphQL 的同學可直接跳過這一章,從 實踐 一章看起。

GraphQL 作為 FaceBook 2015年推出的 API 定義/查詢 語言,在歷經了兩年的發展之後,社群已相對發達和完善。對於 GraphQL 的一些基礎概念,本文不再一一贅述,目前社群相關的文章已經很多,有興趣的同學可以去 google,或者直接看GraphQL 官方教程 Apollo GraphQL Server 官方文件

而 Apollo GraphQL 作為目前社群最流行的 GraphQL 解決方案提供商,提供了從 client 到 server 的一整套完整的工具鏈。在這裡我也準備以 Apollo 為例,通過一步步搭建 Apollo GraphQL Server 的方式,來給大家展示 GraphQL 的特點,以及我的一些思考(主要是我的思考)。

setup

建立基於 express 的 GraphQL server

執行 node server.js,這樣我們就能啟動一個 GraphQL server 了。

注意我們這裡使用了 apollo-server-express 提供的 graphiqlExpress 外掛,graphiql 是一個用於瀏覽器端除錯 graphql 介面的 GUI 工具。服務啟動後,我們在瀏覽器開啟 http://localhost:8080/graphiql就可以看到這樣一個頁面

定義 API schema

我們在 server.js 中定義了這樣一個 endpoint : app.use('/graphql', graphqlExpress({ schema }));

這裡傳入的 schema 是什麼呢?它大概長這樣:

這裡的關鍵是用了 graphql-tools 這個庫提供的 makeExecutableSchema 組合了 schema 定義和對應的 resolver。resolver 是 Apollo GraphQL 工具鏈中提出的一個概念,什麼用呢?就是在我們客戶端請求過來的 schema 中的 field 如果在 GraphQL Server 中有對應的 resolver,那麼在返回資料時候,這些 field 就由對應的 resolver 的執行結果填充(支援返回 promise)。

客戶端請求

這裡藉助 graphiql 皮膚的功能來傳送請求:

看一下 http request payload 資訊:

響應體:

也就是說,無論你是用你熟悉的 http lib 還是社群的 apollo client,只要按照 GraphQL Server 要求的既定格式發請求就 ok 了。

這裡我們使用了 GraphQL 中的 variable 語法,事實上在這種需要傳參的動態查詢場景下,我們應該總是使用這種方式傳送請求:即一個 static query + variable 的方式,而不是在執行時動態的生成 query string。這也是官方建議的最佳實踐。

更復雜的巢狀查詢場景

假設我們有這樣一個場景,即我們需要取到 User Entity 下的 nick 欄位,而 nick 資料並不來自於 user 介面,而是需要根據 userId 呼叫另一個介面取得。這時候我們服務端的程式碼需要這樣寫。

resolver 的引數列表中包含了當前所在 Entity 已有的資料,所以這裡可以直接在函式的入參裡取到已查詢出來的 userId。

看下效果:

服務端的請求:

可以看到,這裡多出了查詢 nick 的請求。也就是說,GraphQL Server 只有在客戶端提交了包含相應欄位的 query 時,才會真正去傳送相應的請求。更多 resolver 說明可以看這裡

其他

在真實的生產環境中,我們通常會有更多更復雜的場景,比如介面的許可權認證、分頁、快取、批量提交、schema 模組化等需求,好在社群都有相對應的一些解決方案,這不是本文的重點所以不在這裡一一介紹了,有興趣的可以去看下我之前寫的 graphql-server-startkit,或者官方的 demo

實踐

如果你真實的使用過 Apollo GraphQL,你會經歷如下過程:

  1. 定義一個 schema 用於描述查詢入口
  2. 編寫 resolver 解析對應型別
  3. 編寫客戶端請求程式碼呼叫 GraphQL 介面,通常我們會封裝一個 get 方法

    如果你的專案中加入了靜態型別系統,那麼你的程式碼可能就會變成這樣:

寫到這裡你可能已經發現,不僅是 entity 型別定義,就連線口的封裝,我們在服務端和客戶端都重複了一遍(雖然一個用的 GraphQL Type Language 一個用的 TS)… 這還是最簡單的場景,如果業務模型複雜起來,你在兩端需要重複的程式碼會更多(比如型別的巢狀定義和 resolve)。這時候你可能會想起 DRY 原則,然後開始思考有沒**有什麼方式可以使得型別及介面定義能兩端複用,或者根據一端的定義自動生成另一端的程式碼?**甚至你開始懷疑,到底有沒有引入 GraphQL 的必要?

思考

GraphQL 作為一個標準化並自帶型別系統的 API Layer,其工程價值我也不再過多廣告了。只是在實踐過程中,既然我們無法完全避免服務端與客戶端的實體與介面定義重複(使用 apollo-codegen 可以避免一部分),而且對於大部分小團隊而言,運維一個 productive nodejs system 實際上都是力有未逮。**那麼我們是不是可以考慮在純客戶端構建一個類 GraphQL 的 API Layer 呢?**這樣既可以有效的避免編碼重複,也能大大的降低對團隊的要求,可操作的空間也比增加一個 nodejs 中間層大得多。

我們可以回憶一下,通常對於一個前端而言,促使我們需要一個 API Layer 的原因是什麼:

  1. 後端介面設計不夠 restful,命名垃圾,用的時候看見那個*一樣的 url 就難受。
  2. 後端同學只願意寫 microservice,提供聚合服務的 web api 被認為沒有技術含量,不願意寫。你需要一個資料,他告訴你需要調 a、b、c 三個介面,然後根據 id 組裝合併。
  3. 介面返回的資料格式各種巢狀及不合理,不是前端想要的結構。
  4. 介面返回的資料欄位命名隨意或者風格不統一,我有強迫症用這種介面會發瘋。
  5. 後端返回的 資料格式/欄位名 一旦變了,前端檢視繫結部分的程式碼需要修改。

通常情況下,碰到這些問題,你可能去跟後端同學據理力爭,要求他們提供呼叫體驗更良好設計更優雅的介面。沒錯這很好,畢竟為了追求完美去跟各種人撕(跟後端撕、跟產品撕、跟UI撕)是一個前端工程師基本的職業素養。但是如果你每天都被撕逼弄得心力交瘁,甚至是你根本找不到撕的物件(比如資料來源介面來著幾個不同部門,甚至是一些祖傳的沒人敢動的介面),這些時候大概就是你迫切希望有一個 API Layer 的時候了。

如何在客戶端實現一個 API Layer

其實很簡單,你只需要在客戶端把 Apollo Server 中要寫的 resolvers 寫一遍,然後配上一些效能提升手段(如快取等),你的 API Layer 就完成了。

比如我們在src下新建一個 loaders/apis 目錄,所有的資料拉取介面都放在這裡。比如這樣:

然後在你業務需要的地方注入相應 loader 呼叫介面即可,如:

如果你不喜歡依賴注入的方式,loaders/apis 層直接 export function getUser 也可以。

如果你碰到了上面描述的第 3、4 、5 三種問題,你可能還需要在這一層做一下資料格式化。比如這樣:

經過這一層的資料處理,我們就能確保我們的應用執行在前端自己定義的資料模型之下。這樣之後後端介面不論是資料結構還是欄位名的變更,我們只需要在這一層做簡單調整即可,而不會影響到我們上層的業務及檢視。相應的,我們的業務層邏輯不再會直接對接介面 url,而是將其隱藏在 API Layer 下,這樣不僅能提升業務程式碼的可讀性,也能做到眼不見為淨。。。

總結

熟悉 GraphQL 的同學可能會很快意識到,我這不過是在客戶端做了一個簡單的 API 封裝嘛,並不能解決在 GraphQL 出現之前的 lots of roundtrips 及 overfetching 問題。但事實上是 roundtrip 的問題我們可以通過客戶端快取來緩解(如果你用的是 axios 你可能需要 axios-extensions ),而且 roundtrip 的問題其實本質上我們不過是將客戶端的 http 開銷轉移到服務端了而已。在客戶端與服務端均不考慮快取的情況,客戶端反而會少一個請求。。。overfetching 問題則取決於 backend service 的粒度,如果 endpoint 不夠 micro,即便是 GraphQL,也會出現介面資料冗餘問題,畢竟 GraphQL 不生產資料,它只是資料的搬運工。。。而如果 endpoint 粒度足夠小,那麼我在客戶端 API 層多開幾個介面(換成 Apollo 也要多寫幾個 resolver),一樣可以按需取資料。服務端 API Layer 只有一個不可替代的優勢就是,如果我們的資料來源介面是不支援跨域或者僅內網可見的,那麼就只能在服務端開個口子做代理了。另外一個優勢就是,GraphQL Server 的 http 開銷是可控的,畢竟機器是我們自己控制,而客戶端的環境則不可控(受限於終端裝置及網路環境,比如低版本瀏覽器或者低速網路,均會導致 http 開銷的效能權重增大)。

可能有同學會說,服務端 API Layer 部署一次任何系統都可以共享其服務,而客戶端 API Layer 的作用域只在某一專案。其實,如果我們把某一專案需要共享的 API Layer 打成一個 npm 包釋出出去,不也能達到同樣的效果嗎,很多平臺的 js sdk 不都是這個思路麼(這裡只討論 web 開發範疇)。

在我看來,不論你是否會搭建一個服務端的 API Layer,**我們其實都需要有一個客戶端 API Layer 從資料來源頭來保證客戶端資料的模型統一及一致性,從而有足夠的能力應對介面的變遷。**如果你考慮的再遠一點,在 API Layer 服務的業務模型層,我們同樣需要有一套獨立的 Service/Model Layer 來應對檢視框架的變遷。這個暫且按下不表,後面會再寫篇文字來詳細說一下我的思路。

事實上,對於大部分團隊而言,客戶端 API Layer 已經夠用了,增加一層 GraphQL 並不是那麼必要。而且如果沒有很好的支援將客戶端介面轉換成 GraphQL Schema 和 resolver 的工具時,我們並不能很愉快的 coding,畢竟兩端重複的工作還是有點多。

相關文章