來源:http://dockone.io/article/7727

在過去的將近半年的時間裏,作者一直在使用 GraphQL 這門相對新興的技術開發 Web 服務,與更早出現的 SOAP 和 REST 相比,GraphQL 其實提供的是一套相對完善的查詢語言,而不是類似 REST 的設計規範,所以需要語言的生態提供相應的框架支持,但是由於從它開源至今也只有兩三年的時間,所以在使用的過程中,尤其是在微服務架構中實踐時確實還會遇到很多問題。

GraphQL 在微服務架構中的實踐


這篇文章中,首先會簡單介紹 GraphQL 是什麼,它能夠解決的問題;在這之後,我們會重點分析 GraphQL 在微服務架構中的使用以及在實踐過程中遇到的棘手問題,在最後作者將給出心中合理的 GraphQL 微服務架構的設計,希望能爲同樣在微服務架構中使用 GraphQL 的工程師提供一定的幫助,至於給出的建議是否能夠滿足讀者在特定業務場景下的需求就需要讀者自行判斷了。

GraphQL

簡單對象訪問協議(SOAP)從今天來看已經是一門非常古老的 Web 服務技術了,雖然很多服務仍然在使用遵循 SOAP 的接口,但是到今天 REST 風格的面向資源的 API 接口已經非常深入人心,也非常的成熟;但是這篇文章要介紹的主角其實是另一門更加複雜、完備的查詢語言 GraphQL。

作爲 Facebook 在 2015 年推出的查詢語言,GraphQL 能夠對 API 中的數據提供一套易於理解的完整描述,使得客戶端能夠更加準確的獲得它需要的數據,目前包括 Facebook、Twitter、GitHub 在內的很多公司都已經在生產環境使用 GraphQL 提供 API;其實無論我們是否決定生產環境中使用 GraphQL,它確實是一門值得學習的技術。

類型系統

GraphQL 的強大表達能力主要還是來自於它完備的類型系統,與 REST 不同,它將整個 Web 服務中的全部資源看成一個有連接的圖,而不是一個個資源孤島,在訪問任何資源時都可以通過資源之間的連接訪問其它的資源。

GraphQL 在微服務架構中的實踐


如上圖所示,當我們訪問 User 資源時,就可以通過 GraphQL 中的連接訪問當前 User 的 Repo 和 Issue 等資源,我們不再需要通過多個 REST 的接口分別獲取這些資源,只需要通過如下所示的查詢就能一次性拿到全部的結果:


GraphQL 在微服務架構中的實踐


GraphQL 這種方式能夠將原有 RESTful 風格時的多次請求聚合成一次請求,不僅能夠減少多次請求帶來的延遲,還能夠降低服務器壓力,加快前端的渲染速度。它的類型系統也非常豐富,除了標量、枚舉、列表和對象等類型之外,還支持接口和聯合類型等高級特性。

GraphQL 在微服務架構中的實踐


爲了能夠更好的表示非空和空字段,GraphQL 也引入了 Non-Null 等標識代表非空的類型,例如 String! 表示非空的字符串。


GraphQL 在微服務架構中的實踐


Schema 中絕大多數的類型都是普通的對象類型,但是每一個 Schema 中都有兩個特殊類型:query 和 mutation,它們是 GraphQL 中所有查詢的入口,在使用時所有查詢接口都是 query 的子字段,所有改變服務器資源的請求都應該屬於 mutation 類型。

集中式 vs 分散式

GraphQL 以圖的形式將整個 Web 服務中的資源展示出來,其實我們可以理解爲它將整個 Web 服務以 “SQL” 的方式展示給前端和客戶端,服務端的資源最終都被聚合到一張完整的圖上,這樣客戶端可以按照其需求自行調用,類似添加字段的需求其實就不再需要後端多次修改了。

GraphQL 在微服務架構中的實踐


與 RESTful 不同,每一個的 GraphQL 服務其實對外只提供了一個用於調用內部接口的端點,所有的請求都訪問這個暴露出來的唯一端點。

GraphQL 在微服務架構中的實踐


GraphQL 實際上將多個 HTTP 請求聚合成了一個請求,它只是將多個 RESTful 請求的資源變成了一個從根資源 Post 訪問其他資源的 Comment 和 Author 的圖,多個請求變成了一個請求的不同字段,從原有的分散式請求變成了集中式的請求,這種方式非常適合單體服務直接對外提供 GraphQL 服務,能夠在數據源和展示層建立一個非常清晰的分離,同時也能夠通過一些強大的工具,例如 GraphiQL 直接提供可視化的文檔;但是在業務複雜性指數提升的今天,微服務架構成爲了解決某些問題時必不可少的解決方案,所以如何在微服務架構中使用 GraphQL 提高前後端之間的溝通效率並降低開發成本成爲了一個值得考慮的問題。

