iT邦幫忙

2025 iThome 鐵人賽

DAY 16
1
Software Development

消除你程式碼的臭味系列 第 16

Day 16- 單一職責:找到唯一修改理由,告別脆弱程式碼

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250903/20124462P1N8QjGguI.png

消除你程式碼的臭味 Day 16- 單一職責:找到唯一修改理由,告別脆弱程式碼

單一職責原則(Single Responsibility Principle, SRP),一個模組,應該只有一個改動的理由。
這也是 Unix 哲學之一:「做一件事,並把它做好。」

如果你的函式和類別或模組混合了多種職責,「取得資料」、「處理運算」、「輸出結果」,你的程式碼就會開始散發難以忽視的「臭味」。

把抓資料、算資料、呈現資料,徹底分開。

什麼是「唯一修改理由」?

精準地說,這代表應該只有一種業務需求或角色(Actor)的變更,會驅使你修改這個模組。

如果需求來自不同的角色,就代表它們是不同的修改理由。

  • 資料庫管理員 關心 DB 結構調整。

  • 產品經理 關心報表格式與商業邏輯。

  • 系統架構師 關心輸出方式的變更(例如從終端機改為 API)。

這三種理由不應該被綁在同一個模組裡。

單一職責切分策略

如何有效地劃分職責?這裡有幾個實用的切入點:

  • 按「資料流」切分: 取得(Input)→ 轉換(Process)→ 輸出(Output)。

  • 按「變動頻率」切分: 將頻繁變動的業務規則與相對穩定的 I/O 操作分開。

  • 按「邊界」切分: 將純粹的計算邏輯與會產生副作用(Side Effect)的外部互動拆離。

經典案例:一個類別包辦所有事

當一個檔案,會因為上述三個不同角色的需求而被迫修改,它就已嚴重違反了設計原則,你的程式碼正在發臭。

// 🔴 臭味:實際場景的程式碼肯定是超級大包,先用簡短範例說明
class ReportService {
  async run(db) {
    // 理由 1: 資料庫 schema 或查詢語言改變
    const rows = await db.query('SELECT * FROM orders'); 
    
    // 理由 2: 報表格式改變
    const text = rows.map(r => `${r.id}:${r.total}`).join('\n');
    
    // 理由 3: 輸出目標改變
    console.log(text);
  }
}

https://ithelp.ithome.com.tw/upload/images/20250918/20124462kf7HXDNMQY.png

  • 它無法測試: 你想測試中間那段格式化的邏輯嗎?抱歉,你得先搭一個真的資料庫,然後還得想辦法攔截 console.log 的輸出。為了測試一小段純文字處理,還要搞好一大堆 I/O 問題。

  • 它無法重用: 如果現在有個新需求,要產生同樣的報表,但不是印出來,而是作為 API 的回傳值呢?你怎麼辦?複製貼上整段程式碼,然後只改最後一行?製造出我們前幾天寫過的 Bug 臭味製造機。

  • 它極度脆弱: 任何一個環節的改變,都可能影響到其他環節。改了 SQL 查詢,可能會影響格式化;改了格式化,可能會讓輸出壞掉。

拆分職責:打造一個工具箱

// 🟢 好品味:這是一個由三個獨立、專精的工具組成的工具箱。

// 工具 1: 負責「撈資料」的扳手。它只關心怎麼從資料庫拿東西。
async function fetchOrders(db) { 
  return db.query('SELECT * FROM orders'); 
}

// 工具 2: 負責「算資料」的計算機。這是純粹的邏輯,不碰任何 I/O。
function formatOrdersAsText(rows) { 
  return rows.map(r => `${r.id}:${r.total}`).join('\n'); 
}

// 工具 3: 負責「輸出」的印表機。它只關心怎麼把文字弄到螢幕上。
function printToConsole(text) { 
  console.log(text); 
}

// 這是一個「工頭」,它不親自幹活,只負責調度工具。
async function runReportWorkflow(db) {
  const rows = await fetchOrders(db);
  const text = formatOrdersAsText(rows);
  printToConsole(text);
}

https://ithelp.ithome.com.tw/upload/images/20250918/20124462nCZYZEE2A1.png
拆分的好處顯而易見。

  • 可獨立測試: 這樣可以獨立地、輕鬆地測試 formatOrdersAsText。給它一個假陣列,斷言它回傳的字串。不需要資料庫,不需要 console

  • 可自由組合 (Composability): 如果要把報表輸出成 API 回應也會很簡單。

    // 重用 fetchOrders 和 formatOrdersAsText 這兩個工具
    const rows = await fetchOrders(db);
    const text = formatOrdersAsText(rows);
    return new Response(text); // 換一個輸出工具即可
    
  • 職責清晰: 每個函式都有一個極其狹窄、定義良好的職責。看名字就知道它是幹嘛的。修改時,也能肯定改動只會影響它自己的職責範圍。

除臭步驟

這是一個從混亂到清晰的實用重構藍圖:

  1. 解救「大腦」:最優先將最純粹的計算邏輯(格式化、驗證)抽離成「純函式 Pure Function」。純函式最容易測試。

  2. 隔離「邊界」:接著將與外部世界互動的輸入/輸出 (I/O) 操作(讀寫資料庫、呼叫 API)也抽離成獨立的「邊界函式」,將不確定性控制在小範圍內。

  3. 建立「工頭」:最後建立一個新的「組合函式」,它不親自工作,只負責調度前面建立的純函式與邊界函式,完成業務流程。原有的巨大類別便可功成身退。

測試與縫隙 Seams:建立信賴感的關鍵

好的設計就是好測試。
在你的程式碼裡留下可以輕易更換零件的插槽,針對不同職責的函式,採用不同測試策略:

  • 純計算函式:進行單元測試 (Unit Test),驗證其邏輯的絕對正確性。

  • 邊界 (I/O) 函式:使用測試替身 (Test Doubles), Stubs (提供假資料) 和 Mocks (驗證呼叫行為),來模擬外部依賴,從而避免緩慢且不穩定的真實 I/O。

  • 組合 (工頭) 函式:進行整合測試 (Integration Test),只驗證各工具函式是否被正確地串連起來,確保它們之間的「契約」有被遵守。

https://ithelp.ithome.com.tw/upload/images/20250918/20124462hzovAeb6It.png

檢查清單

  1. 這個類或模組有幾個「原因」會被修改?
  2. 取數、計算、輸出是否混在一起?
  3. 是否能把每個步驟抽成獨立函式?
  4. 測試能否只針對單一步驟?

今日重點

  • 一件事,一個地方(One Thing, One Place)。
  • 把流程變成幾個小步驟。
  • 區分資料取得、資料處理、呈現(Input, Process, Output)。

遵守單一職責原則,就是有意識地將流程(Process) 拆解成輸入(Input)運算(Process)輸出(Output) 三個獨立部分。

這能讓你的程式碼職責清晰、易於測試、方便重用,從根本上消除臭味,提升程式碼品質。


上一篇
Day 15- 組合優於繼承:用小能力組出行為
下一篇
Day 17- 分離關注點:設定與主要邏輯分開
系列文
消除你程式碼的臭味18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言