あっかぎのページ

時間ごとのtweet頻度を調べるアプリを作りました。

20160201_2

時間ごとのtweet頻度を調べるアプリを作りました。

時間ごとのtweet頻度を調べる

時間ごとにどれだけtweetしているかを表示するWebアプリを作りました。

『いつtwitter?』は@twitter名を入力して『Check』を押すと、そのユーザーのtweet頻度を時間ごとに表示します。直近の200ツイートを分析してグラフ化します。例えば、『有吉弘行』さん、『地震速報』、『名言・格言・恋言』を見るとこんな感じです。

人間の場合は生活リズムがわかったりしますし、地震速報やbotは不定期だったり逆に定期でツイートするのがわかって面白いですね。ツイッターのアカウントを持っている人は試してみてはいかがでしょうか?

ここからが本題

で、ここからが本題です。技術的なお話のメモを残しておきたいと思います。

『いつtwitter?』には次の技術要素を使っています。

  • Twitter gem
  • Sinatra
  • Ajax
  • unicorn
  • nginx

ベースにはruby言語を使っています。Twitter gemを使ってtwitterのデータ収集をして、それをWeb表示するためにSinatraを利用しています。今回はAjaxを使ってtwitter名をクリックされるごとに表示を切り替えています。また、サーバー要素としてnginx上にunicornを使ってWeb動作させています。

基本的には次のページのアイデアを利用させていただき、それをWeb動作させた感じになります。

Twitter gemに関しては上のページが参考になりました。自分だけのWebアプリでよければSinatraを使えば30分くらいで簡単にWeb動作できるのでおすすめですね(単一コードで100行くらいです)。今回はさくらVPSの公開サーバーにSinatraアプリを動作させることを目的にしましたので、nginxとunicornに関してもいろいろ設定しました。

これらの技術要素を使って、Railsを使わなくていい小規模なWebサイト・アプリのWeb公開ができるようになりました。ほとんど同じやり方でRailsでもWeb公開できると思います。

Sinatra単一コード版

Sinatraを使った単一コードで100行くらいのバージョンで、まずはアプリの挙動を押さえておきます。その前に、今回のアプリはgithubで公開しています。

その中の『single_code』のブランチに単一コード版としてapp.rbがあります(ページ最下段におまけとしてそのままのコードを付けておきます)。大きくは次のような枠組みになっています。

require ...

Dotenv.load

class App
...
end

get '/' do
...
end

post '/' do
...
end

__END__

@@include
<!DOCTYPE html>
... html記述
<script>
... ajax関連
</script>
</html>

最初のrequire行で必要なgemを読み込みます。ここでtwitterやsinatraなどのgemを読み込んでいます。

Dotenv.load

これは.envという設定ファイルに例えば次のように記述しておくと

ID=id1234
PASSWORD=pw1234

スクリプト内で次のような使い方で環境変数として利用できるようになります。

require 'dotenv'

Dotenv.load

puts ENV['ID']
puts ENV['PASSWORD']

.envの権限を600にしたり、gitignoreで除外しておくことでソースコードに直接ユーザーIDやパスワードなどを記述しなくてよくなります。今回はこのdotenvを使って.envファイルにtwitter APIのアクセスキー関連を設定しておきました。

class App
...
end

twitterのタイムラインを取得する動作をAppクラスとしてまとめました。基本的な動作は参考サイトとほぼ同じようになっています。initializeでtwitter APIへ接続して、get_tweet_countsでtwitter名の直近200件のタイムラインを取得します。返ってくるのはtweet時間を配列のインデックスとしたヒストグラムです。

make_resultでは結果内容のhtmlを生成するようにしました。当初はjQueryでhtml内容を変更していましたが、せっかくなのでhtml生成もrubyに任せてることにしました。

get '/' do
...
end

post '/' do
...
end

