第五章:異步Web服務(wù)

2018-02-24 15:49 更新

到目前為止,我們已經(jīng)看到了許多使Tornado成為一個Web應(yīng)用強有力框架的功能。它的簡單性、易用性和便捷性使其有足夠的理由成為許多Web項目的不錯的選擇。然而,Tornado受到最多關(guān)注的功能是其異步取得和提供內(nèi)容的能力,它有著很好的理由:它使得處理非阻塞請求更容易,最終導致更高效的處理以及更好的可擴展性。在本章中,我們將看到Tornado異步請求的基礎(chǔ),以及一些推送技術(shù),這種技術(shù)可以使你使用更少的資源來提供更多的請求以編寫更簡單的Web應(yīng)用。

5.1 異步Web請求

大部分Web應(yīng)用(包括我們之前的例子)都是阻塞性質(zhì)的,也就是說當一個請求被處理時,這個進程就會被掛起直至請求完成。在大多數(shù)情況下,Tornado處理的Web請求完成得足夠快使得這個問題并不需要被關(guān)注。然而,對于那些需要一些時間來完成的操作(像大數(shù)據(jù)庫的請求或外部API),這意味著應(yīng)用程序被有效的鎖定直至處理結(jié)束,很明顯這在可擴展性上出現(xiàn)了問題。

不過,Tornado給了我們更好的方法來處理這種情況。應(yīng)用程序在等待第一個處理完成的過程中,讓I/O循環(huán)打開以便服務(wù)于其他客戶端,直到處理完成時啟動一個請求并給予反饋,而不再是等待請求完成的過程中掛起進程。

為了實現(xiàn)Tornado的異步功能,我們構(gòu)建一個向Twotter搜索API發(fā)送HTTP請求的簡單Web應(yīng)用。這個Web應(yīng)用有一個參數(shù)q作為查詢字符串,并確定多久會出現(xiàn)一條符合搜索條件的推文被發(fā)布在Twitter上("每秒推數(shù)")。確定這個數(shù)值的方法非常粗糙,但足以達到例子的目的。圖5-1展示了這個應(yīng)用的界面。

圖5-1

圖5-1 異步HTTP示例:推率

我們將展示這個應(yīng)用的三個不同版本:首先,是一個使用同步HTTP請求的版本,然后是一個使用帶有回調(diào)函數(shù)的Tornado異步HTTP客戶端版本。最后,我們將展示如何使用Tornado 2.1版本新增的gen模塊來使異步HTTP請求更加清晰和易實現(xiàn)。為了理解這些例子,你不需要成為關(guān)于Twitter搜索API的專家,但一定的熟悉不會有害。你可以在https://dev.twitter.com/docs/api/1/get/search閱讀關(guān)于搜索API的開發(fā)者文檔。

5.1.1 從同步開始

代碼清單5-1包含我們的推率計算器的同步版本的代碼。記住我們在頂部導入了Tornado的httpclient模塊:我們將使用這個模塊的HTTPClient類來執(zhí)行HTTP請求。之后,我們將使用這個模塊的AsyncHTTPClient。

代碼清單5-1 同步HTTP請求:tweet_rate.py

import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.httpclient

import urllib
import json
import datetime
import time

from tornado.options import define, options
define("port", default=8000, help="run on the given port", type=int)

class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        query = self.get_argument('q')
        client = tornado.httpclient.HTTPClient()
        response = client.fetch("http://search.twitter.com/search.json?" + \
                urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}))
        body = json.loads(response.body)
        result_count = len(body['results'])
        now = datetime.datetime.utcnow()
        raw_oldest_tweet_at = body['results'][-1]['created_at']
        oldest_tweet_at = datetime.datetime.strptime(raw_oldest_tweet_at,
                "%a, %d %b %Y %H:%M:%S +0000")
        seconds_diff = time.mktime(now.timetuple()) - \
                time.mktime(oldest_tweet_at.timetuple())
        tweets_per_second = float(result_count) / seconds_diff
        self.write("""
<div style="text-align: center">
    <div style="font-size: 72px">%s</div>
    <div style="font-size: 144px">%.02f</div>
    <div style="font-size: 24px">tweets per second</div>
</div>""" % (query, tweets_per_second))

if __name__ == "__main__":
    tornado.options.parse_command_line()
    app = tornado.web.Application(handlers=[(r"/", IndexHandler)])
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(options.port)
    tornado.ioloop.IOLoop.instance().start()

