iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

遠古聖遺物改造工程:遺留系統全面重構實務指南系列 第 12

[Day 12] 程式碼應該遵循哪些規範?

  • 分享至 

  • xImage
  •  

程式碼應該遵循哪些規範?

前面已經選定目標系統使用的技術,固定開發環境與相依版本,也確認框架及共用元件的責任。開始實作功能前,還要把這些決定轉成全體開發人員都能遵循的程式碼規範。否則相同技術仍可能因命名、錯誤處理、模組相依或測試方式不同,逐漸形成多套互不相容的寫法。

程式碼規範的目的,是讓程式的結構與意圖容易判斷,並且在變更進入共同版本前找出可以預防的問題。格式等機械性規則適合交由工具處理,責任邊界與抽象範圍則需要透過文件、範例及程式碼審查確認。兩者缺一不可。

先定義規範的範圍與強度

規範不應該只列出「命名要清楚」或「程式碼要整齊」等原則。每一條規則都要能回答適用範圍、判斷方式及違反後的處理方法。可以按照下列類型整理:

規則類型 適合處理的內容 落實方式
自動修正 縮排、空白、換行、引號、匯入順序等不需要人工判斷的格式 使用固定版本的格式化工具統一修改
自動檢查 未使用項目、不可到達的程式碼、型別問題、危險語法及明確禁止的相依方向 由程式碼檢查工具與靜態分析工具阻擋違規變更
人工判斷 命名是否表達意圖、模組責任是否合理、抽象是否過度及錯誤資訊是否足夠 提供判斷原則與正反例,在程式碼審查時確認
條件規則 只適用特定語言、框架、產生檔案或整合邊界的要求 明確標示適用範圍,不擴大成所有程式都要遵守的限制

規範的優先順序也要清楚。程式語言及框架的官方慣例可以作為起點,再加入目標系統確實需要的限制。不同工具如果對同一格式提出衝突要求,應該選定單一結果並停用重複規則,避免開發人員反覆修改仍無法通過檢查。

規範文件至少要記錄規則內容、採用原因、檢查方式、正反例及例外程序。只保存工具設定,無法說明架構類規則的判斷理由。只有文字文件而沒有對應設定,也容易讓可以自動處理的問題持續進入程式碼。

讓規則可以被引用與維護

每一條規則可以設定固定識別碼、適用範圍、強制程度、檢查方式、維護人員及重新檢查條件。程式碼檢查結果、審查意見與例外紀錄就能引用同一條規則,不必依賴容易改變的標題或段落位置。規則文字修訂後,識別碼仍應該保持不變,讓過去的紀錄可以繼續追蹤。

例如,ESLint 的 no-undef 是用來找出未宣告名稱的常見規則識別碼。下列 TypeScript 程式使用了尚未宣告的 recordCount

export function calculateRecordCount(records: unknown[]) {
    return recordCount + records.length;
}

如果啟用 no-undef,檢查結果會在訊息最後標示規則識別碼:

src/records/calculate-record-count.ts
  2:12  error  'recordCount' is not defined  no-undef

設定檔、檢查結果、程式碼審查意見與例外紀錄都可以引用 no-undef,讓開發人員直接找到對應規則。TypeScript 編譯器本身已經能檢查未宣告名稱,因此只包含 TypeScript 的專案通常不需要重複啟用 no-undef。同時包含 JavaScript 與 TypeScript 時,可以只對 JavaScript 啟用這項規則,TypeScript 則使用編譯器的檢查結果。

強制程度可以區分為阻擋、警告與建議。阻擋規則必須在變更進入共同版本前處理,警告需要確認風險或留下例外紀錄,建議則提供改善方向,不應該因個人偏好阻止變更。維護責任可以指定給角色或程式範圍,不必綁定單一人員。

用命名表達責任與狀態

名稱要讓讀者在不開啟實作內容的情況下,大致判斷一個項目的用途。具體大小寫、字首或檔名格式應該依照已選定語言及框架的慣例決定,本章不指定跨語言都必須相同的寫法。含糊名稱、相似拼字、隱藏的狀態變更及責任過多的函式,都會增加理解與修改成本。

命名規則

不同技術對變數、函式、類別、介面及檔案名稱有不同慣例。專案應該按照已選定語言與框架的慣例,分別定義變數、函式、類別、介面及檔案使用的格式。同一類項目要維持一致,不要因個人偏好混用多種方式。如果系統使用資料庫,還要定義資料庫物件的命名方式,並考量資料模型用詞及遷移工具需要的穩定性。

縮寫、底線或其他字首只有在專案中定義清楚、沒有歧義,而且符合所選技術慣例時才適合使用。型別、存取範圍或其他可以由開發工具直接顯示的資訊,通常不需要重複編入名稱。

常見格式包括:

駝峰式命名法(camelCase)

將第一個單字以小寫開頭,後續單字的第一個字母改為大寫。

setValue, getValue, convertValue...

帕斯卡命名法(PascalCase)

將每個單字的第一個字母改為大寫。

BasicComponent, CommonInterface, AbstractClass...

蛇形命名法(snake_case)

將單字全部改為小寫,並使用底線分隔。

set_value, get_value, convert_value

連字號命名法(kebab-case)

將單字全部改為小寫,並使用連字號分隔。

basic-component, common-interface, abstract-class

連字號命名法不適用於程式語言中的變數、函式、類別、介面等識別字命名。多數程式語言會將 - 解讀為減法運算子,或不允許它出現在識別字中。只有在所選技術明確支援時,才適合用於檔名、設定鍵、路徑片段或其他非程式語言識別內容。

