Go + gqlgenを使ったGraphQLアプリケーションサーバーの実装

MF KESSAIでバックエンドのエンジニアをやっている@garsueです。 先日、社内向けサービスの新規開発でGraphQLを採用することになりました。 今回はその経緯や実装方法についていくつか参考記事を交えながら紹介していきます。

なぜGraphQLか

今回新規で開発するサービスは以下のような特徴があります。

  1. MF KESSAIの内部は複数のサービスに分かれていて、それらをふんだんに利用する
  2. 社内向けなので直近でそこまで高負荷になる見込みはない
  3. フロントエンドとバックエンドのすり合わせにあまり時間をかけたくない(そんなに時間はない)

まず複数サービスとの協調という点について、マイクロサービスをベースとしたGraphQLとGoによる開発を紹介した記事Using GraphQL with Microservices in Goにある内容をそのまま適用できそうだなというところからGraphQLを検討し始めました。

懸念点としてGraphQL(に限らずグラフデータ構造に対するクエリは一般的に)は複雑で現実的な時間で結果が返ってこないことがままあります。1 そういった事情からいきなりGraphQLを外部に公開するサーバーで実戦投入するのはちょっと怖かったのですが、今回は社内向けでユーザーも限られるため導入の第一弾としても丁度良かったです。

一方で社内向けサービスの開発にそこまで工数をかけていられないという事情もあり、スピード感のある開発ができることも重要です。 前述のUsing GraphQL with Microservices in Goで紹介されているgqlgenはSchema firstのライブラリで、GraphQL Schemaから大部分のボイラープレートコードを生成してくれます。 そのおかげで開発者は基本的に生成されたインターフェイスに基づいてドメインロジックの実装に注力できます。

GraphQL Schemaはクライアント側(ブラウザのフロントエンドやスマートフォンアプリなど)のライブラリでもコード生成に使えるのでサービス全体の開発効率もあがります。 定義を共有するという点ではProtocol BuffersやOpen APIと似ていますが、GraphQLの場合はSchemaをもとにクライアント側で任意のクエリを記述できる点が異なります。

今回はWebのフロントエンドのGraphQLライブラリとしてapolloを採用していたので、フロントエンドではapollo-cliでコード生成することになりました。 このあたりは後日フロントエンドエンジニアの同僚がブログにしてくれるかも?

gqlgen

今回採用したGoのGraphQLライブラリの選定理由と gqlgen の機能について紹介します。

GoでGraphQLサーバーを実装するにあたって、まず検討するのはgraphql.orgでも筆頭で紹介されているgraphql-go/graphqlあたりになるかと思います。 graphql-go/graphqlは基本的にSchemaを利用することはなく、コード内でSchemaを記述していくことになります。

今回GraphQLを採用した理由がSchemaによるところが大きかったため、graphql-go/graphqlは採用しませんでした。 Issueを見る限りやはりニーズはありそうなので、今後何か実装されるかもしれません。

Schemaをフルに活用したいとなると、gqlgenが最有力候補に挙がってきます。 生成されるコードもリフレクションを使ったり、interface{}を連発して型情報が落ちたりしていないため、読みやすく扱いやすいです。

生成されるモデルの代わりにユーザーが定義した構造体をベースにコード生成させることも可能です。 この機能が強力で、GraphQLによるアプリケーションサーバーを実装する上で大きな手助けになります。

基本的な使い方はオフィシャルサイトのGetting Startedに譲るとして、ここではMF KESSAIでの使い方を紹介していきます。

生成の基本パターン

gqlgenでコード生成する場合、以下のような生成パターンがあります。2

  • 完全にschemaからモデルのコードを生成させるパターン
  • モデルを生成させる代わりに既存の構造体を指定するパターン
  • 子フィールドを取得するResolverを生成させるパターン

上記のパターンの使い分けをMF KESSAIでの使い方に則して説明していきます。

説明の前提として以下のようなSchemaをschema.graphqlとして保存し、gqlgen initしたものとします。

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  name: String!
  groups(left: Boolean! = false): [Group!]!
}

type Group {
  id: ID!
}

Userを引く際に所属するgroups一覧を引いてくる、場合によっては脱退した(left=true)groupsも引いてくるようなユースケースを想定したSchemaです。

