問題現場
這是一個還在線上用的內部 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 |
驗證方式
- 設定 / DB:
tests/test_config_database.py驗證 Flask 設定與 DB 設定來源已分離。 - 路由 / notes:
tests/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:高風險重構不翻車
適用情境:要整理一個「正在用、不能停、不能改行為」的系統。
不適用情境:全新、尚無使用者的程式可直接設計,不需要這套保護。
前置條件
- 一組能描述「現有對外行為」的測試或規格(沒有就先補:把現有路徑、輸入、預期輸出寫成可跑的測試)。
- 版本控制乾淨、可隨時回到上一個綠燈提交。
步驟
- 固定行為:先把「對外行為不變」寫成一份可驗收的規格(路徑、URL prefix、輸入/輸出、相容性)——沒有這張,後面全是賭。
- 建立邊界:先抽出組裝層(app factory),讓測試不啟 dev server 也能建一個乾淨隔離的 app。
- 小步搬移:一次只搬一層(先設定、再 DB、再 routes、最後 services),每搬一步就立刻跑回歸。
- 隔離機密:DB / Vault 設定獨立成一份,測試用假連線,production 走真 Vault、local/test 留安全 fallback。
- 寫回決策:每個取捨跟「為什麼」都寫回規格,當作下一輪的記憶。
停損點
- 任一回歸測試變紅 → 停,先回到綠再繼續,不要帶著紅燈往下搬。
- 發現某段行為「沒有測試能保護」→ 停,先補測試,再動它。
驗收標準
- 對外行為零變動:路徑、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.py、tests/test_notes_and_routes.py、openspec/specs/flask-db-vault-architecture/spec.md。 - 未解:哪些 UI 截圖遮一遮可以公開;要不要跟其他內部 tracker 併成一個資料平台家族。
作者備註: 重構最虛榮的部分是「拆得多漂亮」,但真正會半夜把你叫醒的,是「拆的過程有沒有不小心把行為改掉」。所以我先把能驗收行為的規格寫好,再動手——有那張安全網,才敢放膽拆。