前言
其實大家大可不必被服務(wù)器這三個字嚇到冬殃,一個入門級后端框架,所需的僅僅是HTTP相關(guān)的知識與應(yīng)用這些知識的編程工具。據(jù)本人的經(jīng)驗辐怕,絕大多數(shù)人擁有搭建后端所涉及到的基礎(chǔ)理論知識,但是缺乏能將之應(yīng)用出去的工具从绘,而本文即是交給讀者這樣一個工具寄疏,并能夠運用之來實現(xiàn)一個可用的后端。
本文以基礎(chǔ)理論知識的運用為主僵井,并不會在服務(wù)器的穩(wěn)定性安全性上做探究陕截,同時為了避免大家在實現(xiàn)中被各種編程語言的獨有特性所困擾,本文選用選Python作為編程語言批什,并會附上詳細(xì)的代碼农曲。
一、最初的嘗試
超文本傳輸協(xié)議(HyperText Transfer Protocol)是迄今為止互聯(lián)網(wǎng)應(yīng)用最為廣泛的協(xié)議渊季,平時大家在瀏覽器上瀏覽網(wǎng)頁朋蔫,逛淘寶,刷博客却汉,上知乎均是基于這種協(xié)議驯妄。
在互聯(lián)網(wǎng)七層架構(gòu)中HTTP位于TCP/UDP之上,這意味著我們我們可以在TCP/UDP層收發(fā)HTTP層的數(shù)據(jù)合砂,而能夠幫助我們在TCP/UDP層收發(fā)數(shù)據(jù)的最原始的一個工具------套接字青扔。
幾乎每一門編程語言都會原生支持套接字,所以本文選用套接字講解,而非python語言本身拿手的第三方庫微猖,套接字與基礎(chǔ)知識之間直接對接谈息,這樣不僅簡化學(xué)習(xí)成本,同時易于讀者從底層了解學(xué)習(xí)HTTP凛剥,也便于理解各種第三方庫的實現(xiàn)機理侠仇,可謂一舉三得。
在套接字的幫助下犁珠,我們可以寫下第一個服務(wù)器端的框架:
#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.
余素,如圖:
怎么樣,是不是很有成就感炊昆,你的代碼“成功”響應(yīng)了瀏覽器的請求并回復(fù)了一個你設(shè)定好的字符串桨吊。
或許新入門的你對上述代碼有所疑惑,不著急凤巨,我們來慢慢過一遍上述代碼视乐。
s = socket(AF_INET, SOCK_STREAM)
創(chuàng)建一個流式套接字用于TCP通信
s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
設(shè)定當(dāng)前套接字,使其允許被復(fù)用
s.bind(('127.0.0.1', 8080))
將當(dāng)前套接字綁定到ip地址為127.0.0.1磅甩,端口號為8080的連接上
注:雖然HTTP默認(rèn)端口為80炊林,但在linux下,監(jiān)聽80號端口需要root權(quán)限卷要。
s.listen(10)
監(jiān)聽當(dāng)前套接字渣聚,設(shè)定并發(fā)數(shù)為10,即在多客戶端并發(fā)請求時僧叉,第11個及其以后的連接請求會被拒絕
conn,addr = s.accept()
響應(yīng)一個連接請求
recv_data = conn.recv(64*1024)
接收來自客戶端的數(shù)據(jù)奕枝,并設(shè)置緩沖區(qū)大小為64KB
resp_data = handle_request(recv_data)
處理請求內(nèi)容,并生成回復(fù)字串
conn.sendall(resp_data)
發(fā)送回復(fù)字串
conn.close()
關(guān)閉與當(dāng)前客戶端的連接
二瓶堕、加入HTTP header
有了上述demo的基礎(chǔ)隘道,或許很多人會想,我是不是只要將自己的東西填入handle_request
中就行了呢郎笆?誠然如此谭梗,但我們似乎還缺一點:如何區(qū)分瀏覽器申請的資源,即怎么知道瀏覽器要的是a.png
還是b.txt
宛蚓。
不著急激捏,我們先來普及一下url基本知識:
首先一個url通常有這樣的結(jié)構(gòu):http[s]://domain-name/path?query-string
,例如:http://a.somesite.com/login.do?username=wierton&passwd=123456
其中http/https
與domain-name
含義自不用說凄吏,path
指申請資源的完整路徑名远舅,query-string
格式一般是數(shù)個鍵值對闰蛔,鍵值對之間用&連接,鍵與值之間用=連接图柏,例如:?username=wierton&password=123456
序六,那如果鍵或值中需要使用&、=這兩個特殊符號呢蚤吹?這時候就要動用url編碼了例诀,其中=號對應(yīng)編碼%3D,&號對應(yīng)編碼%26裁着,因此我們只要在鍵值對中需要這兩個符號的地方將其替換為對應(yīng)的url編碼即可余佃。
有些url中還會有特殊符號#,其具體用途參見這里跨算。
上述內(nèi)容如何對應(yīng)到TCP連接中收到的數(shù)據(jù)呢?我們可以做如下一個簡單的實驗椭懊,只需將之前的代碼略作修改诸蚕,在函數(shù)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
背犯,查看代碼的輸出,我們可以看到如下內(nèi)容:
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
容易發(fā)現(xiàn)盅抚,url中域名之后的內(nèi)容被原封不動的放在第一行GET
字符串之后漠魏。那么代碼收到的除第一行外的這么多數(shù)據(jù)又是什么?有何用處妄均?
一個完整的HTTP請求應(yīng)至少包含一個完整的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
表示申請服務(wù)器資源的完整路徑名,路徑名之后有時會附帶query-string
剪决,兩者之間以符號?分隔灵汪,version
表示協(xié)議的版本檀训,目前常用的是HTTP/1.1
。
第一行結(jié)束后享言,會跟上一個\r\n
作為換行符(注意:是\r\n
而非\n
)峻凫,然后緊接著便是一行行由冒號分割開的鍵值對(關(guān)于這些鍵值對的較為詳細(xì)的含義可以參見這里),其中本文關(guān)注的字段有Host览露、Connection荧琼、User-Agent,同樣差牛,這些鍵值對之間也是以\r\n
作為分隔符(換行符)命锄。當(dāng)然鍵值對的末尾還得加上一個空白行(\r\n
),以區(qū)分開HTTP頭與主體數(shù)據(jù)偏化。
\r\n
英文縮略為CRLF
脐恩,在早期顯示器中,光標(biāo)移動\r
和\n
是兩個分開的操作\r
代表光標(biāo)移回行首侦讨,\n
代表光標(biāo)移動到下一行水平坐標(biāo)不變的位置驶冒,也就是說現(xiàn)在的一個字符\n
其實在早期是由兩個字符\r\n
組成的,同時windows下至今沿用\r\n
作為換行符韵卤。
作為服務(wù)器骗污,在拿到這一串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))
直接甩出這么一堆代碼蜡歹,或許你有點懵逼屋厘,不著急,我們來慢慢分析一下這段代碼月而,也許分析完擅这,你就能寫出比這更優(yōu)的代碼。
首先我們對客戶端傳來的數(shù)據(jù)做如下標(biāo)準(zhǔn)化假設(shè):
- 換行符:在正式數(shù)據(jù)之前景鼠,換行符均為
\r\n
- 數(shù)據(jù)格式:
first-line + key-value-pairs + \r\n + body
- 首行:
(GET|POST) path?params HTTP/1.1
- 即只接受GET和POST兩種方法仲翎,同時只接受1.1版的HTTP協(xié)議。
- 鍵值對:
key : value + \r\n
- 數(shù)據(jù)主體:
body
可為空
那么對于標(biāo)準(zhǔn)假設(shè)外的請求铛漓,采取一律拒絕掉的策略溯香,基于此假設(shè),我們再來回顧這段代碼:
if not '\r\n\r\n' in raw_data:
如果不存在空白行浓恶,拒絕請求
proto_headers, body = raw_data.split('\r\n\r\n', 1)
將原始數(shù)據(jù)以空白行分割為header
與body
兩塊
proto, headers = proto_headers.split('\r\n', 1)
將頭中的第一行與鍵值對分割開
ma = re.match(r'(GET|POST)\s+(\S+)\s+HTTP/1.1', proto)
按標(biāo)準(zhǔn)假設(shè)匹配第一行玫坛,如果不能成功匹配,則拒絕請求
method, path = ma.groups()
將正則表達(dá)式匹配到的分組內(nèi)容提取出來包晰,分別為method
與path[?query-string]
if path[0] == '/': path = path[1:]
將路徑首部的'/'去掉湿镀,這一步是為后期做準(zhǔn)備炕吸,即將客戶端申請的絕對路徑轉(zhuǎn)化為服務(wù)器工作目錄的相對路徑(這里為了安全起見還可以對路徑進行判斷,即最終路徑如果不是落在工作目錄內(nèi)勉痴,就拒掉請求)
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加上=在分割)瀑罗,并轉(zhuǎn)化為一個元組塞到列表中,最終返回這個列表
ma_headers = re.findall(r'^\s*(.*?)\s*:\s*(.*?)\s*\r?$', headers, re.M)
headers = {item[0]:item[1] for item in ma_headers}
這里用正則表達(dá)式來匹配headers數(shù)據(jù)雏掠,并利用正則表達(dá)式的分組功能斩祭,將結(jié)果用生成器打包成一個字典
運行上述代碼,對如下數(shù)據(jù)進行解析:
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
得到結(jié)果如下:
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'}
本節(jié)到此為止乡话,下節(jié)會介紹如何將請求回復(fù)這一過程封裝摧玫,并利用正則表達(dá)式分解不同請求,將其引流至不同的handler绑青。
注:文中涉及的代碼均在python2.7下運行通過席赂。