iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 13 篇

一個蘿蔔一個坑 - 關注點分離 (Separation of Concerns)

  • 分享至 

  • xImage
  •  

想像一間生意興隆的餐廳:主廚專心料理、外場專心點餐送餐、收銀專心結帳、採購專心進貨。每個人各守各的崗位,一個蘿蔔一個坑,整間餐廳才能順暢運轉。反過來說,如果有一位「超人店員」同時煮菜、點餐、收錢、進貨,只要他一請假,整間店就直接停擺;想改善收銀流程,還得先搞懂他怎麼炒菜——這就是今天要談的主題:關注點分離(Separation of Concerns,簡稱 SoC)。

根據 Wikipedia 的說法,「關注點分離」這個詞由電腦科學巨擘 Edsger W. Dijkstra 在 1974 年的論文《On the Role of Scientific Thought》中首次提出。他在文中寫道:

「這就是我有時稱之為『關注點分離』的東西——即使無法做到完美,它仍然是有效整理思緒的唯一可行技術。」(It is what I sometimes have called "the separation of concerns", which, even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts.)

換句話說,Dijkstra 認為人腦一次只能專注處理一件事,所以我們應該一次只研究問題的一個面向:思考正確性的時候先不管效能,思考效能的時候先不管介面美觀。把這個思考技巧套用到軟體設計上,就成了我們熟悉的模組化、分層架構——每個模組只負責一種「關注點(concern)」,例如輸入驗證、資料存取、寄送通知、記錄 log,彼此之間界線分明、互不越界。

值得一提的是,這個原則和我們在 Day 6 討論的單一功能原則(SRP) 是一脈相承的。SRP 告訴我們「一個類別只該有一個改變的理由」,聚焦在類別層級;而關注點分離則是 SRP 的系統層級放大版——它問的是:整個系統該怎麼分層?驗證邏輯、業務邏輯、資料存取、基礎設施(寄信、記 log)各自該住在哪裡?經典的 MVC 架構把「資料模型、畫面呈現、流程控制」拆開,前端把 HTML、CSS、JavaScript 分別負責「結構、樣式、行為」,其實都是關注點分離的具體實踐。

簡單的說法就是:SRP 管的是一個蘿蔔別長成八爪章魚,SoC 管的是整片田要怎麼劃分坑位。

接下來我們透過一個幾乎每個系統都有的業務場景——使用者註冊,看看當所有關注點擠在同一個函式裡時,程式碼會變成什麼樣子。

TypeScript 不好的範例

問題:一個函式包山包海的使用者註冊

// 不好的範例:registerUser() 一個函式扛下五種完全不同的關注點

interface User {
  id: string;
  email: string;
  name: string;
  hashedPassword: string;
  createdAt: Date;
}

// 模擬的資料庫(實務上會是真正的資料庫連線)
const userTable: User[] = [];

async function registerUser(
  email: string,
  password: string,
  name: string
): Promise<User> {
  // 關注點 1:輸入驗證 —— 驗證規則直接寫死在流程裡
  // 問題:其他地方(例如「修改個人資料」)需要相同驗證時,只能複製貼上
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailPattern.test(email)) {
    throw new Error('Email 格式不正確');
  }
  if (password.length < 8) {
    throw new Error('密碼長度至少需要 8 個字元');
  }
  if (name.trim().length === 0) {
    throw new Error('姓名不可為空白');
  }

  // 關注點 2:密碼雜湊 —— 加密演算法的細節混在註冊流程中
  // 問題:日後想從這個陽春演算法換成 bcrypt,就得直接動這個函式
  let hashedPassword = '';
  for (const char of password) {
    hashedPassword += (char.charCodeAt(0) * 31).toString(16);
  }

  // 關注點 3:資料庫存取 —— 檢查重複與寫入資料直接操作資料表
  // 問題:日後從陣列換成 PostgreSQL 或 MongoDB,這段全部要重寫
  const existingUser = userTable.find((user) => user.email === email);
  if (existingUser) {
    throw new Error('這個 Email 已經被註冊過了');
  }
  const newUser: User = {
    id: `user-${Date.now()}`,
    email,
    name,
    hashedPassword,
    createdAt: new Date(),
  };
  userTable.push(newUser);

  // 關注點 4:寄送歡迎信 —— 信件內容與寄送細節也擠在這裡
  // 問題:想把純文字信改成 HTML 樣板、或改用其他郵件服務商,又得動這個函式
  console.log(`[寄信] 收件人:${email}`);
  console.log(`[寄信] 主旨:歡迎加入!`);
  console.log(`[寄信] 內容:${name} 您好,感謝您註冊我們的服務。`);

  // 關注點 5:記錄 log —— 連 log 的格式都是手工拼字串
  // 問題:想統一改成 JSON 格式或送到遠端監控系統,得翻遍所有像這樣的散落點
  console.log(
    `[LOG] ${new Date().toISOString()} - 使用者註冊成功:${email}`
  );

  return newUser;
}

// 使用範例——功能看起來正常,但五種關注點已經緊緊糾纏在一起
registerUser('alice@example.com', 'securePass123', 'Alice')
  .then((user) => console.log(`註冊完成:${user.name}`));

問題分析

這個 registerUser() 函式功能上完全正確,甚至還能通過測試,但它把五種毫不相關的關注點全部塞進同一個函式裡:

  1. 驗證規則被寫死:Email 格式、密碼長度的規則直接嵌在流程中。當「修改個人資料」、「管理員建立帳號」等功能也需要相同驗證時,只能複製貼上,違反了 DRY 原則
  2. 技術細節無法抽換:想把密碼雜湊換成 bcrypt、把陣列換成真正的資料庫、把 console 寄信換成 SendGrid——每一項變更都必須修改同一個函式,而每次修改都可能不小心弄壞其他部分
  3. 無法獨立測試:想單獨測試「Email 驗證規則」,卻被迫連資料庫寫入和寄信一起執行;想測試「重複註冊的防呆」,卻得先想辦法讓寄信不要真的寄出去
  4. 改變的理由太多:套用 Day 6 的 SRP 檢驗法——這個函式至少有五個改變的理由:驗證規則改變、加密方式改變、資料庫改變、信件樣板改變、log 格式改變。任何一個理由都會迫使我們打開這個函式動刀
  5. 認知負擔沉重:閱讀這段程式碼時,你的大腦必須同時在「正規表達式、雜湊演算法、資料表操作、信件文案、log 格式」五種思維之間來回切換——這正是 Dijkstra 想避免的事情

這時候我們可以發現,問題不在於「程式碼會不會動」,而在於每一種關注點的變動,都會波及到其他毫不相干的關注點。

修正後範例

解法:一個蘿蔔一個坑,各模組各司其職

核心思路是:把五種關注點分別拆進專屬的模組——validator 管驗證、hasher 管加密、repository 管資料存取、mailer 管寄信、logger 管記錄,最後由一個薄薄的流程函式把它們串接起來。流程函式只負責「按順序指揮」,不涉入任何一種關注點的實作細節。

// 修正範例:以模組劃分關注點,流程函式只負責串接

interface User {
  id: string;
  email: string;
  name: string;
  hashedPassword: string;
  createdAt: Date;
}

// 改動重點 1:輸入驗證獨立成 validator 模組
// 驗證規則集中在一處,任何需要驗證的功能都能重複使用
class UserValidator {
  private readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  validateEmail(email: string): void {
    if (!this.emailPattern.test(email)) {
      throw new Error('Email 格式不正確');
    }
  }

  validatePassword(password: string): void {
    if (password.length < 8) {
      throw new Error('密碼長度至少需要 8 個字元');
    }
  }

  validateName(name: string): void {
    if (name.trim().length === 0) {
      throw new Error('姓名不可為空白');
    }
  }
}

// 改動重點 2:密碼雜湊獨立成 hasher 模組,並以介面定義契約
// 日後要換成 bcrypt,只需新增一個實作類別,其他程式碼一行都不用動
interface PasswordHasher {
  hash(password: string): string;
}

class SimplePasswordHasher implements PasswordHasher {
  hash(password: string): string {
    let hashed = '';
    for (const char of password) {
      hashed += (char.charCodeAt(0) * 31).toString(16);
    }
    return hashed;
  }
}

// 改動重點 3:資料存取獨立成 repository 模組
// 「資料存在哪裡、怎麼存」的細節被完全封裝,換資料庫只需要換實作
interface UserRepository {
  findByEmail(email: string): User | undefined;
  save(user: User): void;
}

class InMemoryUserRepository implements UserRepository {
  private readonly userTable: User[] = [];

  findByEmail(email: string): User | undefined {
    return this.userTable.find((user) => user.email === email);
  }

  save(user: User): void {
    this.userTable.push(user);
  }
}

// 改動重點 4:寄信獨立成 mailer 模組
// 信件內容與寄送方式集中管理,改樣板、換服務商都不會波及註冊流程
interface Mailer {
  sendWelcomeEmail(email: string, name: string): void;
}

class ConsoleMailer implements Mailer {
  sendWelcomeEmail(email: string, name: string): void {
    console.log(`[寄信] 收件人:${email}`);
    console.log(`[寄信] 主旨:歡迎加入!`);
    console.log(`[寄信] 內容:${name} 您好,感謝您註冊我們的服務。`);
  }
}

// 改動重點 5:記錄 log 獨立成 logger 模組
// log 格式只存在一份,想改成 JSON 或接上監控系統,只需修改這裡
interface Logger {
  info(message: string): void;
}

class ConsoleLogger implements Logger {
  info(message: string): void {
    console.log(`[LOG] ${new Date().toISOString()} - ${message}`);
  }
}

// 改動重點 6:註冊流程縮成一個「薄薄的」服務類別
// 它不懂驗證規則、不懂加密、不懂資料庫、不懂寄信——只負責按順序指揮
class UserRegistrationService {
  constructor(
    private readonly validator: UserValidator,
    private readonly hasher: PasswordHasher,
    private readonly repository: UserRepository,
    private readonly mailer: Mailer,
    private readonly logger: Logger
  ) {}

  async register(
    email: string,
    password: string,
    name: string
  ): Promise<User> {
    // 每一行都只是「委託」,讀起來就像業務流程的目錄
    this.validator.validateEmail(email);
    this.validator.validatePassword(password);
    this.validator.validateName(name);

    if (this.repository.findByEmail(email)) {
      throw new Error('這個 Email 已經被註冊過了');
    }

    const newUser: User = {
      id: `user-${Date.now()}`,
      email,
      name,
      hashedPassword: this.hasher.hash(password),
      createdAt: new Date(),
    };

    this.repository.save(newUser);
    this.mailer.sendWelcomeEmail(email, name);
    this.logger.info(`使用者註冊成功:${email}`);

    return newUser;
  }
}

// 使用範例——在系統入口處組裝各模組,再交給流程函式指揮
const registrationService = new UserRegistrationService(
  new UserValidator(),
  new SimplePasswordHasher(),
  new InMemoryUserRepository(),
  new ConsoleMailer(),
  new ConsoleLogger()
);

registrationService
  .register('alice@example.com', 'securePass123', 'Alice')
  .then((user) => console.log(`註冊完成:${user.name}`));

改進重點說明

  1. 每個模組只有一個坑:UserValidator 只管驗證、PasswordHasher 只管加密、UserRepository 只管資料存取、Mailer 只管寄信、Logger 只管記錄。每個模組都符合 Day 6 的 SRP,而模組之間的劃分方式,正是系統層級的關注點分離
  2. 介面隔離變動:PasswordHasher、UserRepository、Mailer、Logger 都以介面定義契約。想把雜湊換成 bcrypt?新增一個 BcryptPasswordHasher 實作即可;想把資料改存 PostgreSQL?新增一個 PostgresUserRepository 即可——流程函式與其他模組完全不需要修改
  3. 流程函式薄如目錄:UserRegistrationService.register() 讀起來就像一份業務流程的目錄——驗證、查重、建立、儲存、寄信、記錄。任何新進同事只看這一個函式,就能掌握整個註冊流程的全貌,完全不必陷入實作細節
  4. 可獨立測試:想測試驗證規則?直接對 UserValidator 寫單元測試,不會碰到任何資料庫或信件。想測試註冊流程?在測試中傳入假的(mock)Mailer 與 Repository,再也不必擔心測試時真的寄出信件
  5. 建構子注入依賴:UserRegistrationService 透過建構子接收各模組(也就是依賴注入),使得流程函式依賴的是抽象介面而非具體實作——這正呼應了我們在 Day 10 談過的依賴倒置原則(DIP)

這時候我們可以發現,修正後的程式碼行數雖然變多了,但每一段程式碼的「變動理由」都只剩下一個:驗證規則改變只動 UserValidator,信件樣板改變只動 ConsoleMailer,彼此再也不會互相波及。程式碼多了,混亂卻少了——這筆交易非常划算。

值得一提的是,像 Logger 這種「幾乎每個模組都需要用到」的關注點,在軟體工程中有個專有名詞叫做橫切關注點(cross-cutting concern)。它很難被完全隔離在單一模組中,因此衍生出了剖面導向程式設計(AOP)、middleware、decorator 等專門處理它的技術——這裡我們先用最簡單的介面注入處理,知道這個概念的存在即可。

總結

綜合以上所述,我們成功把一個包山包海的 registerUser() 拆成了各司其職的模組,避免了「改一個地方、壞三個地方」的連鎖災難:

  1. 關注點分離是 SRP 的放大版:SRP 看守單一類別的職責,SoC 規劃整個系統的分層分工——驗證、業務邏輯、資料存取、基礎設施各就各位
  2. Dijkstra 的智慧:人腦一次只能專注一件事,程式碼的組織方式應該配合這個限制——閱讀驗證邏輯時不必想著資料庫,修改信件樣板時不必擔心弄壞加密
  3. 以介面劃清界線:模組之間透過介面溝通,實作細節被封裝在各自的坑裡,抽換技術方案(換資料庫、換郵件服務商)不再牽一髮動全身
  4. 薄流程、厚模組:讓串接流程的函式保持輕薄,像目錄一樣一目了然;把真正的邏輯下放到專職模組中,各自獨立演化、獨立測試

需要注意的是,關注點分離也不是拆得越碎越好。如果為了三行驗證邏輯就建立五層抽象,反而違反了 KISS 原則。判斷的基準始終是:這兩段程式碼的「變動理由」相同嗎? 相同就放在一起,不同就分開——一個蘿蔔一個坑,如此而已。

最後埋個伏筆:如果一個類別完全不做關注點分離,把驗證、運算、存取資料庫、寄信、記 log、產報表……所有職責通通往自己身上攬,日積月累之後,它就會膨脹成一隻誰都不敢碰的巨獸——這種反模式有個響亮的名字,叫做上帝物件(God Object)。我們會在第 24 篇好好解剖這隻巨獸,看看它是怎麼誕生的、又該怎麼安全地拆解它。在那之前,請先記住今天的口訣:一個蘿蔔一個坑。

參考資料

上一篇
別把手伸進別人的口袋 - 迪米特法則與 Tell, Don't Ask (Law of Demeter & Tell, Don't Ask)
下一篇
越早失敗越好 - 快速失敗原則 (Fail Fast)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言