💡

GraphQL(gqlgen) の directive で事前条件、事後条件を実装する

2023/05/08に公開

こんにちは。久しぶりに趣味でタイピングゲームに関するライブラリを開発している typer です。

テラーノベルでも遅まきながらバックエンド API を GraphQL に移行することになったので、GraphQL の表現力を活かしてドメインの関心事をスキーマに落とし込むことに四苦八苦している毎日です。今回は、こんな directive 作ってみたら解決したい問題にジャストフィットしたよ、というのを紹介していきます。

GraphQL 移行開始

2023年春現在、テラーノベルではバックエンド API を protocol buffer (protobuf) で定義しています。protobuf = gRPC みたいなイメージがありますが、Google App Engine(GAE) が gRPC 通信に非対応だったことやランタイムの Go のバージョンが低かったこともあって、twirp というフレームワークを使って API を実装していました。

数年前から GraphQL に移行したいと考えていたのですが、バックエンドチームが充実してきたことや gqlgen が要求する Go のバージョンにようやくGAE の Go ランタイムが追いついたこともあって、この春ついに gqlgen を使って GraphQL に移行を始めることになりました🎉🎉🎉

なぜGraphQL?

大部分のスキーマを書き終わった今の段階で感じていることとしては、このドメインに現れる概念をグラフ構造として捉えることで、極めてシンプルかつ自然にスキーマに落とし込めて気持ちいい!ということです。

REST APIを作ろうとするとどうしても「APIをシンプルにしよう」とか「重複した機能を排除しよう」、「リクエスト回数減らして高速化しないと」といったことを考える場面が多いです。例えば、下記はテラーノベルに出てくる User, Story, Comment の関連だけを表した図です。

  • User が投稿した Story リストを取得したい
  • Story に投稿された Comment リストを取得したい
  • User が投稿した Comment リストを取得したい
  • User が投稿した Story ごとに Comment リストを3件ずつ取得したい

例えばこういった機能を実現する REST API を作成する際、上3つはそれほど悩むことはないですが、4つ目の実現方法を考えると「状況による」と思います。
UI の変更によって、こういった情報取得が必要になる場面は頻繁に発生するのですが、1番目のAPIと2番目のAPIを組み合わせてもらうのか、あるいはCommentまで結合して結果を返す新しいAPIを用意するのか、みたいな選択肢を考慮する必要があります。

一方で、GraphQL であればそれぞれの概念の関連をフィールドで表すだけで素直に表現できるので、こういった問題に関してあれこれ悩む必要がない、というのがもっとも嬉しいポイントかもしれません。

directive による事前条件の表明

グラフ構造を表現できることに加えて、さらにスキーマの表現力を高めているのが directive です。Query や特定のフィールドにアクセスさせて良いかどうかといった認証・認可が必要になるケースで使われることが多いと思います。

directive @isAuthenticated on FIELD_DEFINITION

type Query {
  user(name: String!): User @isAuthenticated
}

さき(H.Saki)さん「Goで学ぶGraphQLサーバーサイド入門」より
https://zenn.dev/hsaki/books/golang-graphql/viewer/auth

この directive を gqlgen に則って実装すると下記のようになります。user フィールドを解決するリゾルバ関数が next という引数で渡され、それを呼び出すかどうかを directive 関数内で制御できます。

ここでは、next 呼び出しの前に認証チェック処理を入れることで、未認証状態でこのフィールドにアクセスされたときにエラーを返しています。

func IsAuthenticated(ctx context.Context, _ interface{}, next graphql.Resolver) (res interface{}, err error) {
	// HTTP Request Header を見て認証チェックする
	if !hasAuthToken(ctx) {
		return nil, errors.New("not authenticated")
	}
	return next(ctx)
}

テラーノベルのスキーマでも、管理者ユーザのみがアクセス可能な Query やフィールド、あるいは投稿者本人のみがアクセス可能なフィールド(例えば下書き中の非公開作品情報を取得する)といった概念を directive で表現しています。

directive によって「フィールドに対する共通の前処理・後処理をかけることができる、という実装の共通化」を実現できるのはメリットですが、それ以上に、APIを利用するにあたって、たびたび登場する事前条件がスキーマ上で表明されていることで、APIを利用する側に対してすごく優しい(易しい)作りにできる、というのが嬉しいポイントです。

directive によるレスポンスのフィルタリング(事後条件の表明)

さて本題です。先ほどの directive の例は「あるフィールドにアクセスして良いかどうか」という認可に関するものでしたが、directive はリゾルバを呼び出すかどうかを判断するだけでなく、リゾルバの結果に対する任意の処理を挟み込むこともできます。この仕組みによって、テラーノベル上でうまく directive に落とし込むことができそうな関心事がありました。