Relay 標準

如果說 RESTful 其實是客戶端與服務端在 HTTP 協議通信時定義的固定標準,那麼 Relay 其實也是我們在使用 GraphQL 可以遵循的一套規範。

GraphQL 在微服務架構中的實踐


這種標準的出現能夠讓不同的工程師開發出較爲相似的通信接口,在一些場景下,例如標識對象和分頁這種常見的需求,引入設計良好的標準能夠降低開發人員之間的溝通成本。

Relay 標準其實爲三個與 API 有關的最常見的問題制定了一些規範:

  1. 提供能夠重新獲取對象的機制;
  2. 提供對如何對連接進行分頁的描述;
  3. 標準化 mutation 請求,使它們變得更加可預測;

通過將上述的三個問題規範化,能夠極大地增加前後端對於接口制定和對接時的工作效率。

對象標識符

Node 是 Relay 標準中定義的一個接口,所有遵循 Node 接口的類型都應該包含一個 id 字段:


GraphQL 在微服務架構中的實踐


Faction 和 Ship 兩個類型都擁有唯一標識符 id 字段,我們可以通過該標識符重新從服務端取回對應的對象,Node 接口和字段在默認情況下會假定整個服務中的所有資源的 id 都是不同的,但是很多時候我們都會將類型和 id 綁定到一起,組合後才能一個類型特定的 ID;爲了保證 id 的不透明性,返回的 id 往往都是 Base64 編碼的字符串,GraphQL 服務器接收到對應 id 時進行解碼就可以得到相關的信息。

連接與分頁

在一個常見的數據庫中,一對多關係是非常常見的,一個 User 可以同時擁有多個 Post 以及多個 Comment,這些資源的數量在理論上不是有窮的,沒有辦法在同一個請求全部返回,所以要對這部分資源進行分頁。


GraphQL 在微服務架構中的實踐


Relay 通過抽象出的『連接模型』爲一對多的關係提供了分片和分頁的支持,在 Relay 看來,當我們獲取某一個 User 對應的多個 Post 時,其實是得到了一個 PostConnection,也就是一個連接:


GraphQL 在微服務架構中的實踐


在一個 PostConnection 中會存在多個 PostEdge 對象,其中的 cursor 就是我們用來做分頁的字段,所有的 cursor其實都是 Base64 編碼的字符串,這能夠提醒調用方 cursor 是一個不透明的指針,拿到當前 cursor 後就可以將它作爲 after 參數傳到下一個查詢中:


GraphQL 在微服務架構中的實踐


當我們想要知道當前頁是否是最後一頁時,其實只需要使用每一個連接中的 PageInfo 對象,其中包含了很多與分頁相關的信息,一個連接對象中一般都有以下的結構和字段,例如:Edge、PageInfo 以及遊標和節點等。


GraphQL 在微服務架構中的實踐


Relay 使用了非常多的功能在連接周圍構建抽象,讓我們能夠更加方便地管理客戶端中的遊標,整個連接相關的規範其實特別複雜,可以閱讀 Relay Cursor Connections Specification 瞭解更多與連接和遊標有關的設計。

可變請求

每一個 Web 服務都可以看做一個大型的複雜狀態機,這個狀態機對外提供兩種不同的接口,一種接口是查詢接口,它能夠查詢狀態機的當前狀態,而另一種接口是可以改變服務器狀態的可變操作,例如 POST、DELETE 等請求。

GraphQL 在微服務架構中的實踐


按照約定,所有的可變請求都應該以動詞開頭並且它們的輸入都以 Input 結尾,與之相對應的,所有的輸出都以 Payload 結尾:


GraphQL 在微服務架構中的實踐


除此之外,可變請求還可以通過傳入 clientMutationId 保證請求的冪等性。

小結

Facebook 的 Relay 標準其實是一個在 GraphQL 上對於常見領域問題的約定,通過這種約定我們能夠減少工程師的溝通成本和項目的維護成本並在多人協作時保證服務對外提供接口的統一。

N + 1 問題

在傳統的後端服務中,N + 1 查詢的問題就非常明顯,由於數據庫中一對多的關係非常常見,再加上目前大多服務都使用 ORM 取代了數據層,所以在很多時候相關問題都不會暴露出來,只有真正出現性能問題或者慢查詢時纔會發現。


GraphQL 在微服務架構中的實踐



GraphQL 作爲一種更靈活的 API 服務提供方式,相比於傳統的 Web 服務更容易出現上述問題,類似的問題在出現時也可能更加嚴重,所以我們更需要避免 N + 1 問題的發生。

