依賴注入(dependency injection,或翻譯作相依性注入,簡寫為 DI)可以說是 .NET Core 最大的特徵之一,這個機制鼓勵我們把一些會反覆被不同的類別使用的物件實體註冊成服務,而使用的類別只要宣告它需要被注入特定類別(或介面)的服務,.NET Core 在建構這些類別的時候就會自動把對應的服務實體注入進去(而如果對應的服務實體還不存在,也會同時自動建構之)。這樣的一套作法一方面可以讓我們不用煩惱到底應該在什麼時間點把服務的實體建構出來(反正第一次用到的時候就會自動建構),另一方面也不用再像以前一樣辛苦地把建構出來的服務物件當成參數不斷地傳遞到不同類別的方法之上 1。更值得一提的,是 .NET Core 的 DI 很完整地實作了依照作用域(scope)分開管理服務實體的機制,於是例如不同的連線就會自動分配不同的服務實體,非常方便。

可是說真的,.NET Core 原本設計的 DI 語法用起來還真的有那麼一點麻煩。我們一天到晚會看到類似像這樣的程式碼:

public class MyClass {
    protected readonly IMyService Service;
    protected readonly IMyAdapter Adapter;
    protected readonly IMyDatabase Db;

    public MyClass(IMyService service, IMyAdapter adapter, IMyDatabase db) {
        Service = service;
        Adapter = adpater;
        Db = db;
    }

    ...
}

我們會發現,對於每一個我們需要注入的服務,我們都寫了三次:在類別內部變數宣告寫了一次,在建構式的參數裡面寫了一次,然後建構式裡面要存入內部變數又寫了一次。每一個都得這樣重複寫三次,這真的有點蠢,不是嗎?

更糟的是,如果 MyClass 有子類別的話,由於 C# 的子類別不會直接繼承父類別的參數建構式(只有無參數建構式可以直接繼承),變成每一個子類別我們都還得再寫一次,並且傳遞至父類別的建構式:

public class MySubClass : MyClass {
    public MySubClass(IMyService service, IMyAdapter adapter, IMyDatabase db) :
        base(service, adapter, db) {
        ...
    }
}

這真是夠了喔!啊如果哪一天父類別又要再追加新的注入怎麼辦?不是改到死嗎?

進化一

先來看一個最簡單的進化,可以馬上省掉各位一大堆功夫。

其實在各位的 .NET Core 專案的 Startup.cs 裡面,當我們在那邊註冊服務的時候,我們做的事情是準備一個 IServiceCollection 物件,這個物件會在 Program.cs 中 build 的時候產生出一個 IServiceProvider 並且自動註冊上去,而只要我們拿到這個 IServiceProvider,我們就可以用它取得一切的服務,因此一切的注入都可以簡化成只注入它就好了!

具體的程式碼如下 2

public class MyClass {
    protected readonly IMyService Service;
    protected readonly IMyAdapter Adapter;
    protected readonly IMyDatabase Db;

    public MyClass(IServiceProvider provider) {
        Service = provider.GetRequiredService<IMyService>();
        Adapter = provider.GetRequiredService<IMyAdapter>();
        Db = provider.GetRequiredService<IMyDatabase>();
    }

    ...
}

如此一來,我們就把原本的要寫三次,改進成了只寫兩次。

而子類別的程式碼也簡化成了:

public class MySubClass : MyClass {
    public MySubClass(IServiceProvider provider) : base(provider) {
        ...
    }
}

而且以後不管父類別需要多注入些什麼,也永遠不用改動子類別的宣告!是不是好多了?

進化二

但是改進成寫兩次還不夠厲害;最理想的當然是只寫一次就好。

其實在 Blazor 裡面,我們就有只寫一次的這種語法存在;在 Blazor 的 Component 當中,我們可以使用 InjectAttribute 來標示一個特定的屬性是需要被注入的。用 Razor 語法來寫的話會是這樣:

@inject IMyService Service

<div>...</div>

@code {
    ...
}

而如果用 C# 語法寫的話會是:

public class MyComponent : ComponentBase {
    [Inject] public IMyService Service { get; set; }

    ...
}

