こんにちは!LayerXの mosa_siru (榎本) です。
LayerX インボイスでは、もともと github.com/go-swagger/go-swagger を利用してREST APIを開発していましたが、最近開発したワークフロー機能 のコンポーネントではGraphQLを取り入れました。
GraphQLには様々なメリットがあり、RESTとの比較記事は多くありますが、なぜ僕らは移行したのか、その結果どうなったのかを紹介していきます。
GraphQLのメリット
GraphQLのメリットは、様々な箇所で語られています。例えばこの記事によれば、
- 強力に型付けされたスキーマであること
- アンダーフェッチとオーバーフェッチがないこと(後述)
- Apollo, Relayなどの、クライアントライブラリにより、フロントエンド開発が迅速になること
- 複数のGraphQL APIからの統合が可能
- 強力なエコシステム
があげられています。
強力に型付けられたスキーマにより、バックエンド・フロントエンドのどちらも型とコードを生成することができます。コンパイル時にチェックできますし、自動補完、モックやAPIドキュメントの自動生成、directiveによるvalidation・permission仕様の明確化…など様々な恩恵を受けることができます。
実際にフロントエンドでは、現在TypeScriptでVue Apolloを利用していますが、当然のようにスキーマ駆動開発ができます。モデルは生成され、APIインターフェースの型も生成してくれます。
ApolloやRelayは強力なキャッシュ機構を持っており、storeに状態を持つ設計にはとても相性が良いと思われます。vvakameさんやsonatardさんが熱い思いを六本木GraphQL #3で語っています。(僕も参加しており、前半ではこの記事の内容について話しております!)
と色々ありますが、今回の記事で僕が注目したいのは、バックエンドのメリットです。Go の gqlgen を前提に語りますが、おそらく汎用的なものだと思います。
アンダーフェッチ問題
こちらは超重要です。
要するに、クライアントがほしいデータ構造をうまく提供できず、以下の結末を迎えることです。
- リクエストの量が無駄にたくさんになってしまう(N+1リクエスト問題)
- リスト面のレスポンスにほしいデータが入りきっていないため、各リソースを取りにいくパターン
- それを避けるために、REST APIの仕様が、クライアントUIの都合に引きずられてしまう
GraphQLでは柔軟なデータ構造でレスポンスを構築することができるため、大体のユースケースでは1つのリクエストで全て返すことができます。
驚くべきことに、このためのバックエンドの実装もシンプルです。感覚的には1つ1つのリソース(のResolver)を無垢に実装すれば、その要求を満たすことができます。
オーバーフェッチ問題
GraphQLではほしいデータをクライアント側が明示的に要求するため、「オーバーフェッチ(不要なデータの取りすぎ)」がなくなり、サーバーサイドのパフォーマンスが向上し、帯域が節約されます。
RESTであるあるなのは、実際に使うことのないフィールドを返してしまうことです。
特に、 リレーションされたデータを無駄に構築してしまうことがとても手痛いです。例えばイメージですが、以下のような GET /users
レスポンスは、ユーザーグループを返しております。
{ "users": [ { "id": "1", "name": "mosa", "group": { "id": "111", "name": "LayerX" } }, { "id": "2", "name": "ymatsu", "group": { "id": "222", "name": "LayerY" } } ] }
LayerX インボイスではRDBを使っているのですが、リスト面では使う必要がないのに group
を構築しており、無駄クエリが発生してしまう原因となっていました。
GraphQLでは、以下のような必要最小限のクエリにすれば、裏側で groups
のデータに全く触ることなくレスポンスを構築することができます。(そして例えばGoだと、gqlgenを自然に使えば、何も考えずともそういった振る舞いになるのです)
{ users { id name }
{ "data": { "users": [ { "id": "1", "name": "mosa" }, { "id": "2", "name": "ymatsu" } ] } }
バックエンドコードの見通しの悪さの解決
今回の記事で最も僕が強調したい (そして伝わるかかなり怪しい) のがこちらです。
過去の実装
Goで上記のような GET /users
レスポンスを構築するためには、これまで以下のようなstructのembeddedを定義して、
type User struct { ID string `json:"id"` Name string `json:"name"` GroupID string `json:"group_id"` } type Group struct { ID string `json:"id"` Name string `json:"name"` } type UserEmbedded struct { User Group }
以下のようなメソッドで内部的に取得し、各レイヤーで引き回していました。
func GetUserEmbedded(ctx context.Context) ([]*UserEmbedded, error)
- ※実際は
UserEmbedded
にはいろんなものがEmbedされています。 - ※参考ですが、LayerX インボイスでは xo を利用しており、usersテーブルとgroupsテーブルから自動でUser, Groupの struct が生成されます。 LayerX インボイスの技術スタック〜分野横断で開発するためのSchema Driven Development〜 - LayerX エンジニアブログ
- ※ページング系は省略
さて、このメソッドの中は、実際には悲しいことになっています。
気の利いたORMがあれば別なのでしょうが、
- users DBから、
var users []*User
を取得 (Joinはしない) - N+1を避けるため、usersからgroup_idをひっこぬき、DBから
var Groups []*Group
を取得 - それぞれから
[]*UserEmbedded
を構築
…の流れを,N+1を避けて実際に雑にかいてみたのが以下です。読まなくていいです。コレクション操作がナイーブなので、辛いことがわかればいいです!
func GetUserEmbedded(ctx context.Context) ([]*model.UserEmbedded, error) { // 1. users取得 users, err := GetUsersFromDB(ctx) if err != nil { return nil, err } // 2. groups 取得(N+1を避けるぞ) groupIDSet := map[string]struct{}{} for _, user := range users { groupIDSet[user.GroupID] = struct{}{} } groupIDs := []string{} for id, _ := range groupIDSet { groupIDs = append(groupIDs, id) } groups, err := GetGroupsByIDsFromDB(ctx, groupIDs) if err != nil { return nil, err } // 3. UserEmbedded構築 id2Group := map[string]*model.Group{} for _, group := range groups { id2Group[group.ID] = group } res := make([]*model.UserEmbedded, len(users)) for i, user := range users { group, ok := id2Group[user.GroupID] if !ok { return nil, errors.New("group not found") } res[i] = &model.UserEmbedded{ User: *user, Group: *group, } } return res, nil } func GetUsersFromDB(ctx context.Context) ([]*model.User, error) { return []*model.User{}, nil //略 } func GetGroupsByIDsFromDB(ctx context.Context, ids []string) ([]*model.Group, error) { return []*model.Group{}, nil //略 }
何かがおかしい気がしますが、今までそんな実装をしていました。(しかもhandler層は省略しています。。)
gqlgenを利用した実装
さてこれが、gqlgenを使うとどうなるでしょうか。
結論から言うと、以下のメソッドを実装するだけで、終わりです。えっ?
func GetUsersFromDB(ctx context.Context) ([]*model.User, error) { return []*model.User{}, nil // 略 } func GetGroupByIDFromDB(ctx context.Context, id string) (*model.Group, error) { return &model.Group{}, nil // 略 }
上記メソッドを各resolverが呼び出すコードを置いておくと、gqlgenは最終的なレスポンスを構築してくれます。えっ?過去の実装で言うhanlderの省略してないんですよこれ。
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) { return GetUsersFromDB(ctx) } func (r *userResolver) Group(ctx context.Context, obj *model.User) (*model.Group, error) { return GetGroupByIDFromDB(ctx, obj.GroupID) }
実際の動きとしては、まずqueryResolver(雑に言うhandler)で []*model.User
を取得します。各Userの GroupID
フィールドをちゃんとした *model.Group
にする userResolver を、gqlgen の自動生成コードがよしなに呼んでくれるのです。(しかもResolverの型は自動生成されるので、中を埋めるだけです。)
group を要求しないクエリの場合、このresolverは呼ばれないため、オーバーフェッチすることがありません。
...あんまり伝わらない気がしますが、とにかく楽なのです。こればかりは体験するのが一番良いので、ぜひ公式チュートリアルのTODOリストをやってみてくださいませ。
これによりコードの見通しがよくなり、いろんなものをEmbeddedしていた地獄から解放され、パフォーマンスも向上しました!
アンダーフェッチ問題と合わせると、本当に「各リソース」に注目してserveすれば、あとはよしなに使われる、という感覚を得ました。
それN+1じゃね?
うっ、バレました。 GetGroupByIDFromDB
によって毎回Resolveするのは、N+1なんです。
SELECT * FROM groups WHERE id = '111'; SELECT * FROM groups WHERE id = '222';
それを解決するのがdataloaderという仕組みです。dataloaden も検討しましたが、現在は github.com/graph-gophers/dataloader を使っています。
もう長くなってしまったので説明をはしょりますが!こちらのライブラリは、group_id からの取得を一定期間バッファリングして、一気に取得するぜっていうバッチ実装をやりやすくしてくれます。
SELECT * FROM groups WHERE id in ('111', '222');
実際の使い方は以下の記事とサンプルを参考にしました。感謝。
https://user-first.ikyu.co.jp/entry/go-graphql-dataloader
https://github.com/hatena/go-Intern-Bookmark
まとめ
- GraphQLにはいろんなメリットがあるよ
- その中でもバックエンドに注目すると、オーバーフェッチ・アンダーフェッチ問題を解決しつつ、コードの見通しがとてもよくなるよ
- dataloaderは偉い
というかんじです。
まだまだ紹介しきれていない話がありますが、今の所はGraphQLの選定は正解だったなーというお気持ちで、手応えを感じながら開発を進めております。
最後になりますが、LayerXは絶賛エンジニア採用中ですので、少しでも興味ありましたら一度話を聞きに来てください!