數據庫層面的 N + 1 查詢我們可以通過減少 SQL 查詢的次數來解決,一般我們會將多個 = 查詢轉換成 IN 查詢;但是 GraphQL 中的 N + 1 問題就有些複雜了,尤其是當資源需要通過 RPC 請求從其他微服務中獲取時,更不能通過簡單的改變 SQL 查詢來解決。

GraphQL 在微服務架構中的實踐


在處理 N + 1 問題之前,我們要真正瞭解如何解決這一類問題的核心邏輯,也就是將多次查詢變成一次查詢,將多次操作變成一次操作,這樣能夠減少由於多次請求增加的額外開銷 —— 網絡延遲、請求解析等;GraphQL 使用了 DataLoader 從業務層面解決了 N + 1 問題,其核心邏輯就是整個多個請求,通過批量請求的方式解決問題。

微服務架構

微服務架構在當下已經成爲了遇到業務異常複雜、團隊人數增加以及高併發等需求或者問題時會使用的常見解決方案,當微服務架構遇到 GraphQL 時就會出現很多理論上的碰撞,會出現非常多的使用方法和解決方案。

GraphQL 在微服務架構中的實踐


在這一節中,我們將介紹在微服務架構中使用 GraphQL 會遇到哪些常見的問題,對於這些問題有哪些解決方案需要權衡,同時也會分析 GraphQL 的設計理念在融入微服務架構中應該注意什麼。

當我們在微服務架構中融入 GraphQL 的標準時,會遇到三個核心問題,這些問題其實主要是從單體服務遷移到微服務架構這種分佈式系統時引入的一系列技術難點,這些技術難點以及選擇之間的折衷是在微服務中實踐 GraphQL 的關鍵。

Schema 設計

GraphQL 獨特的 Schema 設計其實爲整個服務的架構帶來了非常多的變數,如何設計以及暴露對外的接口決定了我們內部應該如何實現用戶的認證與鑑權以及路由層的設計。

從總體來看,微服務架構暴露的 GraphQL 接口應該只有兩種;一種接口是分散式的,每一個微服務對外暴露不同的端點,分別對外界提供服務。

GraphQL 在微服務架構中的實踐


在這種情況下,流量的路由是根據用戶請求的不同服務進行分發的,也就是我們會有以下的一些 GraphQL API 服務:


GraphQL 在微服務架構中的實踐



我們可以看到當前博客服務總共由內容、評論以及訂閱三個不同的服務來提供,在這時其實並沒有充分利用 GraphQL 服務的好處,當客戶端或前端同時需要多個服務的資源時,需要分別請求不同服務上的資源,並不能通過一次 HTTP 請求滿足全部的需求。

另一種方式其實提供了一種集中式的接口,所有的微服務對外共同暴露一個端點,在這時流量的路由就不是根據請求的 URL 了,而是根據請求中不同的字段進行路由。

GraphQL 在微服務架構中的實踐


這種路由的方式並不能夠通過傳統的 nginx 來做,因爲在 nginx 看來整個請求其實只有一個 URL 以及一些參數,我們只有解析請求參數中的查詢才能知道客戶端到底訪問了哪些資源。


GraphQL 在微服務架構中的實踐



請求的解析其實是對一顆樹的解析,這部分解析其實是包含業務邏輯的,在這裏我們需要知道的是,這種 Schema 設計下的請求是按照 field 進行路由的,GraphQL 其實幫助我們完成了解析查詢樹的過程,我們只需要對相應字段實現特定的 Resolver 處理返回的邏輯就可以了。

然而在多個微服務提供 Schema 時,我們需要通過一種機制將多個服務的 Schema 整合起來,這種整合 Schema 的思路最重要的就是需要解決服務之間的重複資源和衝突字段問題,如果多個服務需要同時提供同一個類型的基礎資源,例如:User 可以從多種資源間接訪問到。


GraphQL 在微服務架構中的實踐


作爲微服務的開發者或者提供方來講,不同的微服務之間的關係是平等的,我們需要一個更高級別或者更面向業務的服務對提供整合 Schema 的功能,確保服務之間的字段與資源類型不會發生衝突。

GraphQL 在微服務架構中的實踐


前綴

如何解決衝突資源從目前來看有兩種不同的方式,一種是爲多個服務提供的資源添加命名空間,一般來說就是前綴,在合併 Schema 時,通過添加前綴能夠避免不同服務出現重複字段造成衝突的可能。

GraphQL 在微服務架構中的實踐


