facebook/ent を使ってみた

最近 GraphQL の素振りのため、シンプルな Web アプリケーションを作ることを考えていた。
で、どうせなら最低限の機能はシングルバイナリ + いくつかのファイルを用意するだけで動作させようと思い、以下のような構成だけなんとなく考えた。

  • フロントアプリは build 結果を rakyll/statik で Go のバイナリに埋め込む
  • データベースはデフォルトで SQLite を使い、必要に応じて利用者が用意した RDBMS を利用できる

ところで、facebook/ent をご存知だろうか。

entgo.io

これは facebook connectivity の中の人が書いている ORM で以下のコンセプトを持つ。 // facebook connectivity はどうやらネットワークの会社っぽい...?

  • Schema As Code
    • DB スキーマは Go のオブジェクトとして宣言される
  • Easily Traverse Any Graph
    • グラフ構造を持つデータに対するクエリ、集約、走査を簡単にする
  • Statically Typed And Explicit API
    • コード生成により静的に型付けされており、API は明示的である
  • Multi Storage Driver

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 のようなものだと思ってくれればよい。

f:id:tennashi:20200905221507j:plain

  • UserNamespace に所属する
  • Namespace には少なくとも一人の管理者が存在し、管理者のみユーザをメンバーに追加できる。
  • NamespaceWorker を複数持つ
  • タスクは実行ターゲットとして Worker を登録する
  • タスクにはワンライナーを実行する CommandTask とユーザが記述したシェルスクリプトを実行する ScriptTask が存在する

大して ent の Quick Introduction の例以上の面白みがある訳ではないが、そこはまぁやってみた記事のご愛嬌ということで。

全体像はそんなところで、この記事では ent を使って左上部分 UserNamespace 間の実装を書くことを目的とする。

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/

同様に NamespaceID 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
}

エッジの定義

次に UserNamespace 間の関連(エッジ)を定義する。

  • NamespaceUsermembers という多対多の関連を持つ
  • NamespaceUseradmins という多対多の関連を持つ

このことを以下のように記述する。

  • 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 している
  • Create namespace 以下では、3つの Namespace を3つのクエリで作成している
  • Query members of namespace-a 以下ではグラフの members エッジ(スキーマedge.To() で作成したもの)を辿って、namespace-a に所属する User を全て取得する
  • 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() の組合せによってどのような関連にするか、ということが予め決められている。

どの関連を表現したければどう書くのか、についての一覧は以下を読んでほしい。

entgo.io

ここでは実装の話をする。
スキーマから 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 固定である
    • .*/schema というディレクトリ以外に配置したければ、entc init で生成した後にパッケージ名をディレクトリ名に手で編集する
  • スキーマファイルを配置するディレクトリ名は 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 に置いてある。