GoでGraphQLのSubscriptionのシンプルな
実装例を紹介します。
TL;DR
この記事では、GoでGraphQLのSubscriptionsを実装する一番シンプルなサンプルを提供します。
そのため、コードのモジュール化やサーバの複数台構成といった非機能要件など、GraphQLに関係ない仕様は考慮しません。
手順は次のようになります。
- 必要に応じて、gqlgenでプロジェクトを初期化
- GraphQLのスキーマを作成
gqlgen generate
により、ひな形を自動生成./graph/resolver.go
で、必要なプロパティの定義やDIを行う./server.go
に初期化したResolverオブジェクトを渡す処理を行う./schema.resolvers.go
に各Query、Mutation、Subscriptionごとのビジネスロジックを記述
コードはGitHubに掲載しています。
はじめに(おことわり)
この記事では、一番簡単なシンプルな
実装例(チャットサービス)を紹介します。
というのも、GoでGraphQLのSubscriptionの実装方法を調べると海外サイトも含め「結構複雑な例が多い」という印象を受けたから。
例えば、
- モジュール化しすぎてSubscriptionsの記述がどこなのか探しにくい
- Redisを使うなど非機能要件に関するコード量が多い
という感じで、とにかく試してみたい場合も様々な予備知識が必要になります。
そこで、この記事では以下に気をつけた実装を紹介します。
- モジュール化しない
gqlgen
で初期化した構造をそのまま利用- サーバの複数台構成などの非機能要件は無視
そういう意味で「シンプルな」という言葉を使っているということですね。
また、今回はGraphQLの実装にgqlgen
を使います。
gqlgen
のリポジトリにはSubscriptionsの実装例があります。
「公式のサンプルを読み解けるよ」という方はそちらを参照してください。
GraphQLのSubscriptionsおさらい
GraphQLのSubscriptionsとは、サーバからリアルタイムにデータを取得できる仕組みです。
新聞の「購読」と同じように、購読者として登録して、あとは新聞(データ)が来るのを待っていればよいということです。
その手順をざっとおさらいしてみます。
まず、クライアントは特定のイベントを「購読(subscribe)」します。
subscribeすると、各クライアントはlistening状態となります。
クライアントとサーバはwebsocketでコネクションを維持します。
そして、サーバは何かイベントが発生したとき、subscribeしたクライアントにデータをpublishします。
なお、クライアントはデータを受け取った後は再びlistening状態に戻ります。
そのため、データが発行されたタイミングでリアルタイムに処理したり表示したりできます。
これはデータの「ストリーム」を監視しているようなイメージです。
例:チャットアプリ
今回は「チャットアプリ」を実装します。
よく例にでてきますが、一番分かりやすいのでここでも取り上げます。
以下のように、クライアントのいずれかが投稿したメッセージを他のクライアントにも配信するというアプリです。
なお、クライアントはブラウザで動作するGraphQL playgroundを使います。gqlgen
による初期化で追加されますので、それをそのまま使います。
※特にインストールする必要なく、サーバを起動するとGraphQL playgroundへのlocalhostのURLが表示されます。それにブラウザからアクセスします。
実装
gqlgenによる初期化
以下のコマンドで初期化します。
名前などは好きなものに置き換えてください。
go mod init github.com/<Your GitHub account>/graphql-subscription-server go get github.com/99designs/gqlgen go run github.com/99designs/gqlgen init
すると、以下のようなディレクトリ構造に初期化されます。
※gqlgen
のバージョンで少し異なるかもしれません。
$ tree . ├── README.md ├── go.mod ├── go.sum ├── gqlgen.yml ├── graph │ ├── generated │ │ └── generated.go │ ├── model │ │ └── models_gen.go │ ├── resolver.go │ ├── schema.graphqls │ └── schema.resolvers.go └── server.go 3 directories, 10 files
スキーマの定義
続いて、GraphQLのschemaを定義しましょう。
./graph/schema.graphqls
を編集します。
scalar Time type Message { id: String! user: String! createdAt: Time! text: String! } type Mutation { postMessage(user: String!, text: String!): Message } type Query { messages: [Message!]! } type Subscription { messagePosted(user: String!): Message! }
postMessage
はメッセージを投稿するMutationです。messagePosted
はリアルタイムに投稿されたメッセージを受信するためのSubscriptionです。messages
は、これまで投稿された全てのメッセージを返すQueryです。
あとは、gqlgen
に必要なコードを自動で生成してもらいます。
./graph/schema.resolvers.go
を削除しておきましょう。(このあと再生成されます。)
そして、以下のコマンドを実行します。
go run github.com/99designs/gqlgen generate
※この後、スキーマを書き変えた場合は、再びgo run github.com/99designs/gqlgen generate
すればschema.resolvers.go
が自動生成される。
resolver.go
続いて./graph/resolver.go
を開きましょう。このファイルを書き換えていきます。
type Resolver struct { subscribers map[string]chan<- *model.Message messages []*model.Message mutex sync.Mutex } func NewResolver() *Resolver { return &Resolver{ subscribers: map[string]chan<- *model.Message{}, mutex: sync.Mutex{}, } }
subscribers
は購読者ごとのchan
を格納するMapです。messages
はこれまで投稿されたメッセージを格納しておくSliceです。mutex
は購読解除や追加などの共有資源をロックするためのmutexです。
ポイントはsubscribers
というMapです。
こちらのKeyはユーザ名、Valueはチャンネルです。
あとあとメッセージを各購読者に配信するときに参照します。
また、今回はResolver
構造体を初期化するための便利なメソッド NewResolver()
も作成しておきましょう。
server.go
ここでは、1行だけの変更です。
ResolverをNewResolver()
で初期化したものを渡すように書き変えます。
あとはそのままでOKです。
func main() { port := os.Getenv("PORT") if port == "" { port = defaultPort } srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: graph.NewResolver()})) // <----- ここだけ修正 http.Handle("/", playground.Handler("GraphQL playground", "/query")) http.Handle("/query", srv) log.Printf("connect to http://localhost:%s/ for GraphQL playground", port) log.Fatal(http.ListenAndServe(":"+port, nil)) }
schema.resolvers.go
続いて、./graph/schema.resolvers.go
です。
このファイルはgqlgen generate
を行うと自動で生成、その後は更新されます。
この中にはスキーマで定義した各種QueryやMutation、Subscriptionのエントリポイントのひな形が追加されていきます。
今回は、このファイルに直接全てのビジネスロジックを書いていきましょう。
全てのファイルは以下にあります。
Query: messages
まずは、messages
というQueryです。
単にResolver
の中に格納していたメッセージの一覧を返すだけです。
func (r *queryResolver) Messages(ctx context.Context) ([]*model.Message, error) { return r.messages, nil }
Subscription: messagePosted
続いてmessagePosted
というSubscriptionです。
こちらでは、「購読を受け付ける」処理を行います。
func (r *subscriptionResolver) MessagePosted(ctx context.Context, user string) (<-chan *model.Message, error) { r.mutex.Lock() defer r.mutex.Unlock() if _, ok := r.subscribers[user]; ok { err := fmt.Errorf("`%s` has already been subscribed.", user) log.Print(err.Error()) return nil, err } // チャンネルを作成し、リストに登録 ch := make(chan *model.Message, 1) r.subscribers[user] = ch log.Printf("`%s` has been subscribed!", user) // コネクションが終了したら、このチャンネルを削除する go func() { <-ctx.Done() r.mutex.Lock() delete(r.subscribers, user) r.mutex.Unlock() log.Printf("`%s` has been unsubscribed.", user) }() return ch, nil }
配信用のチャンネルを新しくmake(chan *model.Message, 1)
で作成し、それをsubscribers
に格納しておきます。
また、クライアントが接続を解除すると<-ctx.Done()
にて通知されます。
これを利用し、subscriberから当該チャンネルを登録解除する処理も書いておきます。
Mutation: postMessage
最後にpostMessage
というMutationを見てみましょう。
新しいメッセージを投稿するときに利用されるものですね。
func (r *mutationResolver) PostMessage(ctx context.Context, user string, text string) (*model.Message, error) { message := &model.Message{ ID: ksuid.New().String(), CreatedAt: time.Now().UTC(), User: user, Text: text, } // 投稿されたメッセージを保存し、subscribeしている全てのコネクションにブロードキャスト r.mutex.Lock() r.messages = append(r.messages, message) for _, ch := range r.subscribers { ch <- message } r.mutex.Unlock() return message, nil }
ここでは新しいメッセージをmodel.Message{}
としてmessages
スライスに追加している処理があります。
そして、注目すべきはこの新しいメッセージをsubscribers
に配信している処理です。
以下のように、保存したチャンネルをイテレートし、それぞれに新しいメッセージを渡していきます。
直感的ですね。
for _, ch := range r.subscribers { ch <- message }
実行
さて、以上で必要なコードは全て書き終えました。
早速実行してみましょう。
$ go run server.go 2021/05/03 18:34:14 connect to http://localhost:8080/ for GraphQL playground
表示されたplaygroundにブラウザでアクセスしてみましょう。
2つのブラウザを使ってテストしてみます。
まずは、片方のブラウザでmessagePosted
をSubscribeしましょう。
subscription($user: String!) { messagePosted(user: $user) { id user text createdAt } }
variablesには以下を指定しておきましょう。
{ "user": "tanaka" }
つづいて、もう1方のブラウザでpostMessage
を使ってメッセージを配信します。
mutation($user: String!, $text: String!) { postMessage(user: $user, text: $text) { id user text createdAt } }
variablesには以下を指定しておきましょう。
{ "user": "suzuki", "text": "Hi there!!" }
このクエリを実行すると、Subscribeしているブラウザにメッセージが配信されるはずです。
まとめ
この記事ではGoのgqlgenを使って、GraphQLのSubscriptionsを処理するサーバのサンプルを紹介しました。
手順をおさらいすると、次のようになります。
- 必要に応じて、gqlgenでプロジェクトを初期化
- GraphQLのスキーマを作成
gqlgen generate
により、ひな形を自動生成./graph/resolver.go
で、必要なプロパティの定義やDIを行う./server.go
に初期化したResolverオブジェクトを渡す処理を行う./schema.resolvers.go
に各Query、Mutation、Subscriptionごとのビジネスロジックを記述
あとは、みなさんそれぞれのアプリのアーキテクチャに合わせ、モジュール化や非機能要件の実装を行ってください。
おまけ:Redisを使って複数台のサーバ構成に対応
Redisを使うことで、複数台のサーバで稼働する構成にできます。
先ほどと同じリポジトリですが、ブランチを「stateless」に切り替えることで見れます。
コンセプトは本記事と同じく、必要最小限の実装にとどめており、新しいファイルの追加などはせず、全て既存のコードの置き換えとなっています。
解説記事はこちらにあります。
参考リンク
Real-time Chat with GraphQL Subscriptions in Go - Outcrawl