GraphQL 在酒店系統上的實踐

關於我們 Node 服務產生了什麼問題,爲什麼會出現這些問題,以及爲何需要採用 GraphQL 去解決這些問題,是一個值得探究的過程。下面,我將從服務架構入手,簡單介紹一下項目背景,而後通過幾個案例,讓大家更形象的理解我們現在的問題是如何產生的。

1.1 服務架構簡介


左邊方框內屬於我們前端團隊的服務,右邊屬於後端服務。這裏只是比較粗糙的架構,省略了一些負載均衡等細節,對於我司國內酒店服務來說,這裏的圖示就足以解釋說明我們的問題。

而我們 Node 組,是處於承接上下游的一個位置,是屬於直接服務於前端的後端,也就是所謂的 BFF 層,關於這個架構的形成過程,我後面會展開解釋。

就目前而言,我們的主要職責是多端適配,UI 適配,版本控制,下發一些 AB 實驗,日誌收集等。

雖然我上面說了許多職能,但是我認爲我們最核心的職能,其實就是負責給客戶端同學輸送數據,那麼我們輸送數據的過程中會產生什麼問題?爲什麼會產生這些問題,下面讓我來舉例說明一下。

1.2 案例一:數據定製困難

我們都知道,我們的服務最早都是基於 PC 端而設計的,而隨着時代的發展,移動端的各個終端變得越來越豐富,包括現在的 app、小程序、touch 等。所以如果不是拆分服務,我們註定要面臨多端適配的一個演進過程。這其中一個典型例子,就是對於同一接口,不同端的需求可能是不同的。拿我們酒店系統舉例:


對於酒店列表頁展示:
在 PC 端和 APP 端我可以把一家酒店的很多信息都放到列表中展示,每一家酒店就會包含比較完整的酒店模型的字段,然而在一些情況下,比如小程序的這種場景下,可能我需要每家酒店只展示圖片,報價,名稱三個字段,客戶端這種多變的需求是非常常見的,但是我們的接口都是同一個。

那麼,我們 APP 端的代碼大概會是這樣的形式:

hotelInfo: {
    name: "酒店",
    price:232,
    imgUrl:"img.jpg",
    tags:[{
        name:"親子家庭"
    }],
    score:4.2,
    rank:"XX酒店排名第X名"
}
複製代碼

它將會把酒店模型下的所有字段直接返回給客戶端。

而對於小程序端,我們就需要增加一個參數,判斷請求來源是小程序,再去將模型中不顯示的字段單獨刪除,才能達到這種字段級的控制。僞代碼如下圖:

if ( source === "XIAOCHENGXU" ) {
    hotelInfo.tags = undefined;
    hotelInfo.score = undefined;
    hotelInfo.rank = undefined;
}
複製代碼

假設現在我的小程序改版了,我新版本可能又想展示四個字段,老版本還是隻展示三個,這時候我們就面臨了版控問題。

作爲服務端,我們有兩個選擇,不去管是新老版本的差異,冗餘的返回四個字段,這樣帶來的好處就是開發簡單,只增不減,沒有風險。而另一種選擇就是加一段這樣的代碼:

if ( source === "XIAOCHENGXU" ) {
    hotelInfo.tags = undefined;
    if( version <120 ){
        hotelInfo.score = undefined;
    }
    hotelInfo.rank = undefined;
}
複製代碼

這樣針對不同的版本,我們就可以保證返回不同的定製字段了,好處就是不會有冗餘數據的傳輸。壞處也顯而易見,讓服務端代碼的可讀性逐漸下降,凌亂鬆散,可維護性也會越來越低。

假設我們上面這種需求,一週內出現 5-10 次,分配給不同的人開發,我們的字段改動有時就會非常頻繁,而目前的接口文檔還是靠 wiki 或 yapi 人工維護,這就使得我們很難保證這些同學都及時的更新接口文檔,日積月累,服務與文檔就產生了許多難以追蹤的差異。

對於客戶端的同學來說,因爲這種文檔與真實返回數據的不一致性,也直接導致客戶端同學也許要不停的與服務端多次反覆溝通,無形中增加了許多溝通成本,從而降低了整體的迭代效率,開發效率。

從服務端的角度講,同一個接口,針對 restful 架構下,我們的解決方案通常就是根據客戶端的訴求去增加判斷,然後在服務端去過濾一遍數據,把不需要的字段刪除,或把相同字段返回不同的值。然而由於我們每一個酒店的字段都非常多,要去做這樣的開發顯然對服務端增加了很多工作量,而且我這裏只舉了一個客戶端的例子,而各種客戶端如果每一個都有自己特殊的字段定製需求,我們在服務端就需要增加各種各樣的判斷,然後決定的也許只是一兩個字段的返回情況,其本質操作其實都是大同小異,但卻要讓服務端同學的開發越來越困難和難以維護。

這種做法在 PC 時代,產品迭代相對頻率較低的那個年代,是足夠應付的,然而移動端到來後的時代,需求迭代頻率明顯升高,客戶端種類也逐漸變多,各種各樣的屏幕適配需求,字段的定製需求都不盡相同,加之每個客戶端可能還涉及到很多的版本更迭。開發效率難免不下降,運維難度也增大,同時,由於歷史更迭對字段的下線頻率非常之低,現在線上也逐漸形成了許多冗餘的字段。這些字段內容也因爲版本更迭時對文檔維護的不及時等歷史原因,使得目前團隊成員開發時也不敢輕易刪除,總是自然而然的選擇增加字段的保險做法,久而久之就形成了惡性循環,字段越來越多,可以說返回體中的冗餘字段也給傳輸性能帶來了一定負面的影響。

根據案例一總結的問題可以概況爲:

1.3 案例二:多請求拼接

PC 上的酒店詳情頁:

我們可以看到,PC 端酒店詳情內,主要包括了房型、交通、詳情、點評這四類資源,我們目前是通過兩個接口拼接出來的。而在 APP 端,同樣的酒店詳情頁,我們因爲瀑布流的佈局,可能除了以上內容,又多返回了周邊推薦、住客秀兩塊資源。


對於這種不同資源拼接在同一個頁面的需求。基於 restful 的接口,我們通常有兩種處理方式:

第一種方式,我們可能會在原基礎上,再增加兩次請求,分別請求住客秀資源,以及周邊推薦資源,再對數據進行處理。

// 方法1:
/api/detail {交通,點評}
/api/detailprice {房型,詳情}
/api/clientShow {住客秀}
/api/recommendation {周邊推薦}
複製代碼

這種方式可以準確的請求到 APP 端想要的數據,各個接口職責明確,不用做修改,但是需要構造多次請求,使得客戶端代碼變得臃腫。

第二種方式,也就是根據不同的端,去變換我們某一接口的返回內容,在服務端去拼接不同資源,在 APP 的情況下,我們去拼接這兩個資源,而這兩個資源本身可能也是要在後端發兩次請求而得來。

// 方法2:
/api/detail {交通,點評}
/api/detailprice
if( source === "APP" ){
    return {房型,詳情,住客秀,周邊推薦}
}
return {房型,詳情}
複製代碼

這種方式的好處就是客戶端減少了發請求的次數,但是它同時使得原本可以無關係的資源出現了強耦合,與 restful 設計時的初衷實際上相違背,在後續功能迭代和開發中,帶來了隱患。比如我們如果修改了周邊推薦裏面的一些內容,也許他就會直接導致 detaiPrice 這個接口也需要有一個爲了透傳而產生的工作量。同時,這種做法也讓 detailPrice 變得越來越臃腫複雜,提高了這個接口後續維護的困難。

1.4 問題歸納

那麼,我們來總結一下以上案例中遇到的主要問題,並試圖進行根本原因分析。我們在項目管理中,查找問題根本原因時有一些工具(比如魚骨圖、5why 分析、帕累託圖)可以直觀的幫助我們分析問題,如果感興趣的同學可以去了解一下 PMP,ACP 的相關知識,這裏我就不做展開。下面我就利用魚骨圖這個工具來分析一下我們這些問題的根本原因。


我們可以看到,我們右側的魚頭部分,是我們面對的表面問題,而每一根魚骨,反應了一個造成這個問題的主要原因,這些原因也許是不同緯度的原因。
比如我們項目目前運維困難,開發效率低下,性能下降的問題,它可能是由於接口文檔方面,版控方面,多次請求設計方面,冗餘數據方面這幾個方面導致的,進而我們發現這些問題都是基於 restful 接口設計規範,在不停的需求迭代,衆多的客戶端頁面變更中,逐漸產生且難以避免的問題,我們可以認爲這是 restful 接口風格設計本身的問題,也就是說,導致這一系列問題的根本原因是需要找到更好的接口設計範式。

2.1 GraphQL 簡介

經過調研,我們發現,在 2012 年,facebook 就開始研究了一個叫 GraphQL 的技術解決方案,去解決服務端餵食給移動端數據的效率問題。他們的項目產生背景其實和我們所面臨的過程十分類似,也就是始於 PC 端的服務,在移動端盛行後愈加豐富的需求變化下,傳統 restful 設計效率下降後,他們想去解決這個效率問題,所以做出了這個方案。區別在於,facebook 很早就直接研發了這套標準,並且經過多年的實踐,他們已經將這種模式錘鍊的十分成熟了。而我們作爲學習者,還是需要一個思想上的轉變過程的。

那麼怎麼理解 GraphQL 呢?

字面介紹:GraphQL 是一種 API 查詢語言,也是一種用於實現數據查詢的運行時,或者說它只是一種規範。它通常基於 Http 協議。

光看定義,可能還是很抽象,GraphQL 到底是什麼呢?它怎麼集成在我們這個已經有些不堪重負的系統中呢?他是如何改變我們的開發模式呢?

2.2 Restful 到 GraphQL

通過我們上一章節的分析,我們已經看的 restful API 的設計風格在長週期,高速迭代的需求變化下逐漸顯現出來的,一些難以避免的問題。下面我們就來看看這些問題在 GraphQL 下會如何呢?


我們從官方文檔上所看到的,GraphQL 具有:

精確的數據定製能力,也就是說客戶端可以決定想要獲取的數據,不多不少,精確定製。對應我們左邊的第一二個問題,似乎迎刃而解。

自動化的文檔生成能力,這點可以大大減少我們開發人員浪費在文檔維護上的時間,解放精力,專注於業務開發上。

自由組合的資源能力,這點的意思就是,比如對於 restful 我們每一個 endpoint 都對應着一個資源,然而 GraphQL 下就只有一個 endpoint,我們想要查詢什麼,我們就直接自由組合不同的資源拼湊在一個請求裏,同一個頁面就不存在案例二中因資源組合而產生的請求合併設計困擾,避免了多次請求的問題。

2.3 GraphQL 的實施

下面,我們就來談談這個 GraphQL 的規範,是如何在我們現有系統中去落地並解決問題的。

第一步,就是定義 schema。

那麼爲了簡化問題,還是以酒店系統的酒店模型爲例,下圖是酒店信息的對象,而我們定義 schema 所使用的 SDL 語言非常簡單,作爲強類型語言,它的寫法與 TS 非常相像,所以說幾乎沒有學習成本。

hotelInfo:{
    name: "xx酒店",
    price:232,
    imgUrl:"img.jpg",
    tags:[{"name":"親子家庭"}],
    score:4.2,
    rank:"XX酒店排名第X名"
}
複製代碼

下圖是將這種模型,抽象成 GraphQL 中 SDL 後的寫法:

type Hotel {
    name: String!
    imgUrl: String!
    price: Int!
    tags: [Tag]
    score: Float
    rank: String
}
 
 
type Tag {
    name: String!
}
複製代碼

可以看到,定義 schema 的過程,就是一個視圖模型整合過程。

第二步,編寫 resolver 解析器。

這一步其實很好理解,當我們在服務端定義好了服務於業務的視圖模型的全部字段以及類型後,我們自然而然的需要編寫如何獲得這些數據的方法。這一步其實對於已經存在的服務來說非常簡單,你只需要在 resolver 中指明你定義的這個模型中的數據是從什麼地方獲取到的就可以了,而這些數據的獲取方式,可以是異步函數,同步函數計算所得,讀緩存得來,讀數據庫得來,調用三方 API 得來。可以說 GraphQL 這裏就是做了一個聚合的工作,把更上游的數據,也許是微服務或是 restful 服務,在 GraphQL 層聚合起來。一個 reslover 的僞代碼:

hoteReslober:( id: String ) => {
    return db.getHotel(id);
}
複製代碼

第三步,改造客戶端的請求方式。

對於客戶端也就是調用方來說,我們最大的變化,就是需要我們不再只是被動的處理某個接口返回的數據了,不會再面對 over-fetch 和 under-fetch 的問題。我們只需要描述清楚,我們在這個頁面想要的那些內容,然後專注於數據處理,視圖構造就可以了。

下面是 APP 端的一個 query 例子:

query: hotel (id: String ) => {
    name
    imgUrl
    price
    tags{
        name
    }
    score
    rank
}
複製代碼

2.4 GraphQL 的實踐改造過程

若要充分解釋清楚我們項目的演進過程,我想在此還需要回顧一下歷史架構演變過程。

我們可以看到,最開始的時候,我們的服務由客戶端直接調用一個 Java 服務,那個時候的客戶端還是 PC 爲主,後來隨着時代的發展,客戶端的種類變多了,Java 這層就要負責許多業務以外的視圖層的需求改動。由於這層服務本身也有一些業務層面的處理,它乾的工作就越來越多,越來越雜,在一段時間裏,這個服務常常成爲影響開發進度的瓶頸,積壓的需求也就越來越多。而爲了趕工期,又要做到各種兼容,就更容易開發出一些 bug,產生一些失誤,有些不堪重負。於是,隨着時間的推移,大前端組決定遷移走這個 Service 中一部分視圖層相關的工作,而 Node 組本身都是由原前端同學組成,所以自然而然的選擇了 Node.JS,作爲中間層服務技術方案。


服務其實已經存在了我第一章所提到的種種問題,雖然我們想通過 GraphQL 去改善,但是在我們同時面臨遷移大量代碼,不停兼顧新功能的上線,重構成 GraphQL 服務這三項挑戰的時候,我們不可能一蹴而就。於是,爲了不影響外部業務,又能夠應用上這個技術,我們 Node 組先是進行了一個服務分層,將內部服務分成了兩層。對外仍然是黑盒的 restful API,內部主要的負責視圖層控制的大量邏輯,都改造成了 GraphQL 的服務,由自己的上層服務直接調用。


由於我們改造的工期較長,在這一步實現的過程中,我們小程序組也同步增加了一層自己的 GraphQL 服務,這層服務再轉發調用我們 Node 服務的接口,小程序內部就已經在我們改造的同時,也完成了逐步的改造,並上線使用了 GraphQL 一段時間了。我們可以看到,GraphQL 其本身其實是很薄的一層架構,它的一般處理耗時在 20ms 以內,實際上如果你是 restful 的微服務架構,它主要充當的就是 gateway 的數據聚合層。對於已經成熟的業務,是可以平滑的進行逐步升級的。架構層面去應用它也是十分靈活的。不用拘泥於直接把服務一次性全面改造到位。


然後我們服務端將會進一步直接提供對外的 GraphQL 服務,屆時,客戶端也可以同步開展改造調用方式的開發。


下面是小程序利用 GraphQL 服務,對於同一接口調用,所節省的流量對比。我們可以看到,對於這種不同頁面,同一資源的定製,如果利用得當,可以大幅減少我們的冗餘數據,減小返回體帶來的傳輸速度提升也是十分明顯。


而對於開發流程。
傳統流程下,我們是這樣的。