這個程序的結(jié)構(gòu)現(xiàn)在對你而言應(yīng)該已經(jīng)很熟悉了:我們有一個RequestHandler類和一個處理到應(yīng)用根路徑請求的IndexHandler。在IndexHandler的get方法中,我們從查詢字符串中抓取參數(shù)q,然后用它執(zhí)行一個到Twitter搜索API的請求。下面是最相關(guān)的一部分代碼:

client = tornado.httpclient.HTTPClient()
response = client.fetch("http://search.twitter.com/search.json?" + \
        urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}))
body = json.loads(response.body)

這里我們實例化了一個Tornado的HTTPClient類,然后調(diào)用結(jié)果對象的fetch方法。fetch方法的同步版本使用要獲取的URL作為參數(shù)。這里,我們構(gòu)建一個URL來抓取Twitter搜索API的相關(guān)搜索結(jié)果(rpp參數(shù)指定我們想獲得搜索結(jié)果首頁的100個推文,而result_type參數(shù)指定我們只想獲得匹配搜索的最近推文)。fetch方法會返回一個HTTPResponse對象,其?body屬性包含我們從遠端URL獲取的任何數(shù)據(jù)。Twitter將返回一個JSON格式的結(jié)果,所以我們可以使用Python的json模塊來從結(jié)果中創(chuàng)建一個Python數(shù)據(jù)結(jié)構(gòu)。

fetch方法返回的HTTPResponse對象允許你訪問HTTP響應(yīng)的任何部分,不只是body??梢栽?a rel="external nofollow" target="_blank" target="_blank">官方文檔[1]閱讀更多相關(guān)信息。

處理函數(shù)的其余部分關(guān)注的是計算每秒推文數(shù)。我們使用搜索結(jié)果中最舊推文與最新推文時間戳之差來確定搜索覆蓋的時間,然后使用這個數(shù)值除以搜索取得的推文數(shù)來獲得我們的最終結(jié)果。最后,我們編寫了一個擁有這個結(jié)果的簡單HTML頁面給瀏覽器。

5.1.2 阻塞的困擾

到目前為止,我們已經(jīng)編寫了 一個請求Twitter API并向瀏覽器返回結(jié)果的簡單Tornado應(yīng)用。盡管應(yīng)用程序本身響應(yīng)相當快,但是向Twitter發(fā)送請求到獲得返回的搜索數(shù)據(jù)之間有相當大的滯后。在同步(到目前為止,我們假定為單線程)應(yīng)用,這意味著同時只能提供一個請求。所以,如果你的應(yīng)用涉及一個2秒的API請求,你將每間隔一秒才能提供(最多!)一個請求。這并不是你所稱的高可擴展性應(yīng)用,即便擴展到多線程和/或多服務(wù)器 。

為了更具體的看出這個問題,我們對剛編寫的例子進行基準測試。你可以使用任何基準測試工具來驗證這個應(yīng)用的性能,不過在這個例子中我們使用優(yōu)秀的Siege utility工具進行測試。它可以這樣使用:

$ siege http://localhost:8000/?q=pants -c10 -t10s

在這個例子中,Siege對我們的應(yīng)用在10秒內(nèi)執(zhí)行大約10個并發(fā)請求,輸出結(jié)果如圖5-2所示。我們可以很容易看出,這里的問題是無論每個請求自身返回多么快,API往返都會以至于產(chǎn)生足夠大的滯后,因為進程直到請求完成并且數(shù)據(jù)被處理前都一直處于強制掛起狀態(tài)。當一兩個請求時這還不是一個問題,但達到100個(甚至10個)用戶時,這意味著整體變慢。

圖5-2

圖5-2 同步推率獲取

此時,不到10秒時間10個相似用戶的平均響應(yīng)時間達到了1.99秒,共計29次。請記住,這個例子只提供了一個非常簡單的網(wǎng)頁。如果你要添加其他Web服務(wù)或數(shù)據(jù)庫的調(diào)用的話,結(jié)果會更糟糕。這種代碼如果被 用到網(wǎng)站上,即便是中等強度的流量都會導致請求增長緩慢,甚至發(fā)生超時或失敗。

5.1.3 基礎(chǔ)異步調(diào)用

幸運的是,Tornado包含一個AsyncHTTPClient類,可以執(zhí)行異步HTTP請求。它和代碼清單5-1的同步客戶端實現(xiàn)有一定的相似性,除了一些我們將要討論的重要區(qū)別。代碼清單5-2是其源代碼。

