從零開始搭建一個簡易的伺服器(一)
前言
其實大家大可不必被伺服器這三個字嚇到,一個入門級後端框架,所需的僅僅是HTTP相關的知識與應用這些知識的程式設計工具。據本人的經驗,絕大多數人擁有搭建後端所涉及到的基礎理論知識,但是缺乏能將之應用出去的工具,而本文即是交給讀者這樣一個工具,並能夠運用之來實現一個可用的後端。
本文以基礎理論知識的運用為主,並不會在伺服器的穩定性安全性上做探究,同時為了避免大家在實現中被各種程式語言的獨有特性所困擾,本文選用選Python作為程式語言,並會附上詳細的程式碼。
一、最初的嘗試
(HyperText Transfer Protocol)是迄今為止網際網路應用最為廣泛的協議,平時大家在瀏覽器上瀏覽網頁,逛淘寶,刷部落格,上知乎均是基於這種協議。
在網際網路七層架構中HTTP位於TCP/UDP之上,這意味著我們我們可以在TCP/UDP層收發HTTP層的資料,而能夠幫助我們在TCP/UDP層收發資料的最原始的一個工具——套接字。
幾乎每一門程式語言都會原生支援套接字,所以本文選用套接字講解,而非python語言本身拿手的第三方庫,套接字與基礎知識之間直接對接,這樣不僅簡化學習成本,同時易於讀者從底層瞭解學習HTTP,也便於理解各種第三方庫的實現機理,可謂一舉三得。
在套接字的幫助下,我們可以寫下第一個伺服器端的框架:
#coding=utf-8
import re
from socket import *
def handle_request (request):
return 'Welcome to wierton\'s site'
s = socket(AF_INET, SOCK_STREAM)
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 8080))
s.listen(10)
while 1:
conn,addr = s.accept()
print("connected by {}".format(addr))
recv_data = conn.recv(64*1024)
resp_data = handle_request(recv_data)
conn.sendall(resp_data)
conn.close()
s.close()
上述框架能夠幹嘛呢?想要實驗上述程式碼的效果,你只要在瀏覽器中輸入127.0.0.1:8080
,然後你就會看到一行字串Welcome to wierton's site.
,如圖:
怎麼樣,是不是很有成就感,你的程式碼“成功”響應了瀏覽器的請求並回復了一個你設定好的字串。
或許新入門的你對上述程式碼有所疑惑,不著急,我們來慢慢過一遍上述程式碼。
s = socket(AF_INET, SOCK_STREAM)
建立一個流式套接字用於TCP通訊
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
設定當前套接字,使其允許被複用
s.bind(('127.0.0.1', 8080))
將當前套接字繫結到ip地址為127.0.0.1,埠號為8080的連線上
注:雖然HTTP預設埠為80,但在linux下,監聽80號埠需要root許可權。
s.listen(10)
監聽當前套接字,設定併發數為10,即在多客戶端併發請求時,第11個及其以後的連線請求會被拒絕
conn,addr = s.accept()
響應一個連線請求
recv_data = conn.recv(64*1024)
接收來自客戶端的資料,並設定緩衝區大小為64KB
resp_data = handle_request(recv_data)
處理請求內容,並生成回覆字串
conn.sendall(resp_data)
傳送回覆字串
conn.close()
關閉與當前客戶端的連線
二、加入HTTP header
有了上述demo的基礎,或許很多人會想,我是不是隻要將自己的東西填入handle_request
中就行了呢?誠然如此,但我們似乎還缺一點:如何區分瀏覽器申請的資源,即怎麼知道瀏覽器要的是a.png
還是b.txt
。
不著急,我們先來普及一下url基本知識:
首先一個url通常有這樣的結構:http[s]://domain-name/path?query-string
,例如:http://a.somesite.com/login.do?username=wierton&passwd=123456
其中http/https
與domain-name
含義自不用說,path
指申請資源的完整路徑名,query-string
格式一般是數個鍵值對,鍵值對之間用&連線,鍵與值之間用=連線,例如:?username=wierton&password=123456
,那如果鍵或值中需要使用&、=這兩個特殊符號呢?這時候就要動用url編碼了,其中=號對應編碼%3D,&號對應編碼%26,因此我們只要在鍵值對中需要這兩個符號的地方將其替換為對應的url編碼即可。
有些url中還會有特殊符號#,其具體用途參見這裡。
上述內容如何對應到TCP連線中收到的資料呢?我們可以做如下一個簡單的實驗,只需將之前的程式碼略作修改,在函式handle_request
的第一行加上print(request)
,修改後程式碼如下:
#coding=utf-8
import re
from socket import *
def handle_request(request):
print(request)
return 'Welcome to wierton\'s site'
s = socket(AF_INET, SOCK_STREAM)
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 8080))
s.listen(10)
while 1:
conn,addr = s.accept()
print("connected by {}".format(addr))
recv_data = conn.recv(64*1024)
resp_data = handle_request(recv_data)
conn.sendall(resp_data)
conn.close()
s.close()
執行程式碼,並在瀏覽器中輸入127.0.0.1:8080/login.do?username=wierton&passwd=
123456
,檢視程式碼的輸出,我們可以看到如下內容:
GET /login.do?username=wierton&passwd=123456 HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l
ike Gecko) Chrome/52.0.2743.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp
,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8
容易發現,url中域名之後的內容被原封不動的放在第一行GET
字串之後。那麼程式碼收到的除第一行外的這麼多資料又是什麼?有何用處?
一個完整的HTTP請求應至少包含一個完整的HTTP header
,有時header
後面還會附上data
段(如POST
請求中),上面程式碼收到的即是一個HTTP header
,而一個HTTP header
的第一行一般形如method path[?query-string] HTTP/version
,method
可為GET、POST、PUT、HEAD、DELETE、CONNECT、TRACE、OPTIONS
,不過一般常用的只有兩個GET
和POST
,path
表示申請伺服器資源的完整路徑名,路徑名之後有時會附帶query-string
,兩者之間以符號?分隔,version
表示協議的版本,目前常用的是HTTP/1.1
。
第一行結束後,會跟上一個\r\n
作為換行符(注意:是\r\n
而非\n
),然後緊接著便是一行行由冒號分割開的鍵值對(關於這些鍵值對的較為詳細的含義可以參見這裡),其中本文關注的欄位有Host、Connection、User-Agent,同樣,這些鍵值對之間也是以\r\n
作為分隔符(換行符)。當然鍵值對的末尾還得加上一個空白行(\r\n
),以區分開HTTP頭與主體資料。
\r\n
英文縮略為CRLF
,在早期顯示器中,游標移動\r
和\n
是兩個分開的操作\r
代表游標移回行首,\n
代表游標移動到下一行水平座標不變的位置,也就是說現在的一個字元\n
其實在早期是由兩個字元\r\n
組成的,同時windows下至今沿用\r\n
作為換行符。
作為伺服器,在拿到這一串header之後,首先要做的無疑是解析header,分割開鍵與值,並最好能將鍵值對存到Python的字典中去,如下便是將這些資訊提取出來的程式碼:
#coding=utf-8
import re
def parse_header(raw_data):
if not '\r\n\r\n' in raw_data:
print('Unable to parse the data:{}.'.format(raw_data))
return False
proto_headers, body = raw_data.split('\r\n\r\n', 1)
proto, headers = proto_headers.split('\r\n', 1)
ma = re.match(r'(GET|POST)\s+(\S+)\s+HTTP/1.1', proto)
if not ma:
print('unsupported protocol')
return False
method, path = ma.groups()
if path[0] == '/':
path = path[1:]
lis = path.split('?')
lis.append('')
rfile, query_string = lis[0:2]
params = [tuple((param+'=').split('=')[0:2])
for param in query_string.split('&')]
ma_headers = re.findall(r'^\s*(.*?)\s*:\s*(.*?)\s*\r?$', headers, re.M)
headers = {item[0]:item[1] for item in ma_headers}
print("version\t: 1.1")
print("method\t: {}".format(method))
print("path\t: {}".format(rfile))
print("params\t: {}".format(params))
print("headers\t: {}".format(headers))
直接甩出這麼一堆程式碼,或許你有點懵逼,不著急,我們來慢慢分析一下這段程式碼,也許分析完,你就能寫出比這更優的程式碼。
首先我們對客戶端傳來的資料做如下標準化假設:
- 換行符:在正式資料之前,換行符均為\r\n
- 資料格式:first-line + key-value-pairs + \r\n + body
- 首行:(GET|POST) path?params HTTP/1.1
* 即只接受GET和POST兩種方法,同時只接受1.1版的HTTP協議。
- 鍵值對:key : value + \r\n
- 資料主體:body
可為空
那麼對於標準假設外的請求,採取一律拒絕掉的策略,基於此假設,我們再來回顧這段程式碼:
if not '\r\n\r\n' in raw_data:
如果不存在空白行,拒絕請求
proto_headers, body = raw_data.split('\r\n\r\n', 1)
將原始資料以空白行分割為header
與body
兩塊
proto, headers = proto_headers.split('\r\n', 1)
將頭中的第一行與鍵值對分割開
ma = re.match(r'(GET|POST)\s+(\S+)\s+HTTP/1.1', proto)
按標準假設匹配第一行,如果不能成功匹配,則拒絕請求
method, path = ma.groups()
將正則表示式匹配到的分組內容提取出來,分別為method
與path[?query-string]
if path[0] == '/': path = path[1:]
將路徑首部的’/’去掉,這一步是為後期做準備,即將客戶端申請的絕對路徑轉化為伺服器工作目錄的相對路徑(這裡為了安全起見還可以對路徑進行判斷,即最終路徑如果不是落在工作目錄內,就拒掉請求)
lis = path.split('?'); lis.append(''); rfile, query_string = lis[0:2]
以?將路徑與query-string
分割開
params = [tuple((param+'=').split('=')[0:2]) for param in
query_string.split('&')]
這裡使用生成器來簡化程式碼,將其展開的話意思就是將query_string按&分割成若干個token,每個token按=分割成前後兩部分(為了防止某些token沒有=,這裡將token加上=在分割),並轉化為一個元組塞到列表中,最終返回這個列表
ma_headers = re.findall(r'^\s*(.*?)\s*:\s*(.*?)\s*\r?$', headers, re.M)
headers = {item[0]:item[1] for item in ma_headers}
這裡用正則表示式來匹配headers資料,並利用正則表示式的分組功能,將結果用生成器打包成一個字典
執行上述程式碼,對如下資料進行解析:
GET /login.do?username=wierton&passwd=123456 HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l
ike Gecko) Chrome/52.0.2743.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp
,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8
得到結果如下:
version : 1.1
method : GET
path : login.do
params : [('username', 'wierton'), ('passwd', '123456')]
headers : {'Accept-Language': 'zh-CN,zh;q=0.8', 'Accept-Encoding': 'gzip, deflate, sdch', 'Connection': 'keep-alive', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l', 'Host': '127.0.0.1:8080', 'Upgrade-Insecure-Requests': '1'}
本節到此為止,下節會介紹如何將請求回覆這一過程封裝,並利用正則表示式分解不同請求,將其引流至不同的handler。
本文如有不當或錯誤之處,歡迎在評論區指出(但拒絕回覆攻擊性、侮辱性言論),轉載時請註明出處。