后端開發理論基礎之Http協議

打開瀏覽器輸入一個地址,然后得到一個頁面。這幾乎是我們每天都會經歷的事情,本博客的內容就是帶領大家從零開始了解這套機制,力求描述清楚計算機是如何支持我們訪問網頁的。

假設你能理解電話是如何將聲音的震動轉換為電信號而后再轉回聲音信號這個過程,那我就不需要多費口舌,向你過多解釋計算機的基本通信原理了,而且大多數時候程序開發人員也無需關心這種數字模擬信號相互轉換的原理,但必須關心協議,因此我們的第一個話題將圍繞計算機網絡通信協議展開。

《TCP/IP詳解》開篇的第一句話簡明的闡述了協議在計算機通信協議的本質:

Effective communication depends on the use of a common language. This is true for humans and other animals as well as for computers.

有效的溝通建立在通用的語言這一基礎之上,人、動物乃至計算機,無一例外。

<cite>——《TCP/IP詳解》</cite>

為了確保計算機之間的通信能夠建立在同一套體系之內,在計算機網絡發展之初就有先輩們為我們設計了一系列完整的通信協議,以確保我們當今使用的各種軟件和硬件產品能夠依托網絡完成信息交互。TCP/IP協議就是當今計算機世界最主流的網絡通信協議,幾乎所有的計算機系統都遵循這套協議。TCP/IP協議提供了網絡尋址的通用方法和實施標準,各類網絡設備廠商依托這套標準連接所有網絡設備從而形成互聯網。互聯網中的應用程序廠商遵循相同的數據傳輸協議才使得不同的設備之間能夠進行傳輸。最后互聯網中的軟件產品也必須遵循對應的應用層協議才能保證信息被正確的識別。(如果需要詳細了解TCP/IP協議和計算機網絡通信的知識可以翻閱《TCP/IP詳解》這本書。)

HTTP協議簡介

HTTP協議正式我們上面談到的應用層協議。他的主要作用是約定了一套詳細的數據解析規則,以支持客戶端和服務器兩個網絡通信的參與者能夠相互傳輸數據。HTTP協議使用一種一問一答的通信方式,客戶端首先需要發送請求,而后服務端處理之后響應客戶端的請求。而請求和響應的方式如下圖所示。在不考慮傳輸層協議的情況下這就是HTTP協議的全部。

[圖片上傳失敗...(image-9d517d-1653917607526)]

<figcaption>http 請求報文</figcaption>

[圖片上傳失敗...(image-c32042-1653917607526)]

<figcaption>http響應報文</figcaption>

實現HTTP協議

理論上只要我們按照上圖中的約定去編寫程序,發送上圖中規定格式的數據就可以實現http協議了。大多數http服務器都是建立socket傳輸協議的基礎上的。那么我們創建一個簡單的socket服務器然后把數據按照http協議的標準傳輸出去,就可以得到一個http服務器。

為了簡化問題和解決時間,這里使用python的來實現這樣一個http服務器:

import socket

HOST, PORT = '', 8000

def run():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen(1)
    print("http服務器啟動 端口 %s", PORT)

    while True:
        client_conn, client_addr = server.accept()
        http_request = client_conn.recv(1024)
        print("收到請求:%s", http_request)
        http_response = "HTTP/1.1 200 OK\r\n"
        http_response += "Content-Type: text/html;charset=utf-8\r\n"
        http_response += "\r\n"
        http_response += "Hello World!"
        client_conn.sendall(http_response.encode("utf-8"))
        client_conn.close()

    server.close()

if __name__ == '__main__':
    run()

上述代碼實現的HTTP服務器依照HTTP報文的標準對客戶端的請求進行了響應,返回一個響應報文,因為我們的報文返回符合HTTP協議的標準,所以瀏覽器可以解析和顯示我們的內容,盡管目前這個網站只能永遠返回Hello World!

實現靜態網站

