PHP
websocket
laravel
vue.js
マグロデモ

マグロと寿司とWebSocket。Laravel+Vue.jsで簡易的なチャットを作る。

LaravelでWebSocketを試す機会があったので、使い方を忘れないために、「まぐろ」の数だけ「マグロ」を返すチャットをデモで作成しました。

tuna.gif

デモはこちらから

寿司が食べたくなりますね。

LaravelでWebSocket

LaravelでWebSocketを実装するには、Pusherという外部サービスを使う方法と、socket.io+RedisのPUB/SUBで、WebSocketの部分をブリッジしてあげる方法が用意されています。

WebSocketまわりをLaravelの外に切り離すことで、ビジネスロジックに集中できるんだね。すごーい。

みたいな説明がちらほらあったのですが、PusherやRedisを知らない身としてはピンとこなかったので、そこらへんを補足しながらやっていきます。今回はsocket.io+Redisで実装することにしました。

Laravel5.5 Broadcasting

用語の整理

似たような言葉がいくつか出てくるので事前に整理しておきます。

Laravel
Web職人のためのPHPフレームワーク。色々と手軽にできてすごい。
https://laravel.com/

Laravel Echo
LaravelとWebSocketを手軽に連携できるようにしてくれる、クライアントライブラリ。フロントのJavaScriptから使うやつ。簡単に使えてすごい。
https://github.com/laravel/echo

Laravel Echo Server
Node.js+Socket.io.のサーバー。Laravelの公式ではないっぽいですが、ドキュメントに記載があるので、推されている模様。これのおかげで、Node.jsは一行も書く必要がありませんでした。すごい。
https://github.com/tlaverdure/laravel-echo-server

まぐろ / マグロ / 鮪

動物や植物(含む野菜)を表す漢字が常用漢字表にあれば漢字。なければひらがなで書きます。学術的な場合は、カタカナで書きます。

NHK放送文化研究所

NHKのルールに従うのであれば、「まぐろ」に統一したほうが良さそうですが、マグロの方が文中における可読性が高いので、あまり気にせず適当に書いていくことにします。

鮓 / 鮨 / 寿司
鮓と鮨にはルーツの違いがあるようです。ざっくりした認識としては、最も古く西日本で多いのが「鮓」で、次に古くて東日本で多いのが「鮨」、その後に縁起の良い文字を当てられたのが「寿司」みたいです。回転寿司のおかげか、「寿司」が最も庶民的な印象があります。

寿司 | Wikipedia

通信の流れ

sushi-chart.png

作成した簡易チャットでは、上のような流れで通信をします。テキストを入力して送信するとPOSTされて、それをトリガーにしてWebSocKetですべてのブラウザにブロードキャストされる流れです。

Redisについて

Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker. It supports data structures such as strings, hashes, lists, sets, sorted sets...
redis.io

当たり前かもしれませんが、使ったことがなかったのでメモです。
基本的には、キーとバリューを保存するデータベース(KVS)で、キャッシュなどの用途で使われるようですが、LaravelではPUB/SUBを利用してWebSocketとのメッセージングしています。Redisの説明はこちらのスライドがとてもわかりやすかったので、初めての方はぜひ。インメモリってお金かかりそうだなぁ。

Redisの特徴と活用方法について

PUB/SUBとはいったい・・・?

JavaScriptでいうと、addEventListenerが身近なPUB/SUBになるのかな、たぶん。clickイベントにコールバックを指定しておくと(SUBSCRIBE)、click時にPUBLISHされてコールバックが実行されるあの感じ。Redisでは、チャンネルを指定してSUBSCRIBEしておくと、チャンネルがPUBLISHされたときに通知が行きます。これで何かあったときにメッセージを渡せますね。

# 端末AでSUBSCRIBE
redis-cli
> SUBSCRIBE hoge
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "hoge"
3) (integer) 1

# 端末BでPUBLISH
redis-cli
> PUBLISH hoge "Hello! PUB/SUB!"

# すると端末Aにリアクションが!すごーい!
1) "message"
2) "hoge"
3) "Hello! PUB/SUB"

addEventListener | MDN
Pub/Sub | Redis

環境の構築

  • CentOS 7.2
  • PHP 7.2
  • Nginx 1.12
  • Node.js 6.10
  • Redis 3.2
  • Laravel 5.5

上記の環境を、EC2の安いやつにのせています。

Node.jsとRedisのインストール

まずは必要なミドルウェアをインストールします。

$ yum install nodejs
$ yum install redis

Predisの追加

PHPのRedisクライアントであるPredisを、Composerで追加します。

composer require predis/predis

https://github.com/nrk/predis

Laravel Echo Serverのインストール

次に、WebSocketまわりをいい感じにやってくれる、Laravel Echo Serverをnpmでインストールします。
https://github.com/tlaverdure/laravel-echo-server

# インストール
npm install -g laravel-echo-server

# 色々聞かれるので素直に答える
laravel-echo-server init

# 起動
laravel-echo-server start

質問に答えていくとこんな感じのファイルが生成されます。auth関連の設定は、プライベートなチャンネルを使う場合に必要になるみたいです。チャンネルの情報を取得するAPIも用意されているようですが、今回はどちらも使用しないので、特に設定は変更せずにデフォルトのままにしました。

laravel-echo-server.json
{
  "authHost": "http://localhost",
  "authEndpoint": "/broadcasting/auth",
  "clients": [],
  "database": "redis",
  "databaseConfig": {
    "redis": {},
    "sqlite": {
      "databasePath": "/database/laravel-echo-server.sqlite"
    }
  },
  "devMode": false,
  "host": null,
  "port": "6001",
  "protocol": "http",
  "socketio": {},
  "sslCertPath": "",
  "sslKeyPath": "",
  "sslCertChainPath": "",
  "sslPassphrase": "",
  "apiOriginAllow": {
  "allowCors": false,
  "allowOrigin": "",
  "allowMethods": "",
  "allowHeaders": ""
  }
}

Laravel

ここからLaravelのファイルを触っていきます。

Broadcast Service Providerを有効にする

デフォルトでは config/app.phpBroadcastServiceProvider がコメントアウトされているので有効にします。

app.php
'providers' => [
  App\Providers\BroadcastServiceProvider::class,
]

.envの修正

BROADCAST_DRIVER がデフォルトでは log になっているので redis に変更します。

.env
BROADCAST_DRIVER=redis

イベントの作成

php artisan make:event MessageRecieved

コマンドをたたくとイベント用のテンプレが生成されるので、マグロのイベントに修正します。今回は認証を必要としないパブリックなチャンネルなので、 broadcastOnChannel を返すようにしていますが、プライベートなチャンネルを使用する場合は、 PrivateChannel にします。

MessageRecieved.php
<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class MessageRecieved implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /** @var */
    public $messages;

    /**
     * Create a new event instance.
     *
     * @param array $messages
     */
    public function __construct(array $messages)
    {
        $this->messages = $messages;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return new Channel('tuna');
    }
}

ルートの認可(スルー)

イベントが作成できたので、ブロードキャスト用のルート認可を追加します。クロージャの返り値の true/false でチャンネルをリッスンする認可があるかを返しますが、今回はすべてのフレンズにマグロを返したいので、設定せずにスルーします。Laravelではクロージャが多用されているので、JavaScriptっぽくてたまに混乱します。

// ドキュメントの抜粋(今回は設定しないため)
Broadcast::channel('order.{orderId}', function ($user, $orderId) {
    return $user->id === Order::findOrNew($orderId)->user_id;
});

コントローラーでイベントを発行

メッセージがPOSTされたときにイベントを発行するように設定します。イベントの発行はとてもシンプルで、eventヘルパーにイベントのインスタンスを渡すだけです。

php artisan make:controller MessageController
routes/api.php
Route::namespace('Api')->group(function() {
    Route::post('/message', 'MessageController@store');
});

MessageController.php
/**
 * メッセージが来たぞ
 *
 * @param \App\MessageBuilder  $builder
 * @param \Illuminate\Http\Request  $request
 */
public function store(MessageBuilder $builder, Request $request)
{
    $request->validate([
        'friendId' => 'required|integer',
        'text' => 'required|max:255'
    ]);

    $userMessage = $builder->user($request->friendId, $request->text);
    $tunaMessage = $builder->tuna($request->text);

    $messages = [$userMessage];

    if ($tunaMessage) {
        $messages[] = $tunaMessage;
    }

    event(new MessageRecieved($messages));

    return response()->json($messages);
}

マグロを返すルールなどは省略していますが、ここまででLaravelの実装は終わりです。

フロントエンド

はやく仕上げるために、できるだけ用意されているJSファイルを流用する形で実装していきます。

socket.ioのクライアントライブラリ読み込み

Laravel Echo Serverからライブラリを読み込みます。

<script src="https://echo-all-you-need-is-tuna.noplan.cc/socket.io/socket.io.js"></script>

Laravel Echoの初期設定

エントリーファイルの app.js で読み込まれている、 bootstrap.js にLaravel Echoの初期設定があるので、コメントアウトを外して設定します。

app.js
require("./bootstrap");
bootstrap.js
import Echo from "laravel-echo";

window.Echo = new Echo({
  broadcaster: "socket.io",
  host:'https://echo-all-you-need-is-tuna.noplan.cc'
});

チャンネルの受信設定

コンポーネントのマウント時に、Laravelで設定しておいたチャンネルに接続するようにします。channel にチャンネル名、 listen にイベントのクラス名を指定します。

