hiyoko-programingの日記

プログラミングを勉強したてのひよっ子。   エンジニア目指して勉強中。

自動更新機能を実装

チャット画面が自動で更新されるようにする。

https://tech-master.s3.amazonaws.com/uploads/curriculums//330ddabd090b19276af2ad2b46b821e8.gif

左のユーザーが投稿すると、右側のユーザーのブラウザで操作することなくメッセージの追加が行われている。

なぜ自動更新機能が必要か

ここまで作ってきたChatSpaceで 、自分が投稿したメッセージは非同期に投稿することができる。

しかし、同じグループに居る人が見ている別のブラウザには、このメッセージが反映されていない。

これはなぜなのか??

https://tech-master.s3.amazonaws.com/uploads/curriculums//aeb0b8a0f4fc2c468cad474920ac498a.png

すでに実装した非同期での投稿機能の動きを改めて確認。

①ユーザーが投稿を行う
②messagesコントローラーのcreateアクションでデータベースへの保存が行われる
③保存と同時に投稿内容のJSONが作成され、即座にレスポンスとして返される
④返されたJSONを元に、ビューへの追加が行われる

このような動作になっている。

そのため「投稿者以外のユーザー」にとっては、以下の仕様になってしまっている。
・リアルタイムでデータは反映しない
・サーバーにはデータが保存されているので、リロードすれば反映する

これから目指したいゴールは、LINEなどのように、相手からのメッセージが自動でブラウザに反映されることである。

こうすることで、いちいちリロードしなくても相手からのメッセージが表示されるようになり、快適にチャットができるようになる。

自動更新機能を実装

メッセージ画面を自動的に更新することで、自分以外のユーザーが送信したメッセージも自動で追加表示されるようにする。

自動更新とはどのような機能か確認

実装するのは、あるユーザーが「メッセージ3」を投稿したら他のユーザーのブラウザにリロードなしでそのメッセージが表示されるようにすること。

https://tech-master.s3.amazonaws.com/uploads/curriculums//0f2cf00bb8f637cd461e160c7a175e0b.png

図のように、常にサーバーへの問い合わせをして、追加されたメッセージがあればそれを受け取ることで実現する。

また、機能面ではAjax、コントローラー、モデル、jBuilderの連携によって実装していく。

https://tech-master.s3.amazonaws.com/uploads/curriculums//1997e88c9140979fa2c4355db8d4344a.png

上の図は、これから実装する機能の大まかな流れ。

どうすれば自動更新できるか考える

機能を確認したところで、次にどう実装すればよいか?

以下の機能があれば自動更新ができそうである。

①何秒かおきに、JavaScriptを使ってブラウザに表示されているメッセージのうち最も新しいもののidをリクエストとして送る


Railsのコントローラのアクションにてデータベースに保存されている最新のメッセージのidと①のidを比較し、①のidよりも大きいidを持つメッセージたちをレスポンスする


JavaScriptを使って、レスポンスに含まれるメッセージたちをメッセージ一覧の最後に追加する

 

①表示されているメッセージのidが確認できるようにする

jQueryを使って表示されている最新メッセージのidを取得できるようにする。そのためには、messagesテーブルのidを、HTMLの中に埋め込む必要がある。

その時に利用できるのがカスタムデータ属性である。

カスタムデータ属性

カスタムデータ属性とは、HTMLタグの属性の1種。

属性とはなんだったか?

1
<p class="first-message">

例えば、上記の例はpタグにclass属性を設定している。このように、あるタグを使う時に情報を付加するために使用するもの。

属性として設定できる項目はタグごとに決まっているが、自由に追加することができる属性がカスタム属性である。

1
<p class="first-message" data-messege-id=120>

カスタムデータ属性を使うときは、属性名を「data-」で始まる名称にする。

上記のように記述すれば、「message-id」という名前のカスタムデータ属性を設定できたことになる。

このようにdata-任意の名前=任意の値と書き、カスタムデータ属性を設定しておくことで、JavaScriptから簡単に値を取得できる。
jQueryでは、取得したDOMに対しdataというメソッドを利用することで、カスタムデータ属性の値を取得可能である。

カスタムデータ属性の取得の例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 <!DOCTYPE html>
 <html>
   <head>
     <meta charset="utf-8" />
     <script src="http://code.jquery.com/jquery-1.4.3.min.js"></script>
   </head>
   <body>
     <!-- カスタムデータ属性 -->
     <section id="blog" data-author="Taro" data-create-date="2013-04-10">
       <h1>Hello World!</h1>
       <p>This is a sample text.</p>
     </section>
     <script>
       var blog = $("#blog");
      //jQueryでカスタムデータ属性の値を取得
       alert("author : " + blog.data('author'));
       alert("create date : " + blog.data('create-date'));
     </script>
   </body>
 </html>

 メッセージのidをカスタムデータ属性として追加

