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

ブラウザ履歴をスパイしているのは誰?

以前、ブラウザ履歴の盗聴を可能にするセキュリティ脆弱性について記事を書いた。
カリフォルニア大学サンディエゴ校コンピュータサイエンス部門の研究者の論文「JavascriptWebアプリケーションによるプライバシー侵害情報フローの実証的研究」で、ユーザーに対してこの侵入テクニックを使用しているWebサイトについて調査している。
YouPorn*1が一番ひどいけど、PerezHilton、Technorati、TheSun.co.uk、そしてWiredもこの脆弱性を悪用することでユーザーのブラウジング習慣をスパイしている。

Interclick*2に問い合わせしてみたんだ。Interclickは3月から10月までの期間限定で、購入したデータの品質をテストするためにそこらじゅうのWebサイトにスクリプトを張り付けたと説明した。”ターゲット広告キャンペーンのために、いくつかのベンダーから匿名の視聴者のデータを購入しました。データの品質と有効性を理解するために、各所で品質管理測定を行いました。論文で言及されているコードは試験中の品質測定なんです。”
僕はInterclickに説明を求めて、データ購入マーケットの仕組みについて面白い洞察を得た。InterclickはBlueKai、Bizo、AlmondNet、Datalogix、ExelateなどのWebサイト上のユーザーターゲッティングデータを購入している。データセットはおそらく、スポーツ好きや家電の買い物客みたいな特定のユーザーのグループを表している。Interclickは広告をもっと効果的にするために、彼らが何にお金を払っているのか知る必要があって、一連の品質管理測定を行っていたんだ。僕らは偶然それに出くわしたんだ。

役に立つかわからないけど、FirefoxのPrivate Browsing機能はこの攻撃に耐えられるように思える。



以上の文章はCory DoctorowによるWho spies on your browsing history?の日本語訳です。*3

*1:エロ動画サイト

*2:ネット広告の会社

*3:This work is licensed under a Creative Commons License permitting non-commercial sharing with attribution.

Google App EngineでDELETEリクエストを発行するときの注意点

$.ajax({type:"DELETE", url:'/hoge'});

こんな感じでGAEサーバにDELETEリクエストを送ると、ローカルでは正常に動くのに本番環境では400がでる><。

原因

調べてみるとDELETEリクエストがbodyを含んでいると駄目ってことになっている模様。

Indeed, when the appspot frontends see a DELETE request that includes an body, such as your app, they return a 501. But, if you remove the body then it will serve a 200.

(現在は501ではなく400で返ってくる)

解決方法

明示的にbodyを空にしてDELETEリクエストを発行しましょう。

$.ajax({type:"DELETE", url:'/hoge', data:''});

Puhser + Google App EngineでリアルタイムWebアプリ その2

WebSocketで目指せ“リアルタイムWeb”!」で取り上げられているRetrospectiveappのサーバサイド(Heroku + Sinatra)をGoogle App Engine + Bottleで実装してみる。
クライアントサイドは元ネタとほぼ同じ。
デモ : http://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)

App

import os
import re
from google.appengine.ext import webapp
from google.appengine.ext.webapp.util import run_wsgi_app
import bottle
from bottle import route, response, request, error, static_file
from django.utils import simplejson
from pusher import Pusher, Channel
import dbutils
from model import Note

bottle.debug(True)

APP_ID = 'app_id'
KEY = 'key'
SERCRET = 'secret'
CHANNEL_NAME = 'retrospectiveapp-test'

pusher= Pusher(APP_ID, KEY, SERCRET)
channel = Channel(CHANNEL_NAME, pusher)    

@route('/')
def index():
  return static_file('index.html', os.path.dirname(__file__))

@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('note[x]', '')
  y = request.POST.get('note[y]', '')
  w = request.POST.get('note[w]', '')
  h = request.POST.get('note[h]', '')
  text = request.POST.get('note[text]', '')
  angle = request.POST.get('note[angle]', '')
  color = request.POST.get('note[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()

  channel.trigger('note-create', dbutils.to_dict(note))

  return dbutils.to_dict(note)

@route('/notes/:id/softupdate', method = 'PUT')
def softupdate(id):
  text = request.POST.get('n[text]', '')
  socket_id = request.POST.get('socket_id', '')
  
  channel.trigger('note-softupdate', {'text' : text, 'socket_id' : socket_id})
  return {'text' : text}

@route('/notes/:id', method = 'PUT')
def notes_id_post(id):
  n = Note.get_by_id(int(id))

  x = request.POST.get('note[x]', '')
  y = request.POST.get('note[y]', '')
  w = request.POST.get('note[w]', '')
  h = request.POST.get('note[h]', '')  
  text = request.POST.get('note[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()
  
  channel.trigger('note-update', dbutils.to_dict(n))
  
  return dbutils.to_dict(n)

@route('/notes/:id', method = 'DELETE')
def notes_id_delete(id, method = 'DELETE'):
  n = Note.get_by_id(int(id))
  n.delete()
  
  channel.trigger('note-destroy', {'id' : id})
  
  return {'id' : id}

@error(404)
@error(403)
def mistake404(code):
  return 'error'
    
if __name__ == '__main__':
  app = bottle.app()
  run_wsgi_app(app)

Modelをdictに変換するためにKay Frameworkdbutilsをちょっと改造して使ってる。

from google.appengine.ext import db

import datetime
import time

#SIMPLE_TYPES = (int, long, float, bool, dict, basestring, list)
SIMPLE_TYPES = (int, long, float, bool, dict, list)

def to_dict(model):
  output = {}

  #set id
  output['id'] = model.key().id()

  for key in model.properties().iterkeys():
    value = getattr(model, key)
    if isinstance(value, basestring):
      output[key] = unicode(value)
    elif value is None or isinstance(value, SIMPLE_TYPES):
      output[key] = value
    elif isinstance(value, datetime.date):
      # Convert date/datetime to ms-since-epoch ("new Date()").
      ms = time.mktime(value.utctimetuple()) * 1000
      ms += getattr(value, 'microseconds', 0) / 1000
      output[key] = int(ms)
    elif isinstance(value, db.Model):
      output[key] = to_dict(value)
    else:
      output[key] = str(value)
  return output

感想

あとGitHubにホストされているコードを他の言語に移植したときってforkしていいんかな?って疑問におもた。したほうがいいんかな?

SHPAML : Haml for Python

Rubyで作られたマークアップ言語HamlがすごくてPythonにも同じようなのないかと探してみた。
見つけたのがSHPAML

使い方
import shpaml
print shpaml.convert_text(text)
入力
html
 head
  title
   test
 ul
  li
   hoge
  li
   hoge2
 div
  piyo
出力
<html>
 <head>
  <title>
   test
  </title>
 </head>
 <ul>
  <li>
   hoge
  </li>
  <li>
   hoge2
  </li>
 </ul>
 <div>
  piyo
 </div>
</html>


公式で

implemented with less code (due to fewer features)

と明言してるだけあってHamlと比べると貧弱な印象は拭えませんが中々いいんじゃないでしょうか。

appengine-rest-serverの出力形式をJSONに限定

GAEでRESTサービス作りたいなーと思ったときに一番最初に目につくのはappengine-rest-server
中々使いやすいと思うんだけど、デフォルトの出力形式がxmlなのどうにかしたい!というわけで改造してみる。


1625行目からのメソッドdoc_to_output(self, doc)を以下のように書き換え。

def doc_to_output(self, doc):
  self.response.disp_out_type_ = JSON_CONTENT_TYPE
  return xml_to_json(doc)

Pusher + Google App EngineでリアルタイムWeb

「WebSocketで目指せ“リアルタイムWeb”!」(http://www.atmarkit.co.jp/fcoding/articles/websocket/02/websocket02b.html)
これ読んで面白そうだと思ったので、とりあえずTwitterのpublic timelineを表示していくWebサイトを作ってみるよ。

Pusherに登録

http://pusherapp.com/
さくっと登録しましょう。
DashboardApi accessapi_id, key, secretが入手できる。

コーディング

サーバーとしてGAEを使用する。Pusherのアーキテクチャとかは上の記事を読んでください。
HTML

<!DOCTYPE html>
<head>
  <title>Pusher Test</title>
  <link href="/css/main.css" media="screen" rel="stylesheet" type="text/css" /> 
  <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js"></script>
  <script type="text/javascript" src="http://js.pusherapp.com/1.6/pusher.js" ></script>
  <script type="text/javascript" src="/js/main.js" ></script>
  <body>
	<div id="container">
	</div>
  </body>
</head>

Javascript

function generate(data){
	var tweet = '<div class="tweet"><img src="' + data.profile_image_url + '" width="50px" height="50px" alt="photo"/>'
				+ '<a href="http://twitter.com/' + data.screen_name +'">' + data.screen_name + '</a></div>';
	tweet = $(tweet).css({
			'background' : 'white',
			'border' : '2px solid #999', 
			'padding' : '5px',
			'margin-bottom' : '1px',
			'-webkit-border-radius' : '10px',
			'-moz-border-radius' : '10px'
			});
			
	var text = '<div class="text">' + data.text + '</div>'
	text = $(text).css({
			'display' : 'inline',
			'margin-left' : '10px'
		});
	
	tweet.append(text);
	
	$('#container').prepend(tweet);
	
	if ($('.tweet').length >= 15) {
		$('.tweet:last').remove();
	}
}

$(document).ready(function(){
	var pusher = new Pusher('key');
	pusher.subscribe('test_channel');
	pusher.bind('note-update', function(data) {
	  generate(data);
	});	
});

Python(GAE)
pusherのライブラリとしてpusherとかgea-pusherがあるよ。
今回はpusherを使用。

# -*- coding: utf-8 -*-
from google.appengine.ext import webapp
from google.appengine.ext import db
from google.appengine.ext.webapp import template
from google.appengine.ext.webapp.util import run_wsgi_app
import tweepy
from pusher import Pusher, Channel
import os

class Tweet(db.Model):
  id = db.IntegerProperty(required = True)

class Main(webapp.RequestHandler):
  def get(self):
    path = os.path.join(os.path.dirname(__file__), 'main.html')
    self.response.out.write(template.render(path, {}))
    
class Trigger(webapp.RequestHandler):
  def get(self):
    # 送信済みTweetのid
    tweeted = [tweet.id for tweet in Tweet.all().fetch(limit = 1000)]

    app_id = 'app_id'
    key = 'key'
    secret = 'secret'
    pusher = Pusher(app_id, key, secret)

    name = 'test_channel'
    channel = Channel(name, pusher)

    event = 'note-update'    
    public_tweets = tweepy.api.public_timeline()
    for tweet in public_tweets:
      if tweet.id not in tweeted:
        data = {'text' : tweet.text,
                'screen_name' : tweet.user.screen_name,
                'profile_image_url' : tweet.user.profile_image_url
                }
        channel.trigger(event, data)

        t = Tweet(id = tweet.id)
        t.put()       
       

application = webapp.WSGIApplication([('/twitter', Main), ('/trigger', Trigger)], debug = True)

def main():
  run_wsgi_app(application)

if __name__ == "__main__":
  main()

デモ:http://ninoseki-lab.appspot.com/twitter
(更新は1分毎)

追記:今(11/15 22:40)ちょっと調子悪いです