代碼清單5-2 異步HTTP請求:tweet_rate_async.py

import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.httpclient

import urllib
import json
import datetime
import time

from tornado.options import define, options
define("port", default=8000, help="run on the given port", type=int)

class IndexHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous
    def get(self):
        query = self.get_argument('q')
        client = tornado.httpclient.AsyncHTTPClient()
        client.fetch("http://search.twitter.com/search.json?" + \
                urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}),
                callback=self.on_response)

    def on_response(self, response):
        body = json.loads(response.body)
        result_count = len(body['results'])
        now = datetime.datetime.utcnow()
        raw_oldest_tweet_at = body['results'][-1]['created_at']
        oldest_tweet_at = datetime.datetime.strptime(raw_oldest_tweet_at,
                "%a, %d %b %Y %H:%M:%S +0000")
        seconds_diff = time.mktime(now.timetuple()) - \
                time.mktime(oldest_tweet_at.timetuple())
        tweets_per_second = float(result_count) / seconds_diff
        self.write("""
<div style="text-align: center">
    <div style="font-size: 72px">%s</div>
    <div style="font-size: 144px">%.02f</div>
    <div style="font-size: 24px">tweets per second</div>
</div>""" % (self.get_argument('q'), tweets_per_second))
        self.finish()

if __name__ == "__main__":
    tornado.options.parse_command_line()
    app = tornado.web.Application(handlers=[(r"/", IndexHandler)])
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(options.port)
    tornado.ioloop.IOLoop.instance().start()

AsyncHTTPClient的fetch方法并不返回調(diào)用的結(jié)果。取而代之的是它指定了一個callback參數(shù);你指定的方法或函數(shù)將在HTTP請求完成時被調(diào)用,并使用HTTPResponse作為其參數(shù)。

client = tornado.httpclient.AsyncHTTPClient()
client.fetch("http://search.twitter.com/search.json?" + ?
urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}),
        callback=self.on_response)

在這個例子中,我們指定on_response方法作為回調(diào)函數(shù)。我們之前使用期望的輸出轉(zhuǎn)化Twitter搜索API請求到網(wǎng)頁中的所有邏輯被搬到了on_response函數(shù)中。還需要注意的是@tornado.web.asynchronous裝飾器的使用(在get方法的定義之前)以及在回調(diào)方法結(jié)尾處調(diào)用的self.finish()。我們稍后將簡要的討論他們的細節(jié)。

這個版本的應(yīng)用擁有和之前同步版本相同的外觀,但其性能更加優(yōu)越。有多好呢?讓我們看看基準測試的結(jié)果吧。

正如你在圖5-3中所看到的,我們從同步版本的每秒3.20個事務(wù)提升到了12.59,在相同的時間內(nèi)總共提供了118次請求。這真是一個非常大的改善!正如你所想象的,當擴展到更多用戶和更長時間時,它將能夠提供更多連接,并且不會遇到同步版本遭受的變慢的問題。

圖5-3

圖5-3 異步推率獲取

5.1.4 異步裝飾器和finish方法

Tornado默認在函數(shù)處理返回時關(guān)閉客戶端的連接。在通常情況下,這正是你想要的。但是當我們處理一個需要回調(diào)函數(shù)的異步請求時,我們需要連接保持開啟狀態(tài)直到回調(diào)函數(shù)執(zhí)行完畢。你可以在你想改變其行為的方法上面使用@tornado.web.asynchronous裝飾器來告訴Tornado保持連接開啟,正如我們在異步版本的推率例子中IndexHandler的get方法中所做的。下面是相關(guān)的代碼片段:

class IndexHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous
    def get(self):
        query = self.get_argument('q')
        [... other request handler code here...]

記住當你使用@tornado.web.asynchonous裝飾器時,Tornado永遠不會自己關(guān)閉連接。你必須在你的RequestHandler對象中調(diào)用finish方法來顯式地告訴Tornado關(guān)閉連接。(否則,請求將可能掛起,瀏覽器可能不會顯示我們已經(jīng)發(fā)送給客戶端的數(shù)據(jù)。)在前面的異步示例中,我們在on_response函數(shù)的write后面調(diào)用了finish方法:

    [... other callback code ...]
        self.write("""
<div style="text-align: center">
    <div style="font-size: 72px">%s</div>
    <div style="font-size: 144px">%.02f</div>
    <div style="font-size: 24px">tweets per second</div>
</div>""" % (self.get_argument('q'), tweets_per_second))
        self.finish()

