2019年7月31日 星期三

如何將緊密耦合程式碼套用 SOLID 原則,重構成為鬆散耦合且可用於相依性注入容器使用

如何將緊密耦合程式碼套用 SOLID 原則,重構成為鬆散耦合且可用於相依性注入容器使用

在這個練習說明中,將會要把一個具有緊密耦合的專案程式碼,透用 SOLID 原則 Principle 將其重構 refactoring 成為具有鬆散耦合的程式碼;一旦,專案原始碼具有鬆散耦合的特境,便可以先觀察具有緊密耦合和鬆散耦合的程式碼之間的差異有多少,以便了解到進行重構所要付出的成本有多少;另外當已經成為鬆散耦合的專案程式碼,就可以修正程式碼使用手動注入的方式,針對訂單系統物件,注入相關相依所需要用到的物件;最後,接下來就是要解決手動注入的不方便與體會如何快速擴充與變更需求,在此將會使用 Unity DI Container 相依性注入容器,針對需要用到的抽象型別與具體實作型別來進行註冊,接著透過 DI 容器所提供的解析方法,自動的注入相關相依的物件,而且還需要在不修改原始用戶端專案程式碼的情境下,體驗輕鬆變更需求需求的做法。
所以,這篇教學文章將會採用 Console 主控台應用程式專案來進行實作,並且分成底下三個部分:
  • Phase 1
    先了解緊密耦合專案的需求與相關程式碼內容
  • Phase 2
    套用 DIP 原則,使用這個專案從具有緊密耦合程式碼重構成為具有鬆散耦合程式碼,並且修正為手動注入相關相依物件
  • Phase 3
    導入 Unity DI Container 並且使用相依性注入這個技術、體驗 OCP 原則和鬆散耦合的好處

Phase 1 先了解緊密耦合專案的需求與相關程式碼內容

這個範例專案是一個訂單管理系統,首先將會建立一個 OrderInfo 型別的物件,說明此次訂單所包含要購買的商品內容。接著會產生出 OrderManager 型別物件,這個物件將會用來處理此次下單商品,這裡將會呼叫 OrderManager.Procss 方法並且傳入 OrderInfo 物件;這個方法內將會分別進行付款處理、更新客戶相關資訊、與發送通知給購買使用者的工作。不過,為了簡化整體專案的複雜程度,相關處理訂單機制,將會使用 Console.WriteLine 方法顯示一段文字來表示該工作已經執行完畢。
底下將會是如何建立訂單資訊與產生訂單管理員和進行訂單處理的程式碼內容。
OrderInfo orderInfo = new OrderInfo()
{
    CustomerName = "Vulcan Lee",
    Email = "vulcan@my.com",
    Price = 55000,
    Produt = "iPhone 12",
    CreditCard = "1233211234567"
};

// 傳統緊密耦合開發方式,因此,我們需要自己建立所需要執行個體,也就是要使用 
// new 運算子與指定要產生類別的物件名稱
// 這樣,將會造成緊密耦合的設計結果
OrderManager orderManager = new OrderManager();
orderManager.Process(orderInfo);
這裡將會是執行結果輸出內容
進行付款
客戶紀錄已經更新
產品資訊已經更新
已經更新 Vulcan Lee 客戶 iPhone 12 產品訂單資訊
已經送出通知給 vulcan@my.com
接下來將會逐步來了解這個範例專案中所使用到的其他類別,以及這些類別內是否又存在者那些緊密耦合的關係。

OrderManager 類別

從 OrderManger 類別原始碼中,可以看到在該類別終將會與付款 Billing、客戶更新處理 CustomerProcessor、通知 Notification 這三個類別產生了緊密耦合關係,也就是說,在緊密耦合的關係下,相關類別產生了密切關係,若相依項的類別程式碼有異動的時候,就有可能造成其他類別也要隨之修改的問題、因為類別間有著緊密耦合關係,再進行使用這些相依類別的時候,需要關注該類別的明確實作程式碼,例如,當要建立相依類別物件的時候,需要了解該類別建構函式的使用方式與不同建構函式之間的差異,進而花費更多的時間與精力來進行開發整體專案、最後,因為類別間彼此有著緊密相依關係,所以,造成類別可以重複被使用的關係也降低了。
底下的 OrderManager 類別中,僅有宣告一個 Process 方法,該方法將會接收到一個 OrderInfo 物件 (也就是說 OrderManger 類別與 OrderInfo 類別存在者相依關係),在該方法內將會使用 new 運算子關鍵字來建立 Billing 、 CustomerProcessor 、 Notification 執行個體,接著分別呼叫這三個執行個體的方法,以便完成該訂單處理需求。
public class OrderManager
{
    public void Process(OrderInfo orderInfo)
    {
        Billing billing = new Billing();
        CustomerProcessor custmerProcessor = new CustomerProcessor();
        Notification notification = new Notification();

        billing.ProcessPayment(orderInfo);
        custmerProcessor.UpdateCustomerOrder(orderInfo);
        notification.Send(orderInfo);
    }
}

