2020-02-17

[Golang] gqlgen を使ってみた

GraphQLのモデルからファイルを自動生成してくれるgqlgenというものがあります。 https://github.com/99designs/gqlgen

挙動を理解するために チュートリアル に沿って使ってみました。

Golang ってコードの自動生成多いですね。ようやく慣れてきました。

Initialize

このセクションのコードはこのあとのセクションと区別するために world/ ディレクトリ配下で行います。

まずは以下のファイルを置いて

schema.graphql
type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

type User {
  id: ID!
  name: String!
}

type Query {
  todos: [Todo!]!
}

input NewTodo {
  text: String!
  userId: String!
}

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

以下のコマンドでファイル群を初期化します。

$ go run github.com/99designs/gqlgen init

作られたファイルが以下の5つです。

gqlgen.yml
  • 自動生成の設定ファイル
    gqlgen.yml
    # .gqlgen.yml example
    #
    # Refer to https://gqlgen.com/config/
    # for detailed .gqlgen.yml documentation.
    
    schema:
    - schema.graphql
    exec:
      filename: generated.go
    model:
      filename: models_gen.go
    resolver:
      filename: resolver.go
      type: Resolver
    autobind: []
    
    
  • schema
    • GraphQL の設定ファイルです。
    • 配列で複数指定することもできますが、 Glob で指定できるため schema/*.graphql のようにディレクトリ配下の設定を一括取り込みしたりもできます。
  • exec
    • 後述する models_gen.go をどのようなファイル名、どのようなパッケージ名で出力するか設定できます。
      • filename にはパスから指定できるのでディレクトリも変更できます。
      • package を指定すれば出力するファイルのパッケージ名を変更できます。
        • 出力するディレクトリ名と合わせる必要があります
  • model
    • 後述する generated.go をどのようなファイル名、どのようなパッケージ名で出力するか設定できます。 (設定方法は execと同じため省略)
  • models
    • 手動で生成したモデルを gqlgen に教えてあげるための設定です。 {親ディレクトリまでのパス}/{パッケージ名}.{モデル名} という具合に指定します。 詳しくは次のセクションまで読み進めてください。
  • resolver
    • 後述する resolver.go をどのようなファイル名、どのようなパッケージ名で出力するか設定できます。 (設定方法は execと同じため省略)
    • type はリゾルバに設定する名前です。変えたからと言って挙動が変わったりはしません。 通常は Resolver のままにしておくのがよいでしょう。
  • autobind
    • 手動で作成したモデルを自動的に読み込むための設定です。 配列でパスを指定すれば該当するモデルを読み込んでくれます。
    • v0.9.1 から使えます。
    • How to configure gqlgen using gqlgen.yml - gqlgen
generated.go
  • GraphQL を処理するためのランタイム。 gqlgen 自身はコードを自動生成するだけでランタイムを提供しません。
  • このファイルが置かれたディレクトリをモジュールとしてインポートして扱うことになります。手動で変更してはいけません。
  • ResolverRoot というインタフェースに意図しないメソッドが出力される場合、抽出すべきフィールドの型が誤っている可能性があります。 (前にハマったのでメモ)
  • 今回 world/generated.go というファイルが出力されましたが、ファイルが大きすぎてページの読み込みに支障が出ているのでのせません。
models_gen.go
  • graphql の モデル定義を Golang で扱えるように構造体に変換したコード。手動で変更してはいけません。
  • models_gen.go
    // Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
    
    package world
    
    type NewTodo struct {
    	Text   string `json:"text"`
    	UserID string `json:"userId"`
    }
    
    type Todo struct {
    	ID   string `json:"id"`
    	Text string `json:"text"`
    	Done bool   `json:"done"`
    	User *User  `json:"user"`
    }
    
    type User struct {
    	ID   string `json:"id"`
    	Name string `json:"name"`
    }
    
    
resolver.go
  • 実際にリクエストを処理するコードです。初期状態では関数のガワだけが定義されており呼び出すとパニックが発生するので徐々に実装していきます。
  • resolver.go
    package world
    
    import (
    	"context"
    ) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
    
    type Resolver struct{}
    
    func (r *Resolver) Mutation() MutationResolver {
    	return &mutationResolver{r}
    }
    func (r *Resolver) Query() QueryResolver {
    	return &queryResolver{r}
    }
    
    type mutationResolver struct{ *Resolver }
    
    func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
    	panic("not implemented")
    }
    
    type queryResolver struct{ *Resolver }
    
    func (r *queryResolver) Todos(ctx context.Context) ([]*Todo, error) {
    	panic("not implemented")
    }
    
    
server/server.go
  • GraphQLサーバを動かすための最小限のエンドポイント実装です。
  • server.go
    package main
    
    import (
    	"hello/world"
    	"log"
    	"net/http"
    	"os"
    
    	"github.com/99designs/gqlgen/handler"
    )
    
    const defaultPort = "8080"
    
    func main() {
    	port := os.Getenv("PORT")
    	if port == "" {
    		port = defaultPort
    	}
    
    	http.Handle("/", handler.Playground("GraphQL playground", "/query"))
    	http.Handle("/query", handler.GraphQL(world.NewExecutableSchema(world.Config{Resolvers: &world.Resolver{}})))
    
    	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    	log.Fatal(http.ListenAndServe(":"+port, nil))
    }
    
    

早速サーバを起動してみましょう。

$ go run server/server.go 2020/02/09 21:00:00 connect to http://localhost:8080/ for GraphQL playground

続いて localhost:8080 にアクセスしてみると、Play groundが表示されました。

graphql-playground.png

まだ実装されていないので何をやってもエラーになりますが、とりあえず一歩は進展しました。

Implement

info
  • 前のセクションと区別するために kitty/ ディレクトリ配下で行います。
  • ちなみにキティはよく知りません。

チュートリアルに沿って話を進めていきます。

先程自動生成されたファイルは正しくなかったとしましょう。 Todo 構造体には User 構造体が埋め込まれていましたが、実は UserID だけで十分だったのです。

models_gen.go に定義された Todo を切り出して todo.go に以下のように記述します。

todo.go
package kitty

type Todo struct {
	ID     string
	Text   string
	Done   bool
	UserID string
}

(「切り出して」とは言いましたが、 models_gen.go を編集する必要はありません。どうせこのあと作り直すのでどちらでもよいですが)

設定ファイルも次のようにいじっていきます。

gqlgen.yml
schema:
- schema.graphql
exec:
  filename: generated.go
model:
  filename: models_gen.go
models:
  Todo:
    model: hello/kitty.Todo
resolver:
  filename: resolver.go
  type: Resolver
autobind: []

Todo モデルは手動で作ったからそれを見てくれという指定を models に追加しただけです。

info
  • 今回はひとつなのであまり気になりませんが、モデルが増えてくると管理が煩雑になってきます。
  • 手動で追加したのに gqlgen.yml に登録し忘れることもあるでしょう。
  • 先程の説明で軽く触れましたが autobind を使うとモデルを自動検出して読み込んでくれます。 つまり、 models の代わりに以下のように autobind を書いても同じことです。
  • autobind: - hello/kitty

ではもう一度コードの生成を行います。

kitty ディレクトリに移動して

kitty $ go run github.com/99designs/gqlgen -v kitty/todo.go:3 adding resolver method for Todo.user, nothing matched kitty/resolver.go already exists
info
  • 設定ファイルは --config= で指定できるため、ファイル名を変える場合はこのオプションとともに利用ください。
  • -v は詳細情報表示のオプションです

generated.go と models_gen.go が修正されました。 (v0.9.0以前では、フィールドのGetterを書かないとパニックになることがあったのですが、現時点の最新v0.10.2では発生しないようです)

先程のコードを比較してみると models_gen.go から Todo モデルが消えているのがわかります。

  • world/models_gen.go
    // Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
    
    package world
    
    type NewTodo struct {
    	Text   string `json:"text"`
    	UserID string `json:"userId"`
    }
    
    type Todo struct {
    	ID   string `json:"id"`
    	Text string `json:"text"`
    	Done bool   `json:"done"`
    	User *User  `json:"user"`
    }
    
    type User struct {
    	ID   string `json:"id"`
    	Name string `json:"name"`
    }
    
    
  • kitty/models_gen.go
    // Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
    
    package kitty
    
    type NewTodo struct {
    	Text   string `json:"text"`
    	UserID string `json:"userId"`
    }
    
    type User struct {
    	ID   string `json:"id"`
    	Name string `json:"name"`
    }
    
    

todo.go に手動で定義したのでこれは期待通りの動作です。むしろ作られたら困ります。

先程の生成で「 resolver.go がすでにあるので生成されなかった」と警告が出ていたのに気づきましたか?

resolver.go は編集可能なファイルなので勝手に上書きしたりしません。 作り直す場合は一度削除して再実行します。

kitty $ rm resolver.go kitty $ go run github.com/99designs/gqlgen

少しわかりにくいですが、 TodoResolver タイプと User メソッドが追加されました。

warning
  • お気づきのように Query や Mutation はインタフェースのフィールドに追加されます。
  • resolver.go を新規で作る場合はこれらのメソッドも空で作られてくれますが、 すでにある場合は開発者が resolver.go に手動でメソッドを追加してあげないとエラーになるので注意しましょう。
  • type MutationResolver interface { CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) } type QueryResolver interface { Todos(ctx context.Context) ([]*Todo, error) }

ここに処理を実装していきましょう。

resolver.go
package kitty

import (
	"context"
	"fmt"
	"math/rand"
) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.

type Resolver struct {
	todos []*Todo
}

func (r *Resolver) Mutation() MutationResolver {
	return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
	return &queryResolver{r}
}
func (r *Resolver) Todo() TodoResolver {
	return &todoResolver{r}
}

type mutationResolver struct{ *Resolver }

func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
	todo := &Todo{
		Text:   input.Text,
		ID:     fmt.Sprintf("T%d", rand.Int()),
		UserID: input.UserID,
	}
	r.todos = append(r.todos, todo)
	return todo, nil
}

type queryResolver struct{ *Resolver }

func (r *queryResolver) Todos(ctx context.Context) ([]*Todo, error) {
	return r.todos, nil
}

type todoResolver struct{ *Resolver }

func (r *todoResolver) User(ctx context.Context, obj *Todo) (*User, error) {
	return &User{ID: obj.UserID, Name: "user " + obj.UserID}, nil
}

以下のように変更して再度サーバを起動します。

  • Resolver は Todo を保存するための Array todos を持つ
    • Resolver を埋め込んだ全ての構造体も同様に todos を参照できる
  • CreateTodo は 受け取ったパラメータをもとに Todo を作成し todos に追加する
  • Todos は todos をそのまま返却する

動作確認に Mutation(x2) と Query を実行します。

Mutation1
  • playground-kitty.png
Mutation2
  • playground-george.png
Query
  • playground-all.png

うまく動いているようでよかったです。

ちなみに gqlgen とは直接関係ありませんが、GraphQL Playground はサーバ側の負荷が高いとうんともすんとも言わなくなることがあるので apollo-client-developer という Chrome アドオンを使っています。 実際のGraphQLリクエストを捕捉して再送できたりするので便利です。 https://chrome.google.com/webstore/detail/apollo-client-developer-t/jdkknkkbebbapilgoeccciglkfbmbnfm

履歴を永続化してくれるともっとうれしいんだけど。