跳至主要内容

陽春版個人部落格

陽春版個人部落格

雖然 Flask 是一個輕量級的框架,功能不像 Django 那麼完整,但要拿來做網站基本上沒什麼問題。接下來我們就來使用 Flask 框架來建立一個陽春版的個人部落格。我這裡說的陽春是指設計上不會太華麗,功能上也不會太複雜,但可以展示怎麼透過 Flask 與資料庫進行 CRUD 的操作。

CRUD,是「新增(Create)」、「讀取(Read)」、「更新(Update)」以及「刪除(Delete)」幾個字的縮寫,資料庫的基本操作大概也就只有這幾招而已。在這個陽春版的部落格實作,我們將會實作這四個基本操作,可以做到發表文章、編輯文章、刪除文章以及查看文章的功能。

本章節之完整範例可以在我的 GitHub 帳號下載:

專案建立

同樣為了避免環境污染,這裡我也會使用 Poetry 來建立虛擬環境:

$ mkdir simple-flask-blog
$ cd simple-flask-blog
$ poetry init -n

這樣應該就能建立一個空的專案了。接著切換至 Poetry 虛擬環境後,安裝 Flask:

$ poetry shell
$ poetry add flask

這樣基本的環境就建立好了。接著新增主程式 app.py,內容跟前一章學到的沒太大差別:

檔案:app.py
#
from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
@app.route("/posts")
def index():
return render_template("posts/index.html.jinja")

if __name__ == "__main__":
app.run(port=9527, debug=True)

我希望網站的首頁是就會是文章列表,所以這裡除了 /posts 之外,我也把首頁的 / 路徑掛上去了,這樣這兩個路徑都可以交由 index() 函數來處理。

因為這裡會用到樣版,所以別忘了建立樣版目錄 templates,裡面放待會會用到的樣版檔案。不知道這裡大家是否有注意到,我在上面用的樣版檔案的名字有點不太一樣...

樣版附檔名

Flask 對於樣版檔案的附檔名並沒有強制的規範,想用什麼附檔名都行。關於這件事,Flask 官網文件上有這麼一段話:

As stated above, any file can be loaded as a template, regardless of file extension. Adding a .jinja extension, like user.html.jinja may make it easier for some IDEs or editor plugins, but is not required.

在上個章節我使用了 .html 做為樣版的附檔名,不過如果實際開發專案的時候,我會習慣在檔案最後面加上 .jinja 作為附檔名,例如原本應該是 about.html,我會改成 about.html.jinja,這樣在某些 IDE 或是編輯器會更容易辨識。如果各位覺得 .html 看的比較順眼,繼續使用也沒問題,只是到時候在讀取樣版檔案的時候要記得依照自己設定的附檔名。

這裡我建立兩個樣版檔案,一個是做為公用樣版的 layout.html.jinja,另一個是部落格的首頁 posts/index.html.jinja,都是放在預設的 templates 目錄下。公用樣版沒什麼特別的,就是一個基本的 HTML 而已:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Simple Blog</title>
</head>
<body class="bg-gray-50">
<main class="container p-12 mx-auto my-4 bg-white rounded shadow">{% block content %}{% endblock %}</main>
</body>
</html>

這裡我只有留一個區塊而已,又因為我不太想乖乖的寫 CSS 樣式,所以這裡直接用 CDN 的方式引入了 Tailwind CSS,待會可以直接使用。關於 Tailwind CSS 的詳細說明,可參考 Tailwind 官網說明。實務上我通常會另外對這些前端的資源(JavaScript、CSS 以及圖片)進行打包,但這樣會把戰線拉得太長,這裡就先暫時用 CDN 了。

網站連結

posts/index.html.jinja 的內容也很簡單,也就只是用了公用樣版而已:

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="text-3xl">Hello Flask</h1>
{% endblock %}

到這裡跟上個章節學到的都差不多,現在目錄結構應該看起來像這樣:

simple-flask-blog
├─ app.py
├─ poetry.lock
├─ pyproject.toml
└─ templates
├─ posts
│ └─ index.html.jinja
└─ layout.html.jinja

執行 python app.py,連上 http://127.0.0.1:9527 應該就能在畫面上看到 Hello Flask 字樣了。如果懶得自己打字,可以直接到我的 GitHub 上下載完整的專案。

目前進度

分支名稱 01-init

加上導覽列

做好了首頁,我想再加上導覽列,待會要新增文章的時候就可以直接點選連結,不用自己輸入網址。我想像中的導覽列應該是全站都會用的上的,所以我想把導覽列就放在公用樣版裡,這樣就會出現在每個頁面上。雖然導覽列的 HTML 不難寫,但如果直接寫在公用樣版裡,慢慢的就會讓樣版變得很雜亂,所以我會把導覽列的 HTML 寫在另外一個樣版檔案,待會在公用樣版裡引入。

檔案我就放在 shared/navbar.html.jinja

<nav class="my-2">
<ul class="flex gap-4">
<li><a href="/" class="hover:underline">文章列表</a></li>
<li><a href="/posts/new" class="hover:underline">新增文章</a></li>
</ul>
</nav>

新增文章的路徑我先設定成 /posts/new,其他應該不是什麼新東西。如果大家對 HTML 或 CSS 不熟悉的話,可以另外再找其他前端的參考資料。

回到 layout.html.jinja,我要來把這個引進來:

<body class="bg-gray-50">
<main class="container p-12 mx-auto my-4 bg-white rounded shadow">
{% include "shared/navbar.html.jinja" %} {% block content %}{% endblock %}
</main>
</body>

這裡的 {% include %} 語法可以讓我們載入另一個樣版,這麼做的目的是為了讓 layout.html.jinja 的內容保持簡潔、容易閱讀。重新整理之後,現在的畫面應該看起來像這樣:

新增導覽列

目前進度

分支名稱 02-add-navbar

新增文章

準備表單

接下來我們來準備新增文章的頁面,這個頁面還不需要資料庫,只要先把表單(Form)準備好就好。我想讓新增文章的頁面的網址是 /posts/new,所以我在 app.py 加上這段:

# 檔案 app.py
@app.route("/posts/new")
def new():
return render_template("posts/new.html.jinja")

跟剛才的 index.html.jinja 相比,posts/new.html.jinja 的內容就多了一個表單而已:

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="mb-2 text-2xl">新增文章</h1>
<form action="/posts/create" method="POST" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="title">標題</label>
<input
type="text"
id="title"
name="title"
class="w-full p-2 text-xl border border-gray-900 rounded-sm md:w-1/2"
placeholder="請填寫標題"
/>
</div>
<div class="flex flex-col gap-2">
<label for="content">內文</label>
<textarea
id="content"
name="content"
class="w-full p-2 text-xl border border-gray-900 rounded-sm md:w-1/2"
placeholder="文章內容"
rows="6"
></textarea>
</div>
<div>
<button type="submit" class="p-2 text-lg text-white bg-blue-500 rounded-sm">新增文章</button>
</div>
</form>
{% endblock %}

扣掉那些裝飾用的 class 不看,這裡有兩個地方需要特別注意的。

首先,在 <form> 標籤裡的 action 屬性表示待會按下送出按鈕之後會把資料送到什麼地方,而 method 屬性表示會用什麼方式傳送,目前 HTML 的 <form> 標籤只有支援 GETPOST 這兩種方式,這裡我用 POST 來傳送表單資料。

其次,你會看到這兩個輸入框我都有特別設定了 name 屬性,這是因為待會按下送出之後,資料會送到後端程式,而後端程式就得靠這個 name 屬性才能拿到對應的資料。像是 idclass 這種 HTML 屬性是給前端工程師用的東西,後端只看 name 屬性。這個 name 屬性是必要的,如果沒有設定的話,待會後端程式就無法取得表單裡的資料。

現在看起來的畫面應該像這樣:

新增文章表單

看起來好像有那麼一點樣子了,待會就是要處理按下送出之後的事情...

目前進度

分支名稱 03-new-post-form

處理表單

新增文章的表單準備好之後,下一步就是要處理表單送過來的資料。我先建立一個新的路由來處理這個表單:

from flask import Flask, render_template, redirect

# ... 略 ...

@app.route("/posts/create", methods=["POST"])
def create():
# 寫入資料庫,待會做...
return redirect("/")

跟前面的寫法有一些不同,因為在前的表單我有設定使用 POST 方式傳送資料,所以接收資料的後端程式我也想限制只能使用 POST 方法,所以我在 methods 參數裡指定了 ["POST"]。這樣一來,如果是自己在瀏覽器輸入網址然後按下 Enter 鍵,因為這時候瀏覽器是使用 GET 方式在讀取資料,會得到 Method Not Allowed 的訊息。但如果是透過表單按下送出,就會透過 redirect() 函數跳轉到首頁。

雖然我用 redirect("/") 這樣的寫法可以轉到首頁,但我會更建議使用 Flask 的 url_for() 來寫:

from flask import Flask, render_template, redirect, url_for

# ... 略 ...

@app.route("/posts/create", methods=["POST"])
def create():
# 寫入資料庫,待會做...
return redirect(url_for("index"))

url_for() 是 Flask 提供的函數,所以這裡別忘了把函數 import 進來。這個函數可以用來產生路由的 URL,像這裡我用 url_for("index") 就會產生首頁的網址,如果是 url_for("new") 就會產生 /posts/new 這樣的網址,以此類推。這樣不只看起來更知道是要轉給哪個函數做事,重點是將來如果要改路由的話,就不用一個一個去改。這個 url_for() 的設計挺方便的,而且在後面介紹另一個網站開發框架 Django 的時候也會看到類似的寫法。

目前進度

分支名稱 04-process-form

要寫入資料庫之前,我們得先把表單送過來的資料拿到。在 Flask 裡,我們可以透過 request 這個物件來取得表單送過來的資料:

from flask import Flask, render_template, redirect, url_for, request

# ... 略 ...

@app.route("/posts/create", methods=["POST"])
def create():
title = request.form.get("title")
content = request.form.get("content")

print(title, content)

# 寫入資料庫,待會做...
return redirect(url_for("index"))

request.form 會得到一個類似字典但不是字典的物件,這個物件是不可變的,不過因為它的設計類似字典,所以可以透過 get() 方法來取得對應的 Key 的資料,request.form.get("title") 表示會取得在前一個畫面表單裡面叫做 title 欄位的值。我在底下用 print() 函數把抓到的值印出來看看,如果你再操作一次表單送出的話,你應該會在終端機看到這樣的訊息:

127.0.0.1 - - [01/Jul/2024 18:23:47] "GET /posts/new HTTP/1.1" 200 -
Hello 你好
127.0.0.1 - - [01/Jul/2024 18:23:55] "POST /posts/create HTTP/1.1" 302 -

這裡的 Hello你好,就是我在表單裡填的資料,看起來已經可以拿到表單送過來的資料了。

把頁面的流程順好之後,接下來,就是本章節的重點了...

資料庫

資料庫(Database)是用來儲存資料的工具,它可以是另外一台伺服器,也可以是單一一個檔案。透過資料庫專用的查詢語法,我們可以把資料存進去,也可以把資料取出來,或是進行更新或刪除。如果這是你第一次使用資料庫,可以把它想像成在使用 Excel 試算表。一個試算表可能會有很多頁(Sheet),而一個資料庫通常也會有很多個資料表(Table),每個表格會存放不同的資料,例如會員表格會存放會員的資料,商品表格會存放商品的明細。

這裡提到的特定語法叫做「結構化查詢語言」(Structured Query Language, SQL)。這是專門用來跟資料庫溝通的語言,透過這個語言,我們可以對資料庫進行新增、查詢、更新、刪除等操作。雖然 SQL 不算太難學,但這裡我們不會直接使用 SQL 語法,而是透過另一個 Python 套件 SQLAlchemy 來操作資料庫。

