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:

python setup.py install
Dodatkowo musimy zainstalować pycurl oraz dla Pythona 2.5 - simplejson (Python 2.6 posiada wbudowany moduł json).

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()
Następnie uruchom serwer Tornado za pomocą:
python run.py
Pod adresem http://localhost:8888/ powinieneś zobaczyć Tornado w akcji ;) Budową bardzo przypomina framework webapp z GAE, czy web.py. Widoki to klasy dziedziczące "tornado.web.RequestHandler" i posiadające zdefiniowaną metodę "get" i, lub "post" w zależności od typu żądań HTTP jakie ma obsługiwać. Po wprowadzeniu zmian w kodzie należy zrestartować serwer.

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
Niskopoziomowe moduły:
  • 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
Z "pozostałych" modułów Tornado zawiera:
  • 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 przypadku argumentów przesyłanych za pomocą POST, czy GET wystarczy użyć self.get_argument('nazwa_argumentu') by uzyskać dostęp do ich wartości. Mapowanie linków odbywa się podobnie jak w innych pythonowych frameworkach za pomocą wyrażeń regularnych. W tym przypadku należy zwrócić uwagę że system mapowania nie rozpoznaje i nie obsługuje nazw poszczególnych mapowanych zmiennych, a przydziela je do metody w kolejności ich wystąpienia. By wywołać widok FancyUrlHandler wystarczy otworzyć np: http://localhost:8888/foo/bar/4/.

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)
A przykładowy szablon wygląda tak:
<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>
Szablony obsługują if, for, while, try, które trzeba zakończyć za pomocą {% end %}. Obsługiwane jest także dziedziczenie szablonów poprzez extends i block. Wszystko zostało ładnie opisane w dokumentacji modułu szablonów.

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!")
Proste ciasteczka łatwo podrobić, dlatego do przechowywania ważnych informacji należy użyć zabezpieczonych ciasteczek za pomocą metod set_secure_cookie i get_secure_cookie. Dodatkowo trzeba podać "sól" o nazwie "cookie_secret" (długi łańcuch alfanumeryczny doklejany do haszowanych wartości w celu utrudnienia ich złamania):
application = tornado.web.Application([
    (r"/", MainHandler),
], cookie_secret="61oETzKXQAGaYdkL5gEmGeJJFuYh7EQnp2XdTP1o/Vo=")
Podpisane bezpieczne cookie zawierają zakodowaną wartość ciasteczka oraz znacznik czasu i podpis HMAC. Jeżeli ciasteczko jest przestarzałe lub podpis się nie zgadza get_secure_cookie zwróci None, tak jakby ciacho nie istniało.

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=")
Klasa BaseHandler dziedziczy standardową klasę tornado.web.RequestHandler i definiuje get_current_user. Wszystkie klasy widoków dziedziczą BaseHandler zyskując "obsługę" użytkownika.

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)
Jeżeli zastosujesz ten dekorator na metodzie post, a użytkownik nie będzie zalogowany to serwer zwróci odpowiedź 403.

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)
Żeby zwiększyć wydajność warto pozwolić przeglądarkom agresywnie keszować pliki statyczne, tak by nie wysyłały niepotrzebnie żądań If-Modified-Since czy Etag. Tornado obsługuje takie rozwiązanie poprzez metodę static_url() w szablonach, która implementuje wersjonowanie statycznej treści. Oto przykład:
<html>
   <head>
      <title>FriendFeed - {{ _("Home") }}</title>
   </head>
   <body>
     <div><img src="{{ static_url("images/logo.png") }}"/></div>
   </body>
 </html>
static_url przetworzy podaną względną ścieżkę na coś w stylu /static/images/logo.png?v=aae54. Wartość argumentu v to hasz pliku (logo.png). Jego obecność powoduje że Tornado wyśle nagłówki keszowania pozwalające przeglądarkom keszować je w nieskończoność. W warunkach produkcyjnych najlepiej przekazać serwowanie plików statycznych lepszemu serwerowi HTTP, np Nginxowi za pomocą takiej oto regułki:
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()
Dekorator @tornado.web.asynchronous powoduje, iż żądanie danego widoku będzie obsłużone asynchronicznie. Obsługując żądanie w ten sposób musimy je jakoś zamknąć za pomocą self.finish(). Poniżej bardziej realistyczny przykład z FriendFeeda:
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()
Wywołanie get() nie spowoduje zamknięcia połączenia HTTP. http.fetch pobiera dane poprzez API zewnętrznego serwisu (co może trwać jakiś czas) i po zakończeniu działania wywołuje metodę on_response, która zamyka połączenie wyświetlając w miarę możliwości dane. Wszystkie metody używane jako callbacks należy przepuszczać przez self.async_callback, które zapewni poprawną obsługę wyjątków rzucanych przez metodę.

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.

W sieci

RkBlog

Programowanie Sieciowe, 13 September 2009

Comment article
Comment article RkBlog main page Search RSS Contact