LayerX エンジニアブログ

LayerX の エンジニアブログです。

【GraphQL × Go】gqlgenの基本構成とオーバーフェッチを防ぐmodel resolverの実装

こんにちは。SaaS事業でLayerX ワークフローの開発を担当している@sh_komineです。 この記事は、LayerX Advent Calender 2021の16日目の記事です。

LayerX ワークフローではGoとGraphQLをフル活用して開発を行なっています。

www.layerx.jp

GraphQLの良さはいろいろと語られていますが、「Goで実際にどう実装するんだ?」と言うところは、gqlgenの簡潔なGet Startedがあるくらいでなかなか手で動かさないと理解できないなという思いがありましたので、graphqlのプロジェクトの基本構成に触れながら、オーバーフェッチを防ぐ実装の仕方について書いていきたいと思います。

本記事は実際の事例ではなく、gqlgen 初学者の全体把握、gqlgenの仕組みについての理解に焦点を当てた記事になります。

以下の流れで話をします。

  1. graphqlの基本構成
  2. オーバーフェッチングを防ぐためのmodel resolverの実装
  3. 補足 graph/schema.graphqls の分割

今日はしない話

フロントエンドと組み合わせた全体の話や、DBなどの処理も含めた全体を通した話はしません。 実装例については、尊敬する同僚の@mosaさんや@anagoさんが過去に書いた記事がありますので、そちらをご覧ください。

  • RESTとGraphQLでの実装の違いについて解説した記事 tech.layerx.co.jp
  • GraphQLを用いたフロントエンドからバックエンドまで開発の流れを紹介した記事 tech.layerx.co.jp

1. graphqlの基本構成

graphqlの基本構成について理解するために、以下の流れで見ていきます。 運用しているとどんどんファイルは大きくなり、全体像の把握が難しくなってくるので、一番最初のシンプルな構成から内容を見ていきます。

  • 1-1. スケルトンプロジェクトを作る
  • 1-2. 生成されたファイルのそれぞれの役割について

1-1. スケルトンプロジェクトを作る

Getting Startedに倣い、Goのプロジェクト内でgqlgenをインストールして、スケルトンプロジェクト(雛形)を作成します。 (多分、どのプロジェクトでも導入の際はここから始まると思います。)

# go moduleの作成
$ mkdir gqlgen-todos
$ cd gqlgen-todos
$ go mod init github.com/[username]/gqlgen-todos

# gqlgenのスケルトンプロジェクトを作成
$ go get github.com/99designs/gqlgen
$ go run github.com/99designs/gqlgen init

スケルトンプロジェクトは以下のように作成されます。

├── go.mod
├── go.sum
├── gqlgen.yml               // コード生成の設定ファイル
├── graph
│   ├── generated            // 自動生成されたパッケージ(基本的にいじらない)
│   │   └── generated.go
│   ├── model                // Goで実装したgraph model用のパッケージ(自動生成されたファイルと自分でもファイルを定義することが可能)
│   │   └── models_gen.go
│   ├── resolver.go          // ルートのresolverの型定義ファイル. 再生成で上書きされない。
│   ├── schema.graphqls      // GraphQLのスキーマ定義ファイル. 実装者が好きに分割してもOK
│   └── schema.resolvers.go  // schema.graphqlから生成されたresolverの実装ファイル
└── server.go                // アプリへのエントリポイント. 再生成で上書きされない。

めちゃくちゃ簡単に雛形ができました。これがgqlgenのシンプルな基本構成になります。 rootの構成としては、以下のようになっています。

  • gqlgen.yml: 設定ファイル
  • graph: gqlgenの開発で利用するファイルがいろいろと含まれるパッケージ(ここのファイルについては後述)
  • server.go: main関数

次は生成されたファイルのそれぞれの役割について見ていきます。

1-2. 生成されたファイルのそれぞれの役割について

graph/schema.graphqls : GraphQLのスキーマ定義ファイル

graph/schema.graphqls はGraphQLのスキーマ定義ファイルで、APIエンドポイントとその型を管理するのが役割です。 スケルトンプロジェクトでは以下のように生成されます。 以後、自分が書き加えた箇所には ⭐️ をつけていきます。

# ⭐️  type: 基本のオブジェクトタイプ
type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

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

# ⭐️  type Query: データフェッチ系のエンドポイント定義
type Query {
  todos: [Todo!]!
}

# ⭐️  input: Mutaion用のオブジェクト型の定義
input NewTodo {
  text: String!
  userId: String!
}

# ⭐️  type Mutation: Serversideのデータを修正する処理のエンドポイント定義
type Mutation {
  createTodo(input: NewTodo!): Todo!
}

