Yuanlin Lin

Blog

GraphQL x Go! 淺談 gqlgen 為開發帶來的便利性

Yuanlin Lin 林沅霖

2022-05-24

距離開始用 Go 開發後端已經有一段時間了,做了很多大大小小的專案。一開始是用 Go 社群人氣相對較高的 GIN 作為後端框架,開發 RESTful 的 API,但後來在接觸並瞭解了 GraphQL 以後,發現這個東西和我對 API 設計的想法好像更近,所以開始找 Go 後端開發有沒有什麼 GraphQL 的框架,然後找到了 gqlgen 這個框架,他和以往的 RESTful 後端開發模式有著很大的差異,因此想寫這篇文章分享他的不同之處,以及他是如何為後端開發帶來便利的。

封面圖 Unsplash @ Pankaj Patel

什麼是 GraphQL

為了避免閱讀本文的讀者還沒聽過 GraphQL ,或是沒有深入瞭解過,這邊簡短的對 GraphQL 做一個介紹。

在傳統的 HTTP 通訊協定中,有 GET, POST, DELETE, PUT ... 等這種所謂的方法 (Method),而我們通過 方法 + 路徑 的方式組合出了一個前端想要告訴後端的查詢或操作,例如:

GET /users

代表我們想要 取得 所有使用者的資料。

PUT /users/ken20001207

代表我們想要 修改 使用者中,一個名叫 ken20001207 的使用者。


除了方法和路徑,我們還可以用參數 (query params) 來對查詢做出一些補充,例如:

GET /users?limt=10

代表我想要查詢使用者的資料,但最多 回傳 10 筆資料 就好。


這樣的設計看似很完美,不僅功能完整且很直觀,學習成本很低。但如果我們考慮一些實際的應用場景,比如說每個 User 是長這樣的:

type User { id: ID name: String! bio: String! email: String! friends: [User!]! }

並且給出這樣的假設: id, name, bio, email 是直接儲存在資料庫中的資料,但 bio 資料的內容可能很長很長。 friends 這個欄位不是儲存在資料庫中的,而是需要透過 SQL 的 JOIN 或 MongoDB 的 lookup 來進階查詢的。

這個時候就遇到一個問題,在 GET /users 我回傳的 User 需要包含 biofriends 這兩個欄位嗎?如果包含的話,後端需要花更多的時間幫我查詢,而且 response 的大小會變的更大。


那麼我們換個想法,讓前端在請求的時候可以自己決定需要哪些欄位,如何?比如說我在 GET 的時候加入這個參數:

GET /users?field=id&field=name&field=bio&field=friends

field=xxx 告訴後端我需要哪些欄位,不需要的就不用幫我查了。嗯 ... 這看似可行,但 friends 這個欄位裡面的類型也是 User 呀!那這些 friends 欄位內的 User 又需要回傳哪些欄位呢?

GraphQL 的設計幫我們解決了這個問題。首先,我們直接不管原本的 GET, POST, PUT ... 那些 method 了,只有 QueryMutation 兩種:Query 代表我想要和後端 查詢 一些東西, Mutation 代表我想要讓後端 執行 一些操作。例如:

query { users { id name } }

這樣就代表我想要查詢使用者的資料,但只需要 idname 這兩個欄位。

query { users { id friends { id name } } }

這樣就代表我想要查詢使用者的資料,但只需要 idfriends 這兩個欄位,而 friends 裡面的 User 只需要給我 idname 就好。

query { users(limit: 10) { id friends(limit: 5) { id name } } }

參數也沒問題,而且更加強大!是不是很方便呢?


因為篇幅的關係就不再繼續深入介紹 GraphQL 的基本概念和語法,有興趣的讀者可以去 GraphQL 的官方介紹 瞭解其他更多的特性。

什麼是 gqlgen

https://github.com/99designs/gqlgen

知道什麼是 GraphQL 以後,我們來瞭解一下 gqlgen 這個框架,他可以幫我們用一種方便快速的方式實現一個 Go 的 GraphQL API 後端。

每一個 GraphQL API 都會有一份自己的 GraphQL Schema,用 GraphQL 語法編寫而成,裡面定義了所有這個 API 的類型以及 Query 和 Mutation 的接口定義。

type Query { users: [User!]! } type Mutation { updateUserEmail(id: ID!, email: String!): User } type User { id: ID! name: String! email: String! friends: [User!]! }

我們必須先定義好這個 Schema,然後才開始根據他的定義進行開發,也就是所謂的 Schema-Driven Development

在使用 gqlgen 的時候,這是一個非常重要的步驟。因為 gqlgen 會讀取你的 Schema 定義,然後 自動生成 根據你這份 Schema 設計定義出來的一套程式碼模板,我們只要在模板內實作對應的邏輯即可。這個 自動生成 的步驟也是為什麼框架名叫 gqlgen 的原因。

舉例而言,對於上面的範例 Schema,可以生成出這樣的模板:

func (r *userResolvers) Users(ctx Context) ([]*User, error) { // 在這裡實作如何從資料庫獲取使用者資料的邏輯 return /* ... */ } func (r *userResolvers) UpdateUserEmail(ctx Context, id string, email string) (*User, error) { // 在這裡實作如何更新使用者 Email 的邏輯 return /* ... */ }

當我們完成這個模板內的 resolver 邏輯實作以後,gqlgen 就可以自動地幫我們根據前端傳入的 Request 來呼叫不同的 resolver,然後把 resolver 執行完畢回傳的的資料組合起來,再傳送回去。


這時候讀者應該想到了一個問題:我們並沒有解決 Userfriends 欄位需不需要回傳的問題呀,即使前端用 GraphQL 發來請求說不需要 friends,我在 userResolver 裡面不是仍然計算並回傳了嗎?

這就是我覺得 gqlgen 這個框架設計的很精妙的地方了。

從上面的範例來說,Go 程式碼的 User 這個 type 實際上是 gqlgen 幫我們根據 Schema 生成的。然而,如同上面所說,這個 User 類型其實所有的欄位分為兩種:直接存在資料庫的、需要額外計算的。

我們可以 不使用 gqlgen 自動生成的 type 定義,自己在 Go 程式碼寫一個 User type 定義。當 gqlgen 發現你自己寫了一個 type,他就會改用你定義的這個 type。

但我們自己在 Go 裡面定義的 User type 是這樣的:

type User struct { ID string Name string Email string }

這個時候 gqlgen 會發現,你定義的這個 type 和 schema 定義的 User 不一樣!明明 Schema 有 friends 這個欄位,但這個 type 裡面卻沒有 ... 所以他生成的程式模板就會要求你補上這個 User 的 friends 欄位該如何 resolve:

func (r *userResolvers) Users(ctx Context) ([]*User, error) { // 在這裡實作如何從資料庫獲取使用者資料的邏輯 // 但回傳的 User 不需要包含 friends 欄位! return /* ... */ } func (r *userResolvers) Friends(ctx Context, obj *User) ([]*User, error) { // 在這裡實作如何「獲取某個 User 的 friends」的邏輯 return /* ... */ }

這時候他就幫你把 「可以直接從資料庫拿到的欄位」「需要額外計算」 的欄位切開來了,我們可以分開實現這兩個邏輯,然後 gqlgen 會根據前端的 query 請求自動呼叫需要的 resolver,大大降低了後端開發時對於這種複雜 Schema 定義在實現上的成本。

不會有效能問題嗎?

大家看完上面的介紹,想必已經迫不及待的要提出質疑了:

質疑:這樣寫不會有效能問題嗎?原本我一次 SQL 指令就能算好的東西,你寫成這樣 gqlgen 不是要拆成 N 次,甚至 N^2, N^3 次的 SQL 指令才能算完?

是的,這也是我一開始聽到這個方法的第一個反應,這個作法想必會造成嚴重的效能問題!然而沒想到 gqlgen 早已有準備,在官方文檔給出了解釋:

Optimizing N+1 database queries using Dataloaders | gqlgen

官方給出的解決方案是用 Dataloader 的概念來解決這個問題,有興趣的同學建議點進去上面的網址看看官方的舉例,我這裡只是簡單的做個說明。

簡單來說就是,對於你這 N 個資料庫的 Query,Dataloader 會 ...

  1. 把重複的部分聚合在一起(查一次就好,然後回傳一樣的內容回去給所有人)
  2. 聚合在一起以後,再把相同的類型聚合在一起(查一次就好,再根據你要什麼資料,從查詢結果挑出你要的部分)

經過這樣兩次聚合,實際上 N+1 問題已經被做到很大程度的優化了。我們甚至可以在 Dataloader 層加入 Cache 邏輯,再更進一步的優化效能。

結語

今天這篇文主要是想推薦 GraphQL 和 gqlgen 給大家,雖然自己也還沒有更深入的瞭解其他功能和原理,但單純就一個框架使用者的角度,這個 Workflow 是自己近期很滿意的一套解決方案。

也希望閱讀這篇文章的各位大大如果不同的觀點,可以來 Chief Noob 開發社群 | Discord Server 和我們一起分享與討論 👍

分享你的看法

暫無留言,你可以成為第一個留言的人!

author-avatar

關於作者

Yuanlin Lin 林沅霖

台灣桃園人,目前就讀浙江大學,主修計算機科學與技術,同時兼職外包全端開發工程師,熱愛產品設計與軟體開發。