ymmooot

自動生成を活用して GraphQL の Node Query を漏れなく実装する

GraphQL には Global Object Identification という概念がある。
以下のようにグローバルユニークな ID 型を持つ Interface を定義し、いかなる Node も取得できるようにするというもの。

schema.graphqlinterface Node {
  id: ID!
}

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

type Article implements Node {
  id: ID!
  title: String!
}

query {
  node(id: ID!): Node
}

クライアントからは以下のように利用できる。

query.graphqlquery NodeQuery {
  # User:1
  user: node(id: "VlhObGNqb3g=") {
    ... on User {
      id
      name
    }
  }
  # Article:1
  article: node(id: "QXJ0aWNsZTox") {
    ... on Article {
      id
      title
    }
  }
}

これがなぜ嬉しいのかは、以下の記事などが参考になる。

Node Interface を満たす type を追加するたびに、node クエリのリゾルバーでその type を取得できるように対応する必要がある。
これを漏れなく実装する方法を紹介する。

  1. Node Enum の自動生成
  2. Node Enum によるリゾルバーの実装
  3. Node Enum の網羅性チェック

以下は Go と gqlgen で実装した例である。

Node Enum の自動生成

GraphQL のスキーマ定義をもとに、Node の Enum を自動生成する。
Go には Enum がないので string の定数として定義する。
最終的に以下のようなコードが生成されれば良い。

node_enum.gopackage enum

type Node string

const (
    NodeUser    Node = "User"
    NodeArticle Node = "Article"
}

以下にこれを生成するコードの例を載せる。
GraphQL スキーマをパースしてもいいが、今回は gqlgen が生成した Go の構造体(自動マッピングを利用している場合はその構造体)を解析することで Node Enum を生成する。
IsNode メソッドを持つを構造体の名前を抜き出しているだけ。

node/analyze.gofunc Analyze(path string) ([]string, error) {
    fset := token.NewFileSet()
    pkgs, err := parser.ParseDir(fset, path, nil, 0)
    if err != nil {
        return nil, err
    }

    pkg := pkgs["data"]
    cfg := &packages.Config{
        Mode: packages.NeedName | packages.NeedFiles | packages.NeedTypes,
    }
    ptns := []string{}
    for k := range pkg.Files {
        ptns = append(ptns, k)
    }
    pkzs, _ := packages.Load(cfg, ptns...)
    pkz, ok := lo.Find(pkzs, func(p *packages.Package) bool {
        return p.Name == "data"
    })
    if !ok {
        return nil, errors.New("package not found")
    }

    scope := pkz.Types.Scope()

    var isNodeStructNames []string
    for _, name := range scope.Names() {
        obj := scope.Lookup(name)
        named, ok := obj.Type().(*types.Named)
        if !ok {
            continue
        }

        if named.Obj().Pkg() != pkz.Types {
            continue
        }

        methods := named.NumMethods()
        for i := 0; i < methods; i++ {
            m := named.Method(i)
            if m.Name() == "IsNode" && !lo.Contains(isNodeStructNames, named.String()) {
                res := strings.Split(named.String(), ".")
                isNodeStructNames = append(isNodeStructNames, res[1])
                break
            }
        }
    }

    return isNodeStructNames, nil
}
node/generate.go//go:embed template.txt
var templateTxt string

type TemplateParams struct {
    Nodes map[string]string
}

func Generate(path string, names []string) error {
    nodesMap := make(map[string]string)
    for _, v := range names {
        nodesMap["Node"+v] = v
    }

    params := TemplateParams{
        Nodes: nodesMap,
    }

    var buf bytes.Buffer
    t := template.Must(template.New("template-txt").Parse(templateTxt))
    if err := t.Execute(&buf, params); err != nil {
        return err
    }

    formatted, err := format.Source(buf.Bytes())
    if err != nil {
        return err
    }

    return os.WriteFile(path, formatted, 0644)
}
node/template.txt// Code generated by node-gen; DO NOT EDIT.
package enum

type Node string

const (
  {{ range $key, $value := .Nodes }}
    {{- $key }} Node = "{{ $value }}"
  {{ end }}
)
main.gofunc main() {	
    source := "your_source_path"
    dist := "your_dist_path"

    dd, err := node.Analyze(source)
    if err != nil {
        panic(err.Error())
    }
    if err := node.Generate(dist, dd); err != nil {
        panic(err.Error())
    }
}

Node Enum によるリゾルバーの実装

リゾルバーではこの Node Enum を利用してユースケースを呼び分ける。

query_reolver.gofunc (r *queryResolver) Node(ctx context.Context, id data.ID) (Node, error) {  
  // string である ID 型をデコードして、Node と intID に分解する
  nodeType, intID, err := decodeID(id)
  if err != nil {
    return nil, err
  }
  
  switch nodeType {
    case NodeUser:
      return r.userUsecase.GetUserByID(ctx, intID)
    case NodeArticle:
      return r.articleUsecase.GetArticleByID(ctx, intID)
    default:
      return nil, errors.New("invalid Node")
  }
}

enum の網羅性の確認

Go には Enum がないのでビルド時に網羅性チェックはできないが、リンターを利用することでチェックできる。
nishanths/exhaustive は golangci-lint に入っているため簡単に導入できる。

まとめ

make などで適宜この自動生成を呼び出せば、Node Interface を満たす type を追加すると自動的に Node Enum が更新され、万が一漏れがあってもリンターが検知してくれる。

余談

自分はページネーションを Relay Cursor Connections で実現する時も、自動生成を使って楽をしてる。
以下のように pagination.list ファイルに列挙した型の Connection とそれに関する便利関数を生成する。

pagination.listUser
Article
pagination.gen.graphqlinterface Connection {
  pageInfo: PageInfo!
  edges: [Edge!]!
  nodes: [Node!]! # Edge から掘らなくても直接取れるようにしている。GitHub API もやってる。
}

type UserEdge implements Edge {
  cursor: String!
  node: User!
}

type UserConnection implements Connection {
  pageInfo: PageInfo!
  edges: [UserEdge!]!
  nodes: [User!]!
}

type ArticleEdge implements Edge {
  cursor: String!
  node: Article!
}

type ArticleConnection implements Connection {
  pageInfo: PageInfo!
  edges: [ArticleEdge!]!
  nodes: [Article!]!
}

これに加えて、ユースケースから受け取ったアプリケーションモデルを viewmodel.UserConnectionviewmodel.ArticleConnection に変換する Go のコードも自動生成している。(ここで viewmodel は上記の pagination.gen.graphql から gqlgen によって生成される型を含むパッケージ)


GraphQL はどうしても繰り返しが多くなるので、このような自動生成を活用するとだいぶ楽ができる。
このようなペインを解消するために、世には GraphQXL という GraphQL を拡張した書式でスキーマを書き、それを GraphQL に変換してくれるツールなどもある。(自分は使っていないけれど。)


以上です。