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つにメッセージを送らねばならぬ。
これはちょっと面倒で、今回そこらへんはかなりのクソ実装にしてしまった。(反省
それは兎も角、スケーラブルなメッセージサーバーが(事実上)無料で使えるのはすごい。すごすぎる。