1. 程式人生 > >不要叫我,我會叫你(控制反轉原理)

不要叫我,我會叫你(控制反轉原理)

前言

之前看過前輩Artech《https://www.cnblogs.com/artech/》關於控制反轉的一篇文章,文章通俗易懂且言語精煉,寫部落格既是積累也是分享,既然是分享那麼必須讓讀者能夠明白到底講解的什麼,所以在這裡我也挑戰下自己,看看能不能將概念通過簡潔程式碼和語言的形式充分闡述清楚,若有錯誤之處,還望指正。

什麼是控制反轉

控制反轉的英文名為Inversion Of Control,我們簡稱為IOC,控制反轉是一個原則而不是一個設計模式,它是反轉程式的控制流,這個術語在Steapano Mazzocchi的Apache軟體基金會專案Avalon中被推廣,然後在2004年由Robert C. Martin和Martin Fowler進一步推廣。正如Martin Fowler所說:控制反轉是框架的共同特徵,因此說這些輕量級容器之所以特別是因為它們使用控制反轉,就好像在說我的車很特別,因為它帶有輪子一樣。它基本上是框架的定義特徵,控制反轉用於增加程式的模組化並使其可擴充套件。那麼問題來了,真正反轉體現在哪裡呢?在早期計算機軟體,命令列用於通用程式,因此使用者介面由應用程式本身控制,在程式中,我們可以通過將響應輸入命令列來直接控制程式的流程,但是在GUI程式中,我們基本上是將控制元件移交給了視窗系統(UI框架),然後由視窗系統決定下一步要做什麼,此時程式的主控制元件從我們移到了UI框架。控制反轉是庫和框架之間的區別,使用庫時,庫本質上是呼叫特定的函式和方法來執行計算和操作,每個呼叫都會完成一些工作,並將控制權返回到客戶端,而框架會為我們完成一些工作,我們只需要向框架不同位置註冊我們所編寫的程式碼,然後,框架將在需要時呼叫我們編寫的程式碼。用更加通俗易懂的話理解則是:不要叫我,我會叫你或者不要給我們打電話,我們會通知你(好萊塢法則)。有了對概念的初步理解,接下來我們通過程式碼的形式來加深對概念的理解。

    /// <summary>
    /// 車引擎類
    /// </summary>
    public class Engine { }

    /// <summary>
    /// 汽車類
    /// </summary>
    public class Car
    {
        private Engine engine;

        public Car()
        {
            engine = new Engine();
        }
    }

我們反觀上述程式碼,因為汽車的組成離不開引擎構造,當我們呼叫汽車物件例項時,將主動去構造引擎物件例項,表述上沒有任何問題,但是我們意識到引擎和汽車緊密結合在了一起,如果構造引擎物件一旦發生變化,毫無疑問我們需要修改汽車物件,也就是說汽車物件強依賴引擎物件,現在我們將程式碼進行如下修改:

    /// <summary>
    /// 汽車類
    /// </summary>
    public class Car
    {
        private Engine _engine;

        public Car( Engine engine)
        {
            _engine = engine;
        }
    }

在此種情況下,汽車物件並不知道如何構造引擎物件,當呼叫汽車時,汽車的呼叫者有責任和義務將引擎物件例項傳遞給汽車,此時流程控制被反轉,這種反轉類似於基於事件的處理機制。也就是說流程管理從應用程式轉移到了框架,經過如此修改後,引擎上升到了框架,如黑匣子一般,因為我們並不關心引擎具體如何構造。同時我們也可看出,通過控制反轉使程式更加靈活和鬆散耦合。講完了控制反轉的概念和例子,我們似乎還有一個未進行講解,好像我們聽到更多的是依賴注入,那麼依賴注入和控制反轉有著怎樣的聯絡呢?依賴注入和控制反轉兩個相關但概念截然不同,依賴注入的思想就是一個單獨物件,說白了就是編寫類的方式,使得可以在構造時將類或函式的特定例項傳遞給它們,依賴注入其實就意味著控制反轉,因為當我們在物件上呼叫方法時,它們不再定位它們所需的其他物件。取而代之的是,它們在構造時就已被賦予了依賴關係,但我們仍然必須管理構造,通過使用控制元件容器的反轉,我們可以使依賴注入更進一步,通過反轉控制容器,我們只需預先註冊所有可用的類。當容器需要構造一個類的例項時,它可以檢查該類的建構函式需要哪些物件,然後可以從向其註冊的類中構造適當的例項,總的來說依賴注入只是實現控制反轉的一種方式而已。我們拋開依賴注入實現了控制反轉,僅僅只討論依賴注入帶來了哪些好處。

 

