petitviolet blog

    SangriaでGraphQL APIを実装するのに知っておきたいこと

    2018-01-30

    QiitaScalaGraphQLsangria

    この記事はなに

    Sangriaで GraphQL な API を実装するにあたって、公式のサンプルだけだと少し足りないためそれの補足というか tips 的な記事。

    Sangria の導入にはこちらをどうぞ。
    [Scala]Sangria を使って GraphQL API を実装する - Qiita

    複数のモデルにアクセスしたい

    Ctx と Val についての補足にも少し書いたが、sangria ではCtxという型パラメータが頻出していてドキュメントでは

    A typical example of such a context object is a service or repository object that is able to access a database

    書いてある
    つまり Repository や Service のような DB へのアクセス可能なオブジェクトが渡されることを期待している。

    https://qiita.com/petitviolet/items/e3e87c3f3e740b3c57ba#ctx%E3%81%A8val%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6%E3%81%AE%E8%A3%9C%E8%B6%B3

    しかし、サンプルのように Repository が 1 種類(CharacterRepo)しかないこともないはず。

    val Query = ObjectType[CharacterRepo, Unit](
      "Query", fields[CharacterRepo, Unit](
        Field("hero", Character,
          arguments = EpisodeArg :: Nil,
          resolve = ctx ⇒ ctx.ctx.getHero(ctx.argOpt(EpisodeArg))),
        ...
      ))
    
    val StarWarsSchema = Schema(Query)
    

    StarWarsSchemaではCharacterRepoCtxとして要求するので、実行するときには以下のようになる。

    Executor.execute(
      SchemaDefinition.StarWarsSchema,
      queryAst,
      new CharacterRepo,
      ...
    )
    

    GraphQL として Query(Mutation)を実行する際にSchemanew CharacterRepoを渡していて、他の Repository を使えないようになってしまっている。
    複数のモデルにアクセスするような API を実装したいときにはこれでは困る。

    どうやって複数の Repository や DAO を渡すか

    手っ取り早い方法が、Ctxとして使いたい Repository や DAO を保持するオブジェクトを用意すればよい。

    trait container {
      def userRepo: UserRepo
      def characterRepo: CharacterRepo
    }
    

    このcontainerCtxとして使用すればUserRepocharacterRepoのどちらにもアクセス出来るようになる。 使うときにはcontainerとして使えばいいだけなので特に難しくない。

    val Query = ObjectType[container, Unit](
      "Query", fields[container, Unit](
        Field("hero", Character,
          arguments = EpisodeArg :: Nil,
          resolve = ctx ⇒ ctx.ctx.characterRepo.getHero(ctx.argOpt(EpisodeArg))), // characterRepoを使用
        Field("user", User,
          arguments = UserArg :: Nil,
          resolve = ctx ⇒ ctx.ctx.userRepo.getUser(ctx.argOpt(UserArg))), // userRepoを使用
        ...
      ))
    val StarWarsSchema = Schema(Query)
    

    このようにして定義したSchemaに対して GraphQL を実行するには以下のように、containerの実装を注入すればよい。

    object containerImpl extends container {
      override val userRepo = new UserRepoImpl()
      override val characterRepo = new CharacterRepoImpl()
    }
    
    Executor.execute(
      SchemaDefinition.StarWarsSchema,
      queryAst,
      containerImpl,
      ...
    )
    

    Ctx ごとにExecutorを作成して合成できるとよいのだが...。 なお以降のサンプルコードではcontainerを使用している。

    Fetcher を使った遅延取得

    たとえばUserTodoという 2 つのオブジェクトがある。

    case class User(id: Id,
                    name: String,
                    email: String,
                    createdAt: ZonedDateTime,
                    updatedAt: ZonedDateTime)
    
    case class Todo(id: Id,
                    title: String,
                    description: String,
                    userId: Id,
                    createdAt: ZonedDateTime,
                    updatedAt: ZonedDateTime)
    

    TodoにはuserIdがあり、これを使ってUserを取得したい。
    普通に実装するとこのようになる。

    lazy val TodoType: ObjectType[container, Todo] =
      derive.deriveObjectType(
        derive.AddFields(
          Field("user", OptionType(UserType),  // UserTypeはObjectType[Unit, User]のインスタンス
            resolve = { ctx: Context[container, Todo] =>
              // ここでTodo#userIdを使ってDBからUserを取得する
              ctx.ctx.userDao.findById(ctx.value.userId)
            })
        )
      )
    

    このような実装ではいわゆる N+1 問題が生じてしまう。

    N+1 問題への対策はDeferred Value Reslutionで紹介されている。 Deferredを使えばいいらしい。

    まずはSeq[Id]からSeq[User]を取得するためのFetcherを実装する。
    Fetcher.caching(Ctx, Seq[Id]) => Future[Seq[User]]な関数を与えるだけでよい。

    val userFetcher: Fetcher[container, User, User, Id] = Fetcher.caching {
      (ctx: container, ids: Seq[Id]) =>
        Future.apply {
          ctx.userDao.findAllByIds(ids)
        }
    

    これを使って先程のTodoTypeを改めて実装するとこうなる。

    lazy val TodoType: ObjectType[container, Todo] =
      derive.deriveObjectType(
        derive.AddFields(
          Field("user", OptionType(UserType),
            resolve = { ctx: Context[container, Todo] =>
              // userFetcherを使ってuserIdからUserを取得する
              userFetcher.defer(ctx.value.userId)
            })
        )
      )
    

    このTodoTypeを使って Query や Mutation を実装すればよいが、これだけでExecutor.executeを実行すると、

    sangria.execution.deferred.UnsupportedDeferError: Deferred resolver is not defined for deferred value ...
    

    というエラーが出てしまう。Deferred resolverが定義されていないというエラー。

    sangria/Executor.scalaを見るとDeferredResolver[Ctx]が必要らしいので用意する。 非常に簡単で、DeferredResolver.fetchersに実装したFetcherを渡すだけでよい。

    val resolver: DeferredResolver[container] = DeferredResolver.fetchers(userFetcher)
    

    このresolverExecutor.executeに渡す。

    Executor.execute(
      SchemaDefinition.StarWarsSchema,
      queryAst,
      containerImpl,
      deferredResolver = resolver,
      ...
    )
    

    先程まではUserDao.findByIdでつど DB アクセスしていたのが、Fetcherを使うことによって遅延評価(?)されてまとめてUserDao.findAllByIdsでのアクセスとなり、N+1 問題が回避出来る。

    from: https://qiita.com/petitviolet/items/5b1cbc2116c537953eab