Railsで複数グループ対応のチャットアプリを作ってみた


Websocket rails

おれは、node.jsなんて使わないぜ?

はい、嘘ですすいませんでした。 先日、websocketを使うチャットアプリを作る事になったのですが、今までRailsで作ってきたのでできる限りそのままrailsで作りたい、
というか楽したい! と思ってただけです。

今回、websocket-railsなるgemを使ってみた、これがかなり多機能で最初は味見程度で動かしてたんだけど、サクサクと実装が進んでいくので
「え、もうこれで一回リリースしちゃってよくねー?」
みたいな気分です。 ※まだ不安定なところ盛りだくさんなのは秘密

しかし便利すぎてもう離れられない。。
このgem作ったCoreTeamに地元の旨い信州そばを届けたいレベル。

基本的には以下の記事がかなり参考になりました。
IT土方のめもぶろ
特にjsを普段coffeeで書いてなかったから、マジで助かりました。

けど最終的にやりたいことを全部実現する為にはGitHubの本家のドキュメントを読みあさる必要があったのですが、とにかく私の英語力がなさ過ぎる事も痛感しました。
websocket-rails/websocket-rails · GitHub

英語力まじ必要やなぁ..

今日の目標

①Railsアプリでユーザアカウントを作成してるので、認証後にチャットルームへ行ってwebsocket通信でブラウザとサーバでメッセージの受け渡しをする。
②会話のログをサーバ側で保存したい。 保存する時サーバの時刻情報もご一緒に。

で、最終的にこんな感じに。
chromeとsafariで別アカウントでログインしたところ、なんとかうまくいった模様。
ブラウザをリスタートしても会話の履歴が表示されました。


太郎と花子の世界 と 太郎と花子の世界 と kitamura yohou ip 192 168 21 99 homery homery api ssh 129×52

websocket-railsのインストール+Apache/Passegerの場合

gemを入れる

gemなので、いつもどおりですね。

Gemfileに gem ‘websocket-rails’ を追記して bundle実行です。
今回、関連のgemが非常に多いようでして追加で入った連中をgrepしてみたら以下の子がつられて家族の一員となりました。

Installing daemons (1.1.9)
Installing eventmachine (1.0.3)
Installing em-synchrony (1.0.3)
Installing websocket-driver (0.3.2)
Installing faye-websocket (0.7.2)
Installing hiredis (0.5.1)
Installing redis (3.0.7)
Installing redis-objects (0.9.1)
Installing thin (1.6.2)
Installing websocket-rails (0.7.0)

gemで入れた時に初めて気がついたんですが、redis 必要なんだねw
次にwebsocket-railsに必要なファイルを以下こまんどでgenerate。

$ rails g websocket_rails:install
      create  config/events.rb
      create  config/initializers/websocket_rails.rb
      append  app/assets/javascripts/application.js

3つほどファイルが追加になりました。
それぞれのファイルは何者かというと大体以下のノリ(大体だよぉ)
・config/events.rb ←routes.rb的なやつ。
・config/initializers/websocket_raiils.rb ←websoket通信をうけるポートの指定とかそーゆー系
・app/assets/javascripts/application.js ←後述

websocket通信するjsのライブラリが追加されてるんだけど、本体の場所注意

先のwebsocket_rails:install でapplication.js がupdateされたんですが、中身を見ると
//= require websocket_rails/main
という行が追加されているだけで、こんなディレクトリどこにもないし。。ってすごい探しました。

結果的に、 rails c でコンソールはいって Rails.application.config.assets.paths を叩いてみたらば、websocke-railsがインストールされたgemのディレクトリがpathに追加されているようでして、アセットパイプライン実行時にコンパイルしているようでした。

実際にwebsocket-railsのlibに入ってたjs.coffeeがこれ

abstract_connection.js.coffee
channel.js.coffee
event.js.coffee
http_connection.js.coffee
main.js
websocket_connection.js.coffee
websocket_rails.js.coffee

いっぱいあるねぇ(*´ω`*)

あとredisさえ入ってればwebrickとかthinで動かす場合に関してはこれでいいらしい。
けど自分が開発してる環境がApache+passengerだったのでどうするねんー て本家みに行ったら
「スタンドアローンサーバーモードで実行せよ!」
とか書いてあった。 中二っぽい響でいいですね。

スタンドアローンサーバーモードで実行する

https://github.com/websocket-rails/websocket-rails/wiki/Standalone-Server-Mode

nginx+unicornの人とかも基本的には同じノリでいけるんじゃないかと思います。
確かめてないけど。

なお、スタンドアローンサーバーモードの場合はhttpをlisstenするポートとは別のwebsocketを受けるプロセスを立ち上げてRails本体とは非同期で動作する模様。
けどBaseControllerを継承していて(後述)loggerとかsessionとか当たり前のように使えるのが嬉しい。

とりあえずそのモードにするためにイニシャライザを修正

vi config/initializers/websocket_rails.rb
config.standalone = true
config.standalone_port = 3001

websokectてだいたい3001番がデファクトなんですかね・・?

イニシャライザを書き換えたら早速
rake websocket_rails:start_server
を叩くとwebsocket要のデーモンが生成されます。

$ ps aux | grep websocket
ore    30827  0.0  1.0 265944 64888 ?        Sl   20:19   0:03 thin server (0.0.0.0:3001) [websocket_rails]

あれ、thin で動くんだ。今知ったわw

iptablesを使ってる人はポートの開放もお忘れなく。
sudo /sbin/iptables -A INPUT -p tcp –dport 3001 -j ACCEPT
※centosの場合

基本形

まずクライアント側からチャット画面を呼びだしてコネクション張るところまで。

コントローラはchatview_controller.rbとします
今回は適当にviewだすだけ

# coding: utf-8

class ChatviewController < ApplicationController
  before_filter :login_required

  def index
    render 'chat/index'
  end
end

filterは好き好きに。

で、チャット画面はとりあえず以下。
とりあえずフォームビルダ使ってないけど許して。

  <head>
     <meta charset="UTF-8">
     <title>チャットしようぜ</title>
     <%= javascript_include_tag "application" %>
     <%= javascript_include_tag "chat" %>
   </head>
   <body>
     <h1>Chat Sample</h1>
     <div>ようこそ、<%= @current_user.user_name %>さん</div>

     <!-- jsに食わせたい情報などなど -->
     <div id="username" style="display:none;"><%= @current_user.user_name %></div>

     <!-- チャットのタイムラインとかテキストボックスとか -->
     <div id="timeline" style="margin:20px;">
       <div id="chat" data-uri="<%= request.host %>:3001/websocket"></div>
     </div>

     <hr/>
     <div>
      <form class="form-horizontal">
        <input type="text" class="form-control" id="msgbody" />
        <input type="button" class="btn btn-primary" id="send" value="送信"/>
      </form>
     </div>
   </body>

で、このhtmlからhostとportを拾ってきてjsに食わせるわけですが request.port は今回3001なのでそこは静的に。

jsはこう
vi app/assets/javascripts/chat.js.coffee

class @ChatClass
  constructor: (url, useWebsocket) ->
    # これがソケットのディスパッチャー
    @dispatcher = new WebSocketRails(url, useWebsocket)
    console.log(url)
    # イベントを監視
    @bindEvents()

  bindEvents: () =>
    # 送信ボタンが押されたらサーバへメッセージを送信
    $('#send').on 'click', @sendMessage
    # サーバーからnew_messageを受け取ったらreceiveMessageを実行
    @dispatcher.bind 'new_message', @receiveMessage

  sendMessage: (event) =>
    # サーバ側にsend_messageのイベントを送信
    # オブジェクトでデータを指定
    user_name = $('#username').text()
    msg_body = $('#msgbody').val()
    @dispatcher.trigger 'new_message', { name: user_name , body: msg_body }
    $('#msgbody').val('')

  receiveMessage: (message) =>
    console.log message
    # 受け取ったデータをappend
    $('#chat').append "#{message.name}「#{message.body}」<br/>"

$ ->
  window.chatClass = new ChatClass($('#chat').data('uri'), true)

さて、websocket通信を裁くコントローラは独立しているようでして。以下のアクションが基本になる。
vi app/controllers/chat_controller.rb

# coding: utf-8

class ChatController < WebsocketRails::BaseController

  def initialize_session
    logger.debug("initialize chat controller")
  end

  def connect_user
    logger.debug("connected user")
  end

  def new_message
    logger.debug("Call new_message : #{message}")
    broadcast_message :new_message, message

initialize_sessionは、「セッション」とか書いてあるので戸惑ったんですが、websocketサーバが最初に立ち上がった時に実行される模様。
後述のDBのインスタンス作成とかにしか使わない気がしますが、皆は何書いてるのかな。。

connect_userはクライアント側がブラウザでチャット画面に来て最初にjsから3001番にコネクションを張りに来た時実行される。
チャットのログをサーバ側で保存していればここでババーっと送れるね。

new_messageはevents.rbでルーティングされてるアクションです。
broadcast_message で、コネクションがある全ユーザに :new_message を送信 引数のmessageがメッセージ本体。

ブロードキャストしない場合は send_message で。
ではこのアクションに紐づけている events.rbはというと。。
vi config/events.rb

WebsocketRails::EventMap.describe do
  subscribe :client_connected, to: ChatController, with_method: :connect_user
  subscribe :new_message, to: ChatController, with_method: :new_message
end

subscribe の引数にjsが何送ってきたら、どのコントローラのアクションに紐付けるかと書かれています。
まぁroutes.rbと同じもんですね。

ちなみにchat_contoller.rb のデフォのログが log/websocket_rails.logに出力されます。
コネクション開始時やメッセージ送受信時のデバッグが捗る!! 

一応これで基本的に複数人でチャットができるようになりました。
けどチャットルーム対応したいっすね。

複数のチャットルームを動的に生成したい

websocket-rails ではコネクションの束を複数持てるようになっておるようじゃぞ。
マジでこれが最初どうやるのか全然わからんくてGitHubの過去のIssuesとかひたすら読み漁っては自分の英語力の非力さに涙を流し、
気がついたら嫌になって田町の焼き鳥屋でいっぱいひっかけてたなんて日が続きました。

まずはコントローラ
chat_controller.rb のメッセージを受け取るメソッドで、WebsocketRails[:room_name]のように指定するだけでOkでした。
くそうそんな簡単だったとは。。
なお、今回はjsからどの部屋向けかidで送ってもらって、それを拾ってそのidのルームに入ってる人にばらまく事にしました。

  def new_message
    gid = message[:gid]
    WebsocketRails["#{gid}"].trigger(:new_message, message)
  end

んで、js側ではどの束に送るのか? と、どの束のチャットを購読するのか? を定義する必要があったので以下のようにした。

  constructor: (url, useWebsocket) ->
    # これがソケットのディスパッチャー
    group_id = $('#group_id').text()
    @dispatcher = new WebSocketRails(url, useWebsocket)
    @channel = @dispatcher.subscribe(group_id)
    console.log(url)
    # イベントを監視
    @bindEvents()

  bindEvents: () =>
    # 送信ボタンが押されたらサーバへメッセージを送信
    $('#send').on 'click', @sendMessage
    # サーバーからnew_messageを受け取ったらreceiveMessageを実行
    @dispatcher.bind 'new_message', @receiveMessage
    @channel.bind 'new_message', @receiveMessage

  sendMessage: (event) =>
    # サーバ側にsend_messageのイベントを送信
    # オブジェクトでデータを指定
    user_name = $('#username').text()
    msg_body = $('#msgbody').val()
    group_id = $('#group_id').text()
    @dispatcher.trigger 'new_message', { name: user_name , body: msg_body , group_id: group_id}
    $('#msgbody').val('')

  receiveMessage: (message) =>
    console.log message
    # 受け取ったデータをappend
    $('#chat').append "#{message.name}  「#{message.body}」<br/>"

$ ->
  window.chatClass = new ChatClass($('#chat').data('uri'), true)

どのgroup向けか?のidはhtmlに埋め込んじまう事にした。 いや、適当でスマソ。
それよりも一番のキモはchannelという概念。

GitHubでいうこのページを読みませう。
Working with Channels

ようは、dispatcherを作成した後に
@channel = @dispatcher.subscribe(group_id)
とする事でチャットグループを購読する別のインスタンスを作成し、bindEvents: ()の中で
@channel.bind ‘new_message’, @receiveMessage
とすることで自分の開いたグループのチャットが先のControllerで作った
WebsocketRails[“#{gid}”].trigger(:new_message, message)

から飛んでくるメッセージを購読できるようです。

ここまででとりあえず複数のグループ分けされたチャットルームができました。

チャットのログをDBに保存してみる

だって途中から参加した人とか、しばらくブラウザ閉じてた人とか・・(略

なおLINEはHbaseを使用している模様。 けど今回そんな大量のトラフィック見込んでないですし、websocket-railsをインスコした時に一緒に入れたredisで一旦は良しとします。

redisをrubyで操作する方法は今回は割愛。 メッセージのタイムスタンプをサーバ側で追加して、リストで保存する事にします。
クライアント側がブラウザを開いた時、最大100件返すようにしてみたところ、コントローラがこんな感じになりました。

# coding: utf-8
require 'redis'

class ChatController < WebsocketRails::BaseController

  def initialize_session
    logger.info("initialize chat controller")
    @redis = Redis.new(:host => "127.0.0.1", :port => 6379)
    controller_store[:redis] = @redis
  end

  def connect_user
    logger.debug("connected user")
    gid = session[:group_id]  # これセッション変数から拾ってきてるけど、嫌なので他の方法を知りたい。
    talks = controller_store[:redis].lrange gid, 0,100
    talks.each do |message|
      msg = ActiveSupport::HashWithIndifferentAccess.new(eval(message))
      send_message :new_message, obj
    end
  end

  def new_message
    #logger.debug("Call new_message : #{message}")
    gid = message[:group_id]
    message[:time] = Time.now.strftime("%H時%M分").to_s

    #controller_store[:redis].del gid   消す時はこれ。(ただのメモ)
    controller_store[:redis].rpush gid, message
  end

end

ポイントは2つ

Redisのインスタンスを共有する方法に苦戦した

railsの仕組みをよくわかってる人はなんてことはないと思うんですが、今回Redisのインスタンスて一体どうやって他のアクションに渡すねん。。 と苦戦していたのですが、websocket-railisにはDataStoreなる機能があってコントローラインスタンスが存在してる限りデータが残るらしい。
えと、、、 これに、、つっこんでいいですか?

Using the DataStore

The DataStore provides a convenient way to temporarily persist information between execution of events. Since every event is executed within a new instance of the controller class, instance variables set while processing an action will be lost after the action finishes executing.

There are two different DataStore classes that you can use:

The DataStore::Controller class is unique for every controller. You can use it similar to how you would use instance variables within a plain ruby class. The values set within the controller store will be persisted between events. The controller store can be accessed within your controller using the #controller_store method.

The DataStore::Connection class is unique for every active connection. You can use it similar to the Rails session store. The connection data store can be accessed within your controller using the #connection_store method.

というわけでイニシャライズするときにnewして controller_storeに突っ込んでみたところ普通に他のメソッドで呼び出せたからいいかな。
けどマジでなんかキモいから、どなたかもっといい方法知ってる方教えてください(´;ω;`)

