GAEのChannel APIでリアルタイムWebアプリ
SDK 1.4.0で使えるようになったChannel APIを使ってRetrospectiveappを実装してみる。
前回はPusherを使ってWebSocketで通知していたのがChannel APIを使ってCometで通知されるようになるだけ。
WebフレームワークはBottleを使用。
デモ:http://5.latest.myretrospective.appspot.com/
Model
from google.appengine.ext import db class Note(db.Model): x = db.IntegerProperty(required = True) y = db.IntegerProperty(required = True) w = db.IntegerProperty(required = True) h = db.IntegerProperty(required = True) angle = db.IntegerProperty(required = True) text = db.StringProperty(required = True, multiline = True) color = db.StringProperty(required = True) class User(db.Model): user_id = db.StringProperty(required = True) date = db.DateTimeProperty(auto_now_add = True)
App
import os import re import random import logging from google.appengine.ext import webapp from google.appengine.ext.webapp.util import run_wsgi_app from google.appengine.api import channel from django.utils import simplejson import bottle from bottle import route, response, request, error, template, abort import dbutils from model import Note, User def get_uniq_id(): ID_MAX_LENGTH = 10 _alpha = 'abcdefghijklmnopqrstuvwxyz' _chars = '0123456789' + _alpha + _alpha.upper() user_id = '' for i in xrange(ID_MAX_LENGTH): user_id += random.choice(_chars) return user_id def send_message(dict): for u in User.all(): channel.send_message(u.user_id, simplejson.dumps(dict)) bottle.debug(True) @route('/') def index(): user_id = request.COOKIES.get('user_id', '') if not user_id: user_id = get_uniq_id() response.COOKIES['user_id'] = user_id user = User(user_id = user_id) user.put() token = channel.create_channel(user_id) return template('index', token = token) @route('/notes', method = 'GET') def notes_get(): notes = [dbutils.to_dict(note) for note in Note.all()] if notes: return simplejson.dumps(notes) return {} @route('/notes', method = 'POST') def notes_post(): x = request.POST.get('x', '') y = request.POST.get('y', '') w = request.POST.get('w', '') h = request.POST.get('h', '') text = request.POST.get('text', '') angle = request.POST.get('angle', '') color = request.POST.get('color', '') note = Note(x = int(x), y = int(y), w = int(w), h = int(h), text = text, angle = int(float(angle)), color = color ) note.put() dict = dbutils.to_dict(note) dict['message_id'] = 'note-create' send_message(dict) return dict @route('/notes/:id/softupdate', method = 'PUT') def softupdate(id): text = request.POST.get('n[text]', '') dict = {'message_id' : 'note-softupdate', 'text' : text} send_message(dict) return {'text' : text} @route('/notes/:id', method = 'PUT') def notes_id_post(id): n = Note.get_by_id(int(id)) x = request.POST.get('x', '') y = request.POST.get('y', '') w = request.POST.get('w', '') h = request.POST.get('h', '') text = request.POST.get('text', '') if (text): n.text = unicode(text.decode('utf-8')) elif (x and y and w and h): n.x = int(x) n.y = int(y) n.w = int(w) n.h = int(h) n.put() dict = dbutils.to_dict(n) dict['message_id'] = 'note-update' send_message(dict) return dbutils.to_dict(n) @route('/notes/:id', method = 'DELETE') def notes_id_delete(id): n = Note.get_by_id(int(id)) n.delete() dict = {'id' : id, 'message_id' : 'note-destroy' } send_message(dict) return {'id' : id} @error(404) def mistake404(code): abort(401, 'Sorry, access denied.') if __name__ == '__main__': logging.getLogger().setLevel(logging.INFO) app = bottle.app() run_wsgi_app(app)
Javascript
$(document).ready(function(){ $.getJSON('/notes', function(data){ $.each(data, function(index, value){ generateNote(value); }); }); $('.addNote').click(function(){ $.post('/notes', { 'color' : this.className.split(' ')[1], 'text' : 'Click here to write', 'angle' : Math.random() * 5, 'w' : 100, 'h' : 80, 'x' : 40, 'y' : 40 }); }); $('textarea').live('focus', function(){ if (this.value == "Click here to write"){ this.value = ""; } }); $('.delete').live('click', function(){ var id = $(this).parent()[0].id.split("_")[1]; $.ajax({type:"DELETE", url:'/notes/' + id, data: ''}); }); $('textarea').live('blur', function(){ if (this.value == ""){ this.value = "Click here to write"; } var id = $(this).parent()[0].id.split("_")[1]; var text = this.value; $.ajax({type:"PUT", url:'/notes/' + id, data: {'text':text}}); }); $('textarea').live('keyup', function(){ var id = $(this).parent()[0].id.split("_")[1]; var text = this.value; $.ajax({type:"PUT", url:'/notes/' + id + '/softupdate', data: {'id':id,'text':text}}); }); function generateNote(data){ var template = "<div id='note_"+ data.id + "' class='note'><div class='delete'>x</div><textarea class='textedit'>"+ data.text + "</textarea></div>"; template = $(template).addClass(data.color); template = $(template).css({ '-webkit-transform':'rotate(-' + data.angle + 'deg)', '-moz-transform':'rotate(-' + data.angle + 'deg)', 'min-width': data.w, 'min-height': data.h, 'left': data.x, 'top': data.y }); template.find("textarea").width(data.w - 4).height(data.h - 4); // template.find("textarea").autoResize(); $('#notesContainer').append(template); $('.note').draggable({ containment: 'parent', distance: 10, opacity: 0.75 }); $(template).bind("dragstop", function(event, ui) { var x = parseInt(ui.position.left); var y = parseInt(ui.position.top); var w = $(this).width(); var h = $(this).height(); $.ajax({type:"PUT", url:'/notes/' + data.id, data: {'x':x,'y':y,'w':w,'h':h}}); }); }; function updateNote(data){ var note = $("#note_" + data.id); $('textarea', note).val(data.text); // note.find("textarea")[0].value = data.text; note.css({ 'min-width': data.w, 'min-height': data.h, 'left': data.x, 'top': data.y }); $('textarea', note).width(data.w - 4).height(data.h - 4); // note.find("textarea").width(data.w - 4).height(data.h - 4); }; function onMessage(m) { var data = JSON.parse(m.data); if (data.message_id === 'note-create') { generateNote(data); } else if (data.message_id === 'note-softupdate') { updateNote(data); } else if (data.message_id === 'note-update') { updateNote(data); } else if (data.message_id === 'note-destroy') { $("#note_"+data.id).remove(); } } function openChannel() { var token = '{{token}}'; var channel = new goog.appengine.Channel(token); var handler = { 'onopen': function() {}, 'onmessage': onMessage, 'onerror': function() {}, 'onclose': function() {} }; var socket = channel.open(handler); socket.onmessage = onMessage; } function initialize() { openChannel(); } initialize(); });
感想
One Client Per Client ID
Only one client at a time can connect to a channel using a given Client ID, so an application cannot use a Client ID for fan-out. In other words, it's not possible to create a central Client ID for connections to multiple clients (For example, you can't create a Client ID for something like a "global-high-scores" channel and use it to broadcast to multiple game clients.)
ということで全クライアントにブロードキャストするようなことはできない。クライアント1つ1つにメッセージを送らねばならぬ。
これはちょっと面倒で、今回そこらへんはかなりのクソ実装にしてしまった。(反省
それは兎も角、スケーラブルなメッセージサーバーが(事実上)無料で使えるのはすごい。すごすぎる。