痛入爽出 HTTP/2:程式碼實戰1

Bug大師發表於2019-03-03

這一期全是乾貨。幹得你口渴想喝水。


環境搭建

  1. 安裝 Python。你可以選擇官網安裝、Anaconda安裝或者你已經有了 Python3.5 以上的版本。PyPy也可以的。
  2. 可選:建立一個 Python 虛擬環境(不知所云的直接忽略這一步)
  3. 建立我們的專案資料夾
# bash shell
mkdir gethy
cd gethy
複製程式碼

在 Windows 上的同學不用擔心,本教程的一切操作都是可以在 Windows、Linux 和 Mac 上完成的。
4. 建立測試路徑和原始碼路徑

mkdir gethy  # Python 界的約定俗成是在專案根目錄下建立一個同名的路徑來放原始碼
mkdir test
複製程式碼
  1. 安裝依賴
pip install h2
複製程式碼

我們要用到 Lukasa 大神寫的 hyper-h2 庫:https://github.com/python-hyper/hyper-h2

這個庫實現了 h2 協議的底層部分,包括:編碼解碼 TCP 層位元組串(hpack),建立並管理 HTTP 連線。
但是,這個庫並沒有實現 HTTP 應用層的方法(GET、POST)和語義(Requset & Response),也沒有實現 Flow Control(流量管理)和 Server Push(伺服器推送)。這些也是我們要實現的部分(除了 Server Push)

痛入爽出 HTTP/2:程式碼實戰1

我們可以看到,HTTP協議是5、6、7層的協議,但是 hyper-h2 只實現了5、6層的功能。Web 應用是沒有辦法直接使用 hyper-h2 。所以我們要在 hyper-h2 的基礎上,實現完整的 h2 協議。

關於網路協議和架構,請參考 What’s The Difference Between The OSI Seven-Layer Network Model And TCP/IP?

開始程式設計

定義 API

我們遵循自上而下的設計。先設計API,再實現函式。

touch http2protocol.py event.py
複製程式碼

用你最喜歡的編輯器開啟http2protocol.py,加入以下程式碼

class HTTP2Protocol:
    """
    A pure in-memory H2 implementation for application level development.
    It does not do IO.
    """
    
    def __init__(self):
        pass

    def receive(self, data: bytes):
        pass    

    def send(self, stream: Stream):
        pass
複製程式碼

我們的庫只有 2 個公開API,receivesend

receive 用來從 TCP 層獲取資料。send 將一個完整的 Stream 編碼為 TCP 可以直接接收的資料。

值得強調的是,這個庫不做任何 I/O。這種開發正規化叫做 I/O 獨立正規化。庫的使用者應該自己決定使用哪一種 IO 方式。這給予了開發者最大的靈活性。也符合 Clean Architecture 的原則。

hyper-h2 本身也是不做任何 IO 的,所以我們保留這個優良傳統。

英文裡叫 sans-IO model,請參考:http://sans-io.readthedocs.io

定義 Stream

除了HTTP2Protocol類,Stream類也是使用者會直接使用的類。

class Stream:
	def __init__(self, stream_id: int, headers: iterable):
		self.stream_id = stream_id
		self.headers = headers
		self.stream_ended = False
		self.buffered_data = []
		self.data = None
複製程式碼

看到這裡大家可能就會覺得很親切了。一個 Stream 其實就代表了一個常規的 HTTP Request 或者 Response。我們有常規的 headers,常規的 data(有些人叫 body)。與 HTTP/1.x 時代唯一不同的是,多了一個 stream id。

寫測試 TDD

Test Driven Development 與自上而下得到設計模式是密不可分的。現在我們有了API,寫測試同時也交代了 API 的使用方法。

cd ../test
touch test_all.py
複製程式碼

我們的庫很小,一個測試檔案就夠了。我們還需要一個幫助模組

wget https://raw.githubusercontent.com/CreatCodeBuild/gethy/master/test/helpers.py
複製程式碼

這個幫助模組是 Lusaka 大神在 hyper-h2 的測試中提供的。

我們現在來想象一下 gethy 的用法

# 虛擬碼
from gethy import HTTP2Protocol
import Socket
import SomeWebFramework

protocol = HTTP2Protocol()
socket = Socket()
while True:
    if socket.accept():
        while True:
            bytes = socket.receive()
            if bytes:
                requests = protocol.receive(bytes)
                for request in requests:
                    response = SomeWebFramework.handle(request)
                    bytes_to_send = protocol.send(response)
                    socket.send(bytes_to_send)
            else:
                break
複製程式碼

大家可以看到,我在這裡寫了一個虛擬碼的單執行緒阻塞式同步伺服器。我們的庫是完全不做 IO 的。一切IO都直接交給 Server 去完成。gethy 僅僅是在記憶體裡處理資料而已。上面的程式碼例子也清楚地展示了API的使用方式。

測試網路協議的實現的一大難點就在於 IO。如果類庫沒有 IO,那麼測試其實變得簡單了。那麼,我們來看看具體的測試怎麼寫吧。

# test_all.py
def test_receive_headers_only():
    pass
    
def test_receive_headers_and_data():
    pass

def test_send_headers_only():
    pass
    
def test_send_headers_and_data():
    pass
    
def test_send_huge_data():
    pass
    
def test_receive_huge_data():
    pass
複製程式碼

六個測試案例,測試了傳送接收與回覆請求。最後兩個測試使用巨大資料量,是為了測試 Flow Control 的正確性。我們目前可以不管。

先實現第一個

# test_all.py
from gethy import HTTP2Protocol
from gethy.event import RequestEvent

from helpers import FrameFactory

# 因為我們的測試很少,所以全域性變數也OK
frame_factory = FrameFactory()
protocol = HTTP2Protocol()
protocol.receive(frame_factory.preamble())  # h2 建立連線時要有的欄位
headers = [
	(`:method`, `GET`),
	(`:path`, `/`),
	(`:scheme`, `https`),  # scheme 和 schema 在英文中是同一個詞的不同寫法
	                       # 不過,一般在 h2 中用 shceme,說到資料模型時用 schema
	(`:authority`, `example.com`),
]


def test_receive_headers_only():
	"""
	able to receive headers with no data
	"""
	# 客戶端發起的 session 的 stream id 是單數
	# 伺服器發起的 session 的 stream id 是雙數
	# 一個 session 包含一對 request/response
	# id 0 代表整個 connection
	stream_id = 1
	
    # 在這裡手動生成一個 client 的 request frame,來模擬客戶端請求
	frame_from_client = frame_factory.build_headers_frame(headers, 
	                                                      stream_id=stream_id,
	                                                      flags=[`END_STREAM`])
	# 將資料結構序列化為 TCP 可接受的 bytes
	data = frame_from_client.serialize()
	# 伺服器端接收請求,得到一些 gethy 定義的事件
	events = protocol.receive(data)
    
    # 因為請求只有一個請求,所以僅可能有一個事件,且為 RequestEvent 事件
	assert len(events) == 1
	assert isinstance(events[0], RequestEvent)

	event = events[0]
	assert event.stream.stream_id == stream_id
	assert event.stream.headers == headers      #  驗證 Headers
	assert event.stream.data == b``             #  驗證沒有任何資料
	assert event.stream.buffered_data is None   #  驗證沒有任何資料
	assert event.stream.stream_ended is True    #  驗證請求完整(Stream 結束)
複製程式碼

閱讀上面的測試,大家可以基本上知道 gethy 的用法和 http2 的基本語義。大家可以發現,http2 的語義和 http1 基本沒有變化。唯一需要注意的就是 headers 裡4個:xxx字樣的 header。:冒號是協議使用的 header 符號。應用自定義的 header 不應該使用冒號。然後,雖然 http2 協議本身是允許大寫字母,並且是大小寫敏感的,但是 gethy 的依賴庫 hyper-h2 只允許小寫。

現在來實現

def test_receive_headers_and_data():
	stream_id = 3

	client_headers_frame = frame_factory.build_headers_frame(headers, stream_id=stream_id)
	headers_bytes = client_headers_frame.serialize()

	data = b`some amount of data`
	client_data_frame = frame_factory.build_data_frame(data, stream_id=stream_id, flags=[`END_STREAM`])
	data_bytes = client_data_frame.serialize()

	events = protocol.receive(headers_bytes+data_bytes)

	assert len(events) == 1
	assert isinstance(events[0], RequestEvent)

	event = events[0]
	assert event.stream.stream_id == stream_id
	assert event.stream.headers == headers     # 驗證 Headers
	assert event.stream.data == data           # 驗證沒有任何資料
	assert event.stream.buffered_data is None  # 驗證沒有任何資料
	assert event.stream.stream_ended is True   # 驗證請求完整(Stream 結束)
複製程式碼

帶資料的請求也很簡單,加上DATAframe即可。

好的,我們再來看看如何傳送回覆。

def test_send_headers_only():
	stream_id = 1
	response_headers = [(`:status`, `200`)]

	stream = Stream(stream_id, response_headers)
	stream.stream_ended = True
	stream.buffered_data = None
	stream.data = None

	events = protocol.send(stream)
	assert len(events) == 2
	for event in events:
		assert isinstance(event, MoreDataToSendEvent)
複製程式碼

只傳送 Headers 很簡單,建立一個Stream,然後傳送就行了。目前大家可以忽略MoreDataToSendEvent。我會在視訊和後續文章中娓娓道來。

def test_send_headers_and_data():
	"""
	able to receive headers and small amount data.
	able to send headers and small amount of data
	"""
	stream_id = 3
	response_headers = [(`:status`, `400`)]
	size = 1024 * 64 - 2  # default flow control window size per stream is 64 KB - 1 byte

	stream = Stream(stream_id, response_headers)
	stream.stream_ended = True
	stream.buffered_data = None
	stream.data = bytes(size)

	events = protocol.send(stream)
	assert len(events) == size // protocol.block_size + 3
	for event in events:
		assert isinstance(event, MoreDataToSendEvent)

	assert not protocol.outbound_streams
	assert not protocol.inbound_streams
複製程式碼

如果要傳送資料,只需要將stream.data賦值。注意,一定要是bytes型別。以上測試也涉及到了 Flow Control(流量控制),我會在視訊和後續文章中講解。

結語

好啦,想必到這裡你一定對 GetHy 有了大局觀的認識,也熟悉了 API 及應用場景。接下來就是要實現它了。我們下一期再見!


資源

程式碼

GitHub

B站

痛入爽出 HTTP/2:程式碼實現1

油膩的管子

痛入爽出 HTTP/2:程式碼實現1

文章

上期
下期

相關文章