依賴注入 - 看這篇就夠了


引言

本文是依賴注入技術的入門文章,基於 .NET 平台使用 C# 語言講解。

如果對 C# 語言的基本特性和語法比較熟悉,那么理解起來會更加容易一些,其中包括,但不僅限於, C# 中的類,函數,接口。
如果對 C# 語言沒有任何基礎,但是了解其他面向對象的語言,那么應該也不妨礙你讀懂這篇文章。

之前接觸的一些框架和源碼里有用到依賴注入容器,但當時我不知道依賴注入容器怎么用,更加不知道依賴注入技術是什么。我不清楚其他人第一次看到依賴注入容器的代碼是什么感覺,至少我起初看到這些代碼,是比較困惑的,因為沒法連貫起代碼的邏輯性,必要時,只能自己摸索着做些修改,然后運行程序看看結果。直到研究了依賴注入的思想和原理,才算搞明白其中的原委。

聲明

本文有部分內容,包括文字(原文為英文),代碼,和圖片,來自 Pluralsight 在線學習門戶,主要目的是對階段性學習做個總結分享,非用於商業用途,侵刪。

什么是依賴注入

越來越多的框架植入了依賴注入容器。那么什么是依賴注入容器?要解答這個問題,我們得先了解什么是依賴注入。因為許多功能都隱藏在依賴注入容器背后,只學會依賴注入容器的使用而不了解依賴注入技術本身的原理,顯然是管中窺豹可見一斑。

