你有把依賴注入玩壞?

Jeffcky發表於2021-01-17

前言

自從.NET Core給我們呈現了依賴注入,在我們專案中到處充滿著依賴注入,雖然一切都已幫我們封裝好,但站在巨人的肩膀上,除了憑眺遠方,我們也應平鋪好腳下的路,使用依賴注入不僅僅只是解耦,而且使程式碼更具維護性,同時我們也可輕而易舉檢視依賴關係,單元測試也可輕鬆完成,本文我們來聊聊依賴注入,文中示例版本皆為5.0。

淺談依賴注入

在話題開始前,我們有必要再提一下三種服務注入生命週期, 由淺及深再進行講解,基礎內容,我這裡不再多述廢話

Transient(瞬時):每次對瞬時的檢索都會建立一個新的例項。

Singleton(單例):僅被例項化一次。此型別請求,總是返回相同的例項。

Scope(範圍):使用範圍內的註冊。將在請求型別的每個範圍內建立一個例項。

 

如果已用過.NET Core一段時間,若對上述三種生命週期管理的概念沒有更深刻的理解,我想有必要基礎回爐重塑下。為什麼?至少我們應該得出兩個基本結論

 

其一:生命週期由短到長排序,瞬時最短、範圍次之、單例最長

 

只要做過Web專案,關於第一點就很好理解,首先我們只對瞬時和範圍作一個基本的概述,關於單例通過實際例子來闡述,我們理解會更深刻

 

若為瞬時:那麼我們每次從容器中獲取的服務將是不同的例項,所以名為瞬時或短暫

 

若為範圍:在ASP.NET Core中,針對每個HTTP請求都會建立DI範圍,當在HTTP請求中(在中介軟體,控制器,服務或檢視中)請求服務,並且該服務註冊為範圍服務時,如果在請求中多次請求相同型別的請求,則使用相同例項。例如,如果在控制器,服務和檢視中注入了範圍服務,則將返回相同的例項。隨著另一個HTTP請求的流,使用了不同的例項,請求完成後,將處理(釋放)範圍

 

其二:被注入的服務應與注入的服務應具有相同或更長的生命週期

 

從概念上看貌似有點拗口,通過日常生活舉個例子則秒懂,假設有兩個桶,一個小桶和一個大桶,我們能將小桶裝進大桶,但不能將大桶裝進小桶。

 

專業一點講,比如一個單例服務可以被注入瞬時服務,但是一個瞬時服務不能被注入單例服務,因為單例服務比瞬時服務生命週期更長,若瞬時服務被注入單例服務,那麼勢必將延長瞬時服務生命週期,因違背大前提,將會引起異常

public interface ISingletonDemo1
{
}

public class SingletonDemo1 : ISingletonDemo1
{
    private readonly IScopeDemo1 _scopeDemo1;
    public SingletonDemo1(IScopeDemo1 scopeDemo1)
    {
        _scopeDemo1 = scopeDemo1;
    }
}

public interface IScopeDemo1
{
}
public class ScopeDemo1 : IScopeDemo1
{
}

我們在Web中進行演示,然後在Startup中根據其介面名進行註冊,如下:

services.AddSingleton<ISingletonDemo1, SingletonDemo1>();
services.AddScoped<IScopeDemo1, ScopeDemo1>();

從理論上講肯定是這樣,好像有點太絕對,抱著自我懷疑的態度,於是乎,我們在控制檯中驗證一下看看

static void Main(string[] args)
{
    var services = new ServiceCollection();
    services.AddSingleton<ISingletonDemo1, SingletonDemo1>();
    services.AddScoped<IScopeDemo1, ScopeDemo1>();

    services.BuildServiceProvider();
}

然鵝並沒有丟擲任何異常,注入操作都一樣,有點懵,看看各位看官能否給個合理的解釋,在控制檯中並不會丟擲異常......

深談依賴注入

關於依賴注入基礎和使用準則,我建議大家去看看,還是有很多細節需要注意

依賴注入設計準則

https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines

 

在.NET Core中使用依賴注入

https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-usage

比如其中提到一點,服務容器並不會建立服務,也就是說如下框架並沒有自動處理服務,需要我們開發人員自己負責處理服務的釋放

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton(new ExampleService());

    // ...
}

假設我們有一個控制檯命令列專案,我們通過引入依賴注入單例做一些操作

public interface ISingletonService
{
    void Execute();
}

public class SingletonService : ISingletonService
{
    public void Execute()
    {
    }
}

緊接著控制檯入口點演變成如下這般

static void Main(string[] args)
{
    var serviceProvider = new ServiceCollection()
        .AddSingleton<ISingletonService, SingletonService>()
        .BuildServiceProvider();

    var app = serviceProvider.GetService<ISingletonService>();
    app.Execute();
}

若在執行Execute方法裡面做了一些臨時操作,比如建立臨時檔案,我們想在釋放時手動做一些清理,所以我們實現IDisposable介面,如下:

public class SingletonService : ISingletonService, IDisposable
{
    public void Execute()
    {
    }

    public void Dispose()
    {
        // do something
    }
}

然後專案上線,我們可能會發現記憶體中大量充斥著該例項,從而最終導致記憶體洩漏,這是為何呢?我們將服務注入到容器中,容器將會自動管理注入例項的釋放,根據如下可知

 

最終我們通過如下方式即可解決上述記憶體洩漏問題

using (var serviceProvider = new ServiceCollection()
                .AddSingleton<ISingletonService, SingletonService>()
                .BuildServiceProvider())
{
    var app = serviceProvider.GetService<ISingletonService>();

    app.Execute();
}

