Console app 里的依赖注入及其实例生命周期
依赖注入是 ASP.NET Core 里的核心概念之一,我们平常总是愉快地在Startup
类的ConfigureServices
方法里往IServiceCollection
里注册各种类型,以致有一些同学可能误以为依赖注入是只有 ASP.NET Core 才有的特性。但实际上依赖注入也可以用于 .NET Core 的 Console app. 别忘了, ASP.NET Core 的应用本质上也只是一个 Console app而已。今天我们在Console app里试试依赖注入。
我们的目标是创建一个Console app,在其中引入依赖注入,注册不同生命周期的类型,然后创建几个线程,每个线程分别依靠依赖注入“创建”若干类型实例,然后观察不同生命周期下这些实例变量是否指向一个实例还是各不相同。
ServiceCollection
现在闭上眼睛想象一下(别睡着了),我们自己就是依赖注入的执行者,如果有一个漂亮的程序媛跟我们说她要某某类型的一个实例,我们应该怎么做?我们首先需要知道这某某类型是个什么东西以及如何创建对吧?我们如何知道呢?当然是她得提前告诉我们啊,而我们要有个地方把这些信息保留下来然后在需要的时候可以查阅。在 .NET Core里,可以依赖注入的类型叫Service
,而记录这些Service
信息的这地方就是ServiceCollection
。
所以,当程序运行起来之后,我们第一件事情就是创建一个ServiceCollection
,怎么创建呢? new
呗
// using Microsoft.Extensions.DependencyInjection
ServiceCollection services = new ServiceCollection();
听起来高大上的ServiceCollection
,其创建竟然如此简单。😓
IServiceProvider
看着 ServiceCollection
里眼花缭乱的各种类型,我们心中充满自信,“妹子,说吧,你想要哪个类型的实例?”,妹子一脸不乐意“要你个头,我两手空空拿什么去取类型的实例?”……对啊,我们总得给人家一个什么东西,然后人家可以用它从ServiceCollection
里获取实例啊。。。这东西就是IServiceProvider
,我们的ServiceCollection
可以生成一个IServiceProvider
,而任何类型的对象,只要有这个IServiceProvider
就可以从我们的ServiceCollection
里获取实例。
ServiceCollection services = new ServiceCollection();
// 向services注册各种类型
IServiceProvider sp = services.BuildServiceProvider();
//从此以后,任何握有 sp 的对象可以从ServiceCollection里获取实例。
有趣的是, IServiceProvider
是System
命名空间下的。
Service的生命周期
自脱离 ASP.NET Web Form 的世界以来,已经很少听到、看到“生命周期”这个词了。遥想当年无论是面试还是被面试,“ASP.NET 页面的生命周期”那简直是必备问题 —— 跑题了。
还是闭上眼睛(还是别睡着了),想象一下,还是那个漂亮的程序媛,她略带娇嗔地对我们说:“好哥哥,帮我把这个某某类型注册到依赖注入里吧,可以吗?”,既然我们现在有了ServiceCollection
,注册当然不成问题~,但再仔细想想,当我们把某某类型添加到ServiceCollection
,继而创建出一个IServiceProvider
给程序媛妹子,接着程序媛妹子不停地从ServiceCollection
里获取实例时,她得到的是同一个实例呢还是每次请求都给她一个新的实例?谁知道,得问她才知道。所以平常不擅言辞、从不废话的我们不能浪费这次交流的机会,在程序媛妹子让我们注册类型的时候我们还要问清楚她想怎样得到这个类型的实例,每次都给她一个新的,还是总给她同一个?换句话说,当一个Service
被注册到ServiceCollection
的时候,我们需要同时知道它的类型和实例生命周期。
ServiceCollection
很体贴,我们可以直接用不同的注册方法注册不同生命周期的Service
:
// AddTransient 方法将一个类型注册为 Transient 生命周期。因此,每一次你从依赖注入中请求一个 MyTransientClass 实例,你都会得到一个全新实例化的实例。请求10次,就得到10个不同的实例。
service.AddTransient<MyTransientClass>();
// AddSingleton 方法将一个类型注册为 Singleton 生命周期。单体大家都懂,就是无论请求多少次,你从依赖注入都会得到同一个 MySingletonClass 实例。请求10次,得到的却是同一个实例。
service.AddSingleton<MySingletonClass>();
// AddScoped 方法将一个类型注册为 Scoped 生命周期。这个生命周期比较特别。如果你的程序里创建了若干个 "Scope",依赖注入会确保在同一个 Scope 里,你将得到同一个 MyScopedClass 实例,而不同 Scope 里的 MyScopedClass 实例是不同的
// 假设你有3个Scope,每个Scope里请求10次,那么你将得到3个不同的 MyScopedClass 实例。其中每个 Scope 里一个。
// 至于 Scope 到底是什么含义,这就因程序而异了。比如在 ASP.NET Core 里,一个Scope意味着一次浏览器请求往返。而在我们的示例程序里,一个Scope代表一个线程内部。
service.AddScoped<MyScopedClass>();
以上3个生命周期类型基本上涵盖了所有可能的场景:
- 每次都要新实例。
- 永远都只需要同一个实例。
- 在一个范围之内只需要同一个实例,但是不同范围之内的实例要不同。
醒醒,无聊的理论时间过去了,Demo 上场了
说书者曰“闲话休提,且说正话”,咱们也到了“理论休提,且看Demo”的时候了。 .NET Core 的一大优点是命令行友好,并且不用特别依靠功能强大但臃肿的 Visual Studio来开发。我的Demo是在 MacOS + .NET Core CLI (v1.1) + Visual Studio Code 环境下创建和运行的。这套环境在其它平台下的体验几乎没什么区别。
首先打开一个命令行,创建一个目录,然后在新创建的目录里执行 dotnet new
命令。这将创建一个最简单的 Console App.
注意,利用 dotnet new
创建的文件竟然加了可执行属性(所以显示为红色),这应该是个bug,并且会在未来的版本里修复。最后运行 code .
会把当前目录在 Visual Studio Code 里打开,然后我们就可以写代码了。
STEP 1: 添加对 Microsoft.Extensions.DependencyInjection 的引用
首先,我们需要添加一个引用:Microsoft.Extensions.DependencyInjection
,依赖注入的默认实现都在里面。
打开 project.json
,然后在dependencies
里添加引用。添加完成之后,project.json
应该看起来是这样的:
{
"version": "1.0.0-*",
"buildOptions": {
"debugType": "portable",
"emitEntryPoint": true
},
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "1.1.0"
},
"frameworks": {
"netcoreapp1.1": {
"dependencies": {
"Microsoft.NETCore.App": {
"type": "platform",
"version": "1.1.0"
}
},
"imports": "dnxcore50"
}
}
}
添加好引用之后,保存,这时 VS Code的顶部应该会有个提示,说"There are unresolved dependencies from 'project.json'. Please execute the restore command to continue.",你可以直接点“Restore”按钮或者手工在命令行里运行 dotnet restore
命令来还原依赖。
如果你观察新创建的 ASP.NET Core 程序的 project.json 文件,你可能会发现依赖列表里并没有Microsoft.Extensions.DependencyInjection
,那为什么我们在这里需要添加这个引用呢?这是因为你的 project.json 文件里有 ASP.NET Core 的引用,比如 Microsoft.AspNetCore.Mvc
,而它或者它依赖的引用里有对Microsoft.Extensions.DependencyInjection
的引用。因此你的 ASP.NET Core程序其实是间接的引用了Microsoft.Extensions.DependencyInjection
。我们的示例程序里“干净”得很,所以必须直接添加对Microsoft.Extensions.DependencyInjection
的引用。
注意,我是使用 1.1 版本的 .NET Core,所以引用的版本号都是“1.1.0”,如果你使用的是1.0.0或1.0.1版本的 .NET Core,那么这里的版本号会有所不同。
STEP2: 准备工作
在我们的示例程序引入依赖注入之前,有几项准备工作要做。
首先, 我们需要一个可以注册到依赖注入的类型,这个简单:
public class MyClass { }
其次, 我们需要某种方法来检测从依赖注入中得到的类型实例是相同的还是不同的。什么叫相同?就是这些实例都指向内存里的同一个对象。对此,我们可以利用Object
类型的静态方法ReferenceEquals
来检测。顾名思义,无需解释。但是这个方法本身只能针对2个实例进行检测,我们的示例程序想一次得到10个实例引用,怎么检测这10个实例引用是相同还是不同?记得写SQL语句的时候,有个关键字叫Distinct
,它可以剔除集合中的重复项。而我们引以为傲的LINQ同样支持Distinct
,我们可以把所有实例放到一个集合,然后对集合进行Distinct
操作,如果结果是1
,说明集合里所有的实例其实指向同一个对象;如果结果等于集合原本的元素个数,那说明集合里每一个对象都是互不相同的。
鉴于我们使用多个线程向集合里插入数据,我们需要一个多线程安全的集合类型:System.Collections.Concurrent.ConcurrentBag<T>
。
而调用Distinct
方法的时候,我们希望它可以明确地以ReferenceEquals
的方式比较,这一点可以通过创建一个实现IEqualityComparer<T>
接口的类ReferenceEqualComparer<T>
来做到。
public class ReferenceEqualComparer<T> : IEqualityComparer<T>
{
public bool Equals(T x, T y)
{
return Object.ReferenceEquals(x, y);
}
public int GetHashCode(T obj)
{
return obj.GetHashCode();
}
}
然后, 我们创建两个IEnumerable<T>
上的扩展方法来简化比较操作。
public static class IEnumerableExtensions
{
public static bool AreIdentical<T>(this IEnumerable<T> bag)
{
return bag.Distinct(new ReferenceEqualComparer<T>()).Count() == 1;
}
public static bool AreDifferent<T>(this IEnumerable<T> bag)
{
return bag.Distinct(new ReferenceEqualComparer<T>()).Count() == bag.Count();
}
}
最后, 我们创建一个统一的方法,这个方法可以传入一个ServiceCollection
对象,然后我们从中获取IServiceProvider
,再创建10个线程分别利用IServiceProvider
获取服务实例,插入到一个集合中并返回这个集合。
public static ConcurrentBag<MyClass> GetObjectsFromDI(ServiceCollection services)
{
int threadCount = 10;
IServiceProvider sp = services.BuildServiceProvider();
ConcurrentBag<MyClass> bag = new ConcurrentBag<MyClass>();
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++)
{
Thread thread = new Thread(RunPerThread);
threads[i] = thread;
thread.Start(new Tuple<IServiceProvider, ConcurrentBag<MyClass>>(sp, bag));
}
// 确保所有线程都执行完毕之后再继续
for (int i = 0; i < threadCount; i++)
{
threads[i].Join();
}
return bag;
}
public static void RunPerThread(object threadParam)
{
Tuple<IServiceProvider, ConcurrentBag<MyClass>> args = threadParam as Tuple<IServiceProvider, ConcurrentBag<MyClass>>;
IServiceProvider sp = args.Item1;
ConcurrentBag<MyClass> bag = args.Item2;
for (int i = 0; i < 10; i++)
{
bag.Add(sp.GetRequiredService<MyClass>());
}
}
以上的准备工作使我们接下来的验证操作变得容易了很多。
STEP 3: 验证 Singleton 生命周期
我们创建一个方法TryOutSingleton
来验证 Singleton 生命周期
private static void TryOutSingleton()
{
ServiceCollection services = new ServiceCollection(); // 准备好我们的容器
services.AddSingleton<MyClass>(); //把MyClass注册为 Singleton 生命周期
ConcurrentBag<MyClass> bag = GetObjectsFromDI(services); // 调用我们准备好的方法,用若干线程从 IServiceProvider 中获取 MyClass 实例,并加入到集合
Console.WriteLine(bag.AreIdentical()); // 验证集合中的所有元素是否指向内存中的同一个对象。
}
不出所料,最后输出的结果是:
True
STEP 4: 验证 Transient 生命周期
再创建一个 TryOutTransient
方法验证 Transient 生命周期
private static void TryOutTransient()
{
ServiceCollection services = new ServiceCollection(); // 准备好我们的容器
services.AddTransient<MyClass>(); //把MyClass注册为 Transient 生命周期
ConcurrentBag<MyClass> bag = GetObjectsFromDI(services); // 调用我们准备好的方法,用若干线程从 IServiceProvider 中获取 MyClass 实例,并加入到集合
Console.WriteLine(bag.AreDifferent()); // 验证集合中的所有元素是否各不相同
}
同样不出意外,输出结果是:
True
STEP 5: Scoped 生命周期
前面提到过, Scoped 生命周期比较特别,同一个Scope里的实例是同一个,但是不同Scope里的实例是不同的。而Scope具体的含义取决于我们自己的定义。
具体到代码级别,当我们需要创建一个Scope的时候,我们需要用到我们之前得到的IServiceProvider
,它有一个CreateScope
方法可以创建一个类型为Microsoft.Extensions.DependencyInjection.IServiceScope
的Scope,而这个Scope实例有一个IServiceProvider
类型的属性ServiceProvider
!自此,我们应该使用这个来自IServiceScope
的IServiceProvider
(取代之前我们得到的IServiceProvider
)来获取服务实例,它会正确处理Singleton, Transient以及Scoped这3种生命周期!
ServiceCollection services = new ServiceCollection();
// ...
IServiceProvider serviceProvider = services.BuildServiceProvider();
IServiceScope scope = serviceProvider.CreateScope();
IServiceProvider newServiceProvider = scope.ServiceProvider; // 以后靠它来正确处理 Singleton, Transient 和 Scoped 生命周期的实例
MyClass obj = newServiceProvider.GetRequiredService<MyClass>(); // 无论MyClass是哪种生命周期类型,这里都可以得到正确的实例。
为了验证Scoped生命周期,我们现在定义Scope为线程空间。也就是说,每一个线程为一个Scope,对于Scoped生命周期的类型,在同一个线程之内获取的实例应该是同一个,但是不同线程获取的实例是不同的。
在演示代码中,我们注册3个不同的类型,分别对应3种不同的生命周期,看看来自IServiceScope
的IServiceProvider
能否正确处理每一种生命周期类型。
代码有些啰嗦,因为不想再拆分成更小的方法了:
/*
public class MySingleton { }
public class MyTransient { }
public class MyScoped { }
*/
private static void TryOutScoped()
{
Console.WriteLine($"RUNNING {nameof(TryOutScoped)}");
ServiceCollection services = new ServiceCollection();
services.AddSingleton<MySingleton>();
services.AddTransient<MyTransient>();
services.AddScoped<MyScoped>();
IServiceProvider sp = services.BuildServiceProvider();
// 线程1执行
ConcurrentBag<MySingleton> thread1SingletonBag = new ConcurrentBag<MySingleton>();
ConcurrentBag<MyTransient> thread1TransientBag = new ConcurrentBag<MyTransient>();
ConcurrentBag<MyScoped> thread1ScopedBag = new ConcurrentBag<MyScoped>();
Thread thread1 = new Thread(RunPerThreadWithScopedLifetime);
thread1.Start(new Tuple<IServiceProvider, ConcurrentBag<MySingleton>, ConcurrentBag<MyTransient>, ConcurrentBag<MyScoped>>(sp, thread1SingletonBag, thread1TransientBag, thread1ScopedBag));
// 线程2执行
ConcurrentBag<MySingleton> thread2SingletonBag = new ConcurrentBag<MySingleton>();
ConcurrentBag<MyTransient> thread2TransientBag = new ConcurrentBag<MyTransient>();
ConcurrentBag<MyScoped> thread2ScopedBag = new ConcurrentBag<MyScoped>();
Thread thread2 = new Thread(RunPerThreadWithScopedLifetime);
thread2.Start(new Tuple<IServiceProvider, ConcurrentBag<MySingleton>, ConcurrentBag<MyTransient>, ConcurrentBag<MyScoped>>(sp, thread2SingletonBag, thread2TransientBag, thread2ScopedBag));
// 等待执行完毕
thread1.Join();
thread2.Join();
// 验证所有 MySingleton 的实例都指向内存里同一个对象
IEnumerable<MySingleton> singletons = thread1SingletonBag.Concat(thread2SingletonBag);
Console.WriteLine($"Singleton: {singletons.Count()} objects are IDENTICAL? {singletons.AreIdentical()}");
// 验证所有 MyTransient 的实例都各不相同
IEnumerable<MyTransient> transients = thread1TransientBag.Concat(thread2TransientBag);
Console.WriteLine($"Transient: {transients.Count()} objects are DIFFERENT? {transients.AreDifferent()}");
// 对于Scoped生命周期,每个线程集合内的实例应该指向内存里同一个对象,而2个线程集合里的实例应该是不同的。
Console.WriteLine($"collection of thread 1 has {thread1ScopedBag.Count} objects and they are IDENTICAL: {thread1ScopedBag.AreIdentical()}");
Console.WriteLine($"collection of thread 2 has {thread2ScopedBag.Count} objects and they are IDENTICAL: {thread2ScopedBag.AreIdentical()}");
Console.WriteLine($"the first object from thread 1 and the first object from thread 2 are IDENTICAL: {Object.ReferenceEquals(thread1ScopedBag.First(), thread2ScopedBag.First())}");
}
输出结果为:
RUNNING TryOutScoped
Singleton: 20 objects are IDENTICAL? True
Transient: 20 objects are DIFFERENT? True
collection of thread 1 has 10 objects and they are IDENTICAL: True
collection of thread 2 has 10 objects and they are IDENTICAL: True
the first object from thread 1 and the first object from thread 2 are IDENTICAL: False
演示代码可以从Github上获取。