SQLAlchemy 是一種 ORM(Object-Relational Mapping)套件,透過這個套件我們可以不用寫 SQL 語法,也能用操作物件的方式來操作資料表。好處是操作物件會比寫原生的 SQL 來得簡單,待會使用的時候就會感受到差異。另外一個好處是比較不用擔心 SQL 語法會造成的的安全性問題,對安全性有興趣的讀者,可查詢關鍵字「SQL 注入(SQL Injection)」的相關資料。

SQLAlchemy 的 Alchemy 是煉金術的意思,煉金術是一種可以把普通金屬轉化為貴金屬的過程,透過 SQLAlchemy 套件,可以把 Python 世界的物件轉換為 SQL 語法,或是將 SQL 語句的查詢結果轉換回 Python 世界的物件。

不少人可能會以為 ORM 就是資料庫,事實上它只是資料庫與程式語言的中間層,比較像是個經紀人的角色。有事情我們跟經紀人講,經紀人再把我們的話翻譯成資料庫聽的懂的話,如果資料庫查詢有回應結果,經紀人也會把結果再轉換成我們聽的懂的話。不管經紀人跟哪些資料庫對接,對我們來說就是只要對經紀人這個單一窗口就好,也就是說除非我們使用了某些資料庫系統特有的功能,否則如果要切換資料庫系統只要換個設定就行了,其他程式大部份都不需要修改。

目前坊間的資料庫系統有好多種類,有收費的也有開源的,本章我將會使用最簡單的 SQLite,它是一個檔案型的資料庫,不需要另外安裝或架設伺服器。SQLite 適用於行動裝置 App 或是規模較小的專案,或者是開發階段不想安裝資料庫系統也常會使用它,如果是大型專案,可能會考慮使用 PostgreSQL、MySQL 或 MongoDB 等資料庫系統。

SQLAlchemy 是一個 Python 的套件,所以當然得先把套件裝起來,不過因為 Flask 有另一個專門給 SQLAlchemy 的擴充套件 flask-sqlalchemy,這個擴充套件可以讓我們更容易把 SQLAlchemy 整合到 Flask 專案,所以安裝這個擴充套件也會一併安裝 SQLAlchemy:

$ poetry add flask-sqlalchemy
Using version ^3.1.1 for flask-sqlalchemy

...略...

- Installing typing-extensions (4.12.2)
- Installing sqlalchemy (2.0.31)
- Installing flask-sqlalchemy (3.1.1)

接著我們要在 Flask 裡設定設定資料庫連線資訊,之後就能透過 SQLAlchemy 來操作資料庫。

資料庫設定

在 Flask 中設定資料庫連線,我們需要先設定資料庫的 URI(Uniform Resource Identifier),這個 URI 會告訴 SQLAlchemy 要連線到哪一個資料庫。URI 的寫法會根據不同的資料庫而有所不同,SQLite 的 URI 格式是 sqlite:///檔案路徑,這裡的 sqlite:/// 是告訴 SQLAlchemy 要使用 SQLite 資料庫,後面的檔案路徑是 SQLite 資料庫的檔案路徑。現在的程式碼應該是這樣:

from flask import Flask, render_template, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
import os

app = Flask(__name__)

# ...略...

ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{ROOT_PATH}/db/blog.db"

db = SQLAlchemy()
db.init_app(app)

if __name__ == "__main__":
with app.app_context():
db.create_all()
app.run(port=9527, debug=True)

說明一下,在這個專案中,我想把 SQLite 資料庫檔案放在專案的根目錄下的 db 資料夾,資料庫檔名叫 blog.sqlite。你不一定要叫這個名字,也不用跟著我放在一樣的目錄,這只是我個人習慣而已,我比較不喜歡把檔案都散落在專案的根目錄下,所以除非必要,否則我通常都會幫這些檔案另外開資料夾來整理。也因為如此,db 目錄應該要先建立好,不然待會執行會出錯。

另外,還需要在要執行的應用程式中加入設定 SQLALCHEMY_DATABASE_URI,讓知道 SQLAlchemy 要連線到哪一個資料庫。剩下的基本上就是照著文件做而已,在最後的 db.create_all() 方法會判斷如果資料庫不存在就建立一個新的。

如果沒寫錯的話,再次執行 python app.py 啟動應用程式,應該會看到資料庫檔案已經建立好了。現在的目錄結構差不多是這樣:

simple-flask-blog
├─ app.py
├─ db
│ └─ blog.sqlite
├─ poetry.lock
├─ pyproject.toml
└─ templates
├─ layout.html.jinja
├─ posts
│ ├─ index.html.jinja
│ └─ new.html.jinja
└─ shared
└─ navbar.html.jinja

這時候雖然資料庫是建立了,但裡面應該都是空的,接下來我們要定義資料表,並且把資料表的結構建立起來。

目前進度

分支名稱 05-connect-database

定義 Model

在 ORM 的操作中,我們跟 ORM 講話,它會幫我們轉換成 SQL 語句,然後再去資料庫執行。但如果講的更精確一點,我們其實是跟 ORM 的裡面的某個類別講話,這個類別通常會繼承自 ORM 所提供的類別,而且會在類別裡定義跟資料表相對應的屬性或方法,或是這個類別對應哪一個資料表,這樣的類別我們通常會稱之 Model。

再提醒一次,Model 不是資料表,它只是資料表的抽象層。在這個專案中,因為需要把文章標題以及內文等資料寫入資料表,所以我待會會定義一個名為 Post 的 Model。但在建立資料表之前,我先調整一下目前的資料庫連線方式,我想把它另外獨立出來一個模組,方便管理、使用:

# 檔案 config/settings.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

其實沒什麼特別的,只是把剛才的 db 物件獨立出來而已。回頭改一下 app.py

from flask import Flask, render_template, redirect, url_for
import os
from config.settings import db

app = Flask(__name__)

# ... 略 ...

ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{ROOT_PATH}/db/blog.sqlite"
db.init_app(app)

if __name__ == "__main__":
app.run(port=9527, debug=True)

這裡我也順便把原本用來建立資料庫的 create_all() 拿掉了,待會會說明原因。

再回來看看 Model 該怎麼設計,因為這個是準備要把文章標題跟內容等資料寫入來的表格,我大概會想到有以下幾個欄位:

欄位名稱資料型態說明
id數字流水號,也是主鍵(Primary Key)
title字串文章標題
content文字文章內容
created_at日期時間建立時間
updated_at日期時間更新時間

Model 要放在哪個目錄都可以,只要待會能被 import 到就好,那我就在根目錄建一個 models 資料夾,檔名叫做 post.py

# 檔名:models/post.py
from sqlalchemy import Integer, String, Text, DateTime
from sqlalchemy.orm import mapped_column
from sqlalchemy.sql import func
from config.settings import db

class Post(db.Model):
__tablename__ = "posts"

id = mapped_column(Integer, primary_key=True)
title = mapped_column(String, nullable=False)
content = mapped_column(Text)
created_at = mapped_column(DateTime, server_default=func.now())
updated_at = mapped_column(
DateTime, server_default=func.now(), server_onupdate=func.now()
)

這裡特別設定了類別的 __tablename__ 屬性,這是這個 Model 會對應到的資料表名稱,不設定的話也是可以,就是會用類別名稱當資料表名稱,我個人不是很喜歡這樣的設計,我比較喜歡資料表是小寫、複數的命名方式,例如 postsarticles

中間大概就是每個欄位的名稱,這裡使用了 mapped_column() 函數,比較早期版本的 SQLAlchemy 是用 Column() 函數,一樣還是可以用,但在 2.x 版之後改這個寫法了。另外,這裡我設定了 server_default 參數而不是 default,差別在於 server_default 是在資料庫端執行的,而 default 是在 Python 端執行的。現在專案的目錄結構差不多是這樣:

simple-flask-blog
├─ app.py
├─ config
│ └─ settings.py
├─ db
│ └─ blog.sqlite
├─ models
│ └─ post.py
├─ poetry.lock
├─ pyproject.toml
└─ templates
├─ layout.html.jinja
├─ posts
│ ├─ index.html.jinja
│ └─ new.html.jinja
└─ shared
└─ navbar.html.jinja
目前進度

分支名稱 06-add-post-model

資料遷移

是說,並不是建立了 Model 就自動產生資料表,還是得要透過 create_all() 方法來建立資料表,它會檢查看看某個資料表是否存在,如果不存在就幫你建立一個,但如果已經存就當做沒事發生。這聽起來好像很聰明,但如果是新增、修改或刪除資料表欄位的時候,這個方法會因為資料表已經存在所以不會幫我們處理欄位的修改。總不能每次都把資料庫或資料表砍掉再重建吧,這時候就是「資料遷移(Migration)」登場的時機了。

Migration 是剛接觸資料庫的新手比較容易卡關的地方,我們在後面介紹 Django 框架的時候還會再面對一次,因為那是 Django 的運作方式之一,簡單的說,Migration 是用來描述「資料庫的結構長什麼樣子」的檔案,不過更詳細的說明可再參閱該章節說明。

不過 SQLAlchemy 並沒有內建 Migration 的功能,需要另外安裝 flask-migrate 擴充套件:

$ poetry add flask-migrate

... 略 ...

Package operations: 3 installs, 0 updates, 0 removals

- Installing mako (1.3.5)
- Installing alembic (1.13.2)
- Installing flask-migrate (4.0.7)

我直接在 app.py 裡面匯入並使用 Migrate 類別:

from flask_migrate import Migrate  # <-- 別忘了匯入

# ... 略 ...

db.init_app(app)
Migrate(app, db) # <-- 這裡!

if __name__ == "__main__":
app.run(port=9527, debug=True)

就這樣兩行就了,然後待會就會有 flask db 指令可以來操作 Migration 了。第一個需要使用的指令是 flask db init,如果沒寫錯的話,應該會看到類似以下的訊息:

$ flask db init
Creating directory '/private/tmp/simple-flask-blog/migrations' ... done
... 略 ...
Generating /private/tmp/simple-flask-blog/migrations/alembic.ini ... done
Please edit configuration/connection/logging settings in '/private/tmp/simple-flask-blog/migrations/alembic.ini' before proceeding.

執行之後會發現在專案裡多了一個 migrations 目錄,裡面還有一些檔案是用來描述資料庫結構用的,但目前先不用太糾結它的實作細節。因為這裡我的檔名剛好是 app.py,所以不需要特別說明主程式是哪一個,但如果你的主程式不是 app.py 的話,可以透過環境變數 FLASK_APP 來指定,例如主程式的檔名是 manage.py

$ FLASK_APP=manage flask db init

接著,我們可以透過另一個指令 flask db migrate 來產生 Migration 檔案:

$ flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.env] No changes in schema detected.

上面的訊息寫到 No changes in schema detected.,這是因為目前資料庫裡面還沒有任何資料表,所以沒有任何變更。我可以直接在 app.py 裡把用到的 Model 給匯進來,不過後續可能會有越來越多的 Model,所以我在 models 目錄下新增一個 __init__.py 檔案,在這裡把用到的 Model 匯進來:

# 檔案 models/__init__.py
from .post import Post

如果忘記 __init__.py 是什麼用途的話,可回去復習「模組與套件」章節內容。回到 app.py 加上一行:

# ... 略 ...
from flask_migrate import Migrate
import models # <-- 這裡!

app = Flask(__name__)
# ... 略 ...

再執行一次 flask db migrate 指令會發現跟剛才的結果有些不同:

$ flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'posts'
Generating /private/tmp/simple-flask-blog/migrations/versions/c6006c6206d2_.py ... done

它發現新的 Model 了!這個指令會幫我們產生 Migration 檔,放在 migrations/versions 目錄下,檔名看起來是一串沒意義的亂碼,你可以點開來看看裡面的程式碼,會看到 upgrade()downgrade() 兩個函數,這是待會執行 Migration 會被執行的函數。

搞了好久,終於可以建立資料表了!透過 flask db upgrade 指令:

$ flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> c6006c6206d2, empty message

新增表格

是說,如果想要在 VSCode 看到資料表的欄位,可以用關鍵字 SQLite 搜尋一下 VSCode 的外掛,安裝之後就可以看到資料表的欄位了。

再做一次 upgrade 的話會發現沒有效果,因為原本的 Migration 都執行過了,所以不會有任何變更。Migration 的優點,就是只要修改了 Model 的欄位,例如把 title 欄位改成 subject,再執行一次 flask db migrate 會產生新的 Migration 檔案:

$ flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'posts.subject'
INFO [alembic.autogenerate.compare] Detected removed column 'posts.title'
Generating /private/tmp/simple-flask-blog/migrations/versions/ba219490f66e_.py ... done

執行 flask db upgrade 之後,資料表的欄位就換成新名字了。這時候如果執行 flask db downgrade 會發生什麼事呢?

$ flask db downgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running downgrade ba219490f66e -> c6006c6206d2, empty message

原本的 subject 欄位又會被換回 title。猜猜看,再執行一次 downgrade 指令會發生什麼事?再退一步就會連 posts 表格都整個被刪掉。

提醒一下,downgrade 雖然很方便,但這個指令本身是有風險的,原本存在資料表裡的資料可能會因此而整欄或整張表格被刪掉,而這是無法回復的,Migration 只管資料表的「結構」,裡面的資料跟它無關,所以使用的時候要特別留意,更多關於 Migration 的介紹可見 Django 章節說明。

我知道以這個陽春的部落格範例來說,其實沒必要把 Migration 加進來增加複雜度,但我不希望大家看這本書會以為業界的專案都是這麼單純的,所以最終還是決定加進來,讓大家趁早熟悉這個機制。

目前進度

分支名稱 07-add-migration

寫入資料

搞半天,終於可以寫入資料了!回到 app.py,看看剛才寫一本的 create() 函數:

@app.route("/posts/create", methods=["POST"])
def create():
# 寫入資料庫,待會做...
return redirect(url_for("index"))

要寫入資料庫,以前可能要寫類似這樣的 SQL 語法:

INSERT INTO posts(title, ...) VALUES('...', ...);

但使用 ORM 之後,就可以透過操作物件的方式,請 Model 幫我們處理這些事:

# 檔案 app.py
@app.route("/posts/create", methods=["POST"])
def create():
title = request.form.get("title")
content = request.form.get("content")

post = models.Post(title=title, content=content)
db.session.add(post)
db.session.commit()

return redirect(url_for("index"))

最後的 db.session.commit() 就是把資料寫入資料庫的方法,沒有出錯的話,應該就會跳轉到首頁了。但到底有沒有寫進去?你去看資料表就知道了。但如果你不想看資料表,可以透過 flask shell 進到 REPL 環境來看看:

$ flask shell
Python 3.12.3 (main, Apr 9 2024, 08:09:14) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
App: app
Instance: /private/tmp/simple-flask-blog/instance
>>> from models import Post
>>> Post.query.all()
[<Post 1>, <Post 2>]

透過 Post 所繼承來的方法,一樣可以在不需要寫 SQL 語法的情況下,查詢資料庫裡的資料。可以看到這裡有兩筆資料,但因為沒有實作 __repr__ 方法,所以只會顯示 <Post 1> 這樣的字樣,我來幫 Post 加上 __repr__ 方法:

# 檔案 models/post.py
class Post(db.Model):
# ... 略 ...
def __repr__(self):
return f"{self.title}"

我就只加個標題給它,再回到 flask shell 看看:

$ flask shell
>>> from models import Post
>>> Post.query.all()
[為你自己學 Python, 為你自己學 Git]

就能顯示文章的標題了。

目前進度

分支名稱 08-create-post

快閃訊息

雖然已經可以順利新增文章,但大家在操作的時候,會不會覺得哪裡怪怪的?新增文章成功後就直接跳轉首頁,卻沒有任何的提示,不知道是否有完成新增文章這件事。

早年網站開發可能會用 JavaScript 的 alert() 函數來顯示訊息,但以現代的網站使用體驗來說會讓使用者感到干擾,現在比較常見的方式是透過在網頁上的文字來提醒使用者剛才做了什麼事。

Flask 有一個叫做「快閃訊息(Flash Message)」的設計,它會把訊息塞到瀏覽器的 Cookie 裡,只要被讀取過一次就刪掉,所以當頁面重新整理之後就不會出現了,這也是被稱做快閃訊息的原因。

快閃訊息的使用方式很簡單,只要在需要的地方呼叫 flash() 函數並且把想要顯示的訊息傳入即可,不過在使用之前需要幫應用程式設定一個密鑰,這會被用來加密 Cookie,所以最好不要太容易被猜到,可以在 app 物件設定 secret_key 的值:

# 檔案 app.py
app.secret_key = "3Z9lCNu1aUvHZCfyAACL"

雖然直接在程式碼裡面設定很直覺,但這樣做一點都不像秘密...

環境變數

只要是直接寫在程式碼裡面,不管寫的多複雜,只要拿到這個專案的人就等於拿到 secret_key。所以實務上通常會把 secret_key 另外設定在環境變數或其他地方裡,這樣就算拿到原始碼的人也不會知道 secret_key 是什麼。在開發的過程,可以先把 secret_key 寫在 .env 檔案裡,然後可以透過 python-dotenv 套件來讀取設定。先安裝 python-dotenv

$ poetry add python-dotenv

接著在專案根目錄下新增 .env 檔案,裡面放入 secret_key 的值:

APP_SECRET_KEY=3Z9lCNu1aUvHZCfyAACL

然後回到 app.pysecret_key 改成從環境變數讀取:

# 檔案 app.py
# ... 略 ...
from dotenv import load_dotenv
load_dotenv() # <-- 載入 .env 檔案

app = Flask(__name__)

# ... 略 ...

app.secret_key = os.getenv("APP_SECRET_KEY") # <-- 從環境變數讀取 secret_key

這跟 Migration 一樣,就以簡單的教學來說我大可直接把 secret_key 寫在程式碼裡就好,但我不希望各位以為這樣寫是正常的,所以還是花了點時間跟大家說明。

前置作業準備好了,接著就可以準備使用快閃訊息了:

@app.route("/posts/create", methods=["POST"])
def create():
# ... 略 ...

flash("新增文章成功!") # <-- 設定快閃訊息

return redirect(url_for("index"))

在要跳轉離開之前呼叫 flash() 函數,設定準備要顯示的文字訊息,這樣訊息就設定好了。

接著要在網頁上顯示快閃訊息,因為快閃訊息在網站上每一頁都可能會有,所以我會把它跟之前處理導覽列一樣另外寫在別的檔案裡,然後在公用樣版把它引進來。檔案我就叫它 flash.html.jinja,放在 templates/shared 裡:

{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="px-4 py-2 text-lg text-white bg-orange-500 rounded-sm select-none">
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}

這裡的 get_flashed_messages() 是 Flask 提供的函數,可以取得快閃訊息,然後再用樣版提供的 for 迴圈把每一個訊息都顯示出來。再提醒一下,這裡的 iffor 不是 Python 的語法,而是 Jinja2 的語法,所以別忘了要有結尾的 endifendfor

這樣就搞定了,你可再試試新增文章,應該會看到新增文章成功的訊息:

快閃訊息

如果你再重新整理頁面之後訊息就會消失,這就是快閃訊息的特性。

快閃訊息的概念並不是 Flask 發明或特有的,在 Django 或是其他網站開發框架都有類似的實作,很方便,但有個對我個人比較困擾的地方,就是 Flask 跟 flash 只差一個字母,一不小心常會打錯字。

是說,雖然我們把 secret_key 設定在 .env 檔案裡,但這樣其實也只是把設定放在另一個檔案而已,只要取得這個專案的人就能拿到 .env,同樣可以看到 secret_key 的值,所以這個檔案應該不能放在 Git 版控裡,應該要把它加到 .gitignore 裡排除掉。但這樣一來,如果下載這份專案的人,要怎麼知道 .env 裡面要怎麼設定?所以通常會在專案裡另外提供範本檔案,我會讓這個檔案叫做 .env.example,內容如下:

APP_SECRET_KEY=

這個檔案應該會加到 Git 版控裡,拿到這個檔案的人,照著說明文件應該就能知道要怎麼設定 .env 檔案。如果對 Git 操作不熟的話,可參考我寫的另一本書「為你自己學 Git」。

目前進度

分支名稱 09-flash-message

其實最難的部分已經過了,剩下的就簡單多了,接下來就是把文章用列表方式呈現出來...

文章列表

要列出所有文章,同樣可以透過 Post 來取得:

# 檔案 app.py
@app.route("/")
@app.route("/posts")
def index():
posts = models.Post.query.all()
return render_template("posts/index.html.jinja", posts=posts)

透過 models.Post.query.all() 可以取得 posts 資料表裡所有文章。接著把取得的文章資料傳到樣版裡,這樣樣版就可以用 posts 變數來存取文章資料。

這裡我刻意使用複數名詞 posts 來命名,是因為拿出來的資料應該會有很多筆,所以用複數名詞比較貼切,而且待會在樣版跑迴圈的時候,用複數名詞也能跟迴圈裡的變數名稱有所區隔。

另外,你可能會在網路上看到這種寫法:

return render_template("posts/index.html.jinja", **locals())

這是利用 Python 的內建函數 locals() 取得函數裡所有的區域變數,再透過 ** 展開成關鍵字引數的效果。雖然這樣就可以不用一個一個寫,但我個人不太喜歡這種作法,不僅語法不夠明確,而且會把函數裡的所有的區域變數都傳到樣版裡,有些區域變數只是在執行過程中暫時用來存放資料用的而已,不需要也不應該跟著傳到樣版裡。

拿到所有文章之後,回到 index.html.jinja 樣版,跑一個 for 迴圈就能把所有文章列出來:

{% block content %}
<h1 class="text-3xl">Hello World</h1>

<section class="flex flex-col gap-4 mt-2">
{% for post in posts %}
<div>
<header class="my-1">
<h2 class="text-lg font-bold">{{ post.title }}</h2>
</header>
<article class="flex flex-col gap-2 p-2 bg-slate-50">
<time datetime="{{ post.created_at.isoformat() }}" class="text-xs text-slate-500">
{{ post.created_at.strftime('%Y-%m-%d') }}
</time>
<p>{{ post.content }}</p>
</article>
</div>
{% endfor %}
</section>
{% endblock %}

同樣先不要看 Tailwind CSS 的那些東西,把重點放在 for 迴圈的 for post in posts。其中 posts 是剛剛在 index() 函數傳進來的變數,裡面存放了所有文章的資料。在迴圈裡,我刻意用單數名詞 post 來存取每一篇文章,這樣就可以把文章的標題、內容、建立時間等資料顯示出來。

變數名稱並沒有強制規範,但如果這裡改成變數 x,文章的標題跟內容就會變成 x.titlex.content,不需要我自己多說什麼,你就自己比較一下跟 post.title 以及 post.content 的程式碼可讀性。

如果沒寫錯,現在的畫面大概是這樣:

文章列表