Billing 類別

在 Billing 類別中,將會用來處理該訂單的付款機制,因此,有設計一個 ProcessPayment 方法,在這個將會使用 Console.WriteLine("進行付款"); 敘述來表示該訂單已經完成付款過程了。
public class Billing
{
    public void ProcessPayment(OrderInfo orderInfo)
    {
        Console.WriteLine("進行付款");
    }
}

CustomerProcessor 類別

在 CustomerProcessor 類別中,也是僅設計一個 UpdateCustomerOrder 方法,不過,從底下的程式碼中可以看出,這個 CustomerProcessor 類別 將會與 CustomerRepository 和 ProductRepository 這兩個類別產生緊密耦合關係 (因為,在這裡使用了 new 運算子關鍵字來分別建立了這兩個類別的執行個體),並且分別呼叫這兩個物件的 Save() 方法;在這裡同樣的會使用 Console.WriteLine($"已經更新 {orderInfo.CustomerName} 客戶 {orderInfo.Produt} 產品訂單資訊"); 敘述來表示該客戶處理器已經完成相關更新的動作。
public class CustomerProcessor
{
    public void UpdateCustomerOrder(OrderInfo orderInfo)
    {
        CustomerRepository customerRepository = new CustomerRepository();
        ProductRepository productRepository = new ProductRepository();

        customerRepository.Save();
        productRepository.Save();

        Console.WriteLine($"已經更新 {orderInfo.CustomerName} 客戶 {orderInfo.Produt} 產品訂單資訊");
    }
}

CustomerRepository 類別

在 CustomerRepository 類別也是十分的簡單,在 Save() 方法內將會使用 Console.WriteLine($"客戶紀錄已經更新"); 敘述來表示客戶更新的複雜處理邏輯程式碼。
public class CustomerRepository
{
    public void Save()
    {
        Console.WriteLine($"客戶紀錄已經更新");
    }
}

ProductRepository 類別

在 ProductRepository 類別也是十分的簡單,在 Save() 方法內將會使用 Console.WriteLine($"產品資訊已經更新"); 敘述來表示產品更新的複雜處理邏輯程式碼。
public class ProductRepository
{
    public void Save()
    {
        Console.WriteLine($"產品資訊已經更新");
    }
}

Notification 類別

最後,來看看 Notification 類別,在 Send() 方法內將會使用 Console.WriteLine($"已經送出通知給 {orderInfo.Email}"); 敘述來表示已經發送出通知資訊給該訂單的使用者的複雜處理邏輯程式碼。
public class Notification
{
    public void Send(OrderInfo orderInfo)
    {
        Console.WriteLine($"已經送出通知給 {orderInfo.Email}");
    }
}

Phase 2 套用 DIP 原則,使用這個專案從具有緊密耦合程式碼重構成為具有鬆散耦合程式碼,並且修正為手動注入相關相依物件

在底下的說明,將會使用 Visual Studio 2019 工作做為操作說明
這個階段將會需要套用 DIP Dependency Inversion Principle 相依反轉原則,以便可以解除這個專案內的緊密耦合關係之程式碼。
現在來回顧一下,相依反轉 DIP 原則的內容
  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
    高階模組不應該依賴於低階模組,兩者都應相依於抽象
    高階模組 也稱為 Caller (呼叫端)
    低階模組 也稱為 Callee (被呼叫端)
  • Abstractions should not depend on details. Details should depend on abstractions.
    抽象不應該相依於細節。而細節則應該相依於抽象
現在逐一來進行重構工作,以便解除這個專案內使用類別的緊密耦合關係

Billing 類別

對於 OrderManager 類別,其屬於高階模組,在此類別內產生了與類別 Billing 、 CustomerProcessor 、 Notification 緊密耦合關係,也就是違反了 DIP 原則中的:高階組( OrderManager ) 不應依類於低階模組 (Billing、CustomerProcessor、Notification),那要怎麼解決此一問題呢?根據 DIP 原則的說明,這個時候需要做到 兩者都應相依於抽象。
現在,先來進行低階模組 Billing 類別的重構,等到所有低階模組都重構完成之後,再來重構這個 OrderManager 高階模組。
想要在 C# 內將一個類別抽象化,可以建立一個新的介面型別,並且讓原先類別實作該類別的方式,應該是最為簡單與快速的方法;然而在 Visual Studio 2019 內提供了方便的工具可以一氣呵成完成這樣的程式碼重構。
請打開 Billing.cs 檔案,將游標移動到 Billing 類別名稱上,此時,在該最左邊將會出現一個螺絲起子圖示,並且在該圖示的下方,出現了提示文字: 快速動作 (Alt + Enter 或 Ctrl + .),也就是說,若想要使用 Visual Studio 2019 提供的快速動作功能,可以使用底下的方式呼叫出快速動作快顯示窗出來
  • 使用游標點選螺絲起子圖示
  • 使用鍵盤按下 Alt + Enter
  • 使用鍵盤按下 Ctrl + .