名稱涵義

格式一致只能讓名稱的外觀統一,名稱本身仍要表達用途、責任或狀態。下列範例中,較清楚的名稱讓讀者可以區分原始內容、處理後的結果及執行的動作:

不應該使用的命名

  • 意義模糊
    • datatextdoThisconvertData...
    • 意義模糊的變數增加程式的理解難度。
  • 未定義的縮寫
    • abcrst...
    • 沒有明確定義,無法理解變數代表的意義與作用。
  • 相同類型的變數,使用數字遞增命名
    • data1data2data3...
    • 相同命名會讓團隊成員難以確定變數的具體功能、影響範圍、狀態。
  • 使用不同字詞表達相同概念
    • addincrease...
    • 如果沒有明確定義不同字詞的差異,將會導致使用時產生混亂,並且造成理解的困難。

應該使用的命名

  • 意義清晰
    • sourceRecordnormalizedRecordnormalizeSourceRecord
    • 讓名稱分別表達原始內容、處理結果及執行動作。
  • 具有明確定義或已有共識的縮寫
    • 在範圍很小的迴圈中,可以依照所選語言的慣例使用 i 表示索引。離開該範圍後,應該改用 index 或其他能說明用途的名稱。
  • 使用一致詞彙表達相同概念
    • addSourceRecordremoveSourceRecordfindSourceRecord
    • SourceRecord 統一表示相同概念,再使用不同動詞說明實際動作。
  • 讓布林值表達可判斷的狀態
    • isFormatValidhasRequiredValuecanRetry
    • 使用 ishascan 等字首,讓讀者可以預期內容為肯定或否定的判斷結果。
  • 區分單一項目與集合
    • sourceRecordsourceRecords
    • 透過單複數區分一筆內容與多筆內容,降低傳入或傳回內容遭到誤判的可能性。

清楚的名稱不一定最長。判斷重點是名稱能否在目前範圍內提供足夠資訊,並讓相同概念在不同位置維持一致。

讓名稱、作用範圍與實際行為一致

名稱、作用範圍與實際行為要一起判斷。讀者應該能從名稱與公開介面看出資料從哪裡進入、在哪裡改變,以及函式是否會更新狀態。隱藏的資料來源或狀態變更會增加理解與修改風險。

變數與函式還要遵守下列原則:

  • 變數或函式參數的意義或資料性質改變時建立新名稱,不要把原始內容、轉換結果及其他不同階段的值反覆寫回同一個名稱。
  • 內層作用範圍不要使用與外層相同的變數名稱,避免讀者誤判目前讀取或修改的對象。
  • 避免讓功能程式直接讀寫全域可變狀態。需要的內容應該透過明確參數或已定義的互動邊界取得,狀態變更也要集中在可以追蹤的位置。
  • 如果所選語言或框架確實需要全域狀態,應該限制可寫入的位置、定義建立與重設時機,並為同時執行及測試隔離建立檢查方式。
  • 名稱以 ishasfindcheck 表達查詢或判斷時,不要加入名稱沒有表達的狀態變更。如果確實需要修改狀態,應該拆分責任或讓介面清楚呈現副作用(Side Effect)。
  • 函式只承擔名稱所表達的主要責任。例如,驗證函式不應該同時改寫輸入、產生操作提示及要求重新輸入。
  • 傳回內容要符合名稱、型別定義及專案慣例,避免看似回傳判斷結果的函式突然傳回結構完全不同的物件。

部分語言的程式碼檢查工具可以找出變數名稱遮蔽或未使用結果,但工具無法判斷所有名稱與行為是否一致。這類規則仍要搭配正反例及程式碼審查。

定義資料擁有權與修改方式

函式接收可變資料時,規範要說明能否直接修改傳入內容、傳回結果是否與原內容共用狀態,以及哪一方負責後續變更。如果沒有明確約定,呼叫端可能以為原內容不會改變,卻在其他位置觀察到非預期結果。

例如,下列函式建立新物件,不會修改呼叫端傳入的內容:

function renameRecord(record, name) {
    return {
        ...record,
        name,
    };
}

const sourceRecord = { id: 1, name: "Original" };
const renamedRecord = renameRecord(sourceRecord, "Updated");

專案不一定要禁止所有原地修改。確實需要修改傳入內容時,名稱、型別或公開介面應該清楚表達這項行為,並限制可以修改的位置。跨模組傳遞的可變資料也要定義擁有者,避免多個位置在沒有協調的情況下更新相同狀態。

讓固定值與控制流程容易判斷

程式行數較少不代表更容易維護。巢狀條件、過度壓縮的運算式或不常見語法,可能要求讀者同時追蹤多個分支及狀態。固定值、條件分支、迴圈與遞迴都需要清楚表達用途、邊界及結束條件,避免必須逐行模擬才能確認結果。

為固定值補上名稱與來源

魔術數字(Magic Number)是直接出現在程式中,但名稱與上下文不足以說明用途的數值。例如,重試次數、長度限制或狀態代碼直接寫成 320 或其他數值時,讀者可能無法判斷它們代表的規則,也不知道多個相同數值是否需要一起修改。

let connection = null;
let connectionAttemptCount = 0;
while (
    (connection === null || !connection.ready) &&
    connectionAttemptCount < 3 // 3 是甚麼?
) {
    connection = new Connection();
    connectionAttemptCount++;
}

