👅

はじめてGraphQLでアプリケーションつくってみた

2024/07/13に公開

はじめに

簡単に今回作ったアプリをご紹介させていただくと自己紹介をより楽しく行えることを目標としたパーティーゲームになっており、匿名の自己紹介文が誰ものであるかを議論して楽しみます!
どういったアプリケーションか、イメージがわかない方は章末のQRコードからアプリを体験してみてください!
完成したアプリケーションがどのようにGraphQLの操作を実装してるかコードや具体的なアプリケーションの処理を見て、説明していきたいと思います!

Whoamiについて

Whoamiは自己紹介をより楽しく行えることを目標としたパーティーゲームアプリになります。
本アプリにはゲームを進行するホストユーザとその他のゲストユーザーが存在します。以下に簡単なアプリケーションの流れを画像を交えて説明します。
【ユーザ・ルーム作成画面】

招待リンクをコピペで共有するかQRコードからゲストユーザーを追加できます。
【ロビー画面】

各ユーザーは自身のことを入力し、送信します。
【質問フェーズ画面】

先ほど答えたものがランダムに表示されるので、誰のことを示しているか議論して遊びます。
【ゲームフェーズ画面】

すべての回答が表示された後に答え合わせできます!
【回答フェーズ画面】

技術スタック

本アプリはフロントエンドをNext.jsで、バックエンドのAPIにはgo(gin)を使って作成しました。
フロントエンドとバックエンドはGraphQLを使ってやり取りしています。
DBにはmongoDBとredisを使用しております。mongoDBでゲーム情報、ユーザ情報を管理し、リアルタイム処理のためにredisを使用しております。
構成は以下の通りです。

GraphQLについて

GraphQLの詳細な説明は本書では行いません。
フロントとバックエンドで同じスキーマを共有することで、サーバクライアント間での通信を同一のデータ型を使用することができます。

本書ではGraphQLの3つの主要な操作であるQuery、Mutation、Subscriptionについて、作成したアプリケーションのどの部分で使われているかを説明します。

フロントエンドアプリケーションについて

フロントエンド側のアプリケーションでGraphQLのQuery、Mutationの処理が行われているか
具体的な実装を基に説明します。
フロントエンドアプリケーションは、Apollo Clientのライブラリを使用して実装しました。
以下のコマンドで必要なGraphQL関連(Apollo Client, GraphQL Code Generator)のパッケージをインストールします。

$ yarn add graphql @apollo/client
$ yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request 
@graphql-codegen/typescript-operations @graphql-codegen/typescript-resolvers

GraphQLのコード自動生成

GraphQL Code Generatorを使用し、スキーマ定義とクエリドキュメント、設定ファイルからGraphQLの操作を行うtypescriptのコードを自動生成します。

  • スキーマ定義
    schema.graphqlにGraphQLの通信で使うデータの定義を記述します。
scalar Time

# type モデルの定義
type User {
  id: ID!
  name: String!
  answer: String!
  createdAt: Time!
}

type Room {
id: ID!
host: User!
players: [User!]!
viewed: [String!]!
createdAt: Time!
}

enum Event {
  START
  QUIZ
  NEXT
  FINISH
}

type CreateRoomResponse {
  id: ID!
  token: String!
}

# query データ取得
type Query {
  room(id: ID!): Room! @isAuthenticated
  loginedUser: User! @isAuthenticated
  question(id: ID!): User!
  result(id: ID!): [User!]!
}

# input データ入力
# 新しいルーム作成
input NewRoom {
  host_name: String!
}

# 新しいゲストユーザー作成
input NewGuest {
  name: String!
  room_id: String!
}

input Answer {
  user_id: ID!
  answer: String!
}

# mutation データ更新
type Mutation {
  createRoom(input: NewRoom!): CreateRoomResponse!
  createGuest(input: NewGuest!): String!
  publishEvent(room_id: ID!, event: Event!, user_id: ID!): Event!
  answer(input: Answer!): String!
}

# subscription データ更新の監視
type Subscription {
  changeRoom(room_id: ID!): Room!
  subscribeEvent(room_id: ID!): Event!
}
  • クエリドキュメント
     クエリドキュメントにはQuery、Mutation、Subscriptionについてどのようなデータを渡すか、返却されるかをクエリ言語で記述します。以下に例を示します。

Queryのクエリドキュメントの例
ログインユーザを取得するQuery

query getLoginedUser {
  loginedUser {
    id
    name
    answer
  }
}

Mutationのクエリドキュメントの例
回答を登録する処理のMutation

mutation Answer($user_id: ID! , $answer: String!) {
  answer(input:{
        user_id: $user_id
        answer: $answer})
}

Subscriptionのクエリドキュメントの例
ゲームの進行状況をリアルタイム通信するSubscription

subscription SubscribeEvent($room_id: ID!){
  subscribeEvent(room_id: $room_id)
}
  • 設定ファイル(codegen-server.yaml)
    設定ファイルにスキーマ定義、クエリドキュメント、自動生成されるコードの配置先を指定します。
schema: ./graphql/schema.graphql
documents: ./graphql/*/*.graphql
generates:
  ./graphql/lib/client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-graphql-request
  • schemaにはスキーマ定義であるschema.graphqlのパスを指定します。
  • documentsにはすべてのクエリドキュメントを指定します。
  • generatesには自動生成されるコードのファイル名、配置先、生成オプションを指定します。

以下のコマンドを実行することでコード自動生成の自動生成を行うことができます。

$ yarn run graphql-codegen --config ./graphql/codegen-server.yaml

GraphQLによる通信(クライアント側)

クライアント側では、ApolloClientを定義し、useQuery, useMutationと呼ばれるReact Hooksを使用してGraphQLクエリを実行し、データの取得、更新、登録、削除を行います。
https://www.apollographql.com/docs/react/

ApolloClient

Apllo Clientではリンクと呼ばれるGraphQLの通信を行うための情報を定義します。

  • APIクライアント
    APIのエンドポイントやAPIクライアントの設定を行い、Apolloプロバイダーに渡すApollo Clientのインスタンスを作成します。

また今回は認証のためのリンクとSubscriptionを行うためにwebsocket通信するためのリンクを定義します。

import { split, ApolloClient, InMemoryCache, createHttpLink, from, ServerParseError, ServerError } from "@apollo/client";
import { deleteCookie, getCookie } from "cookies-next";
import { getMainDefinition } from '@apollo/client/utilities';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { onError } from '@apollo/client/link/error';
import { createClient } from 'graphql-ws';

// HTTPリクエスト行うためのリンク作成
const httpLink = createHttpLink({
    uri: process.env.NEXT_PUBLIC_API_URL ,
    credentials: "include",
});

// 通信時エラーためのリンク作成
const errorLink = onError(({ graphQLErrors, networkError }) => {

	if (networkError?.name === 'ServerParseError' || networkError?.name === 'ServerError'){
    let parseErrorStatusCode = (networkError as ServerParseError).statusCode
    let serverErrorStatusCode = (networkError as ServerError).statusCode
    if (parseErrorStatusCode === 401 || serverErrorStatusCode === 401) {  
      deleteCookie('token');
      window.location.href = '/';
    }
  }
});

// Websocket通信を行うためのリンク作成
const wsLink = new GraphQLWsLink(createClient({
  url: process.env.NEXT_PUBLIC_WS_URL ?? (() => ''),
  connectionParams:{
    reconnect: true,
    authorization : getCookie('token') ? `Bearer ${getCookie('token')}` : "",
  },
}));

// GraphQL通信の認証を行うためのリンク作成
const authLink = setContext((_, { headers }) => {
    return {
      headers: {
        ...headers,
        authorization : getCookie('token') ? `Bearer ${getCookie('token')}` : "",
      }
    }
});

// 各リンクを統合
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  authLink.concat(httpLink),
);

// Apollo Clientインスタンス作成
export const client = new ApolloClient({
    link: from([errorLink, splitLink]),
    cache: new InMemoryCache(),
});
  • Apolloプロバイダー
    上記で定義したAPIクライアントをアプリケーション全体で使えるようにし、コンポーネントでGraphQLクエリを実行するための機能を提供します。
    ApolloProviderコンポーネントで包まれたツリーの中のコンポーネントは、ApolloClientにアクセスし、GraphQLの操作を行うことができます。
//例
import { ApolloProvider } from "@apollo/client"
import { client } from "../apollo/client";

<ApolloProvider client={client}>
// 以下のコンポーネントはApolloClientにアクセスできる
<ComponetsA/>
</ApolloProvider>

// 以下のコンポーネントはApolloClientにアクセスできない
<ComponetsB/>

以下にどのようにuseQuery, useMutationの使用されているかを実際の機能を基に説明します。

クエリドキュメントの作成

自動生成するために必要なクエリドキュメントを作成します。
クエリドキュメントはQuery、Mutation、Subscriptionの処理を行うときにどういった返却値が欲しいのかを定義します。

  • /graphql/query/user.graphql
// ログインユーザの取得
query getLoginedUser {
  loginedUser {
    id
    name
    answer
  }
}
  • /graphql/mutation/user.graphql
// 回答を登録する
mutation Answer($user_id: ID! , $answer: String!) {
  answer(input:{
        user_id: $user_id
        answer: $answer})
}

ログインユーザの取得(Query)

以下がQuery操作により、サーバサイドからログインユーザを取得する処理のコードの例になります。

// QueryのHooks
import { useQuery } from "@apollo/client";
// 自動生成したクリエドキュメントのオブジェクト
import { GetLoginedUserDocument, GetLoginedUserQuery } from "@/graphql/lib/client";
 ~省略~
// 取得処理
const { data: user } = useQuery<GetLoginedUserQuery>(GetLoginedUserDocument);

useQueryを使用し、先ほど自動生成したクリエドキュメントのオブジェクトをパラメータに渡すことでデータを取得することができます。

回答を登録する処理(Mutation)

次はMutationの例を示します。
ユーザーが回答をサーバ側へ送信する処理のコード例です。

// MutationのHooks
import { useMutation } from "@apollo/client";
import { AnswerDocument, AnswerMutation } from "@/graphql/lib/client";
 ~省略~
// データ送信する関数取得
const [ postAnswer ] = useMutation<AnswerMutation>(AnswerDocument);

// 回答を送信する
const handlePostAnswer = async () => {
    const { data } = await postAnswer({ variables: { user_id: user?.loginedUser.id, answer: answer } });
};

まず、useMutationを使用し、Mutationを実行するための関数postAnswerを作成します。
ボタンが押されるなどユーザーからのアクションがあった場合のトリガーを定義し、そのトリガー内でpostAnswerを呼び出し、データを送信しております。

バックエンドアプリケーションについて

バックエンド側のアプリケーションでGraphQLのQuery、Mutationの処理が行われているか
具体的な実装を基に説明します。
実際のバックエンドの処理には、DBへの処理がありますが今回はページの都合上、説明を省略致します。

バックエンドはGoのgqlgenというライブラリを使用してGraphQLサーバを起動、処理するように実装します。
https://gqlgen.com/

GraphQLの関連コード自動生成

# install
$ go get github.com/99designs/gqlgen

$ go run github.com/99designs/gqlgen init

上記のコマンド実行することで、server.go、gqlgen.yml、schema.graphqls、generated.go、models_gen.go、schema.resolver.go、resolver.goが生成されます。

schema.graphqlsをクライアント側のschema.graphqlと定義が同じになるように修正します。
以下のコマンドを実行するとgenerated.go、models_gen.go、schema.resolver.go、resolver.goがスキーマ定義に合わせてコードが更新されます。スキーマ定義を変更した場合はコードに反映させるために以下のコマンドを毎回実行する必要があります。

$ go run github.com/99designs/gqlgen generate

自動生成されるファイルの内容は以下の通りです。
主に生成されたschema.resolvers.goに独自のロジックを実装していきます。

  • gqlgen.yml: gqlgenの設定ファイルです。スキーマの場所、生成されるGoファイルの場所など

  • server.go: GraphQLサーバーのエントリポイントとなるGoファイルです。

  • graph/schema.resolvers.go: スキーマで定義された各フィールドの実際のデータ取得や操作を行う関数(リゾルバ)を定義します。コード生成後、ユーザーが実装を追加する必要があります。

  • graph/schema.graphqls: GraphQLスキーマ定義ファイルです。データ構造、クエリ、ミューテーション、サブスクリプションなど、APIのスキーマを定義します。gqlgenはこのファイルを基にGoのコードを生成します。

  • graph/generated/generated.go: gqlgenによって自動生成されるGoファイルです。このファイルには、スキーマから生成された型定義やリゾルバインタフェースが含まれています。このファイルを直接編集することはないです。

  • graph/model/models_gen.go : スキーマで定義された型に対応するGoの型定義が含まれるファイルです。gqlgenはスキーマの型を基にして、このファイル内にGoの型を自動生成します。

GraphQLによる通信(サーバ側)

自動生成したコードを使用し、schema.resolvers.goにロジックを実装します。

GraphQLのエンドポイントの実装

自動生成したserver.goを修正して、エンドポイントを実装します。
今回はginというライブラリを使用し、APIを実装します。

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	"github.com/takeru-a/whoami_backend_mongo/graph"
	"github.com/takeru-a/whoami_backend_mongo/graph/subscriber"
	"github.com/takeru-a/whoami_backend_mongo/lib"
	"github.com/takeru-a/whoami_backend_mongo/middleware"
)

func main() {

	gin.SetMode(gin.ReleaseMode)

	r := gin.Default()
	// CORS設定
	r.Use(cors.New(cors.Config{
		AllowOrigins:     []string{os.Getenv("FRONTEND_URL")},
		AllowMethods:     []string{"POST", "GET"},
		AllowHeaders:     []string{"Origin", "Content-Type", "Accept", "Authorization", "Upgrade", "Connection", "Sec-Websocket-Version", "Sec-Websocket-Key", "Sec-Websocket-Protocol"},
		AllowCredentials: true,
		MaxAge: 24 * time.Hour,
	  }),
	  middleware.Middleware(),
	)

	// redisクライアントを作成する
	redisClient := lib.NewRedisClient()
	defer redisClient.Close()
	if err := redisClient.Ping(context.Background()); err != nil {
		log.Fatal(err)
	} 

	// DBを作成する
	db := lib.NewDB()
	defer db.Close()

    // GraphQLのハンドラー作成
	graphqlHandler := handler.New(graph.NewExecutableSchema(graph.Config{
		Resolvers: &graph.Resolver{
			DB: db,
			Subscriber: subscriber.NewRoomSubscriber(context.Background(), redisClient),
		},
		Directives: graph.Directive,
	}))

	// トランスポートを有効にする
	graphqlHandler.AddTransport(transport.GET{})
	graphqlHandler.AddTransport(&transport.POST{})
	graphqlHandler.AddTransport(&transport.Options{})
	graphqlHandler.AddTransport(&transport.MultipartForm{})
	// websocketを有効にする
	graphqlHandler.AddTransport(&transport.Websocket{
		KeepAlivePingInterval: 10 * time.Second,
		PingPongInterval: 	10 * time.Second,
		Upgrader: websocket.Upgrader{
			CheckOrigin: func(r *http.Request) bool {
				return true
			},
			ReadBufferSize:  1024,
            WriteBufferSize: 1024,
	},
})
	
	// /queryにアクセスしたらGraphQLのハンドラーに処理を渡す
	r.POST("/query", func(c *gin.Context) {
		graphqlHandler.ServeHTTP(c.Writer, c.Request)
	})

	r.GET("/query", func(c *gin.Context) {
		graphqlHandler.ServeHTTP(c.Writer, c.Request)
	})

	log.Printf("connect to server")
	r.Run(":"+ os.Getenv("SERVER_PORT"))
}

今回のアプリケーションはクライアントとバックエンドが異なるドメインで動くためCORS設定をしてます。また、Subscriptionを行うためにWebsocket通信を有効にする必要があるためその設定を行っています。
その他、DBへの接続、APIルーティング設定、サーバーの起動を行っています。

データの操作を行うリゾルバの構造体を定義し、その構造体をつかってQuery、Mutation、Subscriptionの処理内容を実装します。

リゾルバ定義

// 
type Resolver struct {
	Subscriber *subscriber.RoomSubscriber
	DB *lib.DB
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }

ログインユーザの取得(Query)

以下がクライアントからQueryの操作が行われ、DBからログインユーザを取得し、クライアントへレスポンスする処理のコードの例になります。
queryResolverの構造体のメソッドを実装します。

// LoginedUser is the resolver for the loginedUser field.
func (r *queryResolver) LoginedUser(ctx context.Context) (*model.User, error) {
	// ユーザーIDを取得する
	id := middleware.ForContext(ctx)
	user, err := r.DB.GetUser(id)
	return user, err
}

ミドルウェアからユーザーIDを取得し、それをキーにDBからログインユーザーの情報を取得してログインユーザ情報を返却しています。

ミドルウェアは以下のように実装しています。
リクエストのヘッダーからJWTTokenを取り出し、検証を行い問題なければTokenからユーザーIDを
コンテキストに保存します。
(JWTTokenはユーザー作成時に付与し、クライアント側のCookieで保存しており、リクエスト時にヘッダーに付与しています。)

package middleware

import (
	"context"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/takeru-a/whoami_backend_mongo/lib"
)

var userCtxKey = &contextKey{"user"}

type contextKey struct {
	user_id string
}

func Middleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		
		// リクエストヘッダーからAuthorizationを取得
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.Next()
			return
		}
		// AuthorizationヘッダーからBearerを取り除く
		tokenString := authHeader[len("Bearer "):]
		// JWTを検証する
		userId, err := lib.VerifyJWT(tokenString)
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{
				"message": "Invalid token",
			})
			c.Abort()
			return
		}
	
		// ユーザーIDをコンテキストに保存する
		userCtx := context.WithValue(c.Request.Context(), userCtxKey, userId)
		c.Request = c.Request.WithContext(userCtx)
		c.Next()
	}
}

// コンテキストからユーザーIDを取得する
func ForContext(ctx context.Context) string {
	userId, _ := ctx.Value(userCtxKey).(string)
	return userId
}

回答を登録する処理(Mutation)

以下がクライアントからの回答が送信され、DBに回答を登録する処理するコードの例になります。
パラメータに渡しているユーザーIDをキーに回答を登録します。

// Answer is the resolver for the answer field.
func (r *mutationResolver) Answer(ctx context.Context, input model.Answer) (string, error) {
	// ユーザーの回答を更新する
	err := r.DB.UpdateUserAnswer(input.UserID, input.Answer)
	if err != nil {
		return "", err
	}

	return "success", nil
}

以上のようにスキーマさえ定義してしまえば、必要なコードを自動生成できるので、あとはエンドポイントとリゾルバーを定義するだけで、GraphQLのAPIを実装することができます!

Redis & Subscriptionによるリアルタイム通信

リアルタイム通信は、ユーザー追加によるロビー画面へリアルタイムで反映するところと、同じルームのユーザが同じタイミングの画面へ遷移するための画面制御に使っています。
今回は同ルームのユーザのゲームの進捗状況をリアルタイムに反映するためにSubscriptionを使用し、画面制御を行っている処理について説明します。
*本アプリケーションではredisを用いて実装しておりますが、Redisを使用しなくてもSubscriptionによるリアルタイム通信は可能です。

画面制御

本アプリケーションでは、ホストユーザだけが画面遷移するボタンをクリックできます。
ゲストユーザーはホストユーザがボタンをクリックすると、それに連動して画面遷移します。
ホストユーザがボタンをクリックする(イベント通知)とそれを監視していたゲストユーザーに通知が行き、それに応じてアクション(画面遷移等)を行う仕組みになっています。
今回はユーザーがロビー画面から、ホストユーザがスタートボタンをクリックし、同じルームのすべてのユーザがゲーム画面から質問フェーズ画面へ画面遷移する処理をフォーカスし、説明します。(他の画面遷移の処理もほとんど一緒です。)

イベント通知(Mutation)&イベント監視(Subscription)

クエリドキュメントの作成

今回も自動生成するために必要なクエリドキュメントを作成します。

  • /graphql/mutation/event.graphql
// イベント通知
mutation PublishEvent($room_id: ID!, $event: Event!, $user_id: ID!){
  publishEvent(
    room_id: $room_id,
    event: $event,
    user_id: $user_id
  )
}
  • /graphql/subscription/user.graphql
// イベント監視
subscription SubscribeEvent($room_id: ID!){
  subscribeEvent(room_id: $room_id)
}

クライアント側

まずはホストユーザがイベント通知を行う処理の実装部分について説明します。
回答を登録する処理と同じくuseMutationを使用し、実装します。

import { useMutation } from "@apollo/client";
import { PublishEventDocument, PublishEventMutation } from "@/graphql/lib/client";

// データ送信する関数取得
const [ pubEvent ] = useMutation<PublishEventMutation>(PublishEventDocument);

// 他のユーザーにゲーム開始のイベントを通知する
const handlePubEvent = async () => {
        setIsLoading(true);
        const { data } = await pubEvent({ variables: { room_id: room_id, event: "START", user_id: "" } });
    };

...

pubEventというスキーマで定義した関数を使用し、パラメータに渡しているRoomIDのルームに存在するユーザーにゲーム開始の合図を送信しています。

次に上記で送信しているイベントを監視する処理について説明します。
ユーザーがロビー画面に遷移したときに、GraphQLのSubscriptionを使用し、RoomIDにユーザーを紐づけて監視します。
この状態でRoomID宛てにイベント送信されるとそのRoomIDに紐づいているすべてのユーザーへイベントが一斉送信され、各ユーザーはそのイベントに応じて画面遷移します。

以下がユーザーとRoomIDを紐づけて監視するようにし、イベントが通知されると画面遷移する実装になります。新たにuseSubscriptionというHooksを使用し、Subscriptionの操作を行います。

import { useSubscription } from "@apollo/client";
import { SubscribeEventDocument, SubscribeEventSubscription } from "@/graphql/lib/client";

// ユーザーとRoomIDを紐づけて監視する
const { data: subscribeEvent } = useSubscription<SubscribeEventSubscription>(SubscribeEventDocument, { variables: { room_id: room_id } });

// STARTというイベントが通知されたら、質問フェーズ画面へ遷移する
useEffect(() => {
    // ページ遷移する
    if(subscribeEvent?.subscribeEvent === "START"){
        router.push(`/game/question/${room_id}`);
    }
}, [subscribeEvent]);

バックエンド側

バックエンドの方もQuery、Mutationの処理を実装したときと同じように、schema.resolvers.goに実装していきますが、まず初めにSubscriptionの処理を共通化させるためのSubscriberという構造体を作成します。以下がコードになります。(Redis部分は説明省略します。)

package subscriber

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/go-redis/redis/v8"
	"github.com/takeru-a/whoami_backend_mongo/graph/model"
	"github.com/takeru-a/whoami_backend_mongo/lib"
)

type RoomSubscriber struct {
	redisClient *lib.RedisClient
}

func NewRoomSubscriber(ctx context.Context, redisClient *lib.RedisClient) *RoomSubscriber {
	subscriber :=  &RoomSubscriber{
		redisClient: redisClient,
	}
	return subscriber
}

// ユーザーとRoomIDを紐づけて監視する
func (r *RoomSubscriber) GameStart(ctx context.Context, roomId string, ch chan<- model.Event) {
	
	// イベント受信を監視するためのgoroutineを起動する
	go func() {
		// RoomIDをもとにRedisのSubscribeを開始する
		pubsub := r.redisClient.Subscribe( ctx, "game:"+ roomId )
		defer pubsub.Close()

		// イベントを受信する
		event := pubsub.Channel()

		for e := range event {

			payload := e.Payload
			var event model.Event
			// メッセージのペイロード(JSON)を model.Event 型に変換する
			if err := json.Unmarshal([]byte(payload), &event); err != nil {			
				fmt.Println("Error unmarshaling message:", err)
				continue
			}
			
			select {
			case ch <- event:
			default:
				fmt.Println("failed to send geme-room channel")
				return
			}
		}
	}()
}

// イベント一斉送信
func (r *RoomSubscriber) GamePublish(ctx context.Context, roomId string, event model.Event) error {

	// イベント情報をJSONに変換する
		eventJson, err := json.Marshal(event)
		if err != nil {
			return fmt.Errorf("failed to marshal event: %w", err)
		}
	// RoomIDをもとにRedisのPublishを実行する
	if err := r.redisClient.Publish(ctx, "game:"+ roomId, eventJson); err != nil {
		return fmt.Errorf("failed to publish game-room: %w", err)
	}
	fmt.Println("send geme-room channel")
	return nil
}

上記で定義したSubscriberを使ってイベント送信時とイベント監視の処理を実装します。

  • イベントを受信したときの処理
// PublishEvent is the resolver for the publishEvent field.
func (r *mutationResolver) PublishEvent(ctx context.Context, roomID string, event model.Event, userID string) (model.Event, error) {
	var err error
	switch event {
	case "START":
		// ゲーム開始イベントの場合
		r.Subscriber.GamePublish(ctx, roomID, event)
	default:
		// それ以外の場合
		err = r.DB.UpdateByEvent(roomID, event, userID)
		r.Subscriber.GamePublish(ctx, roomID, event)
	}

	return event, err
}

GameStartではRedisを用いて、RoomIDをもとにSubscribe(購買、監視)を開始するさせています。
PublishEventではホストユーザがゲーム開始イベントを送信してきた際に、RoomIDに紐づいたユーザーへイベント通知を一斉送信しています。

  • ユーザーをサブスクライブ(購買,監視)する処理
    goroutineを起動し、イベント通知が来るかどうか待ち続け、通知が来た場合はクライアントへイベント通知がされたことを伝えます。
// SubscribeEvent is the resolver for the subscribeEvent field.
func (r *subscriptionResolver) SubscribeEvent(ctx context.Context, roomID string) (<-chan model.Event, error) {
	// チャネルを作成する
	ch := make(chan model.Event)

	// subscribeする
	go func() {
		r.Subscriber.GameStart(ctx, roomID, ch)
	}()

	eventCh := make(chan model.Event)
	// チャネルを受信する
	go func() {
		for e := range ch {
			select {
			case eventCh <- e:
				fmt.Println("send event channel")
			case <-ctx.Done():
				fmt.Println("context done")
				return
			default:
				fmt.Println("failed to send event channel")
				return
			}
		}
	}()

	return eventCh, nil
}

以上の実装で、Subscriptionを用いてリアルタイム処理を行うことができます!

最後に

このアプリケーションのインフラ構築をまとめた記事をZennに書いています。
https://zenn.dev/goal_a/articles/76dc0404909378

また、他の記事や本内容の追加記事など投稿するかもしれないのでチェックお願いします!
https://zenn.dev/goal_a

作成したアプリも複数人で遊んでみてください!

Discussion