陽春版個人部落格
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
,內容跟前一章學到的沒太大差別:
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
,裡面放待會會用到的樣版檔案。不知道這裡大家是否有注意到,我在上面用的樣版檔案的名字跟前個章節的有點不太一樣, 我用了 .html.jinja
當做附檔名...
樣版附檔名
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>
這裡我只有留一個 block
區塊而已,又因為我不太想乖乖的寫 CSS 樣式,所以這裡直接用 CDN 的方式引入了 Tailwind CSS,待會可以直接使用。關於 Tailwind CSS 的詳細說明,可參考 Tailwind 官網說明。實務上我通常會另外對這些前端的資源(JavaScript、CSS 以及圖片)進行打包,但這樣可能會讓這個章節變得失焦,這裡我就先暫時用 CDN 了。
Tailwind CSS https://tailwindcss.com/
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 寫在另外一個樣版檔案,待會在公用樣版裡引入。
檔案我就放在 templates/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.route("/posts/new")
def new():
return render_template("posts/new.html.jinja")
跟剛才的 index.html.jinja
相比,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>
標籤只有支援 GET
與 POST
這兩種方式,這裡我使用 POST
來傳送表單資料。
其次,你會看到這兩個輸入框我都有特別設定了 name
屬性,這是因為待會按下送出按鈕之後,資料會傳送到後端程式,而後端程式就得靠這個 name
屬性才能拿到對應的資料。不像 id
或 class
這種 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
參數裡額外指定了 ``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 語法,同時也能把查詢結果轉換成 Python 物件。
不少人可能會以為 ORM 就是資料庫,事實上 ORM 只是資料庫與程式語言的中間層,更像是經紀人的角色。通常有需要的時候我們跟經紀人講,經紀人再把我們的話翻譯成資料庫聽的懂的話,如果資料庫查詢有回應結果,經紀人也會把結果再轉換成我們聽的懂的話。不管經紀人跟哪些資料庫對接,對我們來說就是只要對經紀人這個單一窗口就好,也就是說除非我們使用了某些資料庫系統特有的功能,否則如果要切換資料庫系統只要換個設定就行了,其他程式大部份都不需要修改。
目前坊間的資料庫系統有好多種 類,有收費的也有開源的,本章我將會使用最簡單的 SQLite,它是一個檔案型的資料庫,不需要另外安裝或架設伺服器。SQLite 適用於行動裝置 App 或是規模較小的專案,或者是在開發階段不想安裝資料庫系統也常會使用它,不過如果是比較大型的專案,我會推薦使用 PostgreSQL、MySQL 或 MongoDB 等資料庫系統。
SQLAlchemy 是一個 Python 的套件,所以當然得先把套件裝起來,不過因為 Flask 有另一個專門給 SQLAlchemy 的擴充套件 flask-sqlalchemy
,這個擴充套件可以讓我們更容易把 SQLAlchemy 整合到 Flask 專案:
$ 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)
從安裝的過程也能看的出來會一併自動安裝 SQLAlchemy。接著我們要在 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
:
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 會對應到的資料表名稱,不設定的話也是可以,預設會 用類別名稱當資料表名稱。不過我自己不是很喜歡這樣的設計,我個人喜歡資料表是小寫加複數的命名方式,例如 posts
或 articles
。
中間大概就是每個欄位的名稱,這裡使用了 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