Help us understand the problem. What is going on with this article?

GraphQLと相性の良いORM Prisma

この記事は GraphQL Advent Calendar 2020 10 日目の記事です。
前回の記事は @mtsmfm さんの Swift 用 graphql-codegen plugin の graphql-codegen-swift-operations を作った でした。

はじめに

PrismaはGraphQLを実装するためのクライアントライブラリ,ORM(Prisma1においてはGraphQLサーバ自体も含む)として広く知られていると思いますが、Prismaはversion2(以下、ただのPrismaと書いている箇所はPrisma2を指します)より、ORM部分に注力し、GraphQLとは直接関係ない方向に成長していく方向に舵を取っています。
(参考: https://www.prisma.io/blog/prisma-2-is-coming-soon-mwwfhevie993)

しかし、Prismaはその出自から、多くの部分においてGraphQLと親和性の高い機能を持っています。
この記事ではそのようなGraphQLと関係の深い機能を紹介したいと思います。

そもそもPrismaって何?1の頃と何が違うの?というのは以前に発表した資料があるので、気になる方は見てみてください。

https://speakerdeck.com/joere/prisma2-with-graphql

執筆時点でのPrismaのバージョンは以下の通りです。
- 2.13.0

Dataloader

よく言及されるGraphQLの問題の1つに、DBアクセスにおいてN+1が発生しやすいという問題があります。
これはGraphQLのクエリが木構造であり、それぞれの要素に対応したResolverを再起的に実行して結果を返す必要があるからです。

具体的に以下のようなSchema定義があるとします。

type Query {
  users: [User]!
}

type User {
  email: String!
  id: Int!
  name: String
  Posts: [Post!]!
  Profile: Profile
}

type Post {
  body: String!
  id: Int!
  published: Boolean!
  title: String!
}

type Profile {
  bio: String!
  id: Int!
  User: User!
}

UserのResolverの実装は以下のようになります。

export const User = objectType({
  name: 'User',
  definition(t) {
    t.nonNull.int('id'),
    t.string('email'),
    t.string('name'),
    t.nonNull.list.field('posts', {
      type: 'Post',
      resolve(root, _args, ctx) {
        return ctx.prisma.user.findUnique({ where: { id: root.id}}).Posts()
      }
    })
    t.field('profile', {
      type: 'Profile',
      resolve: (root, _, ctx) => {
        return ctx.prisma.user.findUnique({ where: { id: root.id }}).Profile()
      }
    })
  }
})

この実装を素朴に読むと、以下のGraphQLを実行したときに User数分のPost, Profileを取得する処理が実行されそうに見えます。

{
  users {
    id
    Posts {
      id
      title
      published
    }
    Profile {
      bio
    }
  }
}

しかし、実際にPrismaから実行されたSQLのログを見てみると、発行されたSQLは以下の5つです。

1. SELECT "public"."User"."id", "public"."User"."email", "public"."User"."name" FROM "public"."User" WHERE 1=1 OFFSET $1
2. SELECT "public"."User"."id" FROM "public"."User" WHERE "public"."User"."id" IN ($1,$2,$3) OFFSET $4
3. SELECT "public"."Post"."id", "public"."Post"."title", "public"."Post"."body", "public"."Post"."published", "public"."Post"."userId" FROM "public"."Post" WHERE "public"."Post"."userId" IN ($1,$2,$3) OFFSET $4
4. SELECT "public"."User"."id" FROM "public"."User" WHERE "public"."User"."id" IN ($1,$2,$3) OFFSET $4
5. SELECT "public"."Profile"."id", "public"."Profile"."bio", "public"."Profile"."userId" FROM "public"."Profile" WHERE "public"."Profile"."userId" IN ($1,$2,$3) OFFSET $4

これはPrismaがdataloaderのオプティマイゼーションに対応しているからです。

RailsなどのFWを用いてREST APIを実装する上では、Eager Loading(事前読み込み)がN+1問題を避ける上では一般的かと思います。
しかし、GraphQLでは先にあげた通り、Resolverが他のResolverを再起的に呼び出す実装になるので、事前読みでのオプティマイゼーションが難しいです。
そこでGraphQLにおいてはLazy Loading(遅延読み込み)がN+1を避ける方法としては一般的です。
dataloaderはLazy Loadingを実装するモジュールで、もともとはGraphQLが発表された当時より、Facebookが参考実装として公開していたライブラリです。
https://github.com/graphql/dataloader

そこから、Lazy Loadingを提供するモジュールは広くdataloaderと呼ばれるのが一般的になっているかなぁと思います。

Prisma Client(PrismaのAPIを提供するNode用のクライアントライブラリ)の実装では、Queryの発行が指示された瞬間にQueryを実行するのではなく、nodeのnextTick callbackが動くまでメモリ上のキャッシュに保存されます。
nextTickが発火した時、requestが一度にPrisma Engine(Rustで実装されているクエリエンジン)に送られます。
一括で送られたクエリは、Prisma Engine上でオプティマイゼーションされたSQLに変換され、その実行結果がNode(Prisma Client)に返ります。

実際にSQLを見てみると、上記の5つのクエリは以下のステップになっていることがわかります。

  1. Userの一覧を取得する
  2. UserのIDの存在チェック(?) * このクエリが必要な理由はぱっと見では分かりませんでした。時間がある時に実装を追ってみたいなぁと思います。
  3. Userに紐づくPostの取得
  4. 2と同様
  5. Userに紐づくProfileの取得

(それぞれのクエリでWHERE "public"."Post"."userId" IN ($1,$2,$3)となっているのはテストで用意したユーザの件数が3件だからです。)

見て分かる通り、件数に従ってクエリの回数が増えるのではなく、Resolverの数に従って増えていることが分かります。

実際の実装は以下から確認できます。

Eager Loadingを利用したオプティマイゼーションもinclude fieldを利用して実装することが可能です。
https://www.prisma.io/docs/concepts/components/prisma-client/field-selection#include

const result = await prisma.user.findUnique({
  where: { id: 1 },
  include: { posts: true },
})

このクエリでは、postsの一覧も含めたユーザを取得しています。

Nested writes/reads

これは先にあげたincludeに通じるところなのですが、PrismaのAPIの多くで、Nestedなオブジェクトに対する操作の一括処理が可能です。

https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#nested-writes

const user = await prisma.user.create({
  data: {
    email: 'alice@prisma.io',
    profile: {
      create: { bio: 'Hello World' },
    },
  },
})

この例ではユーザの作成と同時にProfileも作っています。
このAPIはGraphQLの利点の一つである、クエリをまとめることでリクエスト数を減らせる、という部分と相性が良いです。

Readは以下のようになります

const users = await prisma.user.findMany({
  include: {
    posts: {
      include: {
        categories: {
          include: {
            posts: true,
          },
        },
      },
    },
  },
})

Cursor-based pagination

GraphQLにおいては、Cursor-basedと呼ばれるpaginationが一般的です。

(Offsetに比べて優れているというわけではなく、向いているところが違います。具体的には無限スクロールのような、前回取得した結果から最新の差分を取得するような実装に向いています。
逆にGoogleの検索結果のような、ページ数を指定しての取得には向きません。
このあたりは使い分けになるかなと思います。)

これはGraphQL公開当時より、Relayがベストプラクティスとして採用し、実装していたところから広まりました。
https://relay.dev/graphql/connections.htm

Cursor-based paginationにおいては、一般的にfirstafterを引数として要求します。
firstには取得件数、afterにはCursorと呼ばれる、前回結果より得られた、各要素に一意に振られる文字列を指定し、結果はafter以降を返すことになります。

friends(first:2 after:"5") のように指定した場合、結果は"5"以降から始めて2件返す、ということになります。

この場合のCursorはIDであるとは限らず、条件次第ではoffset/limitの情報も含んでいることもあります。
フォーマットは要件次第で変わりやすいため、GraphQLの公式では必要な情報を入れた上でbase64 encodingをして文字列に変換することが推奨されています。
https://graphql.org/learn/pagination/#pagination-and-edges

上記の通り要件によってCursorの実装は考えないといけないことが多いので必ずしもそのまま使えるわけではないのですが、Prismaでは公式のAPIとして素朴な実装が提供されています。

https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination

const secondQuery = prisma.post.findMany({
  take: 4,
  cursor: {
    id: myCursor,
  },
  where: {
    title: {
      contains: 'Prisma' /* Optional filter */,
    },
  },
  orderBy: {
    id: 'asc',
  },
})

これを動かすためには以下の制限があります。
- 結果のリストがCursorによってソートされていなければならず、かつCursorはuniqueでありsequentionでなければならない
- Page数を指定しての取得はできない

素朴な実装となるので様々なユースケースに対応できるかというと微妙だとは思いますが、簡単なユースケースなら、ORM側に実装されている安心感があります。

Nexus

NexusというGraphQL resolver実装用のFWがあります。
現在はPrismaと直接の関係はないのですが、もともとはPrismaオーガナイゼーションの配下にあり、今でもPrismaの開発者からのコントリビュートがあったり、Prisma連携のドキュメントやライブラリなどのサポートもあったり、Prismaを使っていると何かと捗ります。

NexusはType-safeにGraphQLサーバを快適に実装することを目的としていて、型の自動生成などのDE向上のための機能が充実しています。

実際に書くことになるスキーマ定義、Resolverの実装はgraphql-jsに準拠しているので、触ったことがある人ならとっつきやすいかなぁと思います。

nexusのPrisma plugin(nexus-plugin-prisma)を使うと、modelの値をそのままexposeしたいような簡単なケースでは以下のようにResolverに実装を書かずに済んだりします。

export const User = objectType({
  name: 'User',
  definition(t) {
    t.model.id(),      // ID fieldを公開
    //...
    t.model.Profile(), // 以下と同様
    // t.field('profile', {
    //   type: 'Profile',
    //   resolve: (root, _, ctx) => {
    //     return ctx.prisma.user.findUnique({ where: { id: root.id }}).Profile()
    //   }
    // })
  }
})

僕自身まだあまり使い倒してはいないのですが、個人的にはNexusの体験は良いと感じています。
Prismaを検討の方は合わせてNexusも検討してみても良いのではないでしょうか。

おわりに

Prisma1よりORM部分に特化しGraphQLとは直接の関係がなくなったPrismaですが、その中でもGraphQLを実装する上で相性が良いと感じる部分を紹介させていただきました。
ご興味がある方はぜひお試しください。

joe-re
オンラインミーティングサービスを作っています
http://joe-re.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away