1. 程式人生 > >從零開始搭建一個簡易的伺服器(一)

從零開始搭建一個簡易的伺服器(一)

前言

其實大家大可不必被伺服器這三個字嚇到,一個入門級後端框架,所需的僅僅是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/httpsdomain-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/versionmethod可為GET、POST、PUT、HEAD、DELETE、CONNECT、TRACE、OPTIONS,不過一般常用的只有兩個GETPOSTpath表示申請伺服器資源的完整路徑名,路徑名之後有時會附帶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)將原始資料以空白行分割為headerbody兩塊

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()將正則表示式匹配到的分組內容提取出來,分別為methodpath[?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。

本文如有不當或錯誤之處,歡迎在評論區指出(但拒絕回覆攻擊性、侮辱性言論),轉載時請註明出處。