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

顯示具有 出神入化的用介面 標籤的文章。 顯示所有文章
顯示具有 出神入化的用介面 標籤的文章。 顯示所有文章

#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:

#81 出神入化的用介面 第四集 修改共用的介面 part.2

這一篇文章是上一集的延伸,再來說明新版本 interface 的後續實際使用的情況.先將此篇文章要解決問題再描述一次.想像一家公司出版了一套軟體,而這個產品裡包含了許多的元件檔案 (.dll),而且每一個元件是由不同的團隊產生.每個元件可以有獨立的發行時間,也就是說當 A 團隊要釋出新功能的版本時,他們可以自行釋出.在客戶端裡,可以透過該軟體裡的更新程式來下載並且安裝 A 團隊所製做的新版本元件.由於各元件釋出的時間並非一致,因此元件之間的互動將變得有挑戰性.

A 團隊裡的某些功能是透過 B 團隊的元件所完成的.例如,A 團隊提供的功能裡有一項是計算產品折扣,而這項功能的細節實作者其實是 B 團隊,因此 B 團隊會提供 interface component 給 A 團隊使用,如:



因此,在 A 團隊的程式碼裡,將可能利用下述的程式碼得到實做該介面的物件,然後來進行折扣計算,如:



接著,A, B 團隊從產品經理接收到新需求,他們必須提供一個新的功能 - 回饋金 (cash back).因此,比較簡單的做法便是將上述的 interface 加以延伸.但如上一集文章所提,B 團隊不能直接改 IProductUtils,所以他們必須要製造一個新的 interface,並且繼承自 IProductUtils.



A 團隊接著將他們的程式碼修改如下:



若 A 團隊用上述的方式更改程式,則有可能發生錯誤.當某個用戶端使用新版的 A 團隊程式而且用了舊版的 B 團隊程式,則執行到上述程式碼時,就會發生 TypeLoadException 的錯誤,因為此時電腦裡是舊版的 B 團隊元件,所以 runtime 看不懂 IProductUtils2,對它而言,這是一個 unknown type.

為了防止上述的錯誤發生,A 團隊必須採用一些方法來辨別 runtime 所載入的是舊版或新版的元件.可能的方法如下:

  • 讓 B 團隊為不同版本的元件使用不同的檔案名稱.如 BContract.1.dll 和 BContract.1.1.dll 等.B 團隊發行新版本時,可將兩個新舊兩版的 contract dll 一起釋出,同時在實做的程式碼裡也會同時實做新舊兩版的 interfaces.所以,A 團隊若用舊版 (BContract.1.dll),則 A 團隊只會看到 IProductUtil,若用新版 (BContract.1.1.dll),可看到 IProductUtil 和 IProductUtil2.這樣做的好處是讓新舊兩版的 interface 位於不同的元件裡,當其他團隊取得舊版時,其他團隊只能看到 IProductUtil.使用新版時,則可看到兩個 interfaces.利用實體檔案區別同一個功能的新舊版本.壞處就是可能會造成過多的元件檔案.




  • 讓 B 團隊將新舊版本的 interfaces 寫在同一個元件檔案裡,如 BContact.dll.這樣做將讓 A 團隊無法從元件檔名上知道他們正使用的是新版還是舊版.因此,就必須透過程式碼來解決這問題.A 團隊為了處理新舊兩版的情況,當他們載入 B 團隊的元件時,他們可先嘗試使用 IProductUtil2,若 IProductUtil2 使用時發生 TypeLoadException,則表示 B 團隊的元件是舊版的.此時 A 團隊便可以把回饋金功能的相關 UI 畫面關閉.請參考下列程式碼來看 A 團隊如何使用正確的版本.



    透過上述的方式,當 runtime 執行到 line 12 準備進入 TryGetNewVersion() 時,如果此時 A 團隊使用的是舊版 B 團隊元件,則 runtime 便會發生 TypeLoadException 的錯誤,因為 line 28 的 IPorductUtils2 是一個 unknown type,並且此錯誤會在 line 18 被抓住.接著 A 團隊便可以關閉回饋金的相關功能.如果 A 團隊使用新版本,則不會發生 TypeLoadException.只要能順利取得正確的物件就能呼叫 GetCashBack() 來得到回饋金的金額.若用這個方法,則 A 團隊必須使用 B 團隊的新版本元件在他們的編輯環境裡,才不會發生 build fail (unknown type) 的情況.




  • 這一篇文章提到的情境應該不會是大多數人的情境,畢竟同一個軟體產品裡每一個元件可以有不同的釋出時程是很少見的.如果你的工作情況真的有符合這樣的情境,希望這篇文章能給你一種解法來解決這情境的問題.若你的解法不同,也歡迎你分享給我.

    Hope it helps,

    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:

    標籤分類