gqlgen directive @goField と Global Object Identification

今回はgqlgenの機能を使って Relayの仕様であるGlobal Object Identificationの実装を行なっていく。

Global Object Identificationとは

GraphQLのクライアントライブラリRelayで定められたIDフィールドの実装方法。

relay.dev

ざっくりとした説明は 全スキーマ間(モデル、テーブル)で一意なIDを返そうという方針。

今回の場合だと Task typeのIDを返したいので、 TASK:1 のようにグローバルでユニークなIDを返したい。

この実装をgqlgen(Go) で

directive という機能を用いて実装していく。

筆者は実務でApolloClientを使用しているが、以下の理由で大変便利な為実装している。

• オブジェクトを再取得するため

• Connection を通じてページングを実装するため

• Mutation の結果を予測可能にするため

• クライアント側のもつキャッシュを適切に更新するため

参考: GraphQLスキーマ設計ガイド 第2版 - わかめの自動売り場 - BOOTH

以下実装↓

スキーマ

interface Node {
  id: ID!
}

QueryでIDを含めたオブジェクトを返したい時は上記のNode インターフェースを使用する。

type Query {
  task(id: ID!): Task!
}

type Task implements Node {
  id: ID! 
  title: String!
  note: String!
  completed: Int! # 0 or 1
  created_at: Time!
  updated_at: Time!
}

IDは ID!型であるが、gqlgen.ymlで以下を指定しているため実質string型となる。 intなどを指定すると 同じID型でもint型になる。

models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID

また、上記のIDにはdirectiveを使用していないがひとまずこのまま進める。

resolver

この状態で gqlgenコマンドを使うと下記Resolverが生成される。

func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error) {
    panic(fmt.Errorf("not implemented"))
}

ここでIDを起点にDBからレコードを検索してリターンする。

func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error) {
    var task model.Task
    if err := r.DB.First(&task, id).Error; err != nil {
        return nil, err
    }

    return &task, nil
}

しかし必要なのは TASK: ○○ というグローバルに判定できるIDなので 以下の様なコードが必要

   task.ID = fmt.Sprintf("%s:%s","TASK",task.ID)

出力結果

{
  "data": {
    "task": {
      "id": "TASK:10",
      "title": "test"
    }
  }
}

これでグローバルなIDを返すことが出来た。 ※Task()ではStringから ID情報を抜き取る実装が必要になる。

directiveを使う

ここまでの実装を gqlgenの directive機能を使って分割することができる。

スキーマに以下を追加

 # 追加
directive @goField(forceResolver: Boolean, name: String) on INPUT_FIELD_DEFINITION
    | FIELD_DEFINITION



type Task implements Node {
  id: ID!  @goField(forceResolver: true)  # 追加
  title: String!
  note: String!
  completed: Int! # 0 or 1
  created_at: Time!
  updated_at: Time!
}

その上で再びgqlgenコマンドを実行すると以下のresolverが追加される。

func (r *taskResolver) ID(ctx context.Context, obj *model.Task) (string, error) {
    panic(fmt.Errorf("not implemented"))
}

IDフィールドだけ別リゾルバーとして中身の処理を書き換えることができる。 引数には初めに実装した Task() リゾルバーの値が入ってくる。 以下の様に実装する事でグローバルなIDを返す。

func (r *taskResolver) ID(ctx context.Context, obj *model.Task) (string, error) {
    return fmt.Sprintf("%s:%s", "TASK", obj.ID), nil
}

以上でIDフィールドを別リゾルバーに分割し実装することができた。

directiveの用途

今回の実装ではそこまでdirectiveの旨味を示せていない。

ネストしたスキーマなどでDBアクセスを分離したい時directiveを使えば不要なDB処理を減らせるのが1番大きなメリットである。

例えば以下の様な感じ。

type User {
id: ID!
name: String!

Tasks: [Task!]!  @goField(forceResolver: true)
}

この様にする事でクライアントがuserのidとnameのみが欲しい時に無駄にTaskを取得するのを防ぐことができる。

雑感

directive機能でResolverを分割する方法は理解したが、

forceReolver, name の引数が何に使用されるかが良く分からない。

forceResolverはおそらく指定したtypeのResolverを使用する(なければ生成)。 今回の場合はtaskResolver。

試しにfalseにするとtaskResolverは削除された。

nameに関しては良く分からない。

directive @goField(forceResolver: Boolean, name: String) on INPUT_FIELD_DEFINITION
    | FIELD_DEFINITION

このあたりドキュメントを読んでもあまり詳しい説明がないので機会があれば深ぼっていきたいと思った。

以上