Зависимости. Часть 2
Это вторая часть статьи о зависимостях и AOP в .NET. Первая часть посвящена DIP, паттернам DI, IoC и сквозной функциональности.
IoC-фреймворки: обзор и сравнение
Мир IoC-контейнеров в .NET за последнее десятилетие сильно изменился. Ninject, Unity и Spring.NET уступили место более лёгким и быстрым решениям. Тем не менее базовые концепции — регистрация, разрешение, перехват — остаются неизменными.
Классические фреймворки (историческая перспектива)
| IoC-фреймворк | Преимущества | Недостатки |
|---|---|---|
| Ninject | Перехват, лёгкий (~122 KB), расширяемый (MVC, WCF) | Медленная производительность |
| Castle Windsor | Полный, понимает Decorator, типизированные фабрики, коммерческая поддержка | Местами quirky API |
| Unity | Перехват, хорошая документация, консистентный API | Слабое управление lifetime, нет конвенционного API |
| Spring.NET | Перехват, обширная документация, коммерческая поддержка | XML-центричный, нет конвенционного API, ограниченное Auto-Wiring |
| Autofac | Простой в освоении API, коммерческая поддержка | Нет перехвата, частичная поддержка custom lifetimes |
Актуальные фреймворки (2026)
| IoC-фреймворк | Статус | Когда использовать |
|---|---|---|
| Microsoft.Extensions.DI | ✅ Встроен в .NET, активно развивается | Большинство новых проектов |
| Autofac | ✅ Активен, богатый API | Сложные сценарии с модулями |
| Castle Windsor | ✅ Активен | Легаси-проекты, enterprise с перехватом |
| Scrutor | ✅ Активен | Конвенционная регистрация поверх MS DI |
| DryIoc | ✅ Самый быстрый по бенчмаркам | Highload, performance-critical |
| Ninject | ⚠️ Медленное развитие | Только легаси |
| Unity | ❌ Заброшен Microsoft | Миграция на MS DI |
| Spring.NET | ❌ Не развивается | Миграция |
Производительность IoC-контейнеров
Вопрос производительности IoC-контейнеров часто переоценивается.
«В 99% сценариев использования вы никогда не нагрузите ваш IoC-контейнер до пределов, которые показывают бенчмарки.»
Тем не менее порядок цифр важно понимать. Современные бенчмарки (2024) показывают:
| Контейнер | Singleton (ops/sec) | Transient (ops/sec) |
|---|---|---|
| DryIoc | ~200M | ~80M |
| MS DI | ~150M | ~60M |
| Autofac | ~50M | ~20M |
| Castle Windsor | ~30M | ~15M |
| Ninject | ~5M | ~2M |
Практический вывод: если ваш узкий bottleneck — разрешение зависимостей, а не БД, сетевые вызовы или бизнес-логика, у вас очень необычное приложение. Выбирайте контейнер по удобству API и экосистеме, а не по бенчмаркам.
Composition Root
Composition Root — единственное место в приложении, где собирается весь граф зависимостей. Это архитектурный паттерн, а не просто «место для конфигурации».
Аналогия: корень растения — это и точка входа питательных веществ, и место, откуда вырастает вся структура. IoC-контейнер — корень вашего приложения. Всё строится от него.
Правила хорошего Composition Root
- Один на приложение. Не несколько мест, не «ещё один контейнер для модуля».
- Как можно ближе к точке входа.
в .NET 6+,1
Program.cs
в более ранних версиях.1
Startup.cs
- Не Service Locator. Контейнер не должен передаваться внутрь бизнес-логики.
- Явная регистрация предпочтительна конвенционной для критических компонентов.
«Явное - всегда лучше, чем неявное.»
Composition Root в .NET 8+ (минимальный API)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Program.cs — это и есть Composition Root
var builder = WebApplication.CreateBuilder(args);
// --- Инфраструктура ---
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddStackExchangeRedisCache(opt =>
opt.Configuration = builder.Configuration["Redis:Connection"]);
// --- Репозитории ---
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
// Декораторы через Scrutor (кеширование прозрачно)
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
// --- Бизнес-логика ---
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// --- Pipeline behaviors (AOP через MediatR) ---
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
// --- Внешние сервисы ---
builder.Services.AddHttpClient<IShippingClient, FedExShippingClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["FedEx:BaseUrl"]!));
// --- Современные зависимости ---
builder.Services.AddScoped<IAIAssistant, OpenAIAssistant>();
builder.Services.AddFeatureManagement();
var app = builder.Build();
Composition Root с Autofac (модульная регистрация)
Autofac вводит концепцию модулей — именованных групп регистраций. Это удобно для больших приложений:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Модуль для инфраструктуры
public class InfrastructureModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<EfOrderRepository>()
.As<IOrderRepository>()
.InstancePerLifetimeScope();
builder.RegisterType<EfProductRepository>()
.As<IProductRepository>()
.InstancePerLifetimeScope()
.EnableInterfaceInterceptors() // перехват!
.InterceptedBy(typeof(CacheInterceptor));
builder.RegisterType<CacheInterceptor>().AsSelf();
}
}
// Модуль для бизнес-логики
public class ApplicationModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterAssemblyTypes(ThisAssembly)
.Where(t => t.Name.EndsWith("Service"))
.AsImplementedInterfaces()
.InstancePerLifetimeScope();
}
}
// Program.cs
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(container =>
{
container.RegisterModule<InfrastructureModule>();
container.RegisterModule<ApplicationModule>();
});
Паттерн Register → Resolve → Release
Паттерн RRR описывает обязательный порядок работы с IoC-контейнером:
Register — настройка контейнера: какие классы он знает, как маппятся абстракции на конкретные типы, как некоторые классы связаны между собой.
Resolve — разрешение корневого компонента. Один граф объектов строится из одного запроса на один тип.
Release — освобождение компонентов. Все графы объектов, разрешённые на предыдущей фазе, должны быть освобождены, когда они больше не нужны. Это особенно важно для -компонентов. 1
IDisposable
1
2
3
4
5
6
7
8
9
10
11
12
13
// Register (один раз при старте)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
var app = builder.Build();
// Resolve (на каждый HTTP-запрос — автоматически фреймворком)
app.MapPost("/orders", async (CreateOrderCommand cmd, IOrderService svc) =>
await svc.CreateAsync(cmd));
// Release — ASP.NET Core делает это автоматически в конце scope (запроса)
// Все IDisposable в Scoped и Transient будут освобождены
app.Run();
Антипаттерн: Service Locator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ Service Locator — антипаттерн
public class OrderService
{
public async Task ProcessAsync(int orderId)
{
// Прямое обращение к контейнеру изнутри бизнес-логики
var repo = ServiceLocator.Current.GetInstance<IOrderRepository>();
var order = await repo.GetByIdAsync(orderId);
}
}
// ✅ Правильно — зависимости передаются снаружи
public class OrderService
{
private readonly IOrderRepository _repo;
public OrderService(IOrderRepository repo) => _repo = repo;
public async Task ProcessAsync(int orderId)
{
var order = await _repo.GetByIdAsync(orderId);
}
}
Почему Service Locator — антипаттерн?
- Зависимости скрыты — невозможно понять из конструктора, что нужно классу
- Тестирование усложнено — нужно настраивать глобальный локатор
- Нарушение принципа явных зависимостей
Перехват (Interception)
Перехват — механизм, позволяющий добавить поведение (аспект) к методу без изменения его кода. Реализуется через Dynamic Proxy — класс-обёртку, который генерируется во время выполнения и реализует тот же интерфейс, что и целевой объект.
Схема работы:
- Вызывающий код запрашивает
у контейнера1
IMyInterface
- Контейнер возвращает
вместо1
ProxyInterceptor : IMyInterface
1
MyClass : IMyInterface
- Proxy перехватывает вызов, выполняет аспект (логирование, кеш, авторизацию)
- Proxy вызывает реальный метод и возвращает результат
Castle Windsor: Dynamic Proxy Interception
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Определяем interceptor
public class LoggingInterceptor : IInterceptor
{
private readonly ILogger _logger;
public LoggingInterceptor(ILogger logger) => _logger = logger;
public void Intercept(IInvocation invocation)
{
_logger.LogInformation("Вызов: {Method}({Args})",
invocation.Method.Name,
string.Join(", ", invocation.Arguments));
var sw = Stopwatch.StartNew();
try
{
invocation.Proceed(); // вызов реального метода
_logger.LogInformation("Завершён: {Method} [{Elapsed}ms]",
invocation.Method.Name, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка в {Method}", invocation.Method.Name);
throw;
}
}
}
// Регистрация с перехватом
var container = new WindsorContainer();
container.Register(
Component.For<LoggingInterceptor>(),
Component.For<IOrderService>()
.ImplementedBy<OrderService>()
.Interceptors<LoggingInterceptor>()
);
// Использование — прозрачное, без изменений в коде
var service = container.Resolve<IOrderService>();
service.CreateOrder(cmd); // ← вызов автоматически логируется
Autofac: перехват через Castle DynamicProxy
1
2
3
4
5
6
7
8
// В Autofac перехват реализуется через интеграцию с Castle DynamicProxy
builder.RegisterType<OrderService>()
.As<IOrderService>()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(LoggingInterceptor), typeof(CacheInterceptor));
builder.Register(c => new LoggingInterceptor(c.Resolve<ILogger<LoggingInterceptor>>()));
builder.Register(c => new CacheInterceptor(c.Resolve<IMemoryCache>()));
Microsoft.Extensions.DI + DispatchProxy (встроенный)
Для тех, кто хочет перехват без дополнительных библиотек — из .NET Standard: 1
DispatchProxy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class LoggingProxy<T> : DispatchProxy where T : class
{
private T _decorated = null!;
private ILogger _logger = null!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
_logger.LogInformation("→ {Method}", targetMethod?.Name);
var result = targetMethod?.Invoke(_decorated, args);
_logger.LogInformation("← {Method}", targetMethod?.Name);
return result;
}
public static T Create(T decorated, ILogger logger)
{
var proxy = Create<T, LoggingProxy<T>>() as LoggingProxy<T>;
proxy!._decorated = decorated;
proxy!._logger = logger;
return (proxy as T)!;
}
}
// Использование
services.AddScoped<IOrderService>(sp =>
LoggingProxy<IOrderService>.Create(
sp.GetRequiredService<OrderService>(),
sp.GetRequiredService<ILogger<OrderService>>()
));
IL Code Weaving
IL Weaving — принципиально иной подход по сравнению с Dynamic Proxy. Аспекты добавляются не во время выполнения, а во время сборки (post-compile step):
- Компилятор создаёт обычную сборку
1
.dll / .exe
- AOP Post Processor (PostSharp, Metalama) читает сборку, находит аннотированные методы
- Post Processor вшивает код аспектов прямо в IL-инструкции
- На выходе — готовая сборка с уже «вплетёнными» аспектами
Преимущества перед Dynamic Proxy:
- Нет overhead от создания прокси-объекта в runtime
- Работает с не-интерфейсными классами и даже статическими методами
- Перехват
и1
virtual
методов1
non-virtual
- Compile-time проверка корректности аспектов
Недостатки:
- Усложняет процесс сборки
- Магия в коде: глядя на IL — видишь не то, что писал
- Платная лицензия для серьёзного использования (PostSharp)
PostSharp и Metalama
PostSharp — наиболее зрелый IL Weaver для .NET, существует с 2004 года. В 2022 году те же авторы выпустили Metalama — современного преемника, работающего на Roslyn Source Generators вместо IL Weaving.
PostSharp: OnMethodBoundaryAspect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Определяем аспект
[Serializable]
public class LoggingAspect : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs args)
{
Console.WriteLine($"→ Вход: {args.Method.Name}");
args.MethodExecutionTag = Stopwatch.StartNew();
}
public override void OnExit(MethodExecutionArgs args)
{
var sw = (Stopwatch)args.MethodExecutionTag;
Console.WriteLine($"← Выход: {args.Method.Name} [{sw.ElapsedMilliseconds}ms]");
}
public override void OnException(MethodExecutionArgs args)
{
Console.WriteLine($"✗ Ошибка: {args.Method.Name} — {args.Exception.Message}");
args.FlowBehavior = FlowBehavior.Continue; // подавить исключение
}
}
// Применяем к методу
public class OrderService
{
[LoggingAspect]
public Order CreateOrder(CreateOrderCommand cmd)
{
// чистый бизнес-код, без единой строки логирования
return new Order(cmd.CustomerId, cmd.Items);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// OnExceptionAspect — специализированный аспект для исключений
[Serializable]
public class RetryAspect : OnExceptionAspect
{
public int Attempts { get; set; } = 3;
public override void OnException(MethodExecutionArgs args)
{
if (args.Exception is TransientException && --Attempts > 0)
{
Thread.Sleep(200);
args.FlowBehavior = FlowBehavior.Retry;
}
}
}
public class ExternalApiClient
{
[RetryAspect(Attempts = 5)]
public async Task<ApiResponse> CallAsync(string endpoint)
{
// сеть может упасть — аспект обработает повторные попытки
return await _httpClient.GetFromJsonAsync<ApiResponse>(endpoint);
}
}
Multicast: применить аспект ко всей сборке
1
2
3
4
5
6
7
8
// AssemblyInfo.cs — аспект применяется ко всем public-методам в сборке
[assembly: LoggingAspect(
AttributeTargetTypes = "MyApp.Services.*",
AttributeTargetMemberAttributes = MulticastAttributes.Public)]
[assembly: RetryAspect(
AttributeTargetTypes = "MyApp.Infrastructure.Clients.*",
Attempts = 3)]
Metalama (2026): Roslyn-based, современная замена
Metalama работает на уровне Roslyn, а не IL, что даёт лучшую интеграцию с IDE и compile-time диагностику:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Аспект логирования на Metalama
public class LogAttribute : OverrideMethodAspect
{
public override dynamic? OverrideMethod()
{
var methodName = meta.Target.Method.Name;
Console.WriteLine($"→ {methodName}({string.Join(", ", meta.Target.Parameters.Values)})");
try
{
var result = meta.Proceed();
Console.WriteLine($"← {methodName} = {result}");
return result;
}
catch (Exception ex)
{
Console.WriteLine($"✗ {methodName}: {ex.Message}");
throw;
}
}
}
// Применяется так же, как атрибут
public class OrderService
{
[Log]
public Order CreateOrder(CreateOrderCommand cmd) => new Order(cmd);
}
Metalama vs PostSharp:
| PostSharp | Metalama | |
|---|---|---|
| Механизм | IL Weaving (post-compile) | Roslyn Source Generators (compile-time) |
| IDE поддержка | Хорошая | Отличная (Roslyn-native) |
| Диагностика | Runtime | Compile-time |
| Лицензия | Платная | Бесплатный tier + платный |
| .NET совместимость | .NET Framework + .NET | .NET 6+ |
Frameworks для AOP: полная карта (2026)
Через перехват (Runtime Proxy)
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────┐
│ RUNTIME INTERCEPTION (Dynamic Proxy) │
├────────────────┬────────────────┬───────────────────┤
│ Castle Windsor│ Autofac │ DispatchProxy │
│ (встроенный) │ (+Castle DP) │ (встроен в .NET) │
└────────────────┴────────────────┴───────────────────┘
Плюсы: прост в настройке, работает через интерфейс
Минусы: только virtual/interface методы, небольшой overhead
Через IL Weaving (Compile-time)
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────┐
│ IL WEAVING (Build-time) │
├────────────────────────┬────────────────────────────┤
│ PostSharp │ Metalama │
│ (классический) │ (Roslyn, современный) │
└────────────────────────┴────────────────────────────┘
Плюсы: любые методы, нет runtime overhead, быстрее
Минусы: магия в коде, сложнее дебажить, платно
Через Pipeline (Modern .NET)
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────┐
│ PIPELINE / DECORATOR PATTERN │
├──────────────┬──────────────┬────────────────────── ┤
│ ASP.NET Core│ MediatR │ Scrutor Decorators │
│ Middleware │ Behaviors │ │
└──────────────┴──────────────┴───────────────────────┘
Плюсы: явный, тестируемый, без зависимостей
Минусы: нужно писать руками, больше кода
Когда что выбирать
| Сценарий | Рекомендация |
|---|---|
| Новый ASP.NET Core проект | MS DI + Scrutor + MediatR Behaviors |
| Нужен перехват без PostSharp | Castle Windsor или Autofac + Castle DynamicProxy |
| Аудит и compliance в финансовой системе | Metalama (compile-time гарантии) |
| Legaси-проект на .NET Framework | PostSharp или Castle Windsor |
| CQRS и команды/запросы | MediatR Pipeline Behaviors |
| Простое кеширование | Decorator Pattern + Scrutor |
| Высокая нагрузка | DryIoc + минимальные аспекты |
Практический пример: полный стек
Соберём вместе: MS DI + Autofac + MediatR + Декоратор + Castle Interceptor для типичного enterprise-сервиса.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 1. Интерфейс и реализация
public interface IProductService
{
Task<ProductDto> GetProductAsync(int id);
Task<IReadOnlyList<ProductDto>> SearchAsync(string query);
}
public class ProductService : IProductService
{
private readonly IProductRepository _repo;
private readonly IAIAssistant _ai;
public ProductService(IProductRepository repo, IAIAssistant ai)
{
_repo = repo;
_ai = ai;
}
public async Task<ProductDto> GetProductAsync(int id)
{
var product = await _repo.GetByIdAsync(id)
?? throw new NotFoundException($"Product {id} not found");
return product.ToDto();
}
public async Task<IReadOnlyList<ProductDto>> SearchAsync(string query)
{
// AI-enhanced search
var enrichedQuery = await _ai.CompleteAsync(
$"Expand search query with synonyms: {query}");
return (await _repo.SearchAsync(enrichedQuery))
.Select(p => p.ToDto())
.ToList();
}
}
// 2. Декоратор кеширования (AOP через Scrutor)
public class CachedProductService : IProductService
{
private readonly IProductService _inner;
private readonly IMemoryCache _cache;
private static readonly TimeSpan _ttl = TimeSpan.FromMinutes(5);
public CachedProductService(IProductService inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public Task<ProductDto> GetProductAsync(int id)
=> _cache.GetOrCreateAsync($"product:{id}",
entry => { entry.AbsoluteExpirationRelativeToNow = _ttl;
return _inner.GetProductAsync(id); });
public Task<IReadOnlyList<ProductDto>> SearchAsync(string query)
=> _inner.SearchAsync(query); // поиск не кешируем
}
// 3. Composition Root
builder.Services.AddMemoryCache();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.Decorate<IProductService, CachedProductService>();
// 4. MediatR Behavior для валидации (AOP для команд)
public class GetProductQuery : IRequest<ProductDto>
{
public int ProductId { get; init; }
}
public class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDto>
{
private readonly IProductService _service;
public GetProductQueryHandler(IProductService service) => _service = service;
public Task<ProductDto> Handle(GetProductQuery request, CancellationToken ct)
=> _service.GetProductAsync(request.ProductId);
}
// 5. Endpoint — получает уже кешированный и логируемый результат
app.MapGet("/products/{id}", async (int id, IMediator mediator) =>
await mediator.Send(new GetProductQuery { ProductId = id }));
Ссылки
- Castle Windsor Documentation
- Autofac Interceptors
- Metalama Documentation
- PostSharp Documentation
- Scrutor GitHub
- DryIoc Benchmarks
- IoCPerformance Benchmark
- Mark Seemann, Steven van Deursen — Dependency Injection Principles, Practices, and Patterns (Manning, 2019), chapters 10–12