gqlgenでは基本的にこの graph/schema.graphqlsというGraphQLの定義ファイルを編集し、

gqlgen

というコマンドを実行することで、後述する graph/model/models_gen.gograph/schema.resolvers.go を再生成して開発を進めていくので、全ての起点になるファイルです。 gqlgenの話の前にGraphQLの型定義のより詳しい仕様が知りたい方は公式のドキュメントをご覧ください。

graphql.org

graph/model/models_gen.go : graph/schema.graphqls から自動生成されたGoの型定義ファイル

graph/model/models_gen.gograph/schema.graphqls から自動生成されたGoの型定義ファイルです。 上記の graph/schema.graphqls と見比べると分かりますが、 type Querytype Mutation 以外の type Todo, type Userinput NewTodo のstructが自動生成されています。

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

// ⭐️ input NewTodoから生成されたファイル
type NewTodo struct {
    Text   string `json:"text"`
    UserID string `json:"userId"`
}

// ⭐️ type Todoから生成されたファイル
type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
    User *User  `json:"user"`
}

// ⭐️ type Userから生成されたファイル
type User struct {
    ID      string  `json:"id"`
    Name    string  `json:"name"`
    Friends []*User `json:"friends"`
}

type Querytype Mutationgraph/model/models_gen.go には作成されません。 graph/schema.resolvers.go に作成されます。

また詳しくは後述しますが、graph/model 配下には自分で定義したGoのモデル定義のファイル *.go を置くことができます。 自分で定義したGoのモデルも gqlgen コマンドで読み込まれ、graph/schema.graphqlsをベースに*.goに足りないFieldが graph/schema.resolvers.go に生成されます。 ひとまずは、graph/model パッケージはGraphQLの型オブジェクトに対応するgo のstructを管理する役割があると覚えてください。

graph/schema.resolvers.go : graph/schema.graphqls ファイルから自動生成されたエンドポイント実装用のGoファイル

graph/schema.resolvers.gograph/schema.graphqls ファイルから自動生成されたエンドポイント実装用のGoファイルです。エンドポイントの管理が役割になります。 最初の状態は以下のように未実装の状態でコードが生成されているので、そこに実装をしていく流れになります。

resolverはよくあるcontrollerやhandlerと役割は同じなので、そこまで難しく考えなくて大丈夫です。 この中の詳細の実装はresolverに処理をベタ書きでも、MVCでもDDDでも実装者の好きに実装していくことができます。

graph/schema.resolvers.go

package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
    "context"
    "fmt"
    "math/rand"

    "github.com/shkomine/gqlgen-todos/graph/generated"
    "github.com/shkomine/gqlgen-todos/graph/model"
)

func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
         // ⭐️ ここにデータフェッチの実装をしていく
        // 例えばこんな処理
        // r.todoRepo.Create(input)
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
         // ⭐️ ここにデータ変更の実装をしていく
        // 例えばこんな処理
        // r.todoRepo.List()
    panic(fmt.Errorf("not implemented"))
}

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

ここで、上記の mutationResolverqueryResolverのコードを見てみると

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

どちらも Resolver の型定義を含んでおり、状態を持つ役割はResolverに集約されています。

graph/resolver.go, server.go : gqlgen コマンドで再生成されないgoパッケージ

Resolver の型定義は graph/resolver.go に定義されていて、状態を集約して管理するのが役割です。 自動生成される graph/schema.resolvers.go から参照されているので、型の名前を変えたり消すことはできませんが、この Resolver は初期生成の後gqlgenコマンドを実行しても再生成されることはないので、このstructに参照し続けたいインスタンス(pointer)などを持たせることができます。 MVCなら各model、その他 repositoryやservice, usecaseのstructを持つような実装になるかと思います。

graph/resolver.go

package graph

import "github.com/shkomine/gqlgen-todos/graph/model"

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct{
  // ⭐️ TODO: ここにUserRepositoryなどのインスタンスを保持する
}

Resolver インスタンスを作成しているのは server.go です。 Goのmain関数が含まれています。 このファイルも最初にスケルトンオブジェクトで作成された後はgqlgenコマンドの実行で再生成されないので、自由に書き換えることができます。

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/shkomine/gqlgen-todos/graph"
    "github.com/shkomine/gqlgen-todos/graph/generated"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

         // ⭐️ ここでResolverのインスタンスを生成する
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

         // ⭐️ graphql playgroundを`/query` に設定
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)

    log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

ここまで初期生成されたスケルトンプロジェクトを元にgqlgenの基本構成を説明してきました。 実際に動かしてみるところまでやってみたい方は、さくっと終わるので公式のGetting Startedをやってみてください。 より深く理解ができると思います。

