Разумное АОП для поклонников IOC-контейнеров

09a0666af2de4be8af0402b6cda6b5fe.jpgЯ очень не люблю boilerplate. Такой код скучно писать, уныло сопровождать и модифицировать. Совсем мне не нравится, когда тот самый bolierplate перемешан с бизнес-логикой приложения. Очень хорошо проблему описал krestjaninoff еще 5 лет назад. Если вы не знакомы с парадигмой AOP, прочитайте материал по ссылке, он очень хорошо раскрывает тему.

Как на момент прочтения этой статьи, так и сейчас меня не устраивают ни PostSharp ни Spring. Зато за прошедшее время в .NET появились другие инструменты, позволяющие вытащить «левый» код из бизнес-логики, оформить его отдельными переиспользуемыми модулями и описать декларативно, не скатываясь при этом в переписывание результирующего IL и прочую содомию.

Речь пойдет о проекте Castle.DynamicProxy и его применении в разработке корпоративных приложений. Я позаимствую пример у krestjaninoff, потому что аналогичный код я вижу с завидной регулярностью, и он доставляет мне много хлопот.

public BookDTO getBook(Integer bookId) throws ServiceException, AuthException {
  if (!SecurityContext.getUser().hasRight("GetBook"))
    throw new AuthException("Permission Denied");

  LOG.debug("Call method getBook with id " + bookId);
  BookDTO book = null;
  String cacheKey = "getBook:" + bookId;

  try {
    if (cache.contains(cacheKey)) {
      book = (BookDTO) cache.get(cacheKey);
    } else {
      book = bookDAO.readBook(bookId);
      cache.put(cacheKey, book);
    }
  } catch(SQLException e) {
    throw new ServiceException(e);
  }

  LOG.debug("Book info is: " + book.toString());
  return book;
}

Итак, в примере выше одна «полезная» операция — чтение книги из БД по Id. В нагрузку метод получил:

  • проверку авторизации
  • кеширование
  • обработку исключений
  • логирование

Справедливости ради стоит заметить, что проверку авторизации и прав доступа, кеширование уже мог бы обеспечить ASP.NET с помощью атрибутов [Authorize] и [OutputCache], однако по условию это «сферический web-сервис в вакууме» (к тому же написанный на Java), поэтому требования к нему неизвестны, как, впрочем, неизвестно используется ли ASP.NET, WCF или корпоративный фреймворк.

Задача


c8e2f2b00cbd4c9fa45dd5962cb25abe.jpg
  • переместить вспомогательный код в подходящее место
  • сделать его (код) переиспользуемым для других служб

В мире АОП есть специальный термин, для решаемой нами задачи: cross-cutting concerns. Выделяются base concerns — основную функциональность системы, например, бизнес-логику и cross-cutting concerns — второстепенную функциональность (логирование, проверка прав доступа, обработка ошибок и т.д.), необходимая тем не менее повсеместно в коде приложения.

Наиболее часто мне встречается и прекрасно иллюстрирует ситуацию cross-cutting concern такого вида:

dbContext.InTransaction(x => {
  //...
}, onFailure: e => {success: false, message: e.Message});

В нем уродливо абсолютно все, начиная от возрастающего code nesting, заканчивая перекладыванием функций проектировщика системы на прикладного программиста: нет никакой гарантии, что транзакции будут вызваны везде где нужно, непонятно как управлять уровнем изоляции транзакций и вложенными транзакциями и этот код будет скопирован сто тысяч раз где надо и не надо.

Решение


db6bc792e5f54e84b03c2256e6f21aab.jpg Castle.DynamicProxy предоставляет простое API для создания proxy-объектов на лету с возможностью доопределить то, чего нам не хватает. Этот подход используется в популярных изоляционных фреймворках: Moq и Rhino Mocks. Нам доступно два варианта:
  1. создание прокси по интерфейсной ссылке (в этом случае будет использоваться композиция)
  2. создание прокси для класса (будет создан наследник)

Основное отличие для нас будет заключаться в том, что для модификации методов класса, они должны быть объявлены доступными (public или protected) и виртуальными. Механизм аналогичен Lazy Loading у в Nhibernate или EF. Для обогащения функциональности в Castle.DynamicProxy используются «перехватчики» (Interceptor). Например, чтобы обеспечить транзакционностью все службы приложения можно написать Interceptor вроде такого:
     public class TransactionScoper : IInterceptor
    {
        public void Intercept(IInvocation invocation)
        {
            using (var tr = new TransactionScope())
            {
                invocation.Proceed();
                tr.Complete();                
            }
        }
    }

И создать прокси:
var generator = new ProxyGenerator();
var foo = new Foo();
var fooInterfaceProxyWithCallLogerInterceptor
            = generator.CreateInterfaceProxyWithTarget(foo, TransactionScoper);

Или с использованием контейнера:
var builder = new ContainerBuilder();
builder.Register(c => new TransactionScoper());
builder.RegisterType()
       .As()
       .InterceptedBy(typeof(TransactionScoper));

var container = builder.Build();
var willBeIntercepted = container.Resolve();

Аналогичным образом можно добавить обработку ошибок
    public class ErrorHandler : IInterceptor
    {
        public readonly TextWriter Output;

        public ErrorHandler(TextWriter output)
        {
            Output = output;
        }

        public void Intercept(IInvocation invocation)
        {
            try
            {
                Output.WriteLine($"Method {0} enters in try/catch block", invoca-tion.Method.Name);
                invocation.Proceed();
                Output.WriteLine("End of try/catch block");
            }
            catch (Exception ex)
            {
                Output.WriteLine("Exception: " + ex.Message);
                throw new ValidationException("Sorry, Unhandaled exception occured", ex);
            }
        }
    }

    public class ValidationException : Exception
    {
        public ValidationException(string message, Exception innerException)
            :base(message, innerException)
        { }
    }

Или логирование:
    public class CallLogger : IInterceptor
    {
        public readonly TextWriter Output;

        public CallLogger(TextWriter output)
        {
            Output = output;
        }

        public void Intercept(IInvocation invocation)
        {
            Output.WriteLine("Calling method {0} with parameters {1}.",
              invocation.Method.Name,
              string.Join(", ", invocation.Arguments.Select(a => (a ?? "").ToString()).ToArray()));

            invocation.Proceed();

            Output.WriteLine("Done: result was {0}.", invocation.ReturnValue);
        }
    }

Кеширование и многие другие операции. Отличительной особенностью данного подхода от реализации паттерна «декоратор» средствами ООП является возможность добавлять вспомогательную функциональность к любым типам без необходимости создавать наследников. Подход также решает проблему множественного наследования. Мы спокойно может добавить более одного перехватчика на каждый тип:
var fooInterfaceProxyWith2Interceptors
            = generator.CreateInterfaceProxyWithTarget(Foo, CallLogger, ErrorHandler);

Еще одной сильной стороной данного подхода является выделение сквозной функциональности из слоя бизнес-логики и лучшее отделение инфраструктурного кода от домена приложения.

Если в процессе регистрации нельзя точно сказать какие службы нужно проксировать, а какие — нет, то можно использовать атрибуты для получения информации в runtime (хотя этот подход и может привести к некоторым проблемам):

    public abstract class AttributeBased : IInterceptor
        where T:Attribute
    {
        public void Intercept(IInvocation invocation)
        {
            var attrs = invocation.Method
                .GetCustomAttributes(typeof(T), true)
                .Cast()
                .ToArray();

            if (!attrs.Any())
            {
                invocation.Proceed();
            }
            else
            {
                Intercept(invocation, attrs);
            }
        }

        protected abstract void Intercept(IInvocation invocation, params T[] attr);
    }

Минусы


Я вижу четыре объективных минуса данного подхода:
  1. Не интуитивность
  2. Пересечение с инфраструктурным кодом других фреймворков
  3. Зависимость от IOC-контейнера
  4. Производительность

Не интуитивность


Проще всего разобраться с таким структурированием кода людям, знакомым с концепциями функционального программирования. С изрязным количеством оговорок подход можно назвать напоминающим «композицию». Криво спроектированные перехватчики могут быть причиной изрядного количества не очевидных багов и проблем с производительностью.

Пересечение с инфраструктурным кодом других фреймворков


Как я говорил в начале, атрибуты Authorize и OutputCache уже есть в ASP.NET. В определенном смысле мы занимаемся велосипедостроительством. Подход больше подходит командам, для которых важно абстрагирование от конечной инфраструктуры выполнения. Кроме этого подход работает и в контексте частичного применения, а не «все или ничего». Никто не заставляется нас заново реализовывать проверку авторизации в AOP-стиле, если это не требуется.

Зависимость от IOC-контейнера


Для сервисного слоя минус практически отсутствует, если вы практикуете IOC/DI. В 99% случаев службы будут получены с помощью IOC-контейнера. Создание Entity и Dto обычно происходит явно, с помощью оператора new или маппера. Я думаю, что это правильное положение вещей и не вижу применения перехватчиков на уровне создания Entity или Dto. Я видел несколько примеров применения перехватчиков для заполнения служебных полей в Entity, но со временем от этого подхода всегда отказывались. Гораздо лучше, чтобы объект сам заботился о сохранности своего инварианта.

Производительность


Три предыдущих пункта я привел скорее для точности, чем из прагматических соображений. Я скорее отношу их к границам применимости подхода, а не к настоящим проблемам. По поводу производительности я не был столь уверен, поэтому решил сделать серию бенчмарков c помощью BenchmarkDotNet. С фантазией у меня было не очень, поэтому измерялось время получения случайного числа:
    public class Foo : IFoo
    {
        private static readonly Random Rnd = new Random();

        public double GetRandomNumber() => Rnd.Next();
    }

    public class Foo : IFoo
    {
        private static readonly Random Rnd = new Random();

        public double GetRandomNumber() => Rnd.Next();
    }

Исходники бенчмарков и примеры кода доступны на github. Очевидно, что за магию с рефлексией и динамической компиляцией приходится платить:
  1. Временем создания объекта: ~2,000 ns. Не принципиально, если службы создаются один раз, а за лайфтайм «протухающих» зависимостей, таких как контекст бд отвечает другой объект
  2. Временем выполнения операций: так-же примерно ~1,000 лишних наносекунд внутри Castle.DynamicProxy используется Reflection со всеми вытекающими последствиями.

В абсолютных значениях это довольно много, однако если код выполняется дольше 50 ns, например, происходит запись в БД или запрос по сети, то ситуация выглядит иначе:
public class Bus : Bar
    {
        public override double GetRandomNumber()
        {
            Thread.Sleep(100);
            return base.GetRandomNumber();
        }
    }

Host Process Environment Information:
BenchmarkDotNet=v0.9.8.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4710HQ CPU 2.50GHz, ProcessorCount=8
Frequency=2435775 ticks, Resolution=410.5470 ns, Timer=TSC
CLR=MS.NET 4.0.30319.42000, Arch=64-bit RELEASE [RyuJIT]
GC=Concurrent Workstation
JitModules=clrjit-v4.6.1080.0

Type=InterceptorBenchmarks  Mode=Throughput  GarbageCollection=Concurrent Workstation  
LaunchCount=1  WarmupCount=3  TargetCount=3  
Method Median StdDev
CreateInstance 0.0000 ns 0.0000 ns
CreateClassProxy 1,972.0032 ns 8.5611 ns
CreateClassProxyWithTarget 2,246.4208 ns 5.3436 ns
CreateInterfaceProxyWithTarget 2,063.6905 ns 41.9450 ns
CreateInterfaceProxyWithoutTarget 2,105.9238 ns 4.9295 ns
Foo_GetRandomNumber 11.0409 ns 0.1306 ns
Foo_InterfaceProxyGetRandomNumber 51.6061 ns 0.2764 ns
FooClassProxy_GetRandomNumber 9.0125 ns 0.1766 ns
BarClassProxy_GetRandomNumber 44.8110 ns 0.4770 ns
FooInterfaceProxyWithCallLoggerInterceptor_GetRandomNumber 1,756.8129 ns 75.4694 ns
BarClassProxyWithCallLoggerInterceptor_GetRandomNumber 1,714.5871 ns 25.2403 ns
FooInterfaceProxyWith2Interceptors_GetRandomNumber 2,636.1626 ns 20.0195 ns
BarClassProxyWith2Interceptors_GetRandomNumber 2,603.6707 ns 4.6360 ns
Bus_GetRandomNumber 100,471,410.5375 ns 113,713.1684 ns
BusInterfaceProxyWith2Interceptors_GetRandomNumber 100,539,356.0575 ns 89,725.5474 ns
CallLogger_Intercept 3,841.4488 ns 26.3829 ns
WriteLine 859.0076 ns 34.1630 ns
Думаю, если заменить Reflection на закешированные LambdaExpression можно добиться того, что разницы в производительности не будет совсем, но для этого нужно переписать DynamicProxy, добавить поддержку в популярные контейнеры (сейчас перехватчики точно поддерживаются из коробки Autofac и Castle.Windsor, про остальные не знаю). Сомневаюсь, что это произойдет в ближайшее время.

Поэтому, если в среднем ваши операции выполняются не менее чем 100 ms и три предыдущих минуса вас не пугают, «контейнерное AOP» в C# уже production ready.

Комментарии (5)

  • 11 июля 2016 в 11:14

    +1

    сейчас перехватчики точно поддерживаются из коробки Autofac и Castle.Windsor, про остальные не знаю

    В Unity interception просто свой.

    • 11 июля 2016 в 11:42

      +1

      Да, возможно. Unity уж больно медленный, поэтому много лет его не использую
      • 11 июля 2016 в 11:47

        +1

        Да я тоже его не использую, но для полноты картины.

  • 11 июля 2016 в 11:43

    0

    Не вижу проблемы в зависимости от IoC контейнера. Контейнер выбирается как раз за специфические фичи. Так же как и логгер собственно.
    • 11 июля 2016 в 11:48

      +1

      … и именно поэтому последующая смена одного или другого обходится дорого.

© Habrahabr.ru