因此,不論使用上述哪種方式,都可以呼叫出如下圖的快速動作子視窗出來,請在該彈出功能選項清單中,選擇 擷取介面 這個選項,這表示要將這個類別建立起一個新的介面,並且會自動設定這個類別有時做這個新介面型別。
現在顯示出一個 擷取介面 對話窗,這個對話窗主要分成三個區塊
  • 最上方的是 新介面名稱
    這裡是要設定把這個類別抽取成為一個介面,其介面的型別名稱;在 .NET 開發環境下,若要命名一個介面型別,將會在該介面型別名稱前面加入大寫 I 作為命名慣例,因此,在這裡將會產生出一個 IBilling 的介面型別。
  • 中間的選取目的地
    在這裡將會要設定這個新的介面型別,是要直接產生在現在該類別的檔案內,還是直接產生一個新的檔案,而該檔案名稱為 IBilling.cs;在這裡將會選擇前者 : 新增至 Current 檔案,這是因為為了簡化這個練習的複雜度。
  • 最下方的是 選取公用成員已形成介面
    這裡將會是要勾選,該類別的那些公開成員,要宣告在抽象介面內,在此,需要勾選 ProcessPayment 這個方法,因為,每個實作這個介面的類別,都要提供這個方法實作,這樣才可以完成付款處理動作。
一旦都選取好了之後,現在可以點選 確定 按鈕,這樣在 Billing.cs 檔案內的內容,就會如底下程式碼。所以,透過 Visual Studio 2019 提供的擷取介面功能,可以輕鬆地把一個類別產生出其抽象介面型別,並且自動加入該類別有實作該介面,這樣的機制讓我們省卻了許多撰寫程式碼的時間。
在這裡,日後想要針對訂單進行付款處理,就可以在用戶端來使用 IBilling 這個抽象介面型別,如此,在設計的時候面對的是一個抽象型別,不是一個具體的型別,這也就符合了 DIP 的這句話的低階模組相依於抽象的需求,至於高階模組相依於抽象的設計,將會等到最後才會來進行修正。
高階模組不應該依賴於低階模組,兩者都應相依於抽象
對於 DIP 內的第二個要求
抽象不應該相依於細節。而細節則應該相依於抽象
這裡的抽象 IBilling 並沒有相依於任何類別(細節),所有這樣的修正動作完全的符合了 DIP 原則的兩個要求。一旦,程式碼符合了 DIP 原則,那麼就可以具有鬆散耦合的特性,在撰寫用戶端程式碼的時候,就可以完全使用抽象的方式來進行設計了;至於細節部分,將會可以透過手動方式來提供或者使用 DI Container 容器來自動幫助注入。
public interface IBilling
{
    void ProcessPayment(OrderInfo orderInfo);
}

public class Billing : IBilling
{
    public void ProcessPayment(OrderInfo orderInfo)
    {
        Console.WriteLine("進行付款");
    }
}

Notification 類別

請打開 Notification.cs 檔案,將游標移動到 Notification 類別名稱上,使用 Visual Studio 2019 提供的快速動作功能,將這個類別進行抽象化
底下是使用 Visual Studio 2019 工具,把 Notification 類別抽象化產生出一個新的 INotification 介面結果,這樣的設計是符合 DIP 原則要求的。
public interface INotification
{
    void Send(OrderInfo orderInfo);
}

public class Notification : INotification
{
    public void Send(OrderInfo orderInfo)
    {
        Console.WriteLine($"已經送出通知給 {orderInfo.Email}");
    }
}

CustomerRepository 類別

請打開 CustomerRepository.cs 檔案,將游標移動到 CustomerRepository 類別名稱上,使用 Visual Studio 2019 提供的快速動作功能,將這個類別進行抽象化
底下是使用 Visual Studio 2019 工具,把 CustomerRepository 類別抽象化產生出一個新的 ICustomerRepository 介面結果,這樣的設計是符合 DIP 原則要求的。
public interface ICustomerRepository
{
    void Save();
}