一個永遠只能輸出“hello world”的網站似乎沒有任何意義,為了讓我們的http服務器真正有用,我們可以試圖改造上面的程序讓其可以私服html文件。html文件也叫做網頁文件,是一種可以被瀏覽器解析和展示的專有格式的文件。編寫html文件很簡單,你只需要花上十幾分鐘在w3cSchool上面學習一點html(超文本標記語言)的知識,就可以編寫自己的html文件。

為了方便檢驗我們服務器的功能,我們編寫了兩個html文件并將其放在了一個叫做wwwroot的文件夾內。他們的內容很簡單:

首先是index.html文件,他展示一段話告訴用戶這里是網站的首頁,并且提供了一個超鏈接可以幫助跳轉到"關于我"頁面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首頁</title>
</head>
<body>
    <p>你好!歡迎訪問首頁! 你可以點擊<a href="about.html">這個鏈接</a>了解我!</p>
</body>
</html>

然后是about.html文件,他的作用是顯示關于我的一些信息,并提供了一個返回到“首頁”的超鏈接。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>關于我</title>
</head>
<body>
    <h1>我是誰</h1>
    <p>我是老碼農,立志于用最樸素的思想教會大家編寫有用的程序!</p>
    <p>你可以點擊<a href="index.html">這個鏈接</a>返回首頁</p>
</body>
</html>

然后我們嘗試來升級http服務器,經過一番考慮我們得到了如下程序:

# 靜態服務器 可以返回HTML頁面
from copyreg import constructor
import socket
import os

HOST, PORT = '', 8000
WEBROOT = './wwwroot'

def run():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((HOST, PORT))
    server.listen(1)
    print("http服務器啟動 端口 {}".format(PORT))

    while True:
        client_conn, client_addr = server.accept()
        http_request = client_conn.recv(1024)
        relative_path = get_relative_path(http_request.decode('utf-8', 'ignore'))
        data = load_html(relative_path)
        http_response = "HTTP/1.1 200 OK\r\n"
        http_response += "Content-Type: text/html;charset=utf-8\r\n"
        http_response += "\r\n"
        http_response += data
        client_conn.sendall(http_response.encode("utf-8"))
        client_conn.close()

    server.close()

def get_relative_path(http_request):
    rows = http_request.split('\r\n')[0]
    return rows.split(' ')[1]

def load_html(html_path):
    if html_path == '/':
        html_path = '/index.html'
    if html_path == '/favicon.ico':
        return ''
    full_path = WEBROOT+html_path
    with open(full_path, 'r', encoding='utf-8') as f:
        return f.read()

if __name__ == '__main__':
    run()

運行這個程序,點擊首頁的鏈接你將跳轉到about頁面,然后再點擊回到首頁的鏈接,你將會回到首頁。只要編寫更多的html文件并將其鏈接器起來,我們就能得到一個靜態網站,而這個網站我們沒有用任何其他的技術,完全通過最基礎的編程接口來實現,你甚至可以使用C語言去開發具有相同功能的網站。

實現動態網站

在上面的程序中通過解析request數據可以得到請求路徑,而根據請求路徑去映射本地html文件然后將其作為response的數據部分返回給客戶端我們就可以得到一個靜態網頁伺服器。但是大多數時候人們還希望自己的網站能夠根據請求動態響應頁面,因此還需要對我們的程序進行進一步升級。

近年來流行一種新的思想用于解釋web程序,這種思想的核心是將網絡上的所有資源都當成是一種資產,我們可以對這些資產執行查看、創建、修改、刪除這四類操作已完成遠程資源的管理。HTTP協議為我們考慮到了這類情況,在HTTP協議中,將請求分為了如下幾種類型:

  • GET——用于請求數據
  • POST——用于提交數據
  • PUT——用于修改數據
  • DELETE——用于刪除數據

一個比較典型的例子就是用于客戶管理的web程序可以支持用戶通過網頁查看、創建、修改和刪除客戶以維護自己的客戶資源。我們一般使用GET請求去向服務器請求客戶列表和查看客戶信息,使用POST請求去創建一個新的客戶,使用PUT請求去修改客戶的信息,使用DELETE請求去刪除某一個客戶。近年來很多的web程序都是用這樣的形式去進行web開發,他有一個更專業的名字叫做RestFull風格,因此接下來的服務器設計,我們將盡量支持這四種請求類型。