それが、 ブロックしているユーザやペナルティユーザのコンテンツの表示制限 です。

あるユーザが検索したときに、特定のユーザが投稿した作品やコメント、ユーザ情報そのものを検索結果に表示したくない場合があります。これをどのように実装するかというのも千差万別だと思いますが、テラーノベルでは下記のように directive として実装しています。[1]

"""
下記の機能を実現する directive
- 認証ユーザがブロックしているユーザによるコンテンツを除外する
- ペナルティユーザによるコンテンツを除外する
HasUserId を implements している要素を持つ配列フィールドに対してのみ適用可能
"""
directive @userFilter on FIELD_DEFINITION

"ユーザに属するものを表す"
interface HasUserId {
  userId: ID!
}

type User implements HasUserId {
  id: ID!
  userId: ID!
  name: String!
  stories: [Story!]!
}

type Story implements HasUserId {
  id: ID!
  userId: ID!
  user: User!
  text: String!
  comments: [Comment!]! @userFilter
}

type Comment implements HasUserId {
  id: ID!
  userId: ID!
  user: User!
  text: String!
  story: Story!
}

type Query {
  searchUsers(query: String!): [User!]! @userFilter
  searchStories(query: String!): [Story!]! @userFilter
}

searchUsers Query を実行すると、レスポンスにはペナルティユーザやブロックしている User が含まれないことをスキーマで表現しています。また、同様に Storycomments を取得する際にも、ペナルティユーザやブロックしているユーザによる Comment 投稿が含まれません。

これを実現する gqlgen の directive の実装は下記のようになります。該当のフィールドが解決された後(next が呼び出された後)に、その結果に対して reflection を使って、GraphQL 上の interface を実装した型の要素に対してフィルタリングをかけています。

func UserFilter(ctx context.Context, _ interface{}, next graphql.Resolver) (res interface{}, err error) {
	res, err = next(ctx)
	if err != nil {
		return nil, err
	}
	v := reflect.ValueOf(res)
	if v.Kind() != reflect.Slice {
		return nil, errors.New(fmt.Sprintf("UserFilter failed, res is not slice of UserId: %#v", res))
	}
	filteredSlice := reflect.MakeSlice(reflect.SliceOf(v.Type().Elem()), 0, v.Len())
	for i := 0; i < v.Len(); i++ {
		if o, ok := v.Index(i).Elem().Interface().(model.HasUserID); ok {
			if o != nil {
				// ペナルティユーザかどうか、ブロックしているユーザかどうかによってフィルタリングする
				if o.GetUserID() == "u2" {
					filteredSlice = reflect.Append(filteredSlice, v.Index(i))
				}
			}
		} else {
			return nil, errors.New(fmt.Sprintf("UserFilter failed, res is not slice of UserId: %#v", res))
		}
	}
	return filteredSlice.Interface(), nil
}

UserFilter 関数や next の呼び出しの流れは下図のようになっています。

  • reflection を使うことに若干のパフォーマンスの懸念がある[2]
  • レスポンスが歯抜けになる場合がある[3]
  • スキーマ上で静的に「hasUserId interface を満たした型の配列にのみ userFilter を適用可能」ということを制限できない[4]

といった問題はあるものの、「表示すべきでないユーザに属するコンテンツをフィルタリングする」という重要な関心事に名前をつけて抽出し、スキーマで表現できるようになるのが大きなメリットであると感じています。

まとめ

GraphQL に移行するにあたって、directive による認証・認可といった事前条件の表明だけでなく、特定の条件でレスポンスをフィルタリングするという事後条件の表明をできるようにすることで、より多くのドメインの関心事をスキーマに落とし込む方法を紹介しました。今後も GraphQL 移行にまつわる取り組みや状況をまとめていこうと思います。

脚注
  1. 実際は Connection として定義するフィールドもありますが、ここでは説明のため配列として定義しています ↩︎

  2. 具体型を指定して type switching したり、それぞれの具体型に合わせたコード生成するのもありだと思いますが、reflection を使うことで、スキーマ変更に合わせてコード変更しなくて良いのがメリットです ↩︎

  3. こういった場合に歯抜けにならないようにするにはかなりの複雑さの導入とパフォーマンスを犠牲にする必要がある認識です ↩︎

  4. これに関してはGraphQLスキーマの静的解析で解決できるとは思います ↩︎

テラーノベル テックブログ

Discussion