當下網路就如同空氣一樣在我們的周圍,它以無數種方式改變著我們的生活,但要說網路的核心技術變化甚微。
隨著開源文化的蓬勃發展,誕生了諸多優秀的開源 Web 框架,讓我們的開發變得輕鬆。但同時也讓我們不敢停下學習新框架的腳步,其實萬變不離其宗,只要理解了 Web 框架的核心技術部分,當有一個新的框架出來的時候,基礎部分大同小異只需要重點了解:它有哪些特點,用到了哪些技術解決了什麼痛點?這樣接受和理解起新技術來會更加得心應手,不至於疲於奔命。
還有那些只會用 Web 框架的同學,是否無數次開啟框架的原始碼,想學習提高卻無從下手?
今天我們就抽絲剝繭、去繁存簡,用一個檔案,實現一個迷你 Web 框架,從而把其核心技術部分清晰地講解清楚,配套的原始碼均已開源。
GitHub 地址:https://github.com/521xueweihan/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 建立通訊的流程:
- socket:建立socket
- bind:繫結埠號
- listen:開始監聽
- accept:接收請求
- recv:接收資料
- 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 如何實現兩端通訊嗎?通過兩個可讀、可寫的“虛擬本子”。
再加上還要保證通訊的高效和嚴謹,就需要有對應的“通訊格式”。
所以,處理請求只需要三步走:
- setup:初始化兩個本子
- 讀請求的檔案控制程式碼(rfile)
- 寫響應的檔案控制程式碼(wfile)
- handle:讀取並解析請求、處理請求、構造響應並寫入
- 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 框架。我建議的學習路徑:
- Python3 的 HTTPServer、BaseHTTPRequestHandler
- bottle:單檔案、無三方依賴、持續更新,可用於生產環境的開源 Web 框架:
- werkzeug -> flask
- starlette -> uvicorn -> fastapi
有的時候閱讀框架原始碼不是為了寫一個新的框架,而是向前輩學習和靠攏。
最後
新的技術總是學不完的,掌握核心的技術原理,不僅可以在接受新的知識時快人一步,還可以在排查問題時一針見血。
不知道這種一個檔案講解一個技術點,力求通過簡單的文字和精簡的程式碼描述原理,期間抹去了細枝末節的技術專注於一門技術,最後給出完整可執行的開原始碼的文章,是否符合你的胃口?
本文是我對新的系列一種嘗試,接受任何指點和批評。
如果你喜歡此類文章,就請點贊給我一點鼓勵,還可以留言提建議或者“點餐”。
OneFile 期待你的加入,點選貢獻一份力量。
不要想你為開源做了什麼,你只需要清楚你為自己做了什麼。