是不是有點懵,接下來我們來深入探討三種型別生命週期釋放問題,尤其是單例,首先我們通過注入自增長來標識每一個注入服務,便於檢視釋放時機對應標識

public interface ICountService
{
    int GetCount();
}

public class CountService : ICountService
{
    private int _n = 0;
    public int GetCount() => Interlocked.Increment(ref _n);
}

接下來則是定義瞬時、範圍、單例服務,並將其進行注入,如下:

public interface ISingletonService
{
    void Say();
}

public class SingletonService : ISingletonService, IDisposable
{
    private readonly int _n;
    public SingletonService(ICountService countService)
    {
        _n = countService.GetCount();
        Console.WriteLine($"構造單例服務-{_n}");
    }

    public void Say() => Console.WriteLine($"呼叫單例服務-{_n}");

    public void Dispose() => Console.WriteLine($"釋放單例服務-{_n}");

}

public interface IScopeSerivice
{
    void Say();
}

public class ScopeSerivice : IScopeSerivice, IDisposable
{
    private readonly int _n;
    public ScopeSerivice(ICountService countService)
    {
        _n = countService.GetCount();
        Console.WriteLine($"構造範圍服務-{_n}");
    }

    public void Say() => Console.WriteLine($"呼叫範圍服務-{_n}");

    public void Dispose() => Console.WriteLine($"釋放範圍服務-{_n}");
}

public interface ITransientService
{
    void Say();
}

public class TransientService : ITransientService, IDisposable
{
    private readonly int _n;
    public TransientService(ICountService countService)
    {
        _n = countService.GetCount();
        Console.WriteLine($"構造瞬時服務-{_n}");
    }

    public void Say() => Console.WriteLine($"呼叫瞬時服務-{_n}");

    public void Dispose() => Console.WriteLine($"釋放瞬時服務-{_n}");
}

最後在入口注入並呼叫相關服務,再加上最後列印結果,應該挺好理解的

static void Main(string[] args)
{
    var services = new ServiceCollection();

    services.AddSingleton<ICountService, CountService>();
    services.AddSingleton<ISingletonService, SingletonService>();
    services.AddScoped<IScopeSerivice, ScopeSerivice>();
    services.AddTransient<ITransientService, TransientService>();

    using (var serviceProvider = services.BuildServiceProvider())
    {
        using (var scope1 = serviceProvider.CreateScope())
        {
            var s1a1 = scope1.ServiceProvider.GetService<IScopeSerivice>();
            s1a1.Say();

            var s1a2 = scope1.ServiceProvider.GetService<IScopeSerivice>();
            s1a2.Say();

            var s1b1 = scope1.ServiceProvider.GetService<ISingletonService>();
            s1b1.Say();

            var s1c1 = scope1.ServiceProvider.GetService<ITransientService>();
            s1c1.Say();

            var s1c2 = scope1.ServiceProvider.GetService<ITransientService>();
            s1c2.Say();

            Console.WriteLine("--------------------------------釋放分界線");
        }

        Console.WriteLine("--------------------------------結束範圍1");

        Console.WriteLine();

        using (var scope2 = serviceProvider.CreateScope())
        {
            var s2a1 = scope2.ServiceProvider.GetService<IScopeSerivice>();
            s2a1.Say();

            var s2b1 = scope2.ServiceProvider.GetService<ISingletonService>();
            s2b1.Say();

            var s2c1 = scope2.ServiceProvider.GetService<ITransientService>();
            s2c1.Say();
        }

        Console.WriteLine("--------------------------------結束範圍2");
    }

    Console.ReadKey();
}

我們描述下整個過程,通過容器建立一個scope1和scope2,並依次呼叫範圍、單例、瞬時服務,然後在scope和scope2結束時,釋放瞬時、範圍服務。最終在容器結束時,才釋放單例服務,從獲取、釋放以及列印結果來看,我們可以得出兩個結論

 

其一:每一個scope被釋放時,瞬時和範圍服務都會被釋放,且釋放順序為倒置

 

其二:單例服務在根容器釋放時才會被釋放

 

有了上述結論2不難解釋我們首先給出的假設控制檯命令列專案為何會導致記憶體洩漏,若非手動例項化,例項物件生命週期都將由容器管理,但在構建容器時,我們並未釋放(使用using),所以當我們手動實現IDisposable介面,通過實現Dispose方法進行後續清理工作,但並不會進入該方法,所以會導致記憶體洩漏。看到這裡,我相信有一部分童鞋會有點大跌眼鏡,因為和沉浸在自我想象中的樣子不一致,實踐是檢驗真理的唯一標準,最後我們對依賴注入做一個總結

 

在容器中註冊服務,容器為了處理所有註冊例項,容器會跟蹤所有物件,即使是瞬時服務,也並不是檢索完後,就一次性進行釋放,它依然在容器中保持“活躍”狀態,同時我們也應防止GC釋放超出其範圍的瞬時服務

 

即使是瞬時服務也和作用域(scope)有關,通過引入作用域而進行釋放,否則根容器會一直儲存其例項物件,造成巨大的記憶體損耗,甚至是記憶體洩漏

總結

瞬時服務可作為註冊服務的首選方法,範圍和單例用於共享狀態


? 每一個scope被釋放時,瞬時和範圍服務都會被釋放,且釋放順序為倒置

 

單例服務從不與作用域關聯,它們與根容器關聯,並在處置根容器時處理。

相關文章