public class CustomerRepository : ICustomerRepository
{
    public void Save()
    {
        Console.WriteLine($"客戶紀錄已經更新");
    }
}

ProductRepository 類別

請打開 ProductRepository.cs 檔案,將游標移動到 ProductRepository 類別名稱上,使用 Visual Studio 2019 提供的快速動作功能,將這個類別進行抽象化
底下是使用 Visual Studio 2019 工具,把 ProductRepository 類別抽象化產生出一個新的 IProductRepository 介面結果,這樣的設計是符合 DIP 原則要求的。
public interface IProductRepository
{
    void Save();
}

public class ProductRepository : IProductRepository
{
    public void Save()
    {
        Console.WriteLine($"產品資訊已經更新");
    }
}

CustomerProcessor 類別

請打開 CustomerProcessor.cs 檔案,將游標移動到 CustomerProcessor 類別名稱上,使用 Visual Studio 2019 提供的快速動作功能,將這個類別進行抽象化
底下是使用 Visual Studio 2019 工具,把 CustomerProcessor 類別抽象化產生出一個新的 ICustomerProcessor 介面結果。
public interface ICustomerProcessor
{
    void UpdateCustomerOrder(OrderInfo orderInfo);
}

public class CustomerProcessor : ICustomerProcessor
{
    public void UpdateCustomerOrder(OrderInfo orderInfo)
    {
        CustomerRepository customerRepository = new CustomerRepository();
        ProductRepository productRepository = new ProductRepository();

        customerRepository.Save();
        productRepository.Save();

        Console.WriteLine($"已經更新 {orderInfo.CustomerName} 客戶 {orderInfo.Produt} 產品訂單資訊");
    }
}
這樣的 CustomerProcessor 還是有些問題,因為在這裡還是有產生與 CustomerRepository 、 ProductRepository 緊密耦合的關係,不過,沒有關係,剛剛已經有將這兩個類別的抽象化界面產生出來了,所以,接下來是要修正 ProductRepository 類別相依於這兩個抽象介面,這樣就會符合 DIP 原則了。
在底下的程式碼將會在一次進行重構,這裡產生了一個 CustomerProcessor 類別用的建構函式,也就是當這個物件要被建立的時候,可以呼叫這個建構函式來進行初始化的動作;在這個建構函式內將會傳入兩個介面型別物件,以便在 UpdateCustomerOrder 方法內可以用來呼叫更新客戶資料與產品資料之用。
現在,底下的 CustomerProcessor 將是完全符合 DIP 原則了。
public interface ICustomerProcessor
{
    void UpdateCustomerOrder(OrderInfo orderInfo);
}

public class CustomerProcessor : ICustomerProcessor
{
    private readonly ICustomerRepository customerRepository;
    private readonly IProductRepository productRepository;

    public CustomerProcessor(ICustomerRepository customerRepository,
        IProductRepository productRepository)
    {
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
    }
    public void UpdateCustomerOrder(OrderInfo orderInfo)
    {
        customerRepository.Save();
        productRepository.Save();

        Console.WriteLine($"已經更新 {orderInfo.CustomerName} 客戶 {orderInfo.Produt} 產品訂單資訊");
    }
}

OrderManager 類別

在 OrderManger 類別內將會看到一個錯誤
CS7036 未提供任何可對應到 'CustomerProcessor.CustomerProcessor(ICustomerRepository, IProductRepository)' 之必要型式參數 'customerRepository' 的引數 DIExperience
這是因為剛剛有修正 CustomerProcessor 類別,該類別僅有一個建構函式,該建構函式需要傳入兩個物件,而原先緊密耦合的程式碼,則是呼叫預設建構函式(也就是沒有任何參數的建構函式),因此,會產生出這樣的錯誤訊息,不過,沒有關係,等下修正好之後,就不會有這樣的錯誤了。
請打開 OrderManager.cs 檔案,將游標移動到 OrderManager 類別名稱上,使用 Visual Studio 2019 提供的快速動作功能,將這個類別進行抽象化
底下是使用 Visual Studio 2019 工具,把 OrderManager 類別抽象化產生出一個新的 IOrderManager 介面結果。
public interface IOrderManager
{
    void Process(OrderInfo orderInfo);
}

public class OrderManager : IOrderManager
{
    public void Process(OrderInfo orderInfo)
    {
        Billing billing = new Billing();
        CustomerProcessor custmerProcessor = new CustomerProcessor();
        Notification notification = new Notification();

        billing.ProcessPayment(orderInfo);
        custmerProcessor.UpdateCustomerOrder(orderInfo);
        notification.Send(orderInfo);
    }
}
這樣的 OrderManager 還是有些問題,因為在這裡還是有產生與 Billing 、 ProductRepository 、 Notification 緊密耦合的關係,不過,沒有關係,剛剛已經有將這三個類別的抽象化界面產生出來了,所以,接下來是要修正 OrderManager 類別相依於這三個抽象介面,這樣就會符合 DIP 原則了。
在底下的程式碼將會在一次進行重構,這裡產生了一個 CustomerProcessor 類別用的建構函式,也就是當這個物件要被建立的時候,可以呼叫這個建構函式來進行初始化的動作;在這個建構函式內將會傳入兩個介面型別物件,以便在 Process 方法內可以用來呼叫相關訂單處理動作。
現在,底下的 CustomerProcessor 將是完全符合 DIP 原則了。
public class OrderManager : IOrderManager
{
    private readonly IBilling billing;
    private readonly ICustomerProcessor customerProcessor;
    private readonly INotification notification;

    public OrderManager(IBilling billing,
        ICustomerProcessor customerProcessor,
        INotification notification)
    {
        this.billing = billing;
        this.customerProcessor = customerProcessor;
        this.notification = notification;
    }
    public void Process(OrderInfo orderInfo)
    {
        billing.ProcessPayment(orderInfo);
        customerProcessor.UpdateCustomerOrder(orderInfo);
        notification.Send(orderInfo);
    }
}

使用手動注入方式,來完成呼叫 OrderManager.Process 的功能呼叫

現在已經完成現有訂單系統的鬆散耦合的重構,並且修正符合 DIP 的原則,接下來要修正 Main 方法內的相關程式碼。
首先修正 orderManager 這個變數的型別為 IOrderManager,並且當藥產生 OrderManager 執行個體的時候,因為建構函式的需要,也一並的將所相依的物件也在此產生出來,傳入相對應的建構函式內。
OrderInfo orderInfo = new OrderInfo()
{
    CustomerName = "Vulcan Lee",
    Email = "vulcan@my.com",
    Price = 55000,
    Produt = "iPhone 12",
    CreditCard = "1233211234567"
};

// 在這裡,我們已經將各個具體類別,分別進行介面抽象化,
// 並且在 OrderManager 類別的建構式上,分別需要傳入所需介面的具體實作類別物件
// 因此,在這裡,我們將會在建立 OrderManager 物件的時候,
// 將所需要的相關具體實作物件,都傳入到該建構函式內
IOrderManager orderManager = new OrderManager(
    new Billing(),
    new CustomerProcessor(new CustomerRepository(), new ProductRepository()),
    new Notification()
    );
orderManager.Process(orderInfo);
這裡將會是執行結果輸出內容
進行付款
客戶紀錄已經更新
產品資訊已經更新
已經更新 Vulcan Lee 客戶 iPhone 12 產品訂單資訊
已經送出通知給 vulcan@my.com

Phase 3 導入 Unity DI Container 並且使用相依性注入這個技術、體驗 OCP 原則和鬆散耦合的好處

因為這個專案原始碼已經符合了 DIP 相依反轉原則,現在,便可以直接在這個專案內安裝 Unity DI Container 套件,使用相依性注入的方式來設計這個專案,最後,將會嘗試修正 CustomerRepository 具體實作類別,立即加入一個 Log 機制,但是,在修正過程中,用戶端的程式碼是完全不用修改的,因為他們都相依於抽象介面,而不是相依於具體實作類別。

安裝 Unity DI 容器類別庫

首先需要安裝 Unity NuGet 套件
  • 請使用滑鼠右擊這個專案節點,選擇 管理 NuGet 套件 功能選項
  • 點選 NuGet: DIExperience 視窗的 瀏覽 標籤頁次
  • 在搜尋文字控制項內輸入 Unity
  • 請安裝所篩選出來的 Unity v5.11.1 NuGet 套件到這個專案內

修正程式碼要使用 Unity DI 容器來進行相依物件解析

完成之後,請打開 Program.cs 檔案,準備修改 Main 方法內的程式碼如下所示:
  • 首先將會建立一個型別為 IUnityContainer 的 Unity DI Container 容器物件
  • 而 DI Container 容器物件的主要工作就是 RRR
    • Register 註冊 : 註冊型別對應關係
    • Resolve 解析 : 解析出所需要的具體實作型別
    • Relase 釋放 : 根據註冊定義的物件生命週期,釋放掉不再用到的執行個體
  • 接著可以使用 container 物件,開始進行抽象介面與具體實作類別的對應註冊動作,這裡將會使用到 IUnityContainer RegisterType 這個泛型方法來做到這樣的需求
  • 一旦完成了相關抽象介面與類別的註冊,就可以呼叫 container.Resolve<IOrderManager>(); 方法,請求 DI 容器產生出一個 IOrderManager 的執行個體,而這個物件所相依的其他抽象介面,將會於 DI 容器產生這個物件的時候,也會一並進行解析,分別產生與注入到相關的執行個體內。
  • 當取得了 IOrderManager 物件之後,就可以呼叫 orderManager.Process(orderInfo); 方法,處理這筆訂單。
