Go用ORMのentでスキーマと別構造のJSONへと効率的にシリアライズする方法
Go用ORMのentにおいて、スキーマの構造と異なる構造のJSONにデータをシリアライズする方法について記します。
なお本記事は、執筆時点で最新のent v0.14を前提としています。
TL;DR
- 目的:entスキーマと異なるJSON構造にシリアライズ
- 素朴なEdge走査ではN+1問題(SQLクエリの大量発行)が発生
- Eager Loadingでもクエリ2回発行に加えて構造変換処理が必要
- カスタム述語+JOIN+Scanを使えばクエリ1発で目的の構造体に直接マッピング可能
entとは
entはGo向けのエンティティフレームワーク (ORM)で、当初Facebookにより開発され、現在はAtlasチームが開発を引き継いでいます。
データベースに直接アクセスするコードを書くのではなくORMを用いることには、いくつかの利点があります。
まず、データアクセスをドメインロジック(ビジネスロジック)から分離でき、ロジック層(ビジネス層)の開発者がデータベースの内部機構を深く意識する必要がなくなります(もちろん、把握している方が望ましいですが)。これにより、開発効率、保守性、テスト性が大きく向上します。
また、ORMを利用することでシステムのセキュリティを高めることができます。最大の利点として、SQLインジェクションのような不正なクエリが実行される攻撃を原則として防げる点が挙げられます。「原則として」と書いたのは、多くのORMがSQL文を直接実行する機能を備えており、それを用いたコードにはSQLインジェクションのリスクが残るからです。しかし、ORMの提供するAPIを適切に使用すれば(ORMに脆弱性がない限り)、クエリが改変される余地のない安全なコードを記述できます。他にも、型安全性など様々なセキュリティ上の効果があります。
entは、Schema as Codeという考え方を採用し、スキーマ定義からコードを自動生成する点が特徴です。生成コードは静的型付けされた明示的APIを持ち、コンパイル時に型安全性を確保します。その反面、動的型付けや型リフレクションを用いたORMと比較すると、柔軟性や簡潔性に欠ける面もあります。
他のGo用ORM(たとえばGORMやsqlcなど)と比較すると、スキーマ定義をGoで記述して静的型付けされたコードを生成する点、グラフベースのAPIを持つ点などが特徴で、IDEでのコード自動補完やリファクタリングのしやすさといった点で優れています。大規模なプロジェクトや複雑なドメインモデルを扱う場合に特に強みを発揮します。
やりたいこと
本記事では、entを用いた際に、スキーマ定義とは異なる構造のJSON形式へとデータをシリアライズすることを考えます。
サービスのAPIは、一般的に、ドメインロジックやサービスの要求に基づいて設計されます。一方で、リレーショナルデータベース (RDB) のスキーマは、データベースに格納すべきエンティティやその間の関係性などの分析に基づいて、正規化を経て設計されます。
つまり、サービスAPIとRDBのそれぞれで最適な情報構造化は異なるので、一般的に、サービスAPIで扱うデータの構造と、RDBで正規化されたスキーマの構造は一致しません。それゆえ、サービスの内部実装で、データベースから取得したデータをAPIの要求する形式に変換してシリアライズする必要があります。
SQL文を直接実行する場合は、JOINやSELECTを上手く用いることで、多くの場合はSQL文だけで構造の変換が実現可能です。しかし、ORMを用いるセキュリティ上の利点にとっては、コードにSQL文を直接書かないことが重要です。
そこで、entで定義したスキーマから自動生成されるオブジェクト構造から、異なる構造のJSON形式へと、効率的に変換する方法について記します。
サンプルスキーマ
本記事では、以下のER図(実体関連図)で示されるスキーマをサンプルに説明します。
Userが複数のPetのownerになっているという1対多の関係を持ちます。
上のER図をentの形式でスキーマ定義すると、主要部分は以下のようになります。
// User
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int("age"),
field.String("surname"),
field.String("given_name"),
}
}
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("pets", Pet.Type),
}
}
// Pet
func (Pet) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Int("owner_id"),
}
}
func (Pet) Edges() []ent.Edge {
return []ent.Edge{
edge.From("owner", User.Type).Ref("pets").
Unique().Required().Field("owner_id"),
}
}
出力したいJSON形式
TypeScriptで書くと以下のようになる、ペットの名前 (pet) とその家族名 (family) を持つJSONオブジェクトのリストを、JSON形式にシリアライズすることを考えます。
type PetOwner = {
pet: string;
family: string;
}
Goの構造体で書くと次のような形です。
type PetOwner struct {
Pet string `json:"pet"`
Family string `json:"family"`
}
entでの標準的なやり方
まずは、entの標準的なやり方について書きます。
素朴な方法
entでは、オブジェクトとその間の関係はグラフ構造として表現され、グラフの辺 (Edge) を辿っていくのがentの基本的なやり方です。
そこで、最も単純には、各Petについてowner Edgeの先を取得するQueryOwnerを使えば、次のようなコードでPetOwnerのリストを作ることができます。
pets := client.Pet.Query().AllX(ctx)
petOwners := make([]PetOwner, 0, len(pets))
for _, pet := range pets {
owner := pet.QueryOwner().OnlyX(ctx)
po := PetOwner{
Pet: pet.Name,
Family: owner.Surname,
}
petOwners = append(petOwners, po)
}
ここで作成したpetOwnersは、例えばjson.Marshalに引数として与えればJSONにシリアライズできます。
しかし、このコードには非常に重大な処理性能上の問題があります。
forループ内でQueryOwnerが呼ばれるたびに個別のSQLクエリが発行されるため、Petの件数をNとすると、合計でN+1回のクエリが実行されてしまいます。これは「N+1問題」として知られる典型的なアンチパターンです。
実際に上記のコードを実行したときに発行されるSQLの例を以下に示します。
SELECT `pets`.`id`, `pets`.`name`, `pets`.`owner_id` FROM `pets` args=[]
SELECT DISTINCT `users`.`id`, `users`.`age`, `users`.`surname`, `users`.`given_name` FROM `users` JOIN (SELECT `owner_id` FROM `pets` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`owner_id` LIMIT 2 args=[1]
SELECT DISTINCT `users`.`id`, `users`.`age`, `users`.`surname`, `users`.`given_name` FROM `users` JOIN (SELECT `owner_id` FROM `pets` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`owner_id` LIMIT 2 args=[2]
SELECT DISTINCT `users`.`id`, `users`.`age`, `users`.`surname`, `users`.`given_name` FROM `users` JOIN (SELECT `owner_id` FROM `pets` WHERE `id` = ?) AS `t1` ON `users`.`id` = `t1`.`owner_id` LIMIT 2 args=[3]
たとえば、Petが1万件登録されていると、最初の一覧取得とあわせて1万と1回のSQLクエリが実行されることになります。
これは非常に非効率で、実用には全く適していません。
Eager Loadingを使う方法
entにはEager Loadingという、エッジの先のオブジェクトを一緒に読み込む機能が用意されています。
この機能を使うと次のようなコードが書けます。
pets := client.Pet.
Query().
Select(pet.FieldName).
WithOwner(func(uq *ent.UserQuery) {
uq.Select(user.FieldSurname)
}).
AllX(ctx)
petOwners := make([]PetOwner, 0, len(pets))
for _, pet := range pets {
po := PetOwner{
Pet: pet.Name,
Family: pet.Edges.Owner.Surname,
}
petOwners = append(petOwners, po)
}
PetへのQueryでWithOwnerメソッドを使うと、各PetのEdges.OwnerフィールドにownerのUserが格納されます。ここでは、今回必要なSurnameフィールドだけを格納するようにしています。
このコードでは、JSON出力に必要な情報の取得が1行で書けていますが、実行したときには以下のSQLが発行されます。
SELECT `pets`.`id`, `pets`.`name`, `pets`.`owner_id` FROM `pets` args=[]
SELECT `users`.`id`, `users`.`surname` FROM `users` WHERE `users`.`id` IN (?, ?) args=[1 2]
Petの一覧を取得するクエリの後に、関連するOwner情報をまとめて取得するINを用いたクエリが発行されています。
このように、Eager Loadingを使えば、QueryOwnerを用いた素朴な方法と比べるとSQLクエリ数が大幅に削減されて効率化されますが、まだ2個のクエリが発行されています。
また、Queryで得られたPetのスライスから、最終的に出力したいPetOwnerのスライスへと構造を変換する処理が必要です。
entでJOINを用いるやり方
Eager Loadingでは2個のSQLクエリが発行されていますが、SQLのJOINを使えば1回のSQLクエリで実現できます。
そこで、entでJOINを使う方法を説明します。
カスタム述語を使う方法
entでは、Whereメソッドには、述語 (predicate) を引数として渡します。この述語は、func(*sql.Selector) という型の関数で、自身で定義したカスタム述語を与えることができます。
このカスタム述語の機能を使ってJOINするのが以下のコードです。
pets := client.Pet.
Query().
Where(func(s *sql.Selector) {
t := sql.Table(user.Table).As(user.Table)
s.Join(t).
On(s.C(pet.OwnerColumn), t.C(user.FieldID)).
Select(s.C(pet.FieldName), t.C(user.FieldSurname))
}).
AllX(ctx)
petOwners := make([]PetOwner, 0, len(pets))
for _, pet := range pets {
ownerName, _ := pet.Value(user.FieldSurname)
po := PetOwner{
Pet: pet.Name,
Family: ownerName.(string),
}
petOwners = append(petOwners, po)
}
Whereに渡している無名関数の中で、まずUserのテーブルを変数tとして定義します。
Whereに渡されるs *sql.SelectorはPetのテーブルに紐付いていますから、Petのテーブルのowner_idカラムとUserのテーブルのidカラムが一致するものについてJoinします。
ただ、これだけだとUserの情報は捨てられてしまうので、出力結果にUserのsurnameが含まれるようにSelectします。
Queryの出力結果で、Petのフィールドに含まれないカラムの値は、Valueメソッドにカラム名を指定して取り出せます。
ただし、Selectで指定したカラム名(今回はsurname)がQueryを呼び出すテーブル(今回はpets)のカラム名と重複している場合は、Petのフィールドが上書きされてしまうので注意してください。
このコードで発行されるSQLは以下の通りです。
SELECT `pets`.`name`, `users`.`surname` FROM `pets` JOIN `users` AS `users` ON `pets`.`owner_id` = `users`.`id` args=[]
このように、Queryで発行されるSELECT文でJOINを使うようにすることで、1回のSQLクエリで情報を取得できており、また、必要なものだけに限定してデータベースから所得しているので、Eager Loadingよりさらに処理が効率化されます。
ただ、まだこのコードでは、Valueを使って値を読み出しているので、PetOwnerへの詰め直し処理が残っています。Petの件数が多い場合、そのオーバーヘッドは軽視できません。
カスタム述語とScanを併せて使う方法
発行されるSQL文をさらにカスタマイズすることで、PetOwnerに直接マッピングするようにしたものが以下のコードです。
petOwners := make([]PetOwner, 0)
client.Pet.
Query().
Where(func(s *sql.Selector) {
t := sql.Table(user.Table).As(user.Table)
s.Join(t).
On(s.C(pet.OwnerColumn), t.C(user.FieldID)).
Select(
sql.As(s.C(pet.FieldName), "pet"),
sql.As(t.C(user.FieldSurname), "family"),
)
}).
Select().
ScanX(ctx, &petOwners)
Joinするところまでは前のコードと同じです。
今回は、Selectするときに、PetOwnerのフィールドにタグで指定したJSONオブジェクトメンバーの名前(いわゆるキー名)をAsで指定しています。
これで、PetOwner構造体に合致する出力結果が得られるSQL文が構成されます。
entが生成したコードには、出力結果を指定した構造体のスライスに直接格納するScanメソッドが用意されていますが、Query().Where()の戻り値の型 (*ent.PetQuery) にはScanメソッドは定義されていません。
そこで、空のSelectメソッドを呼び出すことで、Scanメソッドが定義されている型 (*ent.PetSelect) が得られます。
こうして、PetOwner構造体に直接Scanできます。
このコードでは以下のSQLが発行されます。
SELECT `pets`.`name` AS `pet`, `users`.`surname` AS `family` FROM `pets` JOIN `users` AS `users` ON `pets`.`owner_id` = `users`.`id` args=[]
SELECT文でASによりカラム名が指定されていることが分かります。
この方法では、SQLの結果を直接PetOwner構造体に格納しているので、これまでのコードで必要だった詰め直し処理も不要となり、大量データを処理する場合でも効率的にデータのシリアライズが可能です。
おわりに
本記事では、Go用ORMのentを使って、スキーマ定義とは異なる構造のJSONにデータをシリアライズする方法について、標準的な手法からカスタム述語を活用した効率的な手法まで説明しました。
カスタム述語を使う場合は、コードがSQLテーブル定義に依存することには注意が必要です。
保守性の観点では、今回紹介したJOINを用いるコードをリポジトリ層にカプセル化するなど、SQL詳細への依存を特定の箇所に限定し、適切に関心の分離 (SoC) を行う工夫が必要になると思います。
処理する件数が限られる場合には、Eager Loadingを使う方が直感的で保守性が高いかもしれません。
なお、冒頭にも書きましたが、本記事は原稿執筆時点で最新のent v0.14に基づいています。
ent公式ドキュメントのEager Loadingのページには
Since an Ent query can eager-load more than one edge, it is not possible to load all associations in a single JOIN operation. Therefore, Ent executes additional query to load each association. This expected to be optimized in future versions.
と書かれており、今後Eager Loadingの挙動が改善される可能性があります。
参考リンク
- Eager Loading: https://entgo.io/docs/eager-load
- カスタム述語: https://entgo.io/docs/predicates#custom-predicates
- Scan: https://entgo.io/docs/crud/#:~:text=Scan
Discussion