本文將使用python套接字編程從零搭建一個簡易的web服務器,對應於教材《計算機網絡:自頂向下方法》第二章后面套接字編程作業,我們先來看一看客戶機(瀏覽器)和服務器交互的過程中在服務器端發生了哪些事情:
- 當一個客戶(瀏覽器)聯系服務器時創建一個連接套接字;
- 服務器從這個連接接受HTTP請求;
- 解釋該請求以確定所請求的特定文件;
- 從服務器的文件系統獲得請求的文件;
- 創建一個由請求的文件組成的HTTP響應報文,報文前有首部行;
- 經TCP連接向請求的瀏覽器發送響應;如果文件不存在,則返回
404 Not Found
差錯報文。
1.Web服務器
假設我們通過瀏覽器向服務器請求的文件是HelloWorld.html
,文件內容自定(我這里寫的內容就是一句話:太棒了,服務器正常工作!),我們需要將該文件放在與服務器同級的目錄下,然后通過瀏覽器向服務器發起請求,服務器按照上述步驟進行響應。服務器端的全部代碼如下:
from socket import *
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.bind(('', 6789))
serverSocket.listen(1)
while True:
print('服務器已就位')
connectionSocket, addr = serverSocket.accept()
try:
message = connectionSocket.recv(1024).decode()
filename = message.split()[1]
f = open(filename[1:], encoding='utf-8')
outputdata = f.read()
header = 'HTTP/1.1 200 OK\nConnection: close\nContent-Type: text/html\nContent-Length: %d\n\n' % (len(outputdata)+24)
connectionSocket.send(header.encode())
for i in range(0, len(outputdata)):
connectionSocket.send(outputdata[i].encode())
connectionSocket.send("\r\n".encode())
connectionSocket.close()
except IOError:
header = 'HTTP/1.1 404 Not Found'
connectionSocket.send(header.encode())
connectionSocket.close()
bind(('', 6789))
指定了套接字與端口號6789綁定,如果代碼是運行在本地的,那么只需在瀏覽器中輸入http://localhost:6789/HelloWorld.html
即可訪問頁面;如果代碼是部署在雲服務器上的,就需要將IP改為服務器的公網IP,通過這種方式,我們可以很容易地在服務器上部署類似個人簡介這樣的靜態網頁。
listen(1)
指定了服務器在同一時刻只接受一個請求,后續我們將通過多線程編碼來同時處理多個請求。
在構造的頭部信息中,我們通過Content-Length
指定了實體(封裝的TCP報文)的長度,即等於數據長度 + TCP頭部信息長度,通常TCP的頭部信息長度是20字節,但是通過實際觀察網頁源代碼我發現少了四個字節,不難猜測這是因為TCP的選項字段占用了四個字節,因此這里頭部信息長度就是24字節。我們甚至不需要自己指定報文長度,只需要返回最基本的HTTP/1.1 200 OK
即可。
這里插入一個題外話,關於Content-Length
的使用,通過實踐我發現存在以下四種情況:
- 不顯式指定
Content-Length
,前端頁面顯示完好,數據完整;- 顯示指定
Content-Length
且小於實體的長度,前端頁面顯示不完好,數據缺失;- 顯示指定
Content-Length
且等於實體的長度,前端頁面顯示完好,數據完整;- 顯示指定
Content-Length
且大於實體的長度,前端頁面不顯示,瀏覽器控制台報錯ERR_CONTENT_LENGTH_MISMATCH
;
也就是說,最糟糕的情況是指定的長度大於實體的長度,由於長度不匹配,瀏覽器會報錯且前端不顯示任何東西。如果指定的長度小於實體長度,瀏覽器只取消息實體的前面一部分,則前端頁面顯示不完好。在效果上,不顯示指定和顯式指定為實體長度都是一樣的,如果怕麻煩可以不指定。
2.多線程Web服務器
參照上面的代碼,一個最基本的簡易Web服務器就搭建好了,但是它在同一時刻只能處理一個請求,現在我們給它升下級,我們使用多線程的方式讓它能夠同時處理多個請求。具體代碼如下:
from socket import *
import threading
def tcp_process(connectionSocket):
print(threading.current_thread())
try:
message = connectionSocket.recv(1024).decode()
print(repr(message))
print(message)
filename = message.split()[1]
f = open(filename[1:], encoding='utf-8')
outputdata = f.read()
header = 'HTTP/1.1 200 OK\nConnection: close\nContent-Type: text/html\nContent-Length: %d\n\n' % (len(outputdata)+24)
connectionSocket.send(header.encode())
for i in range(0, len(outputdata)):
connectionSocket.send(outputdata[i].encode())
connectionSocket.send("\r\n".encode())
connectionSocket.close()
except IOError:
header = 'HTTP/1.1 404 Not Found'
connectionSocket.send(header.encode())
connectionSocket.close()
if __name__ == "__main__":
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.bind(('', 6789))
serverSocket.listen(10)
while True:
print('服務器已就位')
connectionSocket, addr = serverSocket.accept()
thread = threading.Thread(target=tcp_process, args=(connectionSocket, ))
thread.start()
從上面可以看出,我們只是將connectionSocket
交給一個具體的線程來執行,該線程負責為具體的客戶服務,而主進程不必等待它服務完這個用戶就可以接受下一個用戶的請求,這樣就大大提高了服務器的工作效率。
3.客戶端
最后我們來看看客戶端的代碼,通過客戶端可以不經過瀏覽器直接向服務器發起請求。
from socket import *
clientSocket = socket(AF_INET, SOCK_STREAM)
clientSocket.connect(('localhost', 6789))
while True:
header = 'GET /HelloWorld.html HTTP/1.1\nHost: localhost:6789\nConnection: keep-alive\nUser-Agent: Mozilla/5.0\n\n'
clientSocket.send(header.encode())
message = clientSocket.recv(1024)
print(message.decode())
當我通過運行客戶端代碼向服務器發起請求時,雖然數據成功獲取了,但是在客戶端也收到了以下錯誤:
ConnectionResetError: [WinError 10054] 遠程主機強迫關閉了一個現有的連接。
這個錯誤一般出現在爬蟲過程中,因為抓取信息太過頻繁,而被服務器認定為惡意攻擊。但是這里顯然不是這個原因,在我將服務器代碼中的connectionSocket.close()
注釋掉之后,這個錯誤就沒有了,但是產生錯誤具體的原因至今未明,如果有知道的同學歡迎在評論區告訴我。