你有把依賴注入玩壞?

前言

自從.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被釋放時,瞬時和範圍服務都會被釋放,且釋放順序爲倒置

 

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章