從HTTP請求響應迴圈探索Flask的基本工作方式

greylihui發表於2018-09-15

本文基於Flask Web開發實戰2章《Flask與HTTP》刪減改寫而來,完整的章節目錄請訪問本書主頁http://helloflask.com/book檢視。

HTTP(Hypertext Transfer Protocol,超文字傳輸協議)定義了伺服器和客戶端之間資訊交流的格式和傳遞方式,它是全球資訊網(World Wide Web)中資料交換的基礎。在這篇文章中,我們會以HTTP協議定義的請求響應迴圈流程作為框架,瞭解Flask處理請求和響應的各種方式。

附註 HTTP的詳細定義在RFC 7231~7235中可以看到。RFC(Request For Comment,請求評議)是一系列關於網際網路標準和資訊的檔案,可以將其理解為網際網路(Internet)的設計文件。完整的RFC列表可以在這裡看到:https://tools.ietf.org/rfc/

本章的示例程式在helloflask倉庫的demos/http目錄下,你可以通過下面的操作來執行程式:

$ git clone https://github.com/greyli/helloflask
$ cd helloflask
$ pipenv install
$ pipenv shell
$ cd demos/http
$ flask run複製程式碼

請求響應迴圈

為了更貼近現實,我們以一個真實的URL為例:

http://helloflask.com/hello複製程式碼

當我們在瀏覽器中的位址列中輸入這個URL,然後按下Enter,稍等片刻,瀏覽器會顯示一個問候頁面。這背後到底發生了什麼?你一定可以猜想到,這背後也有一個類似我們第1章編寫的程式執行著。它負責接收使用者的請求,並把對應的內容返回給客戶端,顯示在使用者的瀏覽器上。事實上,每一個Web應用都包含這種處理模式,即“請求-響應迴圈(Request-Response Cycle)”:客戶端發出請求,伺服器處理請求並返回響應,如下圖所示:

請求響應迴圈示意圖 

附註 客戶端(Client Side)是指用來提供給使用者的與伺服器通訊的各種軟體。在本書中,客戶端通常指Web瀏覽器(後面簡稱瀏覽器),比如Chrome、Firefox、IE等;伺服器端(Server Side)則指為使用者提供服務的伺服器,也是我們的程式執行的地方。

這是每一個Web程式的基本工作模式,如果再進一步,這個模式又包含著更多的工作單元,

下圖展示了一個Flask程式工作的實際流程:

Flask Web程式工作流程 

從上圖可以看出,HTTP在整個流程中起到了至關重要的作用,它是客戶端和伺服器端之間溝通的橋樑。

當使用者訪問一個URL,瀏覽器便生成對應的HTTP請求,經由網際網路傳送到對應的Web伺服器。Web伺服器接收請求,通過WSGI將HTTP格式的請求資料轉換成我們的Flask程式能夠使用的Python資料。在程式中,Flask根據請求的URL執行對應的檢視函式,獲取返回值生成響應。響應依次經過WSGI轉換生成HTTP響應,再經由Web伺服器傳遞,最終被髮出請求的客戶端接收。瀏覽器渲染響應中包含的HTML和CSS程式碼,並執行JavaScript程式碼,最終把解析後的頁面呈現在使用者瀏覽器的視窗中。

提示 關於WSGI的更多細節,我們會在第16章進行詳細介紹。

提示 這裡的伺服器指的是處理請求和響應的Web伺服器,比如我們上一章介紹的開發伺服器,而不是指物理層面上的伺服器主機。

HTTP請求

URL是一個請求的起源。不論伺服器是執行在美國洛杉磯,還是執行在我們自己的電腦上,當我們輸入指向伺服器所在地址的URL,都會向伺服器傳送一個HTTP請求。一個標準的URL由很多部分組成,以下面這個URL為例:

http://helloflask.com/hello?name=Grey複製程式碼

這個URL的各個組成部分如下表所示:

資訊

說明

http://

協議字串,指定要使用的協議

helloflask.com

伺服器的地址(域名)

