April 1, 2011

可抽換元件設計模式 - IoC Pattern

在之前的文章[註1]中,筆者介紹了如何以Plugin Pattern實作低耦合且可抽換元件機制。本篇文章將介紹另一種廣泛使用的設計模式 - IoC Pattern

IoC為Inversion of Control的縮寫,中文名稱大多譯為「控制反轉」。根據筆者找到的文獻中,在Martin Fowler的文章「InversionOfControl」最後有提到IoC一詞是他從Johnson與Foote這兩位專家在1988年所發表的論文中看到的,但這兩位專家也是在別的地方看到這個詞,所以目前實際出處暫不可考。

控制反轉,反轉什麼?
筆者認為,在IoC中要反轉的就是類別或模組間的相依關係(相依實作反轉至相依抽象或介面),而Plugin Pattern就有IoC的精神存在。
怎麼說?在之前所提到的案例中,當系統需要Log機制時,最快速的作法大概就是先寫到文字檔。
所以我們可能會有一個類別叫TextLogger,而寫入文字檔的實作細節都寫在TextLogger裡,如以下程式碼
    public class UserAppService
    {
        public void AddUser(UserInfo user)
        {
            UserRepository repository = new UserRepository();
            repository.Add(user);

            TextLogger logger = new TextLogger();
            Dictionary<string, string> messages = new Dictionary<string, string>();
            messages.Add("Action", "建立使用者");
            messages.Add("Detail", string.Format("UserId: {0}, UserName: {1}", user.UserId, user.UserName));
            messages.Add("LogTime", DateTime.Now.ToString());
            logger.Write(messages);
        }
    }
    public class TextLogger
    {
        ppublic void Write(Dictionary<string, string> messages)
        {
            StringBuilder sb = new StringBuilder();
            messages.ToList().ForEach(c => sb.AppendLine(string.Format("{0}:{1}", c.Key, c.Value)));
            sb.AppendLine();
            File.AppendAllText(string.Format("{0}.txt", DateTime.Now.ToString("yyyyMMdd")), sb.ToString());
        }
    }
以上程式碼的作用在使用者資料被新增到資料庫後,系統會做一個簡單的Log。這樣有什麼問題嗎?事實上沒什麼大問題。
但如果這個Log機制將會有以下特性的話,就可以考慮引入IoC至系統中
  1. 多專案共用
  2. 單一機制,多種實作。除了支援寫入文字檔的功能以外,還要能寫入XML、資料庫或是以Email的方式寄出Log檔
所以上述的程式碼範例若以Plugin Pattern來實現IoC的話,最終的UserAppService類別將會如下
    public class UserAppService
    {
        public void AddUser(UserInfo user)
        {
            UserRepository repository = new UserRepository();
            repository.Add(user);

            ILogger logger = LoggerFactory.CreateLogger();
            Dictionary<string, string> messages = new Dictionary<string, string>();
            messages.Add("Action", "建立使用者");
            messages.Add("Detail", string.Format("UserId: {0}, UserName: {1}", user.UserId, user.UserName));
            messages.Add("LogTime", DateTime.Now.ToString());
            logger.Write(messages);
        }
    }
各位可以看的出來,原本UserAppService相依於TextLogger實作類別(實作ILogger介面),現在被反轉為相依於ILogger介面了,而執行期要生成的實作類別(TextLogger/XmlLogger/DbLogger/EmailLogger)則被封裝在LoggerFactory裡並透過Reflection於執行期生成設定檔所預設的實作類別。

這樣有什麼好處呢?當A、B兩專案需要的Log實作機制不同時,只要在設定檔中設定各自的xxxLogger實作類別即可,完全不需要去動到UserAppService的程式碼,如此降低了UserAppService與xxxLogger實作類別間的耦合度。

站在巨人的肩膀上
既然有Plugin Pattern(或Provider Pattern)此等好物了,為何我們還需要使用IoC Pattern?而且Plugin Pattern不也實作了IoC的精神了嗎?
筆者認為IoC Pattern之所以廣為使用原因在於它有強大的Framework支援,如StructureMapMicrosoft UnitySpring.NET (當然不只這幾種)。這些IoC Framework支援以容器(Container)的方式,讓開發人員可以將實作類別以及所對應的抽象類別或介面放在容器裡。實作類別可以透過程式碼或是設定檔的方式註冊到容器裡,於執行期再從容器中提取出來即可。如以下範例為以設定檔方式註冊實作類別於容器中
  <structuremap>
    <assembly name="LoggerSample" />
    <pluginfamily assembly="LoggerSample" defaultkey="txt" type="LoggerSample.IoCPattern.ILoggerIoC">
      <plugin assembly="LoggerSample" concretekey="txt" type="LoggerSample.IoCPattern.TextLoggerIoC" />
      <plugin assembly="LoggerSample" concretekey="xml" type="LoggerSample.IoCPattern.XmlLoggerIoC" />
    </pluginfamily>
  </structuremap>
