トップ ブログ ent + gqlgenによる爆速GraphQLバックエンド開発

ent + gqlgenによる
爆速GraphQLバックエンド開発

2025/01/30

本記事は、NRIエンジニアによって2023年12月22日にQiitaに投稿された記事です。

はじめに

この記事では、バックエンドのコード生成が可能なGoのライブラリentgqlgenを使用して、爆速&省エネでGraphQLバックエンドの開発を行います。
GraphQLを用いたバックエンドの開発負担を、可能な限り軽減することが目的です。

自分用の備忘録でもあるため、蛇足な説明があるかもしれないです。ご容赦ください。

なぜGoでGraphQLバックエンドを開発するのか

GraphQLは、クライアント側で必要なデータの形式や量を柔軟に指定できるAPI用のクエリ言語です。そのため、REST APIとは異なり、必要最低限のリクエストで必要なデータだけを抽出するすることができます。(オーバーフェッチの排除)

一方、リクエストパターンが増加することで、その分サーバー側の開発の負担が増加するというデメリットがあります。

そこで、サーバー側の開発負担を軽減しようと考えるのが自然かと思います。その際、Goはバックエンド機能に関してコード生成可能なライブラリが多くサポートされているため、相性の良い開発言語と言えます。

今回はGoのライブラリとして、ORMにent(拡張パッケージとしてentgql)、GraphQLサーバにgqlgen、DBにmodernc.org/sqliteを使用しました。

爆速&省エネでやりたいこと

本記事の実施内容の概要を、以下の図で示しました。

実施手順は次のようになります。
①. 手動でentのスキーマを定義
②. entgqlでGraphQLスキーマ定義とDB接続で用いるCRUD APIコードを生成
③. gqlgenでリゾルバのスケルトンコードを生成
④. 手動でリゾルバの内部処理を実装(CRUD APIコードを使用)

本記事でやりたいことは、GraphQLバックエンドの開発にあたって、実装部分をできるだけ自動生成に置き換えることです。

各ライブラリの簡単な説明は以下になります。

  • gqlgen:GraphQLのスキーマ定義からリゾルバの自動生成(スケルトンコード)が可能
  • ent:RDBのデータをグラフ型構造にマッピングすることでGraphQLによるDB操作が可能
  • entgql:entの拡張パッケージ。GraphQL integrationという機能によって、entのスキーマ定義からGraphQLのスキーマ定義の自動生成が可能

動作環境

言語/ライブラリ等 名称 バージョン
言語

Go

1.20

GraphQLサーバ

gqlgen

0.17.41

ORM

ent

0.12.5

entの拡張パッケージ

entgql

0.4.5

DB

modernc.org/sqlite

1.28.0

1. プロジェクト作成

今回のプロジェクト名はexampleとしました。

mkdir go_study
cd go_study
go mod init example

2. インストール

まず、必要なライブラリをインストールします。

go get github.com/99designs/gqlgen
go get entgo.io/ent/cmd/ent
go get entgo.io/contrib/entgql

ただし、modernc.org/sqliteは後ほどインストールします。
途中でgo mod tidyを行うため、その時には不要とされ削除されてしまうためです。
以下の記事のように、build tagをつけることでビルド対象外とすることもできます。

3. entによるエンティティの作成

まず、次のコマンドで新規エンティティ(Todo)のスケルトンコードを生成します。

go run -mod=mod entgo.io/ent/cmd/ent new Todo

すると、entディレクトリ下にTodoエンティティに関連するファイルが生成されます。

その中の、ent/schema/todo.goは、Todoエンティティの定義ファイルです。
Todoエンティティの具体的な中身は未定義状態のため、手動で実装していきます。
今回は以下のように定義しました。

ent/schema/todo.go

package schema

import (
  "entgo.io/contrib/entgql"
  "entgo.io/ent"
  "entgo.io/ent/schema"
  // "entgo.io/ent/schema/edge"
  "entgo.io/ent/schema/field"
)

// Todo holds the schema definition for the Todo entity.
type Todo struct {
  ent.Schema
}

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
  return []ent.Field{
    field.String("name"),
    field.String("email").
      NotEmpty().
      MaxLen(255),
    field.Bool("done").
      Default(false),
  }
}

// Edges of the Todo.
func (Todo) Edges() []ent.Edge {
  return nil
}

func (Todo) Annotations() []schema.Annotation {
  return []schema.Annotation{
    entgql.QueryField(),
    entgql.Mutations(entgql.MutationCreate(), entgql.MutationUpdate()),
  }
}

ここで、Fields()はTodoテーブルの各フィールドのデータ型や制限などを定義し、Edges()はTodoテーブルと他テーブルの関係性を定義します。
また、Annotations()では紐づけるテーブルや自動生成するリゾルバを定義できます。