/hello?name=Grey

要獲取的資源路徑(path),類似Unix的檔案目錄結構

附註 這個URL後面的?name=Grey部分是查詢字串(query string)。URL中的查詢字串用來向指定的資源傳遞引數。查詢字串從問號?開始,以鍵值對的形式寫出,多個鍵值對之間使用&分隔。

請求報文

當我們在瀏覽器中訪問這個URL時,隨之產生的是一個發向http://helloflask.com所在伺服器的請求。請求的實質是傳送到伺服器上的一些資料,這種瀏覽器與伺服器之間互動的資料被稱為報文(message),請求時瀏覽器傳送的資料被稱為請求報文(request message),而伺服器返回的資料被稱為響應報文(response message)。

請求報文由請求的方法、URL、協議版本、首部欄位(header)以及內容實體組成。前面的請求產生的請求報文示意如下表所示:

組成說明

請求報文內容

報文首部:請求行(方法、URL、協議)

GET /hello HTTP/1.1

報文首部:各種首部欄位

Host: helloflask.com

Connection: keep-alive

Cache-Control: max-age=0

User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.104 Safari/537.36

...

空行

報文主體

name=Grey


如果你想看看真實的HTTP報文,可以在瀏覽器中向任意一個有效的URL發起請求,然後在瀏覽器的開發者工具(F12)裡的Network標籤中看到URL對應資源載入的所有請求列表,點選任一個請求條目即可看到報文資訊,下圖是使用Chrome訪問本地的示例程式的示例:

在Chrome瀏覽器中檢視請求和響應報文

報文由報文首部和報文主體組成,兩者由空行分隔,請求報文的主體一般為空。如果URL中包含查詢字串,或是提交了表單,那麼報文主體將會是查詢字串和表單資料。

HTTP通過方法來區分不同的請求型別。比如,當你直接訪問一個頁面時,請求的方法是GET;當你在某個頁面填寫了表單並提交時,請求方法則通常為POST。下表是常見的幾種HTTP方法型別:

方法

說明

GET

獲取資源

POST

傳輸資料

PUT

傳輸檔案

DELETE

刪除資源

HEAD

獲得報文首部

OPTIONS

詢問支援的方法

報文首部包含了請求的各種資訊和設定,比如客戶端的型別,是否設定快取,語言偏好等等。

附註 HTTP中可用的首部欄位列表可以在https://www.iana.org/assignments/message-headers/message-headers.xhtml看到。請求方法的詳細列表和說明可以在RFC 7231中看到。

如果執行了示例程式,那麼當你在瀏覽器中訪問http://127.0.0.1:5000/hello,開發伺服器會在命令列中輸出一條記錄日誌,其中包含請求的主要資訊:

127.0.0.1 - - [02/Aug/2017 09:51:37] "GET /hello HTTP/1.1" 200 –複製程式碼

Request物件

現在該讓Flask的請求物件request出場了,這個請求物件封裝了從客戶端發來的請求報文,我們能從它獲取請求報文中的所有資料。

注意 請求解析和響應封裝實際上大部分是由Werkzeug完成的,Flask子類化Werkzeug的請求(Request)和響應(Response)物件並新增了和程式相關的特定功能。在這裡為了方便理解,我們先略過不談。在第16章,我們會詳細瞭解Flask的工作原理。

和上一節一樣,我們先從URL說起。假設請求的URL是http://helloflask.com/hello?name=Grey,當Flask接收到請求後,請求物件會提供多個屬性來獲取URL的各個部分,常用的屬性如下表所示:

屬性

path

u'/hello'

full_path

u'/hello?name=Grey'

host

u'helloflask.com'

host_url

u'http://helloflask.com/ '

base_url

u'http://helloflask.com/hello '

url

u'http://helloflask.com/hello?name=Grey '

url_root

u'http://helloflask.com/ '

除了URL,請求報文中的其他資訊都可以通過request物件提供的屬性和方法獲取,其中常用的部分如下表所示:

屬性/方法

說明

args