resources/assets/js/tuna/App.vue
<script>
export default {
  mounted() {
    this.connectChannel();
  },
  methods: {
    connectChannel() {
      Echo.channel("tuna").listen("MessageRecieved", e => {
        store.recieveMessage(e.messages);
      });
    }
  }
};
</script>
resources/assets/js/tuna/store.js
const store = {
  state: {
    friendId: null,
    timeline: []
  },
  // POSTするとこれが呼ばれる
  postMessage(text) {
    axios.post("/api/message", {
      friendId: this.state.friendId,
      text
    });
  },
  // チャンネル受信時にコールバックでこれが呼ばれる
  recieveMessage(messages) {
    this.state.timeline = [
      ...this.state.timeline,
      ...messages
    ];
  }
};

メッセージのPOSTとWebSocketの受信

テキストが入力された状態で送信ボタンを押してメッセージをPOSTすると、 tuna チャンネルの MessageRecieved イベントをリッスンしている全てのブラウザにブロードキャストされます。

resources/assets/js/tuna/components/Message.vue
<template>
  <button type="button" @click="send" />
</template>
<script>
export default {
  methods: {
    send() {
      store.postMessage(text);
    }
  }
}
</script>

Viewに反映

あとはメッセージを v-for でまわせば、タイムラインが表示されます。

resources/assets/js/tuna/components/Talk.vue
<template>
  <!-- メッセージを追加するとViewに反映される -->
  <div class="talk-list">
      <TalkItem
        v-for="talk in timeline"
        :talk="talk"
      />
  </div>
</template>

WebSocketの確認

ws_border.jpg

# リクエスト
# WebSocketにアップグレードよろしく

Upgrade:websocket
Connection:Upgrade

 ↓

# レスポンス
# 問題なければWebSocketに切り替え

HTTP/1.1 101 Switching Protocols
Upgrade: websocket 
Connection: Upgrade

   ↓

# WebSocketのはじまり

WebSocketの通信内容はChromeのコンソールから確認できます。 101 というレスポンスコードに見慣れない感がありますが、上のような流れでWebSocketがはじまるようです。接続後の通信内容は、「Headers」の横にある「Frames」タブから確認できます。すごい。

101 Switching Protocols | MDN
RFC 7231 6.2.2. 101 Switching Protocols

本番化するときに

Laravel Echo Serverの前にNginxを置く

デフォルトだとNode.jsのサーバーが port:6001 で立ち上がるのですが、 80,443 以外を空けるのが面倒くさかったので、サブドメインを切ってNginxを前に置く構成にしました。SSLの証明書はCertbotで取得しています。

server {
  listen 443 ssl;
  server_name echo-all-you-need-is-tuna.noplan.cc;

  ssl_certificate /path/to/fullchain.pem; # managed by Certbot
  ssl_certificate_key /path/to/privkey.pem; # managed by Certbot
  include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

  location / {
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_pass http://localhost:6001;
  }
}

Nginx+CentOS 7 | Certbot
Using NGINX as a WebSocket Proxy | Nginx

Laravel Echo Serverのプロセスをpm2で管理する

初期の状態では、laravel-echo-server start のコマンドで立ち上げないといけない状態なので、バックグランドでプロセスを起動して柔軟に管理するべく、pm2というNode.jsのプロセス管理ツールを導入しました。
https://github.com/Unitech/pm2

探していた内容のIssuesが上がっていたので、そちらを参考にpm2を設定しました。

echo-chat.json
{
  "name": "echo-chat",
  "script": "laravel-echo-server",
  "args": "start"
}
# インストール
npm install -g pm2

# 起動
pm2 start echo.json

Best Practice Running Laravel Echo Server as Background Service

チャットの工夫

マグロの数だけマグロを返す

しゃべる度にマグロが返ってきたらさすがに過剰なので、テキストに「まぐろ」や「マグロ」や「近大」が含まれるときに、マグロを返すようにしています。機械学習、ではなく力技です。

寿司ガチャ / 排出率は3%

通常は生きたマグロが返ってきますが、ごくまれに寿司が返るように設定しています。

メッセージを保存しない

送られてきたメッセージはDBに保存せず、WebSocketでたれ流すだけにしてるので、後腐れなくマグロの話ができます。リロードしたらキレイになります。

やっていないこと

エラー処理全般と、iOSの入力まわりの調整はやっていないです。

二郎は鮨の夢を見る

マグロに興味を持ったきっかけになったのが、こちらの映画になります。

伝説的職人である「すきやばし次郎」の店主・小野二郎さんと、その背中を追う長男・禎一さんの仕事を捉えたドキュメンタリーです。物語の起伏は少なめですが、一朝一夕ではならない職人の仕事を淡々と映しつつ、偉大な父を追う禎一さんの苦悩が描かれています。

鮨が好きな方、悩んでいる二代目の方にはぜひ。

二郎は鮨の夢を見るの予告

完成したデモ

tuna.gif

こちらが完成品です。
物足りないサイトではありますが、マグロ好き同士が語らえる場になれたら嬉しいです。

デモ
https://all-you-need-is-tuna.noplan.cc

ソース
https://github.com/noplan1989/all-you-need-is-tuna

文中のコードでは、説明を簡単にするためににマグロの処理などを省略しているので、細かい内容はこちらからご確認ください。