SourceGraph 在實踐 GraphQL 時其實就使用了這種增加前綴的方式,這種方式的實現成本比較低,能夠快速解決微服務中 Schema 衝突的問題,讀者可以閱讀 GraphQL at massive scale: GraphQL as the glue in a microservice architecture 一文了解這種做法的實現細節;這種增加前綴解決衝突的方式優點就是開發成本非常低,但是它將多個服務的資源看做孤島,沒有辦法將多個不同服務中的資源關係串聯起來,這對於中心化設計的 GraphQL 來說其實會造成一定體驗上的丟失。

粘合

除了增加前綴這種在工程上開發成本非常低的方法之外,GraphQL 官方提供了一種名爲 Schema Stitching 的方案,能夠將不同服務的 GraphQL Schema 粘合起來並對外暴露統一的接口,這種方式能夠將多個服務中的不同資源粘合起來,能夠充分利用 GraphQL 的優勢。

GraphQL 在微服務架構中的實踐


爲了打通不同服務之間資源的壁壘、建立合理並且完善的 GraphQL API,我們其實需要付出一些額外的工作,也就是在上層完成對公共資源的處理;當對整個 Schema 進行合併時,如果遇到公共資源,就會選用特定的 Resolver 進行解析,這些解析器的邏輯是在 Schema Stitching 時指定的。


GraphQL 在微服務架構中的實踐


我們需要在服務層上的業務層對服務之間的公共資源進行定義,併爲這些公共資源建立新的 Resolver,當 GraphQL 解析當公共資源時,就會調用我們在合併 Schema 時傳入的 Resolver 進行解析和處理。


GraphQL 在微服務架構中的實踐


在整個 Schema Stitching 的過程中,最重要的方法其實就是 mergeSchemas,它總共接受三個參數,需要粘合的 Schema 數組、多個 Resolver 以及類型出現衝突時的回調:


GraphQL 在微服務架構中的實踐


Schema Stitching 其實是解決多服務共同對外暴露 Schema 時比較好的方法,這種粘合 Schema 的方法其實是 GraphQL 官方推薦的做法,同時它們也爲使用者提供了 JavaScript 的工具,但是它需要我們在合併 Schema 的地方手動對不同 Schema 之間的公共資源以及衝突類型進行處理,還要定義一些用於解析公共類型的 Resolver;除此之外,目前 GraphQL 的 Schema Stitching 功能對於除 JavaScript 之外的語言並沒有官方的支持,作爲一個承載了服務發現以及流量路由等功能的重要組件,穩定是非常重要的,所以應該慎重考慮是否應該自研用於 Schema Stitching 組件。

組合

除了上述的兩種方式能夠解決對外暴露單一 GraphQL 的問題之外,我們也可以使用非常傳統的 RPC 方式組合多個微服務的功能,對外提供統一的 GraphQL 接口:

GraphQL 在微服務架構中的實踐


當我們使用 RPC 的方式解決微服務架構下 GraphQL Schema 的問題時,內部的所有服務組件其實與其他微服務架構中的服務沒有太多區別,它們都會對外提供 RPC 接口,只是我們通過另一種方式 GraphQL 整合了多個微服務中的資源。

使用 RPC 解決微服務中的問題其實是一個比較通用同時也是比較穩定的解決方案,GraphQL 作爲一種中心化的接口提供方式,通過 RPC 調用其他服務的接口並進行合併和整合其實也是一個比較合理的事情;在這種架構下,我們其實可以在提供 GraphQL 接口的情況下,也讓各個微服務直接或者通過其他業務組件對外暴露 RESTful 接口,提供更多的接入方式。

雖然 RPC 的使用能爲我們的服務提供更多的靈活性,同時也能夠將 GraphQL 相關的功能拆分到單獨的服務中,但是這樣給我們帶來了一些額外的工作量,它需要工程師手動拼接各個服務的接口並對外提供 GraphQL 服務,在遇到業務需求變更時也可能會導致多個服務的修改和更新。

小結

從使用前綴、粘合到使用 RPC 組合各個微服務提供的接口,對外暴露的 Schema 其實是一個由點到面逐漸聚合的過程,同時實現的複雜度也會逐步上升。

GraphQL 在微服務架構中的實踐


在這三種方式中,作者並不推薦使用前綴的方式隔離多個微服務提供的接口,這種做法並沒有充分利用 GraphQL 的好處,不如使用 RESTful 將多個服務的接口直接解耦,使用 GraphQL 反而是有一些濫用的感覺。

