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がでる><。
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 Frameworkのdbutilsをちょっと改造して使ってる。
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
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/
さくっと登録しましょう。
Dashboard→Api accessでapi_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>
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)ちょっと調子悪いです