はじめに
この記事では、バックエンドのコード生成が可能なGoのライブラリent
とgqlgen
を使用して、爆速&省エネで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エンティティの具体的な中身は未定義状態のため、手動で実装していきます。
今回は以下のように定義しました。
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ファイルを新規作成し、以下の内容を記述します。
// +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.yml
とserver.go
のボイラープレートを取得します。
go run github.com/99designs/gqlgen init
initコマンドでは、graphディレクトリにgenerated.go
、resolver.go
、schema.graphqls
、schema.resolvers.go
、model/models_gen.go
が生成されますが、後ほどgqlgen.yml
を書き換え、generateコマンドで再生成するため、一旦削除します。
この時、ディレクトリ構成は以下のようになっているかと思います。
go_study/
├ ent/
│ └ ...
├ graph/
│ └ schema/
├ go.mod
├ go.sum
├ gqlgen.yml
└ server.go
そして、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によって生成されるためです。
(後ほどentgql
→gqlgen
の順に実行します)
gqlgen
実行時に、schema
で指定した先にgraphqlsファイルが存在しない場合、graphqlsファイルが読み込めなかったという直接的なエラーではなく、その依存関係先で生じたエラーが出力されます。
私の場合は、model
の指定パスが読み込めない、というエラー内容でした。
そのため、実はschema
のパス設定に問題があるにもかかわらず、model
のパス設定等に問題があると思い、一度行き詰まってしまいました。
これで、gqlgen
の準備も整いました。
6. go generateの実行
go generate
コマンドでentc.go
とgqlgen
のgenerateコマンドが順次実行されるように、既存の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.yml
のexec
model
resolver
で指定したパスが存在しない場合でも、各項目のファイルはディレクトリごと生成されます。
生成されたリゾルバはスケルトンコードのため、内部処理が未実装となっています。
そこで、リゾルバの内部処理は手動で実装する必要があります。
8. リゾルバの実装
リゾルバでは、entgql
で生成されたDBへのCRUD APIコードを用いて、所望の内部処理(ビジネスロジック)を実装します。
具体的には、resolver.go
とtodo.resolver.go
を編集します。
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
は以下のように編集します。
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
のサポートを可能にするドライバーラッパーです。
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
を以下のように実装します。
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バックエンド開発の威力を実感していただけたら幸いです。
ご質問やご指摘等ありましたらコメントまでお願いいたします。