OrderInfo orderInfo = new OrderInfo()
{
    CustomerName = "Vulcan Lee",
    Email = "vulcan@my.com",
    Price = 55000,
    Produt = "iPhone 12",
    CreditCard = "1233211234567"
};

// 這裡將會展示如何利用 Unity DI Container 相依性注入容器,
// 建立 DI Container 物件
IUnityContainer container = new UnityContainer();

// 使用 DI Container 進行註冊:需要用到的型別對應集合
container.RegisterType<IBilling, Billing>();
container.RegisterType<ICustomerProcessor, CustomerProcessor>();
container.RegisterType<ICustomerRepository, CustomerRepository>();
container.RegisterType<INotification, Notification>();
container.RegisterType<IOrderManager, OrderManager>();
container.RegisterType<IProductRepository, ProductRepository>();

// 透過 DI Container 進行解析 : 自動產生出具體類別 OrderManager
// 的物件,並且自動幫我們注入所需要用到的具體實作類別物件

IOrderManager orderManager = container.Resolve<IOrderManager>();
orderManager.Process(orderInfo);
底下是使用 Unity DI Container 的執行結果
進行付款
客戶紀錄已經更新
產品資訊已經更新
已經更新 Vulcan Lee 客戶 iPhone 12 產品訂單資訊
已經送出通知給 vulcan@my.com

需求變更:更新客戶資料的時候,要寫入到 Log 內

現在模擬接收到一個需求變更情境,當呼叫 ICustomerRepository.Save() 的時候,需要能夠提供當寫入一筆紀錄到 Log 內。當面對這樣的請求的時候,可以先產生出一個 Log 類別,設計出寫入 Log 的相關動作。
底下的程式碼就是設計出來的 Logger 類別與其抽象介面 ILogger,這裡將會使用 Console.WriteLine($"寫入日誌中 : {message}") 方法模擬已經寫入到指定 Log 內的動作。
public interface ILogger
{
    void Log(string message);
}
public class Logger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"寫入日誌中 : {message}");
    }
}
現在,打開 CustomerRepository.cs 檔案,準備在這個類別中加入此一功能,底下的程式碼為完成後的程式碼內容。
原先的 CustomerRepository 類別之建構函式並不存在,所以將會使用預設建構函式,現在,為了要讓這個類別可以具有 Log寫入功能,因此,需要在這個類別內相依於 Logger 這個類別;不過,為了不要造成緊密耦合關係,因此,將會在這個類別內使其相依於 ILogger 介面。
因此,這需要新增加一個具有一個參數的建構函式,該參數的型別為 ILogger,也就是說,當這個類別的執行個體建立的時候,需要傳入一個 ILogger 物件到其建構函式內,如此,在該類別的 Save() 方法內,就可以使用 ILogger.Log 方法,將相關訊息寫入到 Log 內。
public interface ICustomerRepository
{
    void Save();
}
public class CustomerRepository : ICustomerRepository
{
    // ***********
    //若要在這個類別中加入寫入日誌功能,使用底下的建構式注入方法,並且解除 Save 方法內的註解
    private readonly ILogger _Logger;

    public CustomerRepository(ILogger logger)
    {
        _Logger = logger;
    }
    public void Save()
    {
        _Logger.Log("將會寫入到日誌檔案內");
        Console.WriteLine($"客戶紀錄已經更新");
    }
}
現在,問題來了,依照現在的設計, CustomerRepository 類別的執行個體,是透過 DI 容器自動解析後產生出來的,當初的 CustomerRepository 類別採用的預設建構函式,現在改成了需要一個參數的建構函式,那麼該如何處理呢?
作法十分簡單,因為 CustomerRepository 類別的物件是透過 DI 容器建立的,因此,當 DI 容器查看這個 CustomerRepository 類別僅有一個建構函式,且需要一個參數,就會自動解析這個參數,並且嘗試幫其建立起這個物件,接著傳入到 CustomerRepository 類別建構函式內。
若沒有做到這樣的修正,將會產生出底下的例外異常訊息
Unity.ResolutionFailedException: 'Resolution failed with error: No public constructor is available for type DIExperience.ILogger.