No Images :(

那么什么是依賴注入,依賴注入技術在我們的應用程序代碼中起到了什么作用?這個問題每個人理解不同,自然有很多不同的答案,但基本大同小異。這里翻譯了其中一種解釋(原文可以參考 Mark Seemann 的書籍 Dependency Injection in .NET)。
讓我們一起來看。

依賴注入,也就是 DI,英文 dependency injection 的縮寫,是一系列用於開發解耦合代碼的軟件設計原則和模式。

這個定義不僅解釋了什么是依賴注入:軟件設計原則和模式。同時也告訴了我們依賴注入的作用:開發解耦合的代碼。
所以,依賴注入是設計模式范疇的技術,和其他我們接觸過的設計模式一樣,主要是用於降低應用程序代碼的耦合度。

那么又有問題來了,為什么我們關心耦合度,或者說,為什么我們追求解耦合的代碼?有以下幾點原因:

  • 解耦合的代碼更加易於擴展。我們能夠在不改變大量對象的情況下增加功能。
  • 我們能夠將功能獨立開來,以便編寫簡短的,易於閱讀的單元測試。
  • 我們也獲得了易於維護的代碼。當程序出錯的時候,我們能夠更加容易發現我們需要修改哪部分內容。
  • 我們在團隊協作開發的過程中,比如提交合並代碼,通常不希望也應該避免團隊成員之間的代碼存在沖突,而解耦合有利於團隊成員各自維護自己的代碼片段而互相不受影響。
  • 解耦合可以使延遲綁定變得更加容易。延遲綁定,或者運行時綁定,是我們在運行時做決定而不是編譯時,這在特定場合下很有用。

這么說可能不夠直觀,沒關系,我們只要知道解耦合能夠給我們帶來不少好處,至於具體如何體現的,我們后面會慢慢講。

我們首先會看下緊耦合代碼帶來的一些問題,然后演示如何通過依賴注入來解耦合代碼,以便於進一步的擴展和測試。最后還會介紹如何將 Ninject 容器應用到我們的程序中。

另外,當我們演示代碼時,還會涉及 SOLID 原則相關的部分內容。SOLID 原則包括了個五個編碼設計原則,它能夠幫助我們設計出更易維護的代碼。但本文的主題是依賴注入,對 SOLID 原則的部分內容只簡單帶過,如果想深入了解和學習,可以自行搜索相關資料。

依賴注入的實現方式有很多種,為了縮短篇幅,我將只挑選其中最常用的模式來講解,構造函數注入。但是要知道,如果實際的應用場景不太一樣,還有其他的一些模式可以選擇。

示例程序

由於設計模式的部分概念過於抽象,單純用文字想解釋清楚也比較困難。所以,這里准備了一個應用程序,后面的內容都將圍繞這個應用程序進行講解,這個程序看起來很簡單,實際又有些復雜。

說它簡單是因為這個程序只有一個窗口。它從 Web 服務讀取數據,然后顯示在列表中,這就完了。通常我們不會在這么小規模的應用中使用依賴注入。因為它並不值得,為了代碼的解耦而增加了程序的復雜性。

但是如果給你們展示一個真實的應用,又容易迷失在應用程序架構,模塊交互,業務處理,以及其他許多方面的事情。
因此,我們將使用一個單窗體的應用以便於我們能夠將注意力放在依賴注入的概念上。

說它復雜是因為這個單窗體的應用被分割成了多個層級。通常,我們不會對一個單窗體應用這么分層。但是,這同樣有助於我們關注依賴注入本身,以及我們將看到依賴注入是如何幫助我們在大型應用中解決問題的。

這個應用程序分為四層:

  • 視圖層,應用的界面。它由用戶界面,控件比如按鈕,列表等組成。
  • 表現層,處理 UI 的邏輯部分。它包含了按鈕事件調用的函數,UI 界面列表綁定的存儲數據的對象。
  • 數據訪問層,負責與數據倉庫的交互代碼。示例的開始,我們從 Web 服務獲取數據。數據訪問層知道如何發起一個 Web 服務調用,然后將數據存儲到對象中,以便應用的其他模塊可以方便的使用。
  • 數據倉庫,獲取實際數據的地方。

整個應用程序的代碼結構可以參考下圖:

下面貼上主要部分的代碼:

PeopleViewerWindow.xaml.cs

    using PeopleViewer.Presentation;
    using System.Windows;

    namespace PeopleViewer
    {
        public partial class PeopleViewerWindow : Window
        {
            public PeopleViewerWindow()
            {
                InitializeComponent();

                DataContext = new PeopleViewModel();
            }
        }
    }

PeopleViewModel.cs

    using Common;
    using PersonRepository.Service;
    using System;
    using System.Collections.Generic;
    using System.ComponentModel;
    using System.Windows.Input;

    namespace PeopleViewer.Presentation
    {
        public class PeopleViewModel : INotifyPropertyChanged
        {
            protected ServiceReader Repository;

            public PeopleViewModel()
            {
                Repository = new ServiceReader();
            }

            private IEnumerable<Person> _people;
            public IEnumerable<Person> People
            {
                get { return _people; }
                set
                {
                    if (_people == value)
                        return;
                    _people = value;
                    RaisePropertyChanged("People");
                }
            }

            private string _repositoryType = string.Empty;
            public string RepositoryType
            {
                get { return _repositoryType; }
                set
                {
                    if (_repositoryType == value)
                        return;
                    _repositoryType = value;
                    RaisePropertyChanged("RepositoryType");
                }
            }

            #region RefreshCommand Standard Stuff

            private RefreshCommand _refreshPeopleCommand = new RefreshCommand();
            public RefreshCommand RefreshPeopleCommand
            {
                get
                {
                    if (_refreshPeopleCommand.ViewModel == null)
                        _refreshPeopleCommand.ViewModel = this;
                    return _refreshPeopleCommand;
                }
            }

            public class RefreshCommand : ICommand
            {
                public PeopleViewModel ViewModel { get; set; }
                public event EventHandler CanExecuteChanged;
                public bool CanExecute(object parameter)
                {
                    return true;
                }
                #endregion RefreshCommand Standard Stuff

                public void Execute(object parameter)
                {
                    ViewModel.People = ViewModel.Repository.GetPeople();
                    ViewModel.RepositoryType = ViewModel.Repository.ToString();
                }
            }

            #region ClearCommand Standard Stuff

            private ClearCommand _clearPeopleCommand = new ClearCommand();
            public ClearCommand ClearPeopleCommand
            {
                get
                {
                    if (_clearPeopleCommand.ViewModel == null)
                        _clearPeopleCommand.ViewModel = this;
                    return _clearPeopleCommand;
                }
            }

            public class ClearCommand : ICommand
            {
                public PeopleViewModel ViewModel { get; set; }
                public event EventHandler CanExecuteChanged;
                public bool CanExecute(object parameter)
                {
                    return true;
                }

                #endregion

                public void Execute(object parameter)
                {
                    ViewModel.People = new List<Person>();
                    ViewModel.RepositoryType = string.Empty;
                }
            }

            #region INotifyPropertyChanged Members

            public event PropertyChangedEventHandler PropertyChanged;
            private void RaisePropertyChanged(string propertyName)
            {
                var handler = PropertyChanged;
                if (handler != null)
                    handler(this, new PropertyChangedEventArgs(propertyName));
            }

            #endregion
        }
    }

ServiceReader.cs

    using Common;
    using Newtonsoft.Json;
    using System;
    using System.Collections.Generic;
    using System.Net;

    namespace PersonRepository.Service
    {
        public class ServiceReader
        {
            public IEnumerable<Person> GetPeople()
            {
                WebClient client = new WebClient();
                var baseUrl = "http://localhost:5001/api/people/";
                var result = client.DownloadString(baseUrl);

                return DeserializeObject(result);
            }

            private IEnumerable<Person> DeserializeObject(string result)
            {
                return JsonConvert.DeserializeObject<IEnumerable<Person>>(result);
            }

            public Person GetPerson(string lastName)
            {
                throw new NotImplementedException();
            }
        }
    }

Person.cs

    using System;

    namespace Common
    {
        public class Person
        {
            public string FirstName { get; set; }
            public string LastName { get; set; }
            public DateTime StartDate { get; set; }
            public int Rating { get; set; }
        }
    }

提供 Web 服務相關的代碼沒有放上來,因為不影響我們講解的內容,我們只需要知道示例程序中訪問的 Web API 的 Url,就像我們知道文件系統某個目錄下有個 CSV 文件,能夠給我們提供一些 Person 數據,這就夠了。

什么是緊耦合

粗略一看,我們的應用程序代碼似乎挺好的。代碼有合理地分層,每個層級負責特定的任務。UI 相關的任務都在視圖層,獲取數據相關的任務都在數據訪問層。

但是我們的應用程序還是存在着一個問題:緊耦合。

盡管每個代碼層級負責不同的任務,但是每個層級還是干了一些不屬於它職責范圍的操作,這就導致了緊耦合。

讓我們回到這個應用程序再看一下。

類 PeopleViewerWindow 在視圖層,包含了 UI 控件。但是我們看下它的構造函數會發現,在視圖層, PeopleViewerWindow 正在實例化一個 PeopleViewModel 對象。當我們在代碼中實例化一個對象,耦合就產生了。

PeopleViewerWindow's Constructor

    public partial class PeopleViewerWindow : Window
    {
        public PeopleViewerWindow()
        {
            InitializeComponent();

            DataContext = new PeopleViewModel();
        }
    }

首先,我們需要在編譯時引用那個對象。如果我們不在編譯時引用,毫無疑問,編譯器沒法成功編譯我們的代碼。另外,無論何時我們實例化一個對象,我們就得負責這個對象的生命周期。這樣的結果就是,PeopleViewerWindow 和 PeopleViewModel 在表現層緊緊地耦合在了一起。

我們繼續往下看,還會發現同樣的問題。

類 PeopleViewModel 負責表現層的邏輯。
在它的構造函數中,我們實例化了一個數據訪問對象 ServiceReader。

PeopleViewModel's Constructor

    public class PeopleViewModel : INotifyPropertyChanged
    {
        protected ServiceReader Repository;

        public PeopleViewModel()
        {
            Repository = new ServiceReader();
        }

        ...
    }

正如我們剛剛提到的,我們對 ServiceReader 程序集產生了編譯時的引用,我們就得負責這個對象的生命周期。

特別是,PeopleViewModel 在選擇用什么技術來獲取數據。通過實例化出 ServiceReader,PeopleViewModel 決定了數據來自 Web 服務。這樣的結果就是位於表現層的 PeopleViewModel 和位於數據訪問層的 ServiceReader 緊緊地耦合了。

更糟糕地是,在類 ServiceReader 的方法 GetPeople 中,它實例化了一個 WebClient 對象,來獲取實際的 Web 服務。

ServiceReader's GetPeople

    public class ServiceReader
    {
        public IEnumerable<Person> GetPeople()
        {
            WebClient client = new WebClient();
            var baseUrl = "http://localhost:5001/api/people/";
            var result = client.DownloadString(baseUrl);

            return DeserializeObject(result);
        }
    }

這意味着我們在編譯時引用了 WebClient,我們就得負責這個對象的生命周期。當我們想要在應用中多個地方共享同一個 WebClient 時,這尤其會成為一個問題。結果就是數據訪問層緊緊地耦合了數據源。

數據訪問層通常需要知道數據源相關的一些信息,這里的耦合還不是那么嚴重。然而,這影響了應用程序整體的耦合度。因為視圖層耦合着表現層,表現層耦合着數據訪問層,數據訪問層耦合着數據源,這意味着視圖層耦合着數據源。

可能有人會問,對於這么簡單的一個應用程序來說,代碼耦合有關系嗎?答案是有關系!因為我們的應用程序開發並沒有結束,還會有新的需求,我們往下看。

緊耦合是如何影響我們的代碼的

剛開始,當我們把這個應用演示給客戶的時候,他們會說,emm,不錯,這就是我們想要的功能:讀取數據,並且按照我們的需求顯示出來。

但是,這還不夠。

首先,客戶希望程序可以連接不同的數據源。應用程序從 Web 服務獲取數據沒有問題,但是並不是所有的客戶端都使用 Web 服務。部分客戶端使用逗號分隔的文本文件,比如 CSV,部分使用 SQL 數據庫,部分使用文件數據庫,他們還希望客戶端能夠支持雲服務甚至 Azure 功能。

OK,我們想了一下,應該沒什么問題。這表示我們需要在實例化 ServiceReader 的地方做一些修改。最懶惰的方法就是可以增加 switch 聲明,PeopleViewModel 可以實例化 ServiceReader,CSVReader,SQLReader。

代碼看起來比較挫,但是他能實現我們的功能,所以讓我們接着往下看。

客戶又有更多需求:在客戶端要支持緩存。

在我們的應用中,數據訪問層和數據源之間的通信通常花費了大部分的時間。因為我們通常通過網絡發送信息,這受到網絡延遲和帶寬的限制。因為這個過程很耗時,所以支持客戶端的緩存是很有必要的。所以我們需要一份數據的本地備份,從而不需要總是通過網絡請求就可以重用數據。另外,客戶還要求:緩存應當是可選的。
於是,我們繼續擴展 switch 聲明來支持帶緩存或者不帶緩存的各種 DataReader。

到這里,應該感覺到代碼有些問題了,問題出在哪呢?我們來看。
問題在於,這部分代碼違反了 SOLID 原則中的 S,也就是單一職責原則(Single Responsibility Principle)。

單一職責原則告訴我們,對象應當只有一個原因去作出變更。但是我們的 PeopleViewModel 有多個職責。主要的職責是表現層邏輯,但是它還負責為應用程序選擇數據源,以及負責這些數據源的生命周期。現在,它還要決定我們是否使用緩存。毫無疑問,這肯定包含了太多的職責,這也是為什么代碼會變得越來越難維護。

不僅如此,客戶還要求有單元測試。

單元測試能夠幫助我們節約很多編碼時間。如果我們嘗試為表現層的 PeopleViewModel 寫單元測試,就需要實例化 PeopleViewModel 對象。但是在 PeopleViewModel 的構造函數中,我們實例化了 ServiceReader,而 ServiceReader 實例化了連接 Web 服務的 WebClient 對象。意味着測試想要正常工作,Web 服務就需要保持運行。

這顯然是不現實的,我們需要應用程序有更好的獨立性,以便測試更加可靠和可重復。我們可以在測試中使用反射來改變 ServiceReader,但是這么做的結果是測試代碼變得復雜以及難以維護,而我們希望單元測試應該有簡單可讀的代碼,這樣我們才會更願意去使用它們。

在我們繼續之前,我們再整理下客戶的需求:

  • 使用不同的數據源
  • 增加客戶端緩存,並且是可選的
  • 包含單元測試

上面說的方案顯然是不可取的,那么我們怎么樣才能避免產出緊耦合的代碼呢?這就引出了本文的主題,我們繼續往下看。

使用依賴注入解耦合應用

到這里,我們的主角終於可以上場了。

下面,我們就使用依賴注入進行代碼解耦合。

首先,我們給代碼添加一層抽象。然后,我們會使用依賴注入中的一種模式——構造函數注入,來創建解耦合的代碼。
在之前我們設想的方案中,問題主要出在類 PeopleViewModel 上,也就是應用的表現層,尤其是類 PeopleViewModel 的構造函數,實例化類 ServiceReader 的地方。

因此,我們將關注於類 PeopleViewModel 和 ServiceReader 的解耦。如果我們解耦成功,我們就能夠更容易地滿足用戶的需求。

所以,總體來講,解耦可以分為三步:

  • 添加一個接口,一個抽象層,增加代碼靈活性
  • 在應用程序代碼中加入構造函數注入
  • 將解耦的各個模塊組合到一起

首先第一步,我們需要思考下如何才能夠讓我們的應用程序連接不同的數據源。

這里直接引入 Repository 模式。

Repository 模式作為應用程序對象和數據獲取模塊的媒介,使用類似集合的接口來獲取應用程序對象。它將應用程序從特定的數據存儲技術分割了出來。

Repository 的思想是知道如何和數據源溝通,不管是 HTTP,文件系統上的文檔,還是數據庫訪問。在獲得這些數據之后,將其轉換成應用程序其他模塊可以使用的 C# 對象。

emm,這不就是 ServiceReader 現在干的事情嘛。它對 Web 服務發起了一個 HTTP 請求,然后將 JSON 格式的結果轉換成應用程序可以理解的 People 類對象。但是問題在於表現層的 PeopleViewModel 與數據訪問層的 ServiceReader 直接進行了交互。

為了我們的應用更加地靈活,我們給 Repository 加上接口,所有在表現層的通信都將通過這個接口實現。

這符合 SOLID 原則中的 D,也就是依賴倒置原則(Dependency Inversion Principle)。依賴倒置原則中的一點是,上層的模塊不應該依賴於下層的模塊,應該都依賴於接口。

有了抽象,表現層就可以很容易的與 CSV 或者 SQL Repository 通信了。

很多時候,我們使用的 Repository 是 CRUD Repository,CRUD 代表 create, read, update, 以及 delete。
然而,我們不需要所有這些操作。SOLID 原則中 I,也就是接口分離原則(Interface Segregation Principle)告訴我們,接口應該只包含需要的功能。
一個完整的 Repository 通常有讀寫的操作,但是我們的應用程序中,類 PeopleViewModel 只需要讀操作。
因此為了符合接口分離原則,我們的接口應該也只支持讀操作。

基於此,我們將創建一個數據讀取接口,IPersonReader。接口包含了一個 GetPeople 函數,返回所有的 Person 對象,還有一個 GetPerson 方法檢索單個人的信息。

IPersonReader

    namespace Common
    {
        public interface IPersonReader
        {
            IEnumerable<Person> GetPeople();

            Person GetPerson(string lastName);
        }
    }   

讓我們在代碼里加上這個接口,以對 PeopleViewModel 和 ServiceReader 進行解耦。
類 ServiceReader 有兩個方法 GetPeople 和 GetPerson。這也是我們在接口中需要的兩個方法。
所以我們新建接口 IPersonReader,並且讓 ServiceReader 繼承它。

ServiceReader

    namespace PersonRepository.Service
    {
        public class ServiceReader : IPersonReader
        {
            public IEnumerable<Person> GetPeople()
            {
                ...            
            }

            public Person GetPerson(string lastName)
            {
                throw new NotImplementedException();
            }

            ...
        }
    }

我們回到表現層的 PeopleViewModel,將成員變量 ServiceReader 改為 IPersonReader。這只是解耦的一小部分,我們需要的是避免在構造函數中實例化 ServiceReader。

所以,下面我們准備解耦表現層的 PeopleViewModel 和 數據訪問層的 ServiceReader。
解耦的方式是,通過構造函數,注入 ServiceReader 到 PeopleViewModel, 注入 PeopleViewModel 到 PeopleViewerWindow,然后再將這些對象組合在一起。

我們來看下,在 PeopleViewModel 中的構造函數中,我們不希望實例化 ServiceReader。因為選取數據源不是 PeopleViewModel 的職責。
所以我們給構造函數添加一個參數,通過這個參數我們將 ServiceReader 對象傳遞給 PeopleViewModel 的成員變量 IPersonReader。

這個添加構造函數參數的簡單操作,其實就實現了依賴注入。

PeopleViewModel

namespace PeopleViewer.Presentation
{
    public class PeopleViewModel : INotifyPropertyChanged
    {
        protected IPersonReader DataReader;

        public PeopleViewModel(IPersonReader reader)
        {
            DataReader = reader;
        }
        ...
    }
}

我們沒有消除依賴,PeopleViewModel 仍然依賴於 IPersonReader,我們通過這個接口調用 GetPeople。
但是 PeopleViewModel 不再需要管理依賴對象,我們通過構造函數注入依賴,這就是為什么這個模式叫做構造函數注入。

這個時候如果我們編譯程序會發現,PeopleViewerWindow 的代碼出錯了,因為它想要實例化一個無參的 PeopleViewModel 構造函數。我們可以在 PeopleViewerWindow 中實例化 ServiceReader,但是實例化 ServiceReader 不是 PeopleViewModel 的職責,所以更加不是 PeopleViewerWindow 的職責。

那么怎么解決呢?
我們把這個問題丟出去,不管誰創建了 PeopleViewModel,都應該負責創建一個 ServiceReader。所以我們仍然用構造函數將 PeopleViewModel 注入到 PeopleViewerWindow。

PeopleViewerWindow

namespace PeopleViewer
{
    public partial class PeopleViewerWindow : Window
    {
        public PeopleViewerWindow(PeopleViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;
        }
    }
}

有件事注意一下,這里我們沒有給 PeopleViewModel 創建接口,通常我們只在需要的時候添加接口,因為接口增加了一層復雜性以及對代碼進行了重定向。以我們一貫的經驗來看,View 和 ViewModel 之間的關系大多是一對一或者多對一的,因此我們不介意在這有具體類的耦合。
如果我確實需要將多個 PeopleViewModel 綁定到同一個 PeopleViewerWindow,那么我會先添加一個接口。

接下來,我們需要將各個解耦合的模塊組合起來,我們打開 App.xaml.cs 文件,在 OnSratup 方法中實例化 PeopleViewerWindow,由於 PeopleViewerWindow 的構造函數需要一個 PeopleViewModel 的對象,所以我們需要首先實例化 PeopleViewModel,而 PeopleViewModel 的構造函數需要一個 IPersonReader 類型的對象,所以我們還得先實例化一個 ServiceReader 對象並注入到 PeopleViewModel 的構造函數。

App.OnStartup

namespace PeopleViewer
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            ComposeObjects();
            Application.Current.MainWindow.Show();
        }

        private static void ComposeObjects()
        {
            var repository = new ServiceReader();
            var viewModel = new PeopleViewModel(repository);
            Application.Current.MainWindow = new PeopleViewerWindow(viewModel);
        }
    }
}

