GoのORM「ent」の話

12 min読了の目安(約11400字TECH技術記事

こんにちは。
最近、とある人が「goって簡単だと聞きました。」と言っていて、別の人が「いや、違くてgoはsimpleだけど、easyではないよ」と言っていたのを聞いて、ああ、言い得て妙だなと思ったmasamikiです。

GoのORM

これまで、GoでORM使うならGORMばっかり使ってきたのですが、今回、新しいプロジェクトをやるにあたり、風の噂で聞いていた ent を使うことになりました。

これは、そんなentのお話。

GORM

GORMはThe fantastic ORM library for Golangとかかれている通り、Go言語のためのファンタスティックなORMライブラリです。
Migratorを使ってMigrationの処理を書いたり、

db.Migrator().CreateTable(&User{})

Where, Select, Omit, Joins, Scopes, Preload, RawなどのChain Methodを繋げて、クエリを作ったり、

db.Where("name = ?", "jinzhu2").Where("age = ?", 20).Find(&users)

などRDBで行いたい操作をSQL的な形で、分かりやすく行えるライブラリです、とでも言っておきましょうか。

ent

entはFacebook Connectivityチームが開発したORMで、現在進行形で機能がばしばし増えていっているライブラリです。

開発の動機は、

  1. Goコミュニティでデータをグラフとしてクエリできるツールがないこと
  2. 100%型安全性ORMがないこと
    とのこと。これらを解決しようとしているのがentです。

そして、goの特徴として、定義などを細々書くのが面倒generatorを使いたいという要望をちゃんと満たしてくれる、code generationを含んだライブラリです。というか、むしろ、それが大事なとこです。

準備

ここからしばらくQuick Introductionここに書いてある内容をつらつら書いてあるだけなので、そっち見てもらって大丈夫です。

まず、entのためのcode generatorであるentcを取ってきます。

go get github.com/facebook/ent/cmd/entc

インストール後、環境変数PATHに、entcへのパスを通します。

Schema Generation① Fields

では、goのプロジェクトのrootに入っておきましょう。
そして、以下のコマンド。

entc init User

そうすると、なにやらentというディレクトリができていないだろうか?

├── docker-compose.yml
├── ent
│   ├── generate.go
│   └── schema
│       └── user.go
├── go.mod
└── main.go

ent/schemaに以下のようなUserのSchemaが作成されてるはずです。

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がGraphのノードが持ってる項目ですね。RDBのカラムに当たるものになります。

ノード

とりあえず、nameとageをfieldとして持ちたいので、まず、importにgithub.com/facebook/ent/schema/fieldを追加し、

import (
	"github.com/facebook/ent"
	"github.com/facebook/ent/schema/field"
)

ent.Fieldのsliceを返すように書き換えます。

func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").Default("unknown"),
		field.Int("age").Positive(),
	}
}

そして、

go generate ./ent

どうでしょう。entファイルの中が、どばーっとなっているのではないでしょうか?

.
├── docker-compose.yml
├── ent
│   ├── client.go
│   ├── config.go
│   ├── context.go
│   ├── ent.go
│   ├── enttest
│   │   └── enttest.go
│   ├── generate.go
│   ├── hook
│   │   └── hook.go
│   ├── migrate
│   │   ├── migrate.go
│   │   └── schema.go
│   ├── mutation.go
│   ├── predicate
│   │   └── predicate.go
│   ├── privacy
│   │   └── privacy.go
│   ├── runtime
│   │   └── runtime.go
│   ├── runtime.go
│   ├── schema
│   │   └── user.go
│   ├── tx.go
│   ├── user
│   │   ├── user.go
│   │   └── where.go
│   ├── user.go
│   ├── user_create.go
│   ├── user_delete.go
│   ├── user_query.go
│   └── user_update.go
├── go.mod
├── go.sum
└── main.go

Migration

今回はmysqlでやってます。Migrationをするためには、entのclientでSchema.Createを実行すると、Migrationが走ります。

package main

import (
	"context"
	"log"
	"sample/ent"
	"github.com/go-sql-driver/mysql"
)

func main() {
	entOptions := []ent.Option{}

	// 発行されるSQLをロギングするなら
	entOptions = append(entOptions, ent.Debug())

	// サンプルなのでここにハードコーディングしてます。
	mc := mysql.Config{
		User:                 "root",
		Passwd:               "root",
		Net:                  "tcp",
		Addr:                 "localhost" + ":" + "3309",
		DBName:               "sample",
		AllowNativePasswords: true,
		ParseTime:            true,
	}

	client, err := ent.Open("mysql", mc.FormatDSN(), entOptions...)
	if err != nil {
		log.Fatalf("Error open mysql ent client: %v\n", err)
	}

	defer client.Close()

	// Run the auto migration tool.
	if err := client.Schema.Create(context.Background()); err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
}

