Я очень не люблю 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 или корпоративный фреймворк.

Задача


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

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

Наиболее часто мне встречается и прекрасно иллюстрирует ситуацию cross-cutting concern такого вида:
dbContext.InTransaction(x => {
  //...
}, onFailure: e => {success: false, message: e.Message});

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

Решение


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<Foo>()
       .As<IFoo>()
       .InterceptedBy(typeof(TransactionScoper));

var container = builder.Build();
var willBeIntercepted = container.Resolve<IFoo>();

Аналогичным образом можно добавить обработку ошибок
    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<T> : IInterceptor
        where T:Attribute
    {
        public void Intercept(IInvocation invocation)
        {
            var attrs = invocation.Method
                .GetCustomAttributes(typeof(T), true)
                .Cast<T>()
                .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.
Поделиться с друзьями
-->

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


  1. lair
    11.07.2016 11:14
    +1

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

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


    1. marshinov
      11.07.2016 11:42
      +1

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


      1. lair
        11.07.2016 11:47
        +1

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


  1. Razaz
    11.07.2016 11:43

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


    1. lair
      11.07.2016 11:48
      +1

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


      1. Razaz
        11.07.2016 12:44

        Согласен. Но согласитесь глупо выбирать контейнер или логгер и не пользоваться его фичами, за которые его же и выбирали :) И эти компоненты обычно не меняются часто, если вообще меняются.


        1. lair
          11.07.2016 12:50
          +1

          Но согласитесь глупо выбирать контейнер или логгер и не пользоваться его фичами, за которые его же и выбирали

          Глупо. А вот не дать этим фичам просочиться в клиентский код — уже не глупо.


          И эти компоненты обычно не меняются часто, если вообще меняются.

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


          1. Razaz
            11.07.2016 12:59

            Что вы подразумеваете под просочиться и под клиентский код ;)?

            По второму пункту думал примерно так же как и вы. Абстракция над логгером прожила 3 года. Потом перешли на Serilog и она превратилась в тыкву :)


            1. marshinov
              11.07.2016 13:05

              Например, когда начинают использовать инъекции в property с атрибутом [Dependency]. Моя ненависть к Unity началась с этого.


              1. Razaz
                11.07.2016 13:08

                Нууу это ай ай ай конечно. Но абстракция от контейнера вас тут не спасет.

                Вот тут на почитать: https://simpleinjector.org/blog/2016/07/working-around-the-asp-net-core-di-abstraction/


            1. lair
              11.07.2016 13:10
              +1

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

              Я имею в виду, что когда у меня в системе есть 100500 мест, использующих логгирование, все они начинают зависеть от (например) Serilog.ILogger. И в этот момент выбрасывание серилога начинает стоить денег.


              1. Razaz
                11.07.2016 13:17
                +2

                А есть ли смысл выбрасывать? Я за 12 лет менял логгер 3 раза. 3! А вот абстракций видел на 20 лет вперед ;)
                При том что в принципе даже абстракция вас не спасет, так как Serilog использует свое форматирование. Тоесть даже если прокинуть MyFanceAbstractLogger и написать,

                Logger.Info("Something went wrong during {@Something} because user is {Reason}", Something, Reason)
                

                то вы все равно упретесь в переписывание всех сообщений. Опять вас абстракция не спасет :)

                Сейчас я использую абстракции вида LibLog для распространяемых библиотек.
                Серверный код просто использует специфичные для Serilog врапперы для удобного DI типа ILogger.


                1. lair
                  11.07.2016 13:22
                  +1

                  А есть ли смысл выбрасывать? Я за 12 лет менял логгер 3 раза. 3!

                  Ну вот, раз в четыре года в среднем. У меня, кстати, приблизительно та же статистика. А на большом приложении это правда дорого.


                  При том что в принципе даже абстракция вас не спасет, так как Serilog использует свое форматирование.

                  Ну так абстракция форматирование тоже должна абстрагировать.


                  Я, заметим, не говорю, что абстракция всех спасет. Просто ее отсутствие может представлять проблему. Типичный компромис (который я для себя на конкретном текущем проекте пока сбалансировать не могу).


                  1. Razaz
                    11.07.2016 13:28
                    +1

                    Я как раз пришел к выводу, что излишние абстракции не помогают, а наоборот вредят в среднесрочной и долгосрочной перспективе. Сам использую простые обертки для более удобного DI типа ILogger. Но фишки Serilog используются во всю. И в принципе их просто так не заменить. Но вот за них то Serilog и выбран :)

                    Форматирование очень тяжело абстрагировать, так как старое поколение (Log4Net, NLog2) не являются структурными логгерами, а SLAB — тихий ужас :)

                    Я свой проект веде уже 4 года и пришел к выводу, что уменьшение количества абстракций положительно сказывается на проекте :D


                    1. lair
                      11.07.2016 13:30
                      +1

                      Я свой проект веде уже 4 года и пришел к выводу, что уменьшение количества абстракций положительно сказывается на проекте

                      … если вам так повезло, что вы не собираетесь никогда менять уже абстрагированное.


                      1. Razaz
                        11.07.2016 13:33
                        +1

                        Я не загадываю. Просто понимаю, смена основных инфраструктурных компонентов не бывает бесплатной, сколько бы не абстрагировались от них :) KISS and YAGNI :)


                  1. areht
                    11.07.2016 16:04

                    > Ну так абстракция форматирование тоже должна абстрагировать.

                    Можно с этого места поподробнее? Я вижу 3 варианта:
                    1) писать Logger.Info(«Something went wrong during {@Something} because user is {Reason}», Something, Reason), то есть использовать синтаксис того, что под руками
                    2) писать Logger.Info(«Something went wrong during {0} because user is {1}», Something, Reason), как более «стандартное», с оверхедом и надеждой, что следующий логгер будет совместим.
                    3) писать Logger.Info(«Something went wrong during %%0%% because user is %%1%%», Something, Reason) — придумать свой синтаксис, который потом поддерживать в каждом логгере.

                    При этом первый подход типа не абстрагирует, но чиниться таким же враппером, какой у 2 и 3. А второй вызывает желание форкнуть логгер и выпилить странный синтаксис, вред от которого превышает пользу.

                    Вы каким путём идёте?


                    1. lair
                      11.07.2016 16:11

                      Мы пока не идем никаким путем, мы размышляем. Но одна из веток размышления выглядит как "взять семантически подходящий синтаксис (Serilog-овский выглядит подходящим), затем адаптировать его для тех, кто его не поддерживает".


                      1. marshinov
                        11.07.2016 17:56

                        А можете подробно рассказать, откуда необходимость в большом количестве логов? Фин/гос сектор, каждый пук надо записывать?


                        1. lair
                          11.07.2016 17:58
                          +2

                          Типа того. Плюс много неподконтрольных инсталляций (продукт, а не сервис), поэтому для пост-мортем логи помогают.


                      1. centur
                        12.07.2016 15:00

                        Мы написали абстракции над серилогом только для того чтобы иметь возможность настраивать декораторы для каждого типа сообщения. Какие-то события подробнее логгируются, кто-то добавляет специфические Enrichers при вызове. Есть некоторые костыли в прокидывании динамически формируемого набора Enrichers с доступом к контексту класа, передаем Func внутрь вызова логера. А так да, согласен, структурный логгер это то что нужно, после всех "старичков"...


                1. marshinov
                  11.07.2016 13:22

                  Мы вообще пошли радикальным путем и написали абстракцию от инфраструктуры: https://github.com/hightechtoday/costeffectivecode. Теперь нам все-равно откуда будут данные — из БД, соц.сети, Yandex Market'а. Берем и пишем реализацию IQuery. Так-же пофиг на ORM клиента, потому что она в итоге все-равно скрыта за IQuery. И ILog и IMapper, Pub/Sub тоже наши. Для наших задач подход себя оправдал на все 100%.


                  1. Razaz
                    11.07.2016 13:31
                    +1

                    Если у вас heavy logic — то возможно имеет смысл. У меня в проектах в основном такой логики нет и поэтому стараемся минимизировать абстракции И максимально использовать фичи тех компонентов, что используются.


                    1. marshinov
                      11.07.2016 13:36

                      Причины было три:

                      1. да, часто вагон и маленькая тележка БЛ.
                      2. более чем в половине случаев работа с унаследованными системами, нужен какой-то repair-toolkit для рефакторинга
                      3. необходимость в быстром прототипировании


                      1. Razaz
                        11.07.2016 13:39
                        +1

                        Поэтому и имеет смысл, если нет необходимости в специфике. Плюс у вас фреймворк — тоесть вы решаете обобщенные задачи, поэтому вам необходимо предоставлять интерфейсы для интеграции. Для продукта, который не является фреймворком, смысла в этом не особо много.


  1. divan-9th
    11.07.2016 18:23
    +1

    На мой взгляд реализация через докораторы гораздо наглядней и менее зависит от IOC. Вот прекрасный
    пример

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


    А при чем тут множественное наследование?


    1. marshinov
      11.07.2016 18:40

      Так тоже можно. Разница в том, что логирование любых методов с помощью одного декоратора не сделать, а так можно.


  1. Joshua
    11.07.2016 19:07

    Хм, так вроде postsharp на Interceptor и сделан? В его функциональности чего то не хватает, или просто нет желания платную библиотеку в код добавлять?


    1. marshinov
      11.07.2016 19:16

      Postsharp переписывает IL при компиляции