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
}
}
}
これがなぜ嬉しいのかは、以下の記事などが参考になる。
- https://graphql.org/learn/global-object-identification/
- https://dev.to/zth/the-magic-of-the-node-interface-4le1
Node Interface を満たす type を追加するたびに、node クエリのリゾルバーでその type を取得できるように対応する必要がある。
これを漏れなく実装する方法を紹介する。
- Node Enum の自動生成
- Node Enum によるリゾルバーの実装
- 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.UserConnection
や viewmodel.ArticleConnection
に変換する Go のコードも自動生成している。(ここで viewmodel
は上記の pagination.gen.graphql
から gqlgen によって生成される型を含むパッケージ)
GraphQL はどうしても繰り返しが多くなるので、このような自動生成を活用するとだいぶ楽ができる。
このようなペインを解消するために、世には GraphQXL という GraphQL を拡張した書式でスキーマを書き、それを GraphQL に変換してくれるツールなどもある。(自分は使っていないけれど。)
以上です。