公式ドキュメントあるけどいろいろ説明足りてなくてハマりまくったので、なんとか動くようになるまでの流れを記事にしました。
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
タブに記載されているのでそちらを参照してください。
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
を追加します。
'providers' => [
// コメントアウトを外す
// pusher/pusher-php-serverに必要
App\Providers\BroadcastServiceProvider::class,
// 追加する
// LighthouseのSubscriptionに必要
\Nuwave\Lighthouse\Subscriptions\SubscriptionServiceProvider::class,
],
ベースとなるスキーマの作成
POSTモデルの作成
今回はPostモデルの更新(update)に対してサブスクリプションを付与するので、Lighthouseのディレクティブを使用して最低限のスキーマを作成しましょう。
Postモデルを作成し、一対多のリレーションをします。
(Userモデルはデフォルトのものをそのまま使用します。)
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
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
最低限必要なスキーマを作成します。
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
タイプに定義します。
type Subscription {
postUpdated(author: ID): Post
}
以下のartisanコマンドを使用することでサブスクリプションの定義クラスを作成することができます。
Subscription
タイプではフィールド名のアッパーキャメルのサブスクリプションクラスを検索するので、命名に注意しましょう。
php artisan lighthouse:subscription PostUpdated
上記のコマンドによりApp\GraphQL\Subscriptions\PostUpdated
が作成されます。
Tips
Subscription
タイプでのフィールド名とサブスクリプションクラスの名前が異なる場合は@subscription
ディレクティブを使用することで明示的に指定することが可能です。
type Subscription {
postUpdated(author: ID): Post @subscription(class: "App\\GraphQL\\Subscriptions\\HogeUpdatedSubscription")
}
サブスクリプションクラス
公式ページのチュートリアルだといろいろ書いてありますが、まずは最小構成で作成しましょう。
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以降に設定します。
ws://ws-ap1.pusher.com:80/app/APP_KEY?protocol=5
WebSocketに接続する
チャンネルの発行
GraphQLのSubscriptionスキーマにリクエストを送ると、Pusherにプライベートチャンネルを作成されます。
(エンドポイントは/graphql
)
subscription TestSubscription {
postUpdated(author: 1) {
id
title
content
}
}
{
"data": {
"postUpdated": null
},
"extensions": {
"lighthouse_subscriptions": {
"version": 1,
"channels": {
"TestSubscription": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
}
}
}
}
この時作成されるチャンネルがプライベートチャンネルであることと、このレスポンスのデータがチャンネル名をkeyとして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を使用して接続します。
接続に成功すると以下のようなレスポンスが返ってきます。
(参考:Pusher Document:Double encoding)
{
"event": "pusher:connection_established",
"data": "{\"socket_id\":\"2105.3454839\"}"
}
WebSocketとの接続が完了したら、次は任意のチャンネルと接続します。
今回はプライベートチャンネルなのでauthによる認証が必要です。
チャンネルと接続するにはpusher.subscribeを使用します。
{
"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:subscribe
のdata.auth
にjson形式でセットすれば完了です。
{
"event": "pusher:subscribe",
"data": {
"channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531",
"auth": "278d425bdf160c739803:104afe8991d23fd05b1ce303501ffeb870024b03303cab7df2fc2672abaa90ee"
}
}
Subscribeする
作成したmessageを使ってプライベートチャンネルにSubscribeします。
画像のSend a Message
に上記の先ほど作成したjsonのmessageを入れsendします。
すると以下のようにsubscription_succeeded
のレスポンスが返ってきて接続に成功したことがわかります。
{
"event": "pusher_internal:subscription_succeeded",
"data": "{}",
"channel": "private-lighthouse-ZjZjc3TObGFR5ttEDfk2BQTGDuKI8x0S-1592224531"
}
接続に失敗した場合はevent
にpusher:error
が返されるので、data.message
のエラー内容を読んで対応しましょう。
イベントを発行する
最後にイベントを発行して正常にWebSocketで受信するか確認してみます。
@broadcast
ディレクティブを付与したミューテーションを実行します。
type Mutation {
updatePost(input: UpdatePostInput! @spread): Post @update @broadcast(subscription: "postUpdated")
}
mutation {
updatePost(
input: {
id: 1,
title: "My new Post",
content: "test"
}
) {
id
title
content
created_at
updated_at
author {
id
name
email
}
}
}
上記のクエリーを実行すると以下のようなjsonが返されます。
{
"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": []
}
}
}
ちゃんとイベントが届いたか確認してみます。
しっかりと以下のようなイベントが発行されていますね。
{
"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の方でもしっかりと受信していることが確認できます。
参考文献
GraphQL Subscriptions
Pusher Document:Pusher Channels Protocol
HMAC-SHA256をコマンドラインで求める方法