而在提取實作類別時,僅需針對介面或抽象去提取即可,如
ILoggerIoC logger = ObjectFactory.GetInstance<ILoggerIoC>();
當然IoC Framework不僅僅只有這樣的功能,如StructureMap提供了一種稱為Auto Wiring的功能,可以在提取實作類別時,一併具現化(instantiate)所相依的抽象類別或介面。有些IoC Framework甚至還支援Aspect-Oriented Programming的功能。

Dependency Inversion Principle (DIP)
DIP為著名的S.O.L.I.D.原則之一,由Robert C. Martin在其著作「Agile Software Development, Principles, Patterns, and Practices[註2]中所提出。以下引述該書描述
High-level modules should not depend upon low-level modules. Both should depend upon abstractions.

Abstractions should not depend upon details. Details should depend upon abstractions.
中文的意思為(引述自中譯本)
高階模組不應該相依於低階模組,而兩者都應該相依於抽象概念
抽象概念不應該相依於細節,而細節應該相依於抽象概念
這樣的一個概念其實和IoC的精神是不謀而合的。

Dependency Injection (DI)
DI的中文名稱有多種版本,如「依賴注入(射)」或「相依注入(射)」。DI的概念由Martin Fowler在其文章「Inversion of Control Containers and the Dependency Injection pattern」所提出,他認為IoC一詞的用法雖較為通用但仍然需要一個較為明確的名詞來定義並說明如何實作出IoC的精神,因此他提出三種DI的實作方式,分別為Constructor InjectionSetter InjectionInterface Injection

DI實作流程
1.建立要注入的介面(ILoggerIoC),令欲注入的類別(TextLoggerIoC/XmlLoggerIoC)實作此介面,如
public interface ILoggerIoc
{
    void Write(Dictionary<string, string> messages);
}
 public class TextLoggerIoC : ILoggerIoc

    {
        public void Write(Dictionary<string, string> messages)
        {
            StringBuilder sb = new StringBuilder();
            messages.ToList().ForEach(c => sb.AppendLine(string.Format("{0}:{1}", c.Key, c.Value)));
            sb.AppendLine();
            File.AppendAllText(string.Format("{0}.txt", DateTime.Now.ToString("yyyyMMdd")), sb.ToString());
        }
    }
 public class XmlLoggerIoC : ILoggerIoc

    {
        public void Write(Dictionary<string, string> messages)
        {
            XElement element = new XElement(
                new XElement("Record",
                    new XElement("Action", messages["Action"]),
                    new XElement("Detail", messages["Detail"]),
                    new XElement("LogTime", messages["LogTime"])
                )
            );

            File.AppendAllText(string.Format("{0}_{1}.xml", DateTime.Now.ToString("yyyyMMdd"), Guid.NewGuid()), element.ToString());
        }
    }
2.將欲注入的類別透過程式碼或設定檔註冊在容器中,如
ObjectFactory.Initialize(x =>
            {
                x.For<ILoggerIoC>().Use<TextLoggerIoC>();
                //x.For<ILoggerIoC>().Use<XmlLoggerIoC>();
            });

ObjectFactory.Initialize(x =>
            {
                x.PullConfigurationFromAppConfig = true;
            });
  <structuremap>
    <assembly name="LoggerSample" />
    <pluginfamily assembly="LoggerSample" defaultkey="txt" type="LoggerSample.IoCPattern.ILoggerIoC">
      <plugin assembly="LoggerSample" concretekey="txt" type="LoggerSample.IoCPattern.TextLoggerIoC" />
      <plugin assembly="LoggerSample" concretekey="xml" type="LoggerSample.IoCPattern.XmlLoggerIoC" />
    </pluginfamily>
  </structuremap>
3.挑選Injection類型(Constructor、Setter或Interface)
4.執行期取得容器內的實作類別並注入相依的類別,如
ILoggerIoC log = ObjectFactory.GetInstance<ILoggerIoC>();
接下來筆者將以範例說明如何使用StructureMap實作Constructor Injection及Setter Injection。
Constructor Injection 
相依性由建構式注入,如以下程式碼
    public class ConstructorInjectionUserAppService
    {
        private ILoggerIoC _logger;

        public ConstructorInjectionUserAppService(ILoggerIoC logger)
        {
            this._logger = logger;
        }

        public void AddUser(UserInfo user)
        {
            UserRepository repository = new UserRepository();
            repository.Add(user);

            Dictionary<string, string> messages = new Dictionary<string, string>();
            messages.Add("Action", "建立使用者");
            messages.Add("Detail", string.Format("UserId: {0}, UserName: {1}", user.UserId, user.UserName));
            messages.Add("LogTime", DateTime.Now.ToString());
            this._logger.Write(messages);
        }
    }