前後端同學要先定義接口,然後 mock 數據,分頭開發,自測,然後再聯調,自測,提測。期間聯調可能要爲了開發環境,版本約定,參數改變,接口文檔返回不一致等問題進行數次溝通,浪費許多時間在開發以外。

而 GraphQL 下呢?


在 schema 裏定義好了屬性名和類型後,那麼我們服務端同學就可以直接專注領域服務的開發了,開發完了可以直接升級,做到與前端基本解耦。
前端同學直接可以使用這個服務,不需要再和後端同學約定版本號讓作爲後端判斷參數了,只需要查看最新的文檔,編寫好新的 query,就可以開始自測了。

兩邊同學都極大的縮減了中間無謂的聯調,約定參數的溝通時間。

2.5 在實踐中看到的一些問題

雖然 GraphqQL 具有以上諸多優點,但是我們都知道沒有萬能的銀彈,下面我們來看看 GraphQL 開發中常見的問題。

傳統的 restful API 在系統設計師更加簡潔清晰,各個 endpoint 的職責會十分明確,減少了各個模塊的互相依賴和干擾,所以在 GraphQL 的系統設計中,需要特別注意模塊之間的耦合問題,不能把各個模塊都攪合在一起,這對設計上提高了一定的難度。作爲已經存在的系統,你可以在遷移時候按照純 restful 風格去設計各視圖模型的關係,讓 GraphQL 繼承 restful 的優勢。

性能問題是後端最關注和擔心的一個問題,會不會產生額外的數據庫查詢?如何優化查詢性能?這些都給我們提出了優化和設計上的挑戰。如果你是像我們一樣的 Node 中間層,就不會面臨這種問題,把數據庫本身的優化仍然交給更後端的服務去做,避免 sql 的穿透。

對於已經存在的大型系統,如何完成遷移,是否需要改變語言?是否需要改變框架?如何平滑的實現遷移?這都是我們面臨的直接成本,如何以最小的成本,最低的風險去實現改造,是我們需要着重思考的問題。

由於客戶端有很自由的查詢方式,所以服務端不再是直接控制返回字段,而是需要限制請求方的一些例如查詢深度,查詢頁數限制等。關於安全由於與 restful 方式略有不同,設計更安全合理的請求方案,也是一個值得思考的問題,我們目前採用的方案這裏就暫不展開了。

所以總的來說,並不是我們就一定要用 GraphQL 完全替代 restful API,通常很多情況下都可以採用 GraphQL 調用 restful API 的改造方式。

我們大前端應用 GraphQL,其意義不僅僅是去解決我們前端團隊的效率問題,我們希望這次嘗試應該具有更深遠的影響。

首先,這次經驗告訴我們,GraphQL 適用的場景。

我們認爲它不是 restful 的替代品,而是在我們目前這種,服務端提供視圖層的服務,也就是 BFF 層,需要進行多端適配的架構下,我們去應用 GraphQL,才能發揮它更多的價值。

而且對於已經存在的大型系統,我們不能盲目的大刀闊斧的直接重構,可以靈活的應用架構上面的微調,去逐步實現我們最終應用 GraphQL 的改造,以達到用最低風險,最小成本去完成改造。

對於內部許多本身就是 restful 的服務,我們並不一定要去改造它,完全可以在外層用 GraphQL 服務去調用 restful 服務,保持 restful 服務開發在後端的優點。

我們雖然有很多服務都是公司內部多年積累下來的遺產,有的仍然很好用,但有的服務其實技術層面和業務層面上已經比較老舊了,會帶來很多難以避免的遺留問題。

而我認爲公司系統是一個有機體,每個系統內部需要不斷地自己更新重構,這個過程不僅僅提升各內部系統的性能,而可以通過各個環節效率的提升達到 1+1>2 的效果,從而讓公司整體機能達到質變。

所以對於這種已經經過實踐檢驗的技術方案,我個人是非常推崇的,如果我們有新的系統,符合我所說的需要經常性變更頁面需求,且擁有一個 BFF 層去聚合處理數據,那麼,請放心大膽的擁抱變化,選擇 GraphQL 吧。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6959369588349861901