除了使用前綴的做法之外,無論是粘合還是組合都能夠提供一個完整的 GraphQL 接口,它們兩者都需要在直接對接用戶的 GraphQL 服務中對各個微服務提供的接口進行整合,當我們使用 Schema Stitching 時,其實對後面的服務提出了更多的要求 —— 開發微服務的工程師需要掌握 GraphQL Schema 的設計與開發方法,與此同時,各個微服務之間的類型也可能出現衝突,需要在上層進行解決,不過這也減少了一些最前面的 GraphQL 服務的工作量。

在最後,使用組合方式就意味着整個架構中的 GraphQL 服務需要通過組合 RPC 的方式處理與 GraphQL 相關的全部邏輯,相當於把 GraphQL 相關的全部邏輯都抽離到了最前面。

經過幾次架構的重構之後,在微服務架構中,作者更傾向於使用 RPC 組合各個微服務功能的方式提供 GraphQL 接口,雖然這樣帶來了更多的工作量,但是卻能擁有更好的靈活性,也不需要其他微服務的開發者瞭解 GraphQL 相關的設計規範以及約定,讓各個服務的職責更加清晰與可控。

認證與授權

在一個常見的 Web 服務中,如何處理用戶的認證以及鑑權是一個比較關鍵的問題,因爲我們需要了解在使用 GraphQL 的服務中我們是如何進行用戶的認證與授權的。

GraphQL 在微服務架構中的實踐


如果我們決定 Web 服務作爲一個整體對外暴露的是 GraphQL 的接口,那麼在很大程度上,Schema 設計的方式決定了認證與授權應該如何組織;作爲一篇介紹 GraphQL 在微服務架構中實踐的文章,我們也自然會介紹在不同 Schema 設計下,用戶的認證與授權方式應該如何去做。

上一節中總共提到了三種不同的 Schema 設計方式,分別是:前綴、粘合和組合,這些設計方式在最後都會給出一個如下所示的架構圖:

GraphQL 在微服務架構中的實踐


使用 GraphQL 的所有結構最終都會由一箇中心化的服務對外接受來自客戶端的 GraphQL 請求,哪怕它僅僅是一個代理,當我們有了這張 GraphQL 服務的架構圖,如何對用戶的認證與授權進行設計就變得非常清晰了。

認證

首先,用戶的認證在多個服務中分別實現是大不合理的,如果需要在多個服務中處理用戶認證相關的邏輯,相當於將一個服務的職責同時分給了多個服務,這些服務需要共享用戶認證相關的表,users、sessions 等等,所以在整個 Web 服務中,由一個服務來處理用戶認證相關的邏輯是比較合適的。

GraphQL 在微服務架構中的實踐


這個服務既可以是作爲網關代理的 GraphQL 服務本身,也可以是一個獨立的用戶認證服務,在每次用戶請求時都會通過 RPC 或者其他方式調用該服務提供的接口對用戶進行認證,用戶的授權功能與認證就有一些不同了。

授權

我們可以選擇在 GraphQL 服務中增加授權的功能,也可以選擇在各個微服務中判斷當前用戶是否對某一資源有權限進行操作,這其實是集中式跟分佈式之間的權衡,兩種方式都有各自的好處,前者將鑑權的權利留給了各個微服務,讓它們進行自治,根據其業務需要判斷請求者是否可以訪問後者修改資源,而後者其實把整個鑑權的過程解耦了,內部的微服務無條件的信任來自 GraphQL 服務的請求並提供所有的服務。

GraphQL 在微服務架構中的實踐


上面的設計其實都是在我們只需要對外提供一個 GraphQL 端點時進行的,當業務需要同時提供 B 端、C 端或者管理後臺的接口時,設計可能就完全不同了。

GraphQL 在微服務架構中的實踐


在這時,如果我們將鑑權的工作分給多個內部的微服務,每個服務都需要對不同的 GraphQL 服務(或者 Web 服務)提供不同的接口,然後分別進行鑑權;但是將鑑權的工作交給 GraphQL 服務就是一種比較好的方式了,內部的微服務不需要關心調用者是否有權限訪問該資源,鑑權都由最外層的業務服務來處理,實現了比較好的解耦。

當然,完全的信任其他服務的調用其實是一個比較危險的事情,對於一些重要的業務或者請求調用可以通過外部的風控系統進行二次檢查判斷當前請求方調用的合法性。

GraphQL 在微服務架構中的實踐


何實現一個完備並且有效的風控系統並不是這篇文章想要主要介紹的內容,讀者可以尋找相關的資料瞭解風控系統的原理以及模型。

小結

