畫室的花名冊,通常不只一本
一本記著「誰是誰的徒弟」,另一本記著「誰負責哪一種顏料的調配權限」
兩本冊子,乍看是獨立的紀錄,但仔細一看
每收一位新學徒,兩本冊子都要各補一行,而且順序完全對應師傅收徒弟時,總是同時想著:「這個名字,要記兩次」
Day 09 建立了 INotificationChannel 的繼承體系:EmailNotificationChannel、SmsNotificationChannel、PushNotificationChannel
團隊後來加了一個「發送頻率限制」的需求
「同一位客戶,每種管道都有各自的發送上限(Email 一天 5 封、簡訊一天 2 則、推播不限)」
有人選擇另外開一套繼承體系來管理限制邏輯:
public abstract class ChannelRateLimiter
{
public abstract bool IsWithinLimit(Customer customer);
}
public class EmailRateLimiter : ChannelRateLimiter
{
public override bool IsWithinLimit(Customer customer) => customer.EmailSentToday < 5;
}
public class SmsRateLimiter : ChannelRateLimiter
{
public override bool IsWithinLimit(Customer customer) => customer.SmsSentToday < 2;
}
public class PushRateLimiter : ChannelRateLimiter
{
public override bool IsWithinLimit(Customer customer) => true;
}
三個管道類別,配上三個限制器類別,兩套繼承體系,結構完全對稱
發送通知的地方,得同時操作兩套體系:
public void NotifyCustomer(Customer customer, INotificationChannel channel)
{
ChannelRateLimiter limiter = channel switch
{
EmailNotificationChannel => new EmailRateLimiter(),
SmsNotificationChannel => new SmsRateLimiter(),
PushNotificationChannel => new PushRateLimiter(),
_ => throw new ArgumentException("未知的通知管道")
};
if (limiter.IsWithinLimit(customer))
channel.Send(customer);
}
這段 switch,其實不是新問題,它是 Day 09 才處理掉的舊症狀,悄悄從另一個入口溜了回來
更麻煩的是,半年後要加 LINE 通知(Day 12 的 LineNotificationChannel),意味著:
LineNotificationChannel
LineRateLimiter
switch,把兩者配對起來新增一種管道,變成了三件事,而且第二、第三件事,很容易忘記
解法的核心思想:「這個管道每天最多能發幾次」,跟「這個管道怎麼發送」,其實是同一個物件該回答的兩個問題,不該拆成兩個平行的類別體系
換成程式碼,這是把 RateLimiter 體系的職責,搬移回 INotificationChannel 自己身上:
public interface INotificationChannel
{
void Send(Customer customer);
string Preview(Customer customer);
bool IsWithinLimit(Customer customer);
}
每個管道類別,自己回答自己的限制邏輯:
public class EmailNotificationChannel : INotificationChannel
{
private readonly IEmailService _emailService;
public EmailNotificationChannel(IEmailService emailService) => _emailService = emailService;
public void Send(Customer customer) =>
_emailService.Send(customer.Email, "Order Confirmed", Preview(customer));
public string Preview(Customer customer) => $"親愛的 {customer.Name},您的訂單已確認。";
public bool IsWithinLimit(Customer customer) => customer.EmailSentToday < 5;
}
public class SmsNotificationChannel : INotificationChannel
{
private readonly ISmsService _smsService;
public SmsNotificationChannel(ISmsService smsService) => _smsService = smsService;
public void Send(Customer customer) => _smsService.Send(customer.Phone, Preview(customer));
public string Preview(Customer customer) => $"訂單確認:{customer.Name} 您好。";
public bool IsWithinLimit(Customer customer) => customer.SmsSentToday < 2;
}
ChannelRateLimiter 整套體系可以直接刪除
呼叫端恢復乾淨,switch 也一併消失:
public void NotifyCustomer(Customer customer, INotificationChannel channel)
{
if (channel.IsWithinLimit(customer))
channel.Send(customer);
}
未來新增 LineNotificationChannel,只需要在這一個類別裡,把 Send、Preview、IsWithinLimit 都寫好,就不用還要提心吊膽還有哪個地方沒改到
不是所有「看起來對應」的兩套類別,都該合併:
從 Day 15 到 Day 17,我們看到三種「改動變貴」的方式:
三者的解法看似不同,提煉、搬移、合併,但目標始終一致(讓每一種變化的理由,都只對應到一個修改的地方)
switch 或 if/is 來配對兩者嗎?圓頂立起來了,透視法找到了,灰泥的時間壓力也想清楚了
明天我們前往佛羅倫斯的另一位大師,阿伯提
去看看他留給我們的節制美學:多餘的裝飾,不是美,是負擔
模組四,正式開工