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

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

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

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

代碼+注釋一共 457 行,請(qǐng)放心絕對(duì)簡(jiǎn)單易懂。
2.1 處理連接(HTTPServer)
這里需要簡(jiǎn)單聊一下 socket 這個(gè)東西,在編程語言層面它就是一個(gè)類庫,負(fù)責(zé)搞定連接建立網(wǎng)絡(luò)通信。但本質(zhì)上是系統(tǒng)級(jí)別提供通信的進(jìn)程,而一臺(tái)電腦可以建立多條通信線路,所以每一個(gè)端口號(hào)后面都是一個(gè) socket 進(jìn)程,它們相互獨(dú)立、互不干涉,這也是為什么我們?cè)趩?dòng)服務(wù)的時(shí)候要指定端口號(hào)的原因。
最后,上面所說的服務(wù)器其實(shí)就是一臺(tái)性能好一點(diǎn)、一直開著的電腦,而客戶端就是瀏覽器、手機(jī)、電腦,它們都有 socket 這個(gè)東西(操作系統(tǒng)級(jí)別的一個(gè)進(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?對(duì)象
s.setsockopt(socket.SOL_SOCKET,?socket.SO_REUSEADDR,?1)??#?重啟時(shí)釋放端口
s.bind((HOST,?PORT))??#?綁定地址
s.listen(1)??#?監(jiān)聽TCP,1代表:操作系統(tǒng)可以掛起(未處理請(qǐng)求時(shí)等待狀態(tài))的最大連接數(shù)量。該值至少為1
print('監(jiān)聽端口:',?PORT)
while?1:
????conn,?_?=?s.accept()??#?開始被動(dòng)接受TCP客戶端的連接。
????data?=?conn.recv(1024)??#?接收TCP數(shù)據(jù),1024表示緩沖區(qū)的大小
????print('接收到:',?repr(data))
????conn.sendall(b'Hi,?'+data)??#?給客戶端發(fā)送數(shù)據(jù)
????conn.close()
因?yàn)?HTTP 是建立在相對(duì)可靠的 TCP 協(xié)議上,所以這里創(chuàng)建的是 TCP socket 對(duì)象。
#?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ù)
運(yùn)行效果如下:

結(jié)合上面的代碼,可以更加容易理解 socket 建立通信的流程:
- socket:創(chuàng)建socket
- bind:綁定端口號(hào)
- listen:開始監(jiān)聽
- accept:接收請(qǐng)求
- 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?#?處理請(qǐng)求的類
????????#?創(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ù)可以看出,處理請(qǐng)求與建立連接是分開處理。
下面就要開始啟動(dòng)服務(wù)接收請(qǐng)求了,也就是 HTTPServer 的啟動(dòng)方法 serve_forever,這里包含了接收請(qǐng)求、接收數(shù)據(jù)、開始處理請(qǐng)求、結(jié)束請(qǐng)求的全過程。
def?serve_forever(self):
????while?True:
????????ready?=?selector.select(poll_interval)
????????#?當(dāng)客戶端請(qǐng)求的數(shù)據(jù)到位,則執(zhí)行下一步
????????if?ready:
????????????#?有準(zhǔn)備好的可讀文件句柄,則與客戶端的鏈接建立完畢
????????????request,?client_address?=?self.socket.accept()
????????????#?可以進(jìn)行下面的處理請(qǐng)求了,通過?RequestHandlerClass?處理請(qǐng)求和連接獨(dú)立
????????????self.RequestHandlerClass(request,?client_address,?self)
????????????#?關(guān)閉連接
????????????self.socket.close()
如此循環(huán)下去,就是 HTTPServer 處理連接、建立起 HTTP 連接的全部代碼,就這?對(duì)!是不是很簡(jiǎn)單?
代碼中的 RequestHandlerClass 形參是處理請(qǐng)求的類,下面將深入講解其對(duì)應(yīng)的 HTTPRequestHandler 是如何處理 HTTP 請(qǐng)求。
2.2 處理請(qǐng)求(HTTPRequestHandler)
還記得上面介紹的 socket 如何實(shí)現(xiàn)兩端通信嗎?通過兩個(gè)可讀寫的“虛擬本子”。
再加上還要保證通信的高效和嚴(yán)謹(jǐn),就需要有對(duì)應(yīng)的“通信格式”。
所以,處理請(qǐng)求只需要三步走:
- setup:初始化兩個(gè)本子
- 讀請(qǐng)求的文件句柄(rfile)
- 寫響應(yīng)的文件句柄(wfile)
對(duì)應(yīng)的代碼:
#?處理請(qǐng)求
class?HTTPRequestHandler(object):
????def?__init__(self,?request,?client_address,?server):
????????self.request?=?request?#?接收來的請(qǐng)求(socket)
????????#?1、初始化兩個(gè)本子
????????self.setup()
????????try:
????????????#?2、讀取、解析、處理請(qǐng)求,構(gòu)造響應(yīng)
????????????self.handle()
????????finally:
????????????#?3、返回響應(yīng),釋放資源
????????????self.finish()
????
????def?setup(self):
????????self.rfile?=?self.request.makefile('rb',?-1)?#?讀請(qǐng)求的本子
????????self.wfile?=?self.request.makefile('wb',?0)?#?寫響應(yīng)的本子
????def?handle(self):
????????#?根據(jù)?HTTP?協(xié)議,解析請(qǐng)求
????????#?具體的處理邏輯,即業(yè)務(wù)邏輯
????????#?構(gòu)造響應(yīng)并寫入本子
????def?finish(self):
????????#?返回響應(yīng)
????????self.wfile.flush()
????????#?關(guān)閉請(qǐng)求和響應(yīng)的句柄,釋放資源
????????self.wfile.close()
????????self.rfile.close()
以上就是處理請(qǐng)求的整體流程,下面將詳細(xì)介紹 handle 如何解析 HTTP 請(qǐng)求和構(gòu)造 HTTP 響應(yīng),以及如何實(shí)現(xiàn)把框架和具體的業(yè)務(wù)代碼(處理邏輯)分開。
在解析 HTTP 之前,需要先看一個(gè)實(shí)際的 HTTP 請(qǐng)求,當(dāng)我打開 hellogithub.com 網(wǎng)站首頁的時(shí)候,瀏覽器發(fā)送的 HTTP 請(qǐng)求如下:

整理歸納可得 HTTP 請(qǐng)求格式,如下:
{HTTP?method}?{PATH}?{HTTP?version}\r\n
{header?field?name}:{field?value}\r\n
...
\r\n
{request?body}
得到了請(qǐng)求格式,那么 handle 解析請(qǐng)求的方法也就有了。
def?handle(self):
????#?---?開始解析?---?#
????self.raw_requestline?=?self.rfile.readline(65537)?#?讀取請(qǐng)求第一行數(shù)據(jù),即請(qǐng)求頭
????requestline?=?str(self.raw_requestline,?'iso-8859-1')?#?轉(zhuǎn)碼
????requestline?=?requestline.rstrip('\r\n')?#?去換行和空白行
????#?就可以得到?"GET?/?HTTP/1.1"?請(qǐng)求頭了,下面開始解析
????self.command,?self.path,?self.request_version?=?requestline.split()?
????#?根據(jù)空格分割字符串,可得到("GET",?"/",?"HTTP/1.1")
????#?command?對(duì)應(yīng)的是?HTTP?method,path?對(duì)應(yīng)的是請(qǐng)求路徑
????#?request_version?對(duì)應(yīng)?HTTP?版本,不同版本解析規(guī)則不一樣這里不做展開講解
????self.headers?=?self.parse_headers()?#?解析請(qǐng)求頭也是處理字符串,但更為復(fù)雜標(biāo)準(zhǔn)庫有工具函數(shù)這里略過
????#?---?業(yè)務(wù)邏輯?---?#
????#?do_HTTP_method?對(duì)應(yīng)到具體的處理函數(shù)
????mname?=?('do_'?+?self.command).lower()
????method?=?getattr(self,?mname)
????#?調(diào)用對(duì)應(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 處理請(qǐng)求和返回響應(yīng)的核心代碼片段了,至此 HTTPRequestHandler 全部?jī)?nèi)容均已講解完畢,下面將演示運(yùn)行效果。
2.3 運(yùn)行
class?RequestHandler(HTTPRequestHandler):
????#?處理?GET?請(qǐng)求
????def?do_get(self):
????????#?根據(jù)?path?對(duì)應(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)
????#?啟動(dòng)服務(wù)
????server.serve_forever()
這里通過繼承 Web 框架的 HTTPRequestHandler 實(shí)現(xiàn)的子類 RequestHandler 重寫 do_get 方法,實(shí)現(xiàn)業(yè)務(wù)代碼和框架的分離。這樣保證了框架的靈活性和解耦。
接下來服務(wù)毫無意外地運(yùn)行起來了,效果如下:

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