認證與授權的設計本來是系統中一件比較靈活的事情,無論我們是否在微服務架構中使用 GraphQL 作爲對外的接口,將這部分邏輯交由直接對外暴露的服務是一種比較好的選擇,因爲直接對外暴露的服務中掌握了更多與當前請求有關的上下文,能夠更容易地對來源用戶以及其權限進行認證,而重要或者高危的業務操作可以通過額外增加風控服務管理風險,或者在路由層對 RPC 的調用方通過白名單進行限制,這樣能夠將不同的功能解耦,減少多個服務之間的重複工作。

路由設計

作爲微服務中非常重要的一部分,如何處理路由層的設計也是一個比較關鍵的問題;但是與認證與鑑權相似的是,Schema 的設計最終其實就決定了請求的路由如何去做。

GraphQL Schema Stitching 其實已經是一套包含路由系統的 GraphQL 在微服務架構的解決方案了,它能夠在網關服務器 Resolve 請求時,通過 HTTP 協議將對應請求的片段交由其他微服務進行處理,整個過程不需要手動介入,只有在類型出現衝突時會執行相應的回調。

GraphQL 在微服務架構中的實踐


而組合的方式其實就相當於要手動實現 Schema Stitching 中轉發請求的工作了,我們需要在對外暴露的 GraphQL 服務中實現相應字段的解析器調用其他服務提供的 HTTP 或者 RPC 接口取到相應的數據。

在 GraphQL 中的路由設計其實與傳統微服務架構中的路由設計差不多,只是 GraphQL 提供了 Stitching 的相關工具用來粘合不同服務中的 Schema 並提供轉發服務,我們可以選擇使用這種粘合的方式,也可以選擇在 Resolver 中通過 HTTP 或者 RPC 的方式來自獲取用戶請求的資源。

架構的演進

從今年年初選擇使用 GraphQL 作爲服務對外暴露的 API 到現在大概有半年的事件,服務的架構也在不斷演進和改變,在這個過程中確實經歷了非常多的問題,也一次一次地對現有的服務架構進行調整,整個演進的過程其實可以分爲三個階段,從使用 RPC 組合的方式到 Schema Stitching 最後再回到使用 RPC。

GraphQL 在微服務架構中的實踐


雖然在整個架構演進的過程中,最開始和最終選擇的技術方案雖然都是使用 RPC 進行通信,但是在實現的細節上卻有着很多的不同以及差異,這也是我們在業務變得逐漸複雜的過程發現的。

中心化 Schema 與 RPC

當整個項目剛剛開始啓動時,其實就已經決定了使用微服務架構進行開發,但是由於當時選擇使用的技術棧是 Elixir + Phoenx,所以很多基礎設施並不完善,例如 gRPC 以及 Protobuf 就沒有官方版本的 Elixir 實現,雖然有一些開源項目作者完成的項目,但是都並不穩定,所以最終決定了在 RabbitMQ 上簡單實現了一個基於消息隊列的 RPC 框架,並通過組合的方式對外提供 GraphQL 的接口。

GraphQL 在微服務架構中的實踐


RabbitMQ 在微服務架構中承擔了消息總線的功能,所有的 RPC 請求其實都被轉換成了消息隊列中的消息,服務在調用 RPC 時會向 RabbitMQ 對應的隊列投遞一條消息並持續監聽消息的回調,等待其他服務的響應。

這種做法的好處就是 RabbitMQ 中的隊列承擔了『服務發現』的職能,通過隊列的方式將請求方與服務方解耦,對 RPC 請求進行路由,所以下游的消費者(服務方)可以水平擴展,但是這種方式其實也可以由負載均衡來實現,雖然負載均衡由於並不清楚服務方的負載,所以在轉發請求時的效果可能沒有服務方作爲消費者主動拉的效率高。

最關鍵的問題是,手搓的 RPC 框架作爲基礎服務如果沒有經過充分的測試以及生產環境的考驗是不成熟的,而且作爲語言無關的一種調用方式,我們可能需要爲很多語言同時實現 RPC 框架,這其實就帶來了非常高的人力、測試和維護成本,現在來看不是一個非常可取的方法。

如果我們拋開語言不談,在一個比較成熟的語言中使用 RPC 的方式進行通信,確實能降低很多開發和維護的成本,但是也有另外一個比較大的代價,當業務並不穩定需要經常變更時,內部服務會經常爲對外暴露的 RPC 接口添加額外的字段,而這也會要求最前面的 GraphQL 服務做額外的工作:

GraphQL 在微服務架構中的實踐


每一次服務的修改都會導致三個相關服務或倉庫進行更新,這雖然是在微服務架構中是一件比較正常合理的事情,但是在項目的早期階段這會導致非常多額外的工作量,這也是我們進行第一次架構遷移的主要原因。

去中心化管理的 Schema