For more detailed information run Unity in debug mode: new UnityContainer(ModeFlags.Diagnostic)'
為了要完成這樣的需求,所以需要在 Program.cs 的 Main 方法內,註冊 ILogger 與 Logger 的對應關係,也就是加入 container.RegisterType<ILogger, Logger>(); 這行敘述,如同底下的程式碼。
OrderInfo orderInfo = new OrderInfo()
{
    CustomerName = "Vulcan Lee",
    Email = "vulcan@my.com",
    Price = 55000,
    Produt = "iPhone 12",
    CreditCard = "1233211234567"
};

// 這裡將會展示如何利用 Unity DI Container 相依性注入容器,
// 建立 DI Container 物件
IUnityContainer container = new UnityContainer();

// 使用 DI Container 進行註冊:需要用到的型別對應集合
container.RegisterType<IBilling, Billing>();
container.RegisterType<ICustomerProcessor, CustomerProcessor>();
container.RegisterType<ICustomerRepository, CustomerRepository>();
container.RegisterType<INotification, Notification>();
container.RegisterType<IOrderManager, OrderManager>();
container.RegisterType<IProductRepository, ProductRepository>();
container.RegisterType<ILogger, Logger>();

// 透過 DI Container 進行解析 : 自動產生出具體類別 OrderManager
// 的物件,並且自動幫我們注入所需要用到的具體實作類別物件

IOrderManager orderManager = container.Resolve<IOrderManager>();
orderManager.Process(orderInfo);
底下是執行結果輸出內容
進行付款
寫入日誌中 : 將會寫入到日誌檔案內
客戶紀錄已經更新
產品資訊已經更新
已經更新 Vulcan Lee 客戶 iPhone 12 產品訂單資訊
已經送出通知給 vulcan@my.com




2019年7月30日 星期二

C# 對於有使用修飾詞 async 非同步方法內,有沒有使用 await 關鍵字,其這兩種方法會有何差異呢?

C# 對於有使用修飾詞 async 非同步方法內,有沒有使用 await 關鍵字,其這兩種方法會有何差異呢? 

這篇文章主要是要說明,許多人在使用 C# 撰寫程式碼的時候,經常會寫出 async 修飾詞,設計出一個 async 的方法,不過,在這個方法內卻沒有使用到任何的 await 關鍵字。可是,這樣設計出來的 async 方法是不具備非同步運作的功能喔。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

關於 async 的使用說明

若在一個 C# 方法內,若有要等候一個非同步工作或者非同步方法的時候,可以使用 await 關鍵字來等候這些作業,不過,當在方法內加入了 await 運算子關鍵字的時候,就需要在該方法簽章中,加入 async。而且,對於 async 修飾詞若加入到方法簽章上的時候,對於該方法的回傳值就僅能夠是 Task, Task, void。
若原先的方法是一個事件 void MyEventHandler(object sender, EventArgs args),對於 [.NET 標準的事件方法簽章(https://docs.microsoft.com/zh-tw/dotnet/csharp/event-pattern)],就是使用 void 作為回傳值,因此,對於這樣的方法,需要修正為 async void MyEventHandler(object sender, EventArgs args)。對於該方法不是一個 .NET 標準事件,而且是一般的方法且回傳值為 void : void MyMethod(),這個時候需要將 void 修改成為 Task,其完整的寫法為 async Task MyMethod()。最後,當該方法為一般方法且回傳值為非 void 的型別 : string MyMethod(),當想要修正這個方法為非同步方法的時候,就需要在該方法前面加上 async 修飾詞與使用 Task 的泛型表示方式 async Task<string> MyMethod()

一個沒有使用 await 的非同步方法

在這裡將會檢視兩個範例程式碼,這兩個範例程式碼中,都有設計一個 async Task<string> MyMethodAsync() 方法,不過,其中一個在這個方法內是沒有使用 await 的關鍵字,現在,讓我們來看看這兩種的差別。
第一個就是一個沒有使用 await 的非同步方法,請先觀看底下的範例程式碼,在 MyMethodAsync 非同步方法內,是使用沒有任何的 await 關鍵字,這裡將會使用 Thread.Sleep(3000) 來做到同步休息三秒鐘的行為。
因此,請不要使用任何工具去執行這段範例程式碼,直接觀察看看與模擬執行結果,寫下你認為的輸出結果內容,在這裡僅需要寫下輸出結果是 1,2,3,4 這四個數字的排列順序。
底下是這個 async Task<string> MyMethodAsync() async 方法的實際執行結果,若你所思考的執行結果與這個不同,那麼,這代表你的觀念不正確,簡單的說,若一個有使用 async 的非同步方法,若該方法裡面沒有使用到任何的 await 關鍵字,那麼,這個 async 非同步方法就如同是一個同步方法,也就是我們平常在 .NET C# 程式語言中所設計的方法運作順序相同。而且,從底下的執行結果也看到一個重點,那就是整個執行過程中,並沒有任何非同步的效果,因為,所有程式碼都是在同一個執行緒 Thread 下來運行。
因此,這個範例執行結果為 1 3 4 2
1 (1)
進入到非同步方法
3 (1)
準備離開到非同步方法
4 (1)
2 (1)
呼叫非同步方法結果 My Result
Press any key for continuing...
C Sharp / C#
class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine($"1 ({Thread.CurrentThread.ManagedThreadId})");
        var task= MyMethodAsync();
        Console.WriteLine($"2 ({Thread.CurrentThread.ManagedThreadId})");

        string result = await task;
        Console.WriteLine($"呼叫非同步方法結果 {result}");

        Console.WriteLine("Press any key for continuing...");
        Console.ReadKey();
    }
    static async Task<string> MyMethodAsync()
    {
        Console.WriteLine($"進入到非同步方法");
        Console.WriteLine($"3 ({Thread.CurrentThread.ManagedThreadId})");

        Thread.Sleep(3000);

        Console.WriteLine($"準備離開到非同步方法");
        Console.WriteLine($"4 ({Thread.CurrentThread.ManagedThreadId})");

        return "My Result";
    }
}

在 async 非同步方法內使用 await 的執行順序

在第二個範例程式碼與第一個大致完全相同,只不過在 async 非同步方法內,將 Thread.Sleep(3000) 修改成為要使用 await Task.Delay(3000) 表示式。經過這樣修正之後,也就滿足了 async 修飾詞的需求定義,也就是說,要使用到 async 這個修飾詞,該方法內需要有使用到 await 運算子關鍵字。
現在,回想剛剛第一個範例程式碼的執行結果順序內容 : 1 3 4 2,請嘗試觀看底下的程式碼,嘗試自己想像應該是甚麼樣的執行順序,並且請寫下來。
答案似乎有些差異,因為,兩者的輸出內容卻不盡相同,在這個範例中的執行結果為 1 3 2 4,並且看到輸出 4 的時候,所使用到的執行緒與前面三個都不相同;因為這個 async 非同步方法確實是使用非同步的方式來執行,當要等候 await 的工作結束,此時,該async 非同步方法就會立即返回 return 呼叫端,在呼叫端就會繼續往下執行,而在 await 之後的表示式,就會使用非同步的方式來執行相關作業。
一旦非同步工作完成之後,也就是要繼續執行 await 下面的程式碼,因為這是一個 Console 類型專案,所以,將會從執行緒集區 ThreadPool 內取得一個執行緒,繼續往下來執行,這也就是在這裡所看到的結果。
1 (1)
進入到非同步方法
3 (1)
2 (1)
準備離開到非同步方法
4 (4)
呼叫非同步方法結果 My Result
Press any key for continuing...
C Sharp / C#
class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine($"1 ({Thread.CurrentThread.ManagedThreadId})");
        var task= MyMethodAsync();
        Console.WriteLine($"2 ({Thread.CurrentThread.ManagedThreadId})");

        string result = await task;
        Console.WriteLine($"呼叫非同步方法結果 {result}");

        Console.WriteLine("Press any key for continuing...");
        Console.ReadKey();
    }
    static async Task<string> MyMethodAsync()
    {
        Console.WriteLine($"進入到非同步方法");
        Console.WriteLine($"3 ({Thread.CurrentThread.ManagedThreadId})");

        await Task.Delay(3000);

        Console.WriteLine($"準備離開到非同步方法");
        Console.WriteLine($"4 ({Thread.CurrentThread.ManagedThreadId})");

        return "My Result";
    }
}

結論與建議

若方法內有使用到 await 關鍵字,要在方法簽章上加入 async 修飾詞,若該方法的回傳值不是 void ,則需要修改回傳值為 Task,若該方法不是一個事件的委派訂閱方法,則需要將 void 替換成為 Task。
若該方法有加入 async 修飾詞,但是裡面卻沒有使用到任何 await 運算子關鍵字,則這個 async 非同步方法,其實就是一個同步方法,並沒有具備非同的運算的效果,而且會有些許的效能損失;這是因為當在這個非同步方法內,不論有沒有使用到 await 關鍵字,只要在方法簽章前面有加上 async修飾詞,在建置這個專案的時候,就會產生該非同步方法需要用到的狀態機類別,而在實際執行這個方法的時候,還是會先產生這個狀態機物件,進行該狀態機的相關初始化設定,接著進入到狀態機內執行,只不過,此時並沒會使用非同步方式來執行,而是使用同步方式執行完成之後,就會離開該狀態機,返回到呼叫端程式碼上。