go run main.goをすると以下のようなログがでて

2020/10/22 10:28:54 driver.Tx(b3aedd8f-d0bb-4ae9-8518-18844d658082): started
2020/10/22 10:28:54 Tx(b3aedd8f-d0bb-4ae9-8518-18844d658082).Query: query=SHOW VARIABLES LIKE 'version' args=[]
2020/10/22 10:28:54 Tx(b3aedd8f-d0bb-4ae9-8518-18844d658082).Query: query=SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE `TABLE_SCHEMA` = (SELECT DATABASE()) AND `TABLE_NAME` = ? args=[users]
2020/10/22 10:28:54 Tx(b3aedd8f-d0bb-4ae9-8518-18844d658082).Exec: query=CREATE TABLE IF NOT EXISTS `users`(`id` bigint AUTO_INCREMENT NOT NULL, `name` varchar(255) NOT NULL DEFAULT 'unknown', `age` bigint NOT NULL, PRIMARY KEY(`id`)) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin args=[]
2020/10/22 10:28:54 Tx(b3aedd8f-d0bb-4ae9-8518-18844d658082): committed

こんな感じでテーブルができて、fieldも入っているのが確認できるかと思います。
あら、easy。

mysql> show tables;
+------------------+
| Tables_in_sample |
+------------------+
| users            |
+------------------+
1 row in set (0.00 sec)

mysql> show columns from users;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | unknown |                |
| age   | bigint(20)   | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

Create

UserをCreateする時はこんな感じです。
今のプロジェクトはginを使っているので、ctxはSaveの引数にはgin.Contextを入れてますが。

ctx := context.Background()
u, err := client.User.
	Create().
	SetAge(32).
	SetName("inoue").
	Save(ctx)
if err != nil {
	log.Fatalf("failed creating user: %v", err)
}
log.Println("user was created: ", u)	

実行すると、logにInsert intoのクエリが見えるのではないでしょうか。

2020/10/22 10:52:17 driver.Tx(cdce0f34-d788-4ed6-acaf-332ddf945d8f): started
2020/10/22 10:52:17 Tx(cdce0f34-d788-4ed6-acaf-332ddf945d8f).Exec: query=INSERT INTO `users` (`name`, `age`) VALUES (?, ?) args=[inoue 32]
2020/10/22 10:52:17 Tx(cdce0f34-d788-4ed6-acaf-332ddf945d8f): committed
2020/10/22 10:52:17 user was created:  User(id=1, name=inoue, age=32)

もちろん、DBにも登録されてます。

mysql> select * from users;
+----+-------+-----+
| id | name  | age |
+----+-------+-----+
|  1 | inoue |  32 |
+----+-------+-----+
1 row in set (0.00 sec)

Select

取得するクエリのためのコードはこんな感じです。
NameEQでname = を作っているのですが、こういったfuncも最初のcode generationで生成されているので、それをそのまま使ってあげるだけでokです。

なので、importには"<project>/ent/user"を追加はしてあげないといけないですね。

ctx := context.Background()
u, err := client.User.
	Query().
	Where(user.NameEQ("inoue")).
	Only(ctx)
if err != nil {
	log.Fatalf("failed querying user: %v", err)
}
log.Println("user returned: ", u)

実行すると、こんな感じでクエリとlog.Printlnで出したユーザーの情報が表示されたのではないでしょうか?

2020/10/22 11:15:57 driver.Query: query=SELECT DISTINCT `users`.`id`, `users`.`name`, `users`.`age` FROM `users` WHERE `users`.`name` = ? LIMIT ? args=[inoue 2]
2020/10/22 11:15:57 user returned:  User(id=1, name=inoue, age=32)

Schema Generation② Edges

EdgesがGraphのノードの関係を示してるものですね。
エッジ
mysqlの外部keyでのテーブルの紐付きをEdgeとして定義する感じです。

Userに一対多で紐付くTweetテーブルを作ってみます。

entc init Tweet

先ほどと同様にTweetのFieldsに追加したい項目を記載して

package schema

import (
	"github.com/facebook/ent"
	"github.com/facebook/ent/schema/field"
)

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

// Fields of the Tweet.
func (Tweet) Fields() []ent.Field {
	return []ent.Field{
		field.String("title"),
		field.String("content").Optional(),
	}
}

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

