WEBアプリ開発記

~備忘録としてね~

GAEjでChannelAPI利用時の端末~サーバー間接続をしっかり管理しよう - その2

さて、前回の記事にて、詳しく紹介することにした「ChannelAPIの接続管理」の詳細を書き始めます。
順を追って行きましょう!

動作サンプルはコチラ
スマホとブラウザだったり、PCブラウザで2つタブを開いたり何でもいいですが、同じ部屋IDを入力して試してみてください!
また、別の部屋に入っている場合はメッセージが届かないことまで確認してもらえると良いです。

※前回も書きましたが、面倒な人は直接ソースを見てもらった方がいいと思います!github.com
ちなみに、サーバーサイドは例によってSpringMVCを使って楽しています。

ではいきます。

A.HTMLファイル(1枚だけ!)
特に難しい記載はないので、特徴的なとこだけ抜粋。

<!-- 1.jQueryは便利だから読み込む -->
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
<!-- 2.このURLでChannelAPIに必要な接続スクリプトを読み込む -->
<script src="/_ah/channel/jsapi"></script>
<script src="common.js"></script>
<!-- 3.部屋に入るための自作接続クラス -->
<script src="room-connector.js"></script>
<!-- 4.自作接続クラスを扱うスクリプト -->
<script src="index.js"></script>

1.jQuery
特筆すべきもんでもないですが、いろいろ楽になるので読み込みます。

2.jsapi
GAEでサーバーを起動すると"/_ah/channel/jsapi"というURLでChanneAPIへの接続に必要なスクリプトを取得できますので、このように読み込みます。

3.自作接続クラス
特に説明はしませんが、これを使ってサーバー上の部屋との通信を行います。
中身も大したことないのですが、見たい方はソース見てみてくださいね!(prototype使ってないのはすいません汗)

4.それを扱うindex.jsスクリプト
以下に説明します。

cssはクソほども書いていないので省略!


B.index.jsファイル

$(document).ready(function() {
	// 1.まずは簡単接続用のクラスを生成!
	var roomConnector = new RoomConnector();

	// 2.端末IDを画面上に表示
	$('#device-id').text(roomConnector.getDeviceId());

	// 3.サーバーからのプッシュメッセージを受けたときに何をするかをここに記述
	roomConnector.setOnMessage(function(data) {
		if (data.substring(0, 21) === 'joinRoomAndDeviceIds:') {
			// 4.部屋に接続している端末一覧が送られてきたらそれを画面上に表示
			var joinRoomAndDeviceIds = data.substring(21).replaceAll([ '[', ']', ' ' ], '').split(',');
			$('#join-devices').empty();
			joinRoomAndDeviceIds.forEach(function(roomAndDeviceId) {
				var deviceId = roomAndDeviceId.replaceAll(roomConnector.getRoomId() + ':', '');
				$('#join-devices').append('<div>・' + deviceId + '</div>');
			});
		} else if (data.substring(0, 8) === 'message:') {
			// 5.メッセージが送られてきたらそれを画面上に表示
			var message = data.substring(8);
			var html = '<div>' + message + ':' + new Date().toLocaleString() + '</div>';
			$('#receive').append(html);
		}
	});

	// 6.部屋に入るボタンを押したとき
	$('#btn-enter-room').on('click', function() {
		var roomId = $('#enter-room-id').val();
		if (!roomId) {
			alert('部屋IDが空です');
			return;
		}
		// 7.セミコロンが部屋IDに指定されているとバグるのでそれを回避
		if (roomId.indexOf(':') > -1) {
			alert('部屋IDにコロン(:)は利用できません');
			return;
		}
		roomConnector.open(roomId);
		$('#enter-room').hide();
		$('#current-room-id').text(roomId);
		$('#current-room').show();
	});

	// 8.送信ボタンを押したとき
	$('#btn-send').on('click', function() {
		var message = $('#send').val();
		roomConnector.send(message + ':' + roomConnector.getDeviceId());
		$('#send').val('');
	});

});

1.先ほど読み込んだ自作接続クラスをインスタンス化します。

3.サーバーからメッセージが届いたときの処理を自作接続クラスにセットします。

4.joinRoomAndDeviceIdsという接頭文字で始まるメッセージを受け取ったら、参加者一覧を更新する

5.messageという接頭文字で始まるメッセージを受け取ったら、受信欄にメッセージを追記する

6.部屋に入るボタンを押して初めてメッセージのやり取りが可能になる

8.送信ボタンを押したら自作接続クラスのsendメソッドを呼ぶ


クライアント側のソースは大体こんな感じです!
続いて、サーバーサイド行きます。


サーバーサイドは大きく4つの役割で構成されています。

・Room - 部屋自体を表すオブジェクト
・RoomManager - サーバー上に存在するすべての部屋を把握している奴
・RoomConnectController - 部屋への参加、メッセージの送信を受け付ける奴
・ChannelController - チャンネルの接続やブラウザの切断を検知して適切に部屋参加者を操作する奴(ここ大事!)

てな感じです


C.RoomConnectController.java

package sample;

import java.util.Map;

import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

/**
*
* @author jazzmaster0601
*
*/
@Controller
@RequestMapping("/room-connect")
public class RoomConnectController {

