Laravel5とWebSockets: Broadcasting Eventsを試してみる

Laravel5.1からの新機能でBroadcasting Eventsという機能があります。これはとても素敵な機能でどういうものかと言うとLaravelのイベントの仕組みを通してWebSocketsを利用してブラウザへ直接プッシュ通信ができるというものです。Laravel作者のTaylorの動画があります。

Broadcasting Events in Laravel 5.1

WebSockets + PHPを今回プロジェクトで使う予定なので、このBroadcasting Eventsが使えそうか試してみました。

Broadcasting Eventsの実態

WebSocketsはノンブロッキングな言語でないとリソースが枯渇するためPHPが苦手とする技術でおそらくPHPのWebSockets実装はほとんど実績はなさそうです(いちおうReact PHPというものもありますが)。このBroadcasting EventsでもLaravelがPHPである以上そこは変わりなく実はWebSocketsを扱う部分はその領域が得意なNode.jsやPusherというクラウドに任せる設計になっています。しかしLaravelのイベントの仕組みがシームレスにそこにつながるように考えられていて、とても自然にPHPから扱えるようになっています。

Pusherは試してないのですが、Socket.io/Node.jsを利用する場合、構成としてはLaravelの他にRedisとSocket.ioが動くNode.jsのサーバープロセスが必要になります。イメージとしては、RedisのPubSubの機能を介してLaravelからNode.jsへイベントを伝播させ、Node.jsのSocket.ioから接続がつながっているブラウザへとイベントを伝播させます。

こう書くと結局PHPの他にNode.jsも書かなきゃいけないのか?と思われるかもしれませんが、Node.jsはやることがブラウザへイベントを伝播させるだけなので汎用的に書けていれば一度書いたらほぼPHPのみに集中できると思います。簡単な要件であれば数10行で済むと思います。

Broadcasting Eventsの気になるところ

Taylorの動画やググッて簡単に調べてみて気になった事がありました。Broadcastingという名称が示す通り一斉送信しかできなかったらどうしよう?という点です。個人的に以前WebSocketsを利用した時はNode.js(Sails.js + Socket.io)で実装したので特に問題なかったのですが、今回2つの言語をブリッジする事になるためどこまで出来るか少し不安でした。気になったのは下記の2点です。

  • Laravel側でユーザーが認証しているのにその認証をNode.js側で判断できるか?
  • 一斉送信だけでなく個別のユーザーを識別してプッシュ通信ができるか?

上記の疑問点の検証として今回サンプルを作りました。結果から言うとどちらもうまく出来ることが分かりました。

example-Laravelchat-Socket.io

今回下記の記事を参考に実装しました。あまりググっても疑問に応えるものが少なかったんですが、下記の記事のおかげでだいぶラクできました。

Easy Socket.IO + BroadCasting in Laravel – LukePOLO

サンプル実装の要点

Broadcasting Eventsをサンプルの中で実装した下記のコミットがどのような対応が必要になったかを俯瞰する意味では分かりやすいかもしれません。

integrated with redis and socket.io

以下コードは端折りながら見ていきますが、上記のコミットを見ると全体像は把握できると思います。

Laravel側の対応

PHP側はとてもシンプルです。まず設定面で必ず必要なのは下記です。

  • APP_KEYが任意の文字列で構成されている事(5.2からのbase64形式だとうまくいきませんでした)
  • SESSION_DRIVERがredisになっていること
  • BROADCAST_DRIVERがredisになっていること

Laravelのイベントの作成

これはphp artisan make:event xxxEventで鋳型を作成する事ができます。

namespace App\Events;

use App\Events\Event;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class MessageCreatedEvent extends Event implements ShouldBroadcast
{
    use SerializesModels;

    public $destinations = [];
    public $message;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct($destinations, $message)
    {
        $this->destinations = $destinations;
        $this->message = $message;
    }

    /**
     * Get the channels the event should be broadcast on.
     *
     * @return array
     */
    public function broadcastOn()
    {
        return ['chat'];
    }
}

次にこれをどこで発生させるかですが、メッセージ生成時なのでMessageモデルのライフサイクルメソッドかなと思ってたんですが、調べてるとObserverというものがあってちょうど良さそうなので今回はObserverを作ってそこでMessageモデルの状態変化をトリガーにイベントを発生させるようにしました。

namespace App\Observers;

use App\Message;

class MessageObserver {

    public function saving($model)
    {
        //
    }

    public function saved(Message $model)
    {
        $messages = \App\Message::with('fromUser')->with('toUser')
            ->where('id', $model->id)->get()->toArray();
        event(new \App\Events\MessageCreatedEvent([$model->from_user_id, $model->to_user_id], current($messages)));
    }

}

これをapp/Providers/EventServiceProvider.phpのbootでオブザーブするように設定しました。その後、php artisan optimizeを実行しました。URLを失念してしまったんですが調べていたらProviderに仕掛けた後はphp artisan optimizeを実行しないと反映されないというのがあったんですがどうなんでしょうか?ここは詳しく見てないです。

PHPは基本的なところはこんなものでした。

Socket.io/Node.js側の対応

Socket.io/Node.js側でやっている事は大きく下記になります。

  • CookieからLaravelの認証セッションの確認
  • 接続しているソケットのユーザーの特定
  • Redisからのイベントの伝播

これに必要になったコードは100行ちょっとでした。全貌は下記のとおりです。

require('dotenv').config({
  path: __dirname +'/.env'
});

const env = process.env;
const port = env.NODE_SERVER_PORT;

const _ = require('lodash');
const redis = require('ioredis');
const redisClient = new redis();
const redisBroadcast = new redis();
const cookie = require('cookie');
const crypto = require('crypto');
const PHPUnserialize = require('php-unserialize');

const server = require('http').createServer();
console.log('Server on Port : ' + port);
server.listen(port);
const io = require('socket.io')(server);

io.use(middlewareAuthCheck);

io.on('connection', (socket) => {
  socket.on('user', (user) => {
    user = JSON.parse(user);
    console.log('user', user);
    // mark with userId
    socket.userId = user.id;
  });

  socket.on('disconnect', () => {
    console.log('disconnect..');
  });
});

redisBroadcast.psubscribe('*', (err, count) => {
});

redisBroadcast.on('pmessage', (subscribed, channel, event) => {
  event = JSON.parse(event);
  console.log('pmessage', channel, event);

  const _sockets = _.toArray(io.sockets.connected);
  event.data.destinations.forEach((userId) => {
    const socket = _.find(_sockets, (_socket) => {
      if (+userId === +_socket.userId) {
        return true;
      }
    });
    if (socket) {
      console.log('emit', socket.id, userId);
      io.to(socket.id).emit('chat', event.data.message);
    }
  });
});

function middlewareAuthCheck(socket, next) {

  try{

    var _cookie = socket.request.headers.cookie;
    if(!_cookie){
      throw new Error('No cookie');
    }
    _cookie = cookie.parse(_cookie);
    if(!_cookie || !_cookie['laravel_session']){
      throw new Error('No valid cookie');
    }

    const sessionId = decryptCookie(_cookie['laravel_session']);
    console.log('sessionId', sessionId);

    redisClient.get('laravel:' + sessionId, (err, session) => {

      if (err) {
        next(err);
      } else if (session) {
        next();
      } else {
        next(new Error('No session in redis'));
      }

    });

  }catch(err){
    console.log('Skip this socket since an error occurred', err);
    next(err);
  }

};

function decryptCookie(cookie) {
  console.log('cookie', cookie);
  const parsedCookie = JSON.parse(new Buffer(cookie, 'base64'));

  const iv = new Buffer(parsedCookie.iv, 'base64');
  const value = new Buffer(parsedCookie.value, 'base64');

  var appKey = env.APP_KEY;

  if (/base64/.test(env.APP_KEY)) {
    const matches = /base64:(.+)/.exec(appKey);
    console.log('appKey', matches[1]);
    appKey = new Buffer(matches[1], 'base64').toString();
    console.log('appKey', appKey);
    // TODO: Error in crypto.createDecipheriv if appKey is encoded in base64
    // appKey seems to be binary
    // https://github.com/laravel/framework/commit/370ae34d41362c3adb61bc5304068fb68e626586
  }

  const decipher = crypto.createDecipheriv('aes-256-cbc', appKey, iv);
  const resultSerialized = Buffer.concat([
    decipher.update(value),
    decipher.final()
  ]);

  return PHPUnserialize.unserialize(resultSerialized);
};

大まかな構造は参考にした記事のままです。decryptCookieのところは自分ではお手上げな感じなのでサンプルが見つかって良かったです。感謝…。

実装してみた感想

モダンなアプリケーションでWebSocketsがほぼ必須になっていく中で、Rails5ではActionCableというWebSocketsの機能が提供されるようになりました。ActionCableはNode.jsは使わずたしかノンブロッキングのRuby実装であるEventMachineを内部で利用しています。そんな中でこのLaravelのBroadcasting EventsはピュアなPHPのソリューションではないまでもプラクティスを示すという意味でとても価値があるなと思いました。

実際PHPとJavaScriptという2つの言語を使うんですが、Laravelが良い意味で従来のPHPっぽくない事もあり、クロージャをばりばり使ってるのでJavaScriptのプログラミングモデルに似ていてあまり違和感なく実装できた気がします。仕組み的な面でもPHPでもNode.jsでもdotenvを使ったので環境設定も共用していますし。モダンPHPはNode.jsと相性良いかも?と思いました。

やり残したこと

サンプル作ってみたんですが、見直してたらまだセキュリティ的に穴がありました。そのうち対応しよう。あと、フロント触ったのでElixirにも近々対応しておきたいと思います。

4/15追記

上記の内容だとブラウザ側からユーザー情報をemitして受け取ったSocket.io/Node.js側でそのユーザー情報とsocketを紐付けて管理してたんですが、容易になりすましできてしまうので最初のブラウザからのconnect()で受け取った接続のCookieから取得したセッションに含まれるid(login_web_xxxで識別できるっぽい)を利用するように修正しました。

identify user id from session data

ただ、なぜかRedisから取得したセッションの情報は2回Unserializeしないとオブジェクトになりませんでした。なぜかLaravelがRedisに突っ込む時にSerializeしたオブジェクトを文字列として再度Serializeしてるみたいでした。

4/16追記

Laravel Elixirについて別の記事として書きました。

Laravel ElixirをWebpackと組み合わせて使う