iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

如何拆分大型流程?

前一章已經透過固定案例確認目標功能的輸入、輸出、狀態變化與失敗結果。功能行為受到測試保護後,就能開始整理目標程式的內部結構,避免後續功能逐步取代遺留系統時,持續把新規則加入同一段大型流程。

大型流程的問題通常來自多種責任與變更原因混在一起。單一函式可能同時檢查輸入、取得相依資料、計算結果、修改保存狀態及組合輸出。任何一項規則改變,都需要重新理解整段程式,也容易在修改其中一部分時影響其他路徑。

本章會說明如何辨識責任、縮小變數作用域、抽取函式、改善命名及設定失敗邊界。拆分的目的,是讓每項規則與外部影響都有清楚位置,同時保持已確認的功能行為不變。

先用已驗證行為保護拆分範圍

拆分會改變程式結構,因此開始前要先確認哪些行為不能跟著改變。前一章建立的特徵測試、單元測試或完整路徑案例,可以作為這次調整的安全範圍。測試至少要涵蓋本次會碰到的正常、拒絕與失敗路徑。

如果既有測試只檢查成功回傳值,還不能直接開始大幅拆分。大型流程可能在中途修改保存狀態、呼叫相依項目或留下部分結果,這些行為也需要納入觀察。開始前應該確認:

  • 測試會使用固定輸入與初始狀態,並且檢查主要輸出內容。
  • 測試會確認保存狀態的變化時機與實際內容。
  • 測試會涵蓋缺少內容、邊界值、重複執行及相依項目失敗等主要路徑。
  • 測試會記錄已核准的功能調整,避免把預定差異誤判為拆分造成的問題。
  • 測試失敗時,結果能指出哪一項功能行為改變,並提供比「整段流程無法執行」更具體的資訊。

第一次拆分應該保持輸入輸出契約與處理順序不變。發現原有流程存在錯誤時,可以先用測試記錄目前行為,再將功能修正列為另一項有明確預期結果的修改。結構調整與功能調整分開進行,較容易判斷差異來自哪一次變更。

從資料變化與外部影響辨識責任

函式行數只能提醒程式可能過長,無法直接指出應該在哪裡拆分。較有效的做法是沿著執行順序,標示每一段程式讀取甚麼、產生甚麼,以及是否會影響函式外的狀態。

責任 可以觀察的程式特徵 拆分時需要確認的問題
輸入檢查 檢查欄位、型別、格式、範圍或必要內容。 不符合條件時要拒絕整次執行,還是只拒絕其中一項內容?
功能規則 按照輸入與目前狀態進行判斷、計算或分類。 規則需要哪些資料,判斷順序是否會改變結果?
資料存取 讀取或修改保存內容。 讀寫發生在甚麼時機,失敗時是否已經留下部分結果?
外部互動 呼叫其他程式、取得外部結果或產生通知。 失敗、逾時或重複呼叫時,流程應該留下甚麼狀態?
狀態變更 更新流程狀態、處理進度或可供後續使用的結果。 哪些變更必須一起成功,哪些可以分開處理?
輸出轉換 將內部結果改成呼叫端需要的格式。 格式調整是否混入功能判斷,錯誤資訊是否仍可追溯?

例如,下列函式只有幾行,卻同時包含相依資料讀取、功能計算、保存與輸出轉換:

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 表示內容已經完成檢查與分類。保存方式不需要重新判斷內容是否有效,輸出轉換也不需要重新計算狀態。未來加入欄位時,可以先判斷它屬於原始輸入、匯入計畫、保存結果或呼叫端輸出,避免同一個鬆散物件在所有步驟之間任意增加內容。

資料結構的名稱應該表達功能意義。dataitemtempresult 無法說明內容目前處於哪個階段。像 importInputinspectedRowimportPlansavedImport 這類名稱,能讓讀者從名稱判斷資料是否已經檢查、是否可以保存,以及是否已取得保存後的識別資料。

專用資料結構不一定要建立類別。一般物件、不可變資料結構或語言提供的資料型別都可以使用,選擇標準是能否清楚限制欄位意義與使用階段。

讓名稱表達目的與副作用

抽取函式後,如果名稱仍是 processDatahandleItemdoImport,讀者仍要進入實作才能理解差異。命名應該描述函式完成後可以確認的結果,避免只描述使用的語法或籠統動作。