完全にschemaからモデルを生成させるパターン

まずは既存の構造体との紐付けを一切行わず、完全にgqlgenにコード生成させる場合です。 この場合、以下のようなモデルが生成されます。

type Group struct {
	ID string `json:"id"`
}

type User struct {
	ID     string  `json:"id"`
	Name   string  `json:"name"`
	Groups []Group `json:"groups"`
}

愚直にそのままGoの構造体に翻訳された感じですね。 Resolverは以下のようになります。

type ResolverRoot interface {
	Query() QueryResolver
}

type QueryResolver interface {
	User(ctx context.Context, id string) (*User, error)
}

こちらも素直な出力結果ですね。アプリケーション側では上記のResolverインターフェイスを実装することで開発を進めていきます。

このようなシンプルなResolverはクエリで引いてくるオブジェクトの子フィールドがScalarもしくは親オブジェクトと必ず一緒に取得されるオブジェクトのみで構成される場合で有用です。

上記の例で言えば、クライアントから投げられたクエリでgroupが指定されているかどうかによらず、内部的には必ず取得するような場合です。

GraphQLサーバーから呼び出すDBや外部サービスが常に子フィールドも含めて返してしまうような場合はこの形式のResolverを使うとシンプルになりそうです。

デメリットとしてSchema上で定義されているUsergroupフィールドに対する脱退フラグの引数leftは直接コード上に現れてきません。 リクエストコンテキスト内のResolverContextから指定された引数にアクセスできるみたいですが(未検証)、気軽にアクセスできるとは言えません。

その場合は次のパターンを使って定義済みの構造体にマッピングさせることで改善できます。

モデルを生成させる代わりに既存の構造体を指定するパターン

gqlgenではアプリケーション側で定義済みの構造体をモデルとしてResolverを生成することもできます。 ここではSchema上のUserGroupに対応するものとして以下のような構造体がアプリケーション側で定義されているとします。

type User struct {
	ID     string
	Name   string
	groups []Group
}

type Group struct {
	ID      string
	members []User
}

func (u *User) Groups(left bool) []Group {
	if left {
		return u.groups
	}
	var groups []Group
	for _, group := range u.groups {
		for _, member := range group.members {
			if u.ID == member.ID {
				groups = append(groups, group)
				break
			}
		}
	}
	return groups
}

Groupsがメソッドとして実装されていて、leftフラグがfalseの場合は現在メンバーとして参加しているGroupのみを返します(非現実的な実装ですみません 🙏)。

この構造体をResolverのモデルとして指定するため、以下の設定ファイルgqlgen.ymlを作成します(パッケージ名github.com/garsue/graphql-example/gqlは適宜読み換えてください)。

schema: schema.graphql

models:
  User:
    model: github.com/garsue/graphql-example/gql.User
  Group:
    model: github.com/garsue/graphql-example/gql.Group

この設定とともにgqlgenを実行するとモデルは生成されず、Resolverのみ生成されます。出来上がるResolverは指定したモデルを利用するものになっています。 さらに、生成されたコードを見るとアプリケーション側で定義したGroupsメソッドも呼び出されている事がわかります。

func (ec *executionContext) _User_groups(ctx context.Context, field graphql.CollectedField, obj *User) graphql.Marshaler {
	rawArgs := field.ArgumentMap(ec.Variables)
	args := map[string]interface{}{}
	...(中略)...
	resTmp := ec.FieldMiddleware(ctx, func(ctx context.Context) (interface{}, error) {
		return obj.Groups(args["left"].(bool)), nil
	})
	...(中略)...
}

これでフィールドに対する引数についても扱えるようになりました。

しかし、このパターンにも欠点があります。

例示したGroupメソッドは、ユーザーがメンバーになったことがあるgroupを全件取得済みであることを前提した実装になっていて現実的ではありません。 こうならざるを得なかったのはUser取得時点で、Userに対するあらゆるクエリに対応する必要があったためです。

つまりGroupフィールドが指定されていないクエリだったとしても常にgroupを引かなければならず、GraphQLの利点を活かせていません。

柔軟にフィールドの取得を制御するためには、Groupを取得するResolverを生成させる次のパターンが有効です。

子フィールドを取得するResolverを生成させるパターン

最後に、子フィールドを取得するResolverも生成させるパターンを紹介します。 このパターンが最も強力でgqlgenのよくできているところです。

子フィールドを取得するResolverを生成させるには、アプリケーション側で親オブジェクトのモデルと子フィールドのモデルを直接紐付けなければいいだけです。

以下のような構造体がアプリケーション側で定義されているとします。

type User struct {
	ID     string
	Name   string
}

type Group struct {
	ID      string
}

UserGroupsフィールドもGropusメソッドもなく、この定義だけではUserGroupを紐付ける手がかりがありません。

この状態で先程と同様に生成すると、以下のResolverインターフェイスが生成されます。

type ResolverRoot interface {
	Query() QueryResolver
	User() UserResolver
}

type QueryResolver interface {
	User(ctx context.Context, id string) (*User, error)
}

type UserResolver interface {
	Groups(ctx context.Context, obj *User, left bool) ([]Group, error)
}

新たにUserResolverというResolverが生成されました。

UserResolverUserをもとに、Groupを引いてくるGroupsメソッドを持っています。 フィールド引数のleftも渡ってきていますね。 このGroupsメソッドがUser取得時のクエリに応じて呼び出されます。

あとはUserResolverを実装として、その中で親オブジェクトやフィールド引数の条件にあったGroupリストを取得するコードを記述するだけです。 このGroupsメソッドはクエリでgroupsが指定された場合のみ呼び出されるため、不必要にDBや外部サービスへ問い合わせることもありません。

基本的に子フィールドとして何らかのドメインオブジェクトを含む集約的なオブジェクトに対してはこのパターンのResolverを生成させることになるかと思います。

逆にScalarや値オブジェクト(座標、日付など)のみで構成されるオブジェクトの場合は自動生成させてしまってもいいかもしれません。

MF KESSAIでの使い方

まだ使い込んでいないので決してベストプラクティスではありませんが、MF KESSAIにおけるgqlgenの使い方を紹介します。

基本方針としてGraphQLサーバーを薄く保ちたいため、なるべく手でロジックを書くことを避けて、可能な限りgqlgenのコード生成に寄せています。

その中で他のオブジェクトをまとめる集約的なオブジェクトに対しては専用のResolverを生成させています。

Resolverの実装も不要なロジックが入り込まないようになるべく薄く、条件に応じたデータを取って返すだけになっています。 何らかのドメインロジックが必要になった場合でも、そもそもそのような状況になっている事自体が間違いだと捉え、クライアント側で解決したりGraphQLサーバー依存する外部サービス側で解決したりしています。

課題

GraphQLサーバーを実装するにあたってのよくある課題についても紹介します。

クライアントはクエリをいくらでも複雑にできる

「クライアントはクエリをいくらでも複雑にできる」とは、例えば「ユーザーが所属するグループを取得、そのグループに所属する他のユーザーを取得、そのユーザーが所属する別のグループを取得、そのグループに(以下略)」のような循環するクエリを書けてしまうことです。

この問題に対する対応としてはクエリ内のネスト回数を制限するなどになるかと思います。

gqlgenであればmiddlewareとしてネスト回数の制限機構を記述できるような気もするのですが、まだまだ手が回っていないところです。

n+1問題

GraphQLではフィールド単位でデータの取得が行われるのでn+1問題が容易に発生します。

gqlgenではその対応としてdataloadenの利用を推奨しています。 やりたいこととしてはdataloaderと同じで、データ取得結果をキャッシュして外部への問い合わせを極力減らそうというものです。

が、こちらもまだまだ検証できておらず未評価です。こちらについてはまた別途ご紹介できればと思います。

おわりに

MF KESSAIでのGraphQLの導入状況については書いたとおり、まだまだ模索段階です。 今後また新たな知見が得られたらこちらのブログにて紹介したいと思います。

もっと詳しい話を聞きたい方、GraphQLを仕事で使いたい方、Goで開発したい方はぜひお気軽にMF KESSAIへ遊びにきてください! (ご連絡はこちらから)

参考リンク


  1. この話題についてはMisreading ChatのEpisode 12 – Semantics and Complexity of GraphQLで紹介されているのでご興味のある方は聞いてみることおすすめします。 [return]
  2. この分類は筆者が勝手に定義したものです。 [return]