User側で、新たに"github.com/facebook/ent/schema/edge"をimportして、edge.Toでtweetへ向けたtweetsと言う名の指向性のエッジが生成されるよう定義してあげます。

// Edges of the User.
func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("tweets", Tweet.Type),
	}
}

ついでにTweetからもUserを参照したいので、Tweet側のEdgeも書いてあげますが、今度はedge.Fromで記載してあげます。
逆向きの場合は、Refを使ってUser Schemaのtweets edgeを使ってね、という定義をしてあげます。
それからユーザーは一人でいいのでUniqueも付けてこんな感じに。

// Edges of the Tweet.
func (Tweet) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("users", User.Type).
			Ref("tweets").
			Unique(),
	}
}

そして、また

go generate ./ent

また、entディレクトリの中がどばーっとなったかと思います。

そして、先ほどと同様にMigrationを流してあげると、外部keyも付いた状態のテーブルができてるのが確認できるかと思います。
テーブル

Create

Tweetを作って、UserにそのTweetを紐付けさせます。
userはさっきのUserを使ってみましょうか。

AddTweetsというfuncがgenerateされているはずなので、取得したUserに対してUpdateのChainを記述してあげます。

ctx := context.Background()
// Tweetの作成
huga, err := client.Tweet.
	Create().
	SetTitle("huga").
	SetContent("hoge").
	Save(ctx)
if err != nil {
	log.Fatalf("failed creating tweet: %v", err)
}
log.Println("tweet was created: ", huga)

// Userの取得
u, err := client.User.
	Query().
	Where(user.NameEQ("inoue")).
	Only(ctx)
if err != nil {
	log.Fatalf("failed querying user: %v", err)
}
log.Println("user returned: ", u)

// Userの更新
u, err = u.Update().
	AddTweets(huga).
	Save(ctx)
if err != nil {
	log.Fatalf("failed updating user: %v", err)
}
log.Println("user was updated: ", u)

実行すると

select * from tweets;
+----+-------+---------+-------------+
| id | title | content | user_tweets |
+----+-------+---------+-------------+
|  1 | huga  | hoge    |           1 |
+----+-------+---------+-------------+
1 row in set (0.00 sec)

外部keyの付いたレコードが作成されてるかと思います。

ざっくり、基本的な使い方はそんなところですかね。

これどうすんの

外部keyにカラム名を指定したい。

EdgesのChain MethodにStorageKey(edge.Column("item_id")),という形で、StorageKeyfucnを追加してあげましょう。

関連エンティティを一緒に取得したい。

Eager Loadingの仕組みを使っちゃいましょう。
こんな感じに書けば、UserのEdgesというfieldにTweetsが入る形で取得できます。

users, err := client.User.
    Query().
    WithTweets().
    All(ctx)

ページングのfuncが欲しい。

generateされたQueryにoffsetとlimitを使って追加実装する感じでしょうか。(ガンガン更新されてるライブラリなので、どこかでページングぐらいは付くかも…と期待)

func (iq *ItemQuery) Paging(pp *PagingParam) *ItemQuery {
	if pp == nil {
		return iq
	}

	query := iq
	if pp.PerPage > 0 {
		query = query.Limit(pp.PerPage)
	}
	offset := pp.Offset()
	if offset > 0 {
		query = query.Offset(offset)
	}
	return query
}

enum系のfieldを使いたい。

entの中にGoTypeというfuncがあるので、それを利用します。GoTypeはtypeをオーバーライドしてくれる仕組みなのですが、そこにこのように実装するenumが利用できるっぽいです。

type UserType string

const (
	UserTypeAdmin  UserType = "admin"
	UserTypeNormal UserType = "normal"
)

// Fields of the User.
func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("type").
			GoType(UserTypeNormal).
			Default(string(UserTypeNormal)),
	}
}

Validateをかけたい。

  1. Build in Validatorがあるので、それを使ってしまうのが簡単ですが、種類はこんだけです。(これでも十分か)
  2. FieldにentのValidete funcを使って定義することができます。
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").
            Match(regexp.MustCompile("[a-zA-Z_]+$")).
            Validate(func(s string) error {
                if strings.ToLower(s) == s {
                    return errors.New("group name must begin with uppercase")
                }
                return nil
            }),
    }
}

感想

使ってみて思うのは、整った形で書いていくことができるねぇというのと、欲しいものがgenerateされてくれているので、使い方さえ分かっていれば、サクサクと書いていくことができて、そして型も安心。
書き方が皆統一されてくるので、今後もentを使っていくようにしたいなと。
そんな感じです。