不管是哪一種寫法,我們發現注入都只寫了一次:在宣告內部變數的地方;這當然不可能再更省了。只是很可惜地,InjectAttribute 只有在 Blazor Component 裡面能用,其它地方是沒有這個東西的。

然而,我們當然可以效法 Blazor 的概念,自己寫一個類似的 InjectAttribute

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class InjectAttribute : Attribute { }

這邊我特別允許這個 attribute 也可以在欄位上面使用,這樣我用起來還順便少打了 { get; set; }(反正通常服務注入進來都嘛是唯讀屬性就可以了)。當然,attribute 的作用只是註記,實際上它要發揮作用,還是得透過下面的擴充方法:

public static void Inject(this IServiceProvider provider, object target) {
    var type = target.GetType();
    var props = type.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
    foreach (var prop in props) {
        if (prop.GetCustomAttribute<InjectAttribute>() != null && prop.SetMethod != null) {
            prop.SetValue(target, provider.GetRequiredService(prop.PropertyType));
        }
    }

    var fields = type.GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
    foreach (var field in fields) {
        if (field.GetCustomAttribute<InjectAttribute>() != null) {
            field.SetValue(target, provider.GetRequiredService(field.FieldType));
        }
    }
}

於是我們的類別就如願地進化成了:

public class MyClass {
    [Inject] protected readonly IMyService Service;
    [Inject] protected readonly IMyAdapter Adapter;
    [Inject] protected readonly IMyDatabase Db;

    public MyClass(IServiceProvider provider) {
        provider.Inject(this);
    }

    ...
}

進化三

沒想到吧?竟然還可以再進化?確實可以喔!

雖然注入的項目那邊是已經節省到不能再省了沒錯,可是建構式倒是絕對還可以再省。假如 MyClass 本身也是一個服務,我們只要再多寫一道擴充方法,就可以把它的建構式整個消滅掉。

public static T CreateAndInject<T>(this IServiceProvider provider, params object[] parameters) where T : class {
    var result = ActivatorUtilities.CreateInstance<T>(provider, parameters);
    provider.Inject(result);
    return result;
}

有了這個擴充方法之後,我們要註冊 MyClass 時,可以這樣寫(以 Scoped 為例):

services.AddScoped<IMyClass>(provider => provider.CreateAndInject<MyClass>());

如此一來,建構式就整個消失了:

public class MyClass : IMyClass {
    [Inject] protected readonly IMyService Service;
    [Inject] protected readonly IMyAdapter Adapter;
    [Inject] protected readonly IMyDatabase Db;

    ...
}

更棒的是,假如今天我們要改成註冊子類別:

services.AddScoped<IMyClass>(provider => provider.CreateAndInject<MySubClass>());

那麼,因為子類別直接繼承了父類別的無參數建構式,子類別裡面什麼都不用寫!

public class MySubClass : MyClass {
    ...
}

進化四

傻眼貓咪。對,我還沒完。

當然到這邊我們肯定會忍不住把所有的服務全部都改寫成上面的新語法,可是這樣一來註冊時要寫的東西就變多了:

services.AddSingleton<IMySerivce>(provider => provider.CreateAndInject<MyService>());
services.AddSingleton<IMyAdapter>(provider => provider.CreateAndInject<MyAdapter>());
services.AddScoped<IMyDatabase>(provider => provider.CreateAndInject<MyDatabase>());
services.AddScoped<IMyClass>(provider => provider.CreateAndInject<MySubClass>());

呃……好長喔,不能再更簡單一點嗎?

好,來吧,那我們就再更簡單一點。首先我們定義幾個 attribute:

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class SingletonServiceAttribute : Attribute {
    public Type Type { get; set; }
    public SingletonServiceAttribute(Type type = null) { Type = type; }
}

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class ScopedServiceAttribute : Attribute {
    public Type Type { get; set; }
    public ScopedServiceAttribute(Type type = null) { Type = type; }
}

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class TransientServiceAttribute : Attribute {
    public Type Type { get; set; }
    public TransientServiceAttribute(Type type = null) { Type = type; }
}

我們用這些 attribute 來標註一個類別應該要被註冊成那一種服務,其中選填的參數表示要註冊成的服務類別(不填則直接註冊成自身類別)。例如:

[ScopedService(typeof(IMyClass))]
public class MySubClass : MyClass {
    ...
}

然後,我們再加上兩個如下的擴充方法:

// 這是稍早的 CreateAndInject 的非泛型版本
public static object CreateAndInject(this IServiceProvider provider, Type type, params object[] parameters) {
    var result = ActivatorUtilities.CreateInstance(provider, type, parameters);
    provider.Inject(result);
    return result;
}

public static IServiceCollection AddServicesByAttribute(this IServiceCollection services, Assembly assm) {
    assm.GetTypes()
        .Where(c => c.IsClass && !c.IsAbstract)
        .ToList()
        .ForEach(c => {
            if(c.GetCustomAttribute<SingletonServiceAttribute>() is SingletonServiceAttribute ss) {
                services.AddSingleton(ss.Type ?? c, p => p.CreateAndInject(c));
            } else if (c.GetCustomAttribute<ScopedServiceAttribute>() is ScopedServiceAttribute cs) {
                services.AddScoped(cs.Type ?? c, p => p.CreateAndInject(c));
            } else if (c.GetCustomAttribute<TransientServiceAttribute>() is TransientServiceAttribute ts) {
                services.AddTransient(ts.Type ?? c, p => p.CreateAndInject(c));
            }
        });
    return services;
}

之後我們就可以全自動地來在 Startup.cs 裡面進行註冊,一行搞定所有的註冊!

services.AddServicesByAttribute(typeof(MyClass).Assembly);

這行裡面 MyClass 可以換成任何該組件中的代表性類別,寫在那邊只是為了要能夠取得組件;然後我們的擴充方法就會自動爬遍該組件中所有的類別,把有加上 attribute 的類別註冊上去。

萬歲!我們的依賴注入終極進化就到此完成。

結語

不過,值得指出的是,並不是所有工程師都會喜歡進化四的這種作法,因為它雖然很偷懶方便,但是卻有幾個缺點:

  1. 像這種太過自動化的 metaprogramming 寫法,未來接手維護的人未必看得懂。
  2. 它把服務的註冊分散到了組件的各處,維護上會比較不好掌握到底註冊了哪些服務。

因此,建議只有當團隊有良好的溝通模式以及註解習慣、而且方案中有一套很明確的核心服務反覆地被若干子專案使用的時候採用到進化四;如果這個前提不成立,那最多用到進化三就好。

類似地,進化三也有一點小小的副作用,就是它依賴於使用的人用一種特殊的寫法來把服務註冊上去;所以假如今天我們是要開發給別人用的函式庫,而且我們知道我們的服務類別基本上不會被組件之外的程式碼繼承,那最好是至多用到進化二就好,這樣可以避免使用的人以錯誤(預設)的方式註冊我們的服務。

也許未來 .NET Core 會自己內建通用的 InjectAttribute;如果那樣的話,上面這些無法兩全其美的問題都可以自動獲得解決。只是目前乍看之下他們似乎是沒有這樣的打算;事實上,在 Blazor 元件中存在 InjectAttribute 完全只是為了要提供 Razor 中 @inject 語法的支援,而除此之外,他們並不認為使用者有需要直接在 .cs 檔案中使用 [Inject] 的語法。然而,至少我自己真的覺得這個用起來比起預設的 DI 語法方便多了。

為了大家的方便,我已經把本文中介紹的概念打包成了一個 Nuget 套件 SGLab.Library.DependencyInjection,直接搜尋並安裝即可使用,而本文中所有提到的東西都會在命名空間 Microsoft.Extensions.DependencyInjection 底下。


  1. 以前還有另外一個方法是把服務物件存在一個靜態變數之上,但是這個方法主要只適用於 singleton 的服務,如果是 scoped 或甚至 transient,我們就得另外自己設法實作「管理不同 scope 中的服務實體」的機制,這也是很麻煩。 

  2. 其中如果我們允許對應的服務不一定得要被註冊的話,可以把 GetRequiredService 改成 GetService,後者在找不到對應服務的時候只會傳回 null,而不會丟錯。 

分享此頁至:
最後修改日期: 2020/07/28

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。