會表達功能規則或可能調整的數值,應該改用名稱清楚的常數、列舉值或設定欄位,並記錄單位及適用範圍。如果數值來自外部規格,還要保留規格位置或轉換規則。環境之間可能不同的內容,則應該由已定義的設定方式提供,不要散布在各個函式中。

let connection = null;
let connectionAttemptCount = 0;

const maxConnectionAttempts = 3;

while (
    (connection === null || !connection.ready) &&
    connectionAttemptCount < maxConnectionAttempts
) {
    connection = new Connection();
    connectionAttemptCount++;
}

maxConnectionAttempts 表示最多嘗試三次,而且包含第一次建立連線。如果規則要表達首次失敗後還能重試三次,名稱與判斷方式就要改成最多四次嘗試,避免把「嘗試次數」與「重試次數」視為相同概念。

不需要為所有數值建立常數。迴圈起始值、空集合長度或演算法中意義明確的 01,如果上下文已經能直接說明用途,可以保留原值。判斷重點是修改數值時,是否能知道原因、影響範圍及需要同步調整的位置。

// 從陣列最後一個元素開始,直到陣列第一個元素
// 這裡的 0 與 1 都具有明確定義,因此可以不需要額外說明
for (let i = array.length() - 1; i >= 0; i--) {
    array[i];
}

降低條件與迴圈的巢狀層級

if (a === b) {
    if (a !== c && (c === d || c === e)) {
        handleFirstCase();
    }

    if (b === c && c !== d) {
        handleSecondCase();
    }
}

多層 if-else 與迴圈會增加同時需要追蹤的條件、狀態及離開方式。可以先處理無效輸入與不符合條件的情況,讓主要流程留在較外層。重複或具有獨立目的的判斷與迴圈內容,也可以抽成名稱清楚的函式,讓呼叫位置直接表達步驟。

if (a !== b) return;

if (a !== c && (c === d || c === e)) {
    handleFirstCase();
}

if (b === c && c !== d) {
    handleSecondCase();
}

如果多個 if-else 分支都在比較同一個值,而且選項固定、互斥,所選語言支援的 switchmatch 或相同用途的結構可能更容易閱讀。條件包含範圍、複合判斷、執行順序或不同副作用時,改用這些結構未必更清楚。此時應該拆分判斷責任,或使用能直接表達規則的對映與決策結構。

let a = random(0, 5);

switch (a) {
    case 0: /* 不同狀態下的處理流程 */ break;
    case 1: break;
    case 2: break;
    case 3: break;
    case 4: break;
    case 5: break;
    default: /* 不符合上述狀態時的處理流程 */ break;
}

巢狀層級沒有適用所有程式的固定上限。規範可以搭配程式碼檢查工具設定複雜度門檻,但仍要由實際流程確認拆分後沒有改變執行順序、提早結束條件或狀態變更。

限制遞迴的適用範圍

function traverse(node) {
    if (!node) return;

    // 進行處理
    console.log(node.value);

    if (node.left) traverse(node.left);

    if (node.right) traverse(node.right);

    return;
}

遞迴適合表達可重複分解,而且具有明確終止條件的結構。使用時要定義每次呼叫如何縮小問題、何時停止,以及最大深度是否可能超過執行環境可以承受的範圍。如果輸入可能形成循環關係,還要記錄已處理項目或採用其他方式防止重複進入相同節點。

處理深度無法預估,或流程可以用迴圈清楚表達時,應該優先使用顯式堆疊(Stack)保存待處理項目。程式可以自行決定加入、取出與停止條件,不必依賴函式呼叫堆疊,也比較容易限制處理數量、記錄已處理項目及在必要時中止。

function traverse(node) {
    if (!node) return;

    const stack = [node];
    const visited = [];

    while (stack.length > 0) {
        const current = stack.pop();

        if (visited.includes(current)) continue;

        // 進行處理
        console.log(current.value);

        visited.push(current);

        if (current.right) stack.push(current.right);
        if (current.left) stack.push(current.left);
    }

    return;
}

使用顯式堆疊時,要定義項目加入順序、取出順序、最大待處理數量及循環關係的處理方式,並確認執行順序是否和原本遞迴一致。確定保留遞迴時,則要測試空內容、最小內容、最大預期深度、終止條件不成立時的保護方式,以及循環關係等情況。

統一格式與註解

格式規則應該由格式化工具產生唯一結果。專案需要保存工具版本與設定,並提供共同指令,讓本機開發和自動化流程使用相同方式檢查。開發人員不需要在程式碼審查中反覆討論空白、換行或括號位置。

除了基本格式,還要定義下列項目:

  • 匯入項目的分組與排序交由工具處理,並移除未使用或重複的匯入內容。
  • 註解用來說明限制、取捨、外部格式或不直觀的原因,不要逐行重述程式已經清楚表達的動作。
  • 暫時處理方式要記錄移除條件或追蹤位置,不要只留下沒有時間、原因及後續動作的待辦註解。

例如,JavaScript 專案可以使用 JSDoc 說明較複雜函式的用途與公開介面:

/**
 * 篩選符合條件的紀錄,轉換數值精度後依識別碼排序。
 *
 * 函式會建立新陣列,不會改變呼叫端傳入的內容。
 *
 * @param {Object[]} records - 待處理的紀錄。
 * @param {number} records[].id - 紀錄識別碼。
 * @param {number} records[].value - 需要整理的數值。
 * @param {number} [minimumValue=0] - 納入結果的最小數值。
 * @returns {Object[]} 依識別碼排序的新紀錄陣列。
 * @throws {TypeError} records 不是陣列時拋出。
 */
