← 回作品列表

QA 資料平台重構

Internal QA Flask Platform Refactor

在不改變對外行為的前提下,將單檔 Flask QA 平台重構為 app factory + route/service 分層 + 集中式 DB/Vault 設定,並以 OpenSpec 固定可驗收的「行為不變」標準。

data-platform Flaskapp factory重構VaultOpenSpec 原始碼:私有

問題現場

這是一個還在線上用的內部 QA 平台。功能本身沒問題,問題在結構——整個 Flask 應用擠在一支大檔裡,資料庫連線、Vault 機密、網頁路由、樣板、請求紀錄全攪在一起——像把全部家當塞進同一個抽屜,要拿哪一樣都得先撥開另外四樣。

平常相安無事,要動它的時候才知道痛:

  • 維護者:要改一個小功能,得在一支巨檔裡穿過 DB、Vault、route、template 才找得到,動一處很容易波及看不見的另一處。
  • 想引入 AI 協作:風險更高——AI 在這種高耦合結構裡修改,幾乎一定會踩到隱性相依。
  • 約束:這是正在使用中的內部平台,不能因為「整理程式」而改變任何對外行為

最小技術背景

  • Flask:用 Python 寫網站後端的常見框架。
  • app factory:用一個 create_app() 函式來「組裝」應用,而不是在 import 時就把所有東西初始化;好處是測試時能乾淨地建立隔離的 app。
  • Vault:集中管理機密(如 DB 帳密)的服務;程式以 AppRole 之類的身分去取,而不是把帳密寫在設定裡。
  • behavior-preserving refactor:只整理內部結構、對外行為完全不變的重構。
  • OpenSpec:把「做完要符合哪些條件」寫成版本化規格,當作驗收依據與專案記憶。

重構前後結構