除了要注意請求方式之外,動態網站的請求往往會附帶一些用戶希望傳遞給服務器程序的信息。比如當用戶想查看年齡35歲以上的客戶清單時就必須把年齡大于35這個信息傳遞給服務器;同樣的當用戶想要創建一個新的客戶時,就必須把客戶的姓名、年齡、聯系方式等信息傳遞到服務器。對于這類信息的傳遞HTTP協議提供了多種方式。

url傳參

第一種方式被程序員們稱作URL傳參,具體的方式就是把信息放置在請求頭中的url里面,比如當我想要獲取一個名字叫做Andy的用戶的信息時,其對應的url可能會被表達成'/customer?name=andy'這樣的形式。url后面加上‘?’,然后使用‘屬性名稱’=‘屬性值’的方式進行拼接就可以得到一個帶有請求參數的url,如果有多個參數要傳遞則使用‘&’符號將各組數據隔離開即可。類似于這樣:‘/customer?name=andy&age=18’。當然這只是url傳參的一種方式,本質上并不是http協議的一部分,但是幾乎所有的服務器程序都支持使用這種方式把參數攜帶在url內,一般情況下這種方式被用在GET請求中。

form-data傳遞數據

第二種方式被稱之為表單數據傳參,這種傳參方式是通過HTML中的FORM表單來收集用戶數據,然后將數據放置在request的請求體(body)中。這個時候的request一般如下面展示的一般:

這樣的一個表單用于向服務器發起一個POST請求,并攜帶客戶的姓名和年齡以保證服務器端能夠創建這樣一個name為andy,年齡為18歲的客戶。

<form action="/customer" method="post">
  <input type="text" name="name" value="some text"/>
  <input type="text" name="age"  value="18"/>
  <button type="submit">Submit</button>
</form>

事實上,當用戶在瀏覽器點擊提交按鈕之后,會有這樣一個request被傳遞到服務器:

POST /foo HTTP/1.1
Content-Length: 68137
Content-Type: application/x-www-form-urlencoded
Content-Length: 16

name=andy&age=18

上面的請求在默認情況下被設置為 application/x-www-form-urlencoded 格式,也就是將數據轉換為 : "屬性1"="值"&"屬性2"="值"... 這樣的形式。

如果客戶想要上傳文件,那么使用上面的方式顯然就行不通了,http協議提供的解決方案是使用multipart/form-data作為content-type,下面是從MDN中摘抄來的示例:

<pre class="wp-block-preformatted">POST /test.html HTTP/1.1
Host: example.org
Content-Type: multipart/form-data;boundary="boundary"

--boundary
Content-Disposition: form-data; name="field1"

value1
--boundary
Content-Disposition: form-data; name="field2"; filename="example.txt"

文件內容.......</pre>

看上去似乎比x-www-form-urlencoded這種格式更加復雜,但是它更加能勝任傳輸二進制文件這種特殊需求。

使用json或者xml格式傳遞信息

現下,更多時候傳遞數據的方式是使用json或者xml格式。json和xml這兩種數據格式以一種較穩清晰的結構去定義數據,也有很豐富的配套代碼庫去完成json和xml的解析。

一段表示customer的json數據一般長這樣:

{
    "name" : "Andy",
    "age"  : 18,
}

而使用xml則可以這樣表示:

到此為止,我們還沒有開始構建我們的動態網站。但

<?xml version="1.0" encoding="UTF-8"?>
<cutomer>
  <name>Andy</name>
  <age>18</age>
</cutomer>

開始規劃動態網站的代碼