function normalizeRecords(records, minimumValue = 0) {
    if (!Array.isArray(records)) {
        throw new TypeError("records 必須是陣列");
    }

    return records
        .filter((record) => {
            return (
                Number.isFinite(record?.id) &&
                Number.isFinite(record?.value) &&
                record.value >= minimumValue
            );
        })
        .map((record) => ({
            id: record.id,
            value: Math.round(record.value * 100) / 100,
        }))
        .sort((left, right) => left.id - right.id);
}

公開介面如果需要文件,應該說明使用條件、輸入限制、結果、可能失敗方式及可觀察的狀態變化。文件內容要和實作一起修改。長期與實作不一致的註解,比沒有註解更容易造成錯誤判斷。

檔案組織

檔案及目錄應該按照功能或責任組織,不因個人習慣任意建立另一套分類方式。自動產生的檔案要放在可辨識的範圍,記錄產生來源及更新方式,避免和人工維護內容混在一起。

例如,使用 JavaScript 的專案可以依照功能責任分組,並分開保存測試、自動產生內容及開發工具:

project/
├── src/
│   ├── records/
│   │   ├── normalize-record.js
│   │   └── validate-record.js
│   ├── reports/
│   │   └── create-report.js
│   └── main.js
├── tests/
│   ├── records/
│   │   ├── normalize-record.test.js
│   │   └── validate-record.test.js
│   └── reports/
│       └── create-report.test.js
├── generated/
│   └── types.js
├── tools/
│   └── check-code.js
├── package.json
└── README.md

明確劃分模組責任與相依方向

程式碼規範需要記錄目標系統實際採用的模組邊界,不能只要求「降低耦合」。每個模組至少要說明主要責任、公開介面、允許依賴的對象,以及哪些內容不得由其他模組直接使用。

相依規則可以從下列原則建立:

  • 功能規則由對應模組負責,輸入輸出格式或特定技術的轉換留在相關邊界,避免技術細節散布到功能程式。
  • 共用元件只能依賴比自己更基礎且責任穩定的項目,不得反向依賴使用它的特定功能。
  • 模組之間透過已定義的公開介面互動,不直接讀取其他模組的內部檔案或可變狀態。
  • 相依方向應該可以形成清楚路徑,出現循環相依時要重新劃分責任,不能靠調整載入順序掩蓋問題。
  • 如果系統包含畫面、資料保存或外部互動,功能程式不應直接依賴只能在該技術中使用的細節。必要轉換由邊界元件負責。
  • 框架生命週期、依賴注入及共同處理的使用方式要沿用前面已確認的架構,不在個別功能中另建替代路徑。

專案不一定要採用特定分層模式。重點是相依規則符合實際設計,而且能透過目錄限制、模組系統、架構測試或靜態分析檢查。只有架構圖而沒有檢查方式,程式結構仍可能在日常修改中逐漸偏離設計。

管理公開介面的變更

模組公開介面的參數、傳回內容、錯誤與狀態變更一旦被其他程式使用,就形成需要維護的約定。變更介面前要確認呼叫位置、相容範圍及移除舊介面的條件,避免只修改提供端,讓其他程式在執行時才發現不相容。

如果呼叫位置無法同時修改,可以暫時保留舊介面,清楚標示替代方式與移除條件:

function findRecord(source, { id }) {
    return source.findById(id);
}

/**
 * @deprecated 改用 findRecord(source, { id })。
 */
function findRecordById(source, id) {
    return findRecord(source, { id });
}

如果所有呼叫位置能在同一次變更中完成調整,就應該一起更新並移除舊介面,不必為未確認的需求永久保留相容層。確實需要過渡期時,則要透過程式碼檢查、待辦項目或修訂紀錄追蹤剩餘使用位置。

統一輸入驗證、錯誤、執行紀錄與設定

同一類基礎處理如果由每個模組自行決定,呼叫端就要理解多套結果。規範應該先定義共同原則,再讓各功能補充自己的規則。

統一資料表示方式

空值、缺少欄位、空字串、0false 具有不同意義,規範不應該讓它們互相替代。如果系統會處理時間、數值或文字內容,也要依實際需要定義時區、單位、精度、捨入方式及文字編碼。輸入進入系統邊界時先轉成共同表示方式,內部程式就不必在每次使用時重新猜測資料意義。

例如,更新資料時可以明確定義各種值的意義:

const updateRecordInput = {
    name: undefined,          // 不修改既有名稱
    note: null,               // 清除既有內容
    retryCount: 0,            // 明確表示不重試
    timeoutMilliseconds: 1500, // 單位固定為毫秒
};

這些意義要寫入型別、結構描述或公開介面文件,不能只存在範例註解中。不同輸入方式如果使用不同表示法,應該在各自邊界完成轉換,再交給功能程式處理。

安全處理輸入輸出

輸入進入系統邊界時,先確認格式、必要欄位、型別及可解析性。與功能狀態及計算結果有關的條件,則由負責該功能的模組判斷。這項區分可以避免同一項功能規則分散在多個入口,也能讓不同輸入方式共用一致結果。

輸入驗證要限制預期格式、長度與範圍,不能只確認資料可以被語言解析。程式將資料傳給其他組成項目時,應該使用所選技術提供的結構化介面。如果資料需要寫入另一種語法或輸出格式,則要按照實際位置使用對應的編碼或跳脫方式,不要自行拼接可執行內容。