這裏的去中心化其實並不是指 GraphQL 對外暴露多個端點,而是指 GraphQL 不同 field 的開發過程去中心化,爲了解決中心化的 Schema 加上 RPC 帶來的開發效率問題並且實踐 GraphQL 官方提供的 Schema Stitching 解決方案,我們決定將 Schema 的管理去中心化,由各個微服務對外直接暴露 GraphQL 請求,同時將多個服務的 Schema 進行合併,以此來解決開發的效率問題。

GraphQL 在微服務架構中的實踐


使用 Schema Stitching 的方式能夠將多個服務中不同的 GraphQL Schema 粘合成一個更大的 Schema,這種架構下最關鍵的組件就是用於 Schema 粘合的工具,在上面已經說到,除了 Javascript 之外的其他語言並沒有官方的工具支持,也沒有在生產環境中大規模使用,同時因爲我們使用的也是一個比較小衆的語言 Elixir,所以更不存在一個可以拆箱即用的工具了。

經過評估之後,我們決定在 GraphQL Elixir 實現 Absinthe 上進行一層包裝,並對客戶端的請求進行語法與語義的解析,將字段對應的樹包裝成子查詢發送給下游的服務,最終再由最前面的 GraphQL 服務組合起來:

GraphQL 在微服務架構中的實踐


GraphQL 前端服務總共包含兩個核心組件,分別是 GraphQL Stitcher 和 Dispatcher,其中前者負責向各個 GraphQL 服務請求 IntrospectionQuery 並將獲得的所有 Schema 粘合成一顆巨大的樹;當客戶端進行請求時,Graphql Dispatcher 會通過語法解析當前的請求,並將其中不同的字段以及子字段轉換成樹後轉發給對應的服務。

在實現 GraphQL Stitcher 的過程中,需要格外注意不同服務之間類型衝突的情況,我們在實現的過程中並沒有支持類型衝突以及跨服務資源的問題,而是採用了覆蓋的方式,這其實有很大的問題,內部的 GraphQL 服務其實並不知道整個 Schema 中有哪些類型是已經被使用的,所以經常會造成服務之間的類型衝突,我們只有在發現時手動增加前綴來解決衝突。

增加前綴是一個比較容易的解決衝突的辦法,但是卻並不是特別的優雅,使用這種方式的主要原因是,我們發現了由於權限系統的設計缺陷 —— 在引入 B 端用戶時無法優雅的實現鑑權,所以選擇使用一種比較簡單的辦法臨時解決類型衝突的問題。

在開發各種內部服務時,我們通過 scope 的方式對用戶是否有權限讀寫資源做了限制,內部服務在執行操作前會先檢查請求的用戶是否能夠讀寫該資源,然後開始處理真正的業務邏輯,也就是說用戶鑑權是發生在所有的內部服務中的

當我們對外暴露的 GraphQL 服務僅僅是面向 C 端用戶的時候,使用 scope 並且讓內部服務進行鑑權其實能夠滿足 C 端對於接口的需求,但是當我們需要同時爲 B 端用戶提供 GraphQL 或者 RESTful 接口時,這種鑑權方式其實就非常尷尬了。

GraphQL 在微服務架構中的實踐


在微服務架構中,由於各個服務之間的數據庫是隔離的,對於一條數據庫記錄來說,很多內部服務都只能知道當前記錄屬於哪個用戶或者那些用戶,所以對於 scope 來說傳遞資源、讀寫請求加上來源用戶就能夠讓處理請求的服務判斷當前的來源用戶是否有權限訪問該條記錄。

這種結論基於我們做的一條假設 —— 微服務收到的所有請求其實都要求讀寫來源用戶擁有的資源,所以在引入 B 端用戶時就遇到了比較大的困難,我們採用的臨時解決方案就是在當前用戶的 scope 中添加一些額外的信息並在內部服務中添加新的接口滿足 B 端查詢的需要,但是由於 B 端對於資源的查詢要求可能非常多樣,當我們需要爲不同的查詢接口進行不同的權限限制,並且在 B 端用戶也不能訪問全部用戶的資源時,scope 的方式就很難表現這種複雜的鑑權需求。

在這種 Schema 管理去中心化的架構中,我們遇到了兩個比較重要的問題:

  1. 用於 Schema Stitching 的組件對於 Elixir 語言並沒有官方或者大型開源項目的支持,手搓的組件在承載較大的服務負載時會有很大的壓力,同時功能也有非常多不完善的地方;
  2. 在內部服務對於整個請求沒有太多上下文的情況下,一旦遇到複雜的鑑權需求時,將鑑權交給內部服務的的設計方式會導致服務之間的耦合增加 —— 微服務之間需要不斷傳遞請求的上下文用於鑑權,同時也增加了開發的成本;

