Внедрение зависимостей в .net core сделано очень просто, встроенный контейнер поддерживает не так много кейсов, поэтому достаточно легкий, сама структура же и все интерфейсы хорошо продуманы, так что это позволяет подключать множество сторонних контейнеров, которые для поддержки системы DI в .net core, должны реализовать интерфейс IServiceProvider и IServiceProviderFactory
Как я уже писал, он очень прост, так что в нем можно разобраться не тратя большое количество времени.
Сначала рассмотрим IServiceCollection
, это очень простая последовательность инкапсулирующая ServiceDescriptor
'ы. Service Descriptor это также один из контрактов которые сторонние контейнеры должны принять, а именно - всю информацию о зависимостях они получат используя IServiceCollection. Теперь посмотрим, какие возможности предлагает IServiceCollection:
Service Type
и Lifetime
должны быть заполнены всегда, вместе с ним должно быть заполнено еще одно(как минимум) из полей : ImplementationType
, ImplementationInstanse
, Implementation Factory
. Которое позволяет как-то разрешить создание объекта.
LifeTime'a бывает 3: Singleton, Scoped, Transient, первый будет жить пока живет контейнер, второй пока живет scope, а время существование последнего никак не обрабатывается, он будет каждый раз создаваться заново при необходимости.
.net core, инкапсулирует создание service descriptor'ов extension методами над IServiceCollection:
Когда все сервисы зарегистрированы, мы можем начать создать сам контейнер, это делается вызовом extension метода BuildServiceProvider, ничего сверхъестественного при вызове метода не происходит, создается ServiceProvider
, в нем инициализируется поле _engine - поле типа IServiceProviderEngine(на самом деле это поле всегда имеет тип ServiceProviderEngine), это 'двигатель', а именно класс на который переложены обязанности по созданию объектов, это его единственная задача.
Внутри себя они держит CallSiteFactory
- фабрику, которая обрабатывает ServiceDescriptor'ы и создает удобную структуру, используя которую можно решать зависимости( о ней позже). CallSiteFactory при обработке ServiceDescriptor'ов создает Dictionary<Type, ServiceDescriptorCacheItem> _descriptorLookup
, можно задаться вопросом, почему _descriptorLookup?
Потому что реализация контейнера от microsoft, позволяет регистрировать несколько зависимостей, на один тип:
sc.AddScoped<IA,A>();
sc.AddScoped<IA,AA>();
sc.AddScoped<IA,AAA>();
т.е были зарегистрированы три разные реализации на одну зависимость, в таком случае берется последняя но все запоминаются все:
Также ServiceProviderEngine
внутри держит ссылку на RuntimeResolver, это resolver для объектов, его непосредственная задача - создавать объекты, а задача ServiceProviderEngine
передавать ему необходимые данные для этого. Рассмотрим его подробнее, он наследует класс CallSiteVisitor, которые является visit'ором предоставляющим 4 виртуальных и 4 абстрактых метода, пригодных для переопределения:
Задача этого визитора, дать возможность корректно обойти все дерево зависимостей и на каждом шаге разрешать их:
Два базовых protected метода - VisitCallSite и VisitCallSiteMain, первый определяет откуда запрашивается зависисмость, Root(запрашивается их самого контейнера, Scope - из созданного скоупа, Dispose - из контейнера, но сервис зарегистрирован как Transient, когда lifetime не выбран(а именно, когда он не Singleton, не Scope и не Transient, а это возможно, например, так:
sc.Add(ServiceDescriptor.Describe(typeof(IA), typeof(A), (ServiceLifetime)(-1)));
Для чего нужно, я не знаю, но в таком случае мы лишаемся возможность вызвать dispose у этого сервиса).
Получается, что используя этот визитор у нас появляется возможность управлять разрешением объектов, но также появляются проблемы с рекурсией, ведь граф глубокий, то стек потока может переполниться, это также учтено:
Кстати MaxExecutionStackCount
= 1024, так что контейнер может заметно наплодить потоков, если постараться.
Метод VisitCallSite
нужен чтобы узнать откуда будем разрешать зависимость и попробовать найти ее в кеше, либо зарегистрировать для dispose, а метод VisitCallSiteMain
, нужен для создания зависимости:
Это описание всех типов зависимотей, которые можно запросить. Все эти случаи могут обрабатываться по-разному, что и позволяет делать visitor. Вернемся к обсуждению RuntimeResolver'a, это довольно простой наследник CallSiteVisitor, в основном в своей реализации он использует reflection:
Чуть более интересно обстоит дело с VisitRootCache и VisitScopeCache, но чтобы добраться до них нужно узнать про ServiceProviderEngineScope, это как раз тот Scope, который создается при вызове ServiceProvider.CreateScope()
, он используется для хранения уже созданных зависимостей, которые нужно закешировать и для сохранения ссылок на зависимости, которые нужно задиспозить(поддерживается как IDisposable
, так и IAsyncDisposable
)
При создании контейнера методом BuildServiceProvider
создается RootScope, при ручном создании скоупа ServiceProvider.CreateScope()
, создается новый скоуп, который получает ссылку на ServiceProviderEngine, после получения этого скоупа нужно работать только с ним, не трогая ServiceProvider:
using(var scope = ServiceProvider.CreateScope()){
var d = scope.GetService<A>();
}
// all services disposed
Назначение ServiceProviderEngineScope
еще в одном, дело в том, что сам ServiceProviderEngine
хранит внутри себя ConcurrentDictionary<Type, Func<ServiceProviderEngineScope,object>> RealizedServices;
, в этот Dictionary добавляется ServiceType
при первом его разрешении из контейнера. Понятно, что при таком кешировании мы всегда сможем получать тип подавая на вход ServiceProviderEngineScope
, будем получать эти зависимости. Давайте представим как выглядит сам func:
spes = > spes.GetService(Type);
Вот и все, а за создание такого func'a отвечают наследники ServiceProviderEngine
, например, RuntimeServiceProviderEngine
, который для этого использует RuntimeResolver
:
Получается последовательность разрешения зависимостей такая:
- Ищем в словаре
RealizedServices
Type, который нужно разрешить, возможно, он уже разрешался и мы получимFunc<ServiceProviderEngineScope, object>
, в таком случае просто вызываем его и возвращаем сервис. - Если тип еще не разрешался, то получаем для него
ServiceCallSite
используяCallSiteFactory
. Возможно, чтоServiceCallSite
уже создан, если разрешаемый тип, был зависимостью какого-то уже разрешенного через конструктор сервиса. - Вызываем абстрактный метод класса
ServiceProviderEngine
RealizeService(Type serviceType)
, который возвращаетFunc<ServiceProviderEngineScope,object>
, запоминаем результат вRealizedServices
3.1) Всего наследников класса ServiceProviderEngine 5, самый простой -RuntimeServiceProviderEngine
, который используетRuntimeResolver
, т.е рефлексию для создания каждого сервиса, есть другие реализации, напримерExpressionsServiceProvideEngine
, который для создания сервиса используетExpressionResolverBuilder
, который строит LambdaExpression и создает нужный объект используя конструктор. После построения выражения оно компилируется в Func и возвращается. Тут важно отметить один момент. Если в процессе разрешения transient сервиса нам нужно создать scope и singleton сервисы, они будут созданы корректно. Singleton сервис будет создаваться и использованием RuntimeResolver'a(рефлексии).
А для Scoped, используется _scopeResolverCache
.
Получается, что если большинство сервисов в приложении - Singleton, то выигрыша от использования ExpressionsServiceProvideEngine не будет.
Также существует еще EmitServiceProvideEngine.
Дефолтный контейнер поддерживает открытые generic'и, т.е:
sc.AddScoped(typeof(IA<>), typeof(A<>));
var service = sp.GetService<IA<int>>();
Сделать их реализацию легкой позволяет разделение всей системы контейнера на две части, которое было сделано при проектировании, разделить формирование callsite(информации для создания сервисов), и самого создания сервисов, плюсы такого разделения, это, например, факт, что для поддержки открытых generic'ов нужно дописать код в CallSiteFactory, который при построении callsite, будет искать в descriptors
сервис с открым generic'ом и закрывать его. Код в ServiceProviderEngine
менять не надо.
Также поддерживается еще разрешение IEnumerable, тогда происходит поиск все типам, зарегистрированным под типом T, в таком случае создается IEnumerableCallSite
:
В итоге: В архитектурном плане абсолютно верное решение сначала получать CallSite информацию, и только потом по ней создавать объект. Также использование визитора, отличное решение, оно позволило создавать объекты учитывая и место создания(Scope,Root и т.д) и тип (Constant, ServiceProvider,Constructor, IEnumerable). В данном случае шаблон визитор зафиксировал последовательность создания объекта, наследники же предлагали разные реализации. Интересно, что сам визитор generic'овый, с двумя параметрами TArgument и TResult, использование первого параметра нужно далеко не всем наследникам
Некоторые явно передают null, вместо параметра. Я бы не стал трактовать это как неудачно выбранную абстрацию, мне кажется, что в данном случае это не проблема, а скорее компромисс между гибкостью и отнозначностью, в данном случае в пользу гибкости.
Еще одно хорошнее решение, это выделение класса ServiceProviderEngineScope
хранящего закешированные объекты и список объектов, которым нужен вызов Dispose. Это позволило упростить создание скоупов, ведь если для каждого сервиса который мы создали, кешируется Func<ServiceProviderEngineScope, object>, то получается, что когда мы выбросим старый скоуп и создадим новый, то сможем переиспользовать этот закешированный Func<,>
. Таким образом мы инкапсулировали в ServiceProviderEngineScope
все правила работы со скоупами, а именно - кеширование (ServiceProviderEngineScope.ResolvedServices
) и dispose(ServiceProviderEngineScope._disposables
).
Тут остается тонкий момент, могут ли сервисы из скоупа получать Singleton сервисы? По умолчанию да, но это можно настроить иначе, используя ServiceProviderOptions
.