驗證失敗時,要使用穩定方式指出失敗類型與必要位置,不要把原始輸入全部寫入錯誤內容。輸入如果可能包含敏感資訊,還要先定義遮蔽或省略方式。

例如,輸入驗證可以只回傳穩定的欄位名稱與錯誤代碼,不包含原始輸入值:

function validateImportInput(input) {
    if (typeof input !== "object" || input === null || Array.isArray(input)) {
        return [{ field: "$", code: "invalid_type" }];
    }

    const issues = [];

    if (typeof input.recordId !== "string" || input.recordId.trim() === "") {
        issues.push({ field: "recordId", code: "required" });
    }

    if (!Number.isInteger(input.retryCount) || input.retryCount < 0) {
        issues.push({ field: "retryCount", code: "invalid_range" });
    }

    return issues;
}

例如,識別碼只允許已定義的字元與長度,再透過資料來源提供的介面取得內容:

const recordIdPattern = /^[A-Z0-9-]{1,32}$/u;

function loadRecord(source, rawRecordId) {
    const recordId = String(rawRecordId).trim();

    if (!recordIdPattern.test(recordId)) {
        throw new TypeError("recordId 格式不正確");
    }

    return source.findById(recordId);
}

如果目標系統確實需要組合查詢、指令、標記語言或其他具有特殊語法的內容,規範還要指定可以使用的函式庫或介面,以及禁止直接串接輸入的範圍。密碼、存取權杖、憑證、私密金鑰及其他敏感資訊仍不得寫入程式碼、錯誤內容或執行紀錄。

錯誤處理

規範要區分可以預期的功能失敗、無效輸入、相依項目失敗及未預期錯誤。每一類錯誤都要定義在哪個邊界轉換、需要保留哪些原因,以及呼叫端可以採取甚麼動作。

捕捉錯誤後不能只回傳空值、一般失敗結果或無內容訊息。需要轉換錯誤時,應該保留原始原因與相關脈絡,並避免重複記錄同一項失敗。無法在目前層級處理的錯誤要繼續傳遞到已定義的處理邊界。

例如,在相依項目邊界轉換錯誤時,可以提供穩定的錯誤代碼並透過 cause 保留原始原因:

class DependencyError extends Error {
    constructor(operation, cause) {
        super(`相依項目無法完成 ${operation}`, { cause });
        this.name = "DependencyError";
        this.code = "dependency_failure";
    }
}

async function saveRecord(repository, record) {
    try {
        return await repository.save(record);
    } catch (error) {
        throw new DependencyError("save_record", error);
    }
}

執行紀錄

如果目標系統需要執行紀錄,應該統一層級、欄位、事件名稱及關聯方式。紀錄內容要協助還原執行路徑與判斷失敗位置,避免只留下「發生錯誤」或整個物件的文字輸出。

例如,失敗紀錄可以使用固定事件名稱及關聯欄位,並只留下判斷問題所需的錯誤類型:

function recordImportFailure(writeLog, context, error) {
    writeLog("error", {
        event: "record_import_failed",
        operationId: context.operationId,
        recordId: context.recordId,
        errorType: error.name,
    });
}

密碼、存取權杖、憑證、私密金鑰及其他敏感資訊不得進入執行紀錄。個人資料或大量輸入內容也要按照實際保存與查詢需求限制範圍。只有在系統確實需要時才加入紀錄,不要讓每個函式都產生沒有用途的訊息。

設定管理

設定要有明確結構、必要欄位、預設值與啟動時的檢查方式。程式不要把特定執行環境的位置、連線內容或功能開關散落在實作中。需要敏感資訊時,只保存取得方式及欄位定義,實際內容由已確認的安全設定機制提供。

例如,可以在建立設定時套用預設值並檢查格式,讓無效設定在功能啟動前就明確失敗:

function createImportSettings(rawSettings) {
    const batchSize = Number(rawSettings.batchSize ?? 100);

    if (!Number.isInteger(batchSize) || batchSize <= 0) {
        throw new TypeError("batchSize 必須是正整數");
    }

    return Object.freeze({ batchSize });
}

每個模組只取得自己需要的設定,避免傳入包含所有設定的可變物件。設定變更如果會影響功能結果,也要納入測試與版本管理範圍。

管理資源生命週期

如果程式會取得需要關閉或釋放的資源,規範要定義由哪一層取得、由哪一層釋放,以及正常完成、發生錯誤或取消處理時的清理方式。資源不應該交給不清楚生命週期的共用狀態,也不要依賴執行環境在無法預期的時間代為釋放。

例如,使用 finally 確保讀取成功或失敗後都會關閉資料來源:

async function readRecords(openSource) {
    const source = await openSource();

    try {
        return await source.readAll();
    } finally {
        await source.close();
    }
}

如果資源具有最大使用時間、同時使用數量或重複釋放限制,也要在取得介面或管理元件中統一處理。呼叫端只需要遵守已定義的生命週期,不應該各自建立另一套清理方式。

規範非同步、並行與重試

只有目標系統確實使用非同步或並行處理時,才需要加入相關規則。規範至少要說明執行順序、共享狀態的修改方式、取消與逾時如何傳遞,以及哪些失敗可以重試。可能改變狀態的動作還要先確認重複執行是否安全,不能因為發生錯誤就一律重新執行。

例如,下列讀取操作限制最多嘗試次數,並在每次執行前檢查取消狀態:

async function loadWithRetry(load, maxAttempts, signal) {
    if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
        throw new TypeError("maxAttempts 必須是大於 0 的整數");
    }

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        if (signal?.aborted) {
            throw new Error("處理已取消");
        }

        try {
            return await load({ signal });
        } catch (error) {
            const shouldStop =
                error?.code !== "TEMPORARY_UNAVAILABLE" ||
                attempt === maxAttempts;

            if (shouldStop) throw error;
        }
    }
}

實際規範還要按照相依項目的特性定義等待方式、逾時上限及可重試錯誤。多個處理流程可能修改相同狀態時,則要指定同步方式或避免共享可變狀態,並以測試確認執行順序不會改變功能結果。

在需要時才建立抽象與共用程式碼

避免過度工程

建立共用程式碼的條件要比「看起來重複」更明確。兩段程式只有在目的、規則及變更原因一致時才適合共用。抽象介面則應該用來隔離已確認的變化、建立必要測試邊界,或隱藏不應散布的技術細節。

例如,下列自行定義的函式只把參數原樣傳入另一個函式,再直接傳回結果:

function getRecord(id) {
    return loadRecord(id);
}

如果這層包裝沒有加入輸入檢查、資料轉換、錯誤處理,或形成需要隔離的相依邊界,呼叫端可以直接使用原函式。等到變化或共用需求已經出現,再建立責任清楚的抽象,可以減少不必要的呼叫層次、測試範圍及修改位置。

下列情況適合先保持具體寫法:

  • 只有一個使用位置,而且目前沒有需要替換或隔離的變化。
  • 多段程式外觀相似,但分別屬於不同功能規則。
  • 共用函式需要大量布林值、模式參數或例外分支才能涵蓋所有呼叫端。
  • 介面與實作完全一對一,且沒有形成清楚邊界或測試用途。
  • 抽象名稱只能使用 CommonBaseGeneric 等詞,無法說明具體責任。

選擇管理抽象的機制

確認抽象需求後,可以依語言能力與實際責任,善用模組(module)、命名空間(namespace)、類別(class)及介面(interface)等機制管理抽象:

  • 模組用來集中相關功能與型別,並限制公開項目。沒有狀態的功能通常可以先用模組管理,不必為了分組而建立類別。
  • 命名空間適合在所選語言支援且確實需要整理相關名稱或避免名稱衝突時使用,不應以過深的巢狀結構取代清楚的模組邊界。
  • 類別適合封裝需要共同維持的狀態、限制條件或生命週期,不要只用來收納彼此無關的函式。
  • 介面用來描述呼叫端需要的能力,適合存在多種實作、需要替換相依項目或建立測試邊界的情況,不必為每個類別建立一對一介面。

例如,在 TypeScript 模組中,可以用命名空間集中識別碼規則、介面描述資料來源能力,再由類別封裝讀取流程:

export interface RecordData {
    id: string;
    name: string;
}

export interface RecordSource {
    findById(recordId: string): Promise<RecordData | undefined>;
}

export namespace RecordId {
    const pattern = /^[A-Z0-9-]{1,32}$/u;

    export function parse(rawValue: string): string {
        const recordId = rawValue.trim();

        if (!pattern.test(recordId)) {
            throw new TypeError("recordId 格式不正確");
        }

        return recordId;
    }
}

export class RecordReader {
    constructor(private readonly source: RecordSource) {}

    read(rawRecordId: string): Promise<RecordData | undefined> {
        const recordId = RecordId.parse(rawRecordId);
        return this.source.findById(recordId);
    }
}

管理同一項抽象時,應該選擇足以表達責任的最少機制,不需要同時建立命名空間、介面、基底類別及多層實作。公開名稱與相依方向也要反映功能責任,避免只按照技術種類分組。

建立共用元件後,要定義公開範圍、相依方向、狀態管理及失敗方式。呼叫端不應該依賴內部實作,也不應該透過全域可變狀態交換資料。如果新需求持續要求共用元件加入無關責任,就要拆分或停止擴大抽象範圍。

建立可維護的單元測試

單元測試(Unit Test)要快速、可重複,而且能清楚指出哪一項規則失敗。測試單位可以是函式、類別或小型模組,實際範圍取決於設計邊界,不以行數決定。

測試規範可以包含下列內容:

  • 測試名稱描述測試條件、執行動作與預期結果,失敗時不必先閱讀內容才能判斷目的。
  • 測試內容使用一致的準備、執行及確認結構,讓必要輸入與主要結果容易辨識。
  • 每個測試聚焦一項可說明的行為,但可以同時確認該行為產生的相關結果與狀態變化。
  • 測試資料只保留理解情境所需的欄位,並透過明確方法建立,避免依賴其他測試留下的狀態。
  • 同時涵蓋正常、邊界及失敗情況,特別確認輸入限制、狀態轉換與錯誤處理。
  • 時間、隨機值或外部相依項目要有可控制的測試邊界,避免結果受執行順序或環境差異影響。
  • 修正缺陷時先建立能重現問題的測試,再修改實作,讓相同問題不會在後續變更中再次出現。

例如,使用 JavaScript 且已選擇 Node.js 內建測試工具時,可以用一致的準備、執行與確認結構涵蓋正常及錯誤情況:

import test from "node:test";
import assert from "node:assert/strict";
import { normalizeRecords } from "../src/records/normalize-record.js";

test("輸入包含無效紀錄時,只傳回有效且完成整理的紀錄", () => {
    // 準備
    const records = [
        { id: 3, value: 10.129 },
        { id: 2, value: Number.NaN },
        { id: 1, value: 8.555 },
    ];
    const originalRecords = structuredClone(records);

    // 執行
    const result = normalizeRecords(records, 8);

    // 確認
    assert.deepEqual(result, [
        { id: 1, value: 8.56 },
        { id: 3, value: 10.13 },
    ]);
    assert.deepEqual(records, originalRecords);
});

