gqlgen で子フィールドの情報を返すリゾルバーを実装する

何をするか?

Golang の GraphQL ライブラリである gqlgen を使って、GraphQL スキーマからサーバー実装用のコードを自動生成するときに、オブジェクト型の子フィールド用のリゾルバーを生成する方法を説明します。 gqlgen の基本的な使い方は下記を参照してください。

デフォルト設定でコード生成した場合

ここでは、入力用の GraphQL スキーマとして次のようなファイルを使うことにします。 オブジェクト型として BookAuthor があり、AuthorBookauthor フィールドとしてのみ使用されています。

graph/schema.graphqls
type Query {
  books: [Book!]!
}

type Book {
  id: ID!
  title: String!
  author: Author
}

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

gqlgen generate すると、次のようなモデル(型情報)コードが生成されます。

graph/model/models_gen.go
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

type Author struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type Book struct {
	ID     string  `json:"id"`
	Title  string  `json:"title"`
	Author *Author `json:"author"`
}

さらに、リゾルバーのテンプレートコードとして次のようなメソッドが自動生成されるのですが、デフォルトの設定 (gqlgen.yml) では、Query 型のフィールドに対応するリゾルバーメソッドしか生成されません。 例えば、今回のスキーマの場合 books フィールドを取得するためのリゾルバーメソッドのみが生成されます。

graph/schema.resolvers.go(抜粋)
// Books is the resolver for the books field.
func (r *queryResolver) Books(ctx context.Context) ([]*model.Book, error) {
	panic(fmt.Errorf("not implemented: Books - books"))
}

ちなみに、上記メソッドシグネチャ内の、queryResolverBooks という名前は、「Query オブジェクト型の books フィールドを取得するためのリゾルバー」であることを示しています。 Book オブジェクト型の author フィールドを取得するためのリゾルバーは生成されないので、books フィールド用のリゾルバー実装だけで、すべての子フィールドのデータを返すように実装しなければいけません。 例えば、2 つの書籍データを返すリゾルバー実装は次のような感じになります(強引にハードコードしてます)。

func (r *queryResolver) Books(ctx context.Context) ([]*model.Book, error) {
	// フェイクデータ(本当は別パッケージ化して db.GetBooks() のようにすべき)
	books := []*model.Book{
		{
			ID:     "book-id-1",
			Title:  "Book title 1",
			Author: &model.Author{ID: "author-id-1", Name: "Author name 1"},
		},
		{
			ID:     "book-id-2",
			Title:  "Book title 2",
			Author: &model.Author{ID: "author-id-2", Name: "Author name 2"},
		},
	}

	return books, nil
}

オブジェクト型のフィールドがこれくらいシンプルであれば何とかなるのですが、入れ子構造が深くなってくると、子孫フィールドをすべて処理しなければならず大変です。 また、上記の例では、クライアントから author フィールドが要求されているかどうかにかかわらず著者情報を DB からフェッチしており(今回はハードコードですが)、それも無駄です。 さらに、フィールドに検索やフィルタ用の引数が追加されたら、その引数に応じたデータフェッチ処理を行わなければならず、ますます複雑になってきます。 クライアントから実際に指定されたフィールド引数の値は、リゾルバーのパラメーターとして渡される context.Context オブジェクトを使って graphql.GetFieldContext(ctx) のようにすれば参照できるのですが、これはこれで大変です。

author フィールド用のリゾルバーを、別メソッドとして定義できれば、実装がぐっと楽になります。

フィールド用のリゾルバーを生成する

やりたいことは、GraphQL スキーマで定義した Book オブジェクト型の author フィールドの専用リゾルバーを生成する、ということです。 そのためには、gqlgen の設定ファイル (gqlgen.yml) の models プロパティで次のように設定します。

gqlgen.yml(抜粋)
models:
  Book:
    fields:
      author:
        resolver: true
  # ...

Book 型のフィールドである author のリゾルバーを生成する」という設定です。 そのまんまですね。 このように設定した状態で、gqlgen generate を実行すると、リゾルバーのテンプレートとして次のようなコードが生成されるようになります。

graph/schema.resolvers.go(抜粋)
// Author is the resolver for the author field.
func (r *bookResolver) Author(ctx context.Context, obj *model.Book) (*model.Author, error) {
	panic(fmt.Errorf("not implemented: Author - author"))
}

// Books is the resolver for the books field.
func (r *queryResolver) Books(ctx context.Context) ([]*model.Book, error) {
	panic(fmt.Errorf("not implemented: Books - books"))
}

Book オブジェクト型の author フィールドを処理するための専用のリゾルバーメソッド (*bookResolver) Author が生成されたので、books リゾルバー側の実装では author フィールドを処理する必要がなくなります。 下記はフェイクデータを使用したリゾルバー実装例です。

// Author is the resolver for the author field.
func (r *bookResolver) Author(ctx context.Context, obj *model.Book) (*model.Author, error) {
	// author フィールド用のフェイクデータ(親オブジェクト型 Book の情報を利用して実装できる)
	author := &model.Author{
		ID:   "author-id-" + obj.ID,
		Name: "Author name of " + obj.Title,
	}
	return author, nil
}

// Books is the resolver for the books field.
func (r *queryResolver) Books(ctx context.Context) ([]*model.Book, error) {
	// books フィールド用のフェイクデータ(ここで author フィールドのデータは返さなくてよい)
	books := []*model.Book{
		{ID: "book-id-1", Title: "Book title 1"},
		{ID: "book-id-2", Title: "Book title 2"},
	}
	return books, nil
}

前述の (*queryResolver) Books リゾルバーのみを使った実装よりも、だいぶ分かりやすくなったと思います。 もちろん、クライアントから author フィールドを要求されなかった場合は、(*bookResolver) Author リゾルバーが呼び出されることはないので、余計な DB フェッチが行われる心配もありません。 バッチシですね!

ちなみに、フィールド引数を扱うときもほぼ同様の実装で対応できます。 例えば、GraphQL スキーマの Book オブジェクト型の author フィールドに、次のような realName パラメータを追加したとします(本名を取得するためのフラグのつもり)。

graph/schema.graphqls
type Book {
  id: ID!
  title: String!
  author(realName: Boolean = false): Author
}

すると、author フィールド用のリゾルバーメソッドのパラメーターにも realName が追加されるので、その情報を使ってリゾルバーを実装することができます。

graph/schema.resolvers.go(抜粋)
// Author is the resolver for the author field.
func (r *bookResolver) Author(ctx context.Context, obj *model.Book, realName *bool) (*model.Author, error) {
	// Book 型の author フィールドの値を構築(本当は DB などからフェッチ)
	name := "Author name of " + obj.Title
	if *realName {
		name += " (REAL NAME)"
	}
	author := &model.Author{
		ID:   "author-id-" + obj.ID,
		Name: name,
	}
	return author, nil
}

言語仕様上、Golang 関数の realName パラメーターではデフォルト値が表現できていませんが、クライアントからのクエリで realName 引数が省略された場合は、ちゃんと GraphQL スキーマで定義されたデフォルト値(今回は false) を受け取れるようになっています(これは gqlgen フレームワーク側の仕組みです)。 完璧ですね!