這里我們在做的就是把各個解耦的片段組合起來。運行程序后,和之前一樣,從 web 服務讀取的數據顯示在界面上了。

我們看到,類 App 中的 OnStartup 扮演的是一個 Bootstrapper 的角色,負責啟動應用程序,以及將不同模塊的對象組合到一起。

現在 Bootstrapper 和 Viewer 在同一個工程,我們也可以把 Viewer 放到單獨的一個工程,后面依賴注入容器的部分,我們會看到,我們可以使用不同的 Bootstrapper 來調用這個 Viewer 對象。

我們來回顧下這部分內容,我們對 PeopleViewModel 和 ServiceReader 通過構造函數注入進行解耦。

我們給構造函數增加了一個參數,同時增加了依賴注入,而不是在內部處理依賴。類 PeopleViewModel 依賴 IPersonReader,因為需要調用接口 IPersonReader 的 GetPeople 方法。
我們沒有消除依賴,我們只是控制了怎么處理依賴,通過添加構造函數注入,我們把處理依賴的部分丟給了 Bootstrapper 模塊,這就是依賴注入實現的方式。

PeopleViewModel 不再負責依賴對象的生命周期管理,而只是使用依賴對象。由此,我們解耦合了表現層的 PeopleViewModel 和數據訪問層的 ServiceReader。這給了我們進一步開發的靈活性。