Werkzeug的ImmutableMultiDict物件。儲存解析後的查詢字串,可通過字典方式獲取鍵值。如果你想獲取未解析的原生查詢字串,可以使用query_string屬性

blueprint

當前藍本的名稱,關於藍本的概念在本書第二部分會詳細介紹

cookies

一個包含所有隨請求提交的cookies的字典

data

包含字串形式的請求資料

endpoint

與當前請求相匹配的端點值

files

Werkzeug的MultiDict物件,包含所有上傳檔案,可以使用字典的形式獲取檔案。使用的鍵為檔案input標籤中的name屬性值,對應的值為Werkzeug的FileStorage物件,可以呼叫save()方法並傳入儲存路徑來儲存檔案

form

Werkzeug的ImmutableMultiDict物件。類似files,包含解析後的表單資料。表單欄位值通過input標籤的name屬性值作為鍵獲取

values

Werkzeug的CombinedMultiDict物件,結合了args和form屬性的值

get_data(cache=True, as_text=False, parse_from_data=False)

獲取請求中的資料,預設讀取為位元組字串(bytestring),將as_text設為True返回值將是解碼後的unicode字串

get_json(self, force=False, silent=False, cache=True)

作為JSON解析並返回資料,如果MIME型別不是JSON,返回None(除非force設為True);解析出錯則丟擲Werkzeug提供的BadRequest異常(如果未開啟除錯模式,則返回400錯誤響應,後面會詳細介紹),如果silent設為True則返回None;cache設定是否快取解析後的JSON資料

headers

一個Werkzeug的EnvironHeaders物件,包含首部欄位,可以以字典的形式操作

is_json

通過MIME型別判斷是否為JSON資料,返回布林值

json

包含解析後的JSON資料,內部呼叫get_json(),可通過字典的方式獲取鍵值

method

請求的HTTP方法

referrer

請求發起的源URL,即referer

scheme

請求的URL模式(http或https)

user_agent

使用者代理(User Agent,UA),包含了使用者的客戶端型別,作業系統型別等資訊

提示 Werkzeug的MutliDict類是字典的子類,它主要實現了同一個鍵對應多個值的情況。比如一個檔案上傳欄位可能會接收多個檔案。這時就可以通過getlist()方法來獲取檔案物件列表。而ImmutableMultiDict類繼承了MutliDict類,但其值不可更改。具體訪問Werkzeug文件相關資料結構章節http://werkzeug.pocoo.org/docs/latest/datastructures/

在我們的示例程式中實現了同樣的功能。當你訪問http://localhost:5000/hello?name=Grey,頁面載入後會顯示“Hello, Grey!”。這說明處理這個URL的檢視函式從查詢字串中獲取了查詢引數name的值,如下所示:

from flask import Flask, request

app = Flask(__name__)

@app.route('/hello')
def hello():
    name = request.args.get('name', 'Flask')  # 獲取查詢引數name的值
    return '<h1>Hello, %s!</h1>' % name  # 插入到返回值中複製程式碼

注意 上面的示例程式碼包含安全漏洞,在現實中我們要避免直接將使用者傳入的資料直接作為響應返回,在本章的末尾我們將介紹包括這個漏洞在內的Web常見安全漏洞的具體細節和防範措施。

需要注意的是,和普通的字典型別不同,當我們從request物件中型別為MutliDict或ImmutableMultiDict的屬性(比如files、form、args)中直接使用鍵作為索引獲取資料時(比如request.args['name']),如果沒有對應的鍵,那麼會返回HTTP 400錯誤響應(Bad Request,表示請求無效),而不是丟擲KeyError異常,如下圖所示。為了避免這個錯誤,我們應該使用get()方法獲取資料,如果沒有對應的值則返回None;get()方法的第二個引數可以設定預設值,比如requset.args.get('name', 'Human')。

400錯誤響應 

提示 如果開啟了除錯模式,那麼會丟擲BadRequestKeyError異常並顯示對應的錯誤堆疊資訊,而不是常規的400響應。

在Flask中處理請求

URL是指向網路上資源的地址。在Flask中,我們需要讓請求的URL匹配對應的檢視函式,檢視函式返回值就是URL對應的資源。

路由匹配

為了便於將請求分發到對應的檢視函式,程式例項中儲存了一個路由表(app.url_map),其中定義了URL規則和檢視函式的對映關係。當請求發來後,Flask會根據請求報文中的URL(path部分)來嘗試與這個表中的所有的URL規則進行匹配,呼叫匹配成功的檢視函式。如果沒有找到匹配的URL規則,說明程式中沒有處理這個URL的檢視函式,Flask會自動返回404錯誤響應(Not Found,表示資源未找到)。你可以嘗試在瀏覽器中訪問http://localhost:5000/nothing,因為我們的程式中沒有檢視函式負責處理這個URL,所以你會得到404響應,如下圖所示:

404錯誤響應

如果你經常上網,那麼肯定會對這個錯誤程式碼相當熟悉,它表示請求的資源沒有找到。和前面提及的400錯誤響應一樣,這類錯誤程式碼被稱為HTTP狀態碼,用來表示響應的狀態,具體會在下面詳細討論。

當請求的URL與某個檢視函式的URL規則匹配成功時,對應的檢視函式就會被呼叫。使用flask routes命令可以檢視程式中定義的所有路由,這個列表由app.url_map解析得到:

$ flask routes
Endpoint  Methods  Rule
--------  -------  -----------------------
hello     GET      /hello
go_back   GET      /goback/<int:age>
hi         GET      /hi
...
static    GET      /static/<path:filename>複製程式碼

在輸出的文字中,我們可以看到每個路由對應的端點(Endpoint)、HTTP方法(Methods)和URL規則(Rule),其中static端點是Flask新增的特殊路由,用來訪問靜態檔案,具體我們會在第3章學習。

設定監聽的HTTP方法

在上一節通過flask routes命令列印出的路由列表可以看到,每一個路由除了包含URL規則外,還設定了監聽的HTTP方法。GET是最常用的HTTP方法,所以檢視函式的預設監聽的方法型別就是GET,HEAD、OPTIONS方法的請求由Flask處理,而像DELETE、PUT等方法一般不會在程式中實現,在後面我們構建Web API時才會用到這些方法。

我們可以在app.route()裝飾器中使用methods引數傳入一個包含監聽的HTTP方法的可迭代物件。比如,下面的檢視函式同時監聽GET請求和POST請求:

@app.route('/hello', methods=['GET', 'POST'])
def hello():
    return '<h1>Hello, Flask!</h1>'複製程式碼

當某個請求的方法不符合要求時,請求將無法被正常處理。比如,在提交表單時通常使用POST方法,而如果提交的目標URL對應的檢視函式只允許GET方法,這時Flask會自動返回一個405錯誤響應(Method Not Allowed,表示請求方法不允許),如下圖所示:

405錯誤響應 通過定義方法列表,我們可以為同一個URL規則定義多個檢視函式,分別處理不同HTTP方法的請求,我們在本書第二部分構建Web API時會用到這個特性。

3. URL處理

從前面的路由列表中可以看到,除了/hello,這個程式還包含許多URL規則,比如和go_back端點對應的/goback/<int:year>。現在請嘗試訪問http://localhost:5000/goback/34,在URL中加入一個數字作為時光倒流的年數,你會發現載入後的頁面中有通過傳入的年數計算出的年份:“Welcome to 1984!”。仔細觀察一下,你會發現URL規則中的變數部分有一些特別,<int:year>表示為year變數新增了一個int轉換器,Flask在解析這個URL變數時會將其轉換為整型。URL中的變數部分預設型別為字串,但Flask提供了一些轉換器可以在URL規則裡使用,如下表所示:

轉換器

說明

string

不包含斜線的字串(預設值)

int

整型

float

浮點數

path

包含斜線的字串。static路由的URL規則中的filename變數就使用了這個轉換器

any