	private String getRoomId(Map<String, String> param) {
		return param.get("roomId");
	}

	/**
	 * 部屋へ参加させます
	 * @param param
	 * @return
	 */
	@RequestMapping(value = "open", method = RequestMethod.POST, produces = MediaType.TEXT_PLAIN_VALUE)
	@ResponseBody
	public String open(@RequestBody Map<String, String> param) {
		String roomId = getRoomId(param);
		String roomAndDeviceId = param.get("roomAndDeviceId");
		return RoomManager.getInstance().tryToOpenRoom(roomId).join(roomAndDeviceId);
	}

	/**
	 * 部屋へのメッセージを受け取り参加者に配信します
	 * @param param
	 * @return
	 */
	@RequestMapping(value = "send", method = RequestMethod.POST, produces = MediaType.TEXT_PLAIN_VALUE)
	@ResponseBody
	public String send(@RequestBody Map<String, String> param) {
		String roomId = getRoomId(param);
		String message = param.get("message");
		RoomManager.getInstance().getRoom(roomId).sendMessage(message);
		return "success";
	}

}


1.部屋への参加
リクエストで飛んできた部屋IDに該当する部屋をなければ作ってそこに「roomAndDeviceId」の端末を参加させます。

2.部屋へのメッセージ配信
リクエストで飛んできた部屋IDに該当する部屋の参加者に、リクエストで飛んできたメッセージをプッシュします。


※roomAndDeviceIdってなんやねん?
→次のChannelControllerの説明で詳しく言います。


D.ChannelController.java

package sample;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import com.google.appengine.api.channel.ChannelServiceFactory;

/**
*
* @author jazzmaster0601
*
*/
@Controller
@RequestMapping("/_ah/channel")
public class ChannelController {

	private String getRoomAndDeviceId(HttpServletRequest req) throws IOException {
		return ChannelServiceFactory.getChannelService().parsePresence(req).clientId();
	}

	private String getRoomId(String deviceId) {
		return deviceId.split(":")[0];
	}

	/**
	 * 部屋への接続が完了したときに呼ばれます
	 * @param req
	 * @return
	 * @throws IOException
	 */
	@RequestMapping(value = "connected", method = RequestMethod.POST)
	@ResponseBody
	public String connect(HttpServletRequest req) throws IOException {
		String roomAndDeviceId = getRoomAndDeviceId(req);
		String roomId = getRoomId(roomAndDeviceId);
		Room room = RoomManager.getInstance().getRoom(roomId);
		room.sendJoinRoomAndDeviceIds();
		return "success";
	}

	/**
	 * ブラウザが閉じられたときに呼ばれます
	 * @param req
	 * @return
	 * @throws IOException
	 */
	@RequestMapping(value = "disconnected", method = RequestMethod.POST)
	@ResponseBody
	public String disconnect(HttpServletRequest req) throws IOException {
		String roomAndDeviceId = getRoomAndDeviceId(req);
		String roomId = getRoomId(roomAndDeviceId);
		RoomManager manager = RoomManager.getInstance();
		Room room = manager.getRoom(roomId);
		room.exit(roomAndDeviceId);
		manager.tryToCloseRoom(roomId);
		room = manager.getRoom(roomId);
		if (room != null) {
			room.sendJoinRoomAndDeviceIds();
		}
		return "success";
	}

}


まず大事なのが、GAEのChannelAPIは接続が確立したとき、"/_ah/channel/connected"というURLに対して、POSTリクエストが飛ぶような設定が可能です。
その設定方法は、WEB-INF以下のappengine-web.xml

<inbound-services>
    <service>channel_presence</service>
</inbound-services>

という記載をすることで動作するようになります。


1.部屋への接続が完了
この際に部屋への参加者が増加したはずなので、参加者全員にそのメッセージを送っています。

2.ブラウザが閉じられたとき
上記XMLの設定をしていると、Channelの接続が切断された際に"/_ah/channel/disconnected"というURLに対して、POSTリクエストが飛ぶようになっています。これを検知して、部屋から適切に参加者を退出させるのです。
そして、参加者が減少したはずなのでその旨を部屋に残っている参加者にメッセージングするのです。


※roomAndDeviceIdにそろそろ答えんかい!

ChannelControllerで処理をする際に、「どの部屋に」送るかという情報が必要なのは分かると思います。
しかし、このリクエストの中では、clientIdという端末のIDしか知ることができません。
そこでセッションを使う方法を考えたのですが、このChannelControllerに対するリクエストはRoomConnectControllerに対するリクエストと別のセッションを作成してしまうようなのです。
さて困ったということで、javascript側で生成できる端末固有のIDに部屋IDも一定のルールで付けてしまえ!という考えの元、roomAndDeviceIdというものにしたわけですね。
これは、

「部屋ID:端末ID」

というただの文字列です。


以上が大体の流れです!

しつこく再度載せておきますね。

動作サンプルはコチラ

ソースはコチラgithub.com


追記:それを踏まえて作ったアプリを紹介します。jazzmaster0601.hatenablog.com

今時いろいろなアプリやサービスがあるので、上記のようなアプリでできることは楽勝なのでしょうが、逆にそれしかできないアプリを超簡素化して作りました。
是非♪