The two most important days in your life are the day you were born and the day you find out why. -Mark Twain

顯示具有 物件導向程式設計 標籤的文章。 顯示所有文章
顯示具有 物件導向程式設計 標籤的文章。 顯示所有文章

#112 出神入化的用介面 第八集:Interface 與 Strategy Pattern 把「怎麼做」從「做什麼」分離出來

如果你一路追這個系列,從第一集的 interface 是什麼元件之間怎麼透過 interface 溝通多型的威力,到後面聊的 單元測試依賴注入,再到上一集的 介面隔離原則 (ISP),你對 interface 這東西應該已經很有感覺了.

這一集,我想帶你回到第一集那個寄信的程式,那個例子當初只用來說明什麼是 interface,但其實它還藏著更多東西可以挖.這次我們要用它來聊一個很實用的設計模式:Strategy Pattern.它的核心精神,用一句話講就是:把怎麼做 從 做什麼 分離出來.

在這部落格裡,我沒打算寫太多有關物件導向 design pattern 的文章,而許多 design pattern 的做法其實在 interface 有很大的關係.雖然現在很流行用 AI coding tool 來寫程式,但不見得都能做出很好的設計,畢竟許多需求我們不知道該怎麼寫,在沒有好的資料輸入情況下,語言模型也不見得會給你較佳的設計,所以自己還是要多懂一點比較可靠.

第一集的故事是這樣的:你負責寫一個寄信程式,公司各部門都會把信件內容丟給你寄出去.但 A 部門有 A 部門的 email class,B 部門有 B 部門的 email class,如果你為每一個部門都寫一個 SendEmail(),程式碼就會蠢到不行.

當時的解法是:定義一個 IMailContent 的 interface,讓每個部門的 email class 都去實作它.這樣一來,不管是哪個部門的信件內容,都穿上了 IMailContent 這件外衣,你的寄信程式只要認得這件外衣就好,一個 SendEmail() 通吃所有部門.

我當時是這樣描述的:Interface 提供了 object 一種外衣,不同的 email content object 只要有相同的外衣 (IMailContent),都可以在 SendEmail() 裡面使用.一個 object 可以有多個外衣,因此使得物件導向程式設計變得很彈性也很有趣.

第一集那件外衣是穿在 "資料" 上的 —— 也就是信件的內容.今天這一集,我們要把同樣的概念穿到一個完全不同的東西上面 - 寄信這個動作本身.這就是我說的更深一層的應用.

隨著時間過去與業務成長,需求也變複雜了.現在寄一封信這件事,有好幾種不同的做法:

  • 有些信走公司自己架的 SMTP server (省錢,但量大時容易塞車)
  • 行銷部門的大量電子報改用 SendGrid 這類第三方服務 (送達率高、有統計報表)
  • 跑在 AWS 上的系統則直接用 AWS SES (跟雲端環境整合得好、便宜)

同樣是寄信,但底層怎麼寄,已經有三種完全不同的方式了.那你會怎麼寫?很多人第一直覺會用以下的程式碼來做,用一個參數告訴程式要走哪一種,然後用 if/else 去判斷.

public class EmailService
{
    public void Send(EmailMessage message, string provider)
    {
        if (provider == "SMTP")
        {
            // 連到公司自架的 SMTP server
            var server = new SmtpServer("mail.mycompany.com");
            server.Connect();
            server.SendMail(message.To, message.Subject, message.Body);
            server.Disconnect();
        }
        else if (provider == "SendGrid")
        {
            // 呼叫 SendGrid 的 API
            var client = new SendGridClient("API_KEY_HERE");
            client.Post("/mail/send", message.To, message.Subject, message.Body);
        }
        else if (provider == "AWS_SES")
        {
            // 用 AWS SES 的 SDK
            var ses = new AmazonSimpleEmailServiceClient();
            ses.SendEmail(message.To, message.Subject, message.Body);
        }
        else
        {
            throw new ArgumentException("不支援的寄信方式");
        }
    }
}

這程式能跑,乍看之下也沒什麼大問題.但這種寫法會慢慢變成一場惡夢.

我們來想一下接下來會發生什麼事.

過了一段時間後,公司又要接一個新的寄信服務,比如說 Mailgun.你怎麼辦? 你只能把 Send() 這個 method 再打開,塞進一段新的 else if.再過半年又來一個,你又得打開它,再塞一段.這個 method 就像吹氣球一樣,越長越大,最後變成一坨幾百行、沒人敢碰的怪物.

這裡有幾個很實際的問題:

問題一:每次新增都要動到舊的程式碼.你只是想加一個新的寄信方式,卻被迫去改一個已經在正常運作的 method.每改一次,就有機會手滑改壞旁邊那些原本好好的邏輯.本來只想加東西,結果把別人的功能弄爆了,這種事在工程界天天上演.軟體設計裡有個很有名的原則叫做開放封閉原則 (Open-Closed Principle),講的就是 "對擴充開放,對修改封閉",理想的狀況是,要加新功能時用加新檔案的方式,而不是去改舊檔案.這個 if/else 的寫法剛好完全違反了這件事.

問題二:所有寄信方式全部攪在一起.SMTP 的連線邏輯、SendGrid 的 API 呼叫、AWS SES 的 SDK 操作,三套八竿子打不著的程式碼,硬是被塞在同一個 method 裡面.它們各自有各自的細節、各自的相依套件,現在全部纏在一起,光是讀都讀得很累.

問題三:根本沒辦法好好測試.還記得我們在前面的文章聊過 interface 跟單元測試的關係嗎?現在這個 Send() 你要怎麼測?你想測 SendGrid 那段邏輯有沒有正確組出參數,結果你一呼叫 Send(),它就真的去連 SMTP server 或真的去打 AWS 的 API 了.測試環境哪來這些東西? 這個 method 把決定用哪種方式 跟 實際去寄 死死綁在一起,導致它幾乎無法測試.

講到這裡,解法其實已經呼之欲出了.既然這些寄信方式做的是同一件事 (寄信),只是做法不同,那我們是不是可以幫它們設計一件共同的外衣? 此時又回答了 interface 的本質了.沒錯,這就是 interface 出場的時候了.我們先問自己一個問題:不管是 SMTP、SendGrid 還是 AWS SES,它們共同的行為是什麼?

答案很簡單:把一封信寄出去.就這樣.於是我們可以定義這樣一件外衣:

public interface IEmailSender
{
    void Send(EmailMessage message);
}

這件外衣只描述了一件事: 我會寄信,你給我一個 EmailMessage,我就把它送出去.至於是怎麼送的?外衣不管,那是各家自己的事.

接著,讓三種寄信方式各自去實作這件外衣,一種一個 class 如下所示

public class SmtpEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var server = new SmtpServer("mail.mycompany.com");
        server.Connect();
        server.SendMail(message.To, message.Subject, message.Body);
        server.Disconnect();
    }
}

public class SendGridEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var client = new SendGridClient("API_KEY_HERE");
        client.Post("/mail/send", message.To, message.Subject, message.Body);
    }
}

public class AwsSesEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var ses = new AmazonSimpleEmailServiceClient();
        ses.SendEmail(message.To, message.Subject, message.Body);
    }
}

看到差別了嗎?原本那一大坨 if/else,現在被拆成了三個各自獨立、乾乾淨淨的 class.每個 class 只專心做好自己那一種寄信方式,互不干擾.SMTP 的歸 SMTP,SendGrid 的歸 SendGrid,誰也不認識誰.

這裡的每一個 class,就是一個策略 (strategy).SmtpEmailSender 是一個策略,SendGridEmailSender 是另一個策略,AwsSesEmailSender 又是一個策略.它們穿著同一件外衣 (IEmailSender),但裡面各做各的.

外衣定義好了,策略也都實作好了,那真正使用它的主程式會變成如下:

public class EmailService
{
    private readonly IEmailSender _sender;

    public EmailService(IEmailSender sender)
    {
        _sender = sender;
    }

    public void SendWelcomeEmail(string userEmail)
    {
        var message = new EmailMessage
        {
            To = userEmail,
            Subject = "歡迎加入!",
            Body = "感謝你的註冊,很高興認識你..."
        };

        _sender.Send(message);   // 注意:EmailService 根本不知道背後是 SMTP 還是 SendGrid
    }
}

請你仔細盯著那行 _sender.Send(message) 看.EmailService 在做的事情,是準備好一封歡迎信,然後寄出去.它從頭到尾完全不知道這封信是透過 SMTP 寄的,還是 SendGrid 寄的,還是 AWS SES 寄的.它只認得 IEmailSender 這件外衣,知道這東西會寄信,這樣就夠了.

這就是把怎麼做從做什麼分離的真正意思:

  • EmailService 負責的是做什麼 -> 寄一封歡迎信
  • 各個 Sender 負責的是怎麼做 -> 用什麼管道、什麼技術去寄

這兩件事,被 IEmailSender 這件外衣俐落地切開了.

現在最爽的地方來了.如果哪天要換寄信方式,程式怎麼改?

// 開發環境,用公司的 SMTP 就好
var emailService = new EmailService(new SmtpEmailSender());

// 要改用 SendGrid?換一件外衣就好,EmailService 一個字都不用動
var emailService = new EmailService(new SendGridEmailSender());

// 改用 AWS SES?也是一樣
var emailService = new EmailService(new AwsSesEmailSender());

就這樣.你想用哪一種,就在建立 EmailService 的時候,丟一件對應的外衣給它穿上去.EmailService 本身的程式碼完全不需要改動.

而且,如果哪天又要接 Mailgun 呢?還記得前面 if/else 版本要把 method 打開來改嗎?現在你完全不用碰任何舊程式碼,只要新增一個 MailgunEmailSender class,讓它實作 IEmailSender 就好.舊的東西一個都不用動,這就是前面提到的 "對擴充開放,對修改封閉".

有沒有覺得這個換外衣的感覺,跟第一集很像? 第一集是各部門的信件內容穿上同一件外衣,讓寄信程式通吃.這一集是各種寄信方式穿上同一件外衣,讓主程式自由替換.同一個概念,一個套在資料上,一個套在行為上.這就是 interface 的威力,它不只能描述資料長什麼樣,更能描述一個東西會做什麼動作.

如果你有看第六集講依賴注入 (DI) 的那篇,看到上面那個建構式應該會會心一笑.沒錯,Strategy Pattern 跟 DI 根本是絕配.

EmailService 透過 constructor 接收一個 IEmailSender,這完全就是建構式注入 (Constructor Injection) 的標準做法.實務上你根本不會手動去 new 那些 sender,而是交給 DI 容器處理.例如在 ASP.NET Core 裡,你可以這樣設定:

// 整個專案要用哪種寄信策略,集中在這裡決定
builder.Services.AddScoped<IEmailSender, SendGridEmailSender>();

之後所有需要寄信的地方,只要在建構式裡寫上 IEmailSender,DI 容器就會自動把 SendGridEmailSender 注入進去.哪天要換策略? 改這一行就好,整個專案其他地方完全不用動.策略的 "選擇" 被收斂到了一個地方,乾淨又好管理.

看到這裡,比較敏銳的你可能會皺眉頭:「等等,你只是把那串 if/else 搬到別的地方去了吧? 總得有人決定要用哪個策略吧!」 問得好,這是個很重要的問題.答案是:對,那個判斷還在,但它被移到了一個正確的地方,而且只剩一個.

差別在哪?原本的 if/else 散落在核心的寄信邏輯裡面,每次寄信都要跑一次判斷,而且判斷跟實作攪在一起.現在如果你真的需要在 runtime 根據 configuration 來決定用哪個策略,你可以把這個判斷集中在一個地方,通常是程式啟動時的設定處或是一個專門的 factory,如下所示:

public IEmailSender CreateSender(string provider)
{
    return provider switch
    {
        "SMTP"     => new SmtpEmailSender(),
        "SendGrid" => new SendGridEmailSender(),
        "AWS_SES"  => new AwsSesEmailSender(),
        _          => throw new ArgumentException("不支援的寄信方式")
    };
}

關鍵的差異是:這個判斷現在只出現在一個地方,而且它只負責挑策略,挑完就退場了.真正在做事的 EmailService 依然乾乾淨淨,完全不受影響."選擇策略" 跟 "執行策略" 這兩件事被分開了,這才是重點.這跟原本那種每個 method 裡都重複一份判斷、判斷跟實作糾纏不清的狀況是天差地遠的兩回事.

學了一個新工具,最怕的就是看什麼都想用它.所以這裡也誠實講一下,什麼時候適合、什麼時候不適合.當你發現自己符合下面這些情況時,Strategy pattern 通常會幫上忙:

  • 同一件事有好幾種不同的做法,而且未來可能還會增加 (像寄信、付款方式、檔案壓縮格式、運費計算規則)
  • 你的程式裡開始出現越來越長的 if/else 或 switch,每個分支都是同一類事情的不同做法
  • 你希望這些做法可以隨時抽換,或是想針對每一種做法分別寫單元測試

這跟我在之前的文章談 ISP 時的提醒一樣,設計模式都是拿來解決問題的,不是拿來炫技的.先確定你真的有那個問題,再用對應的工具去解.為了用而用,往往比不用還糟.

Hope it helps!

Share:

#109 出神入化的用介面 第七集 Interface 的設計原則 — 多小才算剛剛好?

在前幾集的文章裡,我們花了不少篇幅在談 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!

Share:

#108 出神入化的用介面 第六集:Interface 與 Dependency Injection

第二集的內容中,我們用了一個極為簡化的例子來說明物件如何在大型軟體系統中移動.當時的做法是在 CommonLibrary 裡放了一個 static 的 Dictionary,讓各元件把自己實做好的物件「存」進去,其他元件再從這個 Dictionary 裡「取」出來使用.透過這樣的方式,ClassLibrary1 和 ClassLibrary2 彼此不需要互相認識 (不需要加 reference),就能使用對方所提供的功能.

這個做法雖然簡單好懂,但如果你在實際的專案中也這樣做的話,隨著系統越來越大,你會發現幾個問題:

第一,你必須自己管理這個 Dictionary,包括什麼時候該把物件放進去,key 要用什麼字串,以及取出來的時候要記得做型別轉換.第二,每個團隊都必須知道這個 Dictionary 的存在,並且要遵守一些潛規則,例如 key 不能重複,放入的順序不能搞錯等等.第三,當物件之間的相依關係越來越複雜時,手動管理這些註冊和取用的程式碼會變得相當繁瑣且容易出錯.

那麼,有沒有什麼方法可以把這些手動管理的工作交給別人來做呢?答案就是這一集要談的主題:Dependency Injection (DI).

先回顧第二集的做法

在第二集裡,CommonLibrary 提供了一個 ObjectContainer,裡面有一個 Dictionary 來儲存各元件的物件:

public class ObjectContainer
{
    public static Dictionary<string, IOperation> Operations = new Dictionary<string, IOperation>();
    public static Dictionary<string, Form> Dialogs = new Dictionary<string, Form>();
}

然後各元件在啟動時把自己的物件註冊進去:

// ClassLibrary2 的註冊
public static class Starter
{
    public static void Register()
    {
        ObjectContainer.Operations["operation2"] = new Operation2();
        ObjectContainer.Dialogs["library2"] = new Lib2WinForm1();
    }
}

最後在 WindowsFormsApp1 啟動的時候呼叫各元件的註冊方法:

static void Main()
{
    ClassLibrary1.Starter.Register();
    ClassLibrary2.Starter.Register();
    Application.Run(new Form1());
}

而 ClassLibrary1 要使用 ClassLibrary2 的功能時,就去 Dictionary 裡撈:

if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
{
    int result = op.AddIntOperation(5);
}

如果你仔細看這整個流程,你會發現它其實做了三件事情:第一,定義一份 interface 作為雙方的合約 (contract);第二,提供一個容器 (Dictionary) 來存放物件;第三,在程式啟動時把物件註冊到容器裡.這三件事情就是 Dependency Injection Container 的核心概念.換句話說,第二集的做法就是一個最原始的、手動版的 DI Container.

什麼是 Dependency Injection

Dependency Injection 這個名字聽起來很嚇人,但其實概念非常簡單.所謂的 "Dependency" 就是一個物件所依賴的其他物件.所謂的 "Injection" 就是把這個依賴的物件「注入」到需要它的地方.

舉個日常生活的例子,你去餐廳吃飯,你不需要自己跑到廚房去拿菜,服務生會把菜端到你的桌上.在這個比喻裡,菜就是你的 dependency,服務生把菜端給你就是 injection,而餐廳的出菜系統就是 DI Container.你只需要告訴服務生你要什麼菜 (透過菜單,也就是 interface),至於這道菜是哪個廚師做的、用什麼鍋子、幾點開始煮的,你完全不需要知道.

回到程式的世界,在第二集的做法裡,ClassLibrary1 要使用 IOperation 時,必須自己去 Dictionary 裡面找,要知道 key 是什麼,還要做型別的判斷.這就好像你去餐廳吃飯,還得自己走到廚房去翻鍋子找你點的菜一樣.而 DI 的做法則是讓框架自動把 IOperation 的實作送到 ClassLibrary1 手上,ClassLibrary1 只需要說「我需要一個 IOperation」就好了.

自己動手做一個簡單的 DI Container

在談現成的 DI 框架之前,讓我們先自己動手做一個極為簡化的 DI Container,這樣你就能完全理解它的運作原理.其實它的本質就是一個用 Type 當作 key 的 Dictionary,取代第二集裡用字串當作 key 的 Dictionary:

public class SimpleDIContainer
{
    // 用 Type 當作 key,存放的是「如何建立這個物件」的方法
    private Dictionary<Type, Func<object>> _registrations 
        = new Dictionary<Type, Func<object>>();

    // 註冊: 告訴 Container 「當有人要 TInterface 時,請建立 TImplementation」
    public void Register<TInterface, TImplementation>() 
        where TImplementation : TInterface, new()
    {
        _registrations[typeof(TInterface)] = () => new TImplementation();
    }

    // 取得: 根據 interface 的型別,自動建立並回傳對應的實作
    public TInterface Resolve<TInterface>()
    {
        if (_registrations.TryGetValue(typeof(TInterface), out var factory))
        {
            return (TInterface)factory();
        }

        throw new Exception($"Type {typeof(TInterface).Name} is not registered.");
    }
}

這個 SimpleDIContainer 做的事情和第二集的 ObjectContainer 非常相似,但有一個關鍵的不同:它用 Type 來當 key,而不是字串.這表示你不再需要記住每個物件對應的字串是什麼,也不會因為打錯字而導致找不到物件.

接著來看看怎麼用它.首先是註冊的部分:

static void Main()
{
    var container = new SimpleDIContainer();

    // 告訴 Container: 當有人需要 IOperation 時,請給他 Operation2 的實體
    container.Register<IOperation, Operation2>();

    // 取得 IOperation 的實作
    IOperation op = container.Resolve<IOperation>();
    int result = op.AddIntOperation(5);  // result = 7
}

對比一下第二集的做法:

// 第二集: 用字串當 key,手動放入和取出
ObjectContainer.Operations["operation2"] = new Operation2();
ObjectContainer.Operations.TryGetValue("operation2", out IOperation op);

// 這一集: 用 Type 當 key,透過泛型自動對應
container.Register<IOperation, Operation2>();
IOperation op = container.Resolve<IOperation>();

你可以看到,核心概念一模一樣,都是「先存再取」,只是手段從字串對應變成了型別對應,讓編譯器可以在編譯時就幫你檢查型別是否正確,不用等到執行時期才發現字串打錯.

讓 Container 自動注入到建構子

上面的 SimpleDIContainer 雖然已經比手動 Dictionary 好了一些,但使用者還是得自己呼叫 Resolve 來取得物件.真正的 DI 精神是讓 Container 自動把 dependency 注入到需要它的地方.最常見的方式就是透過建構子 (constructor) 來注入.

我們把 SimpleDIContainer 稍微加強一下,讓它能夠分析建構子的參數,並且自動注入對應的物件:

public class DIContainer
{
    private Dictionary<Type, Type> _registrations = new Dictionary<Type, Type>();

    public void Register<TInterface, TImplementation>() 
        where TImplementation : class, TInterface
    {
        _registrations[typeof(TInterface)] = typeof(TImplementation);
    }

    public T Resolve<T>()
    {
        return (T)Resolve(typeof(T));
    }

    private object Resolve(Type type)
    {
        // 如果有註冊,就找到對應的實作型別
        if (_registrations.TryGetValue(type, out Type implementationType))
        {
            return CreateInstance(implementationType);
        }

        // 如果沒有註冊但本身是可建立的類別,也嘗試建立
        if (!type.IsAbstract && !type.IsInterface)
        {
            return CreateInstance(type);
        }

        throw new Exception($"Type {type.Name} is not registered.");
    }

    private object CreateInstance(Type type)
    {
        // 找到建構子
        var constructor = type.GetConstructors().First();
        
        // 取得建構子的參數型別,並且遞迴地解析每一個參數
        var parameters = constructor.GetParameters()
            .Select(p => Resolve(p.ParameterType))
            .ToArray();

        // 用解析好的參數來建立物件
        return Activator.CreateInstance(type, parameters);
    }
}

這個加強版的 Container 會做一件很重要的事:當它要建立一個物件時,它會去看這個物件的建構子需要哪些參數,然後遞迴地把每一個參數對應的物件也建立出來並且注入進去.

於是 ClassLibrary1 的程式碼可以寫成這樣:

public class Lib1WinForm1 : Form
{
    private readonly IOperation _operation;

    // Container 會自動把 IOperation 的實作注入到這個建構子
    public Lib1WinForm1(IOperation operation)
    {
        _operation = operation;
        InitializeComponent();
    }

    private void button2_Click(object sender, EventArgs e)
    {
        // 直接使用,不需要再去 Dictionary 裡面找
        int i = 5;
        richTextBox1.Text += $"int starts at {i}\n";
        i = _operation.AddIntOperation(5);
        richTextBox1.Text += $"int becomes {i} after Operation2\n";

        string s = "aBc";
        richTextBox1.Text += $"string starts as {s}\n";
        s = _operation.ChangeStringOperation(s);
        richTextBox1.Text += $"string becomes {s} after Operation2";
    }
}

注意看 Lib1WinForm1 的建構子,它宣告了一個 IOperation 的參數.當 DI Container 要建立 Lib1WinForm1 的時候,它會看到這個建構子需要一個 IOperation,於是就自動去找已經註冊好的 Operation2 來注入.這就是 "Dependency Injection" 這個名字的由來.

對比第二集的做法,ClassLibrary1 不再需要知道 Dictionary 的 key 是什麼字串,不需要做 TryGetValue,也不需要處理找不到的情況.它只需要在建構子裡說「我需要一個 IOperation」,Container 就會自動把正確的實作塞給它.這就像你在餐廳只需要看菜單點菜,不需要自己去廚房找菜一樣.

三種常見的注入方式

上面的例子是透過建構子來注入,這是最常見也是最推薦的方式,叫做 Constructor Injection.除此之外,還有另外兩種方式:

第一種是 Property Injection,就是透過設定屬性來注入:

public class Lib1WinForm1 : Form
{
    // 透過 Property 注入
    public IOperation Operation { get; set; }
}

第二種是 Method Injection,就是透過方法的參數來注入:

public class Lib1WinForm1 : Form
{
    public void DoSomething(IOperation operation)
    {
        // 透過方法參數注入
        int result = operation.AddIntOperation(5);
    }
}

在實務上,Constructor Injection 是最被推薦的方式,因為它可以確保物件在被建立的時候就已經擁有它所需要的所有 dependency,不會有遺漏的情況.而且透過建構子的參數,你可以一眼就看出這個類別依賴了哪些其他物件,程式碼的意圖很明確.

物件的生命週期

在第二集的做法中,我們把 Operation2 的實體放進 Dictionary 以後,它就一直待在那裡直到整個程式結束.但在真實的系統中,不同的物件可能需要不同的生命週期.有些物件只需要一個就夠了 (例如設定檔的管理),有些物件每次使用都需要一個新的 (例如資料庫的連線),還有些物件在同一個請求範圍內需要共用同一個實體.

一般來說,常見的 DI Container 都會提供以下三種生命週期的管理:

Singleton: 整個應用程式只會建立一個實體,所有人共用同一個.以第二集的做法來說,物件放進 Dictionary 之後就不會再變了,所以等同於 Singleton 的概念.

Transient: 每次有人要求時都建立一個全新的實體.如果你的物件有狀態,而且不同的使用者不應該共用這個狀態,就適合使用 Transient.

Scoped: 在同一個範圍內共用一個實體,離開這個範圍後就銷毀.例如在一個 Web 應用中,同一次 HTTP Request 裡面的所有程式碼共用同一個實體,下一次 Request 進來時再建立一個新的.

如果你的系統需要更細膩的生命週期控管,手動用 Dictionary 來管理就會變得很痛苦,這時候 DI Container 的價值就顯現出來了.

Interface 在 DI 中扮演的角色

從第一集到第二集再到現在,Interface 一直都是整個架構的核心.它的角色就是定義合約 (contract),讓提供服務的一方和使用服務的一方之間有一份共同的協議,而雙方不需要直接認識彼此.

在 DI 的架構中,Interface 更是不可或缺的角色.因為 DI Container 的運作原理就是:你告訴 Container「當有人要求 Interface A 的時候,請提供 Class B 的實體」.Container 就像是一個仲介,它認識所有的 interface 和實作之間的對應關係,而使用者只需要跟 Container 說他需要什麼 interface,Container 就會在 runtime 時把正確的實作注入進去.

這樣做最大的好處就是,如果有一天 ClassLibrary2 團隊決定把 Operation2 替換成 Operation2V2,只需要改一行註冊的程式碼就好:

// 原本
container.Register<IOperation, Operation2>();

// 替換成新版本,只改這一行
container.Register<IOperation, Operation2V2>();

ClassLibrary1 團隊的程式碼完全不需要改動,因為它從頭到尾只認識 IOperation 這個 interface,至於背後是誰實做的,它不知道也不需要知道.在第二集的做法中,如果要替換實作,你也需要改一行程式碼,但你還得確保 Dictionary 的 key 沒有變、其他使用這個 key 的地方也都要對應到,這些在大型系統中是容易出差錯的地方.

從手動管理到自動管理

讓我們把第二集和這一集做一個對照,你會更清楚看到兩者之間的關係:

在第二集中,CommonLibrary 提供了 ObjectContainer (一個用字串當 key 的 Dictionary),各元件在啟動時手動將自己的物件註冊到 Dictionary 裡,使用的一方再手動從 Dictionary 裡用字串取出物件來用.這整個流程中的「註冊」和「取用」都是手動完成的.

而在 DI 的做法中,DI Container 用 Type 來當 key (取代字串),各元件透過泛型方法來註冊 interface 和實作的對應關係,使用的一方只需要在建構子宣告 interface 參數,Container 就會在 runtime 自動注入正確的實作.「註冊」仍然是手動的 (你需要告訴 Container 誰對應誰),但「取用」變成自動的了.

所以本質上,DI Container 就是一個進化版的 Dictionary,它把第二集中我們手動做的事情包裝起來,讓整個流程更自動化、更不容易出錯、也更好維護.

實務上的 DI 框架

上面我們自己手寫了一個簡化版的 DI Container 來說明原理,但在實務的專案中,你不需要自己造輪子,幾乎所有主流的程式語言和框架都有提供現成的 DI Container 可以直接使用.

在 C# 的世界裡,除了框架本身內建的 DI 機制以外,也有像 Autofac、Ninject、Unity 等老牌的 DI Container 套件可以選擇,它們各自提供了更多進階的功能,如自動掃描組件來註冊、模組化的註冊、攔截器 (interceptor) 等.在 Java 的世界裡,Spring Framework 的 IoC Container 是最經典的代表,它的概念和我們今天談的完全一樣.在 Python 中也有像 dependency-injector 這類的套件.不管是哪一種語言或框架,DI 的核心精神都是一樣的:透過 interface (或抽象類別) 定義合約,讓 Container 在 runtime 自動注入正確的實作.

值得一提的是,DI 和 IoC (Inversion of Control,控制反轉) 這兩個名詞常常被放在一起談.IoC 是一個比較抽象的設計原則,意思是「把控制權交出去」.在沒有 DI 的時候,一個物件需要什麼 dependency,它就自己去 new 一個出來,控制權在物件自己手上.而用了 DI 之後,物件不再自己建立 dependency,而是由外部 (Container) 來提供,控制權就「反轉」了.所以你可以把 DI 理解為實現 IoC 原則的一種具體做法.

小結

DI Container 帶來的好處包括:不需要手動管理 key 和 Dictionary、物件的生命週期可以被統一管理、替換實作只需要改一行註冊的程式碼、以及程式碼的可測試性大幅提升 (因為你可以很容易地在測試時注入 mock 物件).

如果你還沒有在專案中使用 DI,我會建議你從小地方開始嘗試.先在一個新的小功能中試著用 DI Container 來管理物件的建立和注入,慢慢地你就會體會到它的好處,然後你再也不會想回到手動管理 Dictionary 的日子了.

Share:

#105 出神入化的用介面 第五集:Interface 與 Unit Test — 讓你的程式可以被測試

在前幾集的內容裡,我們透過寄信程式的例子說明了 Interface 如何幫助不同的團隊在不互相 "認識" 的情況下一起工作,也說明了當 Interface 需要修改時,該如何用繼承的方式讓新舊版本都能正常運作。這一集,我們繼續用同一個寄信程式的故事,但要切入一個完全不同的角度 — Unit Test。


你的程式,有辦法被測試嗎?

先問你一個問題。假設你今天寫好了第一集裡那個 SendEmail() method,你怎麼確認它有正確地執行呢?也許你會說,我執行程式,看一下信箱,如果信收到了,就代表它是對的。這個方法當然可行,但你有沒有想過,這樣的驗證方式有幾個很明顯的問題。

問題一,每次你改了 SendEmail() 裡的任何一行程式碼,你就得再跑一次整個程式,然後去信箱確認,這很麻煩。

問題二,如果你的測試信件伺服器今天不穩定或是連不上,你的測試就失敗了,但這個失敗並不是你的程式有問題,而是外部環境的問題,這樣的結果讓人很困惑。

問題三,如果你的程式有五十個 method,難道每一個都要用手動的方式去驗證嗎?

相信你在業界工作久了,應該對 Unit Test 這個詞不陌生。Unit Test 的精神就是讓每一個 method 都可以用程式碼自動驗證,讓你不需要靠手動的方式來確認程式有沒有正確執行。而 Interface,就是讓 Unit Test 變得可行的關鍵工具之一。


問題的根源 — 直接依賴 Class 讓測試變得很痛

讓我們回頭看第一集裡的寄信程式。在最原始的版本裡,SendEmail() 長這樣:

public void SendEmail(IMailContent mail)
{
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
    server.Close();
}

你看到第一行的 new EmailServer() 了嗎?這一行,就是讓這個 method 難以被測試的根本原因。

當 SendEmail() 裡面直接 new 出一個 EmailServer,這就代表你的程式和 EmailServer 這個 class 是緊緊綁在一起的,用業界的說法叫做 hard dependency。只要你呼叫 SendEmail(),它就一定會去嘗試連線到那台 IP 是 1.1.1.1 的 email 伺服器。如果你想寫一個 Unit Test 來驗證 SendEmail() 有沒有正確執行,你就被迫要依賴那台真實的伺服器。一旦那台伺服器不在,或是你的測試環境根本連不到那個 IP,你的 Unit Test 就永遠無法正常跑完,這樣的 Unit Test 是沒有意義的。


解法: 用 Interface 把「怎麼寄」抽離出來

從前幾集的內容,相信你已經能猜到解法的方向了。既然直接依賴 EmailServer class 是問題的根源,那麼我們就把 EmailServer 的行為抽成一個 Interface。

public interface IEmailServer
{
    void Connect(string ip, string username, string password);
    void Send(string receipent, string content, string subject, bool importance);
    void Close();
}

然後讓真正的 EmailServer 去實作這個 Interface:

public class EmailServer : IEmailServer
{
    public void Connect(string ip, string username, string password)
    {
        // 真正的連線邏輯
    }

    public void Send(string receipent, string content, string subject, bool importance)
    {
        // 真正的寄信邏輯
    }

    public void Close()
    {
        // 真正的關閉連線邏輯
    }
}

接下來,把原本的寄信程式改造一下。我們把它包成一個 class,並且讓 IEmailServer 從外面傳進來,而不是在 method 裡面直接 new:

public class EmailSender
{
    private readonly IEmailServer _server;

    public EmailSender(IEmailServer server)
    {
        _server = server;
    }

    public void SendEmail(IMailContent mail)
    {
        _server.Connect("1.1.1.1", "admin", "password");
        _server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
        _server.Close();
    }
}

你有沒有注意到,EmailSender 現在完全不知道傳進來的 IEmailServer 到底是哪一個 object,它只認得 IEmailServer 這個 Interface 的長相。


做一個假的 EmailServer

因為 EmailSender 現在依賴的是 IEmailServer 而不是真正的 EmailServer,所以在寫 Unit Test 的時候,我們完全可以自己做一個假的 EmailServer object 傳進去。這個假的 object 業界通常叫做 Mock 或是 Fake。

public class FakeEmailServer : IEmailServer
{
    public bool ConnectWasCalled { get; private set; }
    public bool SendWasCalled { get; private set; }
    public string LastReceipent { get; private set; }

    public void Connect(string ip, string username, string password)
    {
        ConnectWasCalled = true;
    }

    public void Send(string receipent, string content, string subject, bool importance)
    {
        SendWasCalled = true;
        LastReceipent = receipent;
    }

    public void Close() { }
}

這個 FakeEmailServer 不會真的連線到任何伺服器,也不會真的把信寄出去。它做的事情非常簡單,就是記錄「有沒有被呼叫過」以及「傳進來的值是什麼」。也許你現在覺得這樣的 class 感覺很蠢,但等一下你就會看到它的用途。


Unit Test 實際長什麼樣子

有了 FakeEmailServer,我們就可以寫出一個完全不依賴真實伺服器的 Unit Test:

[TestMethod]
public void SendEmail_ShouldCallSendOnServer_WithCorrectReceipent()
{
    // Arrange — 準備測試所需的物件
    var fakeServer = new FakeEmailServer();
    var sender = new EmailSender(fakeServer);
    var mail = new MailContent
    {
        ReceipentEmail = "test@example.com",
        HtmlContent = "<p>Hello</p>",
        Subject = "Test Subject",
        Importance = false
    };

    // Act — 執行你要測試的動作
    sender.SendEmail(mail);

    // Assert — 驗證結果是否符合預期
    Assert.IsTrue(fakeServer.ConnectWasCalled);
    Assert.IsTrue(fakeServer.SendWasCalled);
    Assert.AreEqual("test@example.com", fakeServer.LastReceipent);
}

Unit Test 通常會分成三個階段,分別是 Arrange、Act、Assert,這三個階段在業界也常被簡稱為 AAA。Arrange 是準備測試所需的物件和資料,Act 是執行你想要測試的動作,Assert 則是驗證執行結果是否符合你的預期。

在這個例子裡,你可以看到 fakeServer 被傳進 EmailSender,當 sender.SendEmail(mail) 被呼叫後,我們透過 fakeServer 裡記錄的狀態來確認 Connect() 有被呼叫、Send() 有被呼叫,而且傳進去的收件人 email 也是正確的。整個測試過程完全不需要真實的伺服器,你在任何一台電腦上跑這個 test,結果都會是一致的。


這一集的重點

回頭看一下整個過程,其實我們做的事情並不複雜。我們把第一集的寄信程式稍微整理了一下,用 Interface 把對 EmailServer 的依賴抽離出來,然後透過建構子把 IEmailServer 從外面傳進去。光是這兩個動作,就讓原本難以測試的程式碼變成可以被自動驗證的程式碼。

記得第一集說過的一句話嗎?「一個 object 可以有多個外衣」。EmailServer 和 FakeEmailServer 都穿著 IEmailServer 這件外衣,對 EmailSender 來說,它完全看不出來傳進來的是哪一個,也不需要知道。在正式環境裡,你傳進去的是真正的 EmailServer;在測試環境裡,你傳進去的是 FakeEmailServer。程式碼一行都不用改,行為卻可以完全不同,這就是 Interface 帶給你的自由。

Interface 帶來的好處,遠遠不只是讓元件之間 decouple 而已。可測試性只是其中一個受益者,而且在實務上,這個好處往往是最直接讓你感受到程式品質提升的地方。如果你的程式裡有很多直接 new 出來的 class,不妨試著問自己一個問題:這個 class,有辦法換成 Interface 嗎?

Share:

#63 出神入化的用介面 第三集_修改共用的介面

上一集的內容中曾提過三個團隊負責三個不同的元件,團隊一負責 ClassLibrary1,團隊二負責 ClassLibrary2,團隊三負責主要的 UI 主體 (WindowsFormApp1) 以及 CommonLibary.你可以把這三部份的功能想像成是一個普通的軟體產品.在產品演進的過程中勢必會再提供更多的功能,這可能會讓 ClassLibrary1 和 ClassLibrary2 之間的互動會更多,這也代表修改共用的介面 (在 CommonLibrary 裡) 是必需的.如果這三個團隊擁有一致的產品釋出時程,則共用 Interface 的修改並不會造成任何影響.但如果不是如此,則修改共用的 Interface 將會是個棘手的事情.



接下來,我們來看三個團隊的產品釋出時程是不一樣的情況.團隊一所負責的 ClassLibrary1,因其功能很容易受到市場影響,所以有著較短產品釋出時程,每隔二個月就會推出新版本.團隊二和團隊三所負責的 ClassLibrary2, CommonLibrary, WindowFormsApp1 是一些基本且較少變動的功能,所以其產品釋出的間隔較長,大約每隔半年才需要更新一次.所以一年裡,ClassLibrary1 會推出六個新版本,ClassLibrary2, CommonLibrary, WindowsFormsApp1 只會推出二個新版本.ClassLibrary1 可以各自獨立釋出,不受限要和 WindowsFormsApp1 或 ClassLibrary2 一起釋出.

共用的介面仍維持跟前一個版本一樣的狀態.如上一集所講的,其共用介面的長相如下:

public interface IOperation
{
    string Name { get; }
    string Description { get; }
    int AddIntOperation(int i);
    string ChangeStringOperation(string input);
}

假設今天團隊二和團隊三釋出新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1,其中 ClassLibrary2 也提供一個新的 method 給 ClassLibrary1 使用.於是就會遇到以下的問題:

  1. 如果直接修改 IOperation,將新的 method 定義加上去的話,那麼對舊版的 ClassLibrary1 會造成問題,因為 IOperation 有不同的 interface 定義.
  2. 在不久的未來, ClassLibrary1 也將釋出新版,它可能會被更新在舊版的 WindowsFormsApp1 上,也可能被更新在新版的 WindowsFormsApp1 上,ClassLibrary1 怎麼知道它所面對的 IOperation 是新的還是舊的呢?

問題當然不止這兩個,但這兩個算是最麻煩的了.首先,IOperation 在舊版裡只有三個 properties 一個 method,在新版裡多了一個 method,變成三個 properties 兩個 methods.如果直接對 IOperation 上修改,這一定行不通的,因為現有版本的 ClassLibrary1 認得的 IOperation 是三個 properties 一個 method.因此,為了讓現有版本的 ClassLibrary1 能繼續使用,所以 IOperation 不能變動.為了讓新版的 ClassLibrary2 提供新的 method,最簡單的方法就是創造一個新的 interface,並且將它繼承 IOperation,如下所示:

public interface IOperation2 : IOperation
{
    int MinusIntOperation(int i);
}

IOperation2 是 IOperation 的小孩,所以 IOperation 有的,IOperation2 都有,並且 IOperation2 增加了一個 method,用來實現 ClassLibrary2 提供的新功能,在 ClassLibrary2 會有一個新的 class 用來實作 IOperation2 的內容.這樣的做法解決了上面所提的第一個問題.一旦新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1 被釋出時,此時的 ClassLibrary1 還沒有新版,所以 ClassLibrary1 只認得 IOperation,不會認得 IOperation2.而 ClassLibrary2 和 CommonLibrary 裡都把 IOperation 的相關內容都保留了,因此新版的 ClassLibrary2 還是能照常提供原有的功能給 ClassLibrary1 使用.

接下來 ClassLibrary1 也要釋出新版了.因為 IOperation2 已經釋出了,所以新版的 ClassLibrary1 必須設計成要能使用 IOperation2,同時新版的 ClassLibrary1 也必須保留原有的功能.接蓍 ClassLibrary1 就面臨到上述第二個問題,也就是 ClassLibrary1 被下載更新時,它有可能被更新在舊版的 ClassLibrary2 上 (沒有 IOperation2),也有可能被更新在新版的 ClassLibrary2 上 (有 IOperation2).此時 ClassLibrary1 怎麼知道它被下載更新後所面對的是舊版還是新版的 ClassLibrary2 呢? 方法應該有好幾種,在這提供兩個簡單且直覺的.

第一: ClassLibrary1 可以檢查 CommonLibrary/ClassLibrary2 的 assembly version 或是 file version.因為 IOperation2 隨著新版的 ClassLibrary2, CommonLibrary, WindowsFormsApp1 釋出,所以 ClassLibrary1 可以知道他們釋出時的版本資訊.

第二: 當 ClassLibrary1 想要使用 IOperation2 時,並不直接使用它.因為 IOperation2 是 IOperation 的小孩,所以當 ClassLibrary1 得到來自 ClassLibrary2 的物件時,一律先將它視為 IOperation,這樣就可以成功讓該物件進入到 ClassLibrary1 的領域中,然後再試著將該物件轉型 (type conversion) 成 IOperation2,如果可以轉型成功,那代表該物件是實作了 IOperation2,也就表示 ClassLibrary1 面對的是新版的 ClassLibrary2.以下是 ClassLibrary1 裡嘗試使用 IOperation2 的簡單 code:

if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
{
    IOperation2 op2 = op as IOperation2;
    if (op2 == null)
    {
        richTextBox1.Text = "We are using older version of interface";
        return;
    }

    int result = op2.MinusIntOperation(100);
    richTextBox1.Text = $"We are using a new version of interface and the result is {result}";
}
else
{
    richTextBox1.Text = "Cannot find Operation2";
}

以上的情況在一般的大型軟體系統中其實是很常見的,解決的方法當然不止如上述的方法,而上述的內容也是用在 Visual Studio 裡,中間有很多細節省略了,但重點就是修改共用 interface 時,是生一個小孩來繼承它,把原有的 interface 原封不動地保留.
Share:

#61 出神入化的用介面 第二集_物件如何在大型軟體系統中移動

在上一集中談到最基礎的 interface 應用和簡單的例子,因此從上一集的內容中應該能讓你了解到 interface 的用途之一.interface 的用途很廣,除了可以做一些物件抽象化的表示方式以外,也可以用來幫助一個物件在一個大型的軟體中不受元件範圍的限制而讓其他不同的元件來使用.在上一集的內容中,你已經看到了最基本的抽象化應用,透過 email interface 的建立,讓所有的團隊可以依照同一份 interface 的規格實做出各自所用的物件,在這一集的內容中將展現一個極為簡單的例子用來說明一個物件如何在大型的軟體系統中移動.

首先,簡介此簡單的例子,下圖是這例子中的元件,一共有四個元件:


WindowsFormsApp1.exe 是整個軟體的基礎,它提供 IDE 介面,以及負責尋找系統上有那些元件,並且呼叫各元件的註冊程式,將每個元件所提供的功能記錄下來.

CommonLibrary.dll 是一個讓各元件都能使用到的共用內容,如一些共用的 interface 定義以及元件被註冊時所需要的空間.

ClassLibrary1 包含了該元件所提供的 Form 和相關的功能,同樣地,ClassLibrary2 也包含了一些 Form 和相關的功能.

上圖中的線條代表 dependency 關係,所以 WindowsFormsApp1 認識另外三元件,ClassLibrary1 只認識 CommonLibrary,ClassLibrary2 也只認識 CommonLibrary,所以 ClassLibrary1 和 ClassLibrary2 彼此並不認識,最後 CommonLibrary 完全不認識其他元件.這裡所用的 "認識" 就是 reference 的意思.

假設以上四個元件分別是由不同的團隊所製作而成,現在 ClassLibrary1 圖隊需要在他們自己的 Form 上面做兩個按鈕,而這兩個按鈕所需的畫面和功能分別是由 ClassLibrary2 團隊所提供的.正常來說,如果一個團隊要用到另一個團隊所開發的功能時,最直接且直接的方法就是將對方的元件在自己的專案中加入 reference,這樣做就能讓自己團隊的元件可以認識另一個團隊的元件,但這樣子做在較大型的軟體團隊中是不方便的,因為第一集已經說明過了.因此,如第一集所說的內容,比較好的方法是要做一個共用的 interface 讓兩個團隊都可以認識這個共用的 interface.於是,ClassLibrary1, ClassLibrary2, 和 CommonLibrary 這三個團隊做成了一個協議,CommonLibrary 團隊將製作一份 interface 讓 ClassLibrary1 和 ClassLibrary2 可以實做.除此之外,CommonLibrary 團隊還提供了一個 Dictionary 用雙方可以將自己實做好的元件放在這個 Dictionary 裡頭.

public interface IOperation
{
    string Name { get; }
    string Description { get; }
    int AddIntOperation(int i);
    string ChangeStringOperation(string input);
}

public class ObjectContainer
{
    public static Dictionary<string, IOperation> Operations = new Dictionary<string, IOperation>();
    public static Dictionary<string, Form> Dialogs = new Dictionary<string, Form>();
}

IOperation 介紹讓是讓雙方可以依自己的邏輯實做成物件,然後放在 Operations dictionary 裡面.因此,只要 ClassLibrary1 團隊知道如何在這個 dictionary 中取出 ClassLibrary2 所放入的物件,那麼 ClassLibrary1 可以使用 ClassLibrary2 團隊所提供的功能了,如 AddIntOperation(), ChangeStringOperation().

於是 ClassLibrary2 團隊將 IOperation 實做如下:

public class Operation2 : IOperation
{
    public string Name => "Operation2";

    public string Description => "This is Operation2 from ClassLibrary2";

    public int AddIntOperation(int i)
    {
        if (i < int.MaxValue - 1)
        {
            return i + 2;
        }

        return i;
    }

    public string ChangeStringOperation(string input)
    {
        return string.IsNullOrEmpty(input) ? null : input.ToLower();
    }
}

然後 ClassLibrary1 團隊在一個按鈕的 code-behind 寫出以下的程式碼來使用 ClassLibrary2 的 IOperation.

private void button2_Click(object sender, EventArgs e)
{
    if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
    {
        int i = 5;
        richTextBox1.Text += $"int starts at {i}\n";
        i = op.AddIntOperation(5);
        richTextBox1.Text += $"int becomes {i} after Operation2\n";

        string s = "aBc";
        richTextBox1.Text += $"sting starts as {s}\n";
        s = op.ChangeStringOperation(s);
        richTextBox1.Text += $"string becomes {s} after Operation2";
    }
    else
    {
        richTextBox1.Text = "Cannot find Operation2";
    }
}

這樣做的話不能成功,因為在 CommonLibrary 的 Operations dictionary 裡面並沒有 ClassLibrary2 所製做的 IOperation 物件,因此 ClassLibrary1 團隊使用上述的程式碼時會看到 "Cannot find Operation2" 的訊息.比較簡單的方法是在整個軟體一開始啟動的時候,ClassLibrary2 就得把 IOperation 物件寫入到 CommonLibrary 的 Operations dictionary 裡.當然,這並不是最好的方法,只是在這極為簡單的例子中,我們暫用這個方法來簡化許多細節.

因為 WindowsFormsApp1 是整個軟體的啟動點,所以我們就在 WindowsFormsApp1 啟動的時候來將相關的物件都寫入到 CommonLibrary 裡面.由於每個團隊會有不同的啟動邏輯,因此,每個團隊可以提供一個入口來讓 WindowsFormsApp1 直接呼叫,而這份入口裡的內容就是將自己的 IOperation 物件寫入到 CommonLibrary 的 Operations dictionary 裡.同樣地,除了 IOperation 以外,每個團隊也可以寫入不同的 Form 物件到 CommonLibrary 的 Dialogs dictionary 裡.

以下是 ClassLibrary2 團隊所使用讓 WindowsFormsApp1 執行注冊的內容:

public static class Starter
{
    public static void Register()
    {
        ObjectContainer.Operations["operation2"] = new Operation2();
        ObjectContainer.Dialogs["library2"] = new Lib2WinForm1();
    }
}

以上的做法是一個極為簡化的方式,在 ClassLibrary2 裡直接做一個 static method 讓 WindowsFormsApp1 呼叫,所以 WindowsFormsApp1 必須知道這一個 "入口".在正常的方式來說,WindowsFormsApp1 找到並執行 ClassLibrary2 的入口不會用這種直接的方法,因為這樣會把程式碼限制住,這方面細節的內容以後將再寫文章來說明,現在就先假設 WindownsFormsApp1 可以找到 ClassLibrary2 並且執行 Starter.Register() 來達成將 Operation2 物件和 Lib2WinForm1 物件寫入到 CommonLibrary 的 dictionary 中.因此,WindowsFormsApp1 的啟動程式看起來如下:

static class Program
{
    [STAThread]
    static void Main()
    {
        // 尋找相關元件並且執行他們所提供的註冊方法
        ClassLibrary1.Starter.Register();
        ClassLibrary2.Starter.Register();

        Application.Run(new Form1());
    }
}

這樣一來,ClassLibrary1 團隊就可以在 CommonLibrary 的 dictionary 裡找到 ClassLibrary2 所製做的 Form 物件以及 IOperation 物件,並且使用它們,如下圖所示:



Form1 是 WindowsFormsApp1 團隊製做的主要 Form,也就是整個軟體最基礎的 IDE,而 Lib1WinForm1 是 ClassLibrary1 所製做的 Form,透過 WindowsFormsApp1 的呼叫將它顯示在畫面上.在 Lib1WinForm1 裡第一個按鈕 (Let's show Lib2WinForm1) 的 code-behind 如下:

private void button1_Click(object sender, EventArgs e)
{
    if (ObjectContainer.Dialogs.TryGetValue("library2", out Form lib2WinForm1))
    {
        lib2WinForm1.ShowDialog();
    }
}

直接到 CommonLibrary 的 Dialogs dictionary 去找是否有 CommonLibrary2 的 Form 物件,如果有找到,就直接對它做 ShowDialog().因此,ClassLibrary1 團隊不一需要 "認識" ClassLibrary2 團隊的元件也可以將它提供的 Form 顯示在畫面上.

同樣地,Lib1WinForm1 的第二個按鈕 (Run Operation2) 是使用 ClassLibrary2 的 IOperation 物件所執行的功能,它的程式碼如下:

private void button2_Click(object sender, EventArgs e)
{
    if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
    {
        int i = 5;
        richTextBox1.Text += $"int starts at {i}\n";
        i = op.AddIntOperation(5);
        richTextBox1.Text += $"int becomes {i} after Operation2\n";

        string s = "aBc";
        richTextBox1.Text += $"sting starts as {s}\n";
        s = op.ChangeStringOperation(s);
        richTextBox1.Text += $"string becomes {s} after Operation2";
    }
    else
    {
        richTextBox1.Text = "Cannot find Operation2";
    }
}

當這個按鈕被按下後,它的結果如下:



你可以看到數字被加 2 (7 = 5+2) 並且字串變小寫 (abc),這都是前面提到 ClassLibrary2 所實做 IOperation 的內容.

當我們把 Visual Studio 的 break point 設定在這程式碼時,你會看到如下畫面:



此時,你可以看到 op 的 data type 是 IOperation,而它裡面真正的物件是 ClassLibrary2.Operation2.

這個範例用極為簡化的方式說明了 Interface 如何幫助 ClassLibrary2 團隊的物件可以在 ClassLibrary1 的程式中呈現,並且是在兩團隊元件互相不 "認識" 的情況下.

Operation2 的實做完全保留在 ClassLibrary2 裡面,其他團隊無法變更,也不需要知道實做細節,讓團隊之間的合作只需要關心 Interface 的定義.

用這個例子可以讓你看到不同的團隊各司其職而達成一個共同的目標.以上的例子 IOperation 是定義在 CommonLibrary,通常來說,自已團隊所開發的 Interface 應該是放在自己所定義的 Interface 元件裡,然後再將這一個 Interface 元件公開給其他團隊來使用.因此,這份 Interface 是大家都看的到,理論上你就不能修改,否則別人用了就會出現問題.下一集的內容將說明當公開共用的 Interface 需要修改時,該怎麼處理比較好.

Share:

#57 出神入化的用介面 第一集_什麼是介面 (Interface)

出神入化這詞用的誇張了,為何選用這詞呢? 在 2016 年底辦了一個 .Net公益課程,課後的問卷裡詢問未來若有機會,大家有興趣聽什麼主題的內容.結果有位朋友寫了 "出神入化的用介面".我想這應該是當天課程上曾提到跟 Interface 有關的事情.後來,我想了想,這主題要描述的清楚並不是件容易的事,也不是三兩句話可以交待的完.所以,用一個說故事的方式來說明這個主題.透過這個主題可以一直延伸到許多程式設計和軟體開發上的事情.

如果你在業界有多年的軟體開發經驗,相信你對 Interface 一定有某種程度以上的使用與了解.但如果你現在還是一個在學的學生或是剛進入職場沒多久的社會新鮮人,也許你對 Interface 的了解可能只限於課本上或是老師口授而來的知識.由於 Interface 這種東西並不是什麼學術研究的題材 (應該說這是很舊的題材了),再加上許多學校裡的教授大部份很少有大量的產業界開發經驗,因此你能從課本或老師那邊得到的了解便是相當有限的.不論你是在業界打滾多年的好手或是剛進入職場沒幾年的菜烏,先來讓我們一起回想一下當初在學校裡上的物件導向程式語言的課程.也許許多人在學校並沒有上過這門課或是學校沒有開這門課,可能是一邊工作一邊看書學習.不論是那一種方式,我相信你一定都看到課程上或書本上介紹 Interface.當你看了 Interface 之後,你覺得你有什麼感覺嗎 ?

老實話,我以前在學校學的時候,還真的沒掌握到重點.只是覺得奇怪,為什麼要多弄一個 Interface,感覺上好像多了一個用不到的東西.其實,後來才發現,並不是用不到,只是還不會用而己.在物件導向式程式設計的世界裡,你一定都知道什麼是 Class,也一定知道什麼是 Object.這關係就有點像關聯式 (Entity relational model) 資料庫裡的 table schema 與 table 裡的資料.舉個例子,在資料庫中建立一個表格時,你一定會告訴資料庫引擎你需要的表格是長什麼樣子.如一個 int 欄位,一個 varchar(50) 欄位,以及一個 boolean 欄位.因此,你能寫入的資料一定要符合這個 table schema 的定義.相同的感覺,當你的 Class 定義好它的長相時,之後依這個 Class 建立出來的 Object 裡面的內容值也一定符合這個 Class 的定義.在資料庫設計中,表格和表格之間可以建立所謂的一對一或一對多等等的關係,同樣的在 Class 和 Class 之間也可以設計出這樣的關係.物件導向式模型有一個東西是關聯式模型裡面所沒有的,那就是 Interface.

Interface 和 Class 都是用來定義 Object 要長成什麼樣子.如下面看到的簡單例子.

class IAmAClass {
    public int Id { get; set;}
    public string Name { get; set;}
}

在撰寫程式時,你可以直接透過 new 這關鍵字來告訴電腦你需要一個記憶體空間來放一個依照 IAmAClass Class 長相所建立出來的 Object,如下

IAmAClass cls1 = new IAmAClass();
IAmAClass cls2 = new IAmAClass();

依照上面的程式碼,此時記憶體裡面有兩個 IAmAClass objects, 一個名字叫 cls1,另一個叫 clas2.這兩個 objects 是依照 IAmAClass 的長相所建立出來的,所以當我想要操作這兩個 objects 時,我就知道他有那些公開的屬性和方法可以使用.這些是最基本的事情,相信你一定知道.接下來,那 Interface 呢 ? 前面說了,Interface 和 Class 都是用來定義 object 要長成什麼樣子,那我們能直接定義一個 Interface 透過 new 關鍵字來建立 object 嗎? 很可惜,似乎不行.Interface 和 Class 好像蠻像的,但 Interface 卻不能直接透過 new 關鍵字來建立 object,這其中一定是有什麼設計上的考量!

相信你以前也想過這樣的問題.Interface 的用途很多,在第一集的文章裡,我先提出一個用途讓你看到為什麼有 Interface 的存在是比較好的.

假設,你的公司有好幾個部門,每個部門做的工作都是獨立又相依,意思就是說你要達成某項任務時,必須要使用其它部門的程式.舉例來說,你做的是一個寄信的程式,但信件的內容是根據各部門的需求而產生的,你不負責信件內容的產生,你只負責做寄信的動作.此時,A部門告訴你它的信件內容是 MailAContent class,它的長相如下 (細節忽略):

class MailAContent {
    public string ReceipentEmail { get; set;}
    public string HtmlContent { get; set;}
    public string Subject { get; set;}
    public bool Importance { get; set;}
}

於是,當你寫寄信程式時,你必須要拿到 MailAContent class 的定義,也就是說你得把對方的 dll 加入到你的程式專案裡,因為要這麼做,你的程式才能明白什麼是 MailAContent class.因此,當你在製做寄信程式時,你的程式某個片段可能會長成如下:

public void SendEmail(MailAContent mail) {
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance); // 假設寄信程式只需要知道這四個資料
    server.Close();
}

到目前為止,看起來似乎合理.接下來,B部門也會產生 email,也要透過你的寄信程式來把信寄出去.但 B部門有自己的 email class,叫 MailBContent class,長相如下:

class MailBContent {
    public string ReceipentEmail { get; set;}
    public string HtmlContent { get; set;}
    public string Subject { get; set;}
    public bool Importance { get; set;}
}

於是你為了要把 B部門的 email 寄出去,你也寫了以下的程式在你的寄信程式中.

public void SendEmail(MailBContent mail) {
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
    server.Close();
}

結果看到這程式,發現有點蠢,這麼多重覆目的的程式碼,於是進行了一個小小的 refactor :

public void SendEmail(MailAContent mail)
{
    SendEmailInternal(mail.ReceipentEmail , mail.HtmlContent , mail.Subject, mail.Importance);
}

public void SendEmail(MailBContent mail)
{
    SendEmailInternal(mail.ReceipentEmail , mail.HtmlContent , mail.Subject, mail.Importance);
}

private void SendEmailInternal(string receipent, string content, string subject, bool importance) {
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(receipent, content, subject, importance);
    server.Close();
}

經過小小的 refactor 之後,你會覺得程式碼好像比較沒那麼蠢了.但接下來, C部門,D部門相繼地把需求提過來,而你也發現每個部門的 email class 都是不一樣的 class,因此你就能想像你的寄信程式就要為 C部門再多弄一個 SendEmail(), 也要再為 D部門再多弄一個 SendEmail(),如果有十個部門而且每個部門的 email class 都不一樣,那麼你就得有十個 SendEmail(),這時你應該會發現程式碼真的有點蠢了.除此之外,因為你寫的寄信程式是要提供給其他部門使用,讓他們的程式碼呼叫你的 SendEmail() 才能寄信.此時,你會發現因為你的寄信程式 reference 每個部門的 email class,所以使得每個部門都要 reference 其他部門的 email class 才能正確地 compile.這真的是相當蠢的一件事.透過這個例子也剛好說明了 Class 真的是軟體開發的一個麻煩源頭之一.

從上面的例子來看,你會發現如果每一個部門都採用相同的 email class 的話,那不就好了嗎? 可能的做法是把 email class 定義在某一個共享元件裡,然後要求每個部門都 reference 這一個共享元件以使得用到相同的 email class.理論上,這是一個可行的方法.在這個例子裡,email class 只是一個很簡單的 data structure,沒有任何 implementation code.如果今天遇到的是一個有 implementation code 的 email class 時,就會遇到一個小麻煩,它就是當這個 email class 改版時 (修改 implementation code),就得通知所有部門更新這一個共享元件.除了這方法以外,你還可以用 Interface.

如前面所說,Interface 和 Class 都是用來定義 Object 要長成什麼樣子,但 Interface 不能用來建立 object,卻可以用來表達一個 object 是否具有其他的 "特定長相". 在元件之間的互動,認定的是長相而不是真正的 object 是什麼,並且放在共享元件裡的是 Interface 而不是 implementation code,因此 email class 改版時,共享元件不需要更新,只有在 interface 改版時才需要更新共享元件.

因此,以上述的例子來說,在共享元件的 interface 長成這樣:

Interface IMailContent {
    string ReceipentEmail { get; }
    string HtmlContent { get; }
    string Subject { get; }
    bool Importance { get; }
}

以 A部門來說,它的 email class 改成如下:

class MailAContent : IMailContent 
{
    public string ReceipentEmail { get; set;}
    public string HtmlContent { get; set;}
    public string Subject { get; set;}
    public bool Importance { get; set;}
}

MailAContent class 使用了 IMailContent,這表示 MailAContent class 一定要有 IMailContent 裡所有成員的 implementation code.一旦 MailAContent class 被建立成 object 時,這個 object 既是 MailAContent 也是 IMailContent,也就是說這個 object 有著兩種 data type 的感覺,這樣說不精確,但感覺起來像是如此.接著,寄信程式可以改成如下:

public void SendEmail(IMailContent mail) {
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(mail.receipent, mail.content, mail.subject, mail.Importance);
    server.Close();
}

當 SendEmail() 被 A部門呼叫時,傳進來的 IMailContent 其實是 MailAContent object,因為這個 object 實做了 IMailContent ,所以這樣傳進來並不會有什麼問題.在 SendEmail() 裡面只認得 IMailContent 的長相,因此在 SendEmail() 存取該物件的成員時,只能用那些 "看" 的到的成員,也就是 IMailContent 上有的成員.同理,當 SendEmail() 被 B部門呼叫時,傳進來的 object 是 MailBContent object,也因為此 object 實做 IMailContent,所以 SendEmail() 執行時不會造成問題.以推類推,所有部門的 email class 都實做了 IMailContent,這樣寄信程式只要一個 SendEmail() 就可以,這樣不是更好嗎?

也許以上寄信這個例子不是很精準,但也把最基本的 Interface 應用呈現出來.如果你還沒機會參與多部門或多人開發的軟體專案,Interface 其實仍是重要的.因為它不僅用在跨部門的情況下相當好用,而且也能幫助你做更好的 unit test.

這集的重點是:
  1. Interface 和 Class 都是用來定義 Object 要 "長" 成什麼樣子,但 Interface 不參與 implementation,只專心定義一個 object 能 "長" 成什麼樣子.Interface 提供了 object 一種 "外衣",不同的 email content object 只要有相同的 "外衣" (IMailContent),都可以在 SendEmail() 裡面使用.一個 object 可以有多個 "外衣",因此使得物件導向程式設計變得很彈性也很有趣.
  2. 當你要使用別人的 Class 時很可能會造成麻煩,應盡量使用 Interface.以上的故事也告訴你當你把一個 class 建立成一個 object 時,這其實就是許多問題的根源之一.

Share:

#52 物件導向程式設計的一個小技巧 - 切開 dependency

這次放長假回到台灣,到台中參加了一場 Study4TW 社群舉辦的活動.在活動中有個問題時間,讓現場的朋友可以提問問題,我則盡量回答我所知道的.其中有一個問題是 "台灣存在著多數不願意跟上時代變化傳統產業, 若不考慮重構, 開發人員該如何面對舊版本的軟體設計呢?" 這個問題的確不是那麼容易可以完整地回答,我當時的回答是跟大家說至少可以先從降低 dependency 的動作開始.所以,這篇文章就來說明我所謂的降低 dependency 是什麼 !

我們在寫物件導向程式時,一定會常常呼叫到其他人寫的程式,也就是說你的程式裡一定會建立一個別人程式的 instance. 例如,你的程式裡有一個 class 叫 DataWriterHelper ,裡面有一個 WriteSecret(),這方法裡面的內容其實是透過其他人寫的程式來達成.假設其他人寫的 class  叫 SecretHelper,而裡面有一個 Write().所以你的程式一開始看起來如下:

class DataWriterHelper {

    public void WriteSecret(string data) {
        SecretHelper _helper = new SecretHelper();
        _helper.Write(data);
    }
}

這樣子就可以很清楚的看到你的程式 DataWriterHelper 和別人的程式 SecretHelper 有一個關係了,換句話說,你的 DataWriterHelper 依賴了 SecretHelper,因為若 SecretHelper 不存在的話,你的 WriteSecret() 便發揮不了功能.

但這樣寫,有什麼不對嗎 ? 好像沒什麼不對,只是失去了一些彈性.如果 SecretHelper 改了一個版本,把 Write() 改成 WriteData(),那麼你的程式也就必須跟著變動.另外,當你要測試 WriteSecret() 時,你會發現你非得把 SecretHelper 的元件也一起加入到測試專案才行,因為你的程式依賴著 SecretHelper.如果你將它 Mock,也是一種可行的方法,只怕真實情況不是一個 Mock 就能滿足你的需要.

接下來,說明什麼叫降低 dependency. 降低的方法可以用一個神奇的東西 - Interface. 首先,先定義好 Interface 的內容.

interface IWriteSecret {
    void WriteSecret(string data);
}

這個 Interface 定義好之後,理論上就應該不會輕易改變. 接著讓你所依賴的程式去實做這一個 Interface.所以 SecretHelper 就變成如下:

clas SecretHelper : IWrtieSecret {

    public void Write(string data) {
        // code for writing secret data
          .... 
    }

    public void WriteSecret(string data) {
        Write(data);
    }
}

Interface 所定義的 WriteSecret() 去呼叫 Write().接著,DataWriterHelper 就會改成如下:

class DataWriterHelper {

    private IWriteSecret  _secretWriter;
    public DataWriterHelper(IWriteSecret  secretWriter) {
        _secretWriter = secretWriter;
    }

    public void WriteSecret(string data) {
        _secretWriter.WriteSecret(data);
    }
}

經過這樣的修改,DataWriterHelper 便不再依賴 SecretHelper 了,而是變成依賴 IWriteSecret.因此,這樣做就把 class 和 class 之間的 dependency 切開,變成 class 依賴新的 interface. 這樣改變的想法來源是根據物件導向設計的 SOLID 原則.上面程式碼 IWirteSecret 的實作物件是透過 constructor injection 的方式傳來的,換句話說,DataWriterHelper 本身不參與 IWriterSecret 實作物件的建立過程,而是由外部的呼叫物件來決定要傳入那一個 IWriterSecret 的物件.

這樣的改變,將使得 DataWriterHelper 的測試力變得強一點,因為你可以傳入測試用的 IWrtieSecret 物件用在 DataWrtierHelper 的 unit test 或 integration test 的情境中.

Share:

#49 - 物件導向的最基礎 - Access Modifier

若我們用 Java 語言來做為說明,則 access modifier 有三種,分別是 public, protected, private. 他們分別控制了那些對象能使用他們所定義的東西.

https://docs.oracle.com/javase/tutorial/java/javaOO/accesscontrol.html 裡面有一個表格呈現出了整個控制力的對照圖.例如,private method 只能被同一個 class 裡面的 method 所使用,只要超過同一個 class 的範圍是無法使用的.public method 剛好相反,它可以被任何的 class 看到來使用,甚至也可以被其他元件看到來使用.因此,當你將 class, property, method 設定為 public 時,你就要非常地小心.這個稍後說明.最後一個是 protected,它能被同一個 class 裡的東西來使用,而也能被該 class 的 child class 來使用.

Private 的控制力道最嚴格,因為除了在同一個 class 的 method, property 以外,其他人都無法存取.因此,在最初的設計上時比較不會對 private method, class, property 等做太多的說明,甚至會跳過他們.Public 就完全相反了.誠如前面所說,一旦你將 class 設定為 public 時,那表示不止你的程式可以看到來使用,其他人的程式也可以看到來使用,這時候你就要非常小心,因為你得考慮到這個 class 一旦被使用了之後,它所執行的程式碼需要具有什麼意義.同時,也要注意在寫程式時是不是有做一些檢查.

來看一個很笨的例子,假設你今天要設計一個傳送訊息通知的 class,而訊息傳送的功能是透過其他的元件來執行,你需要設計的只是一個簡單的 wrapper ,將該元件包起來,然後只要露出一些你需要提供的功能即可.所以程式碼可能是這樣

public class MessageSender 
{
 private Some.Other.Sender _component;
 
 public void Start()
 {
  _component = new Some.Other.Sender();
  _component.OpenConnection();
 }
 
 public void Send(string message)
 {
  _component.Send(message);
 }
 
 public void End()
 {
  _component.CloseConnection();
 }
}

從上面的程式碼你可以看到 MessageSender class 的 access modifier 是 public,這表示全世界都可以看到它,因此任何人都可以使用它.在這個 class 裡面定義了三個 methods,分別是 Start(), Send(), End().會這樣設計可能因為你想要讓使用它的人比較清楚知道該如何使用,因為顧名思義看起來就會覺得一開始的時候就要使用 Start(),需要傳送資料時就用 Send(),而最後在結束時就使用 End().這樣的想法也沒什麼不對,只是漏掉了一點,那就是 public method 是大家都看的到.別人可以看的到不代表他就一定得用它.也就是說,當我 new MessageSender 時,有規定我一定要先呼叫 Start() 嗎 ? 似乎沒有.有規定我最後結束時一定要呼叫 End() 嗎 ? 似乎也沒有.如果我直接呼叫 Send 來傳送字串時,那會發生什麼事呢 ? 答案很明顯,會發生 null object 的現象.

用以上這個笨笨的例子來提醒大家,在設計 public class, public property, public method 時一定要注意到這些 public 的東西被使用時是沒有順序可言的,所以千萬不能自作主張去以為所有人會知道該如何使用,甚至你寫了說明文件該如何使用也不能這樣寫程式.因此,設計 public 時一定要非常地小心,要想到當 public class, public property, public method 是第一個被存取的對象時會發生什麼事.這樣你就會知道上面的程式碼是行不通的,而且相信你也能將上述的程式碼改成一個比較好的程式碼.

Share:

#39 物件導向 - Interface 的基本應用範例

記得以前在學習物件導向語言的時候, 學到了 Class 定義,也學到的物件跟物件之間的關係, 後面也學到繼承關係. 在學習這些物件導向內容的時候也有看到 interface, 不過當時對 interface 並沒有太深刻的了解. 所以在這一篇文章裡面就來談一談 interface.

如果你做過一些小專案或者只是個人在用的一些小程式, 基本上來說 interface 用到的機會應該是不多. 相反的, 如果你曾參與大型專案, 那麼 interface 應該是到處可見的. 為什麼呢? 那是因為大型的專案通常是由多個開發者所共同進行, 所以不同的開發者會負責不同的項目, 你也可以把看成不同的開發者會負責不同的元件. 而在一個大型的專案裡, 元件跟元件之間的溝通是件基本的事情. 然而,元件跟元件之間的溝通不一定只是單純的資料傳遞, 傳遞的內容中有可能是物件本身. 所以, 元件和元件之間就必須要遵守特定的規則, 這樣子我們才能夠成功的將資料或者是物件傳送給對方. 除此之外, interface 的好處也可以應用在軟體測試上. 接下來用一些簡單的例子來說明.


這個例子是微軟開發工具產品裡面的某一個視窗, 而在視窗裡面有一些控制項, 它的長相大概就如下圖所示:


可以看到在這個視窗中有一塊大的空白, 裡面顯示這個不同的專案範本. 假設製作視窗元件的團隊和製作專案範本的團隊是不一樣的, 也就是說他們的程式碼是來自兩個不同的專案, 他們要怎麼做才能夠達成協同運作的目的. 其中的關鍵技巧就是使用 interface. 例如說有一個 interface 的定義如下,

public interface ITemplate {
    List<Template> GetTemplate(TemplateKind kind);
    List<TemplateKind> GetTemplateKind();
}

製作視窗的團隊中, 他們一定會去採用這個 interface 定義, 因為在這一個 interface 定義裡面提供了兩個方法. 第一個方法是取得有多少範本, 第二個方法是去取得有多少範本種類. 在他們顯示這一個空白區域的內容時, 他們就可以將這個介面定義作為參數,

public void ShowTemplates(ITemplate templates)

ShowTemplates 方法本身是可以由外部程式來呼叫的, 所以只要能夠準備好所需要的參數, 就可以將物件參數傳進去, 然後這一個方法就可以依照 interface 提供的方法把範本的種類和範本顯示在空白地域上.

所以透過這種方法, 開發範本的團隊就可以很清楚的知道需要傳過去的參數內容長相是什麼樣子.這樣看起來就像是兩個團隊之間有著共同的合約, 合約裡面的內容就是定義這會有哪些屬性或方法.


Share:

標籤分類