想像一間生意興隆的餐廳:主廚專心料理、外場專心點餐送餐、收銀專心結帳、採購專心進貨。每個人各守各的崗位,一個蘿蔔一個坑,整間餐廳才能順暢運轉。反過來說,如果有一位「超人店員」同時煮菜、點餐、收錢、進貨,只要他一請假,整間店就直接停擺;想改善收銀流程,還得先搞懂他怎麼炒菜——這就是今天要談的主題:關注點分離(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 管的是整片田要怎麼劃分坑位。
接下來我們透過一個幾乎每個系統都有的業務場景——使用者註冊,看看當所有關注點擠在同一個函式裡時,程式碼會變成什麼樣子。
// 不好的範例: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() 函式功能上完全正確,甚至還能通過測試,但它把五種毫不相關的關注點全部塞進同一個函式裡:
這時候我們可以發現,問題不在於「程式碼會不會動」,而在於每一種關注點的變動,都會波及到其他毫不相干的關注點。
核心思路是:把五種關注點分別拆進專屬的模組——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}`));
UserValidator 只管驗證、PasswordHasher 只管加密、UserRepository 只管資料存取、Mailer 只管寄信、Logger 只管記錄。每個模組都符合 Day 6 的 SRP,而模組之間的劃分方式,正是系統層級的關注點分離PasswordHasher、UserRepository、Mailer、Logger 都以介面定義契約。想把雜湊換成 bcrypt?新增一個 BcryptPasswordHasher 實作即可;想把資料改存 PostgreSQL?新增一個 PostgresUserRepository 即可——流程函式與其他模組完全不需要修改
UserRegistrationService.register() 讀起來就像一份業務流程的目錄——驗證、查重、建立、儲存、寄信、記錄。任何新進同事只看這一個函式,就能掌握整個註冊流程的全貌,完全不必陷入實作細節UserValidator 寫單元測試,不會碰到任何資料庫或信件。想測試註冊流程?在測試中傳入假的(mock)Mailer 與 Repository,再也不必擔心測試時真的寄出信件UserRegistrationService 透過建構子接收各模組(也就是依賴注入),使得流程函式依賴的是抽象介面而非具體實作——這正呼應了我們在 Day 10 談過的依賴倒置原則(DIP)這時候我們可以發現,修正後的程式碼行數雖然變多了,但每一段程式碼的「變動理由」都只剩下一個:驗證規則改變只動 UserValidator,信件樣板改變只動 ConsoleMailer,彼此再也不會互相波及。程式碼多了,混亂卻少了——這筆交易非常划算。
值得一提的是,像 Logger 這種「幾乎每個模組都需要用到」的關注點,在軟體工程中有個專有名詞叫做橫切關注點(cross-cutting concern)。它很難被完全隔離在單一模組中,因此衍生出了剖面導向程式設計(AOP)、middleware、decorator 等專門處理它的技術——這裡我們先用最簡單的介面注入處理,知道這個概念的存在即可。
綜合以上所述,我們成功把一個包山包海的 registerUser() 拆成了各司其職的模組,避免了「改一個地方、壞三個地方」的連鎖災難:
需要注意的是,關注點分離也不是拆得越碎越好。如果為了三行驗證邏輯就建立五層抽象,反而違反了 KISS 原則。判斷的基準始終是:這兩段程式碼的「變動理由」相同嗎? 相同就放在一起,不同就分開——一個蘿蔔一個坑,如此而已。
最後埋個伏筆:如果一個類別完全不做關注點分離,把驗證、運算、存取資料庫、寄信、記 log、產報表……所有職責通通往自己身上攬,日積月累之後,它就會膨脹成一隻誰都不敢碰的巨獸——這種反模式有個響亮的名字,叫做上帝物件(God Object)。我們會在第 24 篇好好解剖這隻巨獸,看看它是怎麼誕生的、又該怎麼安全地拆解它。在那之前,請先記住今天的口訣:一個蘿蔔一個坑。