內容來源:2018 年 6 月 9 日,國內某大型電商公司使用者體驗部門前端開發專家鄧若奇在“杭州第一屆 GraphQLParty—GraphQL與領域驅動帶來的協同價值”進行《基於SPA架構的GraphQL工程實踐》演講分享。IT 大咖說(微信id:itdakashuo)作為獨家視訊合作方,經主辦方和講者審閱授權釋出。
閱讀字數:3838 | 10分鐘閱讀
摘要
主要演講主要介紹基於SPA架構的GraphQL工程實踐,從前端視角來分析GraphQL在整個鏈路中的協同效率問題。
GraphQL的哲學
GraphQL是通過一套Schema來定義領域模型,官方稱之為SDL。它引入了一套型別系統來對模型進行約束,如上圖展示的3個型別。
在實際應用中客戶端將要獲取的欄位通過Schema文字的方式傳送給服務端,服務端接收處理後返回json格式的資料。
GraphQL提供了一套統一模型定義,擁有靈活的按需查詢的能力。還有個容易被大家忽視的特性——通過型別系統提供了模型之間的關係描述,由此可以看出雖然資料以json格式返回,但是實際的應用資料呈現的應該是網狀架構,這使得GraphQL成為描述應用資料的極佳選擇,也是它名字的由來。
架構設計與技術選型
從前端視角看前後端分離
以我個人經歷來看,前後端分離可以分為4個階段。
第一階段前端非同步請求資料介面重新整理區域性UI。
第二階段前端接管View層,這是很多基於MVC的框架採用的模式。
第三、四階段隨著nodeJS技術的興起,前後端的協同效率問題開始受到關注,後續通過引入BFF這層讓前端能夠快速迭代,同時後端下沉為服務或微服務。
上圖是我的技術選型方案。前端為React和relay,relay是基於GraphQL和React的資料整合方案。BFF這層引入的是Egg.js,它是阿里開源的面向企業級開發的web框架。
如何設計BFF
基於REST的分層設計
先來看下傳統的基於MVC模式的web server受理REST請求過程。首先請求進入middleware(中介軟體),在此處理一些通用邏輯,比如使用者登入態判斷或API鑑權。接著進入Router將請求分佈到不同的controller,controller這層呼叫model進行業務處理,然後model再呼叫service層取資料,最後資料在controller層完成封裝並返回。
基於GraphQL的分層設計
引入GraphQL之後Router和controller不再被需要,因為首先GraphQL並不基於endpoint,其次它自身的resolver可以完成資料封裝。此架構中我們引入了兩個模組connector和Schema Loader。connector模組一方面針對GraphQL的一些特點做了特殊快取設計,另一方面制定了前後端協作的規範。
構建schema
這是我最初寫的GraphQL程式碼,借鑑與GraphQL-js的官方repo。現在看來這段程式碼存在2個問題,首先schema應與語言無關而只是模型的描述,其次開發的時候應該遵守設計先行的原則,先確定模型然後再寫程式碼。
理想情況應該是這樣的,先確定模型描述和關係,然後再編寫resolver決定具體處理方案,最後在應用載入的時候使用schema Loader將他們繫結在一起。
鑑權與授權
鑑權和授權的區別在於,鑑權主要針對通用邏輯,是粗粒度的,授權則是定製邏輯,粒度較細。
在GraphQL中授權可能針對的是某個欄位,如圖所示query查詢的是小明的工資,由於工資只能自己檢視,所以要在resolver中加入一段授權邏輯保證查詢者為本人。這裡的設計理念是將授權邏輯封裝在model層,讓它在不同的resolver中得以複用。
快取設計
上圖是資料庫中的兩條使用者記錄,他們互為friend,通過兩段程式碼分別查詢使用者和他們的friend。
這是上面程式碼請求的時序圖,可以看到一共發出了4次請求,但最終獲取到的資料只有兩條。
引入快取之後,第二輪的請求就都可以在第一輪的查詢快取中找到。
還可以再進行優化,將兩段程式碼的第一輪請求合併在一起,這才是最優解。
為實現以上的效果,首先需要使用快取。然後還要有請求佇列,將同一個週期中的所有load或query全部快取起來,然後在下一個週期中合併成一個請求放出。最後是批量處理的能力,用於處理附帶批量key的請求。
Facabook提供了一種批量處理的解決方案DataLoader,它接收一個用來處理批量key的方法,每個DataLoader的例項下方都有一個cache。最初的需求在引入DataLoader之後程式碼如下圖所示。
這段程式碼的最終效果是把三個請求合併成一個請求,在後端執行的是一條SQL語句。
不過在實際結合關係型資料庫使用的時候還是略微有些複雜。一般我們對關係型資料庫進行查詢的時候即會依據PK(primary key)也會依據UK(unique key)。如上程式碼關於使用者的查詢既可以通過ID也可以通過Mobiles,這就不得不例項化兩個DataLoader例項。由於是不同DataLoader例項,所以用的是不同的快取,導致快取利用率不高。
為此我編寫了rdb-dataloader模組,讓PK和UK的查詢都在同一個例項中,達到複用快取的目的。注意紅框中的程式碼,這裡先通過name查詢出一條記錄,然後對這條記錄經由ID做第二次查詢,顯然第二次查詢不會發出,而是會使用快取。方案的核心在於快取記錄的全部欄位,資料量的控制應該由分頁邏輯來關心。
DataLoader是請求級別的快取,請求進來的時候初始化DataLoader例項,請求結束後就銷燬。
前後端如何協作
Relay
作為一名前端在使用GraphQL的時候首先要是思考的是對瀏覽器的效能有何影響,這也是接下來進一步挖掘relay的原因。
在使用React元件時,最普遍的訴求就是需要非同步取資料,然後對資料進行渲染,常規的做法是在componentDidMount中新增非同步取數的邏輯。因此實際應用中隨著頁面層級的深入,載入時間會隨之變長,子元件必須等待父元件的資料載入完之後才能開始渲染。
對此最簡單的優化方案是將所有元件需要的資料全部放在第一次請求中,如上所示。可是在後續要新增需求的時候我卻搞出了bug,因為此時已經分不清哪些欄位對應哪些元件。
再來看下relay的實現方式,relay有一個creatFragmenContainer方法,可以向該方法傳入React元件,然後通過GraphQL的scheam返回relay component。這種方式不僅實現了依賴注入也沒有打破元件的資料封裝性。
在最初的query中嵌入上面的fragment後,我們就知道了欄位是由哪個元件發出的。
上圖是一段虛擬碼,表示的是relay底層的協作方式。第一個物件是部落格,有內容,也有作者,但是這個作者是一個 user 型別,部落格不會直接儲存 user 的全部資料,而是通過引用的方式引用到第二個物件。同理評論的作者和它屬於哪個部落格,同樣是用引用的方式。這樣的好處在於只要物件發生改動,所有引用該物件的地方都會同步更新。
請注意圖中1、2、3這幾個數字,他們是全域性唯一的快取key。由於所有的資料都在快取中,所以不能再使用資料庫中的ID,否則對於ID相同的部落格和使用者就無法處理了。唯一ID的實現有各種方案,可以使用base64(type+”:”+id)這種形式。
全域性ID需要後端來配合,定義fromGlobalId和toGobalId這兩個方法。fromGlobalId負責將relay發請求時帶來的ID解包成資料庫ID,toGobalId負責返回的時候對資料庫ID裝包。
客戶端將schema文字傳送到服務端,然後由服務端進行處理的這一過程中,文字量其實是相當大的,對於網路環境不好的使用者體驗會非常差。
那麼能不能直接傳送query id,在服務端通過id解析出文字呢?所幸relay提供了這種方式,在構建relay指令碼的時候會給模組注入hash標識當前schema,通過這個hash前後端就對應起來了。
需要解決的問題
首要解決的是DOS Attack,說白了就是上圖這種巢狀攻擊,請注意這並不是死迴圈,這只是一個攻擊者故意通過你的 query 無限寫的非常複雜的巢狀,讓你的伺服器消耗殆盡。顯然設定query文字長度和query白名單無益於解決問題,正確的做法是控制query的深度。
對於rate limiting限流,由於GraphQL並非是基於Rest,所以不能通過限制路由每分鐘的呼叫次數來解決。而應該是限制讀寫操作,上面的例子表示的就是每分鐘最多隻能新增20個評論,通過directive實現。
不過實際上限流的實現成本是比較大的,如果要專門實現限流功能,需要依賴第三方的一些服務。