This page looks best with JavaScript enabled

Gqlgen

 ·  ☕ 5 min read

一直都聽說 Graphql 很潮(? 工作上其實也有遇到類似的問題,一個玩家的資料有一堆欄位,常常因為各種需求加了一堆參數或 API 來拿裡面的不同欄位,graphql 也有被提到說是一個好選擇,不過現在伺服器都用 restful 寫好好的要搬也是一個大工程,只好自己來研究一下 XD

https://ithelp.ithome.com.tw/articles/10200678 <- 不知道 graphql 是什麼可以參考這個,或是去官網看~

這東西網路上也很多介紹了就不多講,大致上就是用一個 endpoint 就可以讓 client server 之間很有彈性的選擇想要傳輸的資料,很像我們在下資料庫指令。

下面只是玩玩看 gqlgen 這個 Go 的 graphql 套件而已,總之又是一篇雜亂的文章

gqlgen

官方範例在此 https://gqlgen.com/getting-started/

首先進去會教你安裝

然後跑 go run github.com/99designs/gqlgen init 會出現他的範例

還包含一個 playground GUI 讓我們方便下 query

graph/generated 裡面的東西基本上就是那些拆解 query 之類的程式碼, gqlgen 都幫我們產生好拉

schema (與 client 的溝通格式) 就是寫在 graph/schema.graphqls 內,從 gqlgen.yml 裡面可以知道也可以分放在多個檔案內

schema:
  - graph/*.graphqls

graph/model 內是 gqlgen 依照 schema 產生的 go struct

如果希望strcut與schema不同時,就要在gqlgem.yml的models那邊先宣告(Type Mapping),這樣他就不會幫你gen出來
參考gqlgen workshop的

models:
  User:
    model: github.com/99designs/gqlgen-workshop/db.User

或者是直接在autobind那個路徑裡面先自己宣告也行,就不會產生

注意的是如果struct都import來的gqlgen會沒有產生出任何graph/model裡面的檔案導致 module mode l不存在
gen的時候會跑出一堆錯誤
validation failed: packages.Load: -: malformed module path "gqlgen-workshop/graph/model": missing dot in first path element
猜大概是找不到model這個module
亂改了yml發現把 autobind 還有 model 這兩個註解掉就OK了

Implement the resolvers

註解裡面有寫,其實就是把需要的東西放進去例如db

type Resolver struct{
	todos []*model.Todo
}

接著要實作 schema.resolvers.go 裡面 CreateTodo 和 Todos,這兩個 func 也是 gqlgen 依照 schema 產生的,應該滿好理解
CreateTodo 的 input 進來我們就處理 input 存進 db
Todos 就是回傳 db 內容這樣
回傳對應的 struct 後 gqlgen 產生出來的 code (generated.go) 會幫我們處理剩下的事情,譬如把 client 不需要的 field 拿掉之類的

接著就可以 go run server.go 去上面玩玩看了

再來範例上自己寫了一個 Todo 的 struct 而不是用 gen 出來的

type Todo struct {
	ID     string `json:"id"`
	Text   string `json:"text"`
	Done   bool   `json:"done"`
	UserID string `json:"user"`
}

此時再跑一次 go run github.com/99designs/gqlgen generate 會發現 schem.resolvers.go 多了一個 (r *todoResolver) User

這是因為修改之後 Todo struct 與 schema 上的 Todo 對不起來 (UserID: User),所以我們在把 struct Todo 轉換為 schema Todo 時,要給他一個方法,用 struct Todo 裡面的資料組成 schema Todo,詳情請參考,詳情請參考 generated 裡面產生出來的 code

	ctx = graphql.WithFieldContext(ctx, fc)
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
		ctx = rctx // use context from middleware stack in children
		return ec.resolvers.Todo().User(rctx, obj)
	})

基本範例到這邊差不多就結束了~

下面還有很多 reference 可以去看看,下面只寫了我有看的

FieldCollection

https://gqlgen.com/reference/field-collection/
前面的範例是 client query 來了,程式去 db 撈對應的資料,然後交給 gqlgen 去把不必要的欄位篩掉再傳回給 client,field collection 是要讓你在撈 db 前先知道到底有哪些 field 是要用的,撈 db 時可針對去撈,避免浪費資源

CollectAllFields 會直接回傳一個 string slice 包含 query 最上層的 field 名稱
CollectFieldsCtx 會回傳一個 CollectedField 的 slice,CollectedField 是一個 struct 裡面包含該 field 的一些資訊(名稱之類的),還有該 field 的子 field 資訊

下面改寫一下func印出相關資訊

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
	fmt.Printf("CollectAllFields: %+v \n\n", graphql.CollectAllFields(ctx))
	fmt.Printf("CollectFieldsCtx: \n")
	for _, field := range graphql.CollectFieldsCtx(ctx, nil) {
		fmt.Printf("  %+v\n", field.Name)
		for _, sel := range field.Selections {
			fmt.Printf("    %+v\n", sel)
		}
	}

	return r.todos, nil
}
CollectAllFields: [id text done user] 

CollectFieldsCtx: 
  id
  text
  done
  user
    &{Alias:name Name:name Arguments:[] Directives:[] SelectionSet:[] Position:0xc00011df18 Definition:0xc00011b0a0 ObjectDefinition:0xc000108b40}
    &{Alias:id Name:id Arguments:[] Directives:[] SelectionSet:[] Position:0xc00011df58 Definition:0xc00011b030 ObjectDefinition:0xc000108b40}

發 query 後可以看到 CollectAllFields 回傳了上層的 field name, user 裡面只要至少有一個 field 就會回傳 user

而 graphql.CollectFieldsCtx 裡面的各個 field 還有 Selections,這就是包含了子 field 的資訊,Selection 是 interface,這邊只試印出來,實際上要用 CollectFields,裡面會去轉型。
因為 field 無法預測到底有幾層,實際上是要用範例中遞迴的方式去解開

Query complexity

因為 filed 底下有可能又有 field 又有 field,例如下面

type User {
  friends: [User!]!
}

可能 query 出一坨東西然後爆炸

所以需要某些方法來限制 query 的量
下面先修改原本的例子 schema 和 model 並 go generete ./...

---- graph/schema.graphqls
type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
  related(count: Int!): [Todo!]! # count 用來限制回傳的 related todo 數量
}
input Relation {
  a: String!
  b: String!
}

type Mutation {
  createTodo(input: NewTodo!): Todo!
  addRelated(input: Relation!): Todo!
}

---- graph/model/todo.go
type Todo struct {
	ID      string   `json:"id"`
	Text    string   `json:"text"`
	Done    bool     `json:"done"`
	UserID  string   `json:"user"`
	Related []string `json:"related"`
}

然後把 schema.resolvers.go 實作一下
addRelated 就是把 b 加到 a.related

func (r *mutationResolver) AddRelated(ctx context.Context, input model.Relation) (*model.Todo, error) {
	var a, b *model.Todo

	for _, todo := range r.todos {
		if todo.ID == input.A {
			a = todo
		} else if todo.ID == input.B {
			b = todo
		}
	}
	if a == nil || b == nil {
		return nil, errors.New("QQ")
	}
	a.Related = append(a.Related, b.ID)
	return a, nil
}

func (r *todoResolver) Related(ctx context.Context, obj *model.Todo, count int) ([]*model.Todo, error) {
	todos := []*model.Todo{}
	i := 0
	for _, rid := range obj.Related {
		for _, todo := range r.todos {
			if rid == todo.ID {
				todos = append(todos, todo)
				i++
				if i >= count {
					break
				}
			}
		}
	}
	return todos, nil
}

---- server.go
加入 srv.Use(extension.FixedComplexityLimit(5))

這時如果我們下了 query

query getTodos {
    todos {
      id
      text
      done
      related (count:1){
        id
        text
      }
    }
}

會出現 message": “operation has complexity 7, which exceeds the limit of 5”
這是因為預設一個 field 和一層深度都會 complexity + 1
這邊共有 7 個 complexity (todos, id, text, done, related, id, text)

但也可以發現count不管改成多少complexity都不會變
我們可能會想要限制related的數量
這時可使用 custom complexity Calculation

把 server.go 改成

	c := generated.Config{Resolvers: &graph.Resolver{}}
	countComplexity := func(childComplexity, count int) int {
		fmt.Println("childComplexity: ", childComplexity, ", count: ", count)
		return count * childComplexity
	}
	c.Complexity.Todo.Related = countComplexity

	srv := handler.NewDefaultServer(generated.NewExecutableSchema(c))

修改的 struct是graph/generated/generated.go 裡面的 ComplexityRoot struct 計算複雜度的 function

接著跑 query

query getTodos {
    todos {
      id
      text
      done

      related (count:2){
        id
        related (count:3){
            id	
            text
            done
          }
      }
    }
}

會得到

operation has complexity 24, which exceeds the limit of 5",

childComplexity:  3 , count:  3  
childComplexity:  10 , count:  2

#上面使用 countComplexity func 計算複雜度 = childComplexity * count
看起來是用遞迴先跑到最內層的 related (count:3),他的 childComplexity是3 (filed數量),算出來等於 9

再來 related (count:2) 的 childComplexity 是 10 (id + related (count:3)的複雜度),複雜度20
最後再加 todos, id, text, done = 24

總結一下,在 query 來的時候 gqlgen 就會幫我們把複雜度算好了,可以自己替換算 complexity 的 func,如果超過我們設定的複雜度限制就會不給他過,避免有人亂打一堆奇怪的 query,把頻寬或程式弄爆


這次看下來其實感覺 graphql 優勢還滿明顯的,不過應該也有很多坑還沒研究到,像是 restful 就不會有 query complexity 這種東西要處理,而且要用這種新的東西公司內要用感覺也要一陣子研究才能上手

這次的範例程式碼放在下面的 repo 記錄一下,不然之後要用的時候八成也忘記怎麼用了
https://github.com/Markogoodman/gqltest

Share on

Marko Peng
WRITTEN BY
Marko Peng
Good man