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

公式ドキュメントあるけどいろいろ説明足りてなくてハマりまくったので、なんとか動くようになるまでの流れを記事にしました。
[GraphQL Subscriptions](https://lighthouse-php.com/3.1/extensions/subscriptions.html)

### 関連資料
[【Laravel + GraphQL】Lighthouseを使ってみる【その１：チュートリアル】](https://qiita.com/akkino_D-En/items/0ecd834d5d77c21cbd13)
[【Laravel】Pusherを使ってみる](https://qiita.com/akkino_D-En/items/9d6e49c5f7aa41e31021)


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


# セットアップ

## Redis

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

[predis](https://github.com/nrk/predis)を導入し、各種設定をします。

```
composer require predis/predis
```

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

## Pusher

PusherのAppを作成します。
[こちらの記事](https://qiita.com/akkino_D-En/items/9d6e49c5f7aa41e31021#pusher%E3%81%A7app%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)を参考に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`のプロバイダーに`BroadcastServiceProvider`と`SubscriptionServiceProvider`を追加します。

```php: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モデルはデフォルトのものをそのまま使用します。）

```php: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
```

```php: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](https://lighthouse-php.com/)をインストールします。

```
composer require nuwave/lighthouse
```

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

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

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

```graphql: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公式のチュートリアル](https://lighthouse-php.com/tutorial/#the-schema)にだいたい同じものがあるので、解説はそちらを参照してください。


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

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

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

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

```
php artisan lighthouse:subscription PostUpdated
```

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


#### Tips

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

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

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

[公式ページのチュートリアル](https://lighthouse-php.com/3.1/extensions/subscriptions.html#defining-fields)だといろいろ書いてありますが、まずは最小構成で作成しましょう。

```php: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](https://lighthouse-php.com/master/api-reference/directives.html#bcrypt)ディレクティブを使用する場合

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

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

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

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

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

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

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

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

\Nuwave\Lighthouse\Execution\Utils\Subscription::broadcast('postUpdated', $post);
```

* `string` `$subscriptionField`: トリガーするサブスクリプションの名前。
* `mixed` `$root`: サブスクリプションさせたいオブジェクト。
* `bool` `$shouldQueue = null`: オプション。デフォルト構成をオーバーライドする。

# 動作確認

WebSocketの動作確認には[Browser WebSocket Client](https://chrome.google.com/webstore/detail/browser-websocket-client/mdmlhchldhfnfnkfmljgeinlffmdgkjo)を使用します。

## WebSocket接続用URLを取得

[Pusherのドキュメント](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol#websocket-connection)を参考に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](https://chrome.google.com/webstore/detail/browser-websocket-client/mdmlhchldhfnfnkfmljgeinlffmdgkjo)を使用する場合は`protocol`を4以降に設定します。

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

## WebSocketに接続する

### チャンネルの発行

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

```graphql:request
subscription TestSubscription { 
  postUpdated(author: 1) { 
    id 
    title 
    content 
  }
}
```

```json: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を使用して接続します。

<img width="553" alt="スクリーンショット 2020-06-16 12.01.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/8808e88c-3954-3b53-b489-0ac91c275f2e.png">

接続に成功すると以下のようなレスポンスが返ってきます。
（参考:[Pusher Document：Double encoding](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol#double-encoding)）

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

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

チャンネルと接続するには[pusher.subscribe](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol#-pusher-subscribe-client-pusher-channels-)を使用します。

```json:message
{
  "event": "pusher:subscribe",
  "data": {
    "channel": String,
    "auth": String,
    "channel_data": String
  }
}
```

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

各チャンネルで必要な認証用データの詳細は[こちら](https://pusher.com/docs/channels/library_auth_reference/auth-signatures)を参照してください。

#### [認証文字列の生成](https://pusher.com/docs/channels/library_auth_reference/auth-signatures)

上記の`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をコマンドラインで求める方法](https://qiita.com/suin/items/694ee1faf73e454dbdad)を参考にダイジェスト文字列を生成します。

```
echo -n "2105.3454839:private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531" | openssl dgst -sha256 -hmac "7ad3773142a6692b25b8"
>> 104afe8991d23fd05b1ce303501ffeb870024b03303cab7df2fc2672abaa90ee
```

##### Rubyを使用する方法

手元に環境を用意するか[paiza.io](https://paiza.io/ja/projects/new)などで試してみましょう。

```ruby
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:subscribe`の`data.auth`にjson形式でセットすれば完了です。

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

### Subscribeする

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

<img width="744" alt="スクリーンショット 2020-06-16 13.00.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/8aabe500-9dfe-158e-02ba-daa6fdfc9e39.png">

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

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

<img width="1121" alt="スクリーンショット 2020-06-16 13.36.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/860ccec1-3ab6-4f37-eb42-6884dea6773f.png">


```json:response
{
  "event": "pusher_internal:subscription_succeeded",
  "data": "{}",
  "channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
}
```

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


## イベントを発行する

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

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

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

```graphql:request
mutation {
  updatePost(
    input: {
      id: 1, 
      title: "My new Post", 
      content: "test"
    }
  ) {
    id
    title
    content
    created_at
    updated_at
    author {
      id
      name
      email
    }
  }
}
```

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

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/7e4d1cb7-8f51-85a8-d625-ed5579031987.gif)

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

```json
{
  "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の方でもしっかりと受信していることが確認できます。

<img width="1181" alt="スクリーンショット 2020-06-16 13.50.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/adfee6f1-1dce-5ce1-e2c0-31a2cfa6c078.png">



# 参考文献
[GraphQL Subscriptions](https://lighthouse-php.com/3.1/extensions/subscriptions.html)
[Pusher Document:Pusher Channels Protocol](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol#-pusher-subscribe-client-pusher-channels-)
[HMAC-SHA256をコマンドラインで求める方法](https://qiita.com/suin/items/694ee1faf73e454dbdad)