在上一節,我們看到了第一個版本的代碼違反了 SOLID 原則中的 S,單一職責原則。

而更新的代碼中,PeopleViewModel 不再通過實例化 ServiceReader 來選擇數據源,不再負責 ServiceReader 的生命周期,也不再決定是否使用客戶端緩存。

PeopleViewModel 現在只負責表現層的邏輯,提供 UI 能夠調用的方法和綁定的數據,這就做到了職責單一。

解耦合代碼解決的問題

在之前的內容中,我們花了不少時間在應用程序中加入依賴注入。我們通過構造函數注入依賴,然后在啟動應用的 Bootstrapper 中將不同模塊的對象組合起來。

現在代碼已經解耦了,那么,這給我們帶來了什么好處?

后面的內容,我們可以看到,通過替換程序中 ServiceReader,可以從不同的數據源獲取數據,我們可以從文本文件中獲取數據,也可以從數據庫中獲取數據。

我們還可以給程序增加一個客戶端緩存,在應用程序的內存中保存數據,以避免我們每次請求都訪問數據源。

我們可以很容易的給程序添加簡潔的單元測試代碼。

這也是我們之前看過的三個需求,並且我們也會看到代碼的實現方式都遵循了 SOLID 原則。

我們先來看第一個需求,替換不同的數據源。

