第 4 章 Web表單
第 2 章中介紹的請求對象包含客戶端發出的所有請求信息。其中, request.form 能獲取POST 請求中提交的表單數據。
儘管 Flask 的請求對象提供的信息足夠用於處理 Web 表單,但有些任務很單調,而且要重複操作。比如,生成表單的 HTML 代碼和驗證提交的表單數據。
Flask-WTF(http://pythonhosted.org/Flask-WTF/)擴展可以把處理 Web 表單的過程變成一種愉悅的體驗。這個擴展對獨立的 WTForms(http://wtforms.simplecodes.com)包進行了包裝,方便集成到 Flask 程序中。
Flask-WTF 及其依賴可使用 pip 安裝:
(venv) $ pip install flask-wtf4.1 跨站請求偽造保護
默認情況下, Flask-WTF 能保護所有表單免受跨站請求偽造(Cross-Site Request Forgery,CSRF)的攻擊。惡意網站把請求發送到被攻擊者已登錄的其他網站時就會引發 CSRF 攻擊。為了實現 CSRF 保護,Flask-WTF 需要程序設置一個密鑰。 Flask-WTF 使用這個密鑰生成加密令牌,再用令牌驗證請求中表單數據的真偽。設置密鑰的方法如示例 4-1 所示。
示例 4-1hello.py:設置 Flask-WTF
app =Flask(__name__)app.config['SECRET_KEY']='hard to guess string'app.config 字典可用來存儲框架、擴展和程序本身的配置變量。使用標準的字典句法就能把配置值添加到 app.config 對象中。這個對象還提供了一些方法,可以從文件或環境中導入配置值。
SECRET_KEY 配置變量是通用密鑰,可在 Flask 和多個第三方擴展中使用。如其名所示,加密的強度取決於變量值的機密程度。不同的程序要使用不同的密鑰,而且要保證其他人不知道你所用的字符串。
為了增強安全性,密鑰不應該直接寫入代碼,而要保存在環境變量中。這一技術會在第 7 章介紹。
4.2 表單類
使用 Flask-WTF 時,每個 Web 表單都由一個繼承自 Form 的類表示。這個類定義表單中的一組欄位,每個欄位都用對象表示。欄位對象可附屬一個或多個驗證函數。驗證函數用來驗證用戶提交的輸入值是否符合要求。
示例 4-2 是一個簡單的 Web 表單,包含一個文本欄位和一個提交按鈕。
示例 4-2hello.py:定義表單類
from flask_wtf importFormfrom wtforms importStringField,SubmitFieldfrom wtforms.validators importRequiredclassNameForm(Form): name =StringField('What is your name?', validators=[Required()]) submit =SubmitField('Submit')這個表單中的欄位都定義為類變量,類變量的值是相應欄位類型的對象。在這個示例中, NameForm 表單中有一個名為 name 的文本欄位和一個名為 submit 的提交按鈕。 StringField 類表示屬性為 type="text" 的 <input> 元素。 SubmitField 類表示屬性為 type="submit" 的 <input> 元素。欄位構造函數的第一個參數是把表單渲染成 HTML 時使用的標號。
StringField 構造函數中的可選參數 validators 指定一個由驗證函數組成的列表,在接受用戶提交的數據之前驗證數據。驗證函數 Required() 確保提交的欄位不為空。
Form 基類由 Flask-WTF 擴展定義,所以從 flask_wtf 中導入。欄位和驗證函數卻可以直接從 WTForms 包中導入。
WTForms 支持的 HTML 標準欄位如表 4-1 所示。
表4-1 WTForms 支持的 HTML 標準欄位
WTForms 內建的驗證函數如表 4-2 所示。
表4-2 WTForms驗證函數
4.3 把表單渲染成HTML
表單欄位是可調用的,在模板中調用後會渲染成 HTML。假設視圖函數把一個 NameForm 實例通過參數 form 傳入模板,在模板中可以生成一個簡單的表單,如下所示:
<formmethod="POST"> {{ form.hidden_tag() }} {{ form.name.label }} {{ form.name() }} {{ form.submit() }}</form>當然,這個表單還很簡陋。要想改進表單的外觀,可以把參數傳入渲染欄位的函數,傳入的參數會被轉換成欄位的 HTML 屬性。例如,可以為欄位指定 id 或 class 屬性,然後定義 CSS 樣式:
<formmethod="POST"> {{ form.hidden_tag() }} {{ form.name.label }} {{ form.name(id='my-text-field') }} {{ form.submit() }}</form>即便能指定 HTML 屬性,但按照這種方式渲染表單的工作量還是很大,所以在條件允許的情況下最好能使用 Bootstrap 中的表單樣式。 Flask-Bootstrap 提供了一個非常高端的輔助函數,可以使用 Bootstrap 中預先定義好的表單樣式渲染整個 Flask-WTF 表單,而這些操作只需一次調用即可完成。使用 Flask-Bootstrap,上述表單可使用下面的方式渲染:
{%import"bootstrap/wtf.html"as wtf %}{{ wtf.quick_form(form)}}import 指令的使用方法和普通 Python 代碼一樣,允許導入模板中的元素並用在多個模板中。導入的 bootstrap/wtf.html 文件中定義了一個使用 Bootstrap 渲染 Falsk-WTF 表單對象的輔助函數。 wtf.quick_form() 函數的參數為 Flask-WTF 表單對象,使用 Bootstrap 的默認樣式渲染傳入的表單。 hello.py 的完整模板如示例 4-3 所示。
示例 4-3templates/index.html:使用 Flask-WTF 和 Flask-Bootstrap 渲染表單
{%extends"base.html"%}{%import"bootstrap/wtf.html"as wtf %}{% block title %}Flasky{% endblock %}{% block page_content %}<div><h1>Hello,{%if name %}{{ name }}{%else%}Stranger{% endif %}!</h1></div>{{ wtf.quick_form(form)}}{% endblock %}模板的內容區現在有兩部分。第一部分是頁面頭部,顯示歡迎消息。這裡用到了一個模板條件語句。Jinja2 中的條件語句格式為 {%if condition %}...{%else%}...{% endif %}。如果條件的計算結果為 True,那麼渲染 if和 else 指令之間的值。如果條件的計算結果為 False,則渲染 else 和 endif 指令之間的值。在這個例子中,如果沒有定義模板變量 name,則會渲染字符串" Hello,Stranger!"。內容區的第二部分使用 wtf.quick_form() 函數渲染 NameForm 對象。
4.4 在視圖函數中處理表單
在新版 hello.py 中,視圖函數 index() 不僅要渲染表單,還要接收表單中的數據。示例 4-4是更新後的 index()視圖函數。
示例 4-4hello.py:路由方法
@app.route('/', methods=['GET','POST'])def index(): name =None form =NameForm()if form.validate_on_submit(): name = form.name.data form.name.data =''return render_template('index.html', form=form, name=name)app.route 修飾器中添加的 methods 參數告訴 Flask 在 URL 映射中把這個視圖函數註冊為 GET 和 POST 請求的處理程序。如果沒指定 methods 參數,就只把視圖函數註冊為 GET 請求的處理程序。
把 POST 加入方法列表很有必要,因為將提交表單作為 POST 請求進行處理更加便利。表單也可作為 GET 請求提交,不過 GET 請求沒有主體,提交的數據以查詢字符串的形式附加到 URL 中,可在瀏覽器的地址欄中看到。基於這個以及其他多個原因,提交表單大都作為 POST 請求進行處理。
局部變量 name 用來存放表單中輸入的有效名字,如果沒有輸入,其值為 None。如上述代碼所示,在視圖函數中創建一個 NameForm 類實例用於表示表單。提交表單後,如果數據能被所有驗證函數接受,那麼 validate_on_submit() 方法的返回值為 True,否則返回 False。這個函數的返回值決定是重新渲染表單還是處理表單提交的數據。
用戶第一次訪問程序時,伺服器會收到一個沒有表單數據的 GET 請求,所以 validate_on_submit() 將返回 False。 if 語句的內容將被跳過,通過渲染模板處理請求,並傳入表單對象和值為 None 的 name 變量作為參數。用戶會看到瀏覽器中顯示了一個表單。
用戶提交表單後,伺服器收到一個包含數據的 POST 請求。 validate_on_submit() 會調用 name 欄位上附屬的 Required() 驗證函數。如果名字不為空,就能通過驗證, validate_on_submit() 返回 True。現在,用戶輸入的名字可通過欄位的 data 屬性獲取。在 if 語句中,把名字賦值給局部變量 name,然後再把 data 屬性設為空字符串,從而清空表單欄位。最後一行調用 render_template() 函數渲染模板,但這一次參數 name 的值為表單中輸入的名字,因此會顯示一個針對該用戶的歡迎消息。
提示:如果你從 GitHub 上克隆了這個程序的 Git 倉庫,那麼可以執行 git checkout 4a 籤出程序的這個版本。
圖 4-1 是用戶首次訪問網站時瀏覽器顯示的表單。用戶提交名字後,程序會生成一個針對該用戶的歡迎消息。歡迎消息下方還是會顯示這個表單,以便用戶輸入新名字。圖 4-2 顯示了此時程序的樣子。
圖 4-1 Flask-WTF Web 表單
如果用戶提交表單之前沒有輸入名字, Required() 驗證函數會捕獲這個錯誤,如圖 4-3 所示。注意一下擴展自動提供了多少功能。這說明像 Flask-WTF 和 Flask-Bootstrap 這樣設計良好的擴展能讓程序具有強大的功能。
圖 4-2 提交後顯示的 Web 表單
圖 4-3 驗證失敗後顯示的 Web 表單4.5 重定向和用戶會話
最新版的 hello.py 存在一個可用性問題。用戶輸入名字後提交表單,然後點擊瀏覽器的刷新按鈕,會看到一個莫名其妙的警告,要求在再次提交表單之前進行確認。之所以出現這種情況,是因為刷新頁面時瀏覽器會重新發送之前已經發送過的最後一個請求。如果這個請求是一個包含表單數據的 POST 請求,刷新頁面後會再次提交表單。大多數情況下,這並不是理想的處理方式。
很多用戶都不理解瀏覽器發出的這個警告。基於這個原因,最好別讓 Web 程序把 POST 請求作為瀏覽器發送的最後一個請求。
這種需求的實現方式是,使用重定向作為 POST 請求的響應,而不是使用常規響應。重定向是一種特殊的響應,響應內容是 URL,而不是包含 HTML 代碼的字符串。瀏覽器收到這種響應時,會向重定向的 URL 發起 GET 請求,顯示頁面的內容。這個頁面的加載可能要多花幾微秒,因為要先把第二個請求發給伺服器。除此之外,用戶不會察覺到有什麼不同。現在,最後一個請求是 GET 請求,所以刷新命令能像預期的那樣正常使用了。這個技巧稱為 Post/ 重定向 /Get 模式。
但這種方法會帶來另一個問題。程序處理 POST 請求時,使用 form.name.data 獲取用戶輸入的名字,可是一旦這個請求結束,數據也就丟失了。因為這個 POST 請求使用重定向處理,所以程序需要保存輸入的名字,這樣重定向後的請求才能獲得並使用這個名字,從而構建真正的響應。
程序可以把數據存儲在用戶會話中,在請求之間「記住」數據。用戶會話是一種私有存儲,存在於每個連接到伺服器的客戶端中。我們在第 2 章介紹過用戶會話,它是請求上下文中的變量,名為 session,像標準的 Python 字典一樣操作。
默認情況下,用戶會話保存在客戶端 cookie 中,使用設置的 SECRET_KEY 進行加密籤名。如果篡改了 cookie 中的內容,籤名就會失效,會話也會隨之失效。
示例 4-5 是 index() 視圖函數的新版本,實現了重定向和用戶會話。
示例 4-5hello.py:重定向和用戶會話
from flask importFlask, render_template, session, redirect, url_for@app.route('/', methods=['GET','POST'])def index(): form =NameForm()if form.validate_on_submit(): session['name']= form.name.datareturn redirect(url_for('index'))return render_template('index.html', form=form, name=session.get('name'))在程序的前一個版本中,局部變量 name 被用於存儲用戶在表單中輸入的名字。這個變量現在保存在用戶會話中,即 session['name'],所以在兩次請求之間也能記住輸入的值。
現在,包含合法表單數據的請求最後會調用 redirect() 函數。 redirect() 是個輔助函數,用來生成 HTTP 重定向響應。 redirect() 函數的參數是重定向的 URL,這裡使用的重定向 URL 是程序的根地址,因此重定向響應本可以寫得更簡單一些,寫成 redirect('/'),但卻會使用 Flask 提供的 URL 生成函數 url_for()。推薦使用 url_for() 生成 URL,因為這個函數使用 URL 映射生成 URL,從而保證 URL 和定義的路由兼容,而且修改路由名字後依然可用。
url_for() 函數的第一個且唯一必須指定的參數是端點名,即路由的內部名字。默認情況下,路由的端點是相應視圖函數的名字。在這個示例中,處理根地址的視圖函數是 index(),因此傳給 url_for() 函數的名字是 index。
最後一處改動位於 render_function() 函數中,使用 session.get('name') 直接從會話中讀取 name 參數的值。和普通的字典一樣,這裡使用 get() 獲取字典中鍵對應的值以避免未找到鍵的異常情況,因為對於不存在的鍵, get() 會返回默認值 None。
提示:如果你從 GitHub 上克隆了這個程序的 Git 倉庫,可以執行 git checkout 4b 籤出程序的這個版本。
使用這個版本的程序時,刷新瀏覽器頁面,你看到的新頁面就和預期一樣了。
4.6 Flash消息
請求完成後,有時需要讓用戶知道狀態發生了變化。這裡可以使用確認消息、警告或者錯誤提醒。一個典型例子是,用戶提交了有一項錯誤的登錄表單後,伺服器發回的響應重新渲染了登錄表單,並在表單上面顯示一個消息,提示用戶用戶名或密碼錯誤。
這種功能是 Flask 的核心特性。如示例 4-6 所示, flash() 函數可實現這種效果。
示例 4-6hello.py:Flash 消息
from flask importFlask, render_template, session, redirect, url_for, flash@app.route('/', methods=['GET','POST'])def index(): form =NameForm()if form.validate_on_submit(): old_name = session.get('name')if old_name isnotNoneand old_name != form.name.data: flash('Looks like you have changed your name!') session['name']= form.name.datareturn redirect(url_for('index'))return render_template('index.html',form = form, name = session.get('name'))在這個示例中,每次提交的名字都會和存儲在用戶會話中的名字進行比較,而會話中存儲的名字是前一次在這個表單中提交的數據。如果兩個名字不一樣,就會調用 flash() 函數,在發給客戶端的下一個響應中顯示一個消息。
僅調用 flash() 函數並不能把消息顯示出來,程序使用的模板要渲染這些消息。最好在基模板中渲染 Flash 消息,因為這樣所有頁面都能使用這些消息。 Flask 把 get_flashed_messages() 函數開放給模板,用來獲取並渲染消息,如示例 4-7 所示。
示例 4-7templates/base.html:渲染 Flash 消息
{% block content %}<div>{%for message in get_flashed_messages()%}<div><button type="button"class="close" data-dismiss="alert">×</button>{{ message }}</div>{% endfor %}{% block page_content %}{% endblock %}</div>{% endblock %}在這個示例中,使用 Bootstrap 提供的警報 CSS 樣式渲染警告消息(如圖 4-4 所示)。
圖 4-4 Flash 消息在模板中使用循環是因為在之前的請求循環中每次調用 flash() 函數時都會生成一個消息,所以可能有多個消息在排隊等待顯示。 get_flashed_messages() 函數獲取的消息在下次調用時不會再次返回,因此 Flash 消息只顯示一次,然後就消失了。
提示:如果你從 GitHub 上克隆了這個程序的 Git 倉庫,那麼可以執行 git checkout 4c 籤出程序的這個版本。
從 Web 表單中獲取用戶輸入的數據是大多數程序都需要的功能,把數據保存在永久存儲器中也是一樣。下一章將介紹如何在 Flask 中使用資料庫。