🙌

Go で触ってみる GraphQL

2024/12/25に公開

はじめに

こちらは Applibot Advent Calendar 2024 8日目の記事になります。

本記事では、ent, gqlgen を使った GraphQL サーバの構築について解説していきます。
ent/contrib リポジトリに GraphQL のベストプラクティスのキャッチアップが捗りそうな実装パターンがありましたので、その例を踏まえて解説していければと思います。

本記事で使用したコードはこちらに置いています。

GraphQL の基本

GraphQL とは

一言で表すと「API のクエリ言語およびそのランタイム」です。[1][2] GraphQL はクライアント側でデータ取得のクエリを定義・実行できるため、柔軟な開発を可能にすることや、無駄なデータ取得を削減することが期待できます。GraphQL には、いくつかのベストプラクティスがあります。[3] その中の一つである「Global Object Identification」を解説していきます。

Global Object Identification

Global Object Identification とは全ての型で ID が重複しないことを推奨するプラクティスです。GraphQL にはスカラー型というものが存在しており、組み込みのスカラー型には、Int, Float, String, Boolean, ID[1:1]が存在します。ID は名前の通り識別子です。下記のような二つの型が存在する場合、User, Todo の id は必ず一意であるように設計することが重要です。

type User {
 id: ID!
}
type Todo {
 id: ID!
}

Node Query

GraphQL の ID の特性を活かし、定義されるのが Node interface と node Query です。GraphQL では、単体取得を行う際このクエリ経由で全ての情報を取得できるように設計することがベストプラクティスをされてます。

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
  nodes(ids: [ID!]!): [Node]!
}

実践

再掲: 今回使用するソースコード

ディレクトリ構成

├── ent
│   ├── entc.go        # ent x gqlgen を組み合わせて使う自動生成ツール
│   ├── schema         # 管理するデータのスキーマを管理するディレクトリ
│   │   ├── puuid      # prefix uuid 用の mixin を定義しているディレクトリ
│   │   └── todo.go    # DBスキーマ定義
│   ├── puuid.go       # prefix から参照先のテーブルを特定する実装
├── ent_gen.graphql    # entc により自動生成されるファイル(編集対象ではない
├── gqlgen.yml         # gqlgen の設定ファイル
├── server             # http server を起動するための main 処理
├── todo.graphql       # entc によって生成される GraphQL 以外に独自で作成したいGraphQLを定義
└── todo.resolvers.go  # gqlgen によって生成されるファイル

PUUID

PUUID は ID をグローバルで一意にするための実装です。
prefix が一意であることによって、DB のテーブルが分かれている場合でも ID をグローバルで一意にすることができます。

ent/schema/puuid/mixin.go
func (m Mixin) Fields() []ent.Field {
  return []ent.Field{
    field.String("id").
      GoType(ID("")).
      DefaultFunc(func() ID { return MustNew(m.prefix) }),
  }
}
ent/schema/puuid/puuid.go
func MustNew(prefix string) ID { return ID(prefix + fmt.Sprint(newUUID())) }
ent/schema/todo.go
func (Todo) Mixin() []ent.Mixin {
  return []ent.Mixin{
    // "TD" declared once.
    puuid.MixinWithPrefix("TD"),
  }
}

Node Query の実装

entgql extension を使用して、ent の自動生成を行うと gql_node.go というファイルが自動生成されます。
そのファイルの中身を見てみましょう。

ent/gql_node.go
// WithNodeType sets the node Type resolver function (i.e. the table to query).
// If was not provided, the table will be derived from the universal-id
// configuration as described in: https://entgo.io/docs/migrate/#universal-ids.
func WithNodeType(f func(context.Context, puuid.ID) (string, error)) NodeOption {
  return func(o *nodeOptions) {
    o.nodeType = f
  }
}

// Noder returns a Node by its id. If the NodeType was not provided, it will
// be derived from the id value according to the universal-id configuration.
//
//  c.Noder(ctx, id)
//  c.Noder(ctx, id, ent.WithNodeType(typeResolver))
func (c *Client) Noder(ctx context.Context, id puuid.ID, opts ...NodeOption) (_ Noder, err error) {
  defer func() {
    if IsNotFound(err) {
      err = multierror.Append(err, entgql.ErrNodeNotFound(id))
    }
  }()
  table, err := c.newNodeOpts(opts).nodeType(ctx, id)
  if err != nil {
    return nil, err
  }
  return c.noder(ctx, table, id)
}

この実装を読み解くと、ID 情報から自動で参照先のテーブルを特定することが可能になることがわかります。
puuid の実装で ID には prefix が含まれており、その prefix はテーブルに紐づくため、prefix からテーブル名を導く実装を用意しています。

ent/puuid.go
var prefixMap = map[puuid.ID]string{
  "TD": todo.Table,
}

// IDToType maps a pulid.ID to the underlying table.
func IDToType(ctx context.Context, id puuid.ID) (string, error) {
  if len(id) < 2 {
    return "", fmt.Errorf("IDToType: id too short")
  }
  prefix := id[:2]
  typ := prefixMap[prefix]
  if typ == "" {
    return "", fmt.Errorf("IDToType: could not map prefix '%s' to a type", prefix)
  }
  return typ, nil
}

これらを踏まえての Node Query の実装は下記のようになってます。
新しい型の追加をする際は、ent のスキーマを定義することと IDToType に prefix を追加することの二つのでデータの取得が可能となります。とても強力ですね。

ent_gen.resolvers.go
// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id puuid.ID) (ent.Noder, error) {
  return r.client.Noder(ctx, id, ent.WithNodeType(ent.IDToType))
}

// Nodes is the resolver for the nodes field.
func (r *queryResolver) Nodes(ctx context.Context, ids []puuid.ID) ([]ent.Noder, error) {
  return r.client.Noders(ctx, ids, ent.WithNodeType(ent.IDToType))
}

動作確認

サーバ起動し、localhost:8081 にアクセスすると GraphQL の Playground が表示されます。

go run server/server.go

Todo 作成の mutation は用意してありますので、下記の mutation を実行し Todo の作成を行います。

mutation
mutation {
  createTodo(input: {
    status: PENDING
    priority: 1
    text: "TaskA"
    value: 1
  }) {
    status
    id
  }
}

作成された Todo の ID を控えておき、node, nodes クエリを実際にデータを取得してみましょう。

query
query($id: ID!) {
  node(id: $id) {
    id
    __typename
  }
  nodes(ids: [$id]) {
    id
    __typename
  }
}


取得されることが確認できました🎉

まとめ

今回は Node Query, Global Object Identification を中心に Go x GraphQL 紐解いてみました。
ent の gqlgen インテグレーションはとても強力で今日紹介した機能の他にも、フィルタリング用のコードの自動生成や、Mutation で使用する Input の自動生成など、コードを書かずに柔軟なクエリが実現できる実装がたくさんあります。
気になった方はぜひこの機会に触ってみてはいかかでしょうか。

参考記事

脚注
  1. https://spec.graphql.org/October2021/ ↩︎ ↩︎

  2. https://graphql.org/ ↩︎

  3. https://graphql.org/learn/best-practices/ ↩︎

Discussion