我們以讀取 CSV 文件的數據為例子,要從 CSV 文件獲取數據,我們將增加一個 CSV 文件的 DataReader, 然后把它加到程序里。

我們添加一個 PersonDataReader.CSV 工程,其中,類 CSVReader 有我們需要的功能。

CSVReader

namespace PersonRepository.CSV
{
    public class CSVReader : IPersonReader
    {
        string _csvFilename;

        public CSVReader()
        {
            _csvFilename = AppDomain.CurrentDomain.BaseDirectory + "Resources\\People.csv";
        }

        public IEnumerable<Person> GetPeople()
        {
            var people = new List<Person>();

            if (File.Exists(_csvFilename))
            {
                using (var sr = new StreamReader(_csvFilename))
                {
                    string line;
                    while ((line = sr.ReadLine()) != null)
                    {
                        var elems = line.Split(',');
                        var per = new Person()
                        {
                            FirstName = elems[0],
                            LastName = elems[1],
                            StartDate = DateTime.Parse(elems[2]),
                            Rating = Int32.Parse(elems[3])
                        };
                        people.Add(per);
                    }
                }
            }
            return people;
        }

        public Person GetPerson(string lastName)
        {
            Person selPerson = new Person();
            if (File.Exists(_csvFilename))
            {
                var sr = new StreamReader(_csvFilename);
                string line;
                while ((line = sr.ReadLine()) != null)
                {
                    var elems = line.Split(',');
                    if (elems[1].ToLower() == lastName.ToLower())
                    {
                        selPerson.FirstName = elems[0];
                        selPerson.LastName = elems[1];
                        selPerson.StartDate = DateTime.Parse(elems[2]);
                        selPerson.Rating = Int32.Parse(elems[3]);
                    }
                }
            }

            return selPerson;
        }

    }
}

