前一章已經透過固定案例確認目標功能的輸入、輸出、狀態變化與失敗結果。功能行為受到測試保護後,就能開始整理目標程式的內部結構,避免後續功能逐步取代遺留系統時,持續把新規則加入同一段大型流程。
大型流程的問題通常來自多種責任與變更原因混在一起。單一函式可能同時檢查輸入、取得相依資料、計算結果、修改保存狀態及組合輸出。任何一項規則改變,都需要重新理解整段程式,也容易在修改其中一部分時影響其他路徑。
本章會說明如何辨識責任、縮小變數作用域、抽取函式、改善命名及設定失敗邊界。拆分的目的,是讓每項規則與外部影響都有清楚位置,同時保持已確認的功能行為不變。
拆分會改變程式結構,因此開始前要先確認哪些行為不能跟著改變。前一章建立的特徵測試、單元測試或完整路徑案例,可以作為這次調整的安全範圍。測試至少要涵蓋本次會碰到的正常、拒絕與失敗路徑。
如果既有測試只檢查成功回傳值,還不能直接開始大幅拆分。大型流程可能在中途修改保存狀態、呼叫相依項目或留下部分結果,這些行為也需要納入觀察。開始前應該確認:
第一次拆分應該保持輸入輸出契約與處理順序不變。發現原有流程存在錯誤時,可以先用測試記錄目前行為,再將功能修正列為另一項有明確預期結果的修改。結構調整與功能調整分開進行,較容易判斷差異來自哪一次變更。
函式行數只能提醒程式可能過長,無法直接指出應該在哪裡拆分。較有效的做法是沿著執行順序,標示每一段程式讀取甚麼、產生甚麼,以及是否會影響函式外的狀態。
| 責任 | 可以觀察的程式特徵 | 拆分時需要確認的問題 |
|---|---|---|
| 輸入檢查 | 檢查欄位、型別、格式、範圍或必要內容。 | 不符合條件時要拒絕整次執行,還是只拒絕其中一項內容? |
| 功能規則 | 按照輸入與目前狀態進行判斷、計算或分類。 | 規則需要哪些資料,判斷順序是否會改變結果? |
| 資料存取 | 讀取或修改保存內容。 | 讀寫發生在甚麼時機,失敗時是否已經留下部分結果? |
| 外部互動 | 呼叫其他程式、取得外部結果或產生通知。 | 失敗、逾時或重複呼叫時,流程應該留下甚麼狀態? |
| 狀態變更 | 更新流程狀態、處理進度或可供後續使用的結果。 | 哪些變更必須一起成功,哪些可以分開處理? |
| 輸出轉換 | 將內部結果改成呼叫端需要的格式。 | 格式調整是否混入功能判斷,錯誤資訊是否仍可追溯? |
例如,下列函式只有幾行,卻同時包含相依資料讀取、功能計算、保存與輸出轉換:
async function processImport(input, dependencies) {
const rate = await dependencies.getRate(input.date)
const total = input.amount * rate
const saved = await dependencies.save({ total })
return { id: saved.id, total: saved.total }
}
這段程式的問題不在行數。換算規則、保存方式或輸出格式任一項改變,都會修改同一個函式。拆分前可以先標示各行的責任,再決定哪些責任具有獨立名稱與穩定的輸入輸出。
靜態分析(Static Analysis)、尋找參照與呼叫關係圖可以協助找出讀寫位置及相依方向。不過,工具只能顯示程式之間可能存在的關係。實際拆分前仍要回到已確認的入口、執行設定與測試案例,判斷哪些路徑目前真的會執行。
標示責任時,不必立刻決定類別或模組。可以先在大型函式中以空行分出幾個連續步驟,確認每個步驟的輸入、輸出與外部影響。邊界穩定後,再選擇合適的函式與資料結構。
大型函式常在開頭宣告所有變數,之後由不同分支逐步修改。讀者必須同時記住每個變數目前是否已經賦值、哪些區段可能改變它,以及後續是否仍會使用。開始抽取函式前,可以先把變數移到最接近實際用途的位置。
下列 normalizedCode 宣告在迴圈外,所有處理步驟都能讀取或修改它:
let normalizedCode = ''
for (const row of rows) {
normalizedCode = row.code.trim().toUpperCase()
accepted.push({ code: normalizedCode })
}
它只服務單次迴圈,因此可以限制在迴圈內:
for (const row of rows) {
const normalizedCode = row.code.trim().toUpperCase()
accepted.push({ code: normalizedCode })
}
縮小作用域時要檢查:
移動變數宣告本身也可能改變行為。例如,原本的值可能刻意跨迴圈保留,或某個分支需要前一次結果。因此每次調整後都要執行相關測試,不能只根據程式看起來等價就繼續下一步。
抽取方法(Extract Method)會把一段具有完整目的的程式移入新函式,並用名稱表達該段程式要完成的工作。適合抽取的區段通常具有清楚輸入與輸出,或具有需要獨立管理的外部影響。
例如,建立匯入內容的函式可能直接包含數量檢查與換算:
function buildImportRow(row, rate) {
const amount = Number(row.amount)
if (!Number.isFinite(amount) || amount < 0) {
return { reason: 'invalid_amount' }
}
return { code: row.code, amount: Math.round(amount * rate) }
}
如果這段規則會重複使用或獨立變更,可以抽成具有明確結果的函式:
function inspectAmount(value, rate) {
const amount = Number(value)
if (!Number.isFinite(amount) || amount < 0) {
return { reason: 'invalid_amount' }
}
return { value: Math.round(amount * rate) }
}
呼叫端只需要處理有效值或拒絕原因,不需要知道換算前有哪些檢查。這個函式也能用一般輸入直接測試,不必建立保存方式或其他相依項目。
不需要將每一行程式都抽成函式。上例值得抽取的前提,是數量檢查與換算共同形成一項功能規則。如果只有沒有其他意義的單次乘法,留在原本流程會更容易閱讀。
大型流程經常把許多獨立參數一路傳遞到後面,最後很難看出哪些值屬於同一階段。拆分後可以使用具有明確名稱的資料結構,讓每個步驟只接收所需內容。
例如,內容檢查完成後,可以建立一份準備保存的匯入計畫:
const importPlan = {
sourceId,
accepted,
rejected,
status: determineImportStatus(accepted, rejected),
}
importPlan 表示內容已經完成檢查與分類。保存方式不需要重新判斷內容是否有效,輸出轉換也不需要重新計算狀態。未來加入欄位時,可以先判斷它屬於原始輸入、匯入計畫、保存結果或呼叫端輸出,避免同一個鬆散物件在所有步驟之間任意增加內容。
資料結構的名稱應該表達功能意義。data、item、temp 或 result 無法說明內容目前處於哪個階段。像 importInput、inspectedRow、importPlan 與 savedImport 這類名稱,能讓讀者從名稱判斷資料是否已經檢查、是否可以保存,以及是否已取得保存後的識別資料。
專用資料結構不一定要建立類別。一般物件、不可變資料結構或語言提供的資料型別都可以使用,選擇標準是能否清楚限制欄位意義與使用階段。
抽取函式後,如果名稱仍是 processData、handleItem 或 doImport,讀者仍要進入實作才能理解差異。命名應該描述函式完成後可以確認的結果,避免只描述使用的語法或籠統動作。
| 含糊名稱 | 較明確的名稱 | 名稱表達的目的 |
|---|---|---|
check |
assertImportInput |
確認整次匯入的必要輸入,不符合時拋出錯誤。 |
processRows |
buildImportPlan |
將原始內容轉成可保存的匯入計畫。 |
handleRow |
inspectImportRow |
檢查並轉換單列內容,產生有效值或拒絕原因。 |
getStatus |
determineImportStatus |
按照有效與無效數量決定整體狀態。 |
format |
toImportResult |
將保存結果轉成呼叫端需要的摘要。 |
名稱也要符合實際副作用。名稱包含 get 或 calculate 的函式如果會修改保存狀態,容易讓呼叫端誤判使用成本與失敗風險。會產生修改的函式應該使用 save、record、update 或符合功能目的的動詞,純計算則可以使用 calculate、determine、build 或 to。
類別與組成項目也適用相同原則。名稱應該對應穩定責任,不要因為某個流程暫時過長,就把所有抽出的函式放入 CommonHelper 或 Utility。這類名稱會隱藏功能邊界,後續容易再次累積彼此無關的程式。
兩段程式外觀相似,不代表它們具有相同規則。不同功能都可能檢查數量大於零,但其中一項允許零值,另一項則要拒絕零值。直接共用同一個判斷,會把兩項規則綁在一起。
例如,下列兩項判斷只有比較符號不同,差異卻來自各自的功能規則:
const canImport = amount >= 0
const canRefund = amount > 0
抽取共用邏輯前要比較:
如果只有語法重複,可以暫時保留兩份獨立實作。等規則與變更原因都確認一致後再共用,通常比建立包含大量選項與條件分支的通用函式更容易維護。
函式拆小後,完整流程的失敗行為仍然要清楚。只關注每個小函式,很容易忽略多個步驟組合後可能留下的部分結果。流程協調函式需要明確安排讀取、計算、保存與外部互動的順序。
例如,主要流程可以先取得計算所需資料,完成規則判斷後再集中保存:
async function importFile(input, dependencies) {
const rate = await dependencies.getRate(input.date)
const importPlan = buildImportPlan(input, rate)
const savedImport = await dependencies.saveImport(importPlan)
return toImportResult(savedImport)
}
按照這個順序,相依資料取得失敗或規則計算失敗都發生在保存前。保存失敗時也不會回傳看似完成的結果。這段程式已假設 saveImport 能一次保存必要內容。如果實際保存方式無法提供相同範圍,就要明確記錄各步驟成功與失敗後的狀態,以及重新執行時如何避免重複結果。
流程包含不可重複的外部互動時,不能只把呼叫移到另一個函式就視為完成拆分。還要定義呼叫時機、失敗處理、重新嘗試條件與識別方式。如果保存完成後的外部互動失敗,流程也要能區分「尚未保存」與「已保存但外部互動未完成」,避免再次執行時重複修改狀態。
應用流程協調、功能規則、相依項目存取與輸出轉換可以各自具有清楚邊界,但不代表目標系統一定要建立四個固定層次。實際組織方式仍要按照流程複雜度、變更原因與目標架構決定。
拆分完成後,原有完整路徑案例仍要全部通過,確認呼叫順序、保存內容與輸出沒有改變。新增的小型函式則可以按照責任建立較精確的測試。
例如,抽出的狀態規則可以直接測試,不需要先執行完整匯入流程:
import { expect, test } from 'vitest'
import { determineImportStatus } from './import-plan.js'
test('部分內容被拒絕時回傳 partial', () => {
const status = determineImportStatus(2, 1)
expect(status).toBe('partial')
})
這項測試只能保護狀態判斷。完整流程仍需要另一組測試確認相依項目呼叫順序、保存內容與最後輸出。如果只保留小型函式測試,協調順序與外部影響仍可能在修改後出現差異。
測試數量也不必跟著函式數量增加。單純轉交資料、沒有獨立規則的私有函式,可以透過較高層級的結果間接驗證。功能規則、邊界條件與失敗處理則適合直接測試,讓失敗訊息能指出實際改變的行為。
小型函式不一定比較容易理解。拆分過度會讓讀者為了理解一條簡單規則,在多個檔案與只有一行內容的函式之間跳轉。完成一輪調整後,可以使用下列問題檢查結果:
如果抽取後需要傳入大量彼此無關的參數,可能表示責任邊界仍不清楚。如果每個函式都只包裝下一個函式,名稱也沒有增加功能意義,則可能已經拆分過度。合適的邊界應該讓讀者在主要流程看到順序,在規則函式看到判斷,在相依項目看到外部影響。