gqlgen.com

次はいよいよオーバーフェッチングを防ぐためのmodel resolverの実装について触れていきます。

2. オーバーフェッチングを防ぐためのmodel resolverの実装

2-1. オーバーフェッチとは

GraphQLでバックエンドのコードをすっきりさせた話でも触れていますが、

オーバーフェッチ = リクエスト元で必要ないのに余分にリソースをフェッチしてしまうこと

です。

gqlgenのREADMEのよくある質問の最初にも「使わないかもしれない子オブジェクトのフェッチを防ぐにはどうしたらいいか? 」という項目が用意されており、GraphQLの重要なテーマであることがわかります。 GraphQLでは通信元のqueryで取得するリソースを制御することができ、より柔軟で効率的な情報取得が可能です。

例えば、上記のGet Startedの例では、Todoの中にUserが含まれていますが、これは、必ず必要とは限りません。

schema.graphqls

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

GraphQLでは情報の取得側がqueryで明示的に指定することによりそのデータが必要かどうかサーバー側に伝えることができます。

userの名前が必要であれば、以下のようにリクエストをします。

query findTodos {
  todos {
    text
    done
    user {
      name
    }
  }
}

結果

{
  "data": {
    "todos": [
      {
        "text": "技術記事を書く",
        "done": false,
        "user": {
          "name": "田中 太郎"
        }
      },
      {
        "text": "実装をする",
        "done": false,
        "user": {
          "name": "佐藤 二郎"
        }
      }
    ]
  }
}

また、userが必要なければ、以下のようにuserを含めずにリクエストをします。

query findTodos {
  todos {
    text
    done
  }
}

結果

{
  "data": {
    "todos": [
      {
        "text": "技術記事を書く",
        "done": false,
      },
      {
        "text": "実装をする",
        "done": false,
      }
    ]
  }
}

gqlgenではこのuserがリクエストに含まれた時だけ呼ばれるメソッドをgraph/schema.resolvers.goに作成することができます。 これによって、無駄なDBフェッチが走らない実装が可能になります。 gqlgenのドキュメントでは、特に名前がつけられていませんが、graph/schema.resolvers.goの中にqueryResolver, mutationResolver 以外の モデルごとのResolver xxxResolver が生成されるので、ここでは勝手にmodelresolverと呼ばせていただきます。(上記の例では todoResolverが生成されます。)

2-2. model resolverの2つの実装方法

gqlgenのREADMEにも書いてありますが、model resolverの実装には2つの方法があります。

  • カスタムモデルを用いた暗黙的な生成
  • gqlgen.ymlへの記載による明示的な生成

Get Startedでは「カスタムモデルを用いた暗黙的な生成」しか触れられていませんでしたし、自分が試した感触でもそちらの方が便利だったので、カスタムモデルを用いた実装方法を強くおすすめします。

カスタムモデルを用いた暗黙的な生成

