satococoa's blog

主にサーバーサイド、Web 系エンジニアのブログです。Go, Ruby, React, GCP, ...etc.

Map Chasingというアプリを作りました

「第0回 HTML5 プログラミング&クリエイティブコンテスト」に応募するため、Map Chasingというアプリを作り、公開しました。
このアプリの簡単な紹介と技術的な説明を少し書きたいと思います。

コンテストについて

html5-developers-jpというHTML5の普及・促進・情報共有を目的とするコミュニティが主催するプログラミングコンテストです。
応募対象は川柳/キャラクターデザイン/テーマに沿った作品/自由課題があるのですが、僕は自由課題部門に応募してみました。「HTML5, CSS3及び周辺技術の将来性を感じさせるもの」という要件でしたので、個人的に興味があったWebSocketを使って何かリアルタイムな仕組みが作りたいなぁ、ということで。

作成したアプリについて

(スクリーンキャスト、ちょっと見づらいですね。初なのでご勘弁ください。)

Twitterアカウントでログインし、地図上で出題されるミッションに答えていく、という単純なアプリです。
現在実装されているミッションは山手線の駅を探すミッションのみです。
ちなみに最初に自分のアイコンが表示される場所ですが、「東京近辺のランダムな地点」としています。Geolocationで現在地を取得することもできますが、ねぇ・・・。

同時にログインしている人全員に同じミッションが提示されます。ミッションをクリアするとクリアした人にポイントが入り(名前の後ろの数字がスコアです。)、次のミッションが提示されます。特に排他処理はしていませんので、一番最初の人がスコアをゲットした直後、次の問題が出題される前でしたらほぼ同着の方もスコアをゲットできるはずです。

同時にログインしている人が増えるとちょっとした競争になって面白いです。もちろん、ミッションもユーザーのアイコンの移動も全ユーザーでWebSocketを使って通知されていますので、運がよければ(?)他の方のアイコンが動いているのも見ることができます。

Map Chasingというアプリ名の通りユーザー同士で追いかけっこを楽しめるアプリにしよう、というプランのもとで作り始めたのですが、時間的な制約(締め切り的な意味で)と同時にログインする人が多くないと追いかけっこはゲームが成り立たないからという考えのもと、現状のようになりました。

技術的な説明

Herokuを使っています。WebSocketの部分はPusherに丸投げで、ユーザーの情報(Twitterからもらった情報や、スコアなど)はRedis To Goを使っています。
本当はこの程度のアプリではredisを使う必要は全くなく、PostgreSQLやSQLite3でも十分かとは思いますが、どうせやるなら新しい技術を・・・という考えです。
ちなみにRailsではなくてSinatraです。あと、無駄にjQuery mobileです。これも今後のための練習です。
WebSocket
途中まで、Pusherを使わずにem-websocketを使ってサンプル程度ですがまずはちょっとWebSocketを試してみました。
require 'em-websocket'
require 'logger'

EventMachine.run {
  @logger = Logger.new(STDERR)
  @channel = EM::Channel.new
  
  EventMachine::WebSocket.start(:host => '0.0.0.0', :port => 8080) do |ws|
    ws.onopen {
      sid = @channel.subscribe{|msg| ws.send msg}
      @logger.debug "#{sid} connected!"
      
      ws.onmessage {|msg|
        @logger.debug "<#{sid}>: #{msg}"
        @channel.push msg
      }
      ws.onclose {
        @channel.unsubscribe(sid)
        @logger.debug "#{sid} disconnected!"
      }
    }
  end
}
やってることは、メッセージがブラウザから送られてきたらそのメッセージをブラウザに送り返してやる、というそれだけです。で、それをJavaScriptで受けて色々します。
試しに簡単なチャットを実装したところでふと気づいたのですが、herokuで公開するつもりであったことをすっかり忘れていました。herokuのアドオンとして使えるものを・・・ということでPusherを使うことに。
Pusher
Pusherの仕組みはWebSocketとはちょっと変わっていて、メッセージの送信はRESTで(たいてい)サーバーから送り、クライアントはJavaScriptでメッセージを受け取りほげほげする、という流れです。
詳しくは公式のページをご覧いただくとして、おおざっぱに言えばこんなイメージです。サーバー側はちゃんとgemがあるので、そちらを使います。$ gem install pusherですね。クライアント側もJSのライブラリがあり、簡単に使えるようになっています。

