6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Lighthouse】LighthouseのSubscriptionを使ってWebSocket通信する:Pusher編【Laravel + GraphQL + Pusher】

Posted at

公式ドキュメントあるけどいろいろ説明足りてなくてハマりまくったので、なんとか動くようになるまでの流れを記事にしました。
GraphQL Subscriptions

関連資料

【Laravel + GraphQL】Lighthouseを使ってみる【その1:チュートリアル】
【Laravel】Pusherを使ってみる

環境

Laravel:7.0
Lighthouse:4.13
predis:1.1
pusher-php-server:4.1

セットアップ

Redis

今回はRedisを使用するのでDockerなどを用いて構築してください。
ここでは説明を省かせていただきます。

predisを導入し、各種設定をします。

composer require predis/predis
REDIS_CLIENT=predis # predisに変更
REDIS_HOST=redis # Redisコンテナを指定
REDIS_PASSWORD=null
REDIS_PORT=6379

Pusher

PusherのAppを作成します。
こちらの記事を参考にAppを作成してください。

Pusherのライブラリを追加します。

composer require pusher/pusher-php-server

.envにPusherアプリの設定を記載します。
アプリのキーはGetting Startedのサンプル内、またはApp Keysタブに記載されているのでそちらを参照してください。

.env
PUSHER_APP_ID=12345
PUSHER_APP_KEY=hoge-app-key
PUSHER_APP_SECRET=hoge-app-secret
PUSHER_APP_CLUSTER=hoge-cluster

config/app.phpのプロバイダーにBroadcastServiceProviderSubscriptionServiceProviderを追加します。

config/app.php
'providers' => [
    // コメントアウトを外す
    // pusher/pusher-php-serverに必要
    App\Providers\BroadcastServiceProvider::class,

    // 追加する
    // LighthouseのSubscriptionに必要
    \Nuwave\Lighthouse\Subscriptions\SubscriptionServiceProvider::class,
],

ベースとなるスキーマの作成

POSTモデルの作成

今回はPostモデルの更新(update)に対してサブスクリプションを付与するので、Lighthouseのディレクティブを使用して最低限のスキーマを作成しましょう。

Postモデルを作成し、一対多のリレーションをします。
(Userモデルはデフォルトのものをそのまま使用します。)

App/Post.php
class Post extends Model
{
    protected $fillable = ['title', 'content'];

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

postsテーブルのマイグレーションを作成します。

php artisan make:migration create_posts_table
database/migrations/2020_06_15_000000_create_posts_table.php
class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id('id');
            $table->unsignedBigInteger('author_id');
            $table->string('title');
            $table->string('content');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

ベースとなるスキーマの作成

Lighthouseをインストールします。

composer require nuwave/lighthouse

デフォルトのスキーマを作成します。

php artisan vendor:publish --provider="Nuwave\Lighthouse\LighthouseServiceProvider" --tag=schema

最低限必要なスキーマを作成します。

graphql/schema.graphql
type Query {
    users: [User!]! @all
    user(id: ID @eq): User @find
}

type Mutation {
    createUser(name: String!, email: String!, password: String!): User! @create

    createPost(input: CreatePostInput! @spread): Post @create
    updatePost(input: UpdatePostInput! @spread): Post @update
}

type User {
    id: ID!
    name: String!
    email: String!
    created_at: DateTime!
    updated_at: DateTime!
    posts: [Post!]! @hasMany
}

type Post {
    id: ID!
    title: String!
    content: String!
    author: User! @belongsTo
    created_at: DateTime!
    updated_at: DateTime!
}

input CreateUserInput {
    name: String!
    email: String!
    password: String!
}

input UpdateUserInput {
    name: String
    email: String
    password: String
}

input CreateAuthorRelation {
    connect: ID
    create: CreateUserInput
    update: UpdateUserInput
}

input CreatePostInput {
    title: String!
    content: String!
    author: CreateAuthorRelation
}

input UpdatePostInput {
    id: ID!
    title: String
    content: String
}

これらのスキーマはLighthouse公式のチュートリアルにだいたい同じものがあるので、解説はそちらを参照してください。

サブスクリプションタイプの作成

サブスクリプションをSubscriptionタイプに定義します。

graphql/schema.graphql
type Subscription {
    postUpdated(author: ID): Post
}

以下のartisanコマンドを使用することでサブスクリプションの定義クラスを作成することができます。
Subscriptionタイプではフィールド名のアッパーキャメルのサブスクリプションクラスを検索するので、命名に注意しましょう。

php artisan lighthouse:subscription PostUpdated

上記のコマンドによりApp\GraphQL\Subscriptions\PostUpdatedが作成されます。

Tips

Subscriptionタイプでのフィールド名とサブスクリプションクラスの名前が異なる場合は@subscriptionディレクティブを使用することで明示的に指定することが可能です。

graphql/schema.graphql
type Subscription {
    postUpdated(author: ID): Post @subscription(class: "App\\GraphQL\\Subscriptions\\HogeUpdatedSubscription")
}

サブスクリプションクラス

公式ページのチュートリアルだといろいろ書いてありますが、まずは最小構成で作成しましょう。

App\GraphQL\Subscriptions\PostUpdated.php
class PostUpdated extends GraphQLSubscription
{
    /**
     * Check if subscriber is allowed to listen to the subscription.
     *
     * @param  \Nuwave\Lighthouse\Subscriptions\Subscriber  $subscriber
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    public function authorize(Subscriber $subscriber, Request $request): bool
    {
        return true;
    }

    /**
     * Filter which subscribers should receive the subscription.
     *
     * @param  \Nuwave\Lighthouse\Subscriptions\Subscriber  $subscriber
     * @param  mixed  $root
     * @return bool
     */
    public function filter(Subscriber $subscriber, $root): bool
    {
        return true;
    }

