在前幾集的文章裡,我們花了不少篇幅在談 interface 要「怎麼用」.從最基本的 interface 是什麼,到 元件之間如何透過 interface 溝通,再到 多型的威力,然後聊到了 用 interface 寫出可以被單元測試的程式碼,以及上一集談的 依賴注入 (Dependency Injection).這些都是關於「拿到一個 interface 之後,要怎麼善用它」.
但有一個更根本的問題,我們一直沒有正面處理過:
一個 interface 到底應該長什麼樣子?裡面該放幾個 method 才對?
這一集,我們就來聊聊 interface 的「設計」.不是語法層面的事,而是設計層面的事.具體來說,就是 SOLID 原則裡的 Interface Segregation Principle (ISP),中文叫做「介面隔離原則」.
一個故事的開始:萬能工具人
假設你是一間電商公司的工程師.有一天,團隊的架構師設計了一個 interface,叫做 IOrderService,用來處理訂單相關的所有事情:
public interface IOrderService
{
Order CreateOrder(Cart cart);
void CancelOrder(int orderId);
void RefundOrder(int orderId);
OrderStatus GetOrderStatus(int orderId);
IEnumerable<Order> GetOrderHistory(int customerId);
void SendOrderConfirmationEmail(int orderId);
void SendShippingNotification(int orderId);
void GenerateInvoicePdf(int orderId);
void ExportOrdersToCsv(DateTime from, DateTime to);
}
看起來很完整,對吧?訂單的建立、取消、退款、查詢、通知、報表,一個 interface 全部搞定.多有效率!架構師拍拍手,覺得自己做了一件好事.
但問題來了.
痛苦的開始
現在假設你的同事小明被指派去做「出貨通知」的功能.他的 class 需要實作 IOrderService,因為 SendShippingNotification 這個 method 就定義在裡面.
但是小明一打開 IOrderService,傻眼了.他只需要一個 SendShippingNotification,結果他被迫要實作 9 個 method.他根本用不到 CreateOrder、RefundOrder、GenerateInvoicePdf 這些東西.但編譯器不管你用不用得到,interface 裡面定義了什麼,你就得全部實作.
小明只好硬著頭皮寫了一堆這樣的程式碼:
public class ShippingNotifier : IOrderService
{
// 這才是我真正要做的事
public void SendShippingNotification(int orderId)
{
// 寄出出貨通知 email
// ...
}
// 以下全部都是我不需要的,但不寫不行
public Order CreateOrder(Cart cart)
{
throw new NotImplementedException();
}
public void CancelOrder(int orderId)
{
throw new NotImplementedException();
}
public void RefundOrder(int orderId)
{
throw new NotImplementedException();
}
public OrderStatus GetOrderStatus(int orderId)
{
throw new NotImplementedException();
}
public IEnumerable<Order> GetOrderHistory(int customerId)
{
throw new NotImplementedException();
}
public void SendOrderConfirmationEmail(int orderId)
{
throw new NotImplementedException();
}
public void GenerateInvoicePdf(int orderId)
{
throw new NotImplementedException();
}
public void ExportOrdersToCsv(DateTime from, DateTime to)
{
throw new NotImplementedException();
}
}
看到了嗎?9 個 method 裡面有 8 個寫了 throw new NotImplementedException().這不是在寫程式,這是在寫垃圾.而且這種垃圾還很危險,因為如果哪天有人不小心呼叫了那些 NotImplementedException 的 method,程式就會在 runtime 爆掉.編譯不會告訴你任何問題,但一執行就炸了,這才是最可怕的地方.
小明心裡想:「我只是要寄一封 email,為什麼搞得我像在蓋一棟大樓?」
這就是 ISP 要解決的問題
Interface Segregation Principle 的原文是這麼說的:
No client should be forced to depend on methods it does not use.
翻成白話文就是:不要強迫一個 class 去依賴它根本用不到的 method.
用更生活化的方式來說好了.你去一家餐廳吃飯,你只想點一碗牛肉麵,結果服務生說:「我們這邊是套餐制的,點牛肉麵一定要配前菜、甜點、飲料、水果拼盤,還有今日特選鵝肝醬.」你只想吃碗麵,卻被迫買了一整桌你不想吃的東西.
ISP 的精神就是:讓客人可以單點.你只需要牛肉麵,菜單上就應該有「只買牛肉麵」這個選項.
拆!把胖 interface 拆成小的
回到剛才那個 IOrderService 的例子.讓我們用 ISP 的精神來重新設計它.仔細看一下那 9 個 method,其實可以大致分成幾個不同的職責:
看出來了嗎?這些 method 其實分屬不同的關注點.訂單核心操作是一回事,通知是另一回事,報表又是另一回事.把它們全部塞進同一個 interface,就像把廚師、服務生、會計、清潔人員全部綁在同一份工作合約裡,要求每個人都要會做所有的事情一樣荒謬.
所以,拆吧:
public interface IOrderManagement
{
Order CreateOrder(Cart cart);
void CancelOrder(int orderId);
void RefundOrder(int orderId);
}
public interface IOrderQuery
{
OrderStatus GetOrderStatus(int orderId);
IEnumerable<Order> GetOrderHistory(int customerId);
}
public interface IOrderNotification
{
void SendOrderConfirmationEmail(int orderId);
void SendShippingNotification(int orderId);
}
public interface IOrderReporting
{
void GenerateInvoicePdf(int orderId);
void ExportOrdersToCsv(DateTime from, DateTime to);
}
拆完之後會發生什麼事?小明現在只需要實作 IOrderNotification 就好了:
public class ShippingNotifier : IOrderNotification
{
public void SendOrderConfirmationEmail(int orderId)
{
// 寄出訂單確認信
}
public void SendShippingNotification(int orderId)
{
// 寄出出貨通知
}
}
乾乾淨淨,沒有任何多餘的東西.小明要實作的每一個 method 都是他真正需要的.沒有 NotImplementedException,沒有垃圾程式碼,更沒有 runtime 爆炸的風險.
拆了之後,那原本需要全部功能的怎麼辦?
你可能會想:「拆是很好啦,但如果有一個 class 真的需要全部的功能呢?難道要它實作四個 interface?」
答案是:沒問題啊,C# 本來就支援一個 class 同時實作多個 interface.
public class FullOrderService
: IOrderManagement, IOrderQuery, IOrderNotification, IOrderReporting
{
public Order CreateOrder(Cart cart) { /* ... */ }
public void CancelOrder(int orderId) { /* ... */ }
public void RefundOrder(int orderId) { /* ... */ }
public OrderStatus GetOrderStatus(int orderId) { /* ... */ }
public IEnumerable<Order> GetOrderHistory(int customerId) { /* ... */ }
public void SendOrderConfirmationEmail(int orderId) { /* ... */ }
public void SendShippingNotification(int orderId) { /* ... */ }
public void GenerateInvoicePdf(int orderId) { /* ... */ }
public void ExportOrdersToCsv(DateTime from, DateTime to) { /* ... */ }
}
需要全部功能的就實作全部的 interface,只需要部分功能的就只實作需要的那幾個.大家各取所需,皆大歡喜.
讓我們用一個簡單的圖來看拆開前跟拆開後的差別:
那到底拆多小才剛好?
這是每個人學了 ISP 之後都會問的問題:「那我是不是每個 method 都要拆成一個 interface?」
當然不是.如果你把每個 method 都拆成一個獨立的 interface,那你的程式碼會變成另一種噩夢:interface 滿天飛,每個 class 後面掛著一長串的 interface 清單,光是看就頭昏眼花了.
ISP 的重點不是「越小越好」,而是「按照使用者的需求來分組」.
怎麼判斷要不要拆?這裡提供幾個實用的思考方向:
1. 看「誰在用」
如果你發現不同的 class 在實作同一個 interface 時,總是有一部分 method 永遠寫 throw new NotImplementedException(),那就是一個很強的訊號,代表這個 interface 該拆了.那些不被實作的 method 跟其他 method 根本不屬於同一個族群.
2. 看「職責」
如果一個 interface 裡面的 method 分屬不同的職責領域(例如查詢和通知和報表),那它們很可能不應該待在同一個 interface 裡面.這和 Single Responsibility Principle (SRP) 的精神是一致的:一個單元應該只有一個改變的理由.如果「通知的邏輯變了」和「報表的格式變了」是兩個完全不同的改變理由,那它們就不應該被塞進同一個 interface.
3. 看「一起變動的頻率」
如果幾個 method 總是一起被修改、一起被呼叫、一起被測試,那它們留在同一個 interface 裡是合理的.但如果某些 method 的變動週期跟其他 method 完全無關,那就是該分家的時候了.
一個更細緻的例子
讓我們再看一個稍微不一樣的場景,幫助你建立更清楚的直覺.
假設我們有一個使用者管理的 interface:
public interface IUserService
{
User GetUser(int userId);
void UpdateUser(User user);
void DeleteUser(int userId);
bool Authenticate(string username, string password);
string GeneratePasswordResetToken(int userId);
void ResetPassword(string token, string newPassword);
}
這個 interface 看起來沒有前一個那麼誇張,只有 6 個 method.但仔細看,其實裡面混了兩件不同的事:「使用者資料的 CRUD」和「身分驗證」.
一個負責顯示使用者資料的頁面,需要 GetUser 和 UpdateUser,但不需要 Authenticate 和密碼重設.一個負責登入功能的模組,需要 Authenticate,但不需要 DeleteUser.
所以拆開會更好:
public interface IUserRepository
{
User GetUser(int userId);
void UpdateUser(User user);
void DeleteUser(int userId);
}
public interface IAuthenticationService
{
bool Authenticate(string username, string password);
string GeneratePasswordResetToken(int userId);
void ResetPassword(string token, string newPassword);
}
拆完之後,每個使用端只需要依賴自己真正需要的 interface.而且你在寫單元測試的時候(還記得之前聊過的 interface 和 unit test 的關係嗎?),mock 的對象也變得更精準了.你不用為了測試登入功能而去 mock 一堆 GetUser、DeleteUser 的行為,只需要 mock IAuthenticationService 就夠了.
ISP 和 DI 的關係
如果你有跟著這個系列的文章一路看下來的話,你可能已經發現了:ISP 和依賴注入 (DI) 是天生一對.
在 DI 的世界裡,我們透過建構式注入(Constructor Injection)將 interface 傳進去.如果你的 interface 又肥又大,那注入進去之後,這個 class 就被迫依賴了一大堆它用不到的能力.這不但違反了 ISP,也讓 DI 的好處打了折扣.
相反地,如果你的 interface 拆得乾淨俐落,每個 class 只注入它真正需要的 interface,那整個系統的依賴關係就會非常清晰:
public class OrderController
{
private readonly IOrderManagement _orderMgmt;
private readonly IOrderQuery _orderQuery;
// 建構式只注入這個 controller 真正需要的東西
public OrderController(IOrderManagement orderMgmt, IOrderQuery orderQuery)
{
_orderMgmt = orderMgmt;
_orderQuery = orderQuery;
}
public Order PlaceOrder(Cart cart)
{
return _orderMgmt.CreateOrder(cart);
}
public OrderStatus CheckStatus(int orderId)
{
return _orderQuery.GetOrderStatus(orderId);
}
}
看到沒?OrderController 只依賴 IOrderManagement 和 IOrderQuery,它根本不知道 IOrderNotification 和 IOrderReporting 的存在.這就是乾淨的依賴關係.任何人打開 OrderController 的建構式,一眼就知道它需要什麼、不需要什麼.
Hope it helps!