快轉到主要內容
  1. 教學文章/

Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台

·9 分鐘· loading · loading · ·
Python Streamlit SQLModel SQLite CRUD Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 78: 本文

featured

一. 前言:小後台不一定要先開 Django
#

很多 Python 專案長到某個階段,都會冒出一個很樸素的需求: 我想要有個小頁面,可以看資料、新增資料、改幾個欄位、刪掉測試資料。 它可能是任務清單、資料標註 queue、prompt 範本、爬蟲項目,或某個內部工具的管理頁。 這種東西叫小後台、admin、internal tool、資料管理頁,都可以。 重點是它通常還不到需要完整 Web framework 的程度。 今天拍拍君走務實路線: Streamlit 負責畫面,SQLModel 負責資料模型與 SQLite 存取。 這篇不重講 Streamlit 入門,也不重講 SQLAlchemy 的完整 ORM 設計。 如果你還沒看過,可以先補:

二. 為什麼是 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 會對應資料表。 idNone 當預設值,新增資料時還沒有 id,寫進 SQLite 後才會自動產生。 titleurl 加上長度限制,不是什麼華麗 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() 這裡是故意的。 新增成功後,我們希望頁面重新查詢列表,立刻看到新資料。 常見節奏是:

  1. 使用者填表單。
  2. submit 後檢查資料。
  3. 寫入資料庫。
  4. 顯示成功訊息。
  5. 必要時 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_lengthmax_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 做穩。 等使用者真的每天打開它,再決定要不要升級。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 78: 本文

相關文章

Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
Python SQLAlchemy 2.0 實戰:Typed ORM、Session 與查詢模式
·9 分鐘· loading · loading
Python SQLAlchemy ORM Database SQLite Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python Fastapi Streamlit Api Frontend Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools