LayerX エンジニアブログ

LayerX の エンジニアブログです。

GraphQLでバックエンドのコードをすっきりさせた話

こんにちは!LayerXの mosa_siru (榎本) です。

LayerX インボイスでは、もともと github.com/go-swagger/go-swagger を利用してREST APIを開発していましたが、最近開発したワークフロー機能 のコンポーネントではGraphQLを取り入れました。

GraphQLには様々なメリットがあり、RESTとの比較記事は多くありますが、なぜ僕らは移行したのか、その結果どうなったのかを紹介していきます。

GraphQLのメリット

GraphQLのメリットは、様々な箇所で語られています。例えばこの記事によれば、

  1. 強力に型付けされたスキーマであること
  2. アンダーフェッチとオーバーフェッチがないこと(後述)
  3. Apollo, Relayなどの、クライアントライブラリにより、フロントエンド開発が迅速になること
  4. 複数のGraphQL APIからの統合が可能
  5. 強力なエコシステム

があげられています。

強力に型付けられたスキーマにより、バックエンド・フロントエンドのどちらも型とコードを生成することができます。コンパイル時にチェックできますし、自動補完、モックやAPIドキュメントの自動生成、directiveによるvalidation・permission仕様の明確化…など様々な恩恵を受けることができます。

実際にフロントエンドでは、現在TypeScriptでVue Apolloを利用していますが、当然のようにスキーマ駆動開発ができます。モデルは生成され、APIインターフェースの型も生成してくれます。

ApolloやRelayは強力なキャッシュ機構を持っており、storeに状態を持つ設計にはとても相性が良いと思われます。vvakameさんやsonatardさんが熱い思いを六本木GraphQL #3で語っています。(僕も参加しており、前半ではこの記事の内容について話しております!)

www.youtube.com

と色々ありますが、今回の記事で僕が注目したいのは、バックエンドのメリットです。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)

さて、このメソッドの中は、実際には悲しいことになっています。

気の利いたORMがあれば別なのでしょうが、

  1. users DBから、 var users []*User を取得 (Joinはしない)
  2. N+1を避けるため、usersからgroup_idをひっこぬき、DBから var Groups []*Group を取得
  3. それぞれから []*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は絶賛エンジニア採用中ですので、少しでも興味ありましたら一度話を聞きに来てください!

open.talentio.com