test("records 不是陣列時,拋出 TypeError", () => {
    assert.throws(() => normalizeRecords(null), TypeError);
});

測試名稱直接說明條件與預期結果,第一個測試同時確認篩選、數值整理、排序及輸入內容沒有改變,第二個測試則確認文件已記錄的失敗方式。實際專案應該按照已選定的語言與測試工具採用對應語法,不需要跨技術統一成相同寫法。

測試涵蓋率(Test Coverage)可以協助找出未執行的程式路徑,不能單獨證明測試有效。專案可以設定合理門檻,但仍要檢查重要功能規則、邊界與失敗路徑是否有具體測試。只為提高數字而執行程式,無法取代對預期結果的確認。

將可以判斷的規則自動化

格式化工具、程式碼檢查工具(Linter)與靜態分析(Static Analysis)負責的範圍不同。格式化工具統一文字呈現,程式碼檢查工具找出特定語法或慣例問題,靜態分析則可能利用型別、控制流程或資料流找出不一致與風險。這些工具要和編譯、測試及建置一起形成共同檢查流程。

導入時可以採用下列順序:

  1. 固定工具版本與設定,並將設定納入版本管理。
  2. 提供單一共同入口,依序執行格式檢查、程式碼檢查、靜態分析、測試及建置。
  3. 讓本機開發與持續整合(Continuous Integration, CI)呼叫相同的專案指令。
  4. 將無法通過的規則顯示成可定位的錯誤,並在共同版本合併前停止流程。
  5. 對自動產生或外部來源的程式碼明確設定處理範圍,不用大量忽略標記掩蓋人工維護內容的問題。
  6. 新增或提高規則強度時,先說明目的、處理現有違規內容,再啟用阻擋條件。

例如,已使用 npm 的 JavaScript 與 TypeScript 專案可以在 package.json 提供單一共同入口:

{
  "scripts": {
    "format:check": "prettier --check .",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "node --test",
    "build": "tsc --project tsconfig.build.json",
    "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
  }
}

本機開發與自動化流程都執行 npm run check,就能使用相同順序與設定。實際工具、指令及處理範圍仍要按照專案選定的技術調整,並在各工具設定中排除不需要檢查的自動產生或外部來源內容。

如果某條規則經常需要略過,應該檢查規則是否符合所選技術與實際程式結構。確有必要的單一例外可以附上原因及限制範圍,不能直接停用整個專案的檢查。

以程式碼審查確認人工規則

自動化流程只能判斷已經轉成工具設定的規則。模組責任、公開介面、抽象範圍、資料擁有權及錯誤資訊是否足夠,仍需要透過程式碼審查確認。審查範圍應該包含實作、測試、設定、文件與自動產生內容的來源,避免只查看變更行數而忽略整體影響。

審查意見可以區分為阻擋、警告、建議與詢問,並引用對應規則識別碼。通過條件至少包含自動檢查已通過、阻擋問題已處理、必要測試與文件已更新,以及所有例外都留下適用範圍與重新檢查條件。如果由多人共同維護程式碼,還要依變更範圍指定適合的審查人員與核准數量。

例如,一次審查結果可以記錄成下列形式:

變更範圍:
  - src/records/normalize-record.js
  - tests/records/normalize-record.test.js
自動檢查: 通過
人工檢查:
  - 規則: no-undef
    結果: 通過
  - 規則: CODE-BOUNDARY-002
    結果: 通過
未處理阻擋問題: 0
例外紀錄: []
審查結果: 通過

審查通過表示這次變更符合目前規範,且沒有讓已確認的品質與維護性降低。仍可留下不影響通過的改善建議,但純粹出自個人偏好的內容不應該成為阻擋條件。審查意見無法取得共識時,應該回到規則內容、技術結果及既有設計判斷,必要時由對應程式範圍的維護人員確認處理方式。

用正反例與例外紀錄維持規範

容易產生不同解讀的規則需要正反例。範例應該取自目標系統會出現的程式結構,並且只呈現該規則需要比較的差異。

規範目的 不足的做法 較清楚的做法
表達函式用途 使用 processData,無法判斷處理內容與結果 使用能表達主要動作及對象的名稱,例如 normalizeSourceRecord
表達變數用途 使用 data1item2 或只有型別資訊的名稱 使用能表達內容及用途的名稱,並在用途改變時建立新變數
維持名稱一致 以多個未定義的動詞表示同一項動作 選定一個符合實際行為的動詞,並在相同責任中一致使用
揭露狀態變更 名稱看似只查詢結果,執行時卻修改其他狀態 拆分查詢與修改責任,或讓名稱及介面清楚呈現狀態變更
維持模組邊界 直接讀取另一個模組的內部檔案 透過該模組已定義的公開介面取得結果
保留錯誤原因 捕捉所有錯誤後只回傳 false 轉換成已定義的錯誤類型,保留原始原因與必要脈絡
建立共用程式碼 看到兩段相似內容就移入萬用工具模組 先確認目的、規則及變更原因一致,再建立責任清楚的元件
撰寫單元測試 測試名稱只寫「測試功能」 在名稱中寫出條件、動作與預期結果