今回は簡単な動作確認が目的なので、Edges()の定義は省略します。

4. entgqlによるGraphQLのスキーマ定義ファイルの生成

entの拡張パッケージであるentgqlを有効にすることで、entのスキーマ定義とGraphQLのスキーマ定義を紐づけることができます。entgqlを有効にするには、entc (ent codegen) パッケージを使用する必要があります。

まず、ent/entc.goというGoファイルを新規作成し、以下の内容を記述します。

ent/entc.go

// +build ignore

package main

import (
    "log"

    "entgo.io/ent/entc"
    "entgo.io/ent/entc/gen"
    "entgo.io/contrib/entgql"
)

func main() {
  entGqlEx, err := entgql.NewExtension(
        entgql.WithConfigPath("../gqlgen.yml"),
    entgql.WithSchemaGenerator(),
    entgql.WithWhereInputs(true),
    entgql.WithSchemaPath("../graph/schema/todo.graphqls"),
  )
    if err != nil {
        log.Fatalf("creating entgql extension: %v", err)
    }
    if err := entc.Generate("./schema", &gen.Config{}, entc.Extensions(entGqlEx)); err != nil {
        log.Fatalf("running ent codegen: %v", err)
    }
}

公式チュートリアルのままでは、entのスキーマからGraphQLのスキーマを自動生成できません。そこで、entgql.NewExtensionにオプションを付けることで、entのスキーマからGraphQLのスキーマの自動生成が可能になり、より一層の省力化が実現できます。

このとき、entgql.WithConfigPathには、この後登場するgqlgen.ymlのパスを指定します。
また、entgql.WithSchemaPathには、自動生成されるGraphQLのスキーマ定義ファイルの配置したいパスを指定します。ここでは、graph/schema/todo.graphqlsとしたため、graph/schemaディレクトリが必要になります。

mkdir graph/schema

entgqlでは、entgql.WithSchemaPathで指定したディレクトリが存在しない場合、エラーになります。

その場合は、手動で当該ディレクトリを作成してください。

これで、entgqlの準備が整いました。

5. gqlgenによるリゾルバの生成

次に、gqlgenのinitコマンドでgqlgen.ymlserver.goのボイラープレートを取得します。

go run github.com/99designs/gqlgen init

initコマンドでは、graphディレクトリにgenerated.goresolver.goschema.graphqlsschema.resolvers.gomodel/models_gen.goが生成されますが、後ほどgqlgen.ymlを書き換え、generateコマンドで再生成するため、一旦削除します。

この時、ディレクトリ構成は以下のようになっているかと思います。

go_study/
  ├ ent/
  │ └ ...
  ├ graph/
  │ └ schema/
  ├ go.mod
  ├ go.sum
  ├ gqlgen.yml
  └ server.go

そして、gqlgen.ymlを編集し、生成されるファイルの配置したいパスを指定します。
今回は、gqlgen.ymlを以下のように編集しました。(一部抜粋)

gqlgen.ymlの一部抜粋

# Where are all the schema files located? globs are supported eg  src/**/*.graphqls
schema:
  - graph/schema/*.graphqls

# Where should the generated server code go?
exec:
  filename: resolver/generated.go
  package: resolver

# Where should any generated models go?
model:
  filename: resolver/model/models_gen.go
  package: model

# Where should the resolver implementations go?
resolver:
  layout: follow-schema
  dir: resolver
  package: resolver
  filename_template: "{name}.resolvers.go"
  # Optional: turn on to not generate template comments above resolvers
  # omit_template_comment: false

それぞれの項目では、以下のような内容を指定します。

  • schema: 読み込むgraphqlsファイルが配置されているパス
  • exec: generated.goファイルを配置したいパスとパッケージ名
  • model: models_gen.goファイルを配置したいパスとパッケージ名
  • resolver: リゾルバの実装ファイルを配置したいパスや名前テンプレートなど

ここでは、

  • graphqlsファイルはgraphディレクトリ下に配置
  • それ以外のGoファイルはresolverディレクトリ下に配置

となるようにしました。

schemaではentgql.WithSchemaPathで指定したパスを含む必要があります。
graphqlsファイルはまだ存在しませんが、entgqlによって生成されるためです。
(後ほどentgqlgqlgenの順に実行します)

gqlgen実行時に、schemaで指定した先にgraphqlsファイルが存在しない場合、graphqlsファイルが読み込めなかったという直接的なエラーではなく、その依存関係先で生じたエラーが出力されます。

私の場合は、modelの指定パスが読み込めない、というエラー内容でした。
そのため、実はschemaのパス設定に問題があるにもかかわらず、modelのパス設定等に問題があると思い、一度行き詰まってしまいました。

これで、gqlgenの準備も整いました。

6. go generateの実行

go generateコマンドでentc.gogqlgenのgenerateコマンドが順次実行されるように、既存のent/generate.goを以下のように編集します。

ent/generate.go

package ent

//go:generate go run -mod=mod entc.go
//go:generate go mod tidy
//go:generate go run -mod=mod github.com/99designs/gqlgen generate

これで、GraphQLバックエンドに必要なコードの大部分を自動生成することができます。
ただし、entc.goの後にgo mod tidyの実行を求められるため、事前に記述しました。

ではgo generateを実行しましょう。

go generate ./ent

entc.goの実行

ent/schema/todo.goで定義されたentのスキーマ定義から以下の2点が生成されます。

  • DBアクセス・操作に必要なGoファイル(CRUD APIコード)
  • GraphQLのスキーマ定義ファイル (graph/schema/todo.graphqls)

指定したgraphqlsファイルが既に存在していた場合は、内容が上書きされます。

gqlgen generateの実行

graph/schema/todo.graphqlsで定義されたGraphQLのスキーマ定義から、リゾルバなどのファイルが生成されます。

gqlgenでは、gqlgen.ymlexecmodelresolverで指定したパスが存在しない場合でも、各項目のファイルはディレクトリごと生成されます。

生成されたリゾルバはスケルトンコードのため、内部処理が未実装となっています。
そこで、リゾルバの内部処理は手動で実装する必要があります。

8. リゾルバの実装

リゾルバでは、entgqlで生成されたDBへのCRUD APIコードを用いて、所望の内部処理(ビジネスロジック)を実装します。

具体的には、resolver.gotodo.resolver.goを編集します。

resolver.goは以下のように編集します。

resolver/resolver.go

package resolver

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

import (
  "example/ent"

  "github.com/99designs/gqlgen/graphql"
)

type Resolver struct{ client *ent.Client }

// NewSchema creates a graphql executable schema.
func NewSchema(client *ent.Client) graphql.ExecutableSchema {
  return NewExecutableSchema(Config{
    Resolvers: &Resolver{client},
  })
}

resolver.goには、最初からResolver構造体が定義されています。このResolver構造体をtype Resolver struct{ client *ent.Client }とすることで、この構造体はent.Client型のclientフィールドを持つことになり、データベースからのデータ取得やデータ更新などの操作が可能になります。

さらに、NewSchema関数を定義します。ここでは、新たにGraphQLのスキーマを生成し、それを実行可能な状態(graphql.ExecutableSchema)にして返しています。NewSchema関数は、データベースの操作とGraphQLクエリの解釈を繋ぎ合わせる「橋渡し」の役割を果たします。これによって、クライアントからのGraphQLクエリがデータベースへの具体的な操作として変換され、またその結果がクライアントへと返却される、という一連の流れが可能になります。

todo.resolver.goは以下のように編集します。

resolver/todo.resolver.go

package resolver

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.41

import (
  "context"
  "example/ent"
  "example/resolver/model"
  "fmt"
  "log"
  "strconv"
)

// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id string) (ent.Noder, error) {
  panic(fmt.Errorf("not implemented: Node - node"))
}

// Nodes is the resolver for the nodes field.
func (r *queryResolver) Nodes(ctx context.Context, ids []string) ([]ent.Noder, error) {
  panic(fmt.Errorf("not implemented: Nodes - nodes"))
}

// Todos is the resolver for the todos field.
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
  entTodos, err := r.client.Todo.Query().All(ctx)
  if err != nil {
    log.Fatalf("failed querying todos: %v", err)
  }
  modelTodos := make([]*model.Todo, len(entTodos))
  for i, entTodo := range entTodos {
    modelTodos[i] = &model.Todo{
      ID:    strconv.Itoa(entTodo.ID),
      Name:  entTodo.Name,
      Email: entTodo.Email,
      Done:  entTodo.Done,
    }
  }
  return modelTodos, nil
}

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

todo.resolver.goには、Resolver構造体の各メソッドが定義されています。
特に、Todos関数はGraphQLのtodosフィールドのリゾルバです。この関数を、DBから全てのTodoテーブルのデータを取得し、それをGraphQLクエリが要求する形に変換して返す処理をするように実装します。

リゾルバのファイルが2つに分けられている理由

todo.resolver.go内のリゾルバの内部処理は、直接開発者が書き換えても、その後の自動生成で上書きされる恐れがあります。そこで、resolver.go内のResolver構造体に依存性注入(DI)を行うことで、自動生成で上書きされる心配なく、サービス層をDIした新しいリゾルバの使用が可能になります。

9. DB周辺の実装

次に、DB周辺の実装を行います。
DBには軽量なRDBであるSQLiteを使用しました。

Go製のSQLiteライブラリには、CGOを利用するgo-sqlite3などがありますが、これらはgccのインストールが必要になります。そのため、今回はpure-Go (CGO-free) で実行可能なmodernc.org/sqliteを採用しました。

しかし、entは標準でmodernc.org/sqliteをサポートしていないため、以下の記事を参考にDB周りの実装を行いました。

まず、modernc.org/sqliteをインストールします。

go get modernc.org/sqlite

次に、ent/sqlite_driver.goというGoファイルを新規作成し、以下の内容を記述します。
これは、entの標準機能でmodernc.org/sqliteのサポートを可能にするドライバーラッパーです。

ent/sqlite_driver.go

package ent

import (
  "database/sql"
  "database/sql/driver"
  "fmt"

  "modernc.org/sqlite"
)

type sqliteDriver struct {
  *sqlite.Driver
}

func (d sqliteDriver) Open(name string) (driver.Conn, error) {
  conn, err := d.Driver.Open(name)
  if err != nil {
    return conn, err
  }
  c := conn.(interface {
    Exec(stmt string, args []driver.Value) (driver.Result, error)
  })
  if _, err := c.Exec("PRAGMA foreign_keys = on;", nil); err != nil {
    conn.Close()
    return nil, fmt.Errorf("failed to enable enable foreign keys: %w", err)
  }
  return conn, nil
}

func init() {
  sql.Register("sqlite3", sqliteDriver{Driver: &sqlite.Driver{}})
}

10. サーバのエンドポイントの実装

最後に、今回のエンドポイントであるserver.goを以下のように実装します。

server.go

package main

import (
  "context"
  "example/ent"
  "example/resolver"
  "log"
  "net/http"
  "os"

  "entgo.io/ent/dialect"
  _ "modernc.org/sqlite"

  "github.com/99designs/gqlgen/graphql/handler"
  "github.com/99designs/gqlgen/graphql/playground"
)

const defaultPort = "8080"

func main() {
  // Create an ent.Client with in-memory SQLite database.
  entOptions := []ent.Option{}
  entOptions = append(entOptions, ent.Debug())
  client, err := ent.Open(dialect.SQLite, "file::memory:?cache=shared", entOptions...)
  if err != nil {
    log.Fatalf("failed opening connection to sqlite: %v", err)
  }
  defer client.Close()
  // Run the automatic migration tool to create all schema resources.
  if err := client.Schema.Create(context.Background()); err != nil {
    log.Fatalf("failed creating schema resources: %v", err)
  }

  ctx := context.Background()
  u, err := client.Todo.
    Create().
    SetName("John").
    SetEmail("hogehoge@gmail.com").
    SetDone(false).
    Save(ctx)
  if err != nil {
    log.Fatalf("failed creating user: %v", err)
  }
  log.Println("user was created: ", u)

  port := os.Getenv("PORT")
  if port == "" {
    port = defaultPort
  }

  srv := handler.NewDefaultServer(resolver.NewSchema(client))

  http.Handle("/", playground.Handler("GraphQL playground", "/query"))
  http.Handle("/query", srv)

  log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
  log.Fatal(http.ListenAndServe(":"+port, nil))
}

今回はqueryのみ実装しているため、DBテーブルの作成を手動で実装する必要があります。
簡単のため、テーブル作成はエンドポイントであるserver.go内にハードコーディングしています。(大目に見ていただけると幸いです)

11. GraphQLサーバの起動

では、GraphQLサーバを起動してみましょう。

go run server.go

http://localhost:8080/にアクセスして、queryを送った際の結果は次のようになります。

想定通りの結果を取得することができました。

最後に

いかがでしたでしょうか。
実装したコード量に比べて、生成されたコード量がかなり多いかと思います。
また、本記事と同様の手順でmutationの機能も実装可能です。

さらに、entが生成したCRUD APIコードとリゾルバのスケルトンコードから、リゾルバの内部処理まで自動生成する、ということもできたら更なる爆速&省エネ開発が実現できますね。これは、爆速&省エネでやりたいことの④を自動化するという意味です。(ビジネスロジックが明らかな場合に限りますが)

本記事を通して、ent+gqlgenによる爆速GraphQLバックエンド開発の威力を実感していただけたら幸いです。

お気軽にお問い合わせください
オープンソースに関するさまざまな課題、OpenStandiaがまるごと解決します。
下記コンテンツも
あわせてご確認ください。