[Go 語言] 從 graphql-go 轉換到 gqlgen

golang logo

相信各位開發者對於 GraphQL 帶來的好處已經非常清楚,如果對 GraphQL 很陌生的朋友們,可以直接參考之前作者寫的一篇『Go 語言實戰 GraphQL』,內容會講到用 Go 語言實戰 GraphQL 架構,教開發者如何撰寫 GraphQL 測試及一些開發小技巧,不過內容都是以 graphql-go 框架為主。而本篇主題會講為什麼我從 graphql-go 框架轉換到 gqlgen

教學影片

如果對於課程內容有興趣,可以參考底下課程。

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

前言

我自己用 graphql-go 寫了一些專案,但是碰到的問題其實也不少,很多問題都可以在 graphql-go 專案的 Issue 列表內都可以找到,雖然此專案的 Star 數量是最高,討論度也是最高,如果剛入門 GraphQL,需要練習,用這套見沒啥問題,比較資深的開發者,就不建議選這套了,先來看看功能比較圖

其中有幾項痛點是讓我主要轉換的原因:

  1. 效能考量
  2. 功能差異
  3. schema first
  4. 強型別
  5. 自動產生程式碼

底下一一介紹上述特性

效能考量

我自己建立效能 Benchamrk 來比較市面上幾套 GraphQL 套件 golang-graphql-benchmark

  • graphql-go/graphql version: v0.7.9
  • playlyfe/go-graphql version: v0.0.0-20191219091308-23c3f22218ef
  • graph-gophers/graphql-go version: v0.0.0-20200207002730-8334863f2c8b
  • samsarahq/thunder version: v0.5.0
  • 99designs/gqlgen version: v0.11.3
Requests/sec
graphql-go19004.92
graph-gophers44308.44
thunder40994.33
gqlgen49925.73

由上面可以看到光是一個 Hello World 範例,最後的結果由 gqlgen 勝出,現在討論度比較高的也只有 gqlgen 跟 grapgql-go,效能上面差異頗大。這算是我轉過去的最主要原因之一。

功能差異

幾個重點差異,底下看看比較圖:

  1. Type Safety
  2. Type Binding
  3. Upload FIle

等蠻多細部差異,graphql-go 目前不支持檔案上傳,所以還是需要透過 RESTFul API 方式上傳,但是已經有人提過 Issue 且發了 PR, 作者看起來沒有想處理這題。就拿上傳檔案當做例子,在 gqlgen 寫檔案上傳相當容易,先寫 schema

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
"The `Upload` scalar type represents a multipart file upload."
scalar Upload

"The `File` type, represents the response of uploading a file."
type File {
  name: String!
  contentType: String!
  size: Int!
  url: String!
}

就可以直接在 resolver 使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type File struct {
    Name        string
    Size        int
    Content     []byte
    ContentType string
}

func (r *mutationResolver) getFile(file graphql.Upload) (*File, error) {
    content, err := ioutil.ReadAll(file.File)
    if err != nil {
        return nil, errors.EBadRequest(errorUploadFile, err)
    }

    contentType := ""
    kind, _ := filetype.Match(content)
    if kind != filetype.Unknown {
        contentType = kind.MIME.Value
    }

    if contentType == "" {
        contentType = http.DetectContentType(content)
    }

    return &File{
        Name:        file.Filename,
        Size:        int(file.Size),
        Content:     content,
        ContentType: contentType,
    }, nil
}

Schema first

後端設計 API 時需要針對使用者情境及 Database 架構來設計 GraphQL Schema,詳細可以參考 Schema Definition Language。底下可以拿使用者註冊來當做例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
enum EnumGender {
  MAN
  WOMAN
}

# Input Types
input createUserInput {
  email: String!
  password: String!
  doctorCode: String
}

type createUserPayload {
  user: User
  actCode: String
  digitalCode: String
}