匹配一系列給定值中的一個元素

uuid

UUID字串

轉換器通過特定的規則指定,即“<轉換器:變數名>”。<int:year>把year的值轉換為整數,因此我們可以在檢視函式中直接對year變數進行數學計算:

@app.route('goback/<int:year>')
def go_back(year):
    return '<p>Welcome to %d!</p>' % (2018 - year)複製程式碼

預設的行為不僅僅是轉換變數型別,還包括URL匹配。在這個例子中,如果不使用轉換器,預設year變數會被轉換成字串,為了能夠在Python中計算天數,我們就需要使用int()函式將year變數轉換成整型。但是如果使用者輸入的是英文字母,就會出現轉換錯誤,丟擲ValueError異常,我們還需要手動驗證;使用了轉換器後,如果URL中傳入的變數不是數字,那麼會直接返回404錯誤響應。比如,你可以嘗試訪問http://localhost:5000/goback/tang。

在用法上唯一特別的是any轉換器,你需要在轉換器後新增括號來給出可選值,即“<any(value1, value2, ...):變數名>”,比如:

@app.route('/colors/<any(blue, white, red):color>')
def three_colors(color):
    return '<p>Love is patient and kind. Love is not jealous or boastful or proud or rude.</p>'複製程式碼

當你在瀏覽器中訪問http://localhost:5000/colors/<color>時,如果將<color>部分替換為any轉換器中設定的可選值以外的任意字元,均會獲得404錯誤響應。

如果你想在any轉換器中傳入一個預先定義的列表,可以通過格式化字串的方式(使用%或是format()函式)來構建URL規則字串,比如:

colors = ['blue', 'white', 'red']

@app.route('/colors/<any(%s):color>' % str(colors)[1:-1])
...複製程式碼

HTTP響應

在Flask程式中,客戶端發出的請求觸發相應的檢視函式,獲取返回值會作為響應的主體,最後生成完整的響應,即響應報文。

響應報文

響應報文主要由協議版本、狀態碼(status code)、原因短語(reason phrase)、響應首部和響應主體組成。以發向localhost:5000/hello的請求為例,伺服器生成的響應報文示意如下表所示:

組成說明

響應報文內容

報文首部:狀態行(協議、狀態碼、原因短語)

HTTP/1.1 200 OK

報文首部:各種首部欄位

Content-Type: text/html; charset=utf-8

Content-Length: 22

Server: Werkzeug/0.12.2 Python/2.7.13

Date: Thu, 03 Aug 2017 05:05:54 GMT

...

空行

報文主體

<h1>Hello, Human!</h1>

響應報文的首部包含了一些關於響應和伺服器的資訊,這些內容由Flask生成,而我們在檢視函式中返回的內容即為響應報文中的主體內容。瀏覽器接受到響應後,會把返回的響應主體解析並顯示在瀏覽器視窗上。

HTTP狀態碼用來表示請求處理的結果,下表是常見的幾種狀態碼和相應的原因短語:

從HTTP請求響應迴圈探索Flask的基本工作方式

提示 當關閉除錯模式時,即FLASK_ENV使用預設值production,如果程式出錯,Flask會自動返回500錯誤響應;而除錯模式下則會顯示除錯資訊和錯誤堆疊。

附註 響應狀態碼的詳細列表和說明可以在RFC 7231中看到。

在Flask中生成響應

響應在Flask中使用Response物件表示,響應報文中的大部分內容由伺服器處理,大多數情況下,我們只負責返回主體內容。

根據我們在請求一節介紹的內容,Flask會先判斷是否可以找到與請求URL相匹配的路由,如果沒有則返回404響應。如果找到,則呼叫對應的檢視函式,檢視函式的返回值構成了響應報文的主體內容,正確返回時狀態碼預設為200。Flask會呼叫make_response()方法將檢視函式返回值轉換為響應物件。

完整的說,檢視函式可以返回最多由三個元素組成的元組:響應主體、狀態碼、首部欄位。其中首部欄位可以為字典,或是兩元素元組組成的列表。

