練習 51. 從瀏覽器獲取輸入
雖然能讓瀏覽器顯示“Hello World”是件很激動人心的事情,但是如果能讓用戶通過表單(form)向你的應用程序提交文本,那就更令人興奮了。在這個練習中,我們會使用 form 改進你的 web 程序,并且將用戶相關的信息保存到他們的“會話(session)”中。
Web 是如何工作的?
該學點無趣的東西了。在創建 form 前你需要先多學一點關于 web 的工作原理。這里的描述并不完整,但是相當準確,在你的程序出錯時,它會幫你找到出錯的原因。另外,如果你理解了 form 的應用,那么創建 form 對你來說就會更容易。
我會從一個簡單的圖示講起,它向你展示了 web 請求的不同部分,以及信息傳遞的大致流程:為了方便講述一個常規請求(request)的流程,我在每條線上面加了字母標簽以作區別:
你在瀏覽器輸入網址 http://test.com//,它會通過你電腦的網絡設備發送請求(線路 A)。
你的請求被傳送到互聯網(線路 B),然后再抵達遠程服務器(線路 C),然后我的服務器會接受這個請求。
ai醬注: 這里之所以用“my server”是因為舊版書中,作者舉例用的鏈接是 http://learnpythonthehardway.org/,這是作者自己的網站,所以對應也會指向他的服務器。在新版書中,雖然更換了鏈接,但是作者并沒有對這里的表述加以更正。我的服務器接受請求后,我的 web 應用程序就會去處理這個請求(線路 D),然后我的 Python 代碼會去運行
index.GET
這個“處理程序(handler)”。在代碼 return 的時候,我的 Python 服務器就會發出響應(response),這個響應會再通過線路 D 傳遞到你的瀏覽器。
運行這個網站的服務器會從線路 D 獲得響應,然后服務器將這個網站通過線路 C 傳回至互聯網。
響應通過互聯網由線路 B 傳至你的計算機,計算機的網卡再通過線路 A 將響應傳給你的瀏覽器。
最后,你的瀏覽器顯示了這個響應的內容。
這段描述中有幾個術語需要你了解一下,以便你在談論 web 應用時能夠明白并應用它們:
瀏覽器(browser) 這是你幾乎每天都會用到的軟件。大部分人并不知道它真正的原理,他們只會把它叫作“網”(the Internet)。它的作用其實是接收你輸入到地址欄網址(例如http://learnpythonthehardway.org),然后使用該信息向該網址對應的服務器提出請求。
地址(address) 通常這是一個像 http://test.com// 一樣的 URL (Uniform Resource Locator,統一資源定位器),它告訴瀏覽器該打開哪個網站。前面的 http 指出了你要使用的協議 (protocol),這里我們用的是“超文本傳輸協議(Hyper-Text Transport Protocol)”。你還可以試試 ftp://ibiblio.org/ ,這是一個“FTP 文件傳輸協議(File Transport Protocol)”的例子。test.com
這部分是“主機名(hostname)”,也就是一個便于人閱讀和記憶的地址,主機名會被匹配到一串叫作“IP 地址”的數字上面,這個“IP 地址”就相當于網絡中一臺計算機的電話號碼,通過這個號碼可以訪問到這臺計算機。最后,URL 后面還可以跟一個路徑,就像 http://test.com//book/ 中的 /book/
部分,它對應的是服務器上的某個文件或者某些資源,通過訪問這樣的網址,你可以向服務器發出請求,然后獲得這些資源。網站地址還有很多別的組成部分,不過這些是最主要的。
連接(connection) 一旦瀏覽器知道了你想用的協議(http)、你想訪問的服務器(http://test.com/)、以及該服務器需要獲取的資源,它就要創建一個連接。瀏覽器會讓操作系統(Operating System, OS)打開計算機的一個“端口(port)”(通常是 80 端口),端口準備好以后,操作系統會回傳給你的程序一個類似文件的東西,它所做的事情就是通過網絡傳輸和接收數據,讓你的計算機和 http://test.com/ 這個網站所屬的服務器之間實現數據交換。當你使用 http://localhost:8080/ 訪問你自己的站點時,發生的事情其實是一樣的,只不過這次你告訴了瀏覽器要訪問的是你自己的計算機(localhost),要使用的端口不是默認的 80,而是 8080。你還可以直接訪問 http://test.com:80/,這和不輸入端口效果一樣,因為 HTTP 的默認端口本來就是 80。
請求(request) 你的瀏覽器通過你提供的地址建立了連接,現在它需要從遠端服務器要到它(或你)想要的資源。如果你在 URL 的結尾加了 /book/
,那你想要的就是 /book/
對應的文件或資源,大部分的服務器會直接為你調用 /book/index.html
這個文件,不過我們就假裝它不存在好了。瀏覽器為了獲得服務器上的資源,它需要向服務器發送一個“請求”。這里我就不講細節了,你只需要明白,為了得到服務器上的內容,它必須先向服務器發送一個請求才行。有意思的是,“資源”不一定非要是文件。例如當瀏覽器向你的應用程序提出請求的時候,服務器返回的其實是你的 Python 代碼生成的一些東西。
服務器(server) 服務器指的是瀏覽器另一端連接的計算機,它知道如何回應瀏覽器請求的文件和資源。大部分的 web 服務器只要發送文件就可以了,這也是服務器流量的主要部分。不過你學的是使用 Python 組建一個服務器,這個服務器知道如何接受請求,然后返回用 Python 處理過的字符串。當你使用這種處理方式時,你其實是假裝把文件發給了瀏覽器,其實你用的都只是代碼而已。就像你在《練習 50》中看到的,要構建一個“響應”其實也不需要多少代碼。
響應(response) 這就是你的服務器回復你的請求,發回至瀏覽器的 HTML(包括 css、javascript 或 images)。以文件響應為例,服務器只要從磁盤讀取文件,發送給瀏覽器就可以了,不過它還要將這些內容包在一個特別定義的“頭部信息(header)”中,這樣瀏覽器就會知道它獲取的是什么類型的內容。以你的 web 應用程序為例,你發送的其實還是一樣的東西,包括 header 也一樣,只不過這些數據是你用 Python 代碼即時生成的。
這可以算是你能在網上找到的關于瀏覽器如何訪問網站的最快的快速課程了。這個課程應該可以幫你更容易地理解本節的練習,如果你還是不明白,就找找資料多多了解這方面的信息,直到你明白為止。有一個很好的方法,就是你對照著上面的圖示,把你在《練習 50》中創建的 web 程序中的內容分成幾個部分,讓其中的各部分對應到上面的圖示中。如果你可以正確地將程序的各部分對應到這個圖示,那你就大致明白它的工作原理了。
表單(forms)是如何工作的
熟悉“表單”最好的方法就是寫一個可以接收表單數據的程序出來,然后看你可以對它做些什么。先將你的 app.py
文件修改成下面的樣子:
form_test.py
1 from flask import Flask
2 from flask import render_template
3 from flask import request
4
5 app = Flask(__name__)
6
7 @app.route("/hello")
8 def index():
9 name = request.args.get('name', 'Nobody')
10
11 if name:
12 greeting = f"Hello, {name}"
13 else:
14 greeting = "Hello World"
15
16 return render_template("index.html", greeting=greeting)
17
18 if __name__ == "__main__":
19 app.run()
重啟 flask(按 CTRL + C,然后再次運行)確保它再次加載,然后用瀏覽器訪問 http://localhost:5000/hello,應該會顯示 “I just wanted to say Hello, Nobody.” 接著,把瀏覽器中的 URL 改為 http://localhost:5000/hello?name=Frank,你會看到 “Hello, Frank.” 最后,把 name=Frank
這里改成你的名字,它就會對你說 Hello。
讓我們拆解一下腳本中的這些變更:
- 我們沒有直接為
greeting
賦值,而是使用了request.args
從瀏覽器獲取數據。這是一個用鍵值對(key=value pairs) 來包含表單值的簡單字典。 - 然后我用新的
name
構建greeting
,這句你應該已經很熟悉了。 - 其他的內容和以前是一樣的,我們就不再分析了。
URL 中還可以包含多個參數。將本例的兩個變量改成這樣:http://localhost:5000/hello?name=Frank&greet=Hola。然后修改代碼,讓它像這樣獲取 name
和 greet
:
greet = request.args.get( ' greet ' , ' Hello ' )
greeting = f"{greet}, {name}"
你還應該試著不在 URL 上給出 greet 和 name 參數,只讓瀏覽器訪問 http://localhost:5000/hello,然后你會看到,name
會默認為 “Nobody”,greet
會默認為 “Hello”。
創建 HTML 表單
在 URL 上傳遞參數也可以,但就是有點丑,而且對普通用戶來說有點難用。你真正想要的是一個“發送表單”(POST form),這是一個特殊的 HTML 文件,里面有一個 <form>
標簽。這個表單會從用戶那里收集信息,然后發送給你的網站,就像你之前做的那樣。
讓我們來快速創建一個,從中你可以看出它的工作原理。你需要創建一個新的 HTML 文件 templates/hello_form.html:
hello_form.html
<html>
<head>
<title>Sample Web Form</title>
</head>
<body>
<h1>Fill Out This Form</h1>
<form action="/hello" method="POST">
A Greeting: <input type="text" name="greet">
<br/>
Your Name: <input type="text" name="name">
<br/>
<input type="submit">
</form>
</body>
</html>
然后你需要把 app.py 改成這樣:
app.py
1 from flask import Flask
2 from flask import render_template
3 from flask import request
4
5 app = Flask(__name__)
6
7 @app.route("/hello", methods=['POST', 'GET'])
8 def index():
9 greeting = "Hello World"
10
11 if request.method == "POST":
12 name = request.form['name']
13 greet = request.form['greet']
14 greeting = f"{greet}, {name}"
15 return render_template("index.html", greeting=greeting)
16 else:
17 return render_template("hello_form.html")
18
19
20 if __name__ == "__main__":
21 app.run()
改完之后,再次重啟 web 應用,像之前一樣刷新瀏覽器。
這次你會看到一個表單,向你獲取“A Greeting”和“Your Name.”。當你點擊表單上的提交( Submit )按鈕時,它會給你跟之前一樣的問候。不過這次,瀏覽器上面的 URL 還是 http://localhost:5000/hello,哪怕你已經傳遞了參數。
讓這個發揮作用的是 hello_form.html
文件中的這一行:<form action="/hello" method="POST">
。這告訴瀏覽器:
- 從表單中的各個欄位收集用戶輸入的數據。
- 使用一種 POST 類型的請求,將這些數據發送給服務器。這是另外一種瀏覽器請求,它會將表單欄位“隱藏”起來。
- 將這個請求發送至
/hello
URL,這是由action="/hello"
這部分內容告訴瀏覽器的。
你可以看到這兩個 <input>
標簽是如何和你新代碼中的變量名相匹配的。還要注意一下,在 class index
里面,我沒有用 GET 方法,而是使用了 POST 方法。這個新程序的工作原理如下:
你的新請求像之前一樣去到了
index()
,不過現在有一個 if 語句來檢查request.method
是 "POST" 還是 "GET" 方法。這樣瀏覽器就能告訴app.py
一個請求是表單提交還是 URL 參數。如果
request.method
是 "POST",程序就會對表單填寫和提交的內容進行處理,并返回合適的問候語。如果
request.method
是其他東西,那你只要返回hello_form.html
讓用戶來填寫。
作為練習,在 templates/index.html
中添加一個鏈接,讓它指向 /hello
,這樣你可以反復填寫、提交表單并查看結果。
確認你可以解釋清楚這個鏈接的工作原理,以及它是如何讓你實現在
templates/index.html
和 templates/hello_form.html
之間循環跳轉的,還有就是要明白你新修改過的 Python 代碼中,運行的是哪一部分代碼。
創建布局模板(layout template)
在你下一節練習創建游戲的過程中,你需要創建很多的小 HTML 頁面。如果你每次都寫一個完整的網頁,你會很快感覺到厭煩的。幸運的 是你可以創建一個“布局模板”,也就是一種提供了通用的頭文件(headers)和腳注(footers)的外殼模板,你可以用它將你所有的其他網頁包裹起來。好程序員會盡可能減少重復動作,所以要做一個好程序員,使用布局模板是很重要的。
將 templates/index.html
修改為這樣:
index_laid_out.html
{% extends "layout.html" %}
{% block content %}
{% if greeting %}
I just wanted to say
<em style="color: green; font-size: 2em;">{{ greeting }}</em>.
{% else %}
<em>Hello</em>, world!
{% endif %}
{% endblock %}
然后將 templates/hello_form.html
修改為這樣:
hello_form_laid_out.html
{% extends "layout.html" %}
{% block content %}
<h1>Fill Out This Form</h1>
<form action="/hello" method="POST">
A Greeting: <input type="text" name="greet">
<br/>
Your Name: <input type="text" name="name">
<br/>
<input type="submit">
</form>
{% endblock %}
我們所做的就是把每一個頁面頂部和底部反復用到的“boilerplate”(樣板)代碼去掉。這些被去掉的代碼會被放到一個單獨的 templates/layout.html
文件中,之后,這些反復用到的代碼就由 layout.html 來提供了。
修改好之后,創建一個 templates/layout.html
文件,內容如下:
layout.html
<html>
<head>
<title>Gothons From Planet Percal #25</title>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>
這個文件和普通的模板文件類似,不過它會收到其它模板傳遞的內容,并將它們“包裹”起來。任何寫在這里的內容都無需寫在別的模板中了。你的其他 HTML 模板會被插入到 {% block content %}
中。Flask 知道要把 layout.html
文件用作布局,因為你在模板的頂部放了 {% extends "layout.html" %}
。
為表單撰寫自動測試代碼
使用瀏覽器測試 web 程序是很容易的,只要點刷新按鈕就可以了。不過畢竟我們是程序員嘛,如果我們可以寫一些代碼來測試我們的程序,為什么還要重復手動測試呢?接下來你要做的,就是為你的 web 程序寫一個小測試。這會用到你在《練習 47》學過的一些東西,如果你不記得的話,可以回去復習一下。
創建一個新文件,并命名為 tests/app_tests.py,其內容如下:
app_tests.py
1 from nose.tools import *
2 from app import app
3
4 app.config['TESTING'] = True
5 web = app.test_client()
6
7 def test_index():
8 rv = web.get('/', follow_redirects=True)
9 assert_equal(rv.status_code, 404)
10
11 rv = web.get('/hello', follow_redirects=True)
12 assert_equal(rv.status_code, 200)
13 assert_in(b"Fill Out This Form", rv.data)
14
15 data = {'name': 'Zed', 'greet': 'Hola'}
16 rv = web.post('/hello', follow_redirects=True, data=data)
17 assert_in(b"Zed", rv.data)
18 assert_in(b"Hola", rv.data)
最后,用 nosetests
運行這個測試程序,來測試你的 web 應用:
$ nosetests
.
---------------
Ran 1 test in 0.059s OK
我在這兒其實是把整個應用都從 app.py
模塊中引入進來了,然后手動運行它。flask 框架有一個非常簡單用來處理請求的 API,它看起來像這樣:
data = {'name': 'Zed', 'greet': 'Hola'}
rv = web.post('/hello', follow_redirects=True, data=data)
這意味著你可以用 post()
方法發送一個 POST 請求,然后把表單數據作為字典傳給它。其他都和測試 web.get()
請求一模一樣。
在 tests/app_tests.py
自動測試腳本中,我首先確認 /
返回了一個“404 Not Found”響應,因為這個 URL 其實是不存在的。然后我檢查了 /hello
在 GET 和 POST 兩種請求的情況下都能正常工作。就算你沒有弄明白測試的原理,這些測試代碼應該是很好讀懂的。
花些時間研究一下這個最新版的 web 程序,重點研究一下自動測試的工作原理。確保你理解了將 app.py
做為一個模塊導入,然后進行自動化測試的流程。這是一個很重要的技巧,它會引導你學到更多東西。
附加練習
- 閱讀和 HTML 相關的更多資料,然后為你的表單設計一個更好的輸出格式。你可以先在紙上設計出來,然后用 HTML 去實現它。
- 這是一道難題,試著研究一下如何進行文件上傳,通過網頁上傳一張圖像,然后將其保存到磁盤中。
- 更難的難題,找到 HTTP RFC 文件(講述 HTTP 工作原理的技術文件),然后努力閱讀一下。這是一篇很無趣的文檔,不過偶爾你也會用到里邊的一些知識。
- 又是一道難題,找人幫你設置一個 web 服務器,例如 Apache、Nginx 或者 thttpd。試著讓服務器 serve 一下你創建的
.html
和.css
文件。如果失敗了也沒關系,web 服務器本來就都有點爛。 - 完成上面的任務后休息一下,然后試著多創建一些 web 程序出來。
拆解
這里很適合講一下如何拆解 web 應用。你應該這樣做:
打開
FLASK_DEBUG
會造成多大的損害?注意做這個的時候別把自己電腦搞垮了。假設你沒有為表單設置默認參數,哪里會出錯?
你先檢查
POST
然后是“其他東西”。你可以用curl
命令行工具生成不同的請求類型。看看會發生什么?