含糊名稱 較明確的名稱 名稱表達的目的
check assertImportInput 確認整次匯入的必要輸入,不符合時拋出錯誤。
processRows buildImportPlan 將原始內容轉成可保存的匯入計畫。
handleRow inspectImportRow 檢查並轉換單列內容,產生有效值或拒絕原因。
getStatus determineImportStatus 按照有效與無效數量決定整體狀態。
format toImportResult 將保存結果轉成呼叫端需要的摘要。

名稱也要符合實際副作用。名稱包含 getcalculate 的函式如果會修改保存狀態,容易讓呼叫端誤判使用成本與失敗風險。會產生修改的函式應該使用 saverecordupdate 或符合功能目的的動詞,純計算則可以使用 calculatedeterminebuildto

類別與組成項目也適用相同原則。名稱應該對應穩定責任,不要因為某個流程暫時過長,就把所有抽出的函式放入 CommonHelperUtility。這類名稱會隱藏功能邊界,後續容易再次累積彼此無關的程式。

先確認語意相同,再抽取重複程式碼

兩段程式外觀相似,不代表它們具有相同規則。不同功能都可能檢查數量大於零,但其中一項允許零值,另一項則要拒絕零值。直接共用同一個判斷,會把兩項規則綁在一起。

例如,下列兩項判斷只有比較符號不同,差異卻來自各自的功能規則:

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')
})

這項測試只能保護狀態判斷。完整流程仍需要另一組測試確認相依項目呼叫順序、保存內容與最後輸出。如果只保留小型函式測試,協調順序與外部影響仍可能在修改後出現差異。

測試數量也不必跟著函式數量增加。單純轉交資料、沒有獨立規則的私有函式,可以透過較高層級的結果間接驗證。功能規則、邊界條件與失敗處理則適合直接測試,讓失敗訊息能指出實際改變的行為。

判斷是否已經拆到合適程度

小型函式不一定比較容易理解。拆分過度會讓讀者為了理解一條簡單規則,在多個檔案與只有一行內容的函式之間跳轉。完成一輪調整後,可以使用下列問題檢查結果:

  • 流程協調函式是否能按照執行順序讀出主要步驟與失敗位置?
  • 每個抽取函式是否都有可描述的目的、清楚輸入及可確認結果?
  • 功能規則是否和資料存取、外部互動及輸出格式分開?
  • 區域變數是否只存在於實際需要它們的最小範圍?
  • 不同步驟是否透過具有功能意義的資料結構交換內容?
  • 函式名稱是否能說明目的與副作用,不需要先閱讀實作才能分辨?
  • 共用程式碼是否真的具有相同語意、邊界與變更原因?
  • 保存範圍、部分失敗與重新執行結果是否已經明確定義?
  • 原有完整路徑案例與拆分後的規則測試是否都能重複通過?

如果抽取後需要傳入大量彼此無關的參數,可能表示責任邊界仍不清楚。如果每個函式都只包裝下一個函式,名稱也沒有增加功能意義,則可能已經拆分過度。合適的邊界應該讓讀者在主要流程看到順序,在規則函式看到判斷,在相依項目看到外部影響。

重點整理

  • 拆分大型流程前,要先用測試固定輸入、輸出、狀態變化與主要失敗路徑,並且把功能修正和結構調整分開進行。
  • 函式邊界應該從輸入檢查、功能規則、資料存取、外部互動、狀態變更及輸出轉換等責任辨識,不能只按照行數決定。
  • 縮小變數作用域並使用具有功能意義的資料結構,可以限制資料被修改的位置,讓不同步驟的契約更清楚。
  • 抽取方法要讓函式名稱表達完整目的與副作用,流程協調函式則保留主要執行順序與失敗位置。
  • 外觀相似的程式要先確認功能目的、邊界條件與變更原因一致,才能抽取為共用邏輯。
  • 拆分後仍要明確安排保存、外部互動、部分失敗與重新執行的處理方式,不能因為函式變小就忽略完整流程。
  • 原有完整路徑案例要確認整體行為不變,小型規則測試則協助快速定位計算、分類與邊界條件的差異。

上一篇
[Day 15] 如何驗證功能是否正確?
下一篇
[Day 17] 甚麼時候需要撰寫註解?
系列文
遠古聖遺物改造工程:遺留系統全面重構實務指南19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言