比如,普通的響應可以只包含主體內容:

@app.route('/hello')
def hello():
    ...
    return '<h1>Hello, Flask!</h1>'複製程式碼

預設的狀態碼為200,下面指定了不同的狀態碼:

@app.route('/hello')
def hello():
    ...
    return '<h1>Hello, Flask!</h1>', 201複製程式碼

有時你會想附加或修改某個首部欄位。比如,要生成狀態碼為3XX的重定向響應,需要將首部中的Location欄位設定為重定向的目標URL:

@app.route('/hello')
def hello():
    ...
    return '', 302, {'Location': 'http://www.example.com'}複製程式碼

現在訪問http://localhost:5000/hello,會重定向到http://www.example.com。在多數情況下,除了響應主體,其他部分我們通常只需要使用預設值即可。

重定向

如果你訪問http://localhost:5000/hi,你會發現頁面載入後位址列中的URL變為了http://localhost:5000/hello。這種行為被稱為重定向(Redirect),你可以理解為網頁跳轉。在上一節的示例中,狀態碼為302的重定向響應的主體為空,首部中需要將Location欄位設為重定向的目標URL,瀏覽器接受到重定向響應後會向Location欄位中的目標URL發起新的GET請求,整個流程下圖所示:

重定向流程示意圖

在Web程式中,我們經常需要進行重定向。比如,當某個使用者在沒有經過認證的情況下訪問需要登入後才能訪問的資源,程式通常會重定向到登入頁面。

對於重定向這一類特殊響應,Flask提供了一些輔助函式。除了像前面那樣手動生成302響應,我們可以使用Flask提供的redirect()函式來生成重定向響應,重定向的目標URL作為第一個引數。前面的例子可以簡化為:

from flask import Flask, redirect
...
@app.route('/hello')
def hello():
    return redirect('http://www.example.com')複製程式碼

提示 使用redirect()函式時,預設的狀態碼為302,即臨時重定向。如果你想修改狀態碼,可以在redirect()函式中作為第二個引數或使用code關鍵字傳入。

如果要在程式內重定向到其他檢視,那麼只需在redirect()函式中使用url_for()函式生成目標URL即可,如下所示:

from flask import Flask, redirect, url_for 

...

@app.route('/hi')
def hi():
    ...
    return redierct(url_for('hello'))  # 重定向到/hello

@app.route('/hello')
def hello():
    ...複製程式碼

錯誤響應

如果你訪問http://localhost:5000/brew/coffee,你會獲得一個418錯誤響應(I'm a teapot),如下圖所示:

418錯誤響應

附註 418錯誤響應由IETF(Internet Engineering Task Force,網際網路工程任務組)在1998年愚人節釋出的HTCPCP(Hyper Text Coffee Pot Control Protocol,超文字咖啡壺控制協議)中定義(玩笑),當一個控制茶壺的 HTCPCP 收到 BREW 或 POST 指令要求其煮咖啡時應當回傳此錯誤。

大多數情況下,Flask會自動處理常見的錯誤響應。HTTP錯誤對應的異常類在Werkzeug的werkzeug.exceptions模組中定義,丟擲這些異常即可返回對應的錯誤響應。如果你想手動返回錯誤響應,更方便的方法是使用Flask提供的abort()函式。

在abort()函式中傳入狀態碼即可返回對應的錯誤響應,下面的檢視函式返回404錯誤響應:

from flask import Flask, abort

...

@app.route('/404')
def not_found():
    abort(404)複製程式碼

提示 abort()函式前不需要使用return語句,但一旦abort()函式被呼叫,abort()函式之後的程式碼將不會被執行。

附註 雖然我們有必要返回正確的狀態碼,但這並不是必須的。比如,當某個使用者沒有許可權訪問某個資源時,返回404錯誤要比403錯誤更加友好。

本文基於Flask Web開發實戰2章《Flask與HTTP》刪減改寫而來,完整的章節目錄請訪問本書主頁http://helloflask.com/book檢視。



相關文章