model packageの説明で少し触れましたが、gqlgenはgraph/schema.graphqls での定義と model/*.go 内の型定義の差分によって graph/schama.resolvers を生成します。

今、graph/schema.graphqls でのTodoの定義は以下の通りです。

graph/schema.graphqls

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

graph/model/models_gen.go 内のTodoを削除し、graph/model/todo.goというファイルにカスタムモデルを定義します。 その際、todo.go 内のUserを丸っと消してみます。

graph/model/models_gen.go

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

graph/model/todo.go

package model

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

つづいて、gqlgen コマンドを実行すると、graph/schema.graphqls の型定義をベースにgraph/model/*.goのカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.goにmodel resolverを追加します。

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // こんなメソッドになる
        // r.userRepo.GetUserByTodoID(obj.ID)
    panic(fmt.Errorf("not implemented"))
}

// ...

// Todo returns generated.TodoResolver implementation.
func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} }

// ...

type todoResolver struct{ *Resolver }

ただ、上記で生成された状態だとTodoのIDでUserの情報を取得しないといけません。 実際の場合はTodoがUserIDを持っていることの方が多いと思います。 そこで、model/todo.goにUserIDを足します。

model/todo.go

package model

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

これでmodel resolverの中でUserIDを利用できるようになりました。

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // obj.UserIDが使えるので、こんなメソッドになる
        // r.userRepo.GetUserByID(obj.UserID)
    panic(fmt.Errorf("not implemented"))
}

graph/model/todo.goにUserIDを追加してもgraph/schema.graphqls には変更を入れていないので、GraphQLのインタフェースには変化がありません。 このように外には出したくないが、relationを追加するために必要なパラメータなどを自由に定義できるので、カスタムモデルの実装はおすすめです。

gqlgen.ymlへの記載による明示的な生成

自分もあまり使っていませんが、一応もう一つのmodel resolverの生成方法もご紹介します。 以下のように、 gqlgen.yml に具体的な指定をしても、カスタムモデルの時と同様にresolverを生成することができます。

gqlgen.yml

models:
  Todo:
    fields:
      user:
        resolver: true # force a resolver to be generated

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // こんなメソッドになる
        // r.userRepo.GetUserByTodoID(obj.ID)
    panic(fmt.Errorf("not implemented"))
}

// ...

// Todo returns generated.TodoResolver implementation.
func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} }

// ...

type todoResolver struct{ *Resolver }

生成できました。 ですが、こちらの方法だとTodoのモデルにUserIDなどのrelationを追加するために必要なパラメータをgoのモデルに追加することができません。 また、Inline config with directives という機能でより詳細に設定できるそうですが、自分が手元で試した際にはうまく動かなかったので、「カスタムモデルを用いた暗黙的な生成」の方を利用することをおすすめします。

2-3. 具体的なmodel resolverの使用例

ここまで model resolverの実装方法について話してきましたが、重要なのはgraph/schema.graphqls の型定義をベースにgraph/model/*.goのカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.goにmodel resolverを追加します。という部分です。 つまりオーバーフェッチがある部分に関しては、カスタムモデルから削除するだけでqueryで指定されなければ呼び出されないmodel resolverを生成することができます。

例えば以下のような場合に便利です。

Todoにタグ情報が複数紐づく場合にmodel resolverを活用

複数のRelationがあるケースです。

schema.graphqls

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
+  tags: [Tag!]!
}

+type Tag {
+  id: ID!
+  name: String!
+}

schema.resolvers.go

func (r *todoResolver) Tags(ctx context.Context, obj *model.Todo) ([]*model.Tag, error) {
    // ⭐️ TodoのIDでタグの一覧を取得する
    r.tagRepo.ListByTodoID(obj.ID)
}

一点注意点としては、 todoResolverのTagsメソッドは、以下のようにTodoを配列で取得する場合、返却するtodoの件数分呼び出されます。 つまり、有名なN+1問題が発生します。

query findTodos {
  todos {
    text
    done
    tags
  }
}

このN+1問題の解消には、dataloaderという仕組みがあり、実際にはそれで実装しているのですが、 話し始めるととても長くなるのでまたの機会にさせていただきます。 ざっくり言うと、データの呼び出しを一定期間待ち合わせた後に一斉にデータフェッチしてくる仕組みです。 気になる方は 「golang graphql dataloader」で調べてみてください。

provileURLを求められない限りは S3 アクセスしないためにmodel resolverを活用

単一のフィールドであってもCloudのインフラにアクセスする必要があるケースです。 relationだけではなく、こういった単一のフィールドであってもオーバーフェッチを避ける事ができます。

schema.graphqls

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

model/user.go

package model

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

schema.resolvers.go

func (r *userResolver) ProfileURL(ctx context.Context, obj *model.User) (*string, error) {
         // ⭐️ awsのS3にアクセスして署名付きURLを発行する
    r.userRepo.getSignedProfileURL()
}

3. 補足 graph/schema.graphqls の分割

余談ですが、実プロジェクトを回していると、schema.graphgqsはどんどん大きく膨れていってしまい、メンテナンスが大変になります。 この schema.graphgqs は簡単に分割できて、例えば、

  • query.graphgqs
  • mutation.graphgqs
  • input.graphqls
  • type.graphqls

という分割の仕方をすると、

  • query.resolvers
  • mutation.resolvers
  • input.resolvers
  • type.resolvers

というようにresolversファイルが分割され、全体の見通しをよくすることができます。 この際、model/models_gen.goに関しては分割されません。 ファイルがとても大きくなってきた際には是非試してみてください。

最後に

今日はgqlgenの基本構成とオーバーフェッチを防ぐmodel reolverの実装について書いてみました。 gqlgenの基本的な仕組みの理解の助けになったら嬉しいです。

We are Hiring

LayerXではエンジニアはもちろん、全職種で積極的に採用中です。 ぜひカジュアル面談からでもお話しましょう!

  • LayerXのEntranceBook

LayerXの事業やチームについて、もっと知りたい方はこちら layerx.notion.site

  • LayerXのMeety一覧

LayerXのメンバーと、カジュアルに話してみたい方はこちら meety.net

  • LayerXの募集ポジション一覧

実際に応募してみたい方はこちら herp.careers