24
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NRI OpenStandiaAdvent Calendar 2023

Day 4

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

Last updated at Posted at 2023-12-21

はじめに

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

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

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

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

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

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

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

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

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

実施手順は次のようになります。
①. 手動で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.ymlexec model resolverで指定したパスが存在しない場合でも、各項目のファイルはディレクトリごと生成されます。

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

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を送った際の結果は次のようになります。
image.png
想定通りの結果を取得することができました。

最後に

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

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

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

ご質問やご指摘等ありましたらコメントまでお願いいたします。

参考文献

24
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?