你有把依赖注入玩坏?

前言

自从.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();
}

然鹅并没有抛出任何异常,注入操作都一样,有点懵,看看各位看官能否给个合理的解释,在控制台中并不会抛出异常……

深谈依赖注入

关于依赖注入基础和使用准则,我建议大家去看看,还是有很多细节需要注意

依赖注入设计准则

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

 

在.NET Core中使用依赖注入

//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被释放时,瞬时和范围服务都会被释放,且释放顺序为倒置

 

💡 单例服务从不与作用域关联,它们与根容器关联,并在处置根容器时处理。