有些限制可能要求暫時偏離共同規範。例外紀錄要包含適用規則、程式範圍、原因、已知影響、替代檢查、負責人及重新檢查條件。能縮小到單行或單一檔案時,就不要停用整個模組。限制消失後要移除例外,避免暫時決定變成無期限慣例。

規範本身也需要版本管理及審查。規範維護人員要定期確認規則仍符合實際程式結構,並處理已經到達重新檢查條件的例外。語言、框架或工具版本改變時,應該同步更新規範、設定、範例與自動化流程。修訂紀錄要說明變更原因、影響範圍及既有程式的處理方式,讓開發人員可以採用同一個基準。

何時算是完成程式碼規範?

程式碼規範完成時,至少要能確認下列結果:

  • 規範符合已選定的程式語言、框架與工具,且沒有互相衝突的設定。
  • 每條規則都有固定識別碼、適用範圍、強制程度、檢查方式與維護責任。
  • 命名、格式、註解、檔案組織、模組責任與相依方向都有可以套用的原則及範例。
  • 名稱、作用範圍、資料擁有權、傳回內容與狀態變更可以互相對應,沒有依賴隱藏行為才能理解的函式。
  • 公開介面的相容範圍、替代方式與移除條件已經定義。
  • 資料表示、輸入驗證、安全處理、錯誤、執行紀錄、設定與資源生命週期具有一致邊界及處理方式。
  • 系統確實使用非同步或並行處理時,取消、逾時、重試與共享狀態規則已經定義。
  • 共用程式碼與抽象介面有明確建立條件,不會只因表面重複就擴大共用範圍。
  • 單元測試的命名、結構、測試資料與涵蓋原則已經定義。
  • 格式化、程式碼檢查、靜態分析、測試及建置可以從共同入口執行,並納入自動化流程。
  • 程式碼審查已經定義檢查範圍、意見強度、通過條件及爭議處理方式。
  • 正反例可以協助開發人員套用規則,例外、維護責任與修訂也有可追蹤的處理方式。
  • 新增一個小型功能時,可以按照規範完成實作、測試與檢查,不需要依靠未記錄的口頭說明。

如果規範只能在文件中閱讀,實際程式卻無法自動檢查或在審查時判斷,就還沒有形成共同標準。先用一條具代表性的功能路徑套用全部規則,可以找出互相衝突、過度嚴格或仍然含糊的項目,再擴大到後續實作。

例如,一條代表性功能路徑完成檢查後,可以保存下列結果:

規範版本: 1.0.0
代表功能: 紀錄正規化
套用範圍:
  - src/records/
  - tests/records/
自動檢查:
  格式: 通過
  程式碼檢查: 通過
  靜態分析: 通過
  測試: 通過
  建置: 通過
人工檢查:
  命名與責任: 通過
  資料擁有權: 通過
  模組相依方向: 通過
未處理例外: 0
完成結果: 可以作為後續功能的共同基準

這份結果要能連回使用的規範版本、工具設定、測試與審查紀錄。後續功能如果仍需要依靠未記錄的做法,或同一項規則在不同位置產生不同結果,就要先修訂規範再繼續擴大套用範圍。

重點整理

  • 先依專案使用的語言、框架與工具界定規範範圍,再區分自動修正、自動檢查、人工判斷及條件規則。每條規則要有固定識別碼、適用範圍、強制程度、檢查方式、維護責任及例外程序,才能被引用與持續維護。
  • 命名要表達責任、狀態與結果,並和作用範圍、資料擁有權、修改方式及實際行為一致,避免名稱遮蔽、隱藏副作用或需要依賴實作細節才能理解。
  • 固定值要說明用途、來源與適用範圍;條件分支、迴圈及遞迴則要控制巢狀層級,並具有容易判斷的執行路徑、結束條件與保護方式。
  • 格式、匯入順序及可自動判斷的註解規則應交由共同工具處理;檔案與目錄則按照功能責任組織,避免僅依技術種類分散相關程式碼。
  • 模組要有明確責任、公開範圍及相依方向,公開介面的相容範圍、替代方式與移除條件也要在變更前定義,並盡量透過工具或測試檢查架構限制。
  • 空值、缺少欄位、空字串、0false 要有明確且一致的表示方式;輸入驗證、安全處理、錯誤、執行紀錄、設定及資源生命週期則要在已定義的邊界處理,避免敏感資訊與環境細節散布到程式中。
  • 系統確實使用非同步、並行或重試機制時,才需要定義取消、逾時、執行順序、共享狀態、重複執行及失敗後處理方式。
  • 共用程式碼與抽象要根據一致的目的、規則、變更原因或已確認的隔離需求建立,再選用足以表達責任的模組、命名空間、類別或介面,避免表面重複造成過早抽象與多餘層次。
  • 單元測試要以一致結構清楚表達條件、動作與預期結果,涵蓋正常、邊界及失敗情況,並控制時間、隨機值與外部相依項目等測試邊界。
  • 格式化、程式碼檢查、靜態分析、測試及建置要固定版本並提供共同入口;程式碼審查則負責確認工具無法判斷的責任邊界、變更風險與通過條件。
  • 正反例、例外紀錄、維護責任及修訂程序要可以追蹤。當開發人員能只依書面規範完成一項小型功能的實作、測試與檢查,不必依靠未記錄的口頭說明,才代表規範已具備可執行性。

上一篇
[Day 11] 重複的程式碼過多,如何使用框架改善架構?
下一篇
[Day 13] 應該從哪裡開始重構遺留系統?
系列文
遠古聖遺物改造工程:遺留系統全面重構實務指南14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言