用一個檔案,實現迷你 Web 框架

削微寒發表於2022-04-01

當下網路就如同空氣一樣在我們的周圍,它以無數種方式改變著我們的生活,但要說網路的核心技術變化甚微。

隨著開源文化的蓬勃發展,誕生了諸多優秀的開源 Web 框架,讓我們的開發變得輕鬆。但同時也讓我們不敢停下學習新框架的腳步,其實萬變不離其宗,只要理解了 Web 框架的核心技術部分,當有一個新的框架出來的時候,基礎部分大同小異只需要重點了解:它有哪些特點,用到了哪些技術解決了什麼痛點?這樣接受和理解起新技術來會更加得心應手,不至於疲於奔命。

還有那些只會用 Web 框架的同學,是否無數次開啟框架的原始碼,想學習提高卻無從下手?

今天我們就抽絲剝繭、去繁存簡,用一個檔案,實現一個迷你 Web 框架,從而把其核心技術部分清晰地講解清楚,配套的原始碼均已開源。

GitHub 地址:https://github.com/521xueweihan/OneFile

線上檢視:https://hellogithub.com/onefile/

如果你覺得我做的這件事對你有幫助,就請給我一個 ✨Star,多多轉發讓更多人受益。

閒言少敘,下面就開始我們今天的提高之旅。

一、介紹原理

說到 Web 不得不提的就是網路協議,如果我們從 OSI 七層網路模型開始,我敢斷定看完的絕對不超過三成!

所以今天我們就直接聊最上面的一層,也就是 Web 框架接觸最多的 HTTP 應用層,至於 TCP/IP 部分會在聊 socket 的時候粗略帶過。期間我會刻意打碼非必要講解技術的細枝末節,切斷遠離本期主題的技術話題,一個檔案只講一個技術點!絕不拖堂請大家放心閱讀。

首先讓我們先回憶下,平常瀏覽網站的流程。

如果我們把在網上衝浪,比做在一間教室聽課,那麼老師就是伺服器(server),學生就是客戶端(client)。當同學有問題的時候會先舉手(請求建立 TCP),老師發現學生的提問請求,同意學生回答問題後,學生起立提出問題(傳送請求),如果老師承諾會給提問的學生加課堂表現分,那麼提問的時候就需要有個高效的提問方式(請求格式),即:

  • 先報學號
  • 再提問題

師接收到學生的提問後就可以立即回答問題,無需再問學號(返回響應),回答格式(響應格式)如下:

  • 後回答問題
  • 根據學號加分!

有了約定好的提問格式(協議),就可以省去老師每次詢問學生的學號,即高效又嚴謹。最後,老師回答完問題讓學生坐下(關閉連線)。

其實,我們在網路上通訊流程也大致如此:

只不過機器執行起來更加嚴格,大家都是遵循某種協議來開發軟體,這樣就可以實現在某種協議下進行通訊,而這種網路通訊協議就叫做 HTTP(超文字傳輸協議)。

而我們要做的 Web 框架就是處理上面的流程:建立連線、接收請求、解析請求、處理請求、返回請求。

原理部分就聊這麼多,目前你只需要記住網路上通訊分為兩大步:建立連線(用於通訊)和處理請求。

所謂框架就是處理大多數情況下要處理的事情,所以我們要寫的 Web 框架也就是處理兩件事,即:

  • 處理連線(socket)
  • 處理請求(request)

一定要記住:連線和請求是兩個東西,建立起連線才能傳送請求。

而想要建立連線發起通訊,就需要通過 socket 來實現(建立連線),socket 可以理解為兩個虛擬的本子(檔案控制程式碼),通訊的雙方人手一個,它既可以讀也可以寫,只要把傳輸的內容寫到本子上(處理請求),對方就可以看到了。

下面我把 Web 框架分成兩部分進行講解,所有程式碼將採用簡單易懂的 Python3 進行實現。

二、編寫 Web 框架

程式碼+註釋​一共 457 行,請放心絕對簡單易懂。

2.1 處理連線(HTTPServer)

這裡需要簡單聊一下 socket 這個東西,在程式語言層面它就是一個類庫,負責搞定連線建立網路通訊。但本質上是系統級別提供通訊的程式,而一臺電腦可以建立多條通訊線路,所以每一個埠號後面都是一個 socket 程式,它們相互獨立、互不干涉,這也是為什麼我們在啟動服務的時候要指定埠號的原因。

最後,上面所說的伺服器其實就是一臺效能好一點、一直開著的電腦,而客戶端就是瀏覽器、手機、電腦,它們都有 socket 這個東西(作業系統級別的一個程式)。

如果上面這段話沒有看懂也不礙事,能看懂下面的圖就行,得搞明白 socket 處理連線的步驟和流程,才能編寫 Web 框架處理連線的部分。

下面分別展示基於 socket 編寫的 server.py 和 client.py 程式碼。

# coding: utf-8
# 伺服器端程式碼(server.py)
import socket

print('我是服務端!')
HOST = ''
PORT = 50007
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 建立 TCP socket 物件
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # 重啟時釋放埠
s.bind((HOST, PORT))  # 繫結地址
s.listen(1)  # 監聽TCP,1代表:作業系統可以掛起(未處理請求時等待狀態)的最大連線數量。該值至少為1
print('監聽埠:', PORT)
while 1:
    conn, _ = s.accept()  # 開始被動接受TCP客戶端的連線。
    data = conn.recv(1024)  # 接收TCP資料,1024表示緩衝區的大小
    print('接收到:', repr(data))
    conn.sendall(b'Hi, '+data)  # 給客戶端傳送資料
    conn.close()

因為 HTTP 是建立在相對可靠的 TCP 協議上,所以這裡建立的是 TCP socket 物件。

# coding: utf-8
# 客戶端程式碼(client.py)
import socket

print('我是客戶端!')
HOST = 'localhost'    # 伺服器的IP
PORT = 50007              # 需要連線的伺服器的埠
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print("傳送'HelloGitHub'")
s.sendall(b'HelloGitHub')  # 傳送‘HelloGitHub’給伺服器
data = s.recv(1024)
s.close()
print('接收到', repr(data))  # 列印從伺服器接收回來的資料

執行效果如下:

結合上面的程式碼,可以更加容易理解 socket 建立通訊的流程:

  1. socket:建立socket
  2. bind:繫結埠號
  3. listen:開始監聽
  4. accept:接收請求
  5. recv:接收資料
  6. close:關閉連線

所以,Web 框架中處理連線的 HTTPServer 類要做的事情就呼之欲出了。即:
一開始在 __init__方法中建立 socket,接著繫結埠(server_bind)然後開始監聽埠(server_activate)

# 處理連線進行資料通訊
class HTTPServer(object):
    def __init__(self, server_address, RequestHandlerClass):
        self.server_address = server_address # 伺服器地址
        self.RequestHandlerClass = RequestHandlerClass # 處理請求的類

        # 建立 TCP Socket
        self.socket = socket.socket(socket.AF_INET,
                                    socket.SOCK_STREAM)
        # 繫結 socket 和埠
        self.server_bind()
        # 開始監聽埠
        self.server_activate()

通過傳入的 RequestHandlerClass 引數可以看出,處理請求與建立連線是分開處理。

下面就要開始啟動服務接收請求了,也就是 HTTPServer 的啟動方法 serve_forever,這裡包含了接收請求、接收資料、開始處理請求、結束請求的全過程。

def serve_forever(self):
    while True:
        ready = selector.select(poll_interval)
        # 當客戶端請求的資料到位,則執行下一步
        if ready:
            # 有準備好的可讀檔案控制程式碼,則與客戶端的連結建立完畢
            request, client_address = self.socket.accept()
            # 可以進行下面的處理請求了,通過 RequestHandlerClass 處理請求和連線獨立
            self.RequestHandlerClass(request, client_address, self)
            # 關閉連線
            self.socket.close()

如此迴圈下去,就是 HTTPServer 處理連線、建立起 HTTP 連線的全部程式碼,就這?對!是不是很簡單?

程式碼中的 RequestHandlerClass 形參是處理請求的類,下面將深入講解其對應的 HTTPRequestHandler 是如何處理 HTTP 請求。

2.2 處理請求(HTTPRequestHandler)

還記得上面介紹的 socket 如何實現兩端通訊嗎?通過兩個可讀、可寫的“虛擬本子”。

再加上還要保證通訊的高效和嚴謹,就需要有對應的“通訊格式”。

所以,處理請求只需要三步走:

  1. setup:初始化兩個本子
    • 讀請求的檔案控制程式碼(rfile)
    • 寫響應的檔案控制程式碼(wfile)
  2. handle:讀取並解析請求、處理請求、構造響應並寫入
  3. finish:返回響應,銷燬兩個本子釋放資源,然後塵歸塵土歸土,等待下個請求

對應的程式碼:

# 處理請求
class HTTPRequestHandler(object):
    def __init__(self, request, client_address, server):
        self.request = request # 接收來的請求(socket)
        # 1、初始化兩個本子
        self.setup()
        try:
            # 2、讀取、解析、處理請求,構造響應
            self.handle()
        finally:
            # 3、返回響應,釋放資源
            self.finish()
    
    def setup(self):
        self.rfile = self.request.makefile('rb', -1) # 讀請求的本子
        self.wfile = self.request.makefile('wb', 0) # 寫響應的本子
    def handle(self):
        # 根據 HTTP 協議,解析請求
        # 具體的處理邏輯,即業務邏輯
        # 構造響應並寫入本子
    def finish(self):
        # 返回響應
        self.wfile.flush()
        # 關閉請求和響應的控制程式碼,釋放資源
        self.wfile.close()
        self.rfile.close()

以上就是處理請求的整體流程,下面將詳細介紹 handle 如何解析 HTTP 請求和構造 HTTP 響應,以及如何實現把框架和具體的業務程式碼(處理邏輯)分開。

在解析 HTTP 之前,需要先看一個實際的 HTTP 請求,當我開啟 hellogithub.com 網站首頁的時候,瀏覽器傳送的 HTTP 請求如下:

整理歸納可得 HTTP 請求格式,如下:

{HTTP method} {PATH} {HTTP version}\r\n
{header field name}:{field value}\r\n
...
\r\n
{request body}

得到了請求格式,那麼 handle 解析請求的方法也就有了。

def handle(self):
    # --- 開始解析 --- #
    self.raw_requestline = self.rfile.readline(65537) # 讀取請求第一行資料,即請求頭
    requestline = str(self.raw_requestline, 'iso-8859-1') # 轉碼
    requestline = requestline.rstrip('\r\n') # 去換行和空白行
    # 就可以得到 "GET / HTTP/1.1" 請求頭了,下面開始解析
    self.command, self.path, self.request_version = requestline.split() 
    # 根據空格分割字串,可得到("GET", "/", "HTTP/1.1")
    # command 對應的是 HTTP method,path 對應的是請求路徑
    # request_version 對應 HTTP 版本,不同版本解析規則不一樣這裡不做展開講解
    self.headers = self.parse_headers() # 解析請求頭也是處理字串,但更為複雜標準庫有工具函式這裡略過
    # --- 業務邏輯 --- #
    # do_HTTP_method 對應到具體的處理函式
    mname = ('do_' + self.command).lower()
    method = getattr(self, mname)
    # 呼叫對應的處理方法
    method()
    # --- 返回響應 --- #
    self.wfile.flush()

def do_GET(self):
    # 根據 path 區別處理
    if self.path == '/':
        self.send_response(200)  # status code
        # 加入響應 header
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers() # 結束頭部分,即:'\r\n'
        self.wfile.write(content.encode('utf-8')) # 寫入響應 body,即:頁面內容

def send_response(self, code, message=None):
    # 響應體格式
    """
    {HTTP version} {status code} {status phrase}\r\n
    {header field name}:{field value}\r\n
    ...
    \r\n
    {response body}
    """
    # 寫響應頭行
    self.wfile.write("%s %d %s\r\n" % ("HTTP/1.1", code, message))
    # 加入響應 header
    self.send_header('Server', "HG/Python ")
    self.send_header('Date', self.date_time_string())

以上就是 handle 處理請求和返回響應的核心程式碼片段了,至此 HTTPRequestHandler 全部內容均已講解完畢,下面將演示執行效果。

2.3 執行

class RequestHandler(HTTPRequestHandler):
    # 處理 GET 請求
    def do_get(self):
        # 根據 path 對應到具體的處理方法
        if self.path == '/':
            self.handle_index()
        elif self.path.startswith('/favicon'):
            self.handle_favicon()
        else:
            self.send_error(404)

if __name__ == '__main__':
    server = HTTPServer(('', 8080), RequestHandler)
    # 啟動服務
    server.serve_forever()

這裡通過繼承 Web 框架的 HTTPRequestHandler 實現的子類 RequestHandler 重寫 do_get 方法,實現業務程式碼和框架的分離。這樣保證了框架的靈活性和解耦。

接下來服務毫無意外地執行起來了,效果如下:

本文中涉及 Web 框架的程式碼,為方便閱讀都經過了簡化。如果想要獲取完整可執行的程式碼,可前往 GitHub 地址獲取:

https://github.com/521xueweihan/OneFile/blob/main/src/python/web-server.py

該框架並不包含 Web 框架應有的豐富功能,旨在通過最簡單的程式碼,實現一個迷你 Web 框架,讓不瞭解基本 Web 框架結構的同學,得以一探究竟。

如果本文的內容勾起了你對 Web 框架的興趣,你還想更加深入的瞭解更加全面、適用於生產環境、程式碼和結構同樣的簡潔的 Web 框架。我建議的學習路徑:

  1. Python3 的 HTTPServer、BaseHTTPRequestHandler
  2. bottle:單檔案、無三方依賴、持續更新,可用於生產環境的開源 Web 框架:
  3. werkzeug -> flask
  4. starlette -> uvicorn -> fastapi

有的時候閱讀框架原始碼不是為了寫一個新的框架,而是向前輩學習和靠攏。

最後

新的技術總是學不完的,掌握核心的技術原理,不僅可以在接受新的知識時快人一步,還可以在排查問題時一針見血。

不知道這種一個檔案講解一個技術點,力求通過簡單的文字和精簡的程式碼描述原理,期間抹去了細枝末節的技術專注於一門技術,最後給出完整可執行的開原始碼的文章,是否符合你的胃口?
本文是我對新的系列一種嘗試,接受任何指點和批評。

如果你喜歡此類文章,就請點贊給我一點鼓勵,還可以留言提建議或者“點餐”。

OneFile 期待你的加入,點選貢獻一份力量。

不要想你為開源做了什麼,你只需要清楚你為自己做了什麼。

相關文章