既然是面向物件的語言,那麼我們是編寫基於面向物件的程式碼,那麼物件自然而然就有其生命週期,有的物件可能我們只需要一個例項,有的物件可能在程式執行整個過程中一直存在也就是全域性例項,而且有的物件裡面存在著對其他物件的引用,如此一來會造成什麼問題呢?導致程式碼難以理解而且難以更改,尤其是對於全域性例項而言,全域性例項離散性行為太強,分散在整個專案中的各個角落,最主要的是我們所編寫的程式碼細節中也隱藏了物件之間的互動,有些例項就包含了對其他例項的引用,一旦出現問題,我們唯有通讀每一行程式碼。我們通過引入依賴注入代替全域性例項方式,通過依賴注入常用方式即建構函式注入注入依賴項引數,此舉將提高程式碼的可讀性,我們只需快速瀏覽建構函式即可檢視對應依賴關係。通過引入依賴注入我們需要注意的是對對應類進行合理劃分,因為每次引入新的依賴項時,可能還是存在類與類之間的依賴,將不同行為劃分到不同組,如此才能減少類與類之間的耦合,使得我們的設計更具凝聚力。通過引入依賴注入也使得我們在進行單元測試時更加方便,因為我們可通過隔離類來直接測試類例項。

控制反轉程式碼說明 

接下來我們討論下如何利用程式實現控制反轉,實現控制反轉最常見的兩種方式則是:服務定位器模式(SL)和依賴注入模式(DI)。接下來我們通過例子利用依賴注入和服務定位器模式實現控制反轉。我們通過控制檯實現獲取圖書館庫圖書列表,查詢我們想要的圖書,如下我們定義圖書類:

    public class Book
    {
        /// <summary>
        /// 
        /// </summary>
        public int Id { get; set; }
        /// <summary>
        /// 
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="arg"></param>
        /// <returns></returns>
        public bool GetAuthor(string arg)
        {
            return Title.Equals(arg);
        }
    }

然後接下來我們將控制檯程式名稱修改為圖書館庫,然後根據我們輸入的圖書來查詢圖書並列印,虛擬碼如下:

    class Library
    {
        static void Main(string[] args)
        {
            var books = bookFinder.FindAll();
  
foreach (var book in books) { if (!book.GetAuthor(args[0])) continue; Console.WriteLine(book.Title); }; Console.ReadKey(); } }

如上我們通過bookFinder獲取圖書館圖書列表,然後查詢我們輸入的圖書名稱並列印,我們一眼就能看出這個bookFinder從哪裡來呢?我們可能查詢深圳圖書館或者國家圖書館或者網上遠端爬取呢?,所以接下來我們需要建立bookFinder的介面實現,如下:

    /// <summary>
    /// 查詢圖書列表
    /// </summary>
    public interface IBookFinder
    {
        List<Book> FindAll();
    }
    
    /// <summary>
    /// 深圳圖書館庫
    /// </summary>
    public class ShenZhenLibraryBookFinder : IBookFinder
    {
        public List<Book> FindAll()
        {
           ......
        }
    }

    public class Library
    {
        private IBookFinder _bookFinder;

        public Library()
        {
            _bookFinder = new ShenZhenLibraryBookFinder();
        }

        public IEnumerable<Book> BooksAuthoredBy(string title)
        {
            var allBooks = _bookFinder.FindAll();

            foreach (var book in allBooks)
            {
                if (!book.GetAuthor(title)) continue;

                yield return book;
            }
        }
    }