Client Code如下
            ILoggerIoC logger = ObjectFactory.GetInstance<ILoggerIoC>();
            ConstructorInjectionUserAppService service = new ConstructorInjectionUserAppService(logger);
            UserInfo user = new UserInfo();
            user.UserId = Guid.NewGuid();
            user.UserName = "Pete Chen";
            service.AddUser(user);
Constructor Injection的特性就是可以在具現化類別時於建構式中直接看到相依關係。實務上,建構式的相依參數可能不只一個,筆者在實際的專案中曾經有一個類別就使用到了4個注入參數,當時的情況是在Service Layer的某支類別注入4個Repository介面。

Setter Injection
相依性由屬性(Property)或方法(Method)注入,如以下程式碼
public class PropertySetterInjectionUserAppService
    {
        public ILoggerIoC Logger { get; set; }

        public void AddUser(UserInfo user)
        {
            UserRepository repository = new UserRepository();
            repository.Add(user);
            Dictionary<string, string> messages = new Dictionary<string, string>();
            messages.Add("Action", "建立使用者");
            messages.Add("Detail", string.Format("UserId: {0}, UserName: {1}", user.UserId, user.UserName));
            messages.Add("LogTime", DateTime.Now.ToString());

            if (Logger != null)
            {
                Logger.Write(messages);
            }            
        }
    }
Client Code如下
            ILoggerIoC logger = ObjectFactory.GetInstance<ILoggerIoC>();
            PropertySetterInjectionUserAppService service = new PropertySetterInjectionUserAppService();
            service.Logger = logger;
            UserInfo user = new UserInfo();
            user.UserId = Guid.NewGuid();
            user.UserName = "Pete Chen";
            service.AddUser(user);
在Martin Fowler的範例中,Setter Injection是建立一個方法並把相依性注入在此方法中,如SetLoggerIoc(ILoggerIoC logger);然而在Jon Arking與Scott Millett的著作「Professional Enterprise .NET 」中,Setter Injection是將相依性注入在屬性,而Method Injection才是注入在方法。

Interface Injection
在看完Martin Fowler的Interface Injection後,筆者仍理不出頭緒該怎麼實作這個範例比較好,因筆者目前也尚未使用到Interface Injection,找到一些參考資料但看起來又感受不太到是否為正確的做法,所以筆者在此僅留下參考資料,若有朋友實作出來請不吝告知筆者。
Dependency Injection Example: Interface Injection
C# sample code for interface injection
Inside ObjectBuilder Part1

IoC?DI?DIP?儍儍分不清楚
有人可能會問,IoC、DI與DIP間有什麼差別呢?以下引述自Dino Esposito與Andrea Saltarello合著的「Microsoft .NET: Architecting Applications for the Enterprise
They are not always considered synonyms in literature, as sometimes you find IoC to be the principle and DI the application of the principle—namely, the pattern. In reality, IoC is historically a pattern based on DIP. The term dependency injection was coined by Martin Fowler later, as a way to further specialize the concept of inversion of control.
其實說到底它們的出現都是為了相同的事 - 降低類別或模組間的耦合度,這也是為何GoF(Gang of Four)在其著作「Design Patterns[註3]中提到Program to an interface, not an implementation(針對介面而寫,不要針對實作)

IoC Anti-Pattern
在StructureMap的官網中提到了一種誤用IoC Pattern的情形,參考以下範例
 public class UserAppService
    {
        public void AddUser(UserInfo user)
        {
            UserRepository repository = new UserRepository();
            repository.Add(user);

            ILoggerIoC logger = ObjectFactory.GetInstance<ILoggerIoC>();
            Dictionary<string, string> messages = new Dictionary<string, string>();
            messages.Add("Action", "建立使用者");
            messages.Add("Detail", string.Format("UserId: {0}, UserName: {1}", user.UserId, user.UserName));
            messages.Add("LogTime", DateTime.Now.ToString());
            logger.Write(messages);
        }
    } 
與其說是IoC Anti-Pattern,筆者認為用DI Anti-Pattern更為貼切。依照該文所描述,以上範例將IoC容器的使用封裝在UserAppService類別裡,使得UserAppService類別與IoC Framework形成高度耦合且不利於測試的情況。此外,這樣的作法也違反了DI的實作方式。

備註
  1. 可抽換元件設計模式 - Plugin Pattern
  2. 中譯本為「敏捷軟體開發:原則、樣式及實務
  3. 中譯本為「物件導向設計模式
相關文章