メッセージのやり取りをRubyコード、JavaScriptコードで以下のように記述します。Map Chasing上でアイコンを動かしたときの例です。
Ruby(メッセージの送信)
Pusher['map-chasing'].trigger('move', user.to_hash, params[:socket_id])
JavaScript(メッセージの受信)
pusher.bind('move', function(data) {
  if (Users[0].uid == data.uid) return false;
  var uid = data.uid;
  if (!!Users[uid]) {
    var pos = new google.maps.LatLng(data.lat, data.long);
    Users[uid].move(pos);
  } else {
    Users[data.uid] = new User(data);
    Users[data.uid].appear(map);
  }
});
たったこれだけです。非常に簡単ですね。送信、受信ともチャネルを指定できます。僕は今回は"map-chasing"というチャネル一つだけを使いましたが、例えばMMOで言うサーバーのように複数のワールドに分けるために使ったり、チャットチャネルとゲームのイベント送信(キャラクターの移動とか?)チャネルに分ける、など色々な使い道がありそうです。
イベントも任意に定義できますのでソースの見通しがよくなって素敵です。
"move"はアイコンの移動、"appear"はアイコンの出現、"score"は得点時、というように使い分けました。
あと送信側のtrigger()メソッドの第 3引数ですが、以下のケースに対応するためのものです。
  1. Aさんがアイコンを移動("move")しました。
    Aさんのブラウザ上では既にAさんのアイコンは移動済みになる。ドラッグした訳なので。
  2. "move"イベントがPusherに送信されます。
  3. BさんやCさんのブラウザ上では、Pusherから受信した"move"イベントを処理し、AさんのアイコンをJavaScriptで移動させてあげます。
  4. ・・・Aさんのブラウザ上では2.で送信されたイベント(=自分で送信したイベント)を受け取る必要がない!
PusherにJavaScriptで接続した際にsocket_idが取得できます。そのsocket_idをブラウザからPOSTするときにパラメータとしてサーバーに渡してあげてその値を第3引数にすることにより、余分なイベントを受信せずに済むようになります。
Pusherに接続し、socket_idを取得するコードは以下。
var pusher = new Pusher(window.pusher_key);
pusher.bind('pusher:connection_established', function(event){
  socket_id = event.socket_id;
});
ちょっと余談になりますが、Pusherと接続するときにはapp_id, key, secretが必要となります。heroku上では、本番環境用に既にそれらがセットされた状態になっており、さらに開発用のapp_id, key, secretも最初から用意していてくれます。
ただし、開発環境上ではそれらをオブジェクトに与える処理は自分で書かなくてはなりません。
configureメソッドで:development, :productionに処理を分けて、:development用の設定時に例えばymlファイルに書いた設定を読み込む、などの対応が必要かと思います。ソースからその部分を抜粋しました。
configure :production do
  enable :sessions
  use OmniAuth::Builder do
    provider :twitter, ENV['TWITTER_KEY'], ENV['TWITTER_SECRET']
  end
  uri = URI.parse(ENV['REDISTOGO_URL'])
  Ohm.connect(:host => uri.host, :port => uri.port, :password => uri.password)
end

configure :development do
  enable :sessions
  config = YAML::load_file('config.yml')
  use OmniAuth::Builder do
    provider :twitter, config['twitter']['key'], config['twitter']['secret']
  end
  Ohm.connect
  Pusher.app_id = config['pusher']['app_id']
  Pusher.key = config['pusher']['key']
  Pusher.secret = config['pusher']['secret']