重構前(單檔)                      重構後(分層)
app.py  ── 全部擠在這             app.py            ── thin entry,委派 create_app()
  ├ DB 連線                       src/app_factory.py ── 組裝 config / DB / routes / logging
  ├ Vault 讀取                    src/config.py      ── Flask-only 設定
  ├ routes                        src/db_config.py   ── DB / Vault 設定(獨立來源)
  ├ templates 邏輯               src/database.py    ── 連線 / 交易 / SQL helper
  └ request logging              src/routes/*.py    ── 只處理請求解析與回應
                                  src/services/*.py  ── 查詢、彙整、notes、logging

重構後的相依關係(routes 只接 I/O,邏輯下沉到 services,機密設定獨立):

flowchart TD
  ENTRY["app.py|thin entry"] --> FAC["create_app|app_factory"]
  FAC --> CFG["config|Flask-only 設定"]
  FAC --> DBC["db_config|DB / Vault 設定"]
  FAC --> RT["routes|只接 I/O"]
  FAC --> LOG["request logging"]
  DBC --> DB["database|連線 / 交易 / SQL"]
  RT --> SVC["services|查詢 / 彙整 / notes"]
  SVC --> DB

重構過程本身是一條「行為不變」的流程,每搬一步都要先通過回歸測試:

flowchart TD
  S1["寫下行為不變規格|OpenSpec"] --> S2["抽 app factory 建立測試邊界"]
  S2 --> S3["分層搬移一步"]
  S3 --> REG{"回歸測試全綠?"}
  REG -->|否| FIX["回到綠再繼續"]
  FIX --> S3
  REG -->|是| S4["機密改走集中設定 + fallback"]
  S4 --> S5["決策寫回規格"]

關鍵設計決策(含取捨)

決策選了什麼否決的替代為什麼
第一約束先保證行為不變,再拆結構重構同時順手加新功能否決:行為跟結構一起變,出事根本分不清是誰害的
整體做法抽 app factory,分階段拆打掉重寫整個 app否決:打掉重寫的 regression 風險太高,也保不住行為相容
分層route 只接 I/O,service 扛邏輯維持 route 內含商業邏輯否決:route 一肥,測試跟 AI 改它的邊界就糊掉了
機密支援 production Vault,且 local/test 有安全 fallback測試也連 live Vault否決:測試不該依賴、更不該碰到真的機密

代表性技術證據

app factory(pseudo-code,示意)

def create_app(config=None):
    app = Flask(__name__)
    app.config.from_object(config or load_app_config())   # 只放 Flask 自己的設定
    init_db(app, load_db_config())                         # DB / Vault 的設定,獨立成另一份來源
    register_routes(app)                                   # routes 只負責收發,不放邏輯
    install_request_logging(app)
    return app

測試隔離:不碰 production(pseudo-code,示意)

def test_overview_route(monkeypatch):
    monkeypatch.setattr("src.database.connect", fake_conn)  # 給它一條假連線,真 DB 碰都不碰
    app = create_app(TestConfig)                            # 把 app 建起來,但不真的啟 dev server
    resp = app.test_client().get("/overview")
    assert resp.status_code == 200                          # 驗的是行為,不是它內部怎麼寫

踩過的坑

欄位內容
症狀重構後某頁回 404 / note 更新沒生效
初判(猜錯)第一個念頭是某個 route handler 忘了搬
驗證比對 URL prefix 與 blueprint 註冊,handler 其實都在
根因拆 blueprint 時,url_prefix 的註冊位置變了,對外路徑跟著變
修法在 OpenSpec 固定 /overview/analysis/api/update_note 與 prefix 行為,補測試比對
預防behavior-preserving regression 測試覆蓋這幾條路徑與 prefix

驗證方式

  • 設定 / DBtests/test_config_database.py 驗證 Flask 設定與 DB 設定來源已分離。
  • 路由 / notestests/test_notes_and_routes.py 驗證對外路徑與更新行為相容。
  • 可建置性:app factory 能在 smoke check 裡建立 app 而不啟 dev server。
  • 驗收標準(OpenSpec)/overview/analysis/api/update_note 行為與 URL prefix 全部不變;DB 帳密不出現在 Flask-only 設定;測試不依賴 live Vault。

可複製 SOP:高風險重構不翻車

適用情境:要整理一個「正在用、不能停、不能改行為」的系統。

不適用情境:全新、尚無使用者的程式可直接設計,不需要這套保護。

前置條件

  • 一組能描述「現有對外行為」的測試或規格(沒有就先補:把現有路徑、輸入、預期輸出寫成可跑的測試)。
  • 版本控制乾淨、可隨時回到上一個綠燈提交。

步驟

  1. 固定行為:先把「對外行為不變」寫成一份可驗收的規格(路徑、URL prefix、輸入/輸出、相容性)——沒有這張,後面全是賭。
  2. 建立邊界:先抽出組裝層(app factory),讓測試不啟 dev server 也能建一個乾淨隔離的 app。
  3. 小步搬移:一次只搬一層(先設定、再 DB、再 routes、最後 services),每搬一步就立刻跑回歸。
  4. 隔離機密:DB / Vault 設定獨立成一份,測試用假連線,production 走真 Vault、local/test 留安全 fallback。
  5. 寫回決策:每個取捨跟「為什麼」都寫回規格,當作下一輪的記憶。

停損點

  • 任一回歸測試變紅 → 停,先回到綠再繼續,不要帶著紅燈往下搬。
  • 發現某段行為「沒有測試能保護」→ 停,先補測試,再動它。

驗收標準

  • 對外行為零變動:路徑、URL prefix、輸入/輸出與重構前一致。
  • DB 帳密不出現在 Flask-only 設定。
  • 測試不依賴 live Vault / production DB。
  • 新增的每一層都有測試覆蓋。

回滾:以「行為規格 + 回歸測試全綠」的提交為還原點,必要時 revert 到該提交。

可複製到:任何單檔肥大、需在生產約束下整理的內部服務。

決策回放(三個月後回讀用)

  • 背景:內部 QA 平台耦合過高,維護與 AI 協作風險大,但不能停機、不能改行為。
  • 限制:不能改既有 route 行為與 URL prefix;測試不可依賴 production DB / live Vault;不可外流真實 QA 資料與機密。
  • 考慮過但放棄:本來想直接打掉重寫整個 app——被 regression 風險否決。
  • 證據tests/test_config_database.pytests/test_notes_and_routes.pyopenspec/specs/flask-db-vault-architecture/spec.md
  • 未解:哪些 UI 截圖遮一遮可以公開;要不要跟其他內部 tracker 併成一個資料平台家族。

作者備註: 重構最虛榮的部分是「拆得多漂亮」,但真正會半夜把你叫醒的,是「拆的過程有沒有不小心把行為改掉」。所以我先把能驗收行為的規格寫好,再動手——有那張安全網,才敢放膽拆。