Tornado - framework z obsługą nieblokujących się połączeń
Tornado to Pythonowy framework webowy napisany na potrzeby Friendfeeda i zapewniający obsługę wielu równoczesnych otwartych połączeń. Dzięki temu można za jego pomocą tworzyć aplikacje webowe "czasu rzeczywistego", gdzie użytkownik dostaje nowe informacje, gdy tylko pojawią się na serwerze.
Tornado to młody projekt, wydzielony z kodu FriendFeed, w celu uproszczenia zależności, jak i chęci podzielenia się własnym dziełem (i nadzieja na przyśpieszenie jego rozwoju?). Należy zwrócić uwagę że projekt ten nie wykorzystuje komponentów Twisted (za co zwolennicy Twisted linczują Tornado), jak i nie ma obecnie dostępnych testów jednostkowych (co dla niektórych jest ważne). Framework ten warto zastosować tylko wtedy, gdy potrzebujemy jego asynchronicznych funkcjonalności (a serwer COMET typu Orbited nam nie pasuje). Jako framework dla zwykłych aplikacji www nie jest on zbyt funkcjonalny.
Instalacja
Pobieramy pakiet z tornadoweb.org i rozpakowujemy. Instalacja to standardowe:
Witaj Świecie
Stwórz plik run.py (nazwa dowolna) o kodzie:
# -*- coding: utf-8 -*-
import tornado.httpserver
import tornado.ioloop
import tornado.web
# klasa-widok
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write(u"Programiści Pythona pozdrawiają programistów PHP 8D")
# mapowanie URLi
application = tornado.web.Application([
(r"/", MainHandler),
])
if __name__ == "__main__":
http_server = tornado.httpserver.HTTPServer(application)
http_server.listen(8888)
tornado.ioloop.IOLoop.instance().start()
Moduły Tornado
Główne moduły to:
- web: framework webowy, na bazie którego zbudowany jest FriendFeed.
- escape: metody do kodowania, dekodowania XHTML, JSONa i URLi
- database: prosta nakładka na MySQLdb ułatwiająca wykorzystanie tego modułu w warunkach Tornado
- template: system szablonów
- httpclient: nieblokujący się klient HTTP zaprojektowany do pracy z modułem web i httpserver
- auth: implementacja autentykacji i autoryzacji względem źródeł trzecich: Google OpenID/OAuth, Facebook, Yahoo BBAuth, FriendFeed OpenID/OAuth, Twitter OAuth
- locale: obsługa wielojęzyczności
- options: obsługa plików konfiguracyjnych i wiersza poleceń zoptymalizowana pod pracę na serwerach
- httpserver: bardzo prosty serwer HTTP wykorzystywany przez moduł web
- iostream: prosta nakładka na nieblokujące się gniazda upraszczająca wzorce zapisu i odczytu
- ioloop: główna pętla Wejścia/Wyjścia
- s3server: serwer webowych implementujący większość API Amazon S3 wraz z lokalnym przetrzymywaniem plików
Podstawy Tornado web
Obsługa modułu web nie jest skomplikowana, a osoby znające już webapp, czy web.py będą mogły praktycznie z marszu przystąpić do kodowania. Oto nieco bardziej rozbudowany przykład z obsługą formularzy (POST), oraz mapowaniem adresów URL:
# -*- coding: utf-8 -*-
import tornado.httpserver
import tornado.ioloop
import tornado.web
class MainHandler(tornado.web.RequestHandler):
"""
Obsługa POST i parametrów POST/(GET)
"""
def get(self):
resp = u'<form action="./" method="post"><input type="text" name="name" /></form>'
self.write(resp)
def post(self):
# get_argument pobiera dane POSt['name']
myname = self.get_argument('name')
self.write(u'Cześć %s' % myname)
class FancyUrlHandler(tornado.web.RequestHandler):
"""
Przykład danych z URLa mapowanych za pomocą wyrażeń regularnych
"""
def get(self, slug, some_id):
resp = 'Slug to: %s | some_id to: %s' % (slug, some_id)
self.write(resp)
application = tornado.web.Application([
(r"/", MainHandler),
# mapowanie URLi z wyrażeniami regularnymi
# nazwy "zmiennych" nie są wymagane/obsługiwane
(r'/foo/(?P<slug>[\w\-_]+)/(?P<some_id>[0-9]+)/', FancyUrlHandler),
])
if __name__ == "__main__":
http_server = tornado.httpserver.HTTPServer(application)
http_server.listen(8888)
tornado.ioloop.IOLoop.instance().start()
W metodach klas widoków możemy uzyskać dostęp do obiektu żądania - HTTPRequest za pomocą self.request. Obiekt zawiera ten zawiera kilka użytecznych atrybutów:
- arguments - wszystkie argumenty GET i POST
- files - przesłane przez multipart/form-data pliki
- path - żądana ścieżka
- headers - nagłówki żądania HTTP
System szablonów
W Tornado można użyć praktycznie dowolnego systemu szablonów, lecz framework dostarcza także swój własny, pozwalający na stosowanie sporej części składni Pythona. By wykorzystać szablon zamiast self.write użyj self.render(szablon, ...parametry):
class MainHandler(tornado.web.RequestHandler):
"""
Obsługa POST i parametrów POST/(GET)
"""
def get(self):
# stosujemy szablon
self.render("templates/index.html", title=u"To jest strona główna")
def post(self):
# get_argument pobiera dane POSt['name']
myname = self.get_argument('name')
self.write(u'Cześć %s' % myname)
<html>
<body>
<h1>{{ title }}</h1>
<p>
<form action="./" method="post">
<input type="text" name="name" />
<input type="submit" value="Wyślij" />
</form>
</p>
<p>
<ul>
{% for i in range(1, 20) %}
<li>{{ i }}</li>
{% end %}
</ul>
</p>
</body>
</html>
Ciasteczka
Korzystanie z ciasteczek jest równie proste, oto przykład dla zwykłych ciastek:
class MainHandler(tornado.web.RequestHandler):
def get(self):
if not self.get_cookie("nazwa_cookie"):
self.set_cookie("nazwa_cookie", "wartość")
self.write("Nie było Cookie!")
else:
self.write("Cookie już jest!")
application = tornado.web.Application([
(r"/", MainHandler),
], cookie_secret="61oETzKXQAGaYdkL5gEmGeJJFuYh7EQnp2XdTP1o/Vo=")
Autoryzacja użytkowników
Obecnie zalogowany użytkownik dostępny jest w metodach pod self.current_user i w szablonach pod current_user. Domyślnie przyjmuje wartość None. By zaimplementować autoryzację użytkowników w aplikacji musisz nadpisać metodę get_current_user w klasie obsługującej żądanie by określić obecnie zalogowanego użytkownika np. po wartości ciasteczka (tj. system zrób to sam). Oto najprostsze rozwiązanie bazujące na loginie przetrzymywanym w cookie:
class BaseHandler(tornado.web.RequestHandler):
def get_current_user(self):
return self.get_secure_cookie("user")
class MainHandler(BaseHandler):
def get(self):
if not self.current_user:
self.redirect("/login")
return
name = tornado.escape.xhtml_escape(self.current_user)
self.write("Witaj, " + name)
class LoginHandler(BaseHandler):
def get(self):
self.write('<html><body><form action="/login" method="post">'
'Name: <input type="text" name="name">'
'<input type="submit" value="Zaloguj">'
'</form></body></html>')
def post(self):
self.set_secure_cookie("user", self.get_argument("name"))
self.redirect("/")
application = tornado.web.Application([
(r"/", MainHandler),
(r"/login", LoginHandler),
], cookie_secret="61oETzKXQAGaYdkL5gEmGeJJFuYh7EQnp2XdTP1o/Vo=")
Można także wymusić zalogowanie użytkownika na określone widoki za pomocą dekoratora tornado.web.authenticated. Jeżeli użytkownik nie będzie zalogowany to zostanie przekierowany na adres określony w login_url. Oto przykład:
class MainHandler(BaseHandler):
@tornado.web.authenticated
def get(self):
name = tornado.escape.xhtml_escape(self.current_user)
self.write("Witaj, " + name)
settings = {
"cookie_secret": "61oETzKXQAGaYdkL5gEmGeJJFuYh7EQnp2XdTP1o/Vo=",
"login_url": "/login",
}
application = tornado.web.Application([
(r"/", MainHandler),
(r"/login", LoginHandler),
], **settings)
Pliki statyczne
Ustawiając w ustawieniach zmienną static_path umożliwimy obsługę plików statycznych, które będą dostępne przez URL */static/. Także /robots.txt i /favicon.ico będą serwerowane z podanego katalogu statycznego. Oto przykład konfiguracji:
settings = {
"static_path": os.path.join(os.path.dirname(__file__), "static"),
"cookie_secret": "61oETzKXQAGaYdkL5gEmGeJJFuYh7EQnp2XdTP1o/Vo=",
"login_url": "/login",
}
application = tornado.web.Application([
(r"/", MainHandler),
(r"/login", LoginHandler),
], **settings)
<html>
<head>
<title>FriendFeed - {{ _("Home") }}</title>
</head>
<body>
<div><img src="{{ static_url("images/logo.png") }}"/></div>
</body>
</html>
location /static/ {
root /ścieżka/do/static;
if ($query_string) {
expires max;
}
}
Nieblokujące, asynchroniczne żądania
Gdy obsługa żądania HTTP zostanie wykonana - żądanie jest automatycznie kończone, a klient otrzymuje treść strony. Tornado pozwala jednak na obsługę nieblokujących się asynchronicznych połączeń HTTP, które pozostają otwarte, aż ich nie zakończymy.
class MainHandler(tornado.web.RequestHandler):
@tornado.web.asynchronous
def get(self):
self.write("Hello, world")
self.finish()
class MainHandler(tornado.web.RequestHandler):
@tornado.web.asynchronous
def get(self):
http = tornado.httpclient.AsyncHTTPClient()
http.fetch("http://friendfeed-api.com/v2/feed/bret",
callback=self.async_callback(self.on_response))
def on_response(self, response):
if response.error: raise tornado.web.HTTPError(500)
json = tornado.escape.json_decode(response.body)
self.write("Fetched " + str(len(json["entries"])) + " entries "
"from the FriendFeed API")
self.finish()
Powyższy przykład nie przedstawia realnego zastosowania asynchronicznych żądań. Za pomocą "algorytmu" długiego pobierania (long polling), można wykonać np. chat działający w czasie rzeczywistym, a przykład takiego chatu w Tornado jest dostępny online, jak i w katalogu examples (tornado-*/demos/chat). W odróżnieniu od rozwiązań typowych dla COMETa jest to znacznie prostsze i nie wymaga oddzielnego asynchronicznego serwera COMET (np. Orbited).
Autoryzacja względem zewnętrznych usług
Tornado ma wbudowaną obsługę autoryzacji, autentykacji względem zewnętrznych usług: Facebook Connect, Twitter, Google, FriendFeed oraz OAuth i OpenID. Oto przykład wykorzystania autoryzacji względem aplikacji Facebook Connect:
# -*- coding: utf-8 -*-
import tornado.httpserver
import tornado.ioloop
import tornado.web
import tornado.auth
class MainHandler(tornado.web.RequestHandler, tornado.auth.FacebookMixin):
@tornado.web.asynchronous
def get(self):
if self.get_argument("session", None):
self.get_authenticated_user(self.async_callback(self._on_auth))
return
self.authenticate_redirect()
def _on_auth(self, user):
if not user: raise tornado.web.HTTPError(500, "Auth failed")
self.set_secure_cookie("uid", user["uid"])
self.set_secure_cookie("session_key", user["session_key"])
self.redirect("/")
settings = {
"facebook_api_key": 'KLUCZ_APLIKACJI_FB_CONNECT',
}
application = tornado.web.Application([
(r"/", MainHandler),
], **settings)
if __name__ == "__main__":
http_server = tornado.httpserver.HTTPServer(application)
http_server.listen(8888)
tornado.ioloop.IOLoop.instance().start()
Hosting aplikacji Tornado w warunkach produkcyjnych
FriendFeed hostuje serwery tornado ustawione za Nginxem służącym jako loadbalancer i serwer serwujący statykę. Oto prosty szkielet konfiguracji Tornado+Nginx:
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
# Enumerate all the Tornado servers here
upstream frontends {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
server 127.0.0.1:8003;
}
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
keepalive_timeout 65;
proxy_read_timeout 200;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
gzip on;
gzip_min_length 1000;
gzip_proxied any;
gzip_types text/plain text/html text/css text/xml
application/x-javascript application/xml
application/atom+xml text/javascript;
# Only retry if there was a communication error, not a timeout
# on the Tornado server (to avoid propagating "queries of death"
# to all frontends)
proxy_next_upstream error;
server {
listen 80;
# Allow file uploads
client_max_body_size 50M;
location ^~ /static/ {
root /var/www;
if ($query_string) {
expires max;
}
}
location = /favicon.ico {
rewrite (.*) /static/favicon.ico;
}
location = /robots.txt {
rewrite (.*) /static/robots.txt;
}
location / {
proxy_pass_header Server;
proxy_set_header Host $http_host;
proxy_redirect false;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_pass http://frontends;
}
}
}
Asynchroniczne żądania nie są obsługiwane na Google App Engine i serwerach WSGI (jako że ten protokół nie obsługuje nieblokujących się połączeń). Można odpalić Tornado na GAE, lecz bez tej funkcjonalności.
Comment article