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

2018-02-24 15:49 更新

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

5.1 異步Web請(qǐng)求

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

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

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

圖5-1

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

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

5.1.1 從同步開始

代碼清單5-1包含我們的推率計(jì)算器的同步版本的代碼。記住我們?cè)陧敳繉?dǎo)入了Tornado的httpclient模塊:我們將使用這個(gè)模塊的HTTPClient類來執(zhí)行HTTP請(qǐng)求。之后,我們將使用這個(gè)模塊的AsyncHTTPClient。

代碼清單5-1 同步HTTP請(qǐng)求: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()

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

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

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

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

5.1.2 阻塞的困擾

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

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

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

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

圖5-2

圖5-2 同步推率獲取

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

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

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

代碼清單5-2 異步HTTP請(qǐng)求: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é)果。取而代之的是它指定了一個(gè)callback參數(shù);你指定的方法或函數(shù)將在HTTP請(qǐng)求完成時(shí)被調(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)

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

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

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

圖5-3

圖5-3 異步推率獲取

5.1.4 異步裝飾器和finish方法

Tornado默認(rèn)在函數(shù)處理返回時(shí)關(guān)閉客戶端的連接。在通常情況下,這正是你想要的。但是當(dāng)我們處理一個(gè)需要回調(diào)函數(shù)的異步請(qǐng)求時(shí),我們需要連接保持開啟狀態(tài)直到回調(diào)函數(shù)執(zhí)行完畢。你可以在你想改變其行為的方法上面使用@tornado.web.asynchronous裝飾器來告訴Tornado保持連接開啟,正如我們?cè)诋惒桨姹镜耐坡世又蠭ndexHandler的get方法中所做的。下面是相關(guān)的代碼片段:

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

記住當(dāng)你使用@tornado.web.asynchonous裝飾器時(shí),Tornado永遠(yuǎn)不會(huì)自己關(guān)閉連接。你必須在你的RequestHandler對(duì)象中調(diào)用finish方法來顯式地告訴Tornado關(guān)閉連接。(否則,請(qǐng)求將可能掛起,瀏覽器可能不會(huì)顯示我們已經(jīng)發(fā)送給客戶端的數(shù)據(jù)。)在前面的異步示例中,我們?cè)趏n_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)在,我們的推率程序的異步版本運(yùn)轉(zhuǎn)的不錯(cuò)并且性能也很好。不幸的是,它有點(diǎn)麻煩:為了處理請(qǐng)求 ,我們不得不把我們的代碼分割成兩個(gè)不同的方法。當(dāng)我們有兩個(gè)或更多的異步請(qǐng)求要執(zhí)行的時(shí)候,編碼和維護(hù)都顯得非常困難,每個(gè)都依賴于前面的調(diào)用:不久你就會(huì)發(fā)現(xiàn)自己調(diào)用了一個(gè)回調(diào)函數(shù)的回調(diào)函數(shù)的回調(diào)函數(shù)。下面就是一個(gè)構(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.]

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

代碼清單5-3 使用生成器模式的異步請(qǐng)求: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()

正如你所看到的,這個(gè)代碼和前面兩個(gè)版本的代碼非常相似。主要的不同點(diǎn)是我們?nèi)绾握{(diào)用Asynchronous對(duì)象的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對(duì)象的一個(gè)實(shí)例,將我們想要的調(diào)用和傳給該調(diào)用函數(shù)的參數(shù)傳遞給那個(gè)函數(shù)。這里,yield的使用返回程序?qū)ornado的控制,允許在HTTP請(qǐng)求進(jìn)行中執(zhí)行其他任務(wù)。當(dāng)HTTP請(qǐng)求完成時(shí),RequestHandler方法在其停止的地方恢復(fù)。這種構(gòu)建的美在于它在請(qǐng)求處理程序中返回HTTP響應(yīng),而不是回調(diào)函數(shù)中。因此,代碼更易理解:所有請(qǐng)求相關(guān)的邏輯位于同一個(gè)位置。而HTTP請(qǐng)求依然是異步執(zhí)行的,所以我們使用tornado.gen可以達(dá)到和使用回調(diào)函數(shù)的異步請(qǐng)求版本相同的性能,正如我們?cè)趫D5-4中所看到的那樣。

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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)