5.1.5 異步生成器

現(xiàn)在,我們的推率程序的異步版本運轉(zhuǎn)的不錯并且性能也很好。不幸的是,它有點麻煩:為了處理請求 ,我們不得不把我們的代碼分割成兩個不同的方法。當我們有兩個或更多的異步請求要執(zhí)行的時候,編碼和維護都顯得非常困難,每個都依賴于前面的調(diào)用:不久你就會發(fā)現(xiàn)自己調(diào)用了一個回調(diào)函數(shù)的回調(diào)函數(shù)的回調(diào)函數(shù)。下面就是一個構(gòu)想出來的(但不是不可能的)例子:

def get(self):
    client = AsyncHTTPClient()
    client.fetch("http://example.com", callback=on_response)

def on_response(self, response):
    client = AsyncHTTPClient()
    client.fetch("http://another.example.com/", callback=on_response2)

def on_response2(self, response):
    client = AsyncHTTPClient()
    client.fetch("http://still.another.example.com/", callback=on_response3)

def on_response3(self, response):
    [etc., etc.]

幸運的是,Tornado 2.1版本引入了tornado.gen模塊,可以提供一個更整潔的方式來執(zhí)行異步請求。代碼清單5-3就是使用了tornado.gen版本的推率應(yīng)用源代碼。讓我們先來看一下,然后討論它是如何工作的。

代碼清單5-3 使用生成器模式的異步請求:tweet_rate_gen.py

import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.httpclient
import tornado.gen

import urllib
import json
import datetime
import time

from tornado.options import define, options
define("port", default=8000, help="run on the given port", type=int)

class IndexHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous
    @tornado.gen.engine
    def get(self):
        query = self.get_argument('q')
        client = tornado.httpclient.AsyncHTTPClient()
        response = yield tornado.gen.Task(client.fetch,
                "http://search.twitter.com/search.json?" + \
                urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}))
        body = json.loads(response.body)
        result_count = len(body['results'])
        now = datetime.datetime.utcnow()
        raw_oldest_tweet_at = body['results'][-1]['created_at']
        oldest_tweet_at = datetime.datetime.strptime(raw_oldest_tweet_at,
                "%a, %d %b %Y %H:%M:%S +0000")
        seconds_diff = time.mktime(now.timetuple()) - \
                time.mktime(oldest_tweet_at.timetuple())
        tweets_per_second = float(result_count) / seconds_diff
        self.write("""
<div style="text-align: center">
    <div style="font-size: 72px">%s</div>
    <div style="font-size: 144px">%.02f</div>
    <div style="font-size: 24px">tweets per second</div>
</div>""" % (query, tweets_per_second))
        self.finish()

if __name__ == "__main__":
    tornado.options.parse_command_line()
    app = tornado.web.Application(handlers=[(r"/", IndexHandler)])
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(options.port)
    tornado.ioloop.IOLoop.instance().start()

正如你所看到的,這個代碼和前面兩個版本的代碼非常相似。主要的不同點是我們?nèi)绾握{(diào)用Asynchronous對象的fetch方法。下面是相關(guān)的代碼部分:

client = tornado.httpclient.AsyncHTTPClient()
response = yield tornado.gen.Task(client.fetch,
        "http://search.twitter.com/search.json?" + \
        urllib.urlencode({"q": query, "result_type": "recent", "rpp": 100}))
body = json.loads(response.body)

我們使用Python的yield關(guān)鍵字以及tornado.gen.Task對象的一個實例,將我們想要的調(diào)用和傳給該調(diào)用函數(shù)的參數(shù)傳遞給那個函數(shù)。這里,yield的使用返回程序?qū)ornado的控制,允許在HTTP請求進行中執(zhí)行其他任務(wù)。當HTTP請求完成時,RequestHandler方法在其停止的地方恢復。這種構(gòu)建的美在于它在請求處理程序中返回HTTP響應(yīng),而不是回調(diào)函數(shù)中。因此,代碼更易理解:所有請求相關(guān)的邏輯位于同一個位置。而HTTP請求依然是異步執(zhí)行的,所以我們使用tornado.gen可以達到和使用回調(diào)函數(shù)的異步請求版本相同的性能,正如我們在圖5-4中所看到的那樣。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號