    /**
     * Resolve the subscription.
     *
     * @param  \App\Post  $root
     * @param  mixed[]  $args
     * @param  \Nuwave\Lighthouse\Support\Contracts\GraphQLContext  $context
     * @param  \GraphQL\Type\Definition\ResolveInfo  $resolveInfo
     * @return mixed
     */
    public function resolve($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): Post
    {
        return $root;
    }
}

サブスクリプションのトリガー

@broadcastディレクティブを使用する場合

サブスクリプションのトリガーとなるスキーマを設定しましょう。

@broadcastディレクティブをupdatePostミューテーションに設定します。

type Mutation {
    updatePost(input: UpdatePostInput! @spread): Post @update @broadcast(subscription: "postUpdated")
}

また複数のbroadcastディレクティブから一つのSubscriptionを参照したり、またその逆も可能です。
これにより単一のフィールドから複数のサブスクリプションをトリガーすることが可能です。

コードからトリガーする場合

\Nuwave\Lighthouse\Execution\Utils\Subscription::broadcastを使用することで任意の場所からサブスクリプションをトリガーすることが可能です。

Resolver内で処理したい場合などにはこちらを使用しましょう。

$post->title = $newTitle;
$post->save();

\Nuwave\Lighthouse\Execution\Utils\Subscription::broadcast('postUpdated', $post);
  • string $subscriptionField: トリガーするサブスクリプションの名前。
  • mixed $root: サブスクリプションさせたいオブジェクト。
  • bool $shouldQueue = null: オプション。デフォルト構成をオーバーライドする。

動作確認

WebSocketの動作確認にはBrowser WebSocket Clientを使用します。

WebSocket接続用URLを取得

Pusherのドキュメントを参考にWebSocket用のURLを取得します。

[scheme] ://ws-[cluster_name].pusher.com:[port]/app/[key]
  • [schema]:今回はwsを使用します。
  • [cluster_name]:Pusher Appのcluster名をセットします。(例:ap1)
  • [port]:wsの場合は80、wssの場合は443を使用します。
  • [key]:Pusher Appのアプリケーションキーをセットします。

またいくつかのクエリパラメータを設定することが可能です。

  • [protocol]:使用するプロトコルバージョンを指定します。これが指定されていない場合、versionパラメータから推測されます。
    • 現在protocol1,2,3はサポート外となり、エラーになるようになりました。
  • [client]:接続しているクライアントを識別します。
  • [version]:接続しているライブラリのバージョン。

Browser WebSocket Clientを使用する場合はprotocolを4以降に設定します。

サンプルURL
ws://ws-ap1.pusher.com:80/app/APP_KEY?protocol=5

WebSocketに接続する

チャンネルの発行

GraphQLのSubscriptionスキーマにリクエストを送ると、Pusherにプライベートチャンネルを作成されます。
(エンドポイントは/graphql

request
subscription TestSubscription { 
  postUpdated(author: 1) { 
    id 
    title 
    content 
  }
}
response
{
  "data": {
    "postUpdated": null
  },
  "extensions": {
    "lighthouse_subscriptions": {
      "version": 1,
      "channels": {
        "TestSubscription": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
      }
    }
  }
}

この時作成されるチャンネルがプライベートチャンネルであることと、このレスポンスのデータがチャンネル名をkeyとしてRedisに保存されることに注意しましょう。

Redis
keys *

1) "laravel_database_laravel_cache:graphql.subscriber.private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
2) "laravel_database_laravel_cache:graphql.topic.POST_UPDATED"

またgraphql.topic.POST_UPDATEDにはkey名のサブスクリプションに対して生成されているチャンネルの一覧がキャッシュされています。

get laravel_database_laravel_cache:graphql.topic.POST_UPDATED

"O:29:\"Illuminate\\Support\\Collection\":1:{s:8:\"\x00*\x00items\";a:2:{i:1;s:62:\"private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531\";}}"

チャンネルに接続する

WebSocket用URLを使用して接続します。

スクリーンショット 2020-06-16 12.01.02.png

