0%

Flask 筆記 | 表單

在網頁或是 App 中,如果我們希望使用者輸入某些資料,例如登入帳號、搜尋關鍵字、發送留言等,就需要一個表單 (form) 來收集這些資料。HTML 中的 <form> 標籤就是專門用來建立表單的容器,裡面可以放輸入框、下拉選單、按鈕等元素。

基礎處理:GET 方法

表單的結構必須包含表單容器(用 <form> 包住)以及輸入欄位與送出按鈕。一個最簡單的表單結構為:

1
2
3
<form action="/目標路由">
<button>按鈕文字</button>
</form>

簡易表單

延伸之前的根頁面,我們在裡面新增表單:

1
2
3
<form action="/show">
<button>點擊送出表單</button>
</form>

接著在主程式建立路由:

1
2
3
@app.route("/show")
def show():
return "成功送出表單"

執行程式後打開頁面,即可看到表單裡面的按鈕:

表單頁面

圖 1:表單頁面範例

按下按鈕後,就會跳轉到 /show 路徑,並且帶上查詢字串http://127.0.0.1:5000/show?,只是此時尚未設定變數,因此查詢字串為空。

表單送出結果

圖 2:表單送出結果

可以觀察一下後端的狀況,當點擊按鈕後,前端會發送一個 GET 請求,並且成功打到 /show 這個路徑:

1
2
3
4
 * Running on http://127.0.0.1:5000
INFO:werkzeug:Press CTRL+C to quit
INFO:werkzeug:127.0.0.1 - - [09/Aug/2025 12:01:32] "GET / HTTP/1.1" 200 -
INFO:werkzeug:127.0.0.1 - - [09/Aug/2025 12:02:10] "GET /show HTTP/1.1" 200 -

帶參數的表單

正如上面提到的,按下送出按鈕後,前端會帶著表單打到目標路徑。因此我們這時後可以設定輸入區,一個以 input 標籤包住的區域,並指定變數名稱為何。輸入區的基本語法為:

1
<input type="text" name="變數名稱">
  • type:欄位類型(常見有 textpasswordnumber 等)
  • name:送出時的參數名稱
  • value:欄位預設值

例如我們可以設定輸入姓名欄位:

1
姓名 <input type="text" name="name">

打開網站後,即可看到文字與輸入區:

表單輸入區範例

圖 3:表單輸入區範例

輸入 Anthony 後,點擊按鈕觀察到網址的變化,可以發現此時它成功帶著參數 name 過去,網址變成 http://127.0.0.1:5000/show?name=Anthony

表單輸入區送出結果

圖 4:表單輸入區送出結果

當然我們可以對傳入的參數進行一些操作,例如在點擊後,在成功訊息後面加上姓名:

1
2
3
4
@app.route("/show")
def show():
name = request.args.get("name", "")
return "成功送出表單," + name

執行後即可成功看到結果:

表單輸入區變數

圖 5:表單輸入區變數

另一個範例是允許使用者輸入數字,由後端計算平方後回傳至前端。首先我們需要建立一個表單:

1
2
3
4
<form action="/getSquared">
數字 <input type="text" name="number">
<button>計算平方</button>
</form>

接著設定對應的路由 /getSquared

1
2
3
4
@app.route("/getSquared")
def getSquared():
number = int(request.args.get("number", ""))
return f"{number} 的平方為 {number ** 2}"

執行後即可看到數字的輸入區:

平方數字範例

圖 6:平方數字範例

輸入 15 後便可看到結果為 225

平方數字結果

圖 7:平方數字結果

使用渲染樣板

由於表單會帶參數至後端的特性,我們也可以善用此特點,將表單傳入樣板中。將上面計算平方邏輯修改一下,回傳至 show.html 這份樣板中,並帶入變數 result,為使用者輸入數字之平方:

1
2
3
4
5
@app.route("/getSquared")
def getSquared():
number = int(request.args.get("number", ""))
result = number ** 2
return render_template("show.html", result=result)

然後建立 show.html 樣板,並用兩層 {} 包住變數:

1
2
3
4
5
6
7
8
9
10
11
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>平方結果</title>
</head>
<body>
<h3>計算結果為</h3>
<p>{{ result }}</p>
</body>
</html>

執行程式、輸入文字並按下送出後,可以看到此時會顯示 show.html 樣板的文字,而非單純的字串:

平方數字使用渲染樣板

圖 8:平方數字使用渲染樣板

基礎處理:POST 方法

前面的處理都是預設表單為 GET 方法,因此使用 request.args.get() 從網址的查詢字串取得參數。如果我們改用 POST 方法送出表單,資料就不會出現在網址中,而是放在 HTTP 請求的主體 (body),這時後端就要改用 request.form 來讀取。

取得方法 適用情況 資料來源位置 資料會不會出現在網址
request.args GET URL 的查詢字串(? 後面)
request.form POST HTTP 主體 不會

至於為何我們那麼在意資料是否會出現在網址中,用以下的範例便可知一二。假設我們有一個登入頁面 login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登入頁面</title>
</head>
<body>
<form action="/login">
帳號 <input type="text" name="username"><br>
密碼 <input type="password" name="password"><br>
<button type="submit">登入</button>
</form>
</body>
</html>

在主程式中直接使用 request.args 取得資料:

1
2
3
4
5
@app.route("/login")
def login():
username = request.args.get("username", "")
password = request.args.get("password", "")
return render_template("login.html", username=username, password=password)

開啟頁面後即可看到登入畫面

登入頁面

圖 9:登入頁面

但是當我們輸入帳號密碼並按下登入按鈕後,可怕的事情發生了:帳密出現在網址!

登入頁面含有隱私資訊

圖 10:登入頁面含有隱私資訊

因此如同前面所說的,request.args 的資料會出現在網址,密碼等敏感資訊會被記錄在瀏覽器歷史與伺服器日誌中,非常不安全。所以我們需要使用 request.form 來處理這類需要隱藏敏感資訊的問題。使用方法其實很簡單,只要將 login.html 裡面表單的請求方法改為 POST:

1
2
3
4
5
<form action="/login" method="POST">
帳號 <input type="text" name="username"><br>
密碼 <input type="password" name="password"><br>
<button type="submit">登入</button>
</form>

後端加上允許 POST 請求方法[1],並判斷請求方法是否為 POST,最後重新導向至首頁:

1
2
3
4
5
6
7
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
logging.debug(f"帳號: {username},密碼: {password}")
return redirect("/")

此時重新輸入帳號密碼,按下送出後,在後端可以看到確實接收到帳號密碼,且請求方法為 POST:

1
2
3
4
5
 * Running on http://127.0.0.1:5000
INFO:werkzeug:Press CTRL+C to quit
DEBUG:root:帳號: anthony,密碼: abc123
INFO:werkzeug:127.0.0.1 - - [09/Aug/2025 13:56:01] "POST /login HTTP/1.1" 302 -
INFO:werkzeug:127.0.0.1 - - [09/Aug/2025 13:56:01] "GET / HTTP/1.1" 200 -

表單驗證

如果我們每支路由都自己 request.form.get()、自己檢查必填、長度、格式,久了程式碼會變得很亂。WTForms 就是為了這件事生的:它讓你用 Python 類別定義表單,每個欄位是欄位物件,驗證是驗證器,送出時幫你綁定資料 + 驗證 + 提供錯誤訊息;Flask‑WTF 則是把 WTForms 跟 Flask 牽起來的擴充套件,讓它變得更好用。

安裝與設定

在開始前我們需要安裝 flaks-wtf 以及 wtforms。如果使用 pip,可以使用以下方式:

1
pip install flask-wtf wtforms

如果用 Poetry 之類的專案管理,則使用以下方式安裝:

1
poetry add flask-wtf wtforms

安裝完畢後,要在 app.py 中加上以下這行:

1
app.config["SECRET_KEY"] = "dev-secret-change-me"

目的是設定 Flask 應用的祕密金鑰 (secret key),Flask 內部多個機制都要靠它來簽名資料,避免被竄改。

WTForms

正如前面所言,WTForms 的本質與 Flask 無直接關聯,但是正是因為其核心設計理念,大大解決了複雜化的表單建立問題:

  1. 欄位類別化:WTForms 將每一個表單欄位抽象成 Python 物件,並根據欄位性質提供不同的類別:

    • StringField:文字輸入框
    • PasswordField:密碼框
    • IntegerField:整數輸入
    • SubmitField:送出按鈕

    這種做法的好處是:開發者在程式碼中可以像定義資料模型一樣定義表單,欄位的型態與規格清楚明確,後續維護時只要檢視 class 定義就能了解整份表單的結構與需求。

  2. 驗證器 (validators):每個欄位都能直接綁定一組或多組驗證規則,例如:

    • DataRequired():必填
    • Length(min, max):長度限制
    • Email():格式必須是 Email

    驗證器會在表單送出後自動執行,避免開發者在每個路由中重複撰寫檢查程式,並且讓驗證邏輯與欄位定義緊密結合。

  3. 錯誤訊息統一管理:當某個欄位的驗證失敗時,WTForms 會將對應的錯誤訊息收集到表單物件的 errors 屬性中,並以字典的方式存放。這樣不僅方便在後端進行除錯,也方便在前端模板中直接依照欄位名稱顯示錯誤提示,保持使用者體驗一致。

  4. 與模板整合:WTForms 與 Jinja2 模板引擎結合緊密,欄位物件本身就能直接輸出 HTML 代碼。例如:

    • {{ form.username.label }}:生成對應的 <label> 標籤
    • {{ form.username() }}:生成 <input> 標籤及其屬性

    這樣一來,表單的後端定義與前端輸出保持一致,就可減少 HTML 與 Python 之間的重複定義與同步問題。