首先,CSVReader 實現了 IPersonReader 接口,因此我們的 PeopleViewModel 能夠像使用 ServiceReader 一樣使用 CSVReader。
方法 GetPeople 使用 StreamReader 從文件系統加載文件,一旦數據返回,它就將數據解析到 Person 對象。

然后,我們需要重新組合應用程序模塊,用 CSVReader 替換 ServiceReader。
所以我們重新回到 bootstrapper 工程的類 App,引用 PersonDataReader.CSV 工程。然后在 OnStartup 函數中實例化一個 CSVReader 來替代 ServiceReader,只有一行代碼的改動,是不是很簡單。

App.OnStartup

namespace PeopleViewer
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            ComposeObjects();
            Application.Current.MainWindow.Show();
        }

        private static void ComposeObjects()
        {
            var repository = new CSVReader();
            var viewModel = new PeopleViewModel(repository);
            Application.Current.MainWindow = new MainWindow(viewModel);
        }
    }
}

我們重新運行我們的應用,點擊 Fetch People 按鈕,我們從 CSV 文件獲取到了數據。

我們看到,我們不需要修改程序中的 PeopleViewModel 類,不需要修改 PeopleViewerWindow 類,也不需要修改 ServiceReader 類,就替換了程序的數據源。

這符合 SOLID 原則中的 O,也就是開閉原則(Open-Closed Principle)。開閉原則要求我們現有的代碼應當面向擴展開放,而面向修改封閉。

我們接着再來看第二個需求,增加客戶端緩存。

我們可以在 CSVReader 或者 ServiceReader 中直接增加數據緩存的代碼,但是現在我們的代碼已經解耦合了,我們有更好的方式去實現這個功能,這里我們引入 Decorator 模式。

那么什么是 Decorator 模式? Decorator 模式是一種通過包裝現有接口來增加功能的模式。

根據我們的需求翻譯下就是,包裝實現了接口 IPersonReader 的 ServiceReader 或者 CSVReader,來增加數據緩存功能。

我們先導入 PersonRepository.Caching 工程,暫時先不管 CachingReader 的代碼實現,我們先看看現有的代碼需要做哪些修改。

我們以 ServiceReader 為例,ServiceReader 實現了 IPersonReader 接口。我們獲取 ServiceReader, 並將它包裝在一個 CachingReader 里。這樣就增加了我們需要的數據緩存功能。

App.OnStartup

namespace PeopleViewer
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            ComposeObjects();
            Application.Current.MainWindow.Show();
        }

        private static void ComposeObjects()
        {
            var repository = new CSVReader();
            var wrappedRepository = new CachingReader(repository);
            var viewModel = new PeopleViewModel(wrappedRepository);
            Application.Current.MainWindow = new MainWindow(viewModel);
        }
    }
}

完了嗎?完了!就這么簡單。

我們重新運行下程序,確保 Web 服務運行,然后 Fetch People,得到了數據。我們再把 Web 服務關掉,清空數據,重新 Fetch People,依然能夠獲得數據,我們的緩存時間設置了15秒,所以我們等緩存時間到了,重新 Fetch People,數據沒有了,如果我們重新啟動 Web 服務,重新 Fetch People,會發現數據又恢復了。