Sinatraを利用してWeb動作を実装します。get ‘/’ do … endで普通にアクセスされたときに返す内容を記述します。twitter名をurlの変数に取り入れて表示する方法もありましたが、今回はAjaxをテストしたかったので上記のようになっています。post行はAjaxでリクエストされたとき用の記述で、結果のhtmlを返すようにしています。jsonデータなどを返すだけにしてクライアント側(ブラウザ側)で表示切替をする方がサーバー負荷的にはやさしいですが、ここではサーバー側で生成したhtmlを返すようにしています。

__END__

@@include
<!DOCTYPE html>
... html記述
<script>
... ajax関連
</script>
</html>

__END__ @@includeとすることで同じソースの中に別ファイルのように内容を記述できます。中身自体は通常のhtmlで、表示する内容を司ります。Bootstrapで最低限の見た目を作って、最小限度のhtmlにとどめました(と言ってもコードの半分以上を占めています)。

htmlのポイントはAjax部分で

var request = $.ajax({
  type: "POST",
  url: "/",
  data: { screen_name: screen_name }
});

request.done(){
...
}).fail({
...
});

この記述で先ほどのpost “/” do … end部にpost形式でtwitter名(screen_name)を付けてリクエストします。サーバー側でこれに対応したリクエストをきちんと返してきたらdone部を実行して、失敗すればfail部を実行します。単一コードなら特に問題なかったのですが、nginxとunicornでサブディレクトリの下で公開しようとしたときに、このurlのリクエストまわりでいろいろとトラブルがありました。

$ ruby app.rb

できたコードを実行してブラウザ表示されればokです。

Sinatraをunicorn on nginx

さきほどの単一コード版を拡張して公開されたWebサーバーで動作させます。unicornはRailsやSinatraなどをサーバー動作させるツールですが、nginx上で動作させることで複数のリクエストをさばけるようにします。

詳しくはSinatraの公式ページにやり方があります。

開発はRaspberry Pi上で行っていましたが、nginxの導入についてはRaspberyy Piは1つ目、さくらVPSや一般のLinux系は2つ目を参考に設定していただければと思います。

1点メモですが、Raspberry Piでnginxの起動に失敗して次のようなエラーが出ました。

bind() to 0.0.0.0:80 failed (98: Address already in use)

これはIPv6絡みのエラーのようで/etc/nginx/site-available/defaultファイルのlisten [::]:80 default_server;行をコメントアウトすることで回避できました。

