Использование DI-контейнеров часто приводит к появлению неприятных ошибок в рантайме. В таких случаях хочется, чтобы контейнер умел находить проблемы и уведомлять о них в момент сборки.
С проверкой регистраций, добавленных через рефлексию, все довольно просто, и некоторые контейнеры даже предоставляют такой функционал из коробки. Но вот что делать с регистрациями-делегатами сходу неясно и даже может показаться, что от ошибок резолва сервисов внутри делегатов никуда не деться.
В статье на примере Autofac посмотрим, как устроены регистрации-делегаты, и научимся находить в них ошибки до того, как контейнер начнет использоваться приложением. По ходу дела вспомним, как работают замыкания в C# и как размещаются в памяти анонимные делегаты, а также рассмотрим способ анализа MSIL-инструкций в процессе выполнения программы.
Для начала вспомним главные ошибки, с которыми связано использование DI-контейнера. Для примера внимательно рассмотрим следующий код:

Контейнер соберется, но опытные разработчики, посмотрев на код, сразу скажут, что его использование в строчках 29-32 ничем хорошим не закончится. Разберемся, в чем дело.
Сперва обратим внимание, что способы регистрации можно разделить на 2 основные группы — Reflection-регистрации(A, B и D) и Delegate-регистрации(C). Рассмотрим их ключевые отличия.
Разбираем типы регистраций
Reflection-регистрация
При такой регистрации мы передаем контейнеру только тип сервиса, а определение зависимостей и создание объекта контейнер берет на себя. В стандартном случае(когда используется внедрение через конструктор), в момент резолва сервиса с такой регистрацией контейнер действует по следующей логике:
Смотрит на публичные конструкторы типа сервиса и комбинации типов их параметров.
По заданной логике и с учетом доступных в контейнере типов сервисов выбирает один из конструкторов.
Рекурсивно резолвит сервисы, требуемые для выбранного конструктора, после чего создает и возвращает экземпляр сервиса.
Если в процессе что-то идет не так — не получилось выбрать конструктор или зарегистрированных типов не хватает, чтобы создать объект, — выбрасывается ошибка.
Delegate-регистрация
Суть такой регистрации в том, что контейнер делегирует нам логику создания экземпляра сервиса и использует переданный делегат (Factory-method) для создания объекта. Внутри сам делегат может использовать сервисы из контейнера, а может и целиком самостоятельно создавать нужный сервис. Обычно такой тип регистрации используется, когда целевой сервис зависит от примитивов, либо когда в процесс создания сервиса нужно добавить дополнительную логику. Например так:
builder
.Register<MemoryCache>(ctx =>
{
var settings = ctx.Resolve<ISettings>();
return new MemoryCache(
new MemoryCacheOptions()
{
SizeLimit = settings.CacheSize,
ExpirationScanFrequency = settings.CacheScanFrequency,
});
});
Важно отметить, что при любом из типов регистраций создание сервисов происходит в рантайме — т.е. алгоритм создания сервиса через рефлексию или переданный делегат вызывается только тогда, когда приложению потребовался тот или иной сервис.
Разбираем ошибки
Пропущенные регистрации
Итак, в чем же проблема вышеупомянутого кода? Резолв сервиса B в строчке 29 (var b = container.Resolve<B>;
) упадет с ошибкой, т.к. сервис B зависит в конструкторе от типа A, который не добавлен в контейнер. Это классический пример пропущенной регистрации — то есть ситуации, когда из контейнера пытаются получить незарегистрированный сервис, и самое неприятное здесь то, что контейнер разваливается в рантайме, причем происходить это может непредсказуемо и отложено.
Более реалистичный пример
Например, в приложении API некоторого сервиса у нас может быть контроллер, обрабатывающий редко используемый маршрут, который зависит от незарегистрированного сервиса. В этом случае ошибка возникнет только тогда, когда кто-то дернет соответствующий эндпоинт. Соответственно, до этого момента приложение может без проблем проработать неопределенное количество времени, а после внезапно начать спамить ошибками.
Захваченные зависимости
Двигаемся дальше, резолв сервиса d1 в строчке 30 (var d1 = container.Resolve<D>;
) и d2 в строчке 31 (var d2 = container.Resolve<D>;)
отработает корректно. Однако, присмотревшись, можно заметить захваченную зависимость или CaptiveDependency — ситуация, когда сервис сохраняет ссылку на зависимость дольше предполагаемого времени её жизни.
В данном случае — сервис D зарегистрирован как Singleton
, но в конструкторе зависит от Transient
сервиса B — т.е. переменные d1 и d2 будут содержать ссылку на один и тот же объект D, внутри которого захватится объект класса B. Иногда это может быть сделано намеренно, но в большинстве случаев такая ситуация должна насторожить и стать поводом, чтобы ещё раз перепроверить правильность регистраций.
Также стоит отметить, что захваченные зависимости особенно неприятны тем, что их выявление и исправление может быть достаточно непростой задачей и занять немало времени, поскольку в этом случае никаких явных ошибок мы не получим, но приложение, скорее всего, начнет вести себя некорректно.
Более реалистичный пример
К примеру, в том же приложении API некоторого сервиса, у нас может быть провайдер к БД, который зависит от контекста текущего пользователя. Провайдер получает контекст пользователя в конструкторе и далее автоматически подставляет данные пользователя в запросы к БД. Если мы по какой-то причине зарегистрируем упомянутый провайдер как Singleton, то при первом обращении к контроллеру API провайдер захватит контекст соответствующего пользователя, который дальше начнет использоваться для всех остальных запросов. Многообразие возможных негативных последствий от такого бага представить не трудно.
Наконец, смотрим на строчку 32 (var c = container.Resolve<C>;
) и резолв сервиса C. Этот сервис зарегистрирован через делегат, который создает объект типа C. Изучив код делегата, можно заметить, что он содержит сразу обе описанные проблемы.
Строковая зависимость берется из поля someString
объекта класса A, который достается из контейнера. Регистрация сервиса этого типа у нас пропущена, соответственно в 21 строчке (var aHiddenDep = ctx.Resolve<А>();
) получим ошибку. Дальше в 22 строчке(var bDep = ctx.Resolve();
) контейнер пытается получить из контейнера зависимость B. Ошибки тут не будет, но мы получим захваченную зависимость. Тип B регистрируется как Transient
, в то время как тип C — Singleton
. Переданный делегат вызовется только один раз при первом обращении к сервису C, после чего на каждый запрос будет возвращаться уже созданный объект с захваченным внутри экземпляром класса B.
Если мы научимся валидировать контейнер в момент сборки, то от обеих проблем получится избавиться.
Постановка проблемы
Как таковая валидация контейнера — штука далеко не новая. Например, тот же контейнер Microsoft умеет валидировать регистрации при сборке на обе описанные ошибки, но проблема в том, что он делает это только для Reflection-регистраций. Немного подумав, можно примерно представить механизм таких проверок или предложить возможную реализацию:
Сразу после сборки достаточно перебрать все зарегистрированные через рефлексию типы сервисов.
Для каждого из них определить, какой из конструкторов будет использован в момент резолва.
После чего проверить, что все типы параметров конструктора зарегистрированы в контейнере, и что
Lifetime
каждой из зависимостей >=Lifetime
-у зависимого сервиса.
Однако с Delegate-регистрациями дела обстоят хуже. Я не знаю контейнеров, которые бы как-то пытались автоматически проверять, что зарегистрированные делегаты не содержат ошибок, связанных с зависимостями. Более того, сходу непонятно, как вообще должны выглядеть такие проверки? Ведь пока мы не вызвали делегат, мы не знаем, какие типы сервисов он будет пытаться получить из контейнера и будет ли делать это вообще. Поэтому далее сосредоточимся на том, чтобы придумать способ проверять именно такие регистрации.
Выбираем контейнер
Для примера возьмем Autofac
. Он предоставляет множество возможностей по кастомизации и управлению процессом сборки, в отличие от контейнера Microsoft. Кроме того, это один из самых популярных DI-контейнеров в .NET.
В Autofac
нет встроенных средств для валидации контейнера. Авторы объясняют это тем, что из-за гибкости Autofac
и потенциального наличия сложной логики внутри делегатов не всегда можно точно сказать, считаем ли мы конфигурацию контейнера допустимой или нет. В общем-то, так и есть. Однако, на мой взгляд, если писать более менее чистый код, следовать паттернам проектирования и выносить всю логику, связанную с приложением за пределы контейнера, то в большинстве случаев определить, валиден контейнер или нет, можно будет вполне однозначно.
Воспользуемся тем, что Autofac
позволяет добавить callback
, который будет вызван после сборки с переданным внутрь контейнером. Создадим метод расширения, который позволит добавлять в контейнер нужный callback
, и функцию ValidateScope
, в которую будет передаваться контейнер после сборки. Именно она будет отвечать за все проверки:
public static ContainerBuilder ValidateOnBuild
(this ContainerBuilder containerBuilder)
{
containerBuilder.RegisterBuildCallback(scope =>
ValidateScope(scope).EnsureSuccess());
return containerBuilder;
}
private static ValidationResult ValidateScope(IComponentContext scope)
{
//код проверок
}
Внутри IComponentContext
есть объект IComponentRegistry
, который как раз и содержит все регистрации контейнера, а также методы для проверки наличия регистраций заданного типа. Таким образом, нам остается только научиться определять зависимости для каждой регистрации и проверять, что все они добавлены в контейнер.
Получение зависимостей Delegate-регистраций
Регистрации сервисов внутри IComponentRegistry
представлены интерфейсом IComponentRegistration
. Каждая из регистраций содержит Activator
— объект, который отвечает за предоставление экземпляра заданного сервиса.
Существует всего 3 типа активаторов:
ReflectionActivator
— для Reflection-регистрацийDelegateActivator
— для Delegate-регистрацийInstanceActivator
— активатор для особого типа регистраций, когда в контейнер добавляется уже готовый экземпляр объекта, созданный вне контейнера. Понятно, что такие регистрации в процессе валидации проверять не нужно, поскольку объект уже создан, и возникновение вышеописанных проблем невозможно.
Написать код для валидации ReflectionActivator
-а в целом не трудно. Ранее мы уже обсудили общий подход к проверке Reflection-регистраций, поэтому останавливаться на этом не будем и сосредоточимся на исследовании Delegate
-регистраций и DelegateActivator
-а. Отмечу только, что с кодом получившейся реализаций проверок для ReflectionActivator
-а можно ознакомиться по ссылке в конце статьи.
Что считаем зависимостью
Итак, Delegate-регистрации. Для начала определимся — что мы считаем зависимостями в случае с резолвом сервиса через делегат с точки зрения контейнера.
Сперва может показаться, что зависимостями можно считать типы параметров конструктора, возвращаемого из делегата сервиса, однако такой подход не сработает. Рассмотрим повнимательнее уже знакомый пример. MemoryCache
формально зависит только от MemoryCacheOptions
, значения настроек которого берутся из контейнера, и нетрудно догадаться, что если забыть добавить в контейнер ISettings
, то резолв MemoryCache
упадет с ошибкой. Поэтому фактически в данном примере MemoryCache
зависит не от MemoryCacheOptions
, а от наличия в контейнере регистрации ISettings
:
builder
.Register<MemoryCache>(ctx =>
{
var settings = ctx.Resolve<ISettings>();
return new MemoryCache(
new MemoryCacheOptions()
{
SizeLimit = settings.CacheSize,
ExpirationScanFrequency = settings.CacheScanFrequency,
});
});
И тут уже можно сделать важное наблюдение — на самом деле нас интересует только то, какие типы сервисов могут потянуться из контейнера в процессе работы делегата. Это и будут зависимости данной Delegate-регистрации.
В то же время, если в предыдущем примере настройки кэша являются константами, либо получаются независимо от контейнера, то в этом случае правильно считать, что зависимостей у MemoryCache
вообще нет, так как делегат не взаимодействует с контейнером при создании экземпляра сервиса:
builder
.Register<MemoryCache>(ctx =>
{
var cacheSize = Environment.GetEnvironmentVariable("CACHE_SIZE");
return new MemoryCache(
new MemoryCacheOptions()
{
SizeLimit = cacheSize,
ExpirationScanFrequency = 5,
});
});
Здесь может возникнуть вопрос: а как должна себя вести проверка делегата, если приложение запустили в среде, где нет переменной «CACHE_SIZE»? В рамках обозначенных проблем правильный ответ — никак. Наша задача — проверить, что делегат не попытается разрезолвить незарегистрированный сервис, и убедиться, что Lifetime
каждой полученной из контейнера зависимости >= Lifetime
текущей Delegate-регистрации. В этом смысле проверка наличия переменной среды скорее относится к логике приложения, а не к механизмам контейнера. Поэтому даже если переменная отсутствует, вышеприведенную регистрацию будем считать полностью валидной.
Рассмотрим ещё такой пример. Тут класс DBProvider
формально зависит только от класса UserContext
и строки подключения. Строка подключения берется из настроек. В зависимости от окружения, в котором запускается код, из контейнера может потянуться один из классов TestSettings
, ProdSettings
или DefaultSettings
. Какой из классов мы в итоге должны считать зависимостью?
public class UserContext(Guid userId){}
public class DBProvider(UserContext userContext, string connectionString){}
...
public static class DIConfigurator
{
public static void ConfigureContainer(ContainerBuilder builder)
{
builder
.Register<DBProvider>(
сtx =>
{
var env = Environment.GetEnvironmentVariable("ENVIRONMENT");
ISettings settings = env switch
{
"Development" => ctx.Resolve<TestSettings>(),
"Production" => ctx.Resolve<ProdSettings>(),
_ => ctx.Resolve<DefaultSettings>(),
};
var userContext = GetUserContext(ctx);
return new DBProvider(userContext, settings.ConnectionString);
});
}
public static UserContext GetUserContext(IComponentContext ctx)
{
var httpRequest = ctx.Resolve<HttpRequest>();
return new UserContext(httpRequest.Cookies["userId"]);
}
}
Правильный ответ — все три.
Аналогично предыдущему примеру — значение переменной среды и конструкция switch
относятся к логике приложения, а не контейнера, поэтому в данном случае проверка валидации должна требовать наличия регистрации каждого из 3 типов в контейнере на момент сборки.
Кроме того, по-хорошему, в делегатах контейнера вообще не должно быть никаких условий и сложной логики. Например, в данном случае логику, связанную с настройками, лучше было бы вынести внутрь особой реализации ISettings
, которая в зависимости от установленного в конструкторе флага(environment
) возвращала бы значение из той или иной реализации.
Также обратим внимание, что объект класса UserContext
мы получаем из вспомогательного метода GetUserContext
, внутри которого также есть резолв HttpRequest
, который тоже нужно не забыть учесть. Таким образом, у нашей регистрации DBProvider
тут 4 зависимости: TestSettings
, ProdSettings
, DefaultSettings
и HttpRequest.
Подведем итог. В рамках нашего решения зависимостями Delegate-регистрации будем считать типы всех сервисов, которые потенциально могут потянуться из контейнера в процессе работы делегата. Независимо от ветвлений, проверок и прочих конструкций в коде.
Механизм получения типов зависимостей
Получение зависимостей сервиса, добавленных через делегат — задача нетривиальная. Сперва подумаем: как мы сами понимаем, какие сервисы будут тянуться из контейнера внутри делегата? Мне, например, для этого приходится изучать код делегата, а также вложенных вызовов, и глазами искать вызовы Resolve
. Звучит как утомительная, но вполне простая и понятная задача.
Почему бы в таком случае не попробовать автоматизировать этот процесс? То есть нам нужно каким-то образом научить контейнер анализировать код каждого из своих делегатов и находить вызовы нужных методов.
Посмотрим на DelegateActivator
. Сам объект делегата внутри активатора находится в приватном поле _activationFunction
. Данный делегат будет вызван при резолве сервиса с переданным внутрь IComponentContext
, из которого при необходимости будут тянуться зависимости.
Как у любого делегата в C# — у него есть следующие свойства:
MethodInfo Method
— информация о методе, который нужно вызватьobject? Target
(опционально) — объект, в контексте которого метод вызывается
Анализ кода делегата
Обратим внимание, что у объекта MethodInfo
есть метод methodInfo.GetMethodBody().GetILAsByteArray();
, который позволяет получить байты CIL-инструкций данного метода. Анализируя эти инструкции, как раз и можно найти все нужные вызовы. Рассмотрим регистрацию сервиса C из нашего первого примера:
containerBuilder
.Register<C>(ctx =>
{
var aHiddenDep = ctx.Resolve<A>();
var bDep = ctx.Resolve<B>();
return new C(aHiddenDep.someString, bDep);
}).SingleInstance();
Целевой метод делегата будет выглядеть следующим образом. Можно заметить, что когда для резолва используется Generiс
-метод(ctx.Resolve<T>()
) , то тип сервиса явно фиксируется в коде при вызове (инструкции IL_0001
и IL_0007
), соответственно, анализируя такую инструкцию, можно однозначно определить тип зависимости:
.method assembly hidebysig instance class Experiment.C
'<Main>b__0_0'(class [Autofac]Autofac.IComponentContext ctx) cil managed
{
.maxstack 2
.locals init (class Experiment.B V_0)
IL_0000: ldarg.1
IL_0001: call !!0 [Autofac]Autofac.ResolutionExtensions::
Resolve<class Experiment.A>(class [Autofac]Autofac.IComponentContext)
IL_0006: ldarg.1
IL_0007: call !!0 [Autofac]Autofac.ResolutionExtensions::
Resolve<class Experiment.B>(class [Autofac]Autofac.IComponentContext)
IL_000c: stloc.0
IL_000d: ldfld string Experiment.A::someString
IL_0012: ldloc.0
IL_0013: newobj instance void Experiment.C::
.ctor(string, class Experiment.B)
IL_0018: ret
}
Звучит несложно. Достаточно просто проанализировать каждую инструкцию в целевом методе делегата, не забывая рекурсивно запускать проверки на вложенных вызовах других методов, чтобы проверить весь callstack
. В процессе нужно искать вызовы метода Resolve
и добавлять в результат каждый новый тип сервиса, доставаемый из контейнера.
Обработка «замкнутых» делегатов
Однако попытавшись запустить такой алгоритм на методе объекта _activationFunction
, мы получим пустой список зависимостей, а инструкции метода будут совсем не похожи на то, что мы ожидаем увидеть.
Дело в том, что в _activationFunction
находится не целевая функция, которую передавали при регистрации, а обертка или цепочка оберток, вложенных друг в друга. К примеру, дефолтная регистрация делегата в Autofac
выглядит примерно так (пользовательская функция передается в параметр @delegate
):
public static IRegistrationBuilder<T, SimpleActivatorData, SingleRegistrationStyle> Register<T>(
this ContainerBuilder builder, Func<IComponentContext, T> @delegate) where T : notnull
{
if (@delegate == null) throw new ArgumentNullException(nameof(@delegate);
//обертка №1
return builder.Register((c, p) => @delegate(c));
}
Пройдя дальше по callstack-у, можно увидеть, что позже она оборачивается ещё раз (здесь в переменной @delegate
уже будет (c, p) => @delegate(c)
):
public static IRegistrationBuilder<T, SimpleActivatorData, SingleRegistrationStyle>ForDelegate<T(
Func<IComponentContext, IEnumerable<Parameter>, T> @delegate) where T : notnull
{
return new RegistrationBuilder<T, SimpleActivatorData, SingleRegistrationStyle>(
new TypedService(typeof(T)),
//обертка №2
new SimpleActivatorData(new DelegateActivator(typeof(T), (c, p) => @delegate(c, p))),
new SingleRegistrationStyle());
}
То есть в данном случае точкой входа у нас будет обертка, в которой замкнута другая обертка, в которой уже замкнут наш целевой делегат.
Для того чтобы понять, каким образом можно добраться до инструкций целевого делегата, придется разобраться в том, как размещаются в памяти анонимные делегаты.
В данном случае, когда CIL-компилятор видит, что в обертке замыкается объект @delegate
, то генерируется анонимный класс с соответствующим полем и instance-методом (то есть методом, который вызывается из экземпляра объекта).
То есть, по сути, анонимный делегат-обертка (c, p) => @delegate(c, p)
существует только на уровне синтаксиса C#, и можно сказать, что в момент компиляции в CIL он превращается в следующий класс:
public class AutoGeneratedClass001<T>
{
public Func<IComponentContext, IEnumerable<Parameter>, T> @delegate;
public T AutoGeneratedMethod001(
IComponentContext ctx,
IEnumerable<Parameter> parameters)
{
return @delegate(ctx, parameters);
}
}
И тогда строчку new SimpleActivatorData(new DelegateActivator(typeof(T), (c, p) => @delegate(c, p)))
можно представить таким образом:
...
var autoGeneratedClass = new AutoGeneratedClass001()
{
@delegate = @delegate
};
new DelegateActivator(typeof(T), autoGeneratedClass.AutoGeneratedMethod001)
...
Теперь посмотрим на сами CIL-инструкции этого сгенерированного класса, который можно найти в Autofac.dll
. В моей версии пакета он получил имя <>c__DisplayClass0_0`1'
:
//автоматически сгенерированный анонимный класс из делегата с замыканием: (c, p) => @delegate(c, p)
.class auto ansi sealed nested private beforefieldinit '<>c__DisplayClass0_0`1'<T>
extends [netstandard]System.Object
{
//поле в котором лежит "замкнутый" делегат
.field public class [netstandard]System.Func`3<
class Autofac.IComponentContext,
class [netstandard]System.Collections.Generic.IEnumerable`1<class Autofac.Core.Parameter>,
!T> delegate
//автоматически сгенерированный анонимный метод
.method assembly hidebysig instance object
'<ForDelegate>b__0'(class Autofac.IComponentContext c,
class [netstandard]System.Collections.Generic.IEnumerable`1<class Autofac.Core.Parameter> p) cil managed
{
.maxstack 8
//в instance-метод 0-ым аргументом передается инстанс объекта данного класса,
//т.е. объекта, в контексте которого запускается метод(объект по ссылке Target)
//ldarg - загружает ссылку на этот объект в стек вычислений
IL_0000: ldarg.0
//ldfld {тип поля} {тип класса}::{имя поля}
//забирает объект из стека вычислений и выполняет поиск значения поля с указанными типом и именем
//результат помещает в стек вычислений
IL_0001: ldfld
//тип поля(Func<IComponentContext, IEnumerable<Parameter>, T>)
class [netstandard]System.Func`3<
class Autofac.IComponentContext,
class [netstandard]System.Collections.Generic.IEnumerable`1<class Autofac.Core.Parameter>,
!0>
//тип класса
class Autofac.Builder.RegistrationBuilder/'<>c__DisplayClass0_0`1'<!T>::
//имя поля
delegate
//загрузка в стек параметров IComponentContext и IEnumerable<Parameter>
IL_0006: ldarg.1
IL_0007: ldarg.2
//вызывает находящийся в стеке делегат, передав в него IComponentContext и IEnumerable<Parameter>
IL_0008: callvirt instance !2 class [netstandard]System.Func`3<
class Autofac.IComponentContext,
class [netstandard]System.Collections.Generic.IEnumerable`1<class Autofac.Core.Parameter>,
!T>::
Invoke(!0,!1)
IL_000d: box !T
IL_0012: ret
}
}
Изучив вышеприведенный CIL-код нашего сгенерированного метода, можно заметить, что наш замкнутый делегат вызывается с помощью инструкции IL_0008: callvirt
. Но анализируя только её, мы не сможем получить информацию о вызываемом методе, так как операнд инструкции — это объект делегатного типа, ссылка на который будет взята из стека в процессе выполнения этих инструкций.
Что такое стэк в контексте CLI?
Стоит напомнить, что выполнение CIL-кода работает по принципу стековой машины. На каждый вызываемый метод создается виртуальный стек выполнения (evaluation stack), через который и происходит взаимодействие с полями классов, переданными в метод аргументами, ссылками на объекты и т.д.
Тут самое время вспомнить, что мы анализируем код метода объекта Delegate
, и соответственно, у нас есть доступ к объекту Target
, то есть к объекту, в контексте которого вызывается данный метод (в данном случае это будет экземпляр сгенерированного анонимного класса). Получается, когда мы видим вызов делегата, то можно попробовать найти среди предыдущих инструкций загрузку поля аналогичного типа из инстанса объекта. В данном случае нас интересует инструкция IL_0001
— из неё мы можем узнать имя поля, в котором «замкнут» вызываемый делегат.
Дальше останется только найти в объекте Target
поле с таким же типом и именем. По итогу мы получим ссылку на объект замкнутого делегата и сможем рекурсивно запустить все проверки уже на нем. Таким образом можно «разворачивать» цепочки любого уровня вложенности до тех пор, пока в получаемых делегатах Target!=null
.
В упрощенном виде карта памяти с итоговым DelegateActivator
-ом будет выглядеть примерно так. Начинаем анализ с _activationFunction
и поэтапно спускаемся по callstack
-у до метода целевого делегата:

Конечно, данный подход будет хорошо работать только на относительно простых делегатах. Например, мы тут не пытаемся проверить, что в момент вызова делегата в стеке будет лежать именно делегат из поля объекта, ведь в рамках метода может использоваться несколько делегатов одинакового типа. А исходим из того, что если есть вызов делегата типа Т и выше по коду метода есть загрузка в стек делегата из поля инстанса такого же типа, то вызывается именно он. Тем не менее для наших целей (обработка простых оберток с замыканием) описанный способ будет работать достаточно хорошо.
Итоговый алгоритм поиска зависимостей для делегатов
Имеем объект типа
Delegate
(изначально_activationFunction
изDelegateActivator
).Получаем инструкции
Method
.Ищем вызовы
Generic
-методаResolve
и добавляем возвращаемые значения в список DI-зависимостей, найденных в данном методе.Если встречаем вызов метода, то запускаем проверки на нем с шага 2. Результат объединяем с текущим списком найденных зависимостей.
Если встречаем вызов делегата, то пробуем найти соответствующий объект в
Target
. В случае успеха запускаем поиск на нем с шага 1. Результат также объединяем с текущим списком найденных зависимостей.Возвращаем список найденных найденных зависимостей в текущем и вложенных методах.
Оптимизации
Отслеживаем глубину
callstack
-а, чтоб не зарываться очень глубоко. Применительно к DI-контейнерам резолвы зависимостей редко опускаются ниже 3-4 уровняcallstack
-а относительно исходногоDelegate
.Кэшируем результаты анализа каждого метода, чтоб повторно не проверять код одних и тех же методов.
Если
Method
относится к системной библиотеке или не выходит получить инструкции, то останавливаем дальнейшие проверки и возвращаем пустой список зависимостей.
Ограничения
Стоит отметить, что описанный алгоритм поиска зависимостей не сработает, если тип сервиса передается как параметр методу Resolve
. Например так:
containerBuilder
.Register<C>(ctx =>
{
var aType = typeof(A)
var aHiddenDep = (A)ctx.Resolve(aType);
var bDep = ctx.Resolve<B>();
return new C(aHiddenDep.someString, bDep);
}).SingleInstance();
В этом случае, чтобы установить зависимость C
-> A
, придется придумать более хитрый алгоритм анализа инструкций. Поэтому здесь явно обозначим, что проверки будут хорошо работать только при использовании Generic
-методов Resolve
в делегатах. Ну, по своему опыту могу сказать, что почти никогда не встречал резолвы не через Generic
-и.
Заключение
Итак, мы рассмотрели способ определения зависимостей для Delegate-регистраций. Дальше остается только написать код для проверок найденных зависимостей и отладить решение.
Из главных преимуществ описанного подхода можно выделить следующее. Во-первых, мы не вызываем делегаты для проверки зависимостей, а только анализируем сами инструкции. Из этого следует второе преимущество — для корректной работы проверок контейнер не нужно никак подготавливать или изменять. Достаточно просто добавить вызов ValidateOnBuild
в любом месте конфигурации контейнера, что автоматически приведет к запуску всех проверок сразу после сборки.
С кодом получившейся библиотеки можно ознакомиться по ссылке.
Stefanio
Почему не Roslyn анализатор?
aleksbtz Автор
С
Roslyn
я не работал и только поверхностно знаю о его возможностях, но, насколько мне известно, обычно он применяется для статического анализа исходного C# кода и написания различных расширений к IDE для подсветки синтаксиса, добавления ошибок, предупреждений и т.д. О том, что с помощью него можно анализировать код непосредственно в рантайме, я не слышал.Если же говорить про статический анализ, то вот несколько причин, почему я от него отказался для решения описанной проблемы:
Код конфигурации контейнера не обязательно будет сосредоточен в одном проекте/солюшене. В частности, в процессе конфигурации могут вызываться методы из внешних
.dll
сборок, исходного C# кода которых у нас нет.Даже в рамках одного проекта/солюшена код конфигурации обычно разносят по модулям и вспомогательным методам, которые могут переиспользоваться разными контейнерами в разных проектах. В этом случае определить, какая именно цепочка вызовов будет использоваться для конфигурации того или иного контейнера, и какие сервисы в нем в итоге зарегистрируются, с помощью статического анализа будет не просто.
Часто в реальных проектах разработчик не владеет контейнером, и в этом случае непонятно, что считать «точкой входа» для начала анализа. К примеру, если разрабатывается
WebAPI
на шаблонах .NET, то контейнер создается, частично заполняется и собирается средствами фреймворка, а мы же пишем только условный методvoid ConfigureServices(IServiceCollection services)
, который вызывается автоматически, и в котором в контейнер добавляются пользовательские зависимости.