現在讓我們來看下 CachingReader 類的實現。

CachingReader

namespace PersonRepository.Caching
{
    public class CachingReader : IPersonReader
    {
        private TimeSpan _cacheDuration = new TimeSpan(0, 0, 15);
        private DateTime _dataDateTime;
        private IPersonReader _wrappedRepository;
        private IEnumerable<Person> _cachedItems;

        public CachingReader(IPersonReader wrappedPersonRepository)
        {
            _wrappedRepository = wrappedPersonRepository;
        }

        public IEnumerable<Person> GetPeople()
        {
            ValidateCache();
            return _cachedItems;
        }

        public Person GetPerson(string lastName)
        {
            ValidateCache();
            return _cachedItems.FirstOrDefault(p => p.LastName == lastName);
        }

        private bool IsCacheValid
        {
            get
            {
                var _cacheAge = DateTime.Now - _dataDateTime;
                return _cacheAge < _cacheDuration;
            }
        }

        private void ValidateCache()
        {
            if (_cachedItems == null || !IsCacheValid)
            {
                try
                {
                    _cachedItems = _wrappedRepository.GetPeople();
                    _dataDateTime = DateTime.Now;
                }
                catch
                {
                    _cachedItems = new List<Person>()
                    {
                        new Person(){ FirstName="No Data Available", LastName = string.Empty, Rating = 0, StartDate = DateTime.Today},
                    };
                }
            }
        }

        private void InvalidateCache()
        {
            _dataDateTime = DateTime.MinValue;
        }
    }
}

CachingReader 同樣是個 IPersonReader,所以對應用的其他部分來說,CachingReader 與 ServiceReader 或者 CSVReader 沒有什么區別。

這符合 SOLID 原則中的 L,也就是李氏替換原則(Liskov Substitution Principle)。任何基類可以出現的地方,子類一定可以出現。

通過使用 Decorator 模式,我們能夠包裝任何一個現有的 DataReader。我們在 CachingReader 上同樣使用構造函數,來注入真正的 DataReader。

我們有一個私有成員變量 _wrappedReader,和一個 IPersonReader 類型變量,這是我們想要包裝並添加緩存功能的真正的 DataReader。

其他類成員用來控制緩存。TimeSpan 類型的 cacheDuration 對象用來判斷緩存在內存中保留的時間,這里我們設置了15秒,但是我們可以通過配置文件來修改它。其他兩個變量和緩存本身有關,cacheDuration 變量是一個 Person 類型的 IEnumerable 對象,這就是我們內存中的緩存,dataDateTime 變量包含了緩存更新的上一次時間,因此我們能夠通過這個變量來判斷我們的緩存是否有效或者過期。

如果我們看下 GetPeople 方法,會發現有兩個處理步驟。
第一個是驗證緩存。如果緩存過期或者沒有緩存,它就會通過包裝的 reader 來獲取數據,並且保存到 cachedItems 中。如果緩存有效, 那么就直接使用緩存。這只是簡單的客戶端緩存實現方式。

我們也已經看到,通過 Decorator 模式增加緩存功能,只需要在 Bootstrapper 工程中簡單地修改幾行代碼就能夠實現,並且如果需要,我們也可以很容易地配置程序來啟用或者禁用緩存功能。

依賴注入為單元測試帶來了什么

我們都知道,單元測試是用於測試獨立的功能模塊的。這里我們就來看下,怎么為 PeopleViewModel 模塊做單元測試。

首先,我們需要測試 RefreshPeople 方法。當這個方法被調用時,我們期望 PeopleViewModel 的 People 屬性被賦值了。
類似的,我們需要測試 ClearPeople 方法。當這個方法被調用時,我們期望 PeopleViewModel 的 People 屬性被清空了。

回到我們添加依賴注入之前,我們了解了對緊耦合的代碼做單元測試帶來的問題。

由於代碼的耦合度很高,單元測試會依賴於實際的生產數據。

但是,現在我們的代碼是解耦合的,我們不需要依賴於實際數據源。這里,我們創建了一個模擬的數據訪問接口 FakeReader,並且在方法 GetPeople 中返回一些測試數據。

FakeReader

namespace PersonRepository.Fake
{
    public class FakeReader : IPersonReader
    {
        public IEnumerable<Person> GetPeople()
        {
            return new List<Person>()
            {
                new Person() {FirstName = "John", LastName = "Smith",
                    Rating = 7, StartDate = new DateTime(2000, 10, 1)},
                new Person() {FirstName = "Mary", LastName = "Thomas",
                    Rating = 9, StartDate = new DateTime(1971, 7, 23)},
            };
        }

        public Person GetPerson(string lastName)
        {
            throw new NotImplementedException();
        }
    }
}

然后我們看下測試代碼。

UnitTest

namespace PeopleViewer.Presentation.Tests
{
    [TestClass]
    public class PeopleViewModelTests
    {
        [TestMethod]
        public void People_OnRefreshCommand_IsPopulated()
        {
            // Arrange
            var repository = new FakeReader();
            var vm = new PeopleViewModel(repository);

            // Act
            vm.RefreshPeopleCommand.Execute(null);

            // Assert
            Assert.IsNotNull(vm.People);
            Assert.AreEqual(2, vm.People.Count());
        }