有個小地方要調整一下,如果你仔細看會發現,新的文章會出現在最後,也就是說每當新增一篇文章,我得拉到最下面才看的到,這有點不太直觀。這是因為我們在拿資料的時候,預設的排序是 id 由小到大進行排序。如果要改成比較新的文章出現在比較上面,可以透過 order_by 來排序:

# 檔案 app.py
def index():
posts = models.Post.query.order_by(models.Post.id.desc()).all()
return render_template("posts/index.html.jinja", posts=posts)

.desc() 方法是由大到小的降冪排序(Descending Order)的意思,這樣就可以讓比較新的文章出現在上面。不過如果要反過來排的話,還有個更簡單的寫法:

# 檔案 app.py
def index():
posts = models.Post.query.order_by(-models.Post.id).all()
return render_template("posts/index.html.jinja", posts=posts)

在前面加上減號 - 就可以讓排序反過來,我自己比較喜歡這種寫法。另外,我原本對 Model 的 import 是寫成 import models,所以現在要用裡面的東西的時候就得寫成 models.Post,這有點囉嗦,我這裡順手改一下 import 的寫法:

# 檔案 app.py
from models import Post

# ... 略 ...

def index():
posts = Post.query.order_by(-Post.id).all()
return render_template("posts/index.html.jinja", posts=posts)

底下原本有用到 models.Post 的地方別忘了也順手改一下,這樣看起來就清爽多了。

目前進度

分支名稱 10-add-post-list

檢視文章

有文章列表之後,再來就是要看看每篇文章的內容。我希望每篇文章的網址會是 /posts/2 這樣的格式,其中的 2 是文章的流水編號。但這樣的網址是動態的,我們總不能幫每一篇文章都掛一個 route() 吧。還好像這種動態的路徑,在 Flask 中有專門的寫法:

# 檔案 app.py
@app.route("/posts/<id>")
def show(id):
return f"<h1>{id}</h1>"

在路徑裡面加上 <id> 這樣的寫法,在函數裡面取得這個參數,待會就能用這個 id 進到資料表查詢相對應的資料。除了 <id> 這樣的寫法外,如果你確定傳進來的資料是什麼型態,還能在 <id> 前面加註型態,像這樣:

@app.route("/posts/<int:id>")
def show(id):
return f"<h1>{id}</h1>"

<int:id> 的寫法能確保參數 id 是整數型態,萬一傳進來的不是數字,例如 /posts/hello,Flask 會直接給一個頁面找不到的回應。

id 了,我們就可以請 Model 幫忙找出對應的文章:

# 檔案 app.py
@app.route("/posts/<int:id>")
def show(id):
post = Post.query.get(id)
return render_template("posts/show.html.jinja", post=post)

Post.query.get(id) 預設會找出主鍵的值等於傳入的 id,找到之後用同樣的手法傳給樣版,這個樣版就請大家自由發揮創意了,我的 show.html.jinja 大概是這個樣子:

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="text-2xl">{{ post.title }}</h1>
<article>
<time datetime="{{ post.created_at.isoformat() }}" class="text-xs text-slate-500">
{{ post.created_at.strftime('%Y-%m-%d') }}
</time>
<p class="px-4 py-2 bg-gray-50">{{ post.content }}</p>
</article>
{% endblock %}

沒什麼特別的,就是把文章的標題、內容、以及建立時間顯示出來而已。這樣一來,我們就可以看到每篇文章的內容了。但這還有幾個問題要處理,首先,雖然網址我們現在知道是 /posts/2 這樣的路徑,但總不能要使用者自己打網址吧,所以我們要在文章列表裡面加上連結,回到 index.html.jinja 樣版,我在原本的文章標題的地方加上超連結:

<h2 class="text-lg font-bold">
<a href="/posts/{{ post.id }}">{{ post.title }}</a>
</h2>

像上面這樣直接自己組裝網址雖然也可以,但我更建議使用前面學過的 url_for() 函數來產生網址,萬一將來要改網址,用 url_for() 函數產生的網址都不用再手動跟著調整,所以我把網址改成這樣:

<h2 class="text-lg font-bold">
<a href="{{ url_for("show", id=post.id) }}">{{ post.title }}</a>
</h2>

這樣就行了。另一個問題,雖然使用者現在可以點連結進到文章內容,但如果使用者自已輸入網址,例如 /posts/9527,然後這篇文章不存在,這時候應該要回應 HTTP 404 頁面找不到的錯誤,讓使用者知道這個網址是錯的。不過現在 Post.query.get(id) 如果找不到,只會得到 None 並不會出錯,所以我來做個簡單的判斷:

from flask import abort
# ... 略 ...
def show(id):
post = Post.query.get(id)
if post is None:
abort(404)
return render_template("posts/show.html.jinja", post=post)

abort() 函數是 Flask 模組裡的方法,可以用來回應各種 HTTP 錯誤,這裡我們用 abort(404) 表示會丟出一個 404 例外,這樣一來,如果使用者輸入不存在的文章編號,就會看到 404 錯誤頁面了。因為這種判斷很常見,所以 SQLAlchemy 有一個更簡單的方法,就是 .get_or_404() 方法,這個方法跟 .get() 一樣會直接找出對應的文章,但如果找不到就會丟出 404 錯誤:

def show(id):
post = Post.query.get_or_404(id)
return render_template("posts/show.html.jinja", post=post)

語法看起來更直覺,而且也不用自己寫判斷。不過預設的 404 頁面有點不好看,我們可以自己設計一個 404 頁面放在 templates 目錄裡,然後在 app.py 裡面加上一段的程式碼:

@app.errorhandler(404)
def page_not_found(e):
return render_template("errors/404.html.jinja"), 404

errorhandler() 裝飾器可以用來註冊錯誤處理函數,上面這幾行的意思是指當 Flask 遇到 404 錯誤時,就會呼叫這個函數,然後回應我們自己設計的 404 頁面。

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="text-2xl">OOPS! 頁面找不到</h1>
{% endblock %}

