おひとり

できる限りひとりで楽しむための情報やプログラミング情報など。

GoでGraphQLのSubscriptions(サーバ)のシンプルな実装

GoでGraphQLのSubscriptionのシンプルな実装例を紹介します。

TL;DR

この記事では、GoでGraphQLのSubscriptionsを実装する一番シンプルなサンプルを提供します。
そのため、コードのモジュール化やサーバの複数台構成といった非機能要件など、GraphQLに関係ない仕様は考慮しません。

手順は次のようになります。

  1. 必要に応じて、gqlgenでプロジェクトを初期化
  2. GraphQLのスキーマを作成
  3. gqlgen generateにより、ひな形を自動生成
  4. ./graph/resolver.goで、必要なプロパティの定義やDIを行う
  5. ./server.goに初期化したResolverオブジェクトを渡す処理を行う
  6. ./schema.resolvers.goに各Query、Mutation、Subscriptionごとのビジネスロジックを記述

コードはGitHubに掲載しています。

github.com

はじめに(おことわり)

この記事では、一番簡単なシンプルな実装例(チャットサービス)を紹介します。
というのも、GoでGraphQLのSubscriptionの実装方法を調べると海外サイトも含め「結構複雑な例が多い」という印象を受けたから。

例えば、

  • モジュール化しすぎてSubscriptionsの記述がどこなのか探しにくい
  • Redisを使うなど非機能要件に関するコード量が多い

という感じで、とにかく試してみたい場合も様々な予備知識が必要になります。

そこで、この記事では以下に気をつけた実装を紹介します。

  • モジュール化しない
  • gqlgenで初期化した構造をそのまま利用
  • サーバの複数台構成などの非機能要件は無視

そういう意味で「シンプルな」という言葉を使っているということですね。

また、今回はGraphQLの実装にgqlgenを使います。
gqlgenのリポジトリにはSubscriptionsの実装例があります。

github.com

「公式のサンプルを読み解けるよ」という方はそちらを参照してください。

GraphQLのSubscriptionsおさらい

GraphQLのSubscriptionsとは、サーバからリアルタイムにデータを取得できる仕組みです。
新聞の「購読」と同じように、購読者として登録して、あとは新聞(データ)が来るのを待っていればよいということです。

その手順をざっとおさらいしてみます。
まず、クライアントは特定のイベントを「購読(subscribe)」します。

各クライアントはサーバにsubscribeする。
各クライアントはサーバにsubscribeする。

subscribeすると、各クライアントはlistening状態となります。
クライアントとサーバはwebsocketでコネクションを維持します。

クライアントはsubscribe中、listening状態になる。
クライアントはsubscribe中、listening状態になる。

そして、サーバは何かイベントが発生したとき、subscribeしたクライアントにデータをpublishします。

サーバは任意のタイミングでクライアントにデータをpublishする。
サーバは任意のタイミングでクライアントにデータをpublishする。

なお、クライアントはデータを受け取った後は再びlistening状態に戻ります。
そのため、データが発行されたタイミングでリアルタイムに処理したり表示したりできます。
これはデータの「ストリーム」を監視しているようなイメージです。

例:チャットアプリ

今回は「チャットアプリ」を実装します。
よく例にでてきますが、一番分かりやすいのでここでも取り上げます。

以下のように、クライアントのいずれかが投稿したメッセージを他のクライアントにも配信するというアプリです。

1, クライアントのいずれかがpostMessageでメッセージを発行。2, サーバは各クライアントに先ほど発行されたメッセージを配信する。
チャットアプリの動作

なお、クライアントはブラウザで動作する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を編集します。

./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を開きましょう。このファイルを書き換えていきます。

./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です。

./server.go

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のエントリポイントのひな形が追加されていきます。
今回は、このファイルに直接全てのビジネスロジックを書いていきましょう。

全てのファイルは以下にあります。

./graph/schema.resolvers.go

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"
}

f:id:hitoridehitode:20210503184056p:plain
▷を押すとlistening状態になります。

つづいて、もう1方のブラウザでpostMessageを使ってメッセージを配信します。

mutation($user: String!, $text: String!) {
  postMessage(user: $user, text: $text) {
    id
    user
    text
    createdAt
  }
}

variablesには以下を指定しておきましょう。

{
  "user": "suzuki",
  "text": "Hi there!!"
}

f:id:hitoridehitode:20210503184357p:plain
▷を押すとメッセージが送信される。

このクエリを実行すると、Subscribeしているブラウザにメッセージが配信されるはずです。

f:id:hitoridehitode:20210503185533g:plain

まとめ

この記事ではGoのgqlgenを使って、GraphQLのSubscriptionsを処理するサーバのサンプルを紹介しました。
手順をおさらいすると、次のようになります。

  1. 必要に応じて、gqlgenでプロジェクトを初期化
  2. GraphQLのスキーマを作成
  3. gqlgen generateにより、ひな形を自動生成
  4. ./graph/resolver.goで、必要なプロパティの定義やDIを行う
  5. ./server.goに初期化したResolverオブジェクトを渡す処理を行う
  6. ./schema.resolvers.goに各Query、Mutation、Subscriptionごとのビジネスロジックを記述

あとは、みなさんそれぞれのアプリのアーキテクチャに合わせ、モジュール化や非機能要件の実装を行ってください。

おまけ:Redisを使って複数台のサーバ構成に対応

Redisを使うことで、複数台のサーバで稼働する構成にできます。

先ほどと同じリポジトリですが、ブランチを「stateless」に切り替えることで見れます。
コンセプトは本記事と同じく、必要最小限の実装にとどめており、新しいファイルの追加などはせず、全て既存のコードの置き換えとなっています。

github.com

解説記事はこちらにあります。

www.ohitori.fun

参考リンク

Real-time Chat with GraphQL Subscriptions in Go - Outcrawl

f:id:hitoridehitode:20210504114215p:plain