經過上述改造後,我們提供了IBookFinder介面以及其實現,但是現在我們正在將其作為一個框架,需要被其他人可擴充套件和使用,若此時需要提供給國家圖書館使用呢?我們可以看到此時圖書庫即Library同時依賴IBookFinder和及其實現,當我們作為可擴充套件框架時,最佳效果則是依賴介面而不是依賴具體實現細節,那麼此時該例項我們到底該如何使用呢?答案則是控制反轉,我們通過依賴注入實現控制反轉。

    public class BookFinder
    {
        public IBookFinder ProvideShenZhenBookFinder()
        {
            return new ShenZhenLibraryBookFinder();
        }

        public IBookFinder ProvideNationalBookFinder()
        {
            return new NationalLibraryBookFinder();
        }
    }
    /// <summary>
    /// 國家圖書館庫
    /// </summary>
    public class NationalLibraryBookFinder : IBookFinder
    {
        public List<Book> FindAll()
        {
            Console.WriteLine("歡迎來到國家圖書館!");
            return new List<Book>() {
                new Book() { Id = 1, Title = "策略思維" }
            };
        }
    }

    /// <summary>
    /// 深圳圖書館庫
    /// </summary>
    public class ShenZhenLibraryBookFinder : IBookFinder
    {
        public List<Book> FindAll()
        {
            Console.WriteLine("歡迎來到深圳圖書館!");
            return new List<Book>() {
                new Book() { Id = 1, Title = "月亮和六便士" }
            };
        }
    }

接下來我們將上述圖書館庫Library修改為通過建構函式注入IBookFinder介面,此時庫將僅僅只依賴於IBookFinder介面,IBookFinder內部具體實現Library並不關心,然後在控制檯進行如下呼叫:

            var bookFinder = new BookFinder();

            var shenzhenBookFinder = new Library(bookFinder.ProvideShenZhenBookFinder());

            var books = shenzhenBookFinder.BooksAuthoredBy(args[0]);

上述我們通過依賴注入使得我們可以進行可擴充套件,根據不同圖書館需要只需提供IBookFinder具體實現即可,依賴注入並不是實現控制反轉唯一的方式,我們還可以通過服務定位器來實現,服務定位器的背後是一個物件,該物件知道如何獲取應用程式可能需要的所有服務,也就是說服務定位器提供我們返回IBookFinder介面的實現,如下:

    /// <summary>
    /// 服務定位器
    /// </summary>
    public class ServiceLocator
    {
        /// <summary>
        /// 儲存或獲取註冊服務
        /// </summary>
        private IDictionary<string, object> services = new Dictionary<string, object>();

        private static ServiceLocator _serviceLocator;

        public static void Load(ServiceLocator serviceLocator)
        {
            _serviceLocator = serviceLocator;
        }

        /// <summary>
        /// 獲取服務
        /// </summary>
        /// <param name="key"></param>
        /// <returns></returns>
        public static object GetService(string key)
        {
            _serviceLocator.services.TryGetValue(key, out var service);

            return service;
        }

        /// <summary>
        /// 載入服務
        /// </summary>
        /// <param name="key"></param>
        /// <param name="service"></param>
        public void LoadService(string key, object service)
        {
            services.Add(key, service);
        }
    }
            ServiceLocator locator = new ServiceLocator();
            locator.LoadService(nameof(ShenZhenLibraryBookFinder), new ShenZhenLibraryBookFinder());
            locator.LoadService(nameof(NationalLibraryBookFinder), new NationalLibraryBookFinder());
            ServiceLocator.Load(locator);

            var finder = (IBookFinder)ServiceLocator.GetService(nameof(ShenZhenLibraryBookFinder));

            var shenzhenBookFinder = new Library(finder);

            var books = shenzhenBookFinder.BooksAuthoredBy(args[0]);

通過依賴注入和服務定位器實現控制反轉都分離了相互依賴,只不過依賴注入讓我們通過建構函式一目瞭然就可檢視依賴關係,而服務定位器需要顯式請求依賴關係,本質上沒有任何區別,至於如何使用,主要取決於我們對二者的熟悉程度。正如Martin Fowler所說:使用服務定位器時,每個服務都依賴於服務定位器,它可以隱藏對其他實現的依賴關係,但是我們確實需要檢視服務定位器,因此,是否採用定位器還是注入器主要決定於該依賴關係是否成問題。講到這裡我們藉助於IServiceProvider介面實現.NET Core中的服務定位器。如下:

    public class ServiceLocator
    {
        public static IServiceProvider Instance;
    }

