用一個文件,實現(xiàn)迷你 Web 框架(建議收藏)

當(dāng)下網(wǎng)絡(luò)就如同空氣一樣在我們的周圍,它以無數(shù)種方式改變著我們的生活,但要說網(wǎng)絡(luò)的核心技術(shù)變化甚微。
隨著開源文化的蓬勃發(fā)展,誕生了諸多優(yōu)秀的開源 Web 框架,讓我們的開發(fā)變得輕松。但同時也讓我們不敢停下學(xué)習(xí)新框架的腳步,其實萬變不離其宗,只要理解了 Web 框架的核心技術(shù)部分,當(dāng)有一個新的框架出來的時候,基礎(chǔ)部分大同小異只需要重點了解:它有哪些特點,用到了哪些技術(shù)解決了什么痛點?這樣接受和理解起新技術(shù)來會更加得心應(yīng)手,不至于疲于奔命。
還有那些只會用 Web 框架的同學(xué),是否無數(shù)次打開框架的源碼,想學(xué)習(xí)提高卻無從下手?
今天我們就抽絲剝繭、去繁存簡,用一個文件,實現(xiàn)一個迷你 Web 框架,從而把其核心技術(shù)部分清晰地講解清楚,配套的源碼均已開源。
GitHub 地址:https://github.com/521xueweihan/OneFile
在線查看:https://hellogithub.com/onefile/
如果你覺得我做的這件事對你有幫助,就請給我一個 ?Star,多多轉(zhuǎn)發(fā)讓更多人受益。
閑言少敘,下面就開始我們今天的提高之旅。
一、介紹原理
說到 Web 不得不提的就是網(wǎng)絡(luò)協(xié)議,如果我們從 OSI 七層網(wǎng)絡(luò)模型開始,我敢斷定看完的絕對不超過三成!
所以今天我們就直接聊最上面的一層,也就是 Web 框架接觸最多的 HTTP 應(yīng)用層,至于 TCP/IP 部分會在聊 socket 的時候粗略帶過。期間我會刻意打碼非必要講解技術(shù)的細(xì)枝末節(jié),切斷遠(yuǎn)離本期主題的技術(shù)話題,一個文件只講一個技術(shù)點!絕不拖堂請大家放心閱讀。
首先讓我們先回憶下,平常瀏覽網(wǎng)站的流程。
如果我們把在網(wǎng)上沖浪,比做在一間教室聽課,那么老師就是服務(wù)器(server),學(xué)生就是客戶端(client)。當(dāng)同學(xué)有問題的時候會先舉手(請求建立 TCP),老師發(fā)現(xiàn)學(xué)生的提問請求,同意學(xué)生回答問題后,學(xué)生起立提出問題(發(fā)送請求),如果老師承諾會給提問的學(xué)生加課堂表現(xiàn)分,那么提問的時候就需要有個高效的提問方式(請求格式),即:
先報學(xué)號 再提問題
師接收到學(xué)生的提問后就可以立即回答問題(返回響應(yīng))無需再問學(xué)號,回答格式(響應(yīng)格式)如下:
回答問題 根據(jù)學(xué)號加分!
有了約定好的提問格式(協(xié)議),就可以省去老師每次詢問學(xué)生的學(xué)號,即高效又嚴(yán)謹(jǐn)。最后,老師回答完問題讓學(xué)生坐下(關(guān)閉連接)。
其實,我們在網(wǎng)絡(luò)上通信流程也大致如此:

只不過機(jī)器執(zhí)行起來更加嚴(yán)格,大家都是遵循某種協(xié)議來開發(fā)軟件,這樣就可以實現(xiàn)在某種協(xié)議下進(jìn)行通信,而這種網(wǎng)絡(luò)通信協(xié)議就叫做 HTTP(超文本傳輸協(xié)議)。
而我們要做的 Web 框架就是處理上面的流程:建立連接、接收請求、解析請求、處理請求、返回請求。
原理部分就聊這么多,目前你只需要記住網(wǎng)絡(luò)上通信分為兩大步:建立連接(用于通信)和處理請求。
所謂框架就是處理大多數(shù)情況下要處理的事情,所以我們要寫的 Web 框架也就是處理兩件事,即:
處理連接(socket) 處理請求(request)
一定要記住:連接和請求是兩個東西,建立起連接才能發(fā)送請求。
而想要建立連接發(fā)起通信,就需要通過 socket 來實現(xiàn)(建立連接),socket 可以理解為兩個虛擬的本子(文件句柄),通信的雙方人手一個,它既能讀也能寫,只要把傳輸?shù)膬?nèi)容寫到本子上(處理請求),對方就可以看到了。

下面我把 Web 框架分為兩部分進(jìn)行講解,所有代碼將采用簡單易懂的 Python3 進(jìn)行實現(xiàn)。
二、編寫 Web 框架

代碼+注釋一共 457 行,請放心絕對簡單易懂。
2.1 處理連接(HTTPServer)
這里需要簡單聊一下 socket 這個東西,在編程語言層面它就是一個類庫,負(fù)責(zé)搞定連接建立網(wǎng)絡(luò)通信。但本質(zhì)上是系統(tǒng)級別提供通信的進(jìn)程,而一臺電腦可以建立多條通信線路,所以每一個端口號后面都是一個 socket 進(jìn)程,它們相互獨立、互不干涉,這也是為什么我們在啟動服務(wù)的時候要指定端口號的原因。
最后,上面所說的服務(wù)器其實就是一臺性能好一點、一直開著的電腦,而客戶端就是瀏覽器、手機(jī)、電腦,它們都有 socket 這個東西(操作系統(tǒng)級別的一個進(jìn)程)。
如果上面這段話沒有看懂也不礙事,能看懂下面的圖就行,得搞明白 socket 處理連接的步驟和流程,才能編寫 Web 框架處理連接的部分。

下面分別展示基于 socket 編寫的 server.py 和 client.py 代碼。
# coding: utf-8
# 服務(wù)器端代碼(server.py)
import socket
print('我是服務(wù)端!')
HOST = ''
PORT = 50007
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 創(chuàng)建 TCP socket 對象
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 重啟時釋放端口
s.bind((HOST, PORT)) # 綁定地址
s.listen(1) # 監(jiān)聽TCP,1代表:操作系統(tǒng)可以掛起(未處理請求時等待狀態(tài))的最大連接數(shù)量。該值至少為1
print('監(jiān)聽端口:', PORT)
while 1:
conn, _ = s.accept() # 開始被動接受TCP客戶端的連接。
data = conn.recv(1024) # 接收TCP數(shù)據(jù),1024表示緩沖區(qū)的大小
print('接收到:', repr(data))
conn.sendall(b'Hi, '+data) # 給客戶端發(fā)送數(shù)據(jù)
conn.close()
因為 HTTP 是建立在相對可靠的 TCP 協(xié)議上,所以這里創(chuàng)建的是 TCP socket 對象。
# coding: utf-8
# 客戶端代碼(client.py)
import socket
print('我是客戶端!')
HOST = 'localhost' # 服務(wù)器的IP
PORT = 50007 # 需要連接的服務(wù)器的端口
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print("發(fā)送'HelloGitHub'")
s.sendall(b'HelloGitHub') # 發(fā)送‘HelloGitHub’給服務(wù)器
data = s.recv(1024)
s.close()
print('接收到', repr(data)) # 打印從服務(wù)器接收回來的數(shù)據(jù)
運行效果如下:

結(jié)合上面的代碼,可以更加容易理解 socket 建立通信的流程:
socket:創(chuàng)建socket bind:綁定端口號 listen:開始監(jiān)聽 accept:接收請求 recv:接收數(shù)據(jù) close:關(guān)閉連接
所以,Web 框架中處理連接的 HTTPServer 類要做的事情就呼之欲出了。即:一開始在 __init__方法中創(chuàng)建 socket,接著綁定端口(server_bind)然后開始監(jiān)聽端口(server_activate)
# 處理連接進(jìn)行數(shù)據(jù)通信
class HTTPServer(object):
def __init__(self, server_address, RequestHandlerClass):
self.server_address = server_address # 服務(wù)器地址
self.RequestHandlerClass = RequestHandlerClass # 處理請求的類
# 創(chuàng)建 TCP Socket
self.socket = socket.socket(socket.AF_INET,
socket.SOCK_STREAM)
# 綁定 socket 和端口
self.server_bind()
# 開始監(jiān)聽端口
self.server_activate()
通過傳入的 RequestHandlerClass 參數(shù)可以看出,處理請求與建立連接是分開處理。
下面就要開始啟動服務(wù)接收請求了,也就是 HTTPServer 的啟動方法 serve_forever,這里包含了接收請求、接收數(shù)據(jù)、開始處理請求、結(jié)束請求的全過程。
def serve_forever(self):
while True:
ready = selector.select(poll_interval)
# 當(dāng)客戶端請求的數(shù)據(jù)到位,則執(zhí)行下一步
if ready:
# 有準(zhǔn)備好的可讀文件句柄,則與客戶端的鏈接建立完畢
request, client_address = self.socket.accept()
# 可以進(jìn)行下面的處理請求了,通過 RequestHandlerClass 處理請求和連接獨立
self.RequestHandlerClass(request, client_address, self)
# 關(guān)閉連接
self.socket.close()
如此循環(huán)下去,就是 HTTPServer 處理連接、建立起 HTTP 連接的全部代碼,就這?對!是不是很簡單?
代碼中的 RequestHandlerClass 形參是處理請求的類,下面將深入講解其對應(yīng)的 HTTPRequestHandler 是如何處理 HTTP 請求。
2.2 處理請求(HTTPRequestHandler)
還記得上面介紹的 socket 如何實現(xiàn)兩端通信嗎?通過兩個可讀寫的“虛擬本子”。
再加上還要保證通信的高效和嚴(yán)謹(jǐn),就需要有對應(yīng)的“通信格式”。
所以,處理請求只需要三步走:
setup:初始化兩個本子 讀請求的文件句柄(rfile) 寫響應(yīng)的文件句柄(wfile) handle:讀取并解析請求、處理請求、構(gòu)造響應(yīng)并寫入 finish:返回響應(yīng),銷毀兩個本子釋放資源,然后塵歸塵土歸土,等待下個請求
對應(yīng)的代碼:
# 處理請求
class HTTPRequestHandler(object):
def __init__(self, request, client_address, server):
self.request = request # 接收來的請求(socket)
# 1、初始化兩個本子
self.setup()
try:
# 2、讀取、解析、處理請求,構(gòu)造響應(yīng)
self.handle()
finally:
# 3、返回響應(yīng),釋放資源
self.finish()
def setup(self):
self.rfile = self.request.makefile('rb', -1) # 讀請求的本子
self.wfile = self.request.makefile('wb', 0) # 寫響應(yīng)的本子
def handle(self):
# 根據(jù) HTTP 協(xié)議,解析請求
# 具體的處理邏輯,即業(yè)務(wù)邏輯
# 構(gòu)造響應(yīng)并寫入本子
def finish(self):
# 返回響應(yīng)
self.wfile.flush()
# 關(guān)閉請求和響應(yīng)的句柄,釋放資源
self.wfile.close()
self.rfile.close()
以上就是處理請求的整體流程,下面將詳細(xì)介紹 handle 如何解析 HTTP 請求和構(gòu)造 HTTP 響應(yīng),以及如何實現(xiàn)把框架和具體的業(yè)務(wù)代碼(處理邏輯)分開。
在解析 HTTP 之前,需要先看一個實際的 HTTP 請求,當(dāng)我打開 hellogithub.com 網(wǎng)站首頁的時候,瀏覽器發(fā)送的 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) # 讀取請求第一行數(shù)據(jù),即請求頭
requestline = str(self.raw_requestline, 'iso-8859-1') # 轉(zhuǎn)碼
requestline = requestline.rstrip('\r\n') # 去換行和空白行
# 就可以得到 "GET / HTTP/1.1" 請求頭了,下面開始解析
self.command, self.path, self.request_version = requestline.split()
# 根據(jù)空格分割字符串,可得到("GET", "/", "HTTP/1.1")
# command 對應(yīng)的是 HTTP method,path 對應(yīng)的是請求路徑
# request_version 對應(yīng) HTTP 版本,不同版本解析規(guī)則不一樣這里不做展開講解
self.headers = self.parse_headers() # 解析請求頭也是處理字符串,但更為復(fù)雜標(biāo)準(zhǔn)庫有工具函數(shù)這里略過
# --- 業(yè)務(wù)邏輯 --- #
# do_HTTP_method 對應(yīng)到具體的處理函數(shù)
mname = ('do_' + self.command).lower()
method = getattr(self, mname)
# 調(diào)用對應(yīng)的處理方法
method()
# --- 返回響應(yīng) --- #
self.wfile.flush()
def do_GET(self):
# 根據(jù) path 區(qū)別處理
if self.path == '/':
self.send_response(200) # status code
# 加入響應(yīng) header
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers() # 結(jié)束頭部分,即:'\r\n'
self.wfile.write(content.encode('utf-8')) # 寫入響應(yīng) body,即:頁面內(nèi)容
def send_response(self, code, message=None):
# 響應(yīng)體格式
"""
{HTTP version} {status code} {status phrase}\r\n
{header field name}:{field value}\r\n
...
\r\n
{response body}
"""
# 寫響應(yīng)頭行
self.wfile.write("%s %d %s\r\n" % ("HTTP/1.1", code, message))
# 加入響應(yīng) header
self.send_header('Server', "HG/Python ")
self.send_header('Date', self.date_time_string())
以上就是 handle 處理請求和返回響應(yīng)的核心代碼片段了,至此 HTTPRequestHandler 全部內(nèi)容均已講解完畢,下面將演示運行效果。
2.3 運行
class RequestHandler(HTTPRequestHandler):
# 處理 GET 請求
def do_get(self):
# 根據(jù) path 對應(yīng)到具體的處理方法
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)
# 啟動服務(wù)
server.serve_forever()
這里通過繼承 Web 框架的 HTTPRequestHandler 實現(xiàn)的子類 RequestHandler 重寫 do_get 方法,實現(xiàn)業(yè)務(wù)代碼和框架的分離。這樣保證了框架的靈活性和解耦。
接下來服務(wù)毫無意外地運行起來了,效果如下:

本文中涉及 Web 框架的代碼,為方便閱讀都經(jīng)過了簡化。如果想要獲取完整可運行的代碼,可前往 GitHub 地址獲取:
https://github.com/521xueweihan/OneFile/blob/main/src/python/web-server.py
該框架并不包含 Web 框架應(yīng)有的豐富功能,旨在通過最簡單的代碼,實現(xiàn)一個迷你 Web 框架,讓不了解基本 Web 框架結(jié)構(gòu)的同學(xué),得以一探究竟。
如果本文的內(nèi)容勾起了你對 Web 框架的興趣,你還想更加深入的了解更加全面、適用于生產(chǎn)環(huán)境、代碼和結(jié)構(gòu)同樣的簡潔的 Web 框架。我建議的學(xué)習(xí)路徑:
Python3 的 HTTPServer、BaseHTTPRequestHandler bottle:單文件、無三方依賴、持續(xù)更新,可用于生產(chǎn)環(huán)境的開源 Web 框架: 地址:https://github.com/bottlepy/bottle werkzeug -> flask starlette -> uvicorn -> fastapi
有的時候閱讀框架源碼不是為了寫一個新的框架,而是向前輩學(xué)習(xí)和靠攏。
最后
新的技術(shù)總是學(xué)不完的,掌握核心的技術(shù)原理,不僅可以在接受新的知識時快人一步,還可以在排查問題時一針見血。
不知道這種一個文件講解一個技術(shù)點,力求通過簡單的文字和精簡的代碼描述原理,期間抹去了細(xì)枝末節(jié)的技術(shù)專注于一門技術(shù),最后給出完整可運行的開源代碼的文章,是否符合你的胃口?
本文是我對新的系列一種嘗試,接受任何指點和批評。
如果你喜歡此類文章,就請點贊給我一點鼓勵,還可以留言提建議或者“點餐”。
不要想你為開源做了什么,你只需要清楚你為自己做了什么。

w3cschool編程獅
專門幫助零基礎(chǔ)的同學(xué)們學(xué)習(xí)編程基礎(chǔ)的學(xué)習(xí)網(wǎng)站
