物聯網第五步: Tornado-異步與WebSockets

知識點

  • 理解同步與異步執行過程
  • 理解異步代碼的回調寫法與yield寫法
  • Tornado異步
    • 異步Web客戶端AsyncHTTPClient
    • tornado.web.asynchronous
    • tornado.gen.coroutine
    • 並行協程用法
  • WebSocket的使用

7.1 認識異步

1. 同步

我們用兩個函數來模擬兩個客戶端請求,並依次進行處理:

# coding:utf-8

def req_a():
    """模擬請求a"""
    print '開始處理請求req_a'
    print '完成處理請求req_a'

def req_b():
    """模擬請求b"""
    print '開始處理請求req_b'
    print '完成處理請求req_b'

def main():
    """模擬tornado框架,處理兩個請求"""
    req_a()
    req_b()

if __name__ == "__main__":
    main()

執行結果:

開始處理請求req_a
完成處理請求req_a
開始處理請求req_b
完成處理請求req_b

同步是按部就班的依次執行,始終按照同一個步調執行,上一個步驟未執行完不會執行下一步。

想一想,如果在處理請求req_a時需要執行一個耗時的工作(如IO),其執行過程如何?

# coding:utf-8

import time

def long_io():
    """模擬耗時IO操作"""
    print "開始執行IO操作"
    time.sleep(5)
    print "完成IO操作"
    return "io result"

def req_a():
    print "開始處理請求req_a"
    ret = long_io()
    print "ret: %s" % ret
    print "完成處理請求req_a"

def req_b():
    print "開始處理請求req_b"
    print "完成處理請求req_b"

def main():
    req_a()
    req_b()

if __name__=="__main__":
    main()

執行過程:

開始處理請求req_a
開始執行IO操作
完成IO操作
完成處理請求req_a
開始處理請求req_b
完成處理請求req_b

在上面的測試中,我們看到耗時的操作會將代碼執行阻塞住,即req_a未處理完req_b是無法執行的。

我們怎麼解決耗時操作阻塞代碼執行?

2. 異步

對於耗時的過程,我們將其交給別人(如其另外一個線程)去執行,而我們繼續往下處理,當別人執行完耗時操作後再將結果反饋給我們,這就是我們所說的異步。

我們用容易理解的線程機制來實現異步。

2.1 回調寫法實現原理

# coding:utf-8

import time
import thread

def long_io(callback):
    """將耗時的操作交給另一線程來處理"""
    def fun(cb): # 回調函數作爲參數
        """耗時操作"""
        print "開始執行IO操作"
        time.sleep(5)
        print "完成IO操作,並執行回調函數"
        cb("io result")  # 執行回調函數
    thread.start_new_thread(fun, (callback,))  # 開啓線程執行耗時操作

def on_finish(ret):
    """回調函數"""
    print "開始執行回調函數on_finish"
    print "ret: %s" % ret
    print "完成執行回調函數on_finish"

def req_a():
    print "開始處理請求req_a" 
    long_io(on_finish)
    print "離開處理請求req_a"

def req_b():
    print "開始處理請求req_b"
    time.sleep(2) # 添加此句來突出顯示程序執行的過程
    print "完成處理請求req_b"

def main():
    req_a()
    req_b()
    while 1: # 添加此句防止程序退出,保證線程可以執行完
        pass

if __name__ == '__main__':
    main()

執行過程:

開始處理請求req_a
離開處理請求req_a
開始處理請求req_b
開始執行IO操作
完成處理請求req_b
完成IO操作,並執行回調函數
開始執行回調函數on_finish
ret: io result
完成執行回調函數on_finish

異步的特點是程序存在多個步調,即本屬於同一個過程的代碼可能在不同的步調上同時執行。

2.2 協程寫法實現原理

在使用回調函數寫異步程序時,需將本屬於一個執行邏輯(處理請求a)的代碼拆分成兩個函數req_a和on_finish,這與同步程序的寫法相差很大。而同步程序更便於理解業務邏輯,所以我們能否用同步代碼的寫法來編寫異步程序?

回想yield關鍵字的作用?

初始版本

# coding:utf-8

import time
import thread

gen = None # 全局生成器,供long_io使用

def long_io():
    def fun():
        print "開始執行IO操作"
        global gen
        time.sleep(5)
        try:
            print "完成IO操作,並send結果喚醒掛起程序繼續執行"
            gen.send("io result")  # 使用send返回結果並喚醒程序繼續執行
        except StopIteration: # 捕獲生成器完成迭代,防止程序退出
            pass
    thread.start_new_thread(fun, ())

def req_a():
    print "開始處理請求req_a"
    ret = yield long_io()
    print "ret: %s" % ret
    print "完成處理請求req_a"

def req_b():
    print "開始處理請求req_b"
    time.sleep(2)
    print "完成處理請求req_b"

def main():
    global gen
    gen = req_a()
    gen.next() # 開啓生成器req_a的執行
    req_b()
    while 1:
        pass

if __name__ == '__main__':
    main()

執行過程:

開始處理請求req_a
開始處理請求req_b
開始執行IO操作
完成處理請求req_b
完成IO操作,並send結果喚醒掛起程序繼續執行
ret: io result
完成處理請求req_a

升級版本

我們在上面編寫出的版本雖然req_a的編寫方式很類似與同步代碼,但是在main中調用req_a的時候卻不能將其簡單的視爲普通函數,而是需要作爲生成器對待。

現在,我們試圖嘗試修改,讓req_a與main的編寫都類似與同步代碼。

# coding:utf-8

import time
import thread

gen = None # 全局生成器,供long_io使用

def gen_coroutine(f):
    def wrapper(*args, **kwargs):
        global gen
        gen = f()
        gen.next()
    return wrapper

def long_io():
    def fun():
        print "開始執行IO操作"
        global gen
        time.sleep(5)
        try:
            print "完成IO操作,並send結果喚醒掛起程序繼續執行"
            gen.send("io result")  # 使用send返回結果並喚醒程序繼續執行
        except StopIteration: # 捕獲生成器完成迭代,防止程序退出
            pass
    thread.start_new_thread(fun, ())

@gen_coroutine
def req_a():
    print "開始處理請求req_a"
    ret = yield long_io()
    print "ret: %s" % ret
    print "完成處理請求req_a"

def req_b():
    print "開始處理請求req_b"
    time.sleep(2)
    print "完成處理請求req_b"

def main():
    req_a()
    req_b()
    while 1:
        pass

if __name__ == '__main__':
    main()

執行過程:

開始處理請求req_a
開始處理請求req_b
開始執行IO操作
完成處理請求req_b
完成IO操作,並send結果喚醒掛起程序繼續執行
ret: io result
完成處理請求req_a

最終版本

剛剛完成的版本依然不理想,因爲存在一個全局變量gen來供long_io使用。我們現在再次改寫程序,消除全局變量gen。

# coding:utf-8

import time
import thread

def gen_coroutine(f):
    def wrapper(*args, **kwargs):
        gen_f = f()  # gen_f爲生成器req_a
        r = gen_f.next()  # r爲生成器long_io
        def fun(g):
            ret = g.next() # 執行生成器long_io
            try:
                gen_f.send(ret) # 將結果返回給req_a並使其繼續執行
            except StopIteration:
                pass
        thread.start_new_thread(fun, (r,))
    return wrapper

def long_io():
    print "開始執行IO操作"
    time.sleep(5)
    print "完成IO操作,yield回操作結果"
    yield "io result"

@gen_coroutine
def req_a():
    print "開始處理請求req_a"
    ret = yield long_io()
    print "ret: %s" % ret
    print "完成處理請求req_a"

def req_b():
    print "開始處理請求req_b"
    time.sleep(2)
    print "完成處理請求req_b"

def main():
    req_a()
    req_b()
    while 1:
        pass

if __name__ == '__main__':
    main()

執行過程:

開始處理請求req_a
開始處理請求req_b
開始執行IO操作
完成處理請求req_b
完成IO操作,yield回操作結果
ret: io result
完成處理請求req_a

這個最終版本就是理解Tornado異步編程原理的最簡易模型,但是,Tornado實現異步的機制不是線程,而是epoll,即將異步過程交給epoll執行並進行監視回調。

需要注意的一點是,我們實現的版本嚴格意義上來說不能算是協程,因爲兩個程序的掛起與喚醒是在兩個線程上實現的,而Tornado利用epoll來實現異步,程序的掛起與喚醒始終在一個線程上,由Tornado自己來調度,屬於真正意義上的協程。雖如此,並不妨礙我們理解Tornado異步編程的原理。

思考

  1. Tornado裏的異步就是協程,這句話對嗎?
  2. Tornado中出現yield就是異步,這句話對嗎?
  3. 怎麼理解yield將程序掛起?在Tornado中又如何理解yield掛起程序實現異步?

7.2 Tornado異步

因爲epoll主要是用來解決網絡IO的併發問題,所以Tornado的異步編程也主要體現在網絡IO的異步上,即異步Web請求。

1. tornado.httpclient.AsyncHTTPClient

Tornado提供了一個異步Web請求客戶端tornado.httpclient.AsyncHTTPClient用來進行異步Web請求。

fetch(request, callback=None)

用於執行一個web請求request,並異步返回一個tornado.httpclient.HTTPResponse響應。

request可以是一個url,也可以是一個tornado.httpclient.HTTPRequest對象。如果是url,fetch會自己構造一個HTTPRequest對象。

HTTPRequest

HTTP請求類,HTTPRequest的構造函數可以接收衆多構造參數,最常用的如下:

  • url (string) – 要訪問的url,此參數必傳,除此之外均爲可選參數
  • method (string) – HTTP訪問方式,如“GET”或“POST”,默認爲GET方式
  • headers (HTTPHeaders or dict) – 附加的HTTP協議頭
  • body – HTTP請求的請求體

HTTPResponse

HTTP響應類,其常用屬性如下:

  • code: HTTP狀態碼,如 200 或 404
  • reason: 狀態碼描述信息
  • body: 響應體字符串
  • error: 異常(可有可無)

2. 測試接口

新浪IP地址庫

接口說明

1.請求接口(GET):

http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=[ip地址字串]

2.響應信息:

(json格式的)國家 、省(自治區或直轄市)、市(縣)、運營商

3.返回數據格式:

{"ret":1,"start":-1,"end":-1,"country":"\u4e2d\u56fd","province":"\u5317\u4eac","city":"\u5317\u4eac","district":"","isp":"","type":"","desc":""}

3. 回調異步

class IndexHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous  # 不關閉連接,也不發送響應
    def get(self):
        http = tornado.httpclient.AsyncHTTPClient()
        http.fetch("http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=14.130.112.24",
                   callback=self.on_response)

    def on_response(self, response):
        if response.error:
            self.send_error(500)
        else:
            data = json.loads(response.body)
            if 1 == data["ret"]:
                self.write(u"國家:%s 省份: %s 城市: %s" % (data["country"], data["province"], data["city"]))
            else:
                self.write("查詢IP信息錯誤")
        self.finish() # 發送響應信息,結束請求處理

tornado.web.asynchronous

此裝飾器用於回調形式的異步方法,並且應該僅用於HTTP的方法上(如get、post等)。

此裝飾器不會讓被裝飾的方法變爲異步,而只是告訴框架被裝飾的方法是異步的,當方法返回時響應尚未完成。只有在request handler調用了finish方法後,纔會結束本次請求處理,發送響應。

不帶此裝飾器的請求在get、post等方法返回時自動完成結束請求處理。

4. 協程異步

在上一節中我們自己封裝的裝飾器get_coroutine在Tornado中對應的是tornado.gen.coroutine。

class IndexHandler(tornado.web.RequestHandler):
    @tornado.gen.coroutine
    def get(self):
        http = tornado.httpclient.AsyncHTTPClient()
        response = yield http.fetch("http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=14.130.112.24")
        if response.error:
            self.send_error(500)
        else:
            data = json.loads(response.body)
            if 1 == data["ret"]:
                self.write(u"國家:%s 省份: %s 城市: %s" % (data["country"], data["province"], data["city"]))
            else:
                self.write("查詢IP信息錯誤")

也可以將異步Web請求單獨出來:

class IndexHandler(tornado.web.RequestHandler):
    @tornado.gen.coroutine
    def get(self):
        rep = yield self.get_ip_info("14.130.112.24")
        if 1 == rep["ret"]:
            self.write(u"國家:%s 省份: %s 城市: %s" % (rep["country"], rep["province"], rep["city"]))
        else:
            self.write("查詢IP信息錯誤")

    @tornado.gen.coroutine
    def get_ip_info(self, ip):
        http = tornado.httpclient.AsyncHTTPClient()
        response = yield http.fetch("http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=" + ip)
        if response.error:
            rep = {"ret:0"}
        else:
            rep = json.loads(response.body)
        raise tornado.gen.Return(rep)  # 此處需要注意

代碼中我們需要注意的地方是get_ip_info返回值的方式,在python 2中,使用了yield的生成器可以使用不返回任何值的return,但不能return value,因此Tornado爲我們封裝了用於在生成器中返回值的特殊異常tornado.gen.Return,並用raise來返回此返回值。

並行協程

Tornado可以同時執行多個異步,併發的異步可以使用列表或字典,如下:

class IndexHandler(tornado.web.RequestHandler):
    @tornado.gen.coroutine
    def get(self):
        ips = ["14.130.112.24",
            "15.130.112.24",
            "16.130.112.24",
            "17.130.112.24"]
        rep1, rep2 = yield [self.get_ip_info(ips[0]), self.get_ip_info(ips[1])]
        rep34_dict = yield dict(rep3=self.get_ip_info(ips[2]), rep4=self.get_ip_info(ips[3]))
        self.write_response(ips[0], rep1) 
        self.write_response(ips[1], rep2) 
        self.write_response(ips[2], rep34_dict['rep3']) 
        self.write_response(ips[3], rep34_dict['rep4']) 

    def write_response(self, ip, response):
        self.write(ip) 
        self.write(":<br/>") 
        if 1 == response["ret"]:
            self.write(u"國家:%s 省份: %s 城市: %s<br/>" % (response["country"], response["province"], response["city"]))
        else:
            self.write("查詢IP信息錯誤<br/>")

    @tornado.gen.coroutine
    def get_ip_info(self, ip):
        http = tornado.httpclient.AsyncHTTPClient()
        response = yield http.fetch("http://int.dpool.sina.com.cn/iplookup/iplookup.php?format=json&ip=" + ip)
        if response.error:
            rep = {"ret:1"}
        else:
            rep = json.loads(response.body)
        raise tornado.gen.Return(rep)

5. 關於數據庫的異步說明

網站基本都會有數據庫操作,而Tornado是單線程的,這意味着如果數據庫查詢返回過慢,整個服務器響應會被堵塞。

數據庫查詢,實質上也是遠程的網絡調用;理想情況下,是將這些操作也封裝成爲異步的;但Tornado對此並沒有提供任何支持。

這是Tornado的設計,而不是缺陷。

一個系統,要滿足高流量;是必須解決數據庫查詢速度問題的!

數據庫若存在查詢性能問題,整個系統無論如何優化,數據庫都會是瓶頸,拖慢整個系統!

異步並不能從本質上提到系統的性能;它僅僅是避免多餘的網絡響應等待,以及切換線程的CPU耗費。

如果數據庫查詢響應太慢,需要解決的是數據庫的性能問題;而不是調用數據庫的前端Web應用。

對於實時返回的數據查詢,理想情況下需要確保所有數據都在內存中,數據庫硬盤IO應該爲0;這樣的查詢才能足夠快;而如果數據庫查詢足夠快,那麼前端web應用也就無將數據查詢封裝爲異步的必要。

就算是使用協程,異步程序對於同步程序始終還是會提高複雜性;需要衡量的是處理這些額外複雜性是否值得。

如果後端有查詢實在是太慢,無法繞過,Tornaod的建議是將這些查詢在後端封裝獨立封裝成爲HTTP接口,然後使用Tornado內置的異步HTTP客戶端進行調用。

 

7.3 WebSocket

實時獲取消息:
1.前端輪詢:		
	後端服務器 /api/order/new
	有無立即回覆
2.長輪詢
	沒有數據改變時, 不做任何迴應
	當有數據改變時,服務器纔回復相應
3.websocket

WebSocket是HTML5規範中新提出的客戶端-服務器通訊協議,協議本身使用新的ws://URL格式。

WebSocket 是獨立的、創建在 TCP 上的協議,和 HTTP 的唯一關聯是使用 HTTP 協議的101狀態碼進行協議切換,使用的 TCP 端口是80,可以用於繞過大多數防火牆的限制。

WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端直接向客戶端推送數據而不需要客戶端進行請求,兩者之間可以創建持久性的連接,並允許數據進行雙向傳送。

目前常見的瀏覽器如 Chrome、IE、Firefox、Safari、Opera 等都支持 WebSocket,同時需要服務端程序支持 WebSocket。

1. Tornado的WebSocket模塊

Tornado提供支持WebSocket的模塊是tornado.websocket,其中提供了一個WebSocketHandler類用來處理通訊。

WebSocketHandler.open()

當一個WebSocket連接建立後被調用。

WebSocketHandler.on_message(message)

當客戶端發送消息message過來時被調用,注意此方法必須被重寫

WebSocketHandler.on_close()

當WebSocket連接關閉後被調用。

WebSocketHandler.write_message(message, binary=False)

向客戶端發送消息messagea,message可以是字符串或字典(字典會被轉爲json字符串)。若binary爲False,則message以utf8編碼發送;二進制模式(binary=True)時,可發送任何字節碼。

WebSocketHandler.close()

關閉WebSocket連接。

WebSocketHandler.check_origin(origin)

判斷源origin,對於符合條件(返回判斷結果爲True)的請求源origin允許其連接,否則返回403。可以重寫此方法來解決WebSocket的跨域請求(如始終return True)。

2. 前端JavaScript編寫

在前端JS中使用WebSocket與服務器通訊的常用方法如下:

var ws = new WebSocket("ws://127.0.0.1:8888/websocket"); // 新建一個ws連接
ws.onopen = function() {  // 連接建立好後的回調
   ws.send("Hello, world");  // 向建立的連接發送消息
};
ws.onmessage = function (evt) {  // 收到服務器發送的消息後執行的回調
   alert(evt.data);  // 接收的消息內容在事件參數evt的data屬性中
};

3. 在線聊天室的小Demo

後端代碼 server.py

# coding:utf-8

import tornado.web
import tornado.ioloop
import tornado.httpserver
import tornado.options
import os
import datetime

from tornado.web import RequestHandler
from tornado.options import define, options
from tornado.websocket import WebSocketHandler

define("port", default=8000, type=int)

class IndexHandler(RequestHandler):
    def get(self):
        self.render("index.html")

class ChatHandler(WebSocketHandler):

    users = set()  # 用來存放在線用戶的容器

    def open(self):
        self.users.add(self)  # 建立連接後添加用戶到容器中
        for u in self.users:  # 向已在線用戶發送消息
            u.write_message(u"[%s]-[%s]-進入聊天室" % (self.request.remote_ip, datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")))

    def on_message(self, message):
        for u in self.users:  # 向在線用戶廣播消息
            u.write_message(u"[%s]-[%s]-說:%s" % (self.request.remote_ip, datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), message))

    def on_close(self):
        self.users.remove(self) # 用戶關閉連接後從容器中移除用戶
        for u in self.users:
            u.write_message(u"[%s]-[%s]-離開聊天室" % (self.request.remote_ip, datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")))

    def check_origin(self, origin):
        return True  # 允許WebSocket的跨域請求

if __name__ == '__main__':
    tornado.options.parse_command_line()
    app = tornado.web.Application([
            (r"/", IndexHandler),
            (r"/chat", ChatHandler),
        ],
        static_path = os.path.join(os.path.dirname(__file__), "static"),
        template_path = os.path.join(os.path.dirname(__file__), "template"),
        debug = True
        )
    http_server = tornado.httpserver.HTTPServer(app)
    http_server.listen(options.port)
    tornado.ioloop.IOLoop.current().start()

前端代碼index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>聊天室</title>
</head>
<body>
    <div id="contents" style="height:500px;overflow:auto;"></div>
    <div>
        <textarea id="msg"></textarea>
        <a href="javascript:;" onclick="sendMsg()">發送</a>
    </div>
    <script src="{{static_url('js/jquery.min.js')}}"></script>
    <script type="text/javascript">
        var ws = new WebSocket("ws://192.168.114.177:8000/chat");
        ws.onmessage = function(e) {
            $("#contents").append("<p>" + e.data + "</p>");
        }
        function sendMsg() {
            var msg = $("#msg").val();
            ws.send(msg);
            $("#msg").val("");
        }
    </script>
</body>
</html>

7.4 練習

  1. 請解釋清同步、異步、yield、協程幾個概念和Tornado實現異步的原理。

  2. 練習使用Tornado異步Web客戶端。

  3. 練習使用WebSocket。

  4. 修改WebSocket案例中的在線聊天代碼,將獲取到的用戶IP利用異步客戶端查詢歸屬地,並將消息顯示格式爲

[城市網友]-[IP]-[時間]:消息
如
[北京網友]-[14.130.112.24]-[2016-08-29 00:00:00]: 你好,Python

注意,局域網的內網IP沒有歸屬地,只需寫出代碼即可,不用測試。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章