終於弄明白了 Singleton,Transient,Scoped 的作用域是如何實現的

一線碼農發表於2020-09-01

一:背景

1. 講故事

前幾天有位朋友讓我有時間分析一下 aspnetcore 中為什麼向 ServiceCollection 中注入的 Class 可以做到 Singleton,Transient,Scoped,挺有意思,這篇就來聊一聊這一話題,自從 core 中有了 ServiceCollection, 再加上流行的 DDD 模式,相信很多朋友的專案中很少能看到 new 了,好歹 spring 十幾年前就是這麼幹的。

二:Singleton,Transient,Scoped 基本用法

分析原始碼之前,我覺得有必要先介紹一下它們的玩法,為方便演示,我這裡就新建一個 webapi 專案,定義一個 interface 和 concrete ,程式碼如下:

    public class OrderService : IOrderService
    {
        private string guid;

        public OrderService()
        {
            guid = $"時間:{DateTime.Now}, guid={ Guid.NewGuid()}";
        }

        public override string ToString()
        {
            return guid;
        }
    }

    public interface IOrderService
    {
    }

1. AddSingleton

正如名字所示它可以在你的程式中保持著一個例項,也就是說僅有一次例項化,不信的話程式碼演示一下哈。


    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            services.AddSingleton<IOrderService, OrderService>();
        }
    }

    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        IOrderService orderService1;
        IOrderService orderService2;

        public WeatherForecastController(IOrderService orderService1, IOrderService orderService2)
        {
            this.orderService1 = orderService1;
            this.orderService2 = orderService2;
        }

        [HttpGet]
        public string Get()
        {
            Debug.WriteLine($"{this.orderService1}\r\n{this.orderService2} \r\n ------");
            return "helloworld";
        }
    }

接著執行起來多次重新整理頁面,如下圖:

可以看到,不管你怎麼重新整理頁面,guid都是一樣,說明確實是單例的。

2. AddScoped

正從名字所述:Scope 就是一個作用域,那在 webapi 或者 mvc 中作用域是多大呢? 對的,就是一個請求,當然請求會穿透 Presentation, Application, Repository 等等各層,在穿層的過程中肯定會有同一個類的多次注入,那這些多次注入在這個作用域下維持的就是單例,如下程式碼所示:


        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            services.AddScoped<IOrderService, OrderService>();
        }

執行起來多次重新整理頁面,如下圖:

很明顯的看到,每次刷 UI 的時候,guid都會變,而在同一個請求 (scope) 中 guid 是一樣的。

3. AddTransient

前面大家也看到了,要麼作用域是整個程式,要麼作用域是一個請求,而這裡的 Transient 就沒有作用域概念了,注入一次 例項化一次,不信的話上程式碼給你看唄。


        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            services.AddTransient<IOrderService, OrderService>();
        }

從圖中可以看到,注入一次就 new 一次,非常簡單吧,當然了,各有各的應用場景。

之前不清楚的朋友到現在應該也明白了這三種作用域,接下來繼續思考的一個問題就是,這種作用域是如何做到的呢? 要想回答這個問題,只能研究原始碼了。

三:原始碼分析

aspnetcore 中的 IOC 容器是 ServiceCollection,你可以向 IOC 中注入不同作用域的類,最後生成 provider,如下程式碼所示:


            var services = new ServiceCollection();

            services.AddSingleton<IOrderService, OrderService>();

            var provider = services.BuildServiceProvider();

1. AddSingleton 的作用域是如何實現的

通常說到單例,大家第一反應就是 static,但是一般 ServiceCollection 中會有成百上千個 AddSingleton 型別,都是靜態變數是不可能的,既然不是 static,那就應該有一個快取字典什麼的,其實還真的有這麼一個。

1)RealizedServices 字典

每一個 provider 內部都會有一個 叫做 RealizedServices 的字典,這個 字典 將會在後面充當快取存在, 如下圖:

從上圖中可以看到,初始化的時候這個字典什麼都沒有,接下來執行 var orderService = provider.GetService<IOrderService>(); 效果如下圖:

可以看到 RealizedServices 中已經有了一個 service 記錄了,接著往下執行 var orderService2 = provider.GetService<IOrderService>();,最終會進入到 CallSiteRuntimeResolver.VisitCache 方法判斷例項是否存在,如下圖:

仔細看上面程式碼的這句話: if (!resolvedServices.TryGetValue(callSite.Cache.Key, out obj)) 一旦字典存在就直接返回,否則就要執行 new 鏈路,也就是 this.VisitCallSiteMain

綜合來看,這就是為什麼可以單例的原因,如果不明白可以拿 dnspy 仔細琢磨琢磨。。。

2. AddTransient 原始碼探究

前面大家也看到了,provider 裡面會有一個 DynamicServiceProviderEngine 引擎類,引擎類中用 字典快取 來解決單例問題,可想而知,AddTransient 內部肯定是沒有字典邏輯的,到底是不是呢? 除錯一下唄。

和單例一樣,最終解析都是由 CallSiteRuntimeResolver 負責的,AddTransient 內部會走到 VisitDisposeCache 方法,而這裡會一直走 this.VisitCallSiteMain(transientCallSite, context) 來進行 例項的 new 操作,還記得單例是怎麼做的嗎? 它會在這個 VisitCallSiteMain 上包一層 resolvedServices 判斷,?
繼續追一下 VisitCallSiteMain 方法吧,這個方法最終會走 CallSiteKind.Constructor 分支呼叫你的建構函式,程式碼如下:


		protected virtual TResult VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
		{
			switch (callSite.Kind)
			{
			case CallSiteKind.Factory:
				return this.VisitFactory((FactoryCallSite)callSite, argument);
			case CallSiteKind.Constructor:
				return this.VisitConstructor((ConstructorCallSite)callSite, argument);
			case CallSiteKind.Constant:
				return this.VisitConstant((ConstantCallSite)callSite, argument);
			case CallSiteKind.IEnumerable:
				return this.VisitIEnumerable((IEnumerableCallSite)callSite, argument);
			case CallSiteKind.ServiceProvider:
				return this.VisitServiceProvider((ServiceProviderCallSite)callSite, argument);
			case CallSiteKind.ServiceScopeFactory:
				return this.VisitServiceScopeFactory((ServiceScopeFactoryCallSite)callSite, argument);
			}
			throw new NotSupportedException(string.Format("Call site type {0} is not supported", callSite.GetType()));
		}

最終由 VisitConstructor 對我的例項程式碼的建構函式進行呼叫,所以你應該理解了為啥每次注入都會new一次。如下圖:

3. AddScoped 原始碼探究

當你明白了 AddSingleton, AddTransient 的原理,我想 Scoped 也是非常容易理解的,肯定是一個 scoped 一個 RealizedServices 對吧,不信的話繼續上程式碼哈。


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

            services.AddScoped<IOrderService, OrderService>();

            var provider = services.BuildServiceProvider();

            var scoped1 = provider.CreateScope();
            
            var scoped2 = provider.CreateScope();

            while (true)
            {
                var orderService = scoped1.ServiceProvider.GetService<IOrderService>();

                var orderService2 = scoped2.ServiceProvider.GetService<IOrderService>();

                Console.WriteLine(orderService);

                Thread.Sleep(1000);
            }
        }

然後看一下 scoped1 和 scoped2 是不是都存在獨立的快取字典。

從圖中可以看到,scoped1 和 scoped2 中的 ResolvedServices 擁有不用的count,也就說明兩者是獨立存在的,相互不影響。

四: 總結

很多時候大家都這麼習以為常的用著,突然有一天被問起還是有點懵逼的,所以時常多問自己幾個為什麼還是很有必要的哈???。

如您有更多問題與我互動,掃描下方進來吧~

圖片名稱

相關文章