今まで作ったChatSpaceでは、メッセージを表示する際カスタムデータ属性は追加されていない。

コードを追加して、メッセージ1つ1つのHTMLに、カスタムデータ属性としてそのメッセージのidが含まれるよう修正する。なお、ビューの作成にはhamlを使用しているので、hamlの場合どうすればカスタムデータ属性を追加できるのか。

hamlの場合、カスタムデータ属性をつけるには以下のように記述する。

example.haml
1
2
3
%div{data: {message: {id: '1'}}}
# 上記の記述で、以下のようにカスタムデータ属性が反映される
# → <div data-message-id='1'>

編集するファイル

  • _message.html.haml
_message.html.haml
1
2
.message{data: {message: {id: message.id}}}
# 以下省略

推奨検索ワード

  • haml カスタムデータ属性」

 カスタムデータ属性が追加できたか確認

上の作業でコードが追加できたら、正しく動作するか確認する。

https://tech-master.s3.amazonaws.com/uploads/curriculums//9cbdcf7581d426f06e9e83ba0ca4bc99.png

ブラウザの検証ツールを使用して、HTMLタグの中に「data-messege-id」のように属性が追加されていることを確認。

見当たらない場合は、コードの記述が正しく行えていないので見直す。

②新規投稿を取得できるようにする

表示されているメッセージよりも新しい投稿があるのか確認する機能を追加する。確認するためには、以下の2つの機能が必要である。

①コントローラーに、新規投稿を確認するアクションがあること
②①のアクションを呼び出す仕組みがあること

 

新規メッセージ確認用のアクションを追加する

新規メッセージがあるか確認し、追加されている場合はそのデータを返すアクションを作成する。

このようなリクエストに対してJSONなどのデータを返すアクションはWebAPIという仕組みで実装することが一般的である。

 WebAPIとは

WebAPIはAPIの一種である。APIとは何か?

APIは、Application Programming Interfaceの略称で、アプリケーション開発者が外部に向けてアプリケーションの機能の一部を公開する仕組みである。

例えばTwitterAPIを使用すれば、Twitterアプリを使うことなくつぶやきの情報を取得するなどの機能を使うことができる。

WebAPIは、HTTPやHTTPS通信を通じて利用するAPIのこと。例えば天気情報を公開しているAPIであれば、ブラウザのURL欄に必要なアドレス等を入力すればデータを取得することができる(APIの使い方は種類によって様々)。

最近のWebAPIでは、返ってくるデータ形式JSONが広く使われている。

 apiディレクトリおよびコントローラを作成

APIとして機能するコントローラーを作成していく。

①controllersディレクトリ直下にapiディレクトリを作成。
②そのフォルダの中にmessages_controller.rbというファイルを新規作成。既存のmessages_controller.rbと同じ名前だが、別に作成する必要があることに注意する。
③新規作成したapi/messages_controller.rbの中身を以下のように編集。

app/controllers/api/messages_controller.rb
1
2
3
4
class Api::MessagesController < ApplicationController
  def index
  end
end

1行目のクラス定義部分で、Rubyのクラス名は、このように::で繋げて装飾することができる。これを、名前空間またはnamespaceという。

名前空間(namespace)

名前空間をつけることにより、同様のクラス名で名付けたクラスを作ってもそれらを区別することができる。

今回の場合はcontrollers/messages_controller.rbcontrollers/api/messages_controller.rbが存在するが、ディレクトリを分けているおかげで区別できる。

ただし、プログラムがクラスを判別する際はどのディレクトリに入っているかでの判別はできないため、名前空間を利用するルールになっている。こうすることで、Railsは間違えることなく2つのコントローラを区別するようプログラムされている。

https://tech-master.s3.amazonaws.com/uploads/curriculums//404be5aa7512e8dc1201044637983816.png

イメージとしては、同じ苗字の人がいたとしても、部署名などをつければ該当者が一人になることと似ている。

 indexアクションを完成させる

indexアクションの中には、新規で投稿されたメッセージのみをDBから取得する処理を書く。

ビューに表示されている最新メッセージのidが送られてくる(後ほど実装する)ので、そのidより新しい投稿があるかをチェックする。whereメソッドを使ってidを検索条件にすると良い。

app/controllers/api/messages_controller.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Api::MessagesController < ApplicationController
  def index
    # ルーティングでの設定によりparamsの中にgroup_idというキーでグループのidが入るので、これを元にDBからグループを取得する
    group = Group.find(params[:group_id])
    # ajaxで送られてくる最後のメッセージのid番号を変数に代入
    last_message_id = params[:id].to_i
    # 取得したグループでのメッセージ達から、idがlast_message_idよりも新しい(大きい)メッセージ達のみを取得
    @messages = group.messages.includes(:user).where("id > ?", last_message_id)
  end
end

アクションを呼び出せるようルーティングを追加

アクションを呼び出すためのルーティングを設定する。

namespaceを使ったコントローラファイルをルーティングから指定する際は、今までとは違う書き方をする必要がある。

 namespaceを利用したコントローラーのルーティング設定

1
namespace :ディレクトリ名 do ~ end

controllersディレクトリ直下にさらにディレクトリを作成した場合、ルーティングをそれに対応させるための書き方。

 ルーティングを追加

routes.rbを以下のように編集。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Rails.application.routes.draw do
  devise_for :users
  root 'groups#index'
  resources :users, only: [:index, :edit, :update]
  resources :groups, only: [:new, :create, :edit, :update] do
    resources :messages, only: [:index, :create]
#追加
    namespace :api do
      resources :messages, only: :index, defaults: { format: 'json' }
    end
  end
end

このようにnamespace :ディレクトリ名 do ~ endと囲む形でルーティングを記述すると、そのディレクトリ内のコントローラのアクションを指定できる。
ターミナルからrails routesコマンドなどでルーティングを確認すると、/groups/:id/api/messagesというパスでリクエストを受け付け、api/messages_controller.rbのindexアクションが動くようになっている。

また、defaultsオプションを利用して、このルーティングが来たらjson形式でレスポンスするよう指定している。

routes.rbの書き方については他にもオプションがある。

参考記事

Railsのルーティングを極める (後編)

https://techracho.bpsinc.jp/baba/2014_03_03/15619
Railsのルーティング

https://railsguides.jp/routing.html

③投稿内容をレスポンスできるようにする

新規投稿があった場合は、投稿の内容をレスポンスできるようにする。

 json形式でレスポンスするためのファイルを作成

① viewsフォルダに「api」フォルダを作成
apiフォルダに「messages」フォルダを作成
③messagesフォルダ内に「index.json.jbuilder」を作成
④index.json.jbuilderファイルを編集

app/views/api/messages/index.json.jbuilder
1
2
3
4
5
6
7
json.array! @messages do |message|
  json.content message.content
  json.image message.image.url
  json.created_at message.created_at.strftime("%Y年%m月%d日 %H時%M分")
  json.user_name message.user.name
  json.id message.id
end

メッセージは複数投稿されている可能性があるため、配列形式でarray!メソッドを使用してJSONを作成する。

新規作成時のjBuilderファイルも修正が必要。

 jBuilderの設定を行う

create.json.jbuilderを以下のように編集

views/messages/create.json.jbuilder
1
2
3
4
5
6
json.content    @message.content
json.image      @message.image.url
json.created_at @message.created_at.strftime("%Y年%m月%d日 %H時%M分")
json.user_name @message.user.name
#idもデータとして渡す
json.id @message.id

④取得した投稿データを表示できるようにする

作成したアクションを動かすリクエストを実装する。

reloadMessagesという名前で関数を作成して、あとでこのメソッドを呼び出す想定で作成する。

 最新メッセージのidを取得できるようにする

新規投稿だけを取得できるようにするには、今表示されている最新メッセージのidを取得する必要がある。

最初に、このidを取得できるか実験的にコードを記述する。メソッド内に書いてしまうと、そのメソッドが呼び出さない限り実行されないので、簡単に確認できるようにmessage.jsの冒頭に下記の2、3行目を追加する。

message.js
1
2
3
4
5
6
$(function(){
  last_message_id = $('.message:last').data("message-id");
  console.log(last_message_id);

  省略
})

$('.message:last')

jQueryのオブジェクトの指定方法の1つに、:lastがある。

.messageというクラスがつけられた全てのノードのうち一番最後のノード、という意味になる。
1つ1つのメッセージが表示されているdivには.messageというクラスがついており、最新のメッセージは一番下、つまりページの中でも最後のノードということになる。これを利用して、一番最後のメッセージのidを取得している。

記述を行なったら、ブラウザの検証ツールで確認。データベース上のidと同じidが表示されていることも確認する。

message.jsを編集する

jQueryからAPIを呼び出せるようにする。

APIを呼ぶには正しいURLにリクエストを送信する必要がある。

まず「どのURLをリクエストしたいのか」を確認。

今回リクエストしたいのは/groups/id番号/api/messagesである。

どうすれば上記のURLを文字列で作成できるのか?

考え方のヒント

ajax関数のurlに何も指定しなかった場合、リクエストのURLは現在ブラウザに表示されているパスと同様になる。つまり今回の場合は、groups/id番号となる。

これに対して、urlに文字列で値を指定すると、パスを指定することができる。相対パスで書くことで、自動的に現在ブラウザに表示されているURLの後に繋がる形になる。

例えば現在のURLがgroups/3/messagesとして、urlに"hoge"と指定すればリクエストのURLはgroups/3/hogeとなる。
この法則を考えつつ、文字列相対パスとなるようURLを指定する。

 1つ前の作業で追加した2行のうち、1行目( last_message_id = ※※※の部分)を下記の6行目の位置に移動する

message.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$(function() {
//省略

  var reloadMessages = function() {
    //カスタムデータ属性を利用し、ブラウザに表示されている最新メッセージのidを取得
    last_message_id = $('.message:last').data("message-id");
    $.ajax({
      //ルーティングで設定した通り/groups/id番号/api/messagesとなるよう文字列を書く
      url: "api/messages",
      //ルーティングで設定した通りhttpメソッドをgetに指定
      type: 'get',
      dataType: 'json',
      //dataオプションでリクエストに値を含める
      data: {id: last_message_id}
    })
    .done(function(messages) {
      console.log('success');
    })
    .fail(function() {
      console.log('error');
    });
  };
});

取得した最新のメッセージをブラウザのメッセージ一覧に追加する

これまでに作っているbuildHTMLメソッドを編集して、非同期で追加されるメッセージのHTMLにもdata-messege-idという名前のカスタムデータ属性をつける。こうすることで、非同期で追加されるメッセージにもidを与えることができる。

 message.jsを編集しよう

下記のお手本は冗長に記述している。リファクタリングを意識して、通化できる部分がないかを考えながらコードを書く。

message.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//省略
  var buildHTML = function(message) {
    if (message.content && message.image) {
      //data-idが反映されるようにしている
      var html = `<div class="message" data-message-id=` + message.id + `>` +
        `<div class="upper-message">` +
          `<div class="upper-message__user-name">` +
            message.user_name +
          `</div>` +
          `<div class="upper-message__date">` +
            message.created_at +
          `</div>` +
        `</div>` +
        `<div class="lower-message">` +
          `<p class="lower-message__content">` +
            message.content +
          `</p>` +
          `<img src="` + message.image + `" class="lower-message__image" >` +
        `</div>` +
      `</div>`
    } else if (message.content) {
      //同様に、data-idが反映されるようにしている
      var html = `<div class="message" data-message-id=` + message.id + `>` +
        `<div class="upper-message">` +
          `<div class="upper-message__user-name">` +
            message.user_name +
          `</div>` +
          `<div class="upper-message__date">` +
            message.created_at +
          `</div>` +
        `</div>` +
        `<div class="lower-message">` +
          `<p class="lower-message__content">` +
            message.content +
          `</p>` +
        `</div>` +
      `</div>`
    } else if (message.image) {
      //同様に、data-idが反映されるようにしている
      var html = `<div class="message" data-message-id=` + message.id + `>` +
        `<div class="upper-message">` +
          `<div class="upper-message__user-name">` +
            message.user_name +
          `</div>` +
          `<div class="upper-message__date">` +
            message.created_at +
          `</div>` +
        `</div>` +
        `<div class="lower-message">` +
          `<img src="` + message.image + `" class="lower-message__image" >` +
        `</div>` +
      `</div>`
    };
    return html;
  };
//省略

続いて、reloadMessages関数からもHTMLを組み立てる関数を呼ぶようにする。

message.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$(function() {
//省略

  var reloadMessages = function() {
    //カスタムデータ属性を利用し、ブラウザに表示されている最新メッセージのidを取得
    last_message_id = $('.message:last').data("message-id");
    $.ajax({
      //ルーティングで設定した通りのURLを指定
      url: "api/messages",
      //ルーティングで設定した通りhttpメソッドをgetに指定
      type: 'get',
      dataType: 'json',
      //dataオプションでリクエストに値を含める
      data: {id: last_message_id}
    })
    .done(function(messages) {
      //追加するHTMLの入れ物を作る
      var insertHTML = '';
      //配列messagesの中身一つ一つを取り出し、HTMLに変換したものを入れ物に足し合わせる
      $.each(messages, function(i, message) {
        insertHTML += buildHTML(message)
      });
      //メッセージが入ったHTMLに、入れ物ごと追加
      $('.messages').append(insertHTML);
    })
    .fail(function() {
      console.log('error');
    });
  };
});