例如以下是一個 WTForms 的範例:

1
2
3
4
5
from wtforms import Form, StringField, PasswordField, validators

class LoginForm(Form):
username = StringField('使用者名稱', [validators.DataRequired(), validators.Length(min=4, max=25)])
password = PasswordField('密碼', [validators.DataRequired()])

這段程式碼定義了一個表單 LoginForm,有 usernamepassword 兩個欄位,各欄位綁定了驗證規則,包含必填與長度限制。

Flask-WTF

Flask-WTF 解決了幾個 WTForms 沒有,但是與網站開發相關的工作:

  1. 自動 CSRF 保護:WTForms 本身不知道什麼是 CSRF,但 Flask-WTF 會在表單中自動加入隱藏欄位 csrf_token。這個 token 在送出表單時會自動驗證,防止跨站請求偽造攻擊。

  2. 與 Flask Request 整合:在 WTForms 中,我們必須手動將 request.form 的資料傳進表單物件。但是 Flask-WTF 會在建立表單時自動綁定 request.form(POST)或 request.args(GET)的資料,讓我們可以直接檢查。

  3. 簡化驗證流程:提供 validate_on_submit() 方法,一次檢查「是不是 POST 請求」和「所有驗證器都通過」,等價於以下寫法:

    1
    request.method == "POST" and form.validate()

實際操作

首先定義帳密表單 LoginForm,將欄位結構與驗證規則集中在一個類別裡,以後任何路由或樣板要用同一份帳密表單都引用這個類別,不會重複寫驗證邏輯。

1
2
3
4
5
# 定義帳密表單
class LoginForm(FlaskForm):
username = StringField("帳號", validators=[DataRequired()])
password = PasswordField("密碼", validators=[DataRequired()])
submit = SubmitField("登入")

接著撰寫登入路由,在這邊我們將路徑設定為根頁面。

1
2
3
4
5
6
7
8
9
@app.route("/", methods=["GET", "POST"])
def login():
form = LoginForm()
if form.validate_on_submit(): # 自動檢查 POST 與驗證
if form.username.data == "anthony" and form.password.data == "abc123":
return "登入成功"
else:
return "帳號密碼錯誤"
return render_template("login.html", form=form)

Flask‑WTF 會在建立 form = LoginForm() 時,自動把 request.form 綁到欄位(POST 時)。

form.validate() 會逐一執行欄位 validators(此例為 DataRequired()),任何一個失敗就回傳 False,錯誤訊息存在 form.<欄位>.errors。而在 login.html 樣板中,需要處理 CSRF token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登入頁面</title>
</head>
<body>
<h1>登入</h1>
<form method="POST">
{{ form.hidden_tag() }} <!-- CSRF Token -->
<p>
{{ form.username.label }}<br>
{{ form.username(size=20) }}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=20) }}
</p>
<p>{{ form.submit() }}</p>
</form>
</body>
</html>

hidden_tag()用於輸出隱藏欄位(含 CSRF token),沒有它的話 validate_on_submit() 會因 CSRF 驗證失敗而回傳 False。最後執行程式,開啟首頁後,即可看到以下登入頁面:

使用 WTForms & Flask-WTF 的登入頁面

圖 11:使用 WTForms & Flask-WTF 的登入頁面

輸入帳號密碼並登入後,即可看到成功訊息,並且在瀏覽器網址列看不到帳密等隱私資訊,相較於原先使用原生表單的做法安全。

使用 WTForms & Flask-WTF 的登入成功頁面

圖 12:使用 WTForms & Flask-WTF 的登入成功頁面

文件上傳

在許多 Web 應用中,使用者不僅會輸入文字或選擇選項,還會需要上傳檔案,例如上傳頭像、附件、照片或報表。Flask 內建對檔案上傳的支援,核心機制是透過 request.files 來存取上傳的檔案物件,並由開發者決定要如何驗證、儲存與處理。在 Flask 中,我們可以用兩種方式實作檔案上傳:

  • 原生 Flask:直接使用 request.files 搭配自訂驗證邏輯,靈活但需要自行處理安全檢查與錯誤訊息
  • WTForms & Flask-WTF:將檔案上傳欄位與驗證器定義在表單類別中,與其他欄位共用一致的驗證流程與錯誤管理

原生 Flask