# Types
type User {
  id: ID
  email: String!
  nickname: String
  isActive: Boolean
  isFirstLogin: Boolean
  avatarURL: String
  gender: EnumGender
}

type Mutation {
  createUser(input: createUserInput!): createUserPayload
}

除了可以先寫 Schema 之外,還可以根據不同情境的做分類,將一個完整的 Schema 拆成不同模組,這個在 gqlgen 都可以很容易做到。

1
2
3
resolver:
  layout: follow-schema
  dir: graph

之後 gqlgen 會將目錄結構產生如下

1
2
3
4
user.graphql
user.resolver.go
cart.graphql
cart.resolver.go

開發者只要將相對應的 resolver method 實現出來就可以了。

強型別

如果有在寫 graphql-go 就可以知道該如何取得使用者 input 參數,在 graphql-go 使用的是 map[string]interface{} 型態,要正確拿到參數值,就必須要轉換型態

1
2
username := strings.ToLower(p.Args["username"].(string))
password := p.Args["password"].(string)

多了一層轉換相當複雜,而 gqlgen 則是直接幫忙轉成 struct 強型別

1
CreateUser(ctx context.Context, input model.CreateUserInput)

其中 model.CreateUserInput 就是完整的 struct,而並非是 map[string]interface{},在傳遞參數時,就不用多寫太多 interface 轉換,完整的註冊流程可以參考底下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.CreateUserPayload, error) {
    resp, err := api.CreateUser(r.Config, api.ReqCreateUser{
        Email:      input.Email,
        Password:   input.Password,
    })

    if err != nil {
        return nil, err
    }

    return &model.CreateUserPayload{
        User:        resp.User,
        DigitalCode: convert.String(resp.DigitalCode),
        ActCode:     convert.String(resp.ActCode),
    }, nil
}

自動產生代碼

要維護欄位非常多的 Schema 相當不容易,在 graphql-go 每次改動欄位,都需要開發者自行修改,底下是 user type 範例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var userType = graphql.NewObject(graphql.ObjectConfig{
    Name:        "UserType",
    Description: "User Type",
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type: graphql.ID,
        },
        "email": &graphql.Field{
            Type: graphql.String,
        },
        "username": &graphql.Field{
            Type: graphql.String,
        },
        "name": &graphql.Field{
            Type: graphql.String,
        },
        "isAdmin": &graphql.Field{
            Type: graphql.Boolean,
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                source := p.Source
                o, ok := source.(*model.User)

                if !ok {
                    return false, nil
                }

                return o.CheckAdmin(), nil
            },
        },
        "isNewcomer": &graphql.Field{
            Type: graphql.Boolean,
        },
        "createdAt": &graphql.Field{
            Type: graphql.DateTime,
        },
        "updatedAt": &graphql.Field{
            Type: graphql.DateTime,
        },
    },
})

上面這段程式碼是要靠開發者自行維護,只要有任何異動,都需要手動自行修改,但是在 gqlgen 就不需要了,你只要把 schema 定義完整後,如下:

1
2
3
4
5
6
7
8
9
type User {
  id: ID
  email: String!
  username: String
  isAdmin: Boolean
  isNewcomer: Boolean
  createdAt: Time
  updatedAt: Time
}

在 console 端下 go run github.com/99designs/gqlgen,就會自動將代碼生成完畢。你也可以將 User 綁定在開發者自己定義的 Model 層級。

1
2
3
models:
  User:
    model: pkg/model.User

之後需要新增任何欄位,只要在 pkg/model.User 提供相對應的欄位或 method,重跑一次 gqlgen 就完成了。省下超多開發時間。

心得

其實 graphql-go 雷的地方不只有這些,還有很多地方沒有列出,但是上面的 gqlgen 優勢,已經足以讓我轉換到新的架構上。而在專案新的架構上,也同時具備 RESTFul API + GraphQL 設計,如果有時間再跟大家分享這部分。


See also