到目前為止我們還沒有聊任何有關動態網站如何建立的話題,但我們已經掌握了一些必要的知識。我們知道了GET、PUT、POST、DELETE四種HTTP協議所支持的請求方式,也了解了三種常用的HTTP傳遞數據的方式。接下來的工作就是嘗試去解析request消息的內容,根據不同的請求方式來執行不同的處理邏輯,以及在處理邏輯中提取不同的請求參數。所以我們的代碼將作如下規劃:

  • 程序的主體依舊是一個socket服務器;
  • 我們需要一個代碼塊來專門解析request數據并從中得到各種請求參數;
  • 我們還需要一個response生成器專門用來制造各種響應;
  • 我們還需要一個基本的路由器,按照請求方法和路徑將用戶的請求指向對應的處理程序;
  • 最后,我們要再次基礎上編寫一個客戶管理的程序并讓其能夠響應瀏覽器的請求;

首先是程序的主題,依舊是一個簡易的scoket服務器:

# 動態服務器 提供一個客戶管理程序
import socket
from sys import argv
from http_request import Request
from router import Router

HOST, PORT = '', 8000
WEBROOT = './wwwroot'

def run():
    if len(argv) != 2:
        print("請配置程序處理模塊并確保啟動參數輸入正確")
        return
    _,app = argv 
    router = Router(app)
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((HOST, PORT))
    server.listen(1)
    print("http服務器啟動 端口 {}".format(PORT))

    while True:
        client_conn, client_addr = server.accept()
        request = Request(client_conn.recv(1024).decode('utf-8'))
        response = router.route(request)
        client_conn.sendall(response.encode("utf-8"))
        client_conn.close()

    server.close()

if __name__ == '__main__':
    run()

上面的代碼使用通過對客戶端發送的request消息進行解析得到一個程序內建模的Resquet對象,這將有利于我們在后面的程序中更加方便的使用request數據。

Request解析器的代碼如下:

'''
http request 數據解析和封裝
'''

class Request:
    '''
    定義一個Request類來表示客戶端的request
    '''
    def __init__(self,request_raw_data):
        '''
        request_raw_data 就是原始的request數據
        使用method存儲 request的請求方法
        使用url存儲 request的請求路徑
        使用header字典存儲 request的頭信息
        使用url_params存儲 request的url參數信息
        使用form_data字典存儲 request的表單數據
        使用json_data字典存儲 request的json數據
        使用xml_data字典存儲 request的xml數據
        '''
        self.method = ''
        self.url = ''
        self.headers = {} 
        self.url_params = {}
        self.form_data = {}
        self.json_data = {}
        self.__parse__(request_raw_data)

    def __parse__(self, raw):
        '''
        解析response數據
        '''
        rows = raw.split("\r\n")
        self.method, self.url, _ = rows[0].split(' ')
        self.url_params = self.parse_url_params()
        if '?' in self.url:
            self.url = self.url.split('?')[0]
        self.parse_headers(rows)
        self.form_data = self.parse_form_data(rows)

    def parse_headers(self, rows):
        '''
        解析header字典
        '''
        header_rows = rows[1:rows.index("")]
        for row in header_rows:
            k,v = row.split(': ')
            self.headers[k.strip()] = v.strip()

    def parse_url_params(self):
        '''
        解析url參數
        '''
        if '?' in self.url:
            paramsStr = self.url.split('?')[-1]
            return self.paramsExpressionToDict(paramsStr)
        else:
            return {}

    def parse_form_data(self, rows):
        '''
        解析formdata
        '''
        raw_data = rows[rows.index(''):][1]
        return self.paramsExpressionToDict(raw_data)

    def paramsExpressionToDict(self, paramsStr):
        '''
        a=xx&b=xx 形式的參數表達式轉換為字典
        '''
        dict_result = {}
        for attrExpression in paramsStr.split('&'):
            if '=' in attrExpression:
                k,v =attrExpression.split('=')
                dict_result[k.strip()] = v.strip()
        return dict_result

上面的代碼實現了request的基本解析,他可能暫時還不太完善。它的主要功能是request字節碼數據轉換成為一個Response類,并提供header, method, url,url_params, form_data 等基礎數據。

接下來實現一個路由方法,來保證程序能夠根據請求方法和路徑去執行對應的代碼:

'''
請求路由處理程序
'''
import importlib
import types
import sys
from http_request import Request
from http_response import build_404_response

class Router:
    '''
    核心路由器
    '''
    def __init__(self, app_module):
        self.route_table = {}
        self.app_module = app_module
        self.init_route_tables()

    def init_route_tables(self):
        '''
        初始化路由表
        '''
        app = importlib.import_module(self.app_module)
        attrList = dir(app)
        for attrName in attrList:
            attr = getattr(app, attrName)
            if isinstance(attr, types.FunctionType):
                if "method" in attr.__dict__:
                    method = attr.__dict__['method']
                    path = attr.__dict__['path']
                    key = '{}:{}'.format(method, path)
                    if key in self.route_table.keys():
                        print("配置了重復的路由 {}".format(key))
                        sys.exit(1)
                    self.route_table[key] = attr

    def route(self, request:Request):
        '''
        查詢路由表并執行對應的處理程序
        '''
        key = '{}:{}'.format(request.method, request.url)
        if key in self.route_table.keys() :
            func = self.route_table[key]
            return func.__call__(request)
        else:
            return build_404_response()

上面的代碼創建了一個Route類,Route類中封裝了一個叫做init_route_tables的方法,這個方法利用反射的方式動態將我們的業務邏輯代碼加載進入程序。并從代碼中篩選出來對應的邏輯處理代碼放到一個字典中。在需要進行路由的時候,僅需要根據請求方式和路徑找到對應的代碼片段的指針,然后使用反射執行對應的程序即可!

為了方便邏輯代碼的編寫,同時為了支持路由表掃描,我們定義了一個幫助模塊專門提供裝飾器以支持在函數這一級別上進行代碼片段的標記。

from functools import wraps

def route(method:str, path:str):
    def applies(func):
        func.__dict__["method"] = method.upper()
        func.__dict__["path"] = path.lower()
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    return applies

最后,我們編寫業務邏輯實現一個簡單的對客戶信息進行增刪改查的代碼,他看上去有點像flask程序:

from helper import route
from models import Customer
from http_request import Request
from http_response import build_404_response, build_json_response

#所有客戶
customers = [
    Customer("Andy", 28, 'andy@163.com'),
    Customer("Bob", 29, 'bob@163.com'),
    Customer("Tony", 30, 'tony@163.com'),
    Customer("Candy", 19, 'candy@163.com'),
    Customer("Daiv", 22, 'daiv@163.com')
]

@route(method='get', path='/favicon.ico')
def ico(request):
    return build_404_response()

@route(method='get', path="/customer/all")
def query_all(request:Request):
    global customers
    return build_json_response(customers)

@route(method='post', path='/customer')
def create_one(request:Request):
    name = request.form_data['name']
    age = request.form_data['age']
    mail = request.form_data['mail']
    global customers
    customers.append(Customer(name,age,mail))
    return build_json_response(customers)

@route(method="delete", path="/customer")
def delete_by_name(request: Request):
    name = request.url_params['name']
    remove_list = []
    global customers
    for cus in customers:
        if cus.name == name:
            customers.remove(cus)
    return build_json_response(customers)

@route(method="put", path="/customer")
def update(request:Request):
    name = request.form_data['name']
    age = request.form_data['age']
    mail = request.form_data['mail']
    print(name, age, mail)
    global customers
    for cus in customers:
        if cus.name == name:
            customers.remove(cus)
            break
    customers.append(Customer(name,age,mail))
    return build_json_response(customers)

到此為止,我們沒有依托任何web框架就完成了一個動態網站!

寫在后面

為了幫助大家了解HTTP協議,本博文從零實現了三個版本的HTTP服務器。在動態網站服務器的設計上還存在很多缺陷,但繼續寫下去網絡上也充其量就是多了一個類似于flask框架或者diango框架的web框架,并且還可能會存在一大堆問題,這并沒有什么意義。

回到我們的出發點,不依托任何web框架程序從零開始使用最原生的編程接口去實現一個HTTP服務器的目的在于讓讀者能夠了解HTTP協議以及web開發的基本知識,這就像不收些servelet就很難去了解java中的struts/spring等框架。因此,如果有機會,還是推薦大家使用自己擅長的一門語言去實現一下本文中的動態網站,相信會對你的后端開發能力有積極影響!

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容