服務網格與 RPC

使用去中心化管理的 Schema 雖然在一定程度上減少了開發的工作,但是在這種架構下我們也遇到了兩個不能接受的問題,爲了解決這些問題,我們準備對當前的技術架構做出以下的修改,讓各個服務能夠更加靈活的通信:

GraphQL 在微服務架構中的實踐


最新的架構設計中,我們使用 linkerd 來處理服務之間的通信,所有的內部服務不在獨立對來源請求進行鑑權,它們只負責對外提供 RPC 接口,在這裏使用 gRPC 和 Protobuf 對不同服務的接口進行管理,所有的鑑權都發生在最外層的 Web 服務中,面向 C 端用戶的 GraphQL 服務以及面向 B 端用戶的 Web 服務,分別會對來源的請求進行鑑權,通過鑑權後再向對應服務發起 RPC 請求,請求的路由和流量的轉發都由 linkerd 完成。

linkerd 是服務網格(Service Mesh)技術的一個實現,它是一個開源的網絡代理,能夠在不改變現有服務的基礎上爲服務提供服務發現、管理、監控等功能,我們在這篇文章中並不會展開介紹服務網格這門技術,有興趣的讀者可以查找相關的資料。

由於面向 B 端用戶可能涉及到較多的查詢請求,並且這些請求非常複雜,我們可以選擇使用從庫的方式同步其他服務的數據,在服務內部實現相應的查詢功能,當然也可以使用數據中心或者倉庫的方式將數據處理後提供給面向 B 端用戶的外部服務。

GraphQL 在微服務架構中的實踐

這種服務組織方式其實更像是對第一版架構的修改,通過引入 linkerd 解決服務發現、路由以及治理的問題,將一些微服務通用的基礎設施交給相對成熟的開源項目負責,而鑑權邏輯被上移到了幾個直接對外暴露的 Web 服務中,內部的服務不再承擔鑑權的工作,雖然在這時依然會存在一次服務接口的改動,會導致多處進行修改的問題,但是從現在來看這是爲了保持服務的靈活帶來的代價。

總結

從剛開始使用 GraphQL 到現在已經過去了將近半年的時間,在微服務中實踐 GraphQL 的過程中,我們發現了微服務與 GraphQL 之間設計思路衝突的地方,也就是去中心化與中心化

作爲一門中心化的查詢語言,GraphQL 在最佳實踐中應該只對外暴露一個端點,這個端點會包含當前 Web 服務應該提供的全部資源,並把它們合理的連接成圖,但是微服務架構恰恰是相反的思路,它的初衷就是將大服務拆分成獨立部署的服務,所以在最後對架構進行設計時,我們分離了這兩部分的邏輯,使用微服務架構對服務進行拆分,通過 GraphQL 對微服務接口進行組合並完成鑑權功能,同時滿足了兩種不同設計的需求。

在架構演進的過程中,我們遇到了很多設計不合理的地方,也因爲沒有預見到業務擴展帶來需求改動,由此導致架構上無法優雅地實現新的需求;最後選擇使用服務網格(Service Mesh)的方式對現有的架構進行重構,也是因爲微服務治理相關的事情應該由統一的中間層來做,自己重新實現服務治理相關的邏輯成本也非常高,使用服務網格已經與 GraphQL 沒有太多的聯繫了,GraphQL 服務也只是作爲一個對外暴露的端點組合內部服務提供的接口,我們也可以將接口換成 RESTful 或者其他形式,這對於整體的架構設計沒有太多的影響;回過頭來看,當項目剛剛啓動時不應該將 GraphQL 接口擺在一個特別重要的位置上,劃分服務之間的邊界並進行合理的解耦纔是影響比較深遠的事情。

到最後,我們會發現在微服務架構中,GraphQL 其實只是整個鏈路中的一環,或許官方提供的一些工具與微服務中的一些問題有關,但是從整個架構來看對外是否使用 GraphQL 其實不是特別的重要,將服務之間的職責進行解耦並對外提供合理的接口才是最關鍵的,只要架構上的設計合理,我們可以隨時引入一個 GraphQL 服務來組合其他服務的功能,其優點在於:

  1. 將多個網絡請求合併成一個,減少前後端之間的網絡請求次數,加快前端頁面的渲染;
  2. 提供了體驗非常好的調試工具 GraphiQL,並可以通過代碼生成文檔,節約文檔的維護成本和溝通成本;

不得不說 GraphQL 作爲一門新興的技術有着非常多的優點,很多公司都在嘗試使用 GraphQL 對外提供 API,雖然目前來說這門技術不算特別成熟,但是卻也有巨大的潛力

相關文章