...
server {
  listen 80 default_server;
# listen [::]:80 default_server;
...

次にunicornの設定です。下記のようなファイルをunicorn.rbとして保存します。

@dir = "/path/to/app/"

worker_processes 2
working_directory @dir

timeout 30

listen "#{@dir}tmp/sockets/unicorn.sock", :backlog => 64

pid "#{@dir}tmp/pids/unicorn.pid"

stderr_path "#{@dir}log/unicorn.stderr.log"
stdout_path "#{@dir}log/unicorn.stdout.log"

アプリの場所やlogの出力などを設定します。続けてそれぞれ設定したディレクトリーを作っておきます。

$ mkdir tmp
$ mkdir tmp/sockets
$ mkdir tmp/pids
$ mkdir log

そして、先ほどの単一コードをいくつかのファイルに分割してunicornで動作するようにします。

$ mkdir public
$ mkdiv views
$ cp app.rb views/index.erb
$ vi views/index.erb
$ vi app.rb

単一ファイルのhtml部をindex.erbとして別ファイルに分割します。index.erbは以下を残して他を削除、app.rbは__END__以下を削除します。

さらにGemfileに関連するgemをまとめておいて動作環境を構築しておきます。

$ bundle init
$ vi Gemfile

Gemfileの中身は次のようになります。

# A sample Gemfile
source "https://rubygems.org"

gem 'twitter'
gem 'dotenv'
gem 'sinatra'
gem 'rerun'
gem 'unicorn'

rerunはsinatraの編集時のオートリロード用のgemで、下記のようにコマンド指定することで編集ファイルを自動チェックして、ブラウザアクセス時にオートリロードします。

$ bundle exec rerun 'ruby app.rb'

ここでこれらのgemをインストールしておきます。

$ bundle install

次にRack用(unicorn用)の設定ファイルを作っておきます。

$ vi config.ru

起動用のconfig.ruを作成して次のような内容にします(サブディレクトリ名をアプリの場所とする場合)。

require 'sinatra'
require './app.rb'

map '/tweet_rhythm' do
  run Sinatra::Application
end

ここでポイントなのがmap行です。run Sinatra::Applicationだけだと、urlのリクエストにサブディレクトリ名が含まれてしまいます。ところが、sinatraのgetやpost行では’/’という形になっているので、リクエストがマッチせずにエラーになってしまいます。map行でサブディレクトリを指定することで、sinatraには’/’としてリクエストすることができます。

これとnginxのalias指定でうまく処理することができるようになりました。最初この仕組みの解決がわからずに、いろいろと試行錯誤しました。

nginxの設定

nginxの設定です。

http://akkagi.info/tweet_rhythm/

上のようにドメイン名の下にサブディレクトリを指定してアプリを起動できるようにします。このやり方ならアプリごとにドメインを所得しなくても、アプリが増えるごとに同じドメイン下にアプリを増やしていくことができます。

もし、ドメイン単体で運用したい場合はSinatraの公式ページを参考にすればできると思います。

nginxの設定ファイルは/etc/nginx/conf.d以下にあると思います。すでにドメイン単体でサーバー公開ができていることを前提としています。もし、nginx自体の設定がわからない場合はぼくの過去記事や関連ページをググってください。

サブディレクトリにアプリを追加するやり方の設定は次の通りです。ここではakkagi.info.confが設定ファイルとします。

$ sudo vi /etc/nginx/conf.d/akkagi.info.conf

次のように、server { … }記述内にサブディレクトリのlocationを書きます。ポイントはalias行で、この指定によりsinatra側はalias行のurlをトップ階層の’/’として実行されます。そしてproxy_pass行によってupstreamで指定された内容を実行します。ここでは、unicornに処理が渡されます。

upstream tweet_rhythm {
    server unix:/var/www/app/tweet_rhythm/tmp/sockets/unicorn.sock;
}

server {
    ....

    # tweet_rhythm
    location /tweet_rhythm {
        alias /var/www/app/tweet_rhythm/public;
        try_files $uri @app;
    }
    location @app {
        proxy_pass http://tweet_rhythm;
    }
}

nginxの設定ができたらチェックをして、問題なければ設定を再読み込みします。

$ sudo nginx -t -c /etc/nginx/conf.d/akkagi.info.conf
$ sudo service nginx reload

以上でnginx側の処理が終わりました。

unicornの起動

nginx側でリクエストをさばく準備ができたので、実際の処理をuniconで動作させます。

$ bundle exec unicorn -c unicorn.rb

まずは、エラーや初期確認として通常コマンドとして実行します。この状態で先ほど設定したhttp://akkagi.info/tweet_rhythm/へブラウザでアクセスしてみます。

もし、問題なければこれをデーモンとして実行します。(先ほどのコマンド停止はCtrl + C)

$ bundle exec unicorn -c unicorn.rb -E production -D

これでデーモンプロセスとして、バックエンドで処理を待ち続けます。nginxにリクエストが来ると、このデーモンプロセスに処理が移って応答を返します。

おわりに

rubyのWebアプリを以前から公開したくて、やっとそれが実現できました。アプリ単体は単純ですが、ajaxをベースとしたWebアプリをsinatraとnginx上で動かせるようになったのは、ぼくにとって技術的に大きな進歩です。

さくらVPSと契約したのが『rubyのWebアプリを使いたい』というのがひとつの理由だったので、今回それが実現できてよかったです。以前から構想中のWebサイトの実現が一歩近づいたので、それをお見せできる日が来ればと思う今日この頃です。

参考

単一コード

今回のWebアプリの単一コードをそのまま紹介します。Sinatra環境で動作すると思います。

require 'twitter'
require 'dotenv'
require 'time'
require 'sinatra'

Dotenv.load # twitter認証key取得

class App
  attr_reader :screen_name
  def initialize
    @client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV["CONSUMER_KEY"]
      config.consumer_secret     = ENV["CONSUMER_SECRET"]
      config.access_token        = ENV["ACCESS_TOKEN"]
      config.access_token_secret = ENV["ACCESS_SECRET"]
    end
    @screen_name  = nil
    @tweet_counts = Array.new(24, 0)
  end

  def get_tweet_counts(screen_name = 'akkagi0416')
    @screen_name = screen_name
    @tweet_counts = Array.new(24, 0)

    tweets = @client.user_timeline(@screen_name, { count: 200 } )
    tweets.each do |tweet|
      @tweet_counts[tweet.created_at.getlocal.hour] += 1 # 日本時間に変更
    end
    @tweet_counts.map {|count| count.to_f / tweets.count * 100 }
  end

  def make_result
    html = "<h2><span>@#{@screen_name}</span>さんのtweet頻度</h2>"
    html += '<table>
      <tr><th>時間</th><th>tweet頻度</th></tr>'
    (0..23).each do |i|
      html += "<tr><td>#{i}時</td><td>" + "#" * @tweet_counts[i] + "</td></tr>"
    end
    html += "</table>"
    html
  end
end

app = App.new
# p app.get_tweet_counts

# Web part
get '/' do
  app.get_tweet_counts  # akkagi0416(default)
  @result = app.make_result
  # @result = ""
  erb :index
end

post '/' do
  # @tweet_counts = app.get_tweet_counts(params['screen_name'])
  # make_result(@tweet_counts)
  begin
    app.get_tweet_counts(params['screen_name'])
    app.make_result
  rescue Twitter::Error::NotFound
    "<h2><span>@#{app.screen_name}</span>さんは見つかりませんでした</h2>"
  end
end

__END__

@@ index
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,user-scalable=no,maximum-scale=1">
  <title>何時にtwitter? | twitterのつぶやきで生活リズムがわかる?</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
  <style>
  h1{ margin: 0; }
  th, td{ white-space: nowrap; }
  td:nth-of-type(1){ text-align: right; padding-right: 1em; }
  td:nth-of-type(2){ color: #5cb85c; }
  h2{ margin-bottom: 1.5em; font-size: 1em; }
  h2 span{ padding-right: 0.2em; font-size: 1.5em; font-weight: bold; color: #d9534f; }
  section{ margin-bottom: 3em; }
  footer{ text-align: center; }
  </style>
</head>
<body>
<header class="navbar navbar-default">
  <div class="container">
    <h1 class="navbar-brand">何時にtwitter?</h1>
  </div>
</header>
<main class="container">
  <section>
    <h2>@で始まるtwitter名を入力してね</h2>
    <div class="form-group navbar-form">
      <div class="input-group">
        <span class="input-group-addon">@</span>
        <input type="text" id="screen_name" class="form-control" placeholder="akkagi0416">
      </div>
      <button type="submit" class="btn btn-success">Check</button>
    </div>
  </section>
  <section id="result">
    <%= @result %>
  </section>
</main>
<footer class="container">© <a href="akkagi.info">akkagi</a></footer>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.0/jquery.min.js"></script> 
<script>
$(function(){
  $('button').click(function(){
    var screen_name = $('#screen_name').val();
    var request = $.ajax({
      type: "POST",
      url: "/",
      data:{ screen_name: screen_name }
    });
    request.done(function(data){
      console.log('ajax success');
      //console.log(data);
      $('#result').html(data);
    }).fail(function(e){
      console.log('ajax error');
    });
  });
});
</script>
</body>
</html>