こんにちは。メルカリでSoftware Engineerをやっているakkie30です。
この記事は、Mercari Advent Calendar 2021 の5日目の記事です。
私のチームでは半年ほど前からGoのORMライブラリとしてentを採用しています。本記事ではentに関する知見を紹介したいと思います。
entとは
新しいMicroserviceを実装する際に必ず議論になるのが、データベースと、そのデータベースの読み書きを行うライブラリの選定です。メルカリのMicroservicesでは主にGCPを採用しているため、Spanner, CloudSQL, BigTable, Datastoreなどを用いることが多いです。
私のチームでは現在新規サービスを開発しており、サーバ費用を低く抑えつつ機能性も求めた結果CloudSQLを採用しています。Souzohの技術選定にも影響を受け、私のチームでもPostgreSQLを採用しました。
Microserviceの実装はGoで行うのですが、CloudSQL for PostgreSQLに接続するライブラリの選定について以下のような候補がありました。
結論から言うと私のチームではentを使用することに決めました。
entはGoのためのエンティティフレームワークで、テーブルのスキーマやDBへの読み書きを静的に型付けされたGoのコードとして自動生成することができ、SQLを一切書かずにGoのコードのみでデータベースクエリやグラフトラバーサルを容易に行うことができます。
Userという新しいエンティティを作成する例を見てみましょう。
<project>/ent/schema/user.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int("age").
Positive(),
field.String("name").
Default("unknown"),
}
}
age, nameというカラムを持つテーブルをDB上に作りたい、そして読み書きを行いたい場合は、上記のようなGoのコードを書いて go generate ./ent
するだけでentディレクトリ配下に必要なコードが全て生成されます。
ent
├── client.go
├── config.go
├── context.go
├── ent.go
├── generate.go
├── mutation.go
... truncated
├── schema
│ └── user.go
├── tx.go
├── user
│ ├── user.go
│ └── where.go
├── user.go
├── user_create.go
├── user_delete.go
├── user_query.go
└── user_update.go
データベースへの問い合わせも下記のような直感的なコードで行うことができます。
user, err := client.User.
Query().
Where(user.Name("a8m")).
// ユーザーが見つからない場合、`Only`は失敗する
// あるいは、1人以上のユーザーが返却される
Only(ctx)
entを選定したことで、私のチームではサービス開発の工数が大幅に短縮できたように感じています。この記事では、entを選ぶまでに至った議論と、使ってみた感想について紹介したいと思います。
どうしてentにしたの?
チーム内で議論して、以下のような長所・短所を洗い出し、短所を潰す案を考えた上で採用に至りました。
長所
-
スキーマの自動マイグレーションを行うコードも生成してくれるため、スキーマの変更が容易
- ただしロックがかかるようなマイグレーションは実行のタイミングに注意しないといけません。
-
公式のgetting-startedのドキュメントは日本語も用意されている
- 英語話者と日本語話者が混在する私のチームにおいてこれも大きなメリットの1つでした。
-
Goコードでスキーマ定義もDB操作も完結する
client.User.Create().Save(ctx)
(新しいUserを作成する)のような直感的なGoコードを書くだけでデータが作成されるので、SQLの文法ミスを治すのに時間を食うようなことはなくなります。
-
MySQL, PostgreSQL, MariaDB, SQliteといった多様なSQL方言に対応している
- 将来サービスが大規模トラヒックをさばくようになりDBを水平にスケールさせたくなったとき、例えばSpannerへの移行が考えられます。SpannerにはPostgreSQLインタフェースが追加される予定のため、entを使って背後のDB実装を隠蔽したまま(アプリのコードを変更しないまま)Spannerへと移行するようなことも可能になるかもしれません。
-
Graph QLとの調和性
- entが生成するテーブルのには必ず”id”というカラムが必要となります。(私のチームでは、主キーをランダムに散らせたかったので
uuid.New()
で作られるようなランダムなuuid型の値を主キーとして使用しています) - どんなテーブルにも必ず
id
というカラムが必要になるのは最初は違和感でしたが、サービスの前段にGraphQL APIを提供するサービスがあるため、全てのリソースにIDがついている設計はそのGraphQL APIとも調和するものでした。
- entが生成するテーブルのには必ず”id”というカラムが必要となります。(私のチームでは、主キーをランダムに散らせたかったので
-
テスト用のパッケージ enttest も用意されているため、単体テスト用のmock作成が必要なく容易
- entを使用したコードのテストを書く際、enttestパッケージを用いるとin memoryなSQLite DBをmockとして生成してくれて、その上に実際のエンティティを読み書きするテストが書けるので楽です。
-
Privacy 機能が備わっている
現在私のチームで使う予定はありませんが、スキーマにPrivacyを設定することで意図しない読み書きを防ぐことができます。スキーマを書くだけでアプリの大体のロジックが完成する未来もそう遠くなさそうです。
短所
- 複雑なSQL相当のオペレーションを行おうとすると、SQLを書くよりも面倒になる
- Modifierという機能を使用すると、entが実行するSQLに自由に修正を加えることができます。使い方によっては複雑なSQLを実行させることが可能です。
たとえば下記のようなSQLを実行させたいとします。SELECT COUNT(*) AS `count`, SUM(`price`) AS `price`, DATE(created_at) AS `created_at` FROM `users` WHERE `created_at` > x AND `created_at` < y GROUP BY DATE(created_at) ORDER BY DATE(created_at) DESC
entを用いてこれを表現するには、以下のようなコードを書くことになります。
- Modifierという機能を使用すると、entが実行するSQLに自由に修正を加えることができます。使い方によっては複雑なSQLを実行させることが可能です。
client.User.
Query().
Where(
user.CreatedAtGT(x),
user.CreatedAtLT(y),
).
Modify(func(s *sql.Selector) {
s.Select(
sql.As(sql.Count("*"), "count"),
sql.As(sql.Sum("price"), "price"),
sql.As("DATE(created_at)", "created_at"),
).
GroupBy("DATE(created_at)").
OrderBy(sql.Desc("DATE(created_at)"))
}).
ScanX(ctx, &v)
ほとんどSQLのように見える上に、慣れないとこれを書くのにSQLよりよっぽど時間がかかることになってしまいがちです。かといって複雑なSQLを書きたいときだけentを使わず実装するのも、依存ライブラリを増やすことにつながり厄介です。
また この機能を使ってPostgreSQLでしかサポートされないような構文を書いてしまうと、enttestパッケージがSQliteによるin memory DB上での実行を前提としているためenttestが使えなくなり、テストを通すためにPostgreSQLのdocker containerを立ち上げる必要が出てきたりして面倒です。
entを使う際には、Modifier機能を使わずとも書けるようなSQLでアプリが構成できる場合に限った方がよいと考えます。
私のチームのアプリケーションでは、想定されるDBオペレーションが単純なCRUDや外部キーを用いたJOINのみで、複雑な集約は行わないことが想定されたため、この短所はあまり問題にならないだろうと考えました。
また、複雑な処理が必要であればデータをDBから引き抜いた後にアプリ側でその処理を行うことも可能なので、entを使うメリットの大きさを考えるとそうするのも一つの手だと思います。
-
GORMのように公式のGo言語のDataDog-Tracerの実装が(まだ)サポートされていない
- メルカリではDataDogにログやトレース情報を収集するので、tracerの実装が無いとちょっと不安です。ただ、これを実装することはそれほど手間ではないのでいつか時間があるときにやればいい話ということになりました。
-
ent.goが自動生成するコード量は数千行、ときには数万行に及ぶため、リポジトリに占めるent関連のコードの量が膨大になる
linterにこれらの生成されたコードを無視させるため.golangci.yml
(golangci-lintを使っている場合)にskip-dirs: - ./apps/.*/ent
を書き足す必要などがあります。逆に言うとそれさえしてしまえば、自動生成されるコードが膨大であることはあまり気にならなくなります。
-
マスタデータのマイグレーション管理機構がない
- スキーマ変更に伴うDBの自動マイグレーションはentにサポートされているのですが、アプリを動かす上で必要となるマスタデータのマイグレーションやそのロールバックを管理する機構はent自体にはありません。
- gooseやgolang-migrateといった既存のSQL用のマイグレーションツールが使えればよいのですが、SQLを隠蔽するのがentなのでそれらと併用しにくい問題があります。
- この問題を解決するため、個人的に migrent というマイグレーション管理ライブラリを実装して、entを用いながらマスタデータの適用状態の管理を試みようとしています。
- 2021年8月にentにUpsert機能が入ったため、そんなものを使わなくとも、(マスタデータをロールバックすることが無いのであれば、)Upsert機能を用いて冪等性のあるマスタデータの適用は簡単なプログラムを書くだけで可能でしょう。
上記のような長所、短所の補い方を検討した上で、entを採用することを決めました。
CloudSQLとentの併用
メルカリではGKEのクラスタ上でmicroserviceを稼働させています。Cloud SQLを使う際はCloudSQL instanceに特定のPrivate IPから直接アクセスできるよう設定するか、CloudSQL Auth proxyというプロセスを別途用意しなければいけません。私のチームでは開発の際ローカル環境などからもCloudSQLインスタンスに接続しやすくすることを優先して、CloudSQL Auth Proxyを用いて接続を行っています。
本来であれば CloudSQL proxyのプロセスをアプリと同じPodにsidecar的なコンテナとして起動させた上でアプリを起動するといった処理が必要になるのですが、幸運なことにcloudsql-proxy が我々のmicroserviceと同じGoで実装されているおかげで、proxyを別プロセスとしてではなくライブラリとして使用してCloudSQLに接続することができるため、proxyの存在を意識することなくCloudSQLへの接続が実装できました。また、ent clientを初期化する際に既存のSQL connectionオブジェクトを渡すことができるので、スムーズに連携できます。実際にCloudSQL Auth Proxy経由でent clientを初期化するコードは以下のような例になります。
package main
import (
"context"
"database/sql"
"fmt"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/proxy"
"github.com/dakimura/reponame/apps/user/internal/ent"
"github.com/dakimura/reponame/apps/user/internal/ent/user"
"go.uber.org/zap"
goauth "golang.org/x/oauth2/google"
)
func main() {
ctx := context.Background()
logger := zap.NewNop()
// init cloud-sql proxy
err := initProxy(ctx, logger)
if err != nil {
panic(err)
}
dsn := fmt.Sprintf("host=%s:%s:%s user=%s dbname=%s password=%s sslmode=disable",
"yourGCPProjectID", "asia-northeast1", "yourinstancename", "dbusername", "dbname", "dbpassword",
)
dbConn, err := sql.Open("cloudsqlpostgres", dsn)
if err != nil {
panic(err)
}
drv := entsql.OpenDB(dialect.Postgres, dbConn)
defer drv.Close()
// init ent client
opt := []ent.Option{ent.Driver(drv)}
entClient := ent.NewClient(opt...)
// query user!
myuser, err := entClient.User.Query().Where(user.IDEQ("akkie")).All(ctx)
}
func initProxy(ctx context.Context, logger *zap.Logger) error {
const SQLScope = "https://www.googleapis.com/auth/sqlservice.admin"
client, err := goauth.DefaultClient(ctx, SQLScope)
if err != nil {
return err
}
// init cloudsql proxy
proxy.Init(client, nil, nil)
return nil
}
CloudSQL Auth ProxyをKubernetes上で運用する際、アプリと同じPod内にコンテナとして配置すると、PodのTerminate時にproxyコンテナの終了をアプリの終了の後になるよう設定するなどのGracefulな処理が必要となってやや面倒です。
ライブラリとして使えるメリットは大きかったと感じます。
entを使って一番良かったこと
上記のようなメリットも含めて、実際にチームで使うと一番感じるのは「開発工数が少なくなったな」ということです。チーム開発に置いて時間がかかるのは他のチームメンバーとのコミュニケーションが必要となる部分です。entを使わずに開発をしようとしたとき、以下のような作業などがDB周りで必要になってきます。
- テーブルスキーマやインデックスの設計
- DDL作成
- マイグレーション用のスクリプトの作成
- SQL設計
- DB clientの実装
- SQLを実行し結果をモデルオブジェクトに詰め替える部分の実装
それぞれについてテストが必要だったり、Pull Requestを作成し、コードレビューを依頼する必要もあります。それらを行っているだけでも2〜3日が簡単に過ぎることはザラにあるでしょう。
entを使えば、テーブルスキーマ・インデックスの設計さえ終えれば(それもgo言語で書かれた1ファイルで終わる)、あとはentが省略させてくれる作業がほとんどです。
開発チームで何か開発に関する設計ルールやデザインの方針が決まったら、ドキュメントを書いてチームにシェアする以外に「もう決まったルールをコードの生成によって自動化しよう」ということはよく行われていることかと思います。
entを使うことで、上記のような多くの作業が短縮でき生産性が上がったことが何より大きなメリットでした。
RDBMSを用いたサービスをGoで新規に開発される方はぜひentの使用を検討してみてください。
明日の記事はPrashant Mauryaさんです。引き続きお楽しみください!