GoのORM「ent」の話
こんにちは。
最近、とある人が「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で、現在進行形で機能がばしばし増えていっているライブラリです。
開発の動機は、
- Goコミュニティでデータをグラフとしてクエリできるツールがないこと
- 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をかけたい。
- Build in Validatorがあるので、それを使ってしまうのが簡単ですが、種類はこんだけです。(これでも十分か)
- 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を使っていくようにしたいなと。
そんな感じです。
Discussion