【GraphQL】ApolloServer v4 × PrismaでSubscription実装時に関連するデータを取得
はじめに
今回はGraphQL
とApollo Server v4
によるDBでリレーションが組んであるデータをリアルタイムで取得する方法(Subscription)を実装していきます。
前提
Subscriptionについてわからない人は、公式や以下の記事を参考にしてください。
実装
今回実装する機能としては、投稿に対して、投票を行う機能を実装していきます。(いいね機能と概念は同じです)
スキーマ定義
注意点として、今回はコード全体ではなく、関連する箇所をピックアップするような形で記載していきます。
:::note info
Prismaのスキーマ
:::
model Link {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
description String
url String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
votes Vote[]
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
links Link[]
votes Vote[]
}
model Vote {
id Int @id @default(autoincrement())
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
linkId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
@@unique([linkId, userId])
}
上記がDBのスキーマ(テーブル)定義になります。
投票はVote
モデルで管理します。このモデルは親にUser
とLink
のモデルを持つので、そのようにリレーションを組んでいます。
:::note info
GraphQLスキーマ
:::
type Mutation {
vote(linkId: ID!): Vote
}
type Subscription {
newVote: NewVote
}
type Link {
id: ID!
description: String!
url: String!
user: User!
votes: [Vote!]!
}
type User {
id: ID!
name: String!
email: String!
links: [Link!]!
}
type Vote {
id: ID!
link: Link!
user: User!
}
type NewVote {
id: ID
link: Link
user: User
}
GraphQLのスキーマでも同様に、テーブルの関連付けをするようにしています。
やりたいこととしては以下になります。
- 投票をするとVoteテーブルにデータが格納される
- 投票したらリアルタイムで投票時のデータを取得する
そのため、Mutationには投票のMutationを指定し、Subscriptionには投票時に取得するデータを指定しています。
ここで、「SubscriptionのnewVoteにはVote型を指定したらいいのでは?」と思われた方に向けて解説すると、Subscriptionの型をVote
にすると、Voteのidしか取得できず、関連するUserやLinkを取得しようとすると、nullが返却され、エラーとなります。
(実際に動作確認してみました)
そのため少し面倒ですが、NewVoteで型付しています。
意外とこの型付は忘れがちなので、要注意です。
カスタムミューテーション
ここでは、リレーションを組んだデータを取得するための処理を実装します。
例えば、linkに紐づくuserを取得するなどの処理です。
:::note info
Userミューテーション
:::
import type { Context } from '@/types/Context'
export const links = (parent: { id: number }, __: unknown, context: Context) =>
context.prisma.user
.findUnique({
where: { id: parent.id },
})
.links()
Userに紐づくlinkを配列で取得します。
parent
は親という意味なので、親テーブルのidつまりLinksテーブルの親テーブルのidということになるので、Userテーブルのidで検索するということになります。(ややこしいですが、そういうことです)
スキーマ定義でいうと、Userのlinks
に当たります。
type User {
id: ID!
name: String!
email: String!
links: [Link!]!
}
:::note info
Linkミューテーション
:::
import type { Context } from '@/types/Context'
export const user = (parent: { id: number }, __: unknown, context: Context) => {
return context.prisma.link
.findUnique({
where: { id: parent.id },
})
.user()
}
export const votes = (parent: { id: number }, __: unknown, context: Context) =>
context.prisma.link
.findUnique({
where: { id: parent.id },
})
.votes()
Linksでも同じように、関連するデータを取得できるようにしています。
スキーマ定義でいうと、Linkのuser
とvotes
がこれに当たります。
type Link {
id: ID!
description: String!
url: String!
user: User!
votes: [Vote!]!
}
:::note info
Voteミューテーション
:::
import type { Context } from '@/types/Context'
export const link = (parent: { id: number }, __: unknown, context: Context) =>
context.prisma.vote.findUnique({ where: { id: parent.id } }).link()
export const voteUser = (
parent: { id: number },
__: unknown,
context: Context
) => context.prisma.vote.findUnique({ where: { id: parent.id } }).user()
Voteの同様です。1:Nの関連付けがある子テーブルなので、各親テーブルのidでデータを取得するようにしています。
スキーマ定義でいうとVoteのlink
とuser
がこれに当たります。
type Vote {
id: ID!
link: Link!
user: User!
}
投票機能の実装
以下のミューテーションとサブスクリプションの処理詳細を実装していきます。
type Mutation {
vote(linkId: ID!): Vote
}
type Subscription {
newVote: NewVote
}
:::note info
処理詳細
:::
export const vote = async (
_: unknown,
args: { linkId: string },
context: Context
) => {
const userId = context.userId as number
const vote = await context.prisma.vote.findUnique({
where: {
linkId_userId: {
linkId: Number(args.linkId),
userId: userId,
},
},
})
if (vote) {
throw new Error(`Link is already voted: ${args.linkId}`)
}
const newVote = await context.prisma.vote.create({
data: {
user: { connect: { id: userId } },
link: { connect: { id: Number(args.linkId) } },
},
include: {
link: true,
user: true,
},
})
context.pubsub.publish('NEW_VOTE', newVote)
return newVote
}
やっていることとしては以下になります。
- contextよりuserIdを取得
- すでに投票されているかどうかを判定
- 投票する(Voteテーブルにデータを格納)
- サブスクリプションを送信(publish)する
重要なのは、newVoteのinclude
の部分で、これをtrue
にすると、登録に関連するlink
とuser
を取得できます。
また、publish
ですが、第一引数にトリガー名をとり、第二引数に渡したい値をとります。
つまり、サブスクリプションの受信側で同じトリガー名を指定すれば、newVote
を受け取り、newVote
をリアルタイムで取得できるというわけです。
サブスクリプションの実装
以下のようにして実装します。(ここはコード全体をお見せします)
これは公式に沿った実装になっております。
import 'dotenv/config'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'
import { loadSchemaSync } from '@graphql-tools/load'
import { addResolversToSchema } from '@graphql-tools/schema'
import { PrismaClient } from '@prisma/client'
import bodyParser from 'body-parser'
import cors from 'cors'
import express from 'express'
import { PubSub } from 'graphql-subscriptions'
import { useServer } from 'graphql-ws/lib/use/ws'
import { createServer } from 'http'
import { join } from 'path'
import { WebSocketServer } from 'ws'
import { user, votes } from './resolvers/Link'
import { vote } from './resolvers/Mutation'
import { links } from './resolvers/User'
import { link, voteUser } from './resolvers/Vote'
import type { Context } from './types/Context'
import type { NewVote } from './types/NewVote'
import { getUserId } from './utils'
const PORT = 4000
const pubsub = new PubSub()
const prisma = new PrismaClient()
const app = express()
const schema = loadSchemaSync(join(__dirname, './schema.graphql'), {
loaders: [new GraphQLFileLoader()],
})
// リゾルバー関数
const resolvers = {
Mutation: {
vote: vote,
},
Subscription: {
newVote: {
subscribe: () => pubsub.asyncIterator(['NEW_VOTE']),
resolve: (payload: NewVote) => payload,
},
},
Link: {
user: user,
votes: votes,
},
User: {
links: links,
},
Vote: {
link: link,
user: voteUser,
},
}
const schemaWithResolvers = addResolversToSchema({ schema, resolvers })
const httpServer = createServer(app)
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
})
const serverCleanup = useServer({ schema: schemaWithResolvers }, wsServer)
const server = new ApolloServer<Context>({
schema: schemaWithResolvers,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose()
},
}
},
},
],
})
;(async () => {
try {
await server.start()
app.use(
'/graphql',
cors<cors.CorsRequest>(),
bodyParser.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
...req,
prisma,
pubsub,
userId: req && req.headers.authorization ? getUserId(req) : undefined,
}),
})
)
httpServer.listen(PORT, () => {
console.log(`🚀 Query endpoint ready at http://localhost:${PORT}/graphql`)
console.log(
`🚀 Subscription endpoint ready at ws://localhost:${PORT}/graphql`
)
})
} catch (error) {
console.error('Error starting server: ', error)
}
})()
:::note info
型定義
:::
関連する型定義は以下になります。
import type { Prisma, PrismaClient } from '@prisma/client'
import type { DefaultArgs } from '@prisma/client/runtime'
import type { PubSub } from 'graphql-subscriptions'
export type Context = {
prisma: PrismaClient<
Prisma.PrismaClientOptions,
never,
Prisma.RejectOnNotFound | Prisma.RejectPerOperation | undefined,
DefaultArgs
>
pubsub: PubSub
userId?: string | number
}
import type { Link, User } from '@prisma/client'
export type NewVote = {
id: number
link: Link
user: User
}
:::note info
サブスクリプションの実装部分について解説
:::
以下の部分について解説します。
const pubsub = new PubSub()
Subscription: {
newVote: {
subscribe: () => pubsub.asyncIterator(['NEW_VOTE']),
resolve: (payload: NewVote) => payload,
},
}
ここでは、受信側の定義を行います。
PubSub
インスタンスのasyncIterator
に送信側のトリガー名を指定してあげると、サブスクリプションを受信します。
subscribe
の定義だけでもVoteのデータのみは取得できます。
どういうことかというと、UserやLinkのような関連付けされたデータは取得されず、Voteのidのみ取得できるといことです。
関連付けされたデータまで取得する場合は、resolve
としてpyaload
を返却する必要があります。
ここでNewVote
型とすることで、関連付けされたデータも取得できるようになります。
以上で、実装は完了です。
おわりに
改めて、今回の実装を振り返ると、GraphQLのスキーマでも型の定義って重要なんだな〜と思います。
しっかり機械が認識するようにしてあげる、いわば機械側の立場になったつもりでプログラミングすることが重要だと感じました。
どなたかの参考になれば幸いです。
Discussion