接続に成功すると以下のようなレスポンスが返ってきます。
(参考:Pusher Document:Double encoding

{
  "event": "pusher:connection_established",
  "data": "{\"socket_id\":\"2105.3454839\"}"
}

WebSocketとの接続が完了したら、次は任意のチャンネルと接続します。
今回はプライベートチャンネルなのでauthによる認証が必要です。

チャンネルと接続するにはpusher.subscribeを使用します。

message
{
  "event": "pusher:subscribe",
  "data": {
    "channel": String,
    "auth": String,
    "channel_data": String
  }
}
  • data.channel: String: サブスクライブするチャンネルの名前。
  • data.auth: String(オプション): プライベートチャンネルの場合に認証を行うためのフィールド。
  • data.channel_data: String(オプション): プレゼンスチャンネルの場合、チャンネルに関する追加情報を入力するためのフィールド。

各チャンネルで必要な認証用データの詳細はこちらを参照してください。

認証文字列の生成

上記のdata.authにいれるための文字列の形式は以下のようになっています。

<pusher-key>:<signature>

この<signature>HMAC-SHA256で以下の文字列を計算することで算出できます。

<socket_id>:<channel_name>

例えば以下のようなPusher Appの資格情報があるとします。

key = '278d425bdf160c739803'
secret = '7ad3773142a6692b25b8'

また先ほどWebSocketに接続した際にsocket_idとして2105.3454839を、チャンネル名としてprivate-lighthouse-ZjZ...から始まる値を取得しました。

これを組み合わせてると以下のようになります。

<socket_id>:<channel_name>
2105.3454839:private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531

これをHMAC-SHA256で計算します。

コマンドラインで計算する

HMAC-SHA256をコマンドラインで求める方法を参考にダイジェスト文字列を生成します。

echo -n "2105.3454839:private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531" | openssl dgst -sha256 -hmac "7ad3773142a6692b25b8"
>> 104afe8991d23fd05b1ce303501ffeb870024b03303cab7df2fc2672abaa90ee
Rubyを使用する方法

手元に環境を用意するかpaiza.ioなどで試してみましょう。

require "openssl"

digest = OpenSSL::Digest::SHA256.new
secret = "7ad3773142a6692b25b8"
string_to_sign = "2105.3454839:private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"

puts signature = OpenSSL::HMAC.hexdigest(digest, secret, string_to_sign)
# => 104afe8991d23fd05b1ce303501ffeb870024b03303cab7df2fc2672abaa90ee

最後に<pusher-key>:<signature>の形式でpusher:subscribedata.authにjson形式でセットすれば完了です。

message
{
  "event": "pusher:subscribe",
  "data": {
    "channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531",
    "auth": "278d425bdf160c739803:104afe8991d23fd05b1ce303501ffeb870024b03303cab7df2fc2672abaa90ee"
  }
}

Subscribeする

作成したmessageを使ってプライベートチャンネルにSubscribeします。

スクリーンショット 2020-06-16 13.00.26.png

画像のSend a Messageに上記の先ほど作成したjsonのmessageを入れsendします。

すると以下のようにsubscription_succeededのレスポンスが返ってきて接続に成功したことがわかります。

スクリーンショット 2020-06-16 13.36.18.png
response
{
  "event": "pusher_internal:subscription_succeeded",
  "data": "{}",
  "channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
}

接続に失敗した場合はeventpusher:errorが返されるので、data.messageのエラー内容を読んで対応しましょう。

イベントを発行する

最後にイベントを発行して正常にWebSocketで受信するか確認してみます。

@broadcastディレクティブを付与したミューテーションを実行します。

type Mutation {
    updatePost(input: UpdatePostInput! @spread): Post @update @broadcast(subscription: "postUpdated")
}
request
mutation {
  updatePost(
    input: {
      id: 1, 
      title: "My new Post", 
      content: "test"
    }
  ) {
    id
    title
    content
    created_at
    updated_at
    author {
      id
      name
      email
    }
  }
}

上記のクエリーを実行すると以下のようなjsonが返されます。

response
{
  "data": {
    "updatePost": {
      "id": "1",
      "title": "My new Post",
      "content": "test",
      "created_at": "2020-06-15 12:32:32",
      "updated_at": "2020-06-15 14:50:31",
      "author": {
        "id": "1",
        "name": "hogehoge",
        "email": "hogehoge@example.com"
      }
    }
  },
  "extensions": {
    "lighthouse_subscriptions": {
      "version": 1,
      "channels": []
    }
  }
}

ちゃんとイベントが届いたか確認してみます。

ezgif-2-e5b2ce11e2f4.gif

しっかりと以下のようなイベントが発行されていますね。

{
  "event": "lighthouse-subscription",
  "data": "{\"more\":true,\"result\":{\"data\":{\"postUpdated\":{\"id\":\"1\",\"title\":\"My new Post\",\"content\":\"test\"}},\"extensions\":{\"lighthouse_subscriptions\":{\"version\":1,\"channels\":[]}}}}",
  "channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
}

またPusherの方でもしっかりと受信していることが確認できます。

スクリーンショット 2020-06-16 13.50.05.png

参考文献

GraphQL Subscriptions
Pusher Document:Pusher Channels Protocol
HMAC-SHA256をコマンドラインで求める方法

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?