跟 ASP.NET MVC 與 Web API 比起來,在 Web Forms 應用程式中使用 Dependency Injection 要來得麻煩些。這裡用一個範例來說明如何注入相依物件至 Web Forms 的 ASPX 頁面。

使用的開發工具與類別庫:
  • Visual Studio 2013
  • .NET Framework 4.5
  • Unity 3.5.x

問題描述

基於測試或其他原因,希望 ASPX 網頁只依賴特定服務的介面,而不要依賴具象類別。

假設首頁 Default.aspx 需要一個傳回「Hello World!」字串的服務,而我們將此服務的介面命名為 IHelloService。以下為此服務的介面與實作類別:

public interface IHelloService
{
    string Hello(string name);
}

public class HelloService : IHelloService
{
    public string Hello(string name)
    {
        return "Hello, " + name;
    }
}


Default.aspx 的 code-behind 類別大概會像這樣:

public partial class Default : System.Web.UI.Page
{
    public IHelloService HelloService { get; set; }

    protected void Page_Load(object sender, EventArgs e)
    {
        // 在網頁上輸出一段字串訊息。訊息內容由 HelloService 提供。
        Response.Write(this.HelloService.Hello("DI in ASP.NET Web Forms!"));
    }
}

問題來了:Page 物件是由 ASP.NET Web Forms 框架所建立的,我們如何從外界動態注入 IHelloService 物件呢?

解法

一般而言,我們建議儘量採用 Constructor Injection 來注入相依物件,可是此法很難運用在 Web Forms 的 Page 物件上。一個便宜行事的解法是採用 Mark Seemann 所說的「私生注入」(Bastard Injection),像這樣:

    public partial class Default : System.Web.UI.Page
    {
        public IHelloService HelloService { get; set; }

        public Default()
        {
            // 透過一個共用的 Container 物件來解析相依物件。
            this.HelloService = AppShared.Container.Resolve<IHelloService>();
        }

        protected void Page_Load(object sender, EventArgs e)
        {
            // 在網頁上輸出一段字串訊息。訊息內容由 HelloService 提供。
            Response.Write(this.HelloService.Hello("DI in ASP.NET Web Forms!"));
        }
    }

此解法的一個問題是,你必須在每一個 ASPX 頁面的 code-behind 類別中引用 DI 容器的命名空間,而這樣就變成到處都依賴特定的 DI 容器了。我們希望盡可能把呼叫 DI 容器的程式碼集中寫在少數幾個地方就好。

接下來的實作步驟會利用一個 HTTP handler 來攔截 Page 物件的建立程序,以便在 Page 物件建立完成後,立刻以 Property Injection 的方式將 Page 物件需要的服務給注入進去。

實作步驟

Step 1:建立新專案

建立一個新的 ASP.NET Web Application 專案,目標平台選擇 .NET Framework 4.5,專案名稱命名為:WebFormsDemo。

專案範本選擇 Empty,然後在 Add folder and core references for 項目上勾選「Web Forms」。

專案建立完成後,透過 NuGet 管理員加入 Unity 套件。

Step 2:註冊型別

在應用程式的「組合根」建立 DI 容器並註冊相依型別。這裡選擇在 Global_asax.cs 的 Application_Start 方法中處理這件事:

public class Global : System.Web.HttpApplication
{
    protected void Application_Start(object sender, EventArgs e)
    {
        var container = new UnityContainer();
        Application["Container"] = container; // 把容器物件保存在共用變數裡

        // 註冊型別
        container.RegisterType<IHelloService, HelloService>();
    }
}

Step 3:撰寫 HTTP Handler

在專案根目錄下建立一個子目錄:Infrastructure,然後在此目錄中加入一個新類別:UnityPageHandlerFactory.cs。程式碼:

public class UnityPageHandlerFactory : System.Web.UI.PageHandlerFactory
{
    public override IHttpHandler GetHandler(HttpContext context, string requestType, string virtualPath, string path)
    {
        Page page = base.GetHandler(context, requestType, virtualPath, path) as Page;
        if (page != null)
        {
            var container = context.Application["Container"] as IUnityContainer;
            var properties = GetInjectableProperties(page.GetType());

            foreach (var prop in properties) 
            {
                try
                {
                    var service = container.Resolve(prop.PropertyType);
                    if (service != null)
                    {
                        prop.SetValue(page, service);
                    }
                }
                catch
                {
                    // 沒辦法解析型別就算了。
                }
            }
        }
        return page;
    }

    public static PropertyInfo[] GetInjectableProperties(Type type)
    {
        var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
        if (props.Length == 0)
        {
            // 傳入的型別若是由 ASPX 頁面所生成的類別,那就必須取得其父類別(code-behind 類別)的屬性。
            props = type.BaseType.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
        }
        return props;
    }        
}

程式說明:
  • ASP.NET Web Forms 框架會呼叫此 handler 物件的 GetHandler 方法來建立 Page 物件。
  • 在 GetHandler 方法中,先利用父類別來建立 Page 物件,然後緊接著進行 Property Injection 的處理。首先,從 Application["Container"] 中取出上一個步驟所建立的 DI 容器,接著找出目前的 Page 物件有宣告哪些公開屬性,然後利用 DI 容器來逐一解析各屬性的型別,並將建立的物件指派給屬性。
  • 靜態方法 GetInjectableProperties 會找出指定型別所宣告的所有公開屬性,並傳回呼叫端。注意這裡只針對「Page 類別本身所宣告的公開屬性」來進行 Property Injection,這樣就不用花時間在處理由父類別繼承而來的數十個公開屬性。

Step 4:註冊 HTTP Handler

在 web.config 中註冊剛才寫好的 HTTP handler:

<configuration>
  <system.web>
    <compilation debug="true" targetFramework="4.5" />
    <httpRuntime targetFramework="4.5" />
  </system.web>

  <system.webServer>
    <handlers>
      <add name="UnityPageHandlerFactory" path="*.aspx" verb="*" type="WebFormsDemo.Infrastructure.UnityPageHandlerFactory"/>
    </handlers>
  </system.webServer>
</configuration>

基礎建設的部分到此步驟已經完成,接著就是撰寫各個 ASPX 頁面。

Step 5:撰寫測試頁面

在專案中加入一個新的 Web Form,命名為 Default.aspx。然後在 code-behind 類別中宣告相依服務的屬性,並且在其他地方呼叫該服務的方法。參考以下範例:

public partial class Default : System.Web.UI.Page
{
    public IHelloService HelloService { get; set; }

    protected void Page_Load(object sender, EventArgs e)
    {
        // 在網頁上輸出一段字串訊息。訊息內容由 HelloService 提供。
        Response.Write(this.HelloService.Hello("DI in ASP.NET Web Forms!"));
    }
}

你可以看到,ASPX 網頁並不需要引用 Unity 容器的命名空間,因為注入相依物件的動作已經由基礎建設預先幫你處理好了。

Step 6:執行看看

執行時,瀏覽器應該會顯示一行訊息:「Hello, DI in ASP.NET Web Forms!」

Happy coding!

2014-08-16 補充:

感謝對岸網友提醒我,Unity 已經有提供 BuildUp 方法,不用像本文範例那樣,還得自己寫個 GetInjectableProperties 來處理 Property Injection(但 HTTP handler 仍是少不了的)。

But(聽說人生最厲害的就是這個 BUT),如果採用 Unity 的 BuildUp 方法,就得在許多類別裡面用到 Unity 的 DependencyAttribute 來裝飾你的屬性。這麼一來,對 Unity 容器的依賴更深。如果你覺得將來不可能改用別的 DI 框架,使用 Unity 的 BuildUp 的確是個不錯的辦法。