Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save eterekhin/f0bfe389282a86e630921ea0bafb760e to your computer and use it in GitHub Desktop.
Save eterekhin/f0bfe389282a86e630921ea0bafb760e to your computer and use it in GitHub Desktop.

Внедрение зависимостей в .net core сделано очень просто, встроенный контейнер поддерживает не так много кейсов, поэтому достаточно легкий, сама структура же и все интерфейсы хорошо продуманы, так что это позволяет подключать множество сторонних контейнеров, которые для поддержки системы DI в .net core, должны реализовать интерфейс IServiceProvider и IServiceProviderFactory

Устройство контейнера

Как я уже писал, он очень прост, так что в нем можно разобраться не тратя большое количество времени. Сначала рассмотрим IServiceCollection, это очень простая последовательность инкапсулирующая ServiceDescriptor'ы. Service Descriptor это также один из контрактов которые сторонние контейнеры должны принять, а именно - всю информацию о зависимостях они получат используя IServiceCollection. Теперь посмотрим, какие возможности предлагает IServiceCollection:

Содержимое ServiceDescriptor:

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:

Получается последовательность разрешения зависимостей такая:

  1. Ищем в словаре RealizedServices Type, который нужно разрешить, возможно, он уже разрешался и мы получим Func<ServiceProviderEngineScope, object>, в таком случае просто вызываем его и возвращаем сервис.
  2. Если тип еще не разрешался, то получаем для него ServiceCallSite используя CallSiteFactory. Возможно, что ServiceCallSite уже создан, если разрешаемый тип, был зависимостью какого-то уже разрешенного через конструктор сервиса.
  3. Вызываем абстрактный метод класса 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment