自定義socket實現HTTP

自定義socket實現HTTP

Web服務的本質3
之前已經帶過一點了,下面使用socket發一個請求並且接收返回的數據。去掉模塊的封裝,從比較底層的層面瞭解一下其中的過程。

HTTP/1.0

用socket自定義http請求:

import socket
from bs4 import BeautifulSoup

client = socket.socket()
# 連接
client.connect(('edu.51cto.com', 80))
# 發送
header = b'GET / HTTP/1.0\r\nHost: edu.51cto.com\r\n\r\n'
client.sendall(header)
# 接收
data = client.recv(1024)
content = b''
while data:
    content += data
    data = client.recv(1024)
head, body = content.split(b'\r\n\r\n')
print(head)
print(len(body), body)
soup = BeautifulSoup(body.decode(), features='html.parser')
title = soup.find('title')
print(title)

這裏發送的請求頭裏有HTTP的版本 "HTTP/1.0" ,所以返回的響應頭裏有這個 “Connection: close” ,這是一個短連接,接收數據就是上面的方式可以判斷服務端是否傳完了。接收數據到最後會收到一個空,就表示收完了。這個空應該是socket連接斷開時發送的。

HTTP/1.1

如果發的HTTP請求版本是 “HTTP/1.1” ,返回的響應頭裏會有這些 “Transfer-Encoding: chunked\r\nConnection: keep-alive\r\n” 。然後在響應頭和響應體之間會是這個 “\r\n\r\n2b0\r\n” ,前面的 “\r\n\r\n” 是響應頭和響應體的分隔符,關鍵是中間的數字,這個是之後要發送的16進制字節數。也就是說這裏的數據是分段發送的。每段數據都是前面是字節數,後面是數據,並且這裏的分隔符也是 “\r\n” 。大概是這個樣子的:

響應頭
\r\n\r\n
2b0\r\n(0x2b0個字符)\r\n
27e4\r\n(0x27e4個字符)\r\n
1c7c\r\n(0x1c7c個字符)\r\n
0\r\n\r\n

像上面這樣,最後是會發一個空的,所以是以 "\r\n0\r\n\r\n" 結尾。下面是自己寫的實現拼接head和body的方法:

import socket
from bs4 import BeautifulSoup

client = socket.socket()
# 連接
client.connect(('edu.51cto.com', 80))
# 發送
header = b'GET / HTTP/1.1\r\nHost: edu.51cto.com\r\n\r\n'
client.sendall(header)
# 接收
data = client.recv(1024)
body = b''

# 獲取請求頭
while len(data.split(b'\r\n\r\n', 1)) != 2:
    data += client.recv(1024)
head, data = data.split(b'\r\n\r\n', 1)
print('HEAD', head)

# 拼接body
while data != b'0\r\n\r\n':
    while len(data.split(b'\r\n', 1)) != 2:
        data += client.recv(1024)
    l, b = data.split(b'\r\n', 1)
    length = int(l, base=16)
    if len(b) <= length:
        body += b
        length -= len(b)
        # 一下子把整段數據剩餘的部分都讀完
        # length 可能會很長,但是可能一次收不全,所以得用循環和計數直到收完
        while length:
            b = client.recv(length)
            body += b
            length -= len(b)
        data = b''
    else:
        body += b[:length]
        data = b[length:]

    # 把下一段body開頭的 b'\r\n' 切掉
    while len(data) < 2:
        data += client.recv(1024)
    else:
        # 這個斷言可以驗證之前的邏輯是否有問題
        assert data[0:2] == b'\r\n', b'data error: %d %b' % (len(data), data)
        data = data[2:]

print(len(body), body)
soup = BeautifulSoup(body.decode(), features='html.parser')
title = soup.find('title')
print(title)

驗證body接收是否正確,可以和上面的HTTP/1.0的結果對比一下,看一下body的長度。

自定義異步IO實現HTTP

先補充點 selector 模塊的知識,再用異步的 socket 實現 HTTP 請求。

補充知識(fileno() 方法)

select 和 selectors 模塊裏,需要把創建的socket實例放到監聽的列表裏。這裏,可以添加到監聽列表裏的可以不是原生的socket實例。這裏可以是 fd 也可以是一個擁有 fileno() 方法的對象。
fd : 文件描述符,是一個整數,它是文件對象的 fileno() 方法的返回值。
這裏,我們不僅要把socket對象加到監聽列表裏,還需要給它綁定一些別的屬性。這就需要對socket封裝一下。寫一個自己類,加一寫自己的屬性以及一個socket對象的實例屬性。關鍵是在類裏實現一個 fileno() 方法,該方法原樣返回 socket 實例的 fileno() 方法就可以了:

class HttpResponse(object):
    """接收一個實例化好的socket對象,在封裝一些別的數據"""

    def __init__(self, sk, item):
        self.sk = sk
        self.item = item

    def fileno(self):
        """請求sockect對象的文件描述符,用於select監聽"""
        return self.sk.fileno()

selector 模塊

官方文檔:https://docs.python.org/3/library/selectors.html

模塊定義了一個 BaseSelector 的抽象基類,以及它的子類,包括:SelectSelector,PollSelector,EpollSelector,DevpollSelector,KqueueSelector。
另外還有一個DefaultSelector類,它其實是以上其中一個子類的別名而已,它自動選擇爲當前環境中最有效的Selector,所以平時用 DefaultSelector類就可以了,其它用不着。

# 用之前先創建實例
sel = selectors.DefaultSelector()

模塊定義了兩個常量,在註冊事件的時候定義響應哪類事件:

  • EVENT_READ : 表示可讀的; 它的值其實是1
  • EVENT_WRITE : 表示可寫的; 它的值其實是2

上面兩個常量是位掩碼,是這樣定義的:

# generic events, that must be mapped to implementation-specific ones
EVENT_READ = (1 << 0)
EVENT_WRITE = (1 << 1)

所以應該也可以同時監聽兩個事件, EVENT_READ+EVENT_WRITE ,也就是3。

抽象基類中的註冊事件的方法
fileobj上一小節講了,傳入socket對象或者是其他實現了 fileno() 方法對象。events參數就是上面的兩個常量。data參數在select方法裏會返回:

register(fileobj, events, data=None)  # 註冊一個文件對象
unregister(fileobj)  # 註銷一個已經註冊過的文件對象
modify(fileobj, events, data=None)  # 用於修改一個註冊過的文件對象,比如從監聽可讀變爲監聽可寫。

一個文件對象只能註冊一個事件。註冊的事件可以調用上面的 unregister 方法註銷。
另外如果要改變文件對象監聽的 event ,則調用上面的 modify 方法。它其實就是 register + unregister,但是使用modify更高效。

抽象基類中的其他方法
select(timeout=None) :用於選擇滿足我們監聽的event的文件對象。
這個方法如果不設置參數就是阻塞的。返回1個元組 (key, mask)
key 就是一個SelectorKey類的實例,
key.fileobj 就是註冊方法的第一個參數,也就是傳入的文件對象,比如socket對象。
key.data 就是註冊方式的第三個參數,一般可以把回調函數傳進去。
mask 就是 EVENT 事件的常量,1、2或者也可能是3。

close() : 關閉 selector。

get_key(fileobj) : 返回註冊文件對象的 key,返回的是 SelectorKey 類的實例,同select方法裏的key。

實現異步 IO 的 HTTP

import selectors
import socket
from bs4 import BeautifulSoup

url_list = [
    {'host': 'edu.51cto.com', 'port': 80, },
    {'host': 'www.baidu.com', 'port': 80, },
    {'host': 'www.python-requests.org', 'port': 80, 'url': '/en/master/'},
    {'host': 'open-falcon.org', 'port': 80, 'url': '/'},
    {'host': 'www.jetbrains.com', 'port': 80},
]

class HttpSocket(object):
    """接收一個實例化好的socket對象,在封裝一些別的數據"""

    def __init__(self, sk, item):
        self.sk = sk
        self.item = item
        self.host = self.item.get('host')
        self.port = self.item.get('port', 80)
        self.method = self.item.get('method', 'GET')
        self.url = self.item.get('url', '/')
        self.body = self.item.get('body', '')
        self.callback = self.item.get('callback')
        self.buffer = []  # 請求的返回值記錄在這裏

    def fileno(self):
        """請求sockect對象的文件描述符,用於select監聽"""
        return self.sk.fileno()

    def create_request_header(self):
        """創建請求信息"""
        request = '%s %s HTTP/1.0\r\nHost: %s\r\n\r\n%s' % (self.method.upper(), self.url, self.host, self.body)
        return request.encode('utf-8')

    def write(self, data):
        """把接收到的數據寫入 self.buffer"""
        self.buffer.append(data)

    def finish(self):
        """接收完畢後執行的函數"""
        content = b''.join(self.buffer)
        head, body = content.split(b'\r\n\r\n', 1)
        print(head)
        print(len(body), body)
        soup = BeautifulSoup(body.decode(), features='html.parser')
        title = soup.find('title')
        print(title)

class AsyncRequest(object):

    def __init__(self):
        self.sel = selectors.DefaultSelector()

    def add_request(self, item):
        """創建連接請求"""
        host = item.get('host')
        port = item.get('port')
        client = socket.socket()
        client.setblocking(False)
        try:
            client.connect((host, port))
        except BlockingIOError as e:
            pass  # 至此,已經向服務器發出連接請求了
        hsk = HttpSocket(client, item)
        self.sel.register(hsk, selectors.EVENT_WRITE, self.connect)
        # 不同同時註冊2個事件,下面的註冊要等到連接建立之後執行
        # self.sel.register(sk, selectors.EVENT_READ, self.accept)

    def connect(self, hsk, mask):
        """建立連接後的回調函數
        發送請求,然後註冊 EVENT_READ 事件
        """
        print("連接成功:", hsk.item)
        content = hsk.create_request_header()
        print("發送請求:", content)
        hsk.sk.sendall(content)
        self.sel.modify(hsk, selectors.EVENT_READ, self.accept)

    def accept(self, hsk, mask):
        """接收請求返回的內容"""
        # print("返回信息:", hsk.item)
        data = hsk.sk.recv(1024)
        if data:
            hsk.write(data)
        else:
            print("接收完畢", hsk.item)
            hsk.finish()
            self.sel.unregister(hsk)

    def run(self):
        """主函數"""
        while self.sel._fd_to_key:
            events = self.sel.select()
            for key, mask in events:
                callback = key.data  # key.data就是sel.register裏的第三個參數
                callback(key.fileobj, mask)  # key.fileobj就是sel.register裏第一次參數

if __name__ == '__main__':
    obj = AsyncRequest()
    for url_dic in url_list:
        obj.add_request(url_dic)
    obj.run()

接收完畢之後,最後執行的函數,這裏是調用finish函數。這個函數最好可以自定義,那麼就需要在搞一個callback參數。思路大概是這樣的,最後就在finish函數裏先可以做一些處理。然後判斷一下,如果有callback,則調用callback。否則繼續之後finish裏之後的代碼。
這個callback參數在哪裏設置似乎在實現上都沒問題:
可以在url_list里加,在HttpSocket的構造函數裏提取出來。
或者是先給 AsyncRequest 類的構造函數,然後在add_request方法裏實例化HttpSocket的時候再傳過去。
再或者給add_request再加個參數,也是在add_request方法裏實例化HttpSocket的時候再傳過去。

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