Posted at

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

More than 1 year has passed since last update.


この記事はなに

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問題が回避出来る。