一. 前言:小後台不一定要先開 Django #
很多 Python 專案長到某個階段,都會冒出一個很樸素的需求: 我想要有個小頁面,可以看資料、新增資料、改幾個欄位、刪掉測試資料。 它可能是任務清單、資料標註 queue、prompt 範本、爬蟲項目,或某個內部工具的管理頁。 這種東西叫小後台、admin、internal tool、資料管理頁,都可以。 重點是它通常還不到需要完整 Web framework 的程度。 今天拍拍君走務實路線: Streamlit 負責畫面,SQLModel 負責資料模型與 SQLite 存取。 這篇不重講 Streamlit 入門,也不重講 SQLAlchemy 的完整 ORM 設計。 如果你還沒看過,可以先補:
- Streamlit 入門
- Streamlit 進階:session_state、cache 與多頁 Dashboard
- SQLAlchemy 2.0 實戰 我們會做一個「書籤管理小後台」:
- 列出書籤
- 搜尋與篩選
- 新增資料
- 編輯資料
- 刪除資料
- 用 SQLite 存在本機 樸素很好。 小後台最重要的是穩、清楚、真的願意每天打開。
二. 為什麼是 SQLModel? #
SQLModel 把三件事放在同一個開發體驗裡:
- Pydantic 風格的資料驗證
- SQLAlchemy 底層的 ORM 能力
- Python type hints 的可讀性 對小型 CRUD 工具來說,它的優點是 model 很短,資料形狀清楚,SQLite 和 PostgreSQL 都能接。 你不用在 dataclass、dict、SQLAlchemy model 之間一直轉換。 拍拍君不會說它永遠比 SQLAlchemy 好。 如果你的專案已經有完整 SQLAlchemy 2.0 架構,就沿用既有模式。 如果你需要複雜 migration、關聯查詢、交易邊界,SQLAlchemy + Alembic 會更完整。 但如果需求是「本機小工具 + SQLite + 清楚 CRUD」,SQLModel 很舒服。 它不會把你推進大型架構,也不會讓程式只剩一堆散落 SQL 字串。
三. 建立專案 #
先建立專案:
uv init streamlit-sqlmodel-crud
cd streamlit-sqlmodel-crud
uv add streamlit sqlmodel
不用 uv 也可以:
python -m venv .venv
source .venv/bin/activate
pip install streamlit sqlmodel
建議結構如下:
streamlit-sqlmodel-crud/
├── app.py
├── data/
│ └── bookmarks.db
└── src/
├── __init__.py
├── db.py
├── models.py
└── repository.py
小 demo 可以全部塞進 app.py。
但 CRUD 工具很容易越寫越長。
資料模型、連線、查詢函式先切出來,後面比較不會變成「按鈕旁邊順手寫 SQL」。
mkdir -p src data
touch src/__init__.py
四. 建立資料模型 #
建立 src/models.py:
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Bookmark(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str = Field(index=True, min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=500)
tag: str = Field(default="", max_length=80, index=True)
note: str = Field(default="", max_length=500)
is_favorite: bool = Field(default=False, index=True)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
table=True 代表這個 class 會對應資料表。
id 用 None 當預設值,新增資料時還沒有 id,寫進 SQLite 後才會自動產生。
title、url 加上長度限制,不是什麼華麗 validation,但能擋掉空字串和超長內容。
時間用 timezone-aware UTC。
本機工具也建議這樣做,因為時間混亂通常不是今天爆,是三個月後爆。
五. 初始化 SQLite #
建立 src/db.py:
from pathlib import Path
from sqlmodel import Session, SQLModel, create_engine
DB_PATH = Path("data") / "bookmarks.db"
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
def init_db() -> None:
SQLModel.metadata.create_all(engine)
def get_session() -> Session:
return Session(engine)
create_all() 很適合 demo 和本機工具。
但正式產品如果 schema 會一直改,請改用 migration。
拍拍君之前寫過 Alembic migration,那篇就是處理這個問題。
也注意:可以把 engine 放全域,但不要把同一個 Session 永遠放全域。
Session 是一次工作單位,不是長期狀態。
Streamlit 的 rerun 模型會讓長期 Session 很難追。
六. Repository:把 CRUD 關在一個地方 #
建立 src/repository.py。
先寫列表與新增:
from datetime import datetime, timezone
from sqlmodel import Session, select
from .models import Bookmark
def list_bookmarks(
session: Session,
*,
keyword: str = "",
favorite_only: bool = False,
) -> list[Bookmark]:
statement = select(Bookmark).order_by(Bookmark.updated_at.desc())
if keyword:
pattern = f"%{keyword.strip()}%"
statement = statement.where(
(Bookmark.title.ilike(pattern))
| (Bookmark.url.ilike(pattern))
| (Bookmark.tag.ilike(pattern))
)
if favorite_only:
statement = statement.where(Bookmark.is_favorite.is_(True))
return list(session.exec(statement))
def create_bookmark(session: Session, **data) -> Bookmark:
bookmark = Bookmark(**data)
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
為什麼要做 repository?
因為 Streamlit 頁面應該負責互動,不應該到處散落資料庫細節。
按鈕旁邊可以呼叫 create_bookmark(),但不該順手把 select()、commit()、refresh() 全部寫一遍。
接著補更新與刪除:
def get_bookmark(session: Session, bookmark_id: int) -> Bookmark | None:
return session.get(Bookmark, bookmark_id)
def update_bookmark(session: Session, bookmark_id: int, **data) -> Bookmark | None:
bookmark = session.get(Bookmark, bookmark_id)
if bookmark is None:
return None
for key, value in data.items():
setattr(bookmark, key, value)
bookmark.updated_at = datetime.now(timezone.utc)
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
def delete_bookmark(session: Session, bookmark_id: int) -> bool:
bookmark = session.get(Bookmark, bookmark_id)
if bookmark is None:
return False
session.delete(bookmark)
session.commit()
return True
小工具也要處理找不到資料的情況。
使用者可能開著頁面很久,資料已經被另一個流程刪掉。
None / False 回傳值可以讓 UI 給出合理訊息。
七. 第一版 Streamlit App #
接著寫 app.py。
先初始化資料庫,然後顯示列表:
import streamlit as st
from src.db import get_session, init_db
from src.repository import list_bookmarks
st.set_page_config(
page_title="拍拍君書籤小後台",
page_icon="📚",
layout="wide",
)
init_db()
st.title("拍拍君書籤小後台")
st.caption("Streamlit + SQLModel + SQLite")
keyword = st.text_input("搜尋", placeholder="輸入標題、網址或標籤")
favorite_only = st.checkbox("只看收藏")
with get_session() as session:
bookmarks = list_bookmarks(
session,
keyword=keyword,
favorite_only=favorite_only,
)
st.write(f"共 {len(bookmarks)} 筆")
st.dataframe(
[
{
"id": item.id,
"favorite": item.is_favorite,
"title": item.title,
"tag": item.tag,
"url": item.url,
"updated_at": item.updated_at,
}
for item in bookmarks
],
use_container_width=True,
hide_index=True,
)
啟動:
uv run streamlit run app.py
目前還沒有資料,所以表格是空的。
沒關係。
先確認頁面跑起來,data/bookmarks.db 也被建立。
八. 新增資料:用 st.form 控制送出 #
Streamlit 的 st.form 很適合 CRUD。
表單裡的 widget 不會每打一個字就觸發完整流程,只有按 submit 時才送出。
from src.repository import create_bookmark
with st.expander("新增書籤", expanded=True):
with st.form("create-bookmark", clear_on_submit=True):
title = st.text_input("標題")
url = st.text_input("網址")
tag = st.text_input("標籤")
note = st.text_area("備註")
is_favorite = st.checkbox("加入收藏")
submitted = st.form_submit_button("新增")
if submitted:
if not title.strip():
st.error("標題不能空白。")
elif not url.strip():
st.error("網址不能空白。")
else:
with get_session() as session:
create_bookmark(
session,
title=title.strip(),
url=url.strip(),
tag=tag.strip(),
note=note.strip(),
is_favorite=is_favorite,
)
st.success("已新增書籤。")
st.rerun()
st.rerun() 這裡是故意的。
新增成功後,我們希望頁面重新查詢列表,立刻看到新資料。
常見節奏是:
- 使用者填表單。
- submit 後檢查資料。
- 寫入資料庫。
- 顯示成功訊息。
- 必要時 rerun,讓列表與表單狀態回到乾淨畫面。 不要每個 widget 都手動 rerun,也不要寫入後完全不刷新。 CRUD 小工具要順,靠的就是這些小節奏。
九. 編輯資料:用 id 綁定表單 #
最簡單的編輯方式,是用 selectbox 選 id。
from src.repository import get_bookmark, update_bookmark
ids = [item.id for item in bookmarks if item.id is not None]
if ids:
selected_id = st.selectbox("選擇要編輯的書籤 id", ids)
with get_session() as session:
selected = get_bookmark(session, selected_id)
if selected is None:
st.warning("這筆資料已不存在。")
else:
with st.form(f"edit-bookmark-{selected_id}"):
edit_title = st.text_input("標題", value=selected.title)
edit_url = st.text_input("網址", value=selected.url)
edit_tag = st.text_input("標籤", value=selected.tag)
edit_note = st.text_area("備註", value=selected.note)
edit_favorite = st.checkbox("收藏", value=selected.is_favorite)
saved = st.form_submit_button("儲存修改")
if saved:
with get_session() as session:
updated = update_bookmark(
session,
selected_id,
title=edit_title.strip(),
url=edit_url.strip(),
tag=edit_tag.strip(),
note=edit_note.strip(),
is_favorite=edit_favorite,
)
if updated is None:
st.error("找不到要更新的資料。")
else:
st.success("已儲存修改。")
st.rerun()
form key 用 edit-bookmark-{selected_id},是為了讓 widget 狀態跟資料 id 對齊。
如果不同資料共用同一組 widget,畫面可能保留上一筆的值。
讀取資料和更新資料各自開 Session。
畫面渲染時讀資料,按下儲存時再開一個短生命週期 Session 寫入。
使用者可能停在頁面上十分鐘才按儲存,資料庫連線不用陪他發呆。
十. 刪除資料:一定要有確認 #
刪除按鈕很危險。 小後台也不要讓人一點就沒。
from src.repository import delete_bookmark
st.divider()
st.subheader("刪除資料")
if ids:
delete_id = st.selectbox("選擇要刪除的書籤 id", ids, key="delete_id")
confirm_text = st.text_input("輸入 DELETE 確認刪除")
if st.button("刪除", type="primary"):
if confirm_text != "DELETE":
st.error("請輸入 DELETE 才會刪除。")
else:
with get_session() as session:
ok = delete_bookmark(session, delete_id)
if ok:
st.success("已刪除。")
st.rerun()
else:
st.error("找不到要刪除的資料。")
正式工具可以再加軟刪除、操作紀錄、權限檢查。 如果你需要登入和權限,可以看 Streamlit Auth 實戰。 今天先把本機 CRUD 流程做好。
十一. 表單驗證放哪裡? #
有三層驗證可以考慮。
第一層是 UI 驗證。
例如標題不能空白、URL 不能空白。
這層要給使用者清楚訊息。
第二層是 model 驗證。
SQLModel 欄位上的 min_length、max_length 可以擋掉明顯錯誤。
這層比較像資料邊界。
第三層是資料庫約束。
例如欄位不可為 NULL、unique index、foreign key。
這層是最後防線。
拍拍君建議:
- UI 負責友善錯誤。
- model 負責資料形狀。
- database 負責不可違反的規則。 例如 URL 可以先正規化:
def normalize_url(value: str) -> str:
value = value.strip()
if not value:
raise ValueError("網址不能空白")
if not value.startswith(("http://", "https://")):
value = "https://" + value
return value
這比在畫面上到處重複 if not url 更乾淨。
十二. 常見踩雷 #
第一個坑:把資料庫 Session cache 起來。
@st.cache_resource
def get_cached_session():
return Session(engine)
不建議。
Session 是工作單位,不是長期資源。
可以 cache engine,不要 cache Session。
第二個坑:新增成功但列表沒更新。
通常是沒有重新查詢,或 cache 沒有清。
本篇範例資料量小,先不 cache 查詢。
第三個坑:widget 狀態跟資料 id 沒綁好。
新增表單可以用 clear_on_submit=True。
編輯表單可以用資料 id 放進 key。
第四個坑:以為 SQLite 適合所有多人寫入場景。
SQLite 很適合本機工具、單人或低併發內部小工具。
如果很多人同時大量寫入,請換 PostgreSQL。
第五個坑:沒有備份資料庫檔案。
本機 SQLite 就是一個檔案。
簡單是優點,也是風險。
結語 #
Streamlit 最棒的地方,是它讓 Python 工程師不用先變成前端工程師,就能做出可用的操作介面。 SQLModel 最舒服的地方,是它讓資料模型、驗證和 ORM 不必拆成三套心智模型。 兩個工具加上 SQLite,就很適合做本機 CRUD 小後台。 有時候,一個乾淨的 model、一組清楚的 repository 函式、一個不亂跑的 Streamlit 表單,就已經夠把日常工作救回來。 拍拍君的建議是:先把最小 CRUD 做穩。 等使用者真的每天打開它,再決定要不要升級。