チャットメッセージの保存と読み込みに苦戦した

今回クライアントから飛んでくるメッセージはActiveSupportのHashWithIndifferentAccessのインスタンスになっていたのですが、Redisに何も考えないで突っ込むと文字列に変換されるので、読み込んでクライアントに返却する時苦戦しました。
けど、
どうしようかな( ´∀`)・・・
どうしようかなー!! ・・(*_*;
と、悩んだところやっぱり何も考えずにそのまま保存する事にしました。

保存した文字列を再びハッシュにしてから変換しちまえ って事でDBからの呼び出し時に

msg = ActiveSupport::HashWithIndifferentAccess.new(eval(message))

としたところ今ん所うまくいってるからいいや。
ただ、redisに保存する時のエスケープ処理とかやってないからか、たまに変なエラーでる。
要改修

まとめ

・英語の情報しか転がっていない箇所がめちゃめちゃ多くて、久しぶりに疲弊した。

・Redisが結構面白い。 リストとか特に。

・これを今度はスマホアプリとwebsocketで対話させたいけど、IF紐解くのめんどくせー

・TCPdumpでwebsocketの中身見ようとしたら全然みれないんだけど、なんででしたっけ?
最初コネクション張るところはHTTPリクエスト飛んでてうまくキャプチャできたんだけども。。

・古い会話を削除するバッチを作らないと( ゚д゚)ハッ!

・通信環境が悪くてコネクション切れた時にjsが再接続しにいくようにしたい。(だ、、だれか。。)

2014-04-20 | Posted in Rails2 Comments » 


関連記事

コメント2件

 chiku_ | 2015.07.14 20:43

railsでwebsocketに挑戦しようとしており、大変参考なりました。ありがとうございます。

> 通信環境が悪くてコネクション切れた時にjsが再接続しにいくようにしたい。
上記ですが、クライアントのjs側で定期的にダミー送信しないとブラウザがwebsocketをブチ切ります。参考までに・・。

 altarfの管理人 | 2015.07.14 23:23

ありがとうございます!
> 上記ですが、クライアントのjs側で定期的にダミー送信しないとブラウザがwebsocketをブチ切ります。

あー、そうなんですね。 なんでもいいから定期的にパケット投げないといけないんですねー
勉強になります! ありがとうございます〜

Comment





Comment



*