以簡單的教學來說,客製化 404 頁面也不是必要的,但如果是一個正式的網站,有個好看的錯誤頁面是很重要的,因為這樣可以讓使用者知道他們輸入的網址是錯的,還可以在這個時候做一些指引,例如提供文章搜尋功能或是連回文章列表之類的功能

目前進度

分支名稱 11-display-post

編輯文章

接下來我們要來實作編輯文章的功能,這個功能跟新增文章的功能有點類似,差別在於要把原本的文章內容載入到表單裡面。編輯頁面的連結我想設定成 /posts/2/edit,所以我先在 show.html.jinja 頁面的下面加上編輯連結,使用者可以直接點選進入編輯頁面:

{% extends "layout.html.jinja" %} {% block content %} ... 略 ...
<article>... 略 ...</article>
<footer class="my-1">
<a href="/posts/{{ post.id }}/edit" class="inline-block px-2 py-1 text-white bg-green-400">編輯</a>
</footer>
{% endblock %}

雖然這樣手工組裝網址也行,但既然我們都會使用 url_for() 了,這裡也可以改成使用 url_for() 來產生網址:

<footer class="my-1">
<a href="{{ url_for('edit', id=post.id) }}" class="inline-block px-2 py-1 text-white bg-green-400">編輯</a>
</footer>

再來回到 app.py,加一個用來處理編輯文章的方法:

# 檔案 app.py
@app.route("/posts/<int:id>/edit")
def edit(id):
post = Post.query.get_or_404(id)
return render_template("posts/edit.html.jinja", post=post)

這裡沒有什麼新東西,接著就是要來處理 edit.html.jinja 頁面了,因為編輯文章的頁面跟新增文章的頁面長的滿像的,只是「新增文章」的地方要改成「更新文字」、表單的路徑要調整一下,以及要把值先塞進表單的輸入框裡,所以我這裡直接複製 new.html.jijna 的內容來做調整:

{% extends "layout.html.jinja" %} {% block content %}
<h1 class="mb-2 text-2xl">更新文章</h1>
<form action="/posts/{{ post.id }}/update" method="POST" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label for="title">標題</label>
<input
type="text"
id="title"
name="title"
class="w-full p-2 text-xl border border-gray-900 rounded-sm md:w-1/2"
placeholder="請填寫標題"
value="{{ post.title }}"
/>
</div>
<div class="flex flex-col gap-2">
<label for="content">內文</label>
<textarea
id="content"
name="content"
class="w-full p-2 text-xl border border-gray-900 rounded-sm md:w-1/2"
placeholder="文章內容"
rows="6"
>
{{ post.content }}</textarea
>
</div>
<div>
<button type="submit" class="p-2 text-lg text-white bg-blue-500 rounded-sm">更新文章</button>
</div>
</form>
{% endblock %}

重新整理之後,應該就會看到表單裡面有原本文章的標題跟內文了。其中 action="/posts/{{ post.id }}/update" 待會我也會把它改成 url_for() 的寫法。接著準備實作更新文章的功能:

# 檔案 app.py
@app.route("/posts/<int:id>/update", methods=["POST"])
def update(id):
post = Post.query.get_or_404(id)

post.title = request.form.get("title")
post.content = request.form.get("content")

db.session.add(post)
db.session.commit()

flash("文章更新成功!")
return redirect(url_for("show", id=id))

同樣也沒有什麼新把戲,就是先把文章調出來,設定一下 titlecontent 的值,然後再把文章存回資料庫,最後再導回文章頁面。這樣就完成了文章的編輯功能。其中 db.session.add(post) 這行還可以省略,SQLAlchemy 夠聰明知道我們正在處理這筆資料,這裡可以直接 db.session.commit() 就好。

目前進度

分支名稱 12-edit-post

刪除文章

終於來到 CRUD 的最後一關了,在 CRUD 裡樣,最簡單的應該就是刪除功能了。刪除文章的路徑我想設定成 /posts/2/delete,所以我同樣在 show.html.jinja 頁面的下面加上刪除連結,使用者可以直接點選進入刪除頁面:

{% block content %} ... 略 ...
<footer class="flex gap-2 my-1">
<a href="{{ url_for('edit', id=post.id) }}" class="px-2 py-1 text-white bg-green-400 select-none">編輯</a>

<form action="/posts/{{ post.id }}/delete" method="POST" onsubmit="return confirm('確認刪除?')">
<button class="px-2 py-1 text-white bg-red-400 select-none">刪除</button>
</form>
</footer>
{% endblock %}

因為一般的超連結沒辦法做出 POST 效果,所以這裡我使用 <form> 來包住一個 <button>,這樣就可以做出 POST 的效果。這裡我也加了一個 onsubmit 事件,當使用者按下刪除按鈕時,會跳出一個確認視窗,確認後才會真的送出表單,免得你家的貓不小心踩到鍵盤就把文章給刪掉了。這裡的 /posts/{{ post.id }}/delete 待會也會改成 url_for() 的寫法。

最後來實作 app.py 裡的刪除文章功能:

# 檔案 app.py
@app.route("/posts/<int:id>/delete", methods=["POST"])
def delete(id):
post = Post.query.get_or_404(id)

db.session.delete(post)
db.session.commit()

flash("文章已刪除")
return redirect(url_for("index"))

找到它、刪掉它,然後離開它,接著回瀏覽器試看看刪除功能,這會真的把文章刪掉,而且救不回來喔!

目前進度

分支名稱 13-delete-post

小結

基本上要針對一個 Model 進行 CRUD 差不多就是這樣的流程,透過 Flask 與 SQLAlchemy 來的組合,CRUD 應該會變的簡單很多,不太需要寫 SQL 語句,也不用擔心 SQL Injection 的問題。不過大家應該也有注意到,現在的文章功能是每個人都可以進行新增、修改、刪除,這樣也太危險了,下一章我們將會介紹怎麼實作使用者登入的功能,讓使用者只能編輯或刪除自己編寫的文章,讓部落格系統變得更加完整。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!