        [TestMethod]
        public void People_OnClearCommand_IsEmpty()
        {
            // Arrange
            var repository = new FakeReader();
            var vm = new PeopleViewModel(repository);
            vm.RefreshPeopleCommand.Execute(null);
            Assert.AreEqual(2, vm.People.Count(), "Invalid Arrangement");

            // Act
            vm.ClearPeopleCommand.Execute(null);

            // Assert
            Assert.AreEqual(0, vm.People.Count());
        }
    }
}

我們發現 FakeReader 也是完全獨立的,並不影響實際的數據訪問邏輯,我們可以通過 PeopleViewModel 的構造函數注入 FakeReader 對象進行單元測試。這就給了我們對測試代碼,數據源的完全控制。

並且,測試的代碼只有幾行,而易於閱讀和編寫的測試是能夠鼓勵程序員去使用它們的。

依賴注入容器

到這里,我們已經基本了解了依賴注入的概念,並且在應用程序中通過依賴注入,我們解耦合了代碼。我們也看到了依賴注入給我們帶來的便利,包括但不僅限於易於擴展代碼,易於單元測試。

在 .NET 的世界中,有很多流行的功能齊全的依賴注入容器,許多框架也都包含了依賴注入容器,比如 ASP.NET Core MVC。

我覺得依賴注入容器就像一個蒙面俠,為什么呢?
主要是因為當我剛接觸依賴注入容器的代碼時,就感覺像是隔着一層面具在觀察陌生人,你想象不出他臉上遮住的部分,但是當我們看過了這個陌生人的臉后(了解了依賴注入的原理),即使他重新戴回面具,我們依然能夠想起他的長相。
這就是為什么一開始我們避開了容器來講解依賴注入。

我們以 NinJect 為例進行講解。我們會看到如何在程序中配置容器,管理對象生命周期,以及從容器中獲得對象。
我們可以通過包管理平台 NuGet 下載 NinJect 包,並在項目中引用。

這里,我們將 Bootstrappter 部分的代碼和 PeopleViewerWindow 分開放在了單獨的工程里,以便 PeopleViewerWindow 可以被多個版本的 Bootstrappter調用。以下是更新后的工程目錄:

以下是涉及依賴注入容器的代碼部分。

App.OnStartup with NinJect

namespace Bootstrappter.Ninject
{
    public partial class App : Application
    {
        IKernel Container;
        bool IsCacheEnabled = false;
        
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            ConfigureContainer();
            ComposeObjects();

            Application.Current.MainWindow.Show();
        }

        private void ConfigureContainer()
        {
            Container = new StandardKernel();

            if (IsCacheEnabled)
            {
                Container.Bind<IPersonReader>().To<CachingReader>().InSingletonScope()
                    .WithConstructorArgument<IPersonReader>(Container.Get<CSVReader>());
            }
            else
            {
                Container.Bind<IPersonReader>().To<CSVReader>().InSingletonScope();
            }
        }

        private void ComposeObjects()
        {
            Application.Current.MainWindow = Container.Get<PeopleViewerWindow>();
        }
    }
}

在了解依賴注入原理之前,看到這段代碼可能是比較痛苦的。在現在的上下文環境里,我們或許能夠猜測,這跟我們之前手動組合模塊的代碼實現的功能是基本一樣的。但是,我們發現,這里面見不到類 PeopleViewModel 的影子,可我們很清楚,不說功能,單從編譯的角度來講,實例化類 PeopleViewModel 是必須的,因為 PeopleViewerWindow 的構造函數需要一個 PeopleViewModel 對象。那么就只有一種可能,就是依賴注入容器內部實例化了 PeopleViewModel 對象並且傳遞給了 PeopleViewerWindow 的構造函數。

所以,我們看到,容器具有自動注冊功能,它會為對象搜索依賴並歸類,並且能夠根據依賴關系將對象串聯起來。
這使得我們的配置代碼降到了最少,同時,也給了我們很大的靈活性。

當然,這也降低了效率,因此應用程序的啟動時間變長了。所以我們在使用容器的時候要考慮,是否需要犧牲程序運行的效率來簡化代碼。

容器另一個特性是生命周期管理,當我們在看緊耦合的代碼時,我們發現如果一個對象實例化一個依賴,那么它就得負責這個對象的生命周期。有了依賴注入容器,容器會負責生命周期。我們能夠告訴容器如何管理生命周期,比如使用單例還是每次調用都使用新的實例。

我們還看到,通過變量 IsCacheEnabled 控制着應用程序是否支持緩存功能。想要更加的靈活,我們可以選擇從配置文件讀取變量 IsCacheEnabled 的值。

總結

相信看到這部分內容的,已經對依賴注入以及依賴注入容器有了基本的了解。本文只是依賴注入的入門講解,重點介紹了如何通過構造函數來注入依賴。

我們看到,緊耦合的應用程序帶來了許多問題,而解耦合給我們的代碼帶來了許多益處。不僅如此,我們還看到了我們的應用程序是怎么遵守 SOLID 原則的,以及 Decorator 模式在程序中的應用。

最后,再次強調,依賴注入是幫助我們開發解耦合代碼的一系列軟件設計原則和模式。解耦合代碼是其中的關鍵,也是本文的中心,而依賴注入只是其中的一種解耦合方法。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM