Go 之 GraphQL 的踩坑指南
1.REST vs GraphQL
REST 是一個很流行的前後端交互形式的約定,在這種約定下,前端專註於頁面,同時與後端進行數據交互;而後端則專註於提供 API 介面。 RESTful API 開發中遇到的問題:
- 擴展性 :隨著 API 數量的不斷增加,RESTful API 的介面會變得越來臃腫。
- 無法按需獲取 :一個返回 id、name、age、 city、 addr、 email 的介面,如果僅獲取部分信息,如 name、age,卻必須返回介面的全部信息,然後從中提取自己需要的。壞處是不僅會增加網路傳輸量,而且不便於 client 處理數據。
- RESTful API 不好處理的問題 : 比如確保 client 提供的參數是類型安全的,如何從代碼生成 API 的文檔等。
- 一個請求無法獲取所需全部資源 :例如 client 需要顯示一篇文章的內容,同時要顯示評論,作者信息,那麼就需要調用文章、評論、用戶的介面。壞處是造成服務的的維護困難,以及響應時間變長 。RESTful API 通常由多個端點組成,每個端點代表一種資源。所以當 client 需要多個資源是,它需要向 RESTful API 發起多個請求,才能獲取到所需要的數據。
Facebook 開源的 GraphQL ,在 Twitter、GitHub 等大公司已做實踐,GraphQL 是一種數據查詢語言,提供以下的性質:
- 請求你的數據不多不少 :GraphQL 查詢總是能準確獲得你想要的數據,不多不少,所以返回的結果是可預測的。
- 獲取多個資源只用一個請求 :GraphQL 查詢不僅能夠獲得資源的屬性,還能沿著資源間進一步查詢,所以 GraphQL 可以通過一次請求就獲取你應用所需的所有數據。
- 描述所有的可能類型系統: GraphQL API 基於類型和欄位的方式進行組成,使用類型來保證應用只請求可能的類型,同時提供了清晰的輔助性錯誤信息。
- 使用你現有的數據和代碼: GraphQL 讓你的整個應用共享一套 API,通過 GraphQL API 能夠更好的利用你的現有數據和代碼。GraphQL 引擎已經有多種語言實現,GraphQL 不限於某一特定資料庫,可以使用已經存在的數據、代碼、甚至可以連接第三方的 APIs。
- API 演進無需劃分版本: 給 GraphQL API 添加欄位和類型而無需影響現有查詢。老舊欄位可以廢棄,從工具中隱藏。
2. GraphQL 介紹
官網給出的定義:「 GraphQL 既是一種用於 API 的查詢語言 也是一個滿足你數據查詢的運行時 。GraphQL 對你的 API 中的數據提供了一套易於理解的完整描述 ,使得客戶端能夠準確地獲得它需要的數據 ,而且沒有任何冗餘,也讓 API 更容易地隨著時間推移而演進,還能用於構建強大的開發者工具」。
- API 不是用來調用的嗎?是的,這正是 GraphQL 的強大之處,引用官方文檔的一句話
ask exactly what you want
。 - 本質上來說 GraphQL 是一種查詢語言。
- 上述的定義比較抽象很難理解,實踐過 GraphQL 的使用後能夠更加深刻的理解。
- 數據在本質上是分層的,同時也是關係圖。GraphQL 的核心目標就是表達這種關係圖。
在 GraphQL 中,通過定義 Schema 和聲明 Type 來達到上述描述的功能,需要學習:
- 對於數據模型的抽象是通過 Type 來描述的 ,那麼如何定義 Type?
- 對於介面獲取數據的邏輯是通過 schema 來描述的 ,那麼如何定義 schema?
2.1 如何定義 Type
對於數據模型的抽象是通過 Type 來描述的,每一個 Type 有若干 Field 組成,每個 Field 又分別指向某個 Type。
GraphQL 的 Type 簡單可以分為兩種,一種是 scalar type(標量類型) ,另一種是 object type(對象類型)。
2.1.1 scalar type
GraphQL 中的內建的標量包含 String、Int、Float、Boolean、Enum,標量是 GraphQL 類型系統中最小的顆粒。
2.1.2 object type
僅有標量是不夠抽象一些複雜的數據模型,需要使用對象類型。通過對象類型來構建 GraphQL 中關於一個數據模型的形狀,同時還可以聲明各個模型之間的內在關聯(一對多,一對一或多對多)。
一對一模型展示:
type Article { id: ID text: String isPublished: Boolean author: User}
上述代碼,聲明了一個 Article 類型,它有三個 Field,分別是 id(ID類型)、text(String類型)、isPublished(Boolean類型)以及 author(新建的對象類型User),User 類型的聲明如下:
type User { id: ID name: String}
2.1.3 Type Modifier
類型修飾符,當前的類型修飾符有兩種,分別是 List 和 Required ,語法分別為 [Type] 和 Type! ,兩者可以組合使用:
- [Type]! :列表本身為必填項,但內部元素可以為空
- [Type!] :列表本身可以為空,但是其內部元素為必填
- [Type!]! :列表本身和內部元素均為必填
2.2 如何定義 Schema
schema 用來描述對於介面獲取數據邏輯 ,GraphQL 中使用 Query 來抽象數據的查詢邏輯,分為三種,分別是 query(查詢)、mutation(更改)、subscription(訂閱) 。API 的介面概括起來有 CRUD(創建、獲取、更改、刪除)四類,query 可以覆蓋R(獲取)的功能,mutation 可以覆蓋( CUD 創建、更改、刪除)的功能。
注意: Query 特指 GraphQL 中的查詢(包含三種類型),query 指 GraphQL 中的查詢類型(僅指查詢類型)。
2.2.1 Query
- query(查詢):當獲取數據時,選擇 query 類型
- mutation(更改): 當嘗試修改數據時,選擇 mutation 類型
- subscription(訂閱):當希望數據更改時,可以進行消息推送,使用 subscription 類型(針對當前的日趨流行的 real-time 應用提出的)。
以 Article 為數據模型,分別以 REST 和 GraphQL 的角度,編寫 CURD 的介面
- Rest 介面
GET /api/v1/articles/GET /api/v1/article/:id/POST /api/v1/article/DELETE /api/v1/article/:id/PATCH /api/v1/article/:id/
- GraphQL Query
query { articles():[Article!]! article(id: Int!): Article!}mutation { createArticle(): Article! updateArticle(id: Int): Article! deleteArticle(id: Int): Article!}
注意:
- GraphQL 是按照類型來劃分職能的 query、mutation、subscription,同時必須明確聲明返回的數據類型。
2.2.2 Resolver
上述的描述並未說明如何返回相關操作( query、mutation、subscription )的數據邏輯。所有此處引入一個更核心的概念 Resolver(解析函數)。
GraphQL 中默認有這樣的約定,Query(包括 query、mutation、subscription )和與之對應的 Resolver 是同名的,比如關於articles(): [Articles!]!
這個 query,它的 Resolver 的名字必然叫做 articles。
以已經聲明的 articles 的 query 為例,解釋下 GraphQL 的內部工作機制:
Query { articles { id author { name } comments { id desc author } }}
按照如下步驟進行解析:
- 首先進行第一次解析,當前的類型是 query 類型,同時Resolver的名字為 articles。
- 下一步會使用 articles 的 Resolver 獲取解析數據,第一層解析完畢。
- 下一步對第一層解析的返回值,進行第二層解析,當前 articles 包含三個子 query ,分別是 id、author 和 comments。
- id 在 Author 類型中為標量類型,解析結束。
- author 在 articles 類型中為對象類型 User,嘗試使用 User 的 Resolver 獲取數據,當前 field 解析完畢。
- 下一步對第二層解析的返回值,進行第三層解析,當前 author 還包含一個 query,name 是標量類型,解析結束。
- comments 解析同上。
概括總結 GraphQL 大體解析流程就是遇見一個 Query 之後,嘗試使用它的 Resolver 取值,之後再對返回值進行解析,這個過程是遞歸的,直到所有解析 Field 類型是 Scalar Type(標量類型)為止。整個解析過程可以想像為一個很長的 Resolver Chain(解析鏈)。 GraphQL 在實際使用中常常作為中間層來使用,數據的獲取通過 Resolver 來封裝,內部數據獲取的實現可能基於 RPC、REST、WS、SQL 等多種不同的方式。
3.GraphQL 例子
下面這部分將會展示一個用 graphql-go 庫實現的用戶管理的例子,包括獲取全部用戶信息、獲取指定用戶信息、修改用戶名稱、刪除用戶的功能,以及如何創建枚舉類型的功能 「完整代碼在這裡」:
3.1 生成的 schema 文件內容如下:
//mutation 操作可完成C(創建)、U(更新)、D(刪除)type Mutation { """[用戶管理] 修改用戶名稱""" //操作注釋信息 changeUserName( """用戶ID""" //參數注釋信息,必傳一個Int類型的值 userId: Int!? """用戶名稱""" userName: String! ): Boolean? """[用戶管理] 創建用戶""" createUser( """用戶名稱""" userName: String!? """用戶郵箱""" email: String!? """用戶密碼""" pwd: String!? """用戶聯繫方式""" phone: Int ): Boolean """[用戶管理] 刪除用戶""" deleteUser( """用戶ID""" userId: Int! ): Boolean}//query 操作,可完成 R(查詢)type Query { """[用戶管理] 獲取指定用戶的信息""" UserInfo( """用戶ID""" userId: Int! ): userInfo? """[用戶管理] 獲取全部用戶的信息""" UserListInfo: [userInfo]!}//object type 說明"""用戶信息描述"""type userInfo { """用戶email""" email: String //欄位說明? """用戶名稱""" name: String? """用戶手機號""" phone: Int? """用戶密碼""" pwd: String? """用戶狀態""" status: UserStatusEnum? """用戶ID""" userID: Int}//枚舉類型在schema文件中的展示"""用戶狀態信息"""enum UserStatusEnum { """用戶可用""" EnableUser? """用戶不可用""" DisableUser}?
注意
- GraphQL 基於 golang 實現的例子比較少 。
- GraphQL 的 schema 可以自動生成,具體操作可查看 graphq-cli 文檔,步驟大致包括 npm 包的安裝、graphql-cli 工具的安裝,配置文件的更改(此處需要指定服務對外暴露的地址) ,執行
graphql get-schema
命令。
3.2 GraphQL 的 object type 定義
type UserInfo struct { UserID uint64 `json:"userID"` Name string `json:"name"` Email string `json:"email"` Phone int64 `json:"phone"` Pwd string `json:"pwd"` Status model.UserStatusType `json:"status"`}//這段內容是如何使用 GraphQL 定義枚舉類型var UserStatusEnumType = graphql.NewEnum(graphql.EnumConfig{ Name: "UserStatusEnum", Description: "用戶狀態信息", Values: graphql.EnumValueConfigMap{ "EnableUser": &graphql.EnumValueConfig{ Value: model.EnableStatus, Description: "用戶可用", }, "DisableUser": &graphql.EnumValueConfig{ Value: model.DisableStatus, Description: "用戶不可用", }, },})//定義 object type, 前端可以按需獲取該類型中包含的欄位var UserInfoType = graphql.NewObject(graphql.ObjectConfig{ Name: "userInfo", Description: "用戶信息描述", Fields: graphql.Fields{ "userID": &graphql.Field{ Description: "用戶ID", Type: graphql.Int, }, "name": &graphql.Field{ Description: "用戶名稱", Type: graphql.String, }, "email": &graphql.Field{ Description: "用戶email", Type: graphql.String, }, "phone": &graphql.Field{ Description: "用戶手機號", Type: graphql.Int, }, "pwd": &graphql.Field{ Description: "用戶密碼", Type: graphql.String, }, "status": &graphql.Field{ Description: "用戶狀態", Type: UserStatusEnumType, }, },})
3.3 query 與 mutation 的定義
var MutationType = graphql.NewObject(graphql.ObjectConfig{ Name: "Mutation", Fields: graphql.Fields{ "createUser": &graphql.Field{ Type: graphql.Boolean, Description: "[用戶管理] 創建用戶", Args: graphql.FieldConfigArgument{ "userName": &graphql.ArgumentConfig{ Description: "用戶名稱", Type: graphql.NewNonNull(graphql.String), }, "email": &graphql.ArgumentConfig{ Description: "用戶郵箱", Type: graphql.NewNonNull(graphql.String), }, "pwd": &graphql.ArgumentConfig{ Description: "用戶密碼", Type: graphql.NewNonNull(graphql.String), }, "phone": &graphql.ArgumentConfig{ Description: "用戶聯繫方式", Type: graphql.Int, }, }, Resolve: func(p graphql.ResolveParams) (interface{}, error) { userId, _ := strconv.Atoi(GenerateID()) user := &model.User{ //展示如何解析傳入的參數,傳入參數必須符合斷言 Name: p.Args["userName"].(string), Email: sql.NullString{ String: p.Args["email"].(string), Valid: true, }, Pwd: p.Args["pwd"].(string), Phone: int64(p.Args["phone"].(int)), UserID: uint64(userId), Status: int64(model.EnableStatus), } ...... return true, nil? }, }, },})?var QueryType = graphql.NewObject(graphql.ObjectConfig{ Name: "Query", Fields: graphql.Fields{ "UserListInfo": &graphql.Field{ Description: "[用戶管理] 獲取指定用戶的信息", //定義了非空的 list 類型 Type: graphql.NewNonNull(graphql.NewList(UserInfoType)), Resolve: func(p graphql.ResolveParams) (interface{}, error) { users, err := model.GetUsers() if err != nil { log.WithError(err).Error("[query.UserInfo] invoke InserUser() failed") return false, err } usersList := make([]*UserInfo, 0) for _, v := range users { userInfo := new(UserInfo) userInfo.Name = v.Name userInfo.Email = v.Email.String userInfo.Phone = v.Phone userInfo.Pwd = v.Pwd userInfo.Status = model.UserStatusType(v.Status) usersList = append(usersList, userInfo)? } return usersList, nil? }, }, },})?
注意:
- 此處僅展示了部分例子。
- 此處筆者僅列舉了 query、mutation 類型的定義。
3.4 如何定義服務 main 函數
?func main() { ...... //new graphql schema schema, err := graphql.NewSchema( graphql.SchemaConfig{ Query: object.QueryType, Mutation: object.MutationType, }, )? //此次從 http 請求的 header中獲取 user_id 的值,然後通過 context 向後續操作傳遞 http.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { ctx := context.Background() //read user_id from gateway userIDStr := r.Header.Get("user_id") if len(userIDStr) > 0 { userID, err := strconv.Atoi(userIDStr) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } ctx = context.WithValue(ctx, "ContextUserIDKey", userID) } h.ContextHandler(ctx, w, r)? }) log.Fatal(http.ListenAndServe(svrCfg.Addr, nil))}?
4.總結
筆者在實踐 GraphQL 的過程中,發現存在以下問題:
- 除了 Facebook 官方的 Node.js 版本支持的比較好,其他版本的文檔和實踐都比較少。
- graphql-go 庫親測存在
n + 1
問題,建議使用 graph-gophers 下的 graphql-go 的庫,因為 Features 里明確寫著parallel execution of resolvers
。 - Rest 和 GraphQL 都是服務端承載的系統對外的介面,二者是可以共存的。
- GraphQL 更容易造成拒絕服務攻擊,在使用時要特別小心。
- GraphQL 的利好主要是在於前端的開發效率,但落地卻需要服務端的全力配合。
如果是一家沒有技術包袱的小公司,根據介面變動頻繁等的業務的特性,考慮選擇使用 GraphQL 完全可以理解;但如果是一個大公司,對外已經全部使用 RESTful API ,基於人力成本的考慮不使用 GraphQL 也是合理的。無論 GraphQL 還是 RESTful API ,因地制宜選擇適合的才是好的。雖然 GraphQL 對於 Facebook 這樣的公司是合適的,但不可能其他所有公司的業務需求都跟 Facebook 相同。
筆者初次接觸 GraphQL ,不免有理解有誤的地方,歡迎指出。
5.參考資料
- GraphQL入門
- GraphQL官網中文版
- 30分鐘理解GraphQL核心概念
- 在GraphQL中建模一個博客索引
- 阻礙你使用GraphQL的十個問題
- GitHub為什麼開放一套GraphQL版本的API?
推薦閱讀:
※解讀GraphQL(二): 使用Apollo Data構建GraphQL應用
※如何評價 Meteor 的替代品 Meatier?
※解讀GraphQL(三):Relay—面向未來的前端框架
※如何利用GitHub GraphQL API開發個人博客?