この記事はTitanium Advent Calendar 2014の4日目の記事です。
はじめに
複数のデバイスやブラウザ間でリアルタイムでデータをやり取りするサービスは珍しいものでもなくなりつつありますが、実装が面倒で手軽に試すのは難しいので、まだ作ったことがないという方も多いのではないかと思います。しかし最近ではPusherやFirebase、PubNubといったサービスを利用することでこの手の機能は簡単に実装できるようになりました。
そこで、Titaniumを使ったリアルタイムの通信を手軽に実装する例を紹介したいと思います。今回はPubNubの無料枠を利用して、Publish/Subscribeの仕組みを使ったリアルタイム通信アプリを作成します。
PubSubHubbub
いまさらっとPublish/Subscribeって書きましたけど、あまり馴染みのない方もいらっしゃるかもしれませんね。昔、PubSubHubbubというプロトコルが発表されて、今でもちょいちょいと利用されているのですが、これの登場以前は例えばブログの更新情報なんかは、新規の投稿がサーバ上に保存されてRSSなどでデータが配信されても、それを取得するためには読む側がデータを自分で取りに行く必要がありました。もしこれが投稿する側から購読する側にプッシュ通知されたら便利だよね、ということで策定されたのがこのPubSubHubbubです。ざっくり説明すると、ブログなどのデータを投稿する側がPubSubHubbubのサーバにそのことを通知すると、PubSubHubbubのサーバが購読者として登録していた人たちに通知してくれるという仕組みです。難しいことは中心に立っているPubSubHubbubのサーバが担い、データを送る側と受け取る側は簡単な機能しか要りません。このような仕組みで投稿者が通知した内容を購読者に向けてブロードキャストするのがPublish/Subscribeの仕組みということになります。
先ほど挙げたPusherやFirebase、PubNubなどは、このようなPublish/Subscribeに必要なサーバ側の機能を提供してくれるサービスです。どれもいいとは思いますが、ググったらCTOがいい人だという書き込みがあったので今回はPubNubを使うことにします。いやあ、まさかその人もこんな遠くの国で自分の人格が褒められているとは夢にも思っていないでしょう。
はじめてのリアルタイムメッセージング
さっそくですが、最初のバージョンを実装してみましょう。まず非常に簡単な、同じサービスに接続しているユーザ同士がメッセージを送り合うだけのアプリを作成してみます。Yoが本当にYoしか言えなくなったような感じのやつです。
さっそく使ってみましょう。なんと、PubNubはTitanium用のJSライブラリを用意してくれているんです。一部更新したものを置いておきましたので使ってみてください。
https://github.com/yagitoshiro/javascript/blob/master/titanium/pubnub.js
Alloyのテンプレートから新規にプロジェクトを作成して、app/assetsの下にlibsというディレクトリを追加しました。その下に上のリンクのファイルを置いています。別に意味はありませんが、まあ、なんとなくここに。
app/
├── README
├── alloy.js
├── assets
│ ├── android
│ ├── blackberry
│ ├── iphone
│ ├── libs
│ │ └── pubnub.js
│ └── mobileweb
├── config.json
├── controllers
│ └── index.js
├── models
├── styles
│ └── index.tss
└── views
└── index.xml
さて、最初のアプリの機能は次の通りです:
1: 起動するとチャットのチャンネルに登録する
2: 登録すると同時にメッセージを受信したときの動作も登録する
3: クリックするとチャットに特定のメッセージを投稿する
これだけ?ええ、これだけです。文句のあるやつは前に出ろ。
//index.js
var pubnub = require('libs/pubnub')({
publish_key : 'PubNubで取得したpublish_key',
subscribe_key : 'PubNubで取得したsubscribe_key',
ssl: true,
origin: 'pubsub.pubnub.com'
});
//PubNubに登録してメッセージ受信時にalertを実行
pubnub.subscribe({
channel: 'my_super_chat', //これはなんでもいい
callback: function(message){
alert(message);
}
});
function doClick(e){
pubnub.publish({
channel: 'my_super_chat', //subscribeとpublishするchannelが一致すればいい
message: 'もんげえ'
});
}
豆知識
Alloyのcontrollerはコンパイルされると大きな一つの関数の中に入るので、一見するとグローバル変数みたいに見えてもちゃんと閉じ込められています。
さっそく動かしてみましょう。
デフォルトの画面に表示されている「Hello, World」のラベルをクリックすると互いにメッセージを送信しましたね。PubSubHubbubと違って投稿した側にも通知が届くのがわかると思います。チャットだと自分のメッセージも一覧に表示しますから、これは便利かもしれないですね。
あとはListViewとかTableViewでメッセージを表示すればなんとかなりそうです。簡単ですね。
おしまいです。
おしまいだよ。
もうないってばしつこいな。
まったくもう。仕方がないですね。
Pub/Subの問題
実は、Publish/Subscribeモデルだとチャットを実装しようとするとひとつ問題があります。普通、チャットはそのチャンネルにアクセスしている間に会話だけできればよさそうなものですが、現実世界の実装をみると、自分がいない間に投稿されたメッセージも読むことができるものばかりだと思います。
WebSocketを使ったPublish/Subscribeモデルだと、これがなかなかうまく実装できません。データをいつ受信したか、どこまで受信したか、そもそもユーザはいつから接続が切れていたのか、サーバ側では検知することができません。実際、チャットをPub/Subで実装するのはそんなに難易度が高いわけではないのに採用されないケースがあるのは、個々のクライアントがどこで接続が実際に切れてどこまで読んだのかがわかりにくいからかもしれません。それに、Redisの以前のバージョンだと切れてしまったクライアントのセッションが溜まりパフォーマンスが低下することがあったり(現在は対応済み)、サーバ側の運用としても頭の痛い問題なのかもしれません。
しかし、PubNubのようなBaaSを利用することの利点は、そういった問題をすべて丸投げできることです。セッションの扱いは完全にサーバ側に任せられます。なので、Pub/Sub型アーキテクチャの問題については前者の未読データの扱いだけに集中することができます。
過去データの取得
PubNubでは過去のデータにアクセスする方法がいくつか用意されているので、30日間無料で試すことができます。そう、有料です。まあ、ストレージの使用料だから仕方がないですね。保存期間は1日から無期限まで選択することができます。その点、Firebaseだとストレージが100MBまで無料で利用できる上に、Snapshopという機能を利用することができるので便利かもしれません。もうそっちに乗り換えたくなりましたが、仕方がありません。Finish what we began!
追記
PubNubの試用期間に申し込んだら、カスタマーサポートからいつでもサポートします、15分くらい電話かSkypeで話したいですってメールが届きました。こういうのっていいですね。
pubnub.history({
channel: 'my_super_chat',
count: 100,//取得する件数の最大値
reverse: true, //古い順
callback: function(e){
var start = e[1]; //取得したデータの一番古いものの作成時間
var end = e[2]; //同じく最新のデータの作成時間
alert(e[0].join("\n")); //取得した過去のデータ
}
});
PubNubのhistoryを利用すると(アプリのコンソールから30日間のトライアルを有効にします)、callbackで指定した引数の0番目にデータが配列で返ります。並び順も指定できるので、古い順に全部取得することも簡単です。あとはこの配列をListViewにbindするなりなんなりすれば立派なチャットアプリになるでしょう。他にもstartやendにUNIX時間を渡すと期間指定が出来ます。取得したデータの期間は引数(上の例では「e」)の1番目と2番目に入ってきます。便利ですね。
もちろん、メッセージは文字列しか渡せませんが、JSONでいろいろなデータをやり取りすることもできます。
pubnub.subscribe({
channel: 'my_super_chat',
callback : function(message){
var data = JSON.parse(message);
alert(data.message);
}
});
function doClick(){
pubnub.publish({
channel: 'my_super_chat',
message: JSON.stringify({
message: 'もんげえ',
user: "yagi_",
icon: "http://example.com/icon/yagi_"
})
});
}
まあ、こんなもんですかね。
近くのユーザを地図上にマッピング
リアルタイムなやり取りを使って出来るアプリのアイデアに、同じサービスを利用しているユーザを地図上にマッピングしてリアルタイムで動向を見るというものがあります。人でやってしまうとちょっとプライバシーの問題とかいろいろありそうですが、タクシーやバスなどの交通機関に搭載したり、物流の現場で活用するといった幅広い用途が考えられます。
そしてこちらも割と簡単に実現できます。Mapモジュールを使ってさくっと作ってみましょう。Mapモジュールの設定の仕方はこちらをどうぞ。わからない?そういうときはサポートサイトがありますよ。
//app/controllers/index.js
var pubnub = require('libs/pubnub')({
publish_key : 'PubNubで取得したpublish_key',
subscribe_key : 'PubNubで取得したsubscribe_key',
ssl: true,
origin: 'pubsub.pubnub.com'
});
//地図上に表示するピン
var annotations = {};
//全ユーザーからの通知チャンネルに登録、受信したら地図上にピンを表示
pubnub.subscribe({
channel: 'map',
callback : function(message){
var data = JSON.parse(message);
if(annotations[data.id]){
$.mapview.removeAnnotation(annotations[data.id]);
}
var annotation = Alloy.Globals.Map.createAnnotation({
pincolor: Alloy.Globals.Map.ANNOTATION_RED,
latitude: data.latitude,
longitude: data.longitude,
title: data.id
});
annotations[data.id] = annotation;
$.mapview.addAnnotation(annotation);
}
});
//自分の位置情報を投稿
function publishLocation(e){
$.mapview.userLocation = true;
Ti.API.info(e);
if(e.success && e.coords){
pubnub.publish({
channel: 'map',
message: JSON.stringify({
latitude: e.coords.latitude,
longitude: e.coords.longitude,
id: Ti.App.Properties.getString('uuid')
})
});
}
}
//位置情報を取得するあれやこれや
Ti.Geolocation.purpose = "理由などない";
Ti.Geolocation.accuracy = Ti.Geolocation.ACCURACY_BEST;
Ti.Geolocation.preferredProvider = Ti.Geolocation.PROVIDER_GPS;
function addHandler(){
Ti.Geolocation.addEventListener('location', publishLocation);
}
function removeHandler(){
Ti.Geolocation.removeEventListener('location', publishLocation);
}
if(Ti.Geolocation.locationServicesEnabled){
addHandler();
Ti.Geolocation.getCurrentPosition(function(e){
publishLocation(e);
});
//ふと思いついたのでAndroidでは電池を節約してみましょう
if(Ti.Platform.osname == 'android'){
$.index.addEventListener('open', function(e){
var activity = Ti.Android.currentActivity;
activity.addEventListener('destroy', removeHandler);
activity.addEventListener('pause', removeHandler);
activity.addEventListener('resume', addHandler);
});
}
}
//アプリを起動!
$.index.open();
viewの方は単純に地図が出ているだけです。
<Alloy>
<Window class="container">
<Module id="mapview" module="ti.map" method="createView" />
</Window>
</Alloy>
画像を見ると、お互いの位置を表示しているのがわかると思います(自分の位置には青い丸がついていますね)。あと、そろそろiPhoneを充電した方がいいみたいです。
エミュレータのGPSの設定を適当に設定しているので擬似的に複数の位置にユーザーがいるように見えていますが、実際には中年男性が一人で部屋の中で動かしています。ユーザーが移動するとピンも移動しますが、Geolocationの設定により更新頻度が高く割とバッテリーを食うので、気をつけてください。
プログラムの内容をざっくり説明すると、まず位置情報のストリームチャンネルにsubscribeして、それから自分の位置情報を取得していったんpublishし、あとは位置情報が更新されるたびにpublishし直しています。subscribeしたときに受信時の挙動を設定しています。すでにピンが立っていれば削除して更新、なければ新規にピンを立てています。
やってみると笑えるほど簡単なので、みなさまぜひご活用ください。上に挙げた例の他にも、ヨットレースや大規模なかくれんぼ、外回りの営業の監視、桶狭間の戦いの待ち伏せなど、アイデア次第でまだまだたくさんの活躍の場があるはずです。
以上です。
なんてね。
そろそろ終わりだと思った?
残念でした。
というのも…
Geohashを使って実用的に
単純な機能であればこれでいいのですが、よく考えたら、この実装だと、もしユーザーがたくさんいたら困りますよね。Ingressのような、とまではいかなくても、日本中にユーザーがいるサービスの場合、画面に表示されないエリアの情報まで常に取得し続けていたら、とんでもない処理数になってしまい、あまりいいことはないと思います。できれば現在表示されている範囲と、隣接する区域内だけの情報を取得するようにしたいですよね。
そこでGeohashと組み合わせてみましょう。Geohashの詳しい説明は省略しますが、ようするに緯度経度に沿った矩形の並びになるよう地図を分割するのに便利なアルゴリズムです。
戦略としてはこんな感じです。まず矩形ごとにチャンネルがあるとみなします。現在地の位置情報から自分が今どの矩形に入っているのかを割り出して、さらに東西南北の8つの近隣の矩形も取得し、それら全てのチャンネルにsubscribeします。矩形があまり大きくても小さくても意味がないのですが、そこは実用の範囲内におさまるように調整するといいでしょう。あとは自分の位置情報から矩形を出してそのチャンネルにpublishすればおしまいですね。
JavaScriptでGeohashを扱う方法はいくつもありますが、今回はNode.jsのモジュール「node-geohash」から拝借しました。先ほど作成したapp/assets/libs以下にgeohash.jsとして保存します。jmkを使ってnodeのモジュールを読み込んでもいいんですが、長くなるのでやめました。
var geohash = require('libs/geohash');
var pubnub = require('libs/pubnub')({
publish_key : 'PubNubで取得したpublish_key',
subscribe_key : 'PubNubで取得したsubscribe_key',
ssl: true,
origin: 'pubsub.pubnub.com'
});
var digits = 5;
var annotations = {};
var neighbors = [];
var center;
function unsubscribe_all(){
for(var i in neighbors){
pubnub.unsubscribe({
channel: neighbors[i],
callback: function(){}
});
}
}
function subscribe(e, callback){
if(e.success && e.coords){
unsubscribe_all();//いったん登録を解除しよう
center = geohash.encode(e.coords.latitude, e.coords.longitude, digits);
neighbors = geohash.neighbors(center);
for(var key in neighbors){
subscribe_to_channel(neighbors[key]);
}
subscribe_to_channel(center);
callback(e);
}
}
function subscribe_to_channel(geohash){
pubnub.subscribe({
channel: geohash,
callback: function(message){
var data = JSON.parse(message);
if(annotations[data.id]){
$.mapview.removeAnnotation(annotations[data.id]);
}
var annotation = Alloy.Globals.Map.createAnnotation({
pincolor: Alloy.Globals.Map.ANNOTATION_RED,
latitude: data.latitude,
longitude: data.longitude,
title: center + ":" + data.id
});
annotations[data.id] = annotation;
$.mapview.addAnnotation(annotation);
}
});
}
function publish(e){
$.mapview.userLocation = true;
Ti.API.info(e);
if(e.success && e.coords && center){
pubnub.publish({
channel: center,
message: JSON.stringify({
latitude: e.coords.latitude,
longitude: e.coords.longitude,
id: Ti.App.Properties.getString('uuid')
})
});
$.mapview.reagion = {
latitude: e.coords.latitude,
longitude: e.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01
};
}
}
//位置情報を取得するあれやこれや
Ti.Geolocation.purpose = "理由などない";
Ti.Geolocation.accuracy = Ti.Geolocation.ACCURACY_BEST;
Ti.Geolocation.preferredProvider = Ti.Geolocation.PROVIDER_GPS;
//あとは以下同文なので略
細かいことは考えずに実装してみました。digitsの値を変えると矩形の大きさが変わります。例えば次の画像はそれぞれ4と5を指定したものですが、数字が大きければ大きいほど矩形の範囲が狭くなるので、Android(4を指定)で二人表示されていたユーザーの位置がiOS(5を指定)では一人しか表示されていません。
いかがでしたか?リアルタイムで情報をPub/Sub形式で複数のデバイス間で共有することにより、ビジネスから遊びまで面白い使い道がまだまだたくさん見つかると思います。一度試してみてはいかがでしょうか。
この記事はTitanium Advent Calendar 2014の4日目の記事です。次はモロ屋さんです。よろしく。