数秒ごとにリクエストするように実装する

jQueryにおいて、一定時間が経過するごとに処理を実行することができる関数がsetInterval()関数である。

setInterval()関数

第一引数に動かしたい関数名を、第二引数に動かす間隔をミリ秒単位で渡すことができる。
今回は、reloadMessages関数を数秒おきに呼び出す。

 

※引数で渡している7000という数字は、7秒という意味になる。500にすると、0.5秒である。
こちらを小さくしすぎると、更新の際に二重でメッセージが表示されてしまう場合があるため、必ず7000に設定する。

1
2
3
4
5
$(function() {
//途中省略
//$(function(){});の閉じタグの直上(処理の最後)に以下のように追記
  setInterval(reloadMessages, 7000);
});

機能をブラッシュアップする

ここまでで自動更新の基本的な機能の実装ができた。
ここからは、細かい機能面でのブラッシュアップを行なっていく。

メッセージを取得したら画面がスクロールするようにする

今のアプリの動作では、メッセージの数が多い場合、追加されたメッセージを読むために手動でスクロールする必要がある。自動的に、追加されたメッセージが読めるよう機能を追加する。

 

スクロールを行うにはjQueryanimate関数を利用する。以下のように編集する。
コードを追加する場所は、非同期通信が成功した場合行う処理の最後がよい。
また更新するメッセージがなかった場合は.doneの後の処理が動かないよう、条件分岐を追加している。さらに、フォームの中身を空にして、フォームを再度送信できるようにする処理も追記している。

message.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$(function() {
//省略

  var reloadMessages = function() {
    //カスタムデータ属性を利用し、ブラウザに表示されている最新メッセージのidを取得
    last_message_id = $('.message:last').data("message-id");
    $.ajax({
      //ルーティングで設定した通りのURLを指定
      url: "api/messages",
      //ルーティングで設定した通りhttpメソッドをgetに指定
      type: 'get',
      dataType: 'json',
      //dataオプションでリクエストに値を含める
      data: {id: last_message_id}
    })
    .done(function(messages) {
      if (messages.length !== 0) {
        //追加するHTMLの入れ物を作る
        var insertHTML = '';
        //配列messagesの中身一つ一つを取り出し、HTMLに変換したものを入れ物に足し合わせる
        $.each(messages, function(i, message) {
          insertHTML += buildHTML(message)
        });
        //メッセージが入ったHTMLに、入れ物ごと追加
        $('.messages').append(insertHTML);
        $('.messages').animate({ scrollTop: $('.messages')[0].scrollHeight});
      }
    })
    .fail(function() {
      console.log('error');
    });
  };
});

自動更新が必要ない画面では行わないようにする

jQueryは今のところ全てのページにて発火するため、どの画面を見ていても自動更新処理が行われる。このままでは、メッセージ更新を行わないページにおいてエラーが発生したり、無駄なトラフィックが発生してしまう。

「グループのメッセージ一覧ページ」を表示している時だけ自動更新が行われるようにコードを追加する。jQuery正規表現にまつわるメソッドである、.matchを利用する。

match

JavaScriptの文字列が利用できるメソッド。引数に正規表現を取り、メソッドを利用した文字列にその正規表現とマッチする部分があれば、それを含む配列を返り値とする。

example.js
1
2
3
var str = "hogefuga"
str.match(/hoge/);
// → ["hoge", index: 1, input: "ghogefuga", groups: undefined]]

返り値の値に含まれる他の情報は、一旦無視してしまって構わない。
マッチする部分がない場合、返り値はnullになる。そのため、自動更新を行うべきURLである場合のみ、という条件分岐を作ることができる。
それでは、自動更新のメソッドを呼び出している部分を以下のように編集する。

1
2
3
4
5
6
7
$(function() {
//途中省略
//$(function(){});の閉じタグの直上(処理の最後)に以下のように追記
  if (document.location.href.match(/\/groups\/\d+\/messages/)) {
    setInterval(reloadMessages, 7000);
  }
});

matchメソッドの引数として書いている

/\/groups\/\d+\/messages/の部分が正規表現

正規表現は基本的には//で囲んだ部分になるが、/自体も正規表現に含めたい場合、直前に\(バックスラッシュ)を付ける。

また、\+d\の部分は、「桁無制限の数値」という意味になる。具体的には、dが0 ~ 9までの数字のどれかを表し、+は+のついた文字が何文字でもマッチする、という特殊な意味を持つ。

これで、URLにgroups/数字/messagesという部分があるページでない限り、reloadMessagesメソッドが動くことはない。