end
OmniAuth
今回の開発にあたって、実は一番感動したのはOmniAuthでした。
手順はたったの3ステップ。
  1. useブロックで使用するプロバイダごとの設定。(上記ソース抜粋参照)
  2. get '/auth/:provider/callback'処理を書く。
  3. '/auth/:provider'にアクセスさせる。
    リダイレクトなり、リンククリックなりご自由に。
これだけです。あっという間でした。get '/auth/:provider/callback'は以下のようになります。
get '/auth/twitter/callback' do
  auth_hash = request.env['omniauth.auth']
  login(auth_hash)
  redirect '/'
end
request.env['omniauth.auth']ハッシュの中にTwitterからもらってきた値が含まれているので、それを使ってログイン処理やユーザーの登録処理を行います。
※ loginメソッドの中で、初アクセスユーザーについてはRedisに情報を登録しています。
ENV['TWITTER_KEY']ですが、以下のようにしてherokuに環境変数を登録できます。localではymlを読むようにしちゃっています。
$ heroku config:add TWITTER_KEY=my_twitter_key
Redis
前から興味があったので使ってみました。heroku上ではRedis To Goというアドオンを使っています。一応WebSocketを用いたリアルタイムなアプリなので、処理が早いという噂のredis、使わざるを得ませんね。
redis自体はredisドキュメント日本語訳を読んで学びました。
Rubyからredisを使う際は、Ohmというライブラリを使いました。Ohmで保存されたユーザーのデータですが、面白い構造をしています。
redis> keys *
1. "User:2"
2. "User:uid:MTQ2MDA4Njk="
3. "User:all"
4. "User:1:_indices"
5. "User:2:_indices"
6. "User:uid:NzU3NTI1NjM="
7. "User:id"
8. "User:1"
9. "current_question"
redis> hgetall User:1
1. "uid"
2. "14600869"
3. "nickname"
4. "satococoa"
5. "image"
6. "http://a2.twimg.com/profile_images/1211141886/gravatar_normal.png"
7. "lat"
8. "35.6911965175872"
9. "long"
10. "139.77053279826234"
11. "created"
12. "2011-02-11 16:05:11 +0900"
13. "modified"
14. "2011-02-12 02:07:46 +0900"
15. "score"
16. "87"
redis> get User:id
"2"
User:idは主キーのためのauto_incrementなintegerが格納され、User:1, User:2というようなハッシュに登録されたデータが入っています。

まとめ

出来上がったアプリ自体はそう新しい感じではないのですが(とはいえ、複数人で同時に遊ぶと結構面白いとは思います)、WebSocketに関しては非常に未来を感じました。
作ってみて初めてわかる、この「わくわく感」です。面白いことがたくさんできそうだなぁという感触を得ることができました。

もう一つ感じたのが、開発者には今後ますます知識とモラルが必要になるなぁ、と言うことです。
どういうことかというと、いざやろうと思えば、僕はこういったアプリを通じてユーザーの現在地とTwitterアカウント、さらには特定の地域に関しての土地勘があるか否か、という情報を手に入れることができてしまいます。(最初はGeolocationも使おうと思っていたのですが、それはプライバシー的にまずい、と思ってやめた訳です。)
また、TwitterではなくてFacebookのアカウントでログインするように作ることも容易です。そうすると友人のデータや誕生日なども手に入ってしまいます。
もちろん接続の許可をTwitterなりFacebookからは求められ、そのときにユーザーは拒否することもできるのですが、深く考えずに「なんだかよくわからないから」と許可してしまうユーザーも少なからずいると思います。
その辺を悪用するようなサービスが多発した場合には、せっかく盛り上がってきたソーシャルなインターネットの世界への信頼が一気に損なわれてしまいます。肝に銘じて開発しなきゃなぁ、と感じた次第です。

そんなことを感じるきっかけと、楽しい開発のきっかけをくださったこのコンテストに感謝しています。ありがとうございました。

長くなってしまいました。ここまで読んでくださった方、どうもありがとうございました。せっかくなのでMap Chasingで10点くらい取るまで遊んでください。