除了以上寫法外,我們還可以通過例項化ServiceLocator的方式來獲取服務,如下:

    public class ServiceLocator
    {
        private IServiceProvider _currentServiceProvider;
        private static IServiceProvider _serviceProvider;

        public ServiceLocator(IServiceProvider currentServiceProvider)
        {
            _currentServiceProvider = currentServiceProvider;
        }

        public static ServiceLocator Current
        {
            get
            {
                return new ServiceLocator(_serviceProvider);
            }
        }

        public static void SetLocatorProvider(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }

        public T GetService<T>()
        {
            return _currentServiceProvider.GetRequiredService<T>();
        }
    }

    /// <summary>
    /// IServiceProvider擴充套件方法
    /// </summary>
    public static class ServiceProviderExtensions
    {
        public static T GetRequiredService<T>(this IServiceProvider provider)
        {
            var serviceType = typeof(T);
           
            if (provider is ISupportRequiredService requiredServiceSupportingProvider)
            {
                return (T)requiredServiceSupportingProvider.GetRequiredService(serviceType);
            }

            var service = (T)provider.GetService(serviceType);

            if (service == null)
            {
                throw new InvalidOperationException($"{serviceType} no registered");
            }

            return service;
        }
    }

接下來我們寫一個簡單的介面來驗證是否正確:

    public interface IHelloWorld
    {
        string Say();
    }

    public class HelloWorld : IHelloWorld
    {
        public string Say()
        {
            return "Hello World";
        }
    }

 

不知道上述兩種寫法是否存在有什麼不妥的地方,有的時候通過服務定位器的方式也非常清爽,因為當我們例項化最終具體實現時通過構造注入依賴項時,本沒有什麼,但是若後期一旦需要增加或減少依賴項時,我們同樣需要修改最終具體實現,像這種情況是否可以考慮用服務定位器模式,直接通過服務定位器去獲取指定服務,當在具體方法裡時我們每次都得去獲取服務,反而不如在構造器中一勞永逸注入。所以選擇注入器和定位器根據個人而選擇或者根據具體功能實現而定才是最佳。 

控制反轉舉慄說明 

上述我們通過程式碼的形式來進一步闡述了控制反轉,在程式碼的世界裡,我們運用控制反轉游刃有餘,在現實生活裡,我們運用控制反轉也是得心應手。年末將至,全家歡聚一堂,這應該是一年中最熱鬧的一次家庭聚會了吧,為了準備年飯具體要提供哪些食材和食物作為家庭的一份子都得有基本瞭解,所以我們必須提前準備好這些,這就像我們編寫一個沒有依賴注入的基本程式一樣,這是在自家做的情況,自家做飯吃完後,又不能抹抹嘴上油,拍拍屁股馬上走人,還得收拾不是,於是乎我們將年飯地點切換到飯店進行,此時飯店類似取締了我們自備食材這一塊,飯店就像餐飲服務商一樣,我們不用自己做,飯店會給我們提供食物,它會根據我們的不同需求注入不同的餐飲服務。從自家-》飯店,整個流程控制權進行反轉,我們將年飯控制權交給了飯店,因為飯店成為了年飯這一事件的策劃者,它是我們能不能成功吃上年飯的必要條件,我們告訴飯店老闆:有幾個人、帶了小孩、口味需重一點等等,我們需要做的就是提供一些基本引數,然後飯店自會組織,我們並不需要關心和干涉細節,他們會處理所有問題,一切就緒後會通知我們。

總結 

寫本文的目的是一直對控制反轉和依賴注入不太理解,在腦海中一直處於模糊的概念,同時呢,之前面試官問我關於依賴注入的理解,我居然支支吾吾的說成依賴倒置原則(Dependency Inversion Principle),千萬不要將依賴注入、依賴倒置、控制反轉搞混淆了,依賴倒置是完全不同的原理,雖然它也可以提供類之間的鬆散耦合和反轉依賴項。文中若有錯誤之處,還望指出,感謝您的閱讀,謝