Зависимости. Часть 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

  1. Один на приложение. Не несколько мест, не «ещё один контейнер для модуля».
  2. Как можно ближе к точке входа.
    1
    
    Program.cs
    
    в .NET 6+,
    1
    
    Startup.cs
    
    в более ранних версиях.
  3. Не Service Locator. Контейнер не должен передаваться внутрь бизнес-логики.
  4. Явная регистрация предпочтительна конвенционной для критических компонентов.

«Явное - всегда лучше, чем неявное.»

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. Вызывающий код запрашивает
    1
    
    IMyInterface
    
    у контейнера
  2. Контейнер возвращает
    1
    
    ProxyInterceptor : IMyInterface
    
    вместо
    1
    
    MyClass : IMyInterface
    
  3. Proxy перехватывает вызов, выполняет аспект (логирование, кеш, авторизацию)
  4. 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 (встроенный)

Для тех, кто хочет перехват без дополнительных библиотек —

1
DispatchProxy
из .NET Standard:

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. Компилятор создаёт обычную сборку
    1
    
    .dll / .exe
    
  2. AOP Post Processor (PostSharp, Metalama) читает сборку, находит аннотированные методы
  3. Post Processor вшивает код аспектов прямо в IL-инструкции
  4. На выходе — готовая сборка с уже «вплетёнными» аспектами

Преимущества перед 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 }));

Ссылки