facebook/ent を使ってみた
最近 GraphQL の素振りのため、シンプルな Web アプリケーションを作ることを考えていた。
で、どうせなら最低限の機能はシングルバイナリ + いくつかのファイルを用意するだけで動作させようと思い、以下のような構成だけなんとなく考えた。
- フロントアプリは build 結果を rakyll/statik で Go のバイナリに埋め込む
- データベースはデフォルトで SQLite を使い、必要に応じて利用者が用意した RDBMS を利用できる
ところで、facebook/ent をご存知だろうか。
これは facebook connectivity の中の人が書いている ORM で以下のコンセプトを持つ。 // facebook connectivity はどうやらネットワークの会社っぽい...?
- Schema As Code
- DB スキーマは Go のオブジェクトとして宣言される
- Easily Traverse Any Graph
- グラフ構造を持つデータに対するクエリ、集約、走査を簡単にする
- Statically Typed And Explicit API
- コード生成により静的に型付けされており、API は明示的である
- Multi Storage Driver
- MySQL、PostgreSQL、SQLite、Gremlin をサポートする
cf. https://github.com/facebook/ent#ent---an-entity-framework-for-go
Easily Traverse Any Graph
-> GraphQL と相性良さそう!!Multi Storage Driver
-> オプションで使う RDBMS 簡単に切り替えられそう!!Statically Typed And Explicit API
->interface{}
が頻発しないの最高!
とまぁ安易ではあるが、今回やろうとしていることと合致していると感じ、簡単にではあるが触ってみたので、その紹介をしようと思う。
hello ent
タスクスケジューラを作る、という想定で以下のグラフを書いた。
// 例えば Rundeck のようなものだと思ってくれればよい。
User
はNamespace
に所属するNamespace
には少なくとも一人の管理者が存在し、管理者のみユーザをメンバーに追加できる。Namespace
はWorker
を複数持つ- タスクは実行ターゲットとして
Worker
を登録する - タスクにはワンライナーを実行する
CommandTask
とユーザが記述したシェルスクリプトを実行するScriptTask
が存在する
大して ent の Quick Introduction の例以上の面白みがある訳ではないが、そこはまぁやってみた記事のご愛嬌ということで。
全体像はそんなところで、この記事では ent を使って左上部分 User
と Namespace
間の実装を書くことを目的とする。
entc
の導入
ent は Go で書かれたスキーマからコードを生成する。
この生成ツールが entc
だ。
まずはこの entc
を導入する。
導入は README にある通り、go get
で行なう。
cf. https://github.com/facebook/ent#quick-installation
$ go get github.com/facebook/ent/cmd/entc
データベーススキーマの生成
entc
はスキーマ自体の生成もしてくれるので、まずはそこから。
$ entc init User Namespace
するとコマンドを実行したディレクトリ直下に ent
というディレクトリが作成されており、以下のような構造でファイルが生成されているはずだ。
ent/ - generate.go - schema/ - namespace.go - user.go
いくつかのファイルの中身を見てみる。
sample/ent/generate.go
package ent //go:generate go run github.com/facebook/ent/cmd/entc generate ./schema
これは go generate ./...
でぱっとスキーマからコード生成できるように entc
が気をきかせて生成してくれているものだ。
ent/schema/user.go
package schema import "github.com/facebook/ent" // User holds the schema definition for the User entity. type User struct { ent.Schema } // Fields of the User. func (User) Fields() []ent.Field { return nil } // Edges of the User. func (User) Edges() []ent.Edge { return nil }
重要なのはこちらのファイルだ。
この Fields() の返り値としてフィールドを記述し、Edges
の返り値としてグラフの関連を記述する。
フィールドを定義する
早速 User
のフィールドを定義していく。
User
は以下のようなフィールドを持つとする。
ID
: ユーザ ID。必須かつ Unique である。string
型Name
: ユーザ名。必須かつ Unique である。string
型AvatarURL
: ユーザのアバター画像の URL。省略可能。string
型sample/ent/schema/user.go
package schema import ( "github.com/facebook/ent" "github.com/facebook/ent/schema/field" ) // User holds the schema definition for the User entity. type User struct { ent.Schema } // Fields of the User. func (User) Fields() []ent.Field { return []ent.Field{ field.String("id").Unique(), field.String("name").Unique(), field.String("avatar_url").Optional(), } } // Edges of the User. func (User) Edges() []ent.Edge { return nil }
フィールドはデフォルトで必須であり、省略可能にするときに Optional()
メソッドを呼ぶ。
このように制約はメソッドチェインの形で指定する。
UNIQUE 制約を付けたいときは Unique()
メソッドだ。
ここで、値のバリデーションをかけることも可能だ。
具体的に他にどのようなことが出来るかについては以下のドキュメントを参照してほしい。
https://entgo.io/docs/schema-fields/
同様に Namespace
は ID
Name
という必須フィールドを持つとして以下のように書く。
sample/ent/schema/namespace.go
package schema import ( "github.com/facebook/ent" "github.com/facebook/ent/schema/field" ) // Namespace holds the schema definition for the Namespace entity. type Namespace struct { ent.Schema } // Fields of the Namespace. func (Namespace) Fields() []ent.Field { return []ent.Field{ field.String("id").Unique(), field.String("name").Unique(), } } // Edges of the Namespace. func (Namespace) Edges() []ent.Edge { return nil }
エッジの定義
次に User
と Namespace
間の関連(エッジ)を定義する。
Namespace
とUser
はmembers
という多対多の関連を持つNamespace
とUser
はadmins
という多対多の関連を持つ
このことを以下のように記述する。
sample/ent/schema/namespace.go
package schema import ( "github.com/facebook/ent" "github.com/facebook/ent/schema/edge" "github.com/facebook/ent/schema/field" ) // Namespace holds the schema definition for the Namespace entity. type Namespace struct { ent.Schema } // Fields of the Namespace. func (Namespace) Fields() []ent.Field { return []ent.Field{ field.String("id").Unique(), field.String("name").Unique(), } } // Edges of the Namespace. func (Namespace) Edges() []ent.Edge { return []ent.Edge{ edge.To("members", User.Type), edge.To("admins", User.Type).Required(), } }
sample/ent/schema/user.go
package schema import ( "github.com/facebook/ent" "github.com/facebook/ent/schema/edge" "github.com/facebook/ent/schema/field" ) // User holds the schema definition for the User entity. type User struct { ent.Schema } // Fields of the User. func (User) Fields() []ent.Field { return []ent.Field{ field.String("id").Unique(), field.String("name").Unique(), field.String("avatar_url").Optional(), } } // Edges of the User. func (User) Edges() []ent.Edge { return []ent.Edge{ edge.From("namespaces", Namespace.Type).Ref("members"), edge.From("owned_namespaces", Namespace.Type).Ref("admins"), } }
edge.To()
edge.From()
関数で関連を示す。
これで、先の2つの関連を作ることができる。
これで、Namespace
及び User
のフィールドとエッジが定義できたのでコードを生成し、実行して、ここまでの要件が満たされていることを確認する。
データを作成し、クエリを投げてみる
entc
が用意してくれていた、generate.go
のおかげで、以下のコマンド一発でコードが生成される。
$ go generate ./...
ent
ディレクトリ内に大量のファイル/ディレクトリが作成されてしまい、面食らったかもしれない。
これらの詳細を見る前にとにかく使ってみる。
sample/main.go
package main import ( "context" "fmt" "github.com/tennashi/ent-sample/ent" "github.com/tennashi/ent-sample/ent/namespace" "github.com/tennashi/ent-sample/ent/user" _ "github.com/mattn/go-sqlite3" ) func initClient() (*ent.Client, error) { client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1") if err != nil { return nil, err } // Run the auto migration tool. if err := client.Schema.Create(context.Background()); err != nil { return nil, err } return client, nil } func createEntities(ctx context.Context, c *ent.Client) error { fmt.Println("=== Create users ===") userNames := []string{"user-a", "user-b", "user-c"} bulk := make([]*ent.UserCreate, len(userNames)) for i, name := range userNames { bulk[i] = c.User.Create().SetID("user:" + fmt.Sprintf("%.3d", i+1)).SetName(name) } us, err := c.User.CreateBulk(bulk...).Save(ctx) if err != nil { return err } fmt.Println(us) fmt.Println("=== Create namespaces ===") nsa, err := c.Namespace.Create().SetID("namespace:001").SetName("namespace-a"). AddMembers(us[0], us[1], us[2]).AddAdmins(us[0]).Save(ctx) if err != nil { return err } fmt.Println(nsa) nsb, err := c.Namespace.Create().SetID("namespace:002").SetName("namespace-b"). AddMembers(us[0], us[1]).AddAdmins(us[0], us[1]).Save(ctx) if err != nil { return err } fmt.Println(nsb) nsc, err := c.Namespace.Create().SetID("namespace:003").SetName("namespace-c"). AddMembers(us[2]).AddAdmins(us[0]).Save(ctx) if err != nil { return err } fmt.Println(nsc) return nil } func main() { c, err := initClient() if err != nil { panic(err) } defer c.Close() ctx := context.Background() err = createEntities(ctx, c) if err != nil { panic(err) } fmt.Println("=== Query members of namespace-a ===") us, err := c.Namespace.Query().Where(namespace.NameEQ("namespace-a")).QueryMembers().All(ctx) if err != nil { panic(err) } fmt.Println(us) fmt.Println("=== Query namespaces to which user-a belongs ===") nss, err := c.User.Query().Where(user.NameEQ("user-a")).QueryNamespaces().All(ctx) if err != nil { panic(err) } fmt.Println(nss) }
これを実行すると以下のような出力が得られるはずだ。
go run main.go === Create users === [User(id=user:001, name=user-a, avatar_url=) User(id=user:002, name=user-b, avatar_url=) User(id=user:003, name=user-c, avatar_url=)] === Create namespaces === Namespace(id=namespace:001, name=namespace-a) Namespace(id=namespace:002, name=namespace-b) Namespace(id=namespace:003, name=namespace-c) === Query members of namespace-a === [User(id=user:001, name=user-a, avatar_url=) User(id=user:002, name=user-b, avatar_url=) User(id=user:003, name=user-c, avatar_url=)] === Query namespaces to which user-a belongs === [Namespace(id=namespace:001, name=namespace-a) Namespace(id=namespace:002, name=namespace-b)]
詳細の解説はドキュメントに任せるが、ざっくりと以下のようなことをしている。
Create users
以下では、3つのUser
を bulk insert している- フィールドを埋めるのは
SetID()
やSetName()
メソッドをチェインさせて行なう - https://entgo.io/docs/crud/#create-many
- フィールドを埋めるのは
Create namespace
以下では、3つのNamespace
を3つのクエリで作成している- 関連するオブジェクトは
AddMembers()
やAddAdmins()
などで登録する - https://entgo.io/docs/crud/#create-an-entity
- 関連するオブジェクトは
Query members of namespace-a
以下ではグラフのmembers
エッジ(スキーマでedge.To()
で作成したもの)を辿って、namespace-a に所属するUser
を全て取得するQueryMembers()
でmembers
エッジを辿っている- https://entgo.io/docs/crud/#query-the-graph
Query namespaces to which user-a belongs
以下では、グラフのnamespaces
エッジ(スキーマでedge.From()
で作成したもの)を辿って、user-a がmembers
に入っているようなNamespace
を全て取得する- 上記と同様
QueryNamespaces()
を使って辿っている
- 上記と同様
このように、ent はメソッドチェインをベースにクエリを作成する。
その際用いる、各種メソッドは entc
により自動生成されたものだ。
ところで、先のスキーマ定義で軽く流したところが気になっている人もいるかもしれない。
つまり、何故 edge.To()
edge.From()
をあのように組み合わせることで、多対多が表現できるのか、ということだ。
エッジ定義のもう少し詳細
答えを最初に書くと、そう実装されているから、だ。
つまり、edge.To()
edge.From()
単体で何か意味を持つ訳ではなく、entc
がスキーマ定義全体を読んで、その edge.To()
edge.From()
の組合せによってどのような関連にするか、ということが予め決められている。
どの関連を表現したければどう書くのか、についての一覧は以下を読んでほしい。
ここでは実装の話をする。
スキーマから entc
の内部表現としてのグラフを生成するコードは以下の部分だ。
ここのコメントに以下のようなことが書かれている。
// resolve resolves the type reference and relation of edges. // It fails if one of the references is missing or invalid. // // relation definitions between A and B, where A is the owner of // the edge and B uses this edge as a back-reference: // // O2O // - A have a unique edge (E) to B, and B have a back-reference unique edge (E') for E. // - A have a unique edge (E) to A. // // O2M (The "Many" side, keeps a reference to the "One" side). // - A have an edge (E) to B (not unique), and B doesn't have a back-reference edge for E. // - A have an edge (E) to B (not unique), and B have a back-reference unique edge (E') for E. // // M2O (The "Many" side, holds the reference to the "One" side). // - A have a unique edge (E) to B, and B doesn't have a back-reference edge for E. // - A have a unique edge (E) to B, and B have a back-reference non-unique edge (E') for E. // // M2M // - A have an edge (E) to B (not unique), and B have a back-reference non-unique edge (E') for E. // - A have an edge (E) to A (not unique).
これを読めば、どう書けばどの関係が出るのかが一目瞭然である。
用語とメソッドの関連だけ書いておくと、
- back-reference :
edge.From()
で関連付けられるエッジのこと - unique edge :
Unique()
メソッドを呼ばれたエッジのこと(edge.To()
とedge.From()
双方から呼び出せて、エッジが unique であることを示す)
ここで何を言いたかったかと言うと、edge.From()
edge.To()
はあくまでコード生成機がコードを生成する際のエンティティ間の関連を明記するための記法であって、"edge.From()
があるから QueryNamespace()
が生えて" というような生成結果を操作するものではない、ということだ。
あくまでも edge.From()
edge.To()
及び Unique()
メソッド呼び出しの組合せに応じて O2O O2M M2O M2M のパターンを導出するだけのものである。
なお、現在のスキーマでエンティティ間の関連をどう解釈しているのかは、entc describe
コマンドで確認できる。
// 折り返しで見辛いかもしれないが、手元で確認してほしい。
$ entc describe ./ent/schema/ Namespace: +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ | id | string | true | false | false | false | false | false | json:"id,omitempty" | 0 | | name | string | true | false | false | false | false | false | json:"name,omitempty" | 0 | +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+ +---------+------+---------+---------+----------+--------+----------+ | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | +---------+------+---------+---------+----------+--------+----------+ | members | User | false | | M2M | false | true | | admins | User | false | | M2M | false | false | +---------+------+---------+---------+----------+--------+----------+ User: +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+ | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+ | id | string | true | false | false | false | false | false | json:"id,omitempty" | 0 | | name | string | true | false | false | false | false | false | json:"name,omitempty" | 0 | | avatar_url | string | false | true | false | false | false | false | json:"avatar_url,omitempty" | 0 | +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+ +------------------+-----------+---------+---------+----------+--------+----------+ | Edge | Type | Inverse | BackRef | Relation | Unique | Optional | +------------------+-----------+---------+---------+----------+--------+----------+ | namespaces | Namespace | true | members | M2M | false | true | | owned_namespaces | Namespace | true | admins | M2M | false | true | +------------------+-----------+---------+---------+----------+--------+----------+
とにかくこれで最低限の ent の機能紹介と、サンプルコードの作成が完了した。
その他注意点
スキーマファイルの出力先をコマンドラインオプションで変更できるが、上手く動いてくれず、いくつか手作業での修正が必要になる。
詳細は下記の Issue を読んでほしい。
まず、entc init
でデフォルトではスキーマファイルは ent/schema
ディレクトリに schema
パッケージとして生成される。
この出力先を変更する --target
オプションが存在するのだが
--target
オプションを指定すると最初の方で説明したgenerate.go
が生成されない- これは出力先を変えたのだから、
entc generate
のオプションも変更する必要があるはずで、それを自動で判断できないからだ
- これは出力先を変えたのだから、
entc generate
を動作させるためにはスキーマファイルを配置しているディレクトリ名とパッケージ名が一致していなければならない- 一致していない場合は
entc generate
で import エラーが発生しこける
- 一致していない場合は
--target
オプションで指定したディレクトリ名に関わらず、生成されるスキーマファイルのパッケージ名はschema
固定である- スキーマファイルを配置するディレクトリ名は
ent
であってはならないentc
が内部で使っているent
パッケージと衝突してentc generate
がこける
と、いうことで --target
オプションで指定するディレクトリ名は /ent
で終了してはならず、それ以外なら指定は可能だが、パッケージ名を手で修正しなければならない。
例えば
sample/ - schema/ - database/ - user.go // スキーマファイル - ent/ - ... // entc generate によって生成されたファイル群
という配置にしたければ以下のようにする。
$ entc init --target schema/database User # schema/database/user.go が生成される $ vim schema/database/user.go # package database に修正
そして generate.go
を以下のように作成する。
sample/ent/generate.go
package ent //go:generate go run github.com/facebook/ent/cmd/entc generate --target . ../schema/database
これで任意の配置にできるはずだ。
まとめ
facebook/ent の最低限の使い方と私がハマったところを解説した。
まだ使い始めたばかりなので、性能面ではどうか、や n + 1 問題はどうやって解決するのか、生成される SQL は妥当か、などまだまだ要検証な部分も残っている。
まだ読んでいないが、性能面や生成される SQL を調査してくれてそうな記事もある。
全体像として示したようなアプリを gqlgen + ent + React + Appolo client + graphql-codegen という組み合わせで鋭意作成中なので、今後はその辺りも調査していければと思う。
ざっと使った感じ、やはり GraphQL のリゾルバ実装はとても直感的に書け、CRUD している限りは gqlgen の生成結果と entc の生成結果の構造体を詰め替えるだけでほぼ頭を使わずに書けるという印象だ。
ただし、やはり ent 側は裏に RDB がいるという制約があり、GraphQL の柔軟さ(例えば Union 型)にどうやって追従するのかというところは考えないといけない。
なお、今回のサンプルコードは https://github.com/tennashi/ent-sample に置いてある。