如果只是要快速實作檔案上傳功能,而不需要與 WTForms 整合,我們可以使用原生 Flask,直接透過 request.files 存取上傳檔案。雖然這種方式比較靈活、容易上手,但同時需要自行處理檔案驗證與安全性,否則可能會讓應用暴露在風險之下。HTML 端必須使用 POST 方法,並將表單的 enctype 屬性設為 multipart/form-data,這是瀏覽器在上傳檔案時必須使用的編碼方式,否則檔案資料不會被送到後端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上傳檔案</title>
</head>
<body>
<form action="/upload" method="POST" enctype="multipart/form-data">
<label for="file">選擇檔案</label>
<input type="file" id="file" name="file">
<br>
<input type="submit" value="上傳">
</form>
</body>
</html>
  • <input type="file" name="file">name 屬性很重要,這個值會成為後端 request.files 的索引鍵
  • enctype="multipart/form-data" 必須設定,否則檔案內容不會被包含在請求中

在後端,我們可以從 request.files 取出檔案物件,檢查它是否存在,然後將它儲存到指定目錄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import os
from werkzeug.utils import secure_filename

@app.route("/upload", methods=["GET", "POST"])
def upload():
if request.method == "POST":
file = request.files.get("file")
if file and file.filename:
# 避免惡意檔名導致覆蓋系統檔案
filename = secure_filename(file.filename)
filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(filepath) # 儲存檔案
return redirect(url_for("upload"))
else:
logging.debug("沒有選擇檔案或檔名無效")
return render_template("upload.html")

注意到我們使用 secure_filename() 來處理檔案名稱,因為如果直接使用 file.filename 可能存在安全風險,例如攻擊者上傳名為 ../../app.py 的檔案,可能會覆蓋後端檔案;secure_filename() 會將檔名中不安全的字元移除或替換,並保證只留下安全的檔名。

上傳頁面

圖 13:上傳頁面

點擊上傳後,可以看到後端出現 POST 到 /upload 的要求,表示成功上傳。

1
INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:04:56] "POST /upload HTTP/1.1" 302 -

如果想要檢查檔案是否為特定類型,可以定義一個函式 allowed_file,判斷其是否有 . 與副檔名是否在允許的類型內:

1
2
3
4
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "pdf"}

def allowed_file(filename: str) -> bool
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS

接著在 file.save() 前檢查一遍:

1
2
3
# 檢查檔案類型
if not allowed_file(filename):
return "檔案類型不允許"

WTForms & Flask-WTF

相比原生 Flask 用 request.files 處理檔案上傳,WTForms + Flask-WTF 能將欄位定義、驗證規則、錯誤訊息集中管理,還自動整合 CSRF 保護。樣板其實與原生 Flask 相差不大,只是需要加上 CSRF 防護機制而已,並且善用 Flask-WTF 生成標籤的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上傳檔案 (WTF)</title>
</head>
<body>
<form action="/upload_wtf" method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}

<p>
{{ form.file.label }}<br>
{{ form.file() }}
{% for err in form.file.errors %}
<div class="error">{{ err }}</div>
{% endfor %}
</p>

<p>{{ form.submit() }}</p>
</form>
</body>
</html>

接著需要定義可重複使用的上傳表單類別 UploadForm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import SubmitField

# 定義上傳表單
class UploadForm(FlaskForm):
file = FileField(
label="選擇檔案",
validators=[
FileRequired(message="請選擇要上傳的檔案"),
FileAllowed(["jpg," "jpeg", "png", "gif", "pdf"], "只允許圖片或 PDF")
]
)
submit = SubmitField("上傳")

定義上傳頁面路由:

1
2
3
4
5
6
7
8
9
10
@app.route("/upload_wtf", methods=["GET", "POST"])
def upload_wtf():
form = UploadForm()
if form.validate_on_submit():
file = form.file.data
filename = secure_filename(file.filename)
filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(filepath)
return redirect(url_for("upload_wtf"))
return render_template("upload_wtf.html", form=form)

執行程式後打開 /upload_wtf 頁面即可看到選擇檔檔案區與上傳按鈕:

使用 WTForms & Flask-WTF 的上傳頁面

圖 14:使用 WTForms & Flask-WTF 的上傳頁面

如果上傳允許的檔案類型,則會出現 302 代碼:

1
2
3
4
 * Running on http://127.0.0.1:5000
INFO:werkzeug:Press CTRL+C to quit
INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:56:13] "POST /upload_wtf HTTP/1.1" 302 -
INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:56:13] "GET /upload_wtf HTTP/1.1" 200 -

但如果上傳不允許的檔案類型,頁面會顯示錯誤提示:

上傳檔案類型不被允許

圖 15:上傳檔案類型不被允許

而且後端不會出現 302 代碼,而是出現正常的 200 代碼,因為重新導向至上傳頁面,要求使用者重新上傳:

1
2
INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:56:34] "POST /upload_wtf HTTP/1.1" 200 -
INFO:werkzeug:127.0.0.1 - - [10/Aug/2025 15:56:55] "GET /upload_wtf HTTP/1.1" 200 -

  1. 1.預設是 GET 方法。