用一個文件,實現迷你 Web 框架

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

隨着開源文化的蓬勃發展,誕生了諸多優秀的開源 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 來實現(建立連接),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:初始化兩個本子
  1. handle:讀取並解析請求、處理請求、構造響應並寫入

  2. 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 框架:

  1. werkzeug -> flask

  2. starlette -> uvicorn -> fastapi

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

最後

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

不知道這種一個文件講解一個技術點,力求通過簡單的文字和精簡的代碼描述原理,期間抹去了細枝末節的技術專注於一門技術,最後給出完整可運行的開源代碼的文章,是否符合你的胃口?

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

HelloGitHub 分享 GitHub 上有趣、入門級的開源項目。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/I7rNwrISDx1aaOKfeW3qHQ