Aspect Oriented Programming with SNAP

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

В экосистеме .net cуществует много средств, задача которых предоставлять механизмы логгирования и трассировки. Это могут быть сторонние библиотеки как log4net, NLog. Также остается недооцененным функционал трассировки и логгирования, поставляемый в .net из коробки (NET Framework System.Diagnostics). Есть решения, которые расширяют стандартные средства трассировки .net. В Microsoft Enterprise Library для этих целей есть Logging Application Block, также есть third-party библиотеки, например Essential Diagnostics.

Недостатка в средствах трассировки и логгирования нет. В целом, идея одна и та же, предоставляемый функционал варьируется в ту или иную сторону. Однако, сегодня речь не о выборе logging framework. Более важный вопрос, на мой взгляд, не в выборе той или иной библиотеки, а в ее использовании в нашем приложении, другими словами, как происходят вызовы API библиотеки из нашего кода.

Содержание статьи.

  1. Cross-cutting concerns
  2. Aspect orientation
  3. Классификация AOP framework'ов
  4. Выбор AOP framework'а
  5. SNAP - Simple .NET Aspect Oriented Programming

Cross-cutting concerns

Посмотрим как можно добавить вызовы logging framework в код приложения. Самый распространненый подход состоит в добавлении кода трассировки непосредственно в код приложения, зашив его напрямую в тело метода. Рассмотрим следующий псевдокод расчета маршрута из пункта A в пункт B.
public class CalculateRouteCommandHandler
{
    private IMapService _mapService;

    public CalculateRouteCommandHandler(IMapService mapService)
    {
        _mapService = mapService;
    }

    public void HandleCommand(CalculateRouteCommand command)
    {
        Logger.Info("{0} started.".FormatString(command));
        Logger.Info("From: {0}; To: '{1}'.".FormatString(command.Departure, command.Destination));

        try
        {
            var intermediateroutes = CalculateIntermediateRoutes();
            foreach (var intermediateroute in intermediateroutes)
            {
                CalculateDistance(intermediateroute);
                Logger.Debug("Distance calculated: {0}".FormatString(intermediateroute.Distance))

                CalculateTransport(intermediateroute);
                Logger.Debug("Transport calculated: {0}".FormatString(intermediateroute.Transport))

                var alternatives = FindAlternatives(intermediateroute);
                if(alternative.Any())
                {
                    Logger.Info("Found alternative routes: '{0}'".FormatString(alternatives))
                }
            }
            foreach (var jump in command.Jumps)
            {
                Logger.Info("{} completed".FormatString(command));
                Logger.Info("Jump from '{0}' to '{1}' with distance of '{2}'"
                    .FormatString(jump.Departure, jump.Destination, jump.Distance))
            } 
        }
        catch(RouteCalculationException ex)
        {
            Logger.Error("Fail to calculate route", ex);
            throw;
        }
    }
}

Класс состоит из одного метода, который чуть ли не на половину состоит из кода логгирования. Плохо то, что бизнес-логика приложения и инфраструктурные механизмы (в виде логгирования) перемешаны. Инфраструктурные детали, будучи сугубо техническими, засоряют основной код приложения, который выражает требования бизнеса. Даже при относительно небольшом проценте инфраструктурного кода, читать и понимать бизнес задачи, которые этот код призван решать и выражать, становится сложно. Распространенность этого подхода обусловлена легкостью реализации, но отнюдь не соображениями о правильной архитектуре, и оценке последствий такого решения.

Определим понятие cross-cutting concerns (сквозная функциональность).

In software development cross-cutting concerns are logical parts of the program that affect (crosscut) other concerns or modules.
Сквозная функциональность - это те аспекты, которые составляют техническое решение бизнес задачи, но в тоже время прямого отношения к бизнес-логике не имеют. Примерами сквозной функциональности может служить:
  • Безопасность
  • Поддержка транзакционности
  • Обработка ошибок
  • Поддержка многопоточности
  • Измерение производительности
  • Логгирование
Если представить слоистую архитектуру приложения, состоящую из presentation, application, domain и infrastructure слоев, расположенных горизонтально друг над другом, то сквозная функциональность не имеет отношения ни к какому конкретному слою, а пересекает все слои вертикально. Отсюда и название - сквозная функциональность.

Aspect orientation

Редко целью задач является разработка и поддержание инфраструктуры приложения. Зачастую задачи диктуются бизнесом, и несут в себе определенные бизнес требования. Поэтому разработчик в первую очередь сосредоточен на решении задач приложения, задач бизнеса. Однако не задумываться об инфраструктуре нельзя, иначе в итоге сквозная функциональсть будет беспорядочно и неуправляемо разбросана и повторяться по всему приложению.

Никто не отменял такие принципы, как Don't repeat yourself, Single Responsibility Principle, Separation Of Concerns. Собственно идеей Aspect Oriented Programming и является отделение сквозной функциональности от бизнес-логики приложения (separation of concerns), и поощрение принципов DRY и SRP.

public class CalculateRouteCommandHandler
{
    private IMapService _mapService;

    public CalculateRouteCommandHandler(IMapService mapService)
    {
        _mapService = mapService;
    }

    [TraceCommand]
    public virtual void HandleCommand(CalculateRouteCommand command)
    {
        try
        {
            var intermediateroutes = CalculateIntermediateRoutes();
            foreach (var intermediateroute in intermediateroutes)
            {
                CalculateDistance(intermediateroute);
                CalculateTransport(intermediateroute);
                FindAlternatives(intermediateroute);
            }
        }
    }

    [TraceCall(Format = "Distance calculated: {1}", Level = LogLevel.Debug)]
    protected void virtual CalculateDistance(Route intermediate)
    {
        ...
    }

    [TraceCall(Format = "Transport calculated: {1}", Level = LogLevel.Debug)]
    protected void virtual CalculateTransport(Route intermediate)
    {
        ...
    }

    [TraceCall(Format = "Alternatives found: {1}", Level = LogLevel.Info, LogStart = false)]
    protected void virtual FindAlternative(Route intermediate)
    {
        ...
    }
}

HandleCommand метод более не засорен деталями логгирования и описывает исключительно бизнес задачу. Логгирование применено посредством аспектов. Аспект представляет из себя тот или иной элемент сквозной функциональности. Аспекты TraceCommand и TraceCall выполняют логгирование события обработки команд и вызова методов соответственно.

Классификация AOP framework'ов

Задачей AOP framework'ов является предоставление разработчику удобных механизмов работы со сквозной функциональностью в виде аспектов, таким образом, чтобы бизнес-логика и сквозная функциональность были отделены друг от друга.

AOP фреймворки можно классифицировать по следующим критериям:
  • Каким образом осуществляется перехват кода и вызов аспектов?
  • В какие элементы кода можно внедрять аспекты?
  • Каким образом можно применять аспекты к коду приложения?

Первый подход состоит в интрументировании IL-кода сборки на этапе пост-компиляции (code weaving) путем внедрения кода вызова аспектов в нужных места кода приложения. Основной представитель этого класса фреймворков - PostSharp.

With PostSharp, software developers can encapsulate implementation patterns into classes called aspects, and apply these aspects to their code using custom attributes.

С другой стороны, можно обойтись и без низкоуровневого изменения IL-кода, а вместо этого использовать run-time средства, такие как subclass либо interface proxy. Идея состоит в прозрачной для клиента подмене реальных классов прокси-классами, которые помимо вызова проксируемого класса добавляют поведение вызова нужных аспектов. Библиотека Castle.DynamicProxy предоставляет механизм создания таких прокси и перехвата виртуальных методов.

Castle DynamicProxy is a library for generating lightweight .NET proxies on the fly at runtime. Proxy objects allow calls to members of an object to be intercepted without modifying the code of the class. Both classes and interfaces can be proxied, however only virtual members can be intercepted.

Целью перехвата обычно служит вызов метода. Реже - вызов поля, более того, вызов поля всегда можно смоделировать в виде вызова метода, так что основной use case - это перехват метода (method interception). Фреймворки, которые инструментируют сборку путем переписывания кода, позволяют внедрить перехват в любое место, будь то вызов метода или поля. Минус состоит, во-первых, в самой идее переписывания скомпилированного кода, во-вторых, в увеличении времени компиляции и сборки, а также сложности настройки и громоздкости этого процесса. Подход, который не требует инструментирования сборки, выглядит более заманчивей. В тоже время, он позволяет перехватывать только виртуальные public/protected методы. Если же компоненты в приложении управляются контейнером, то возможности перехвата сужаются еще и до списка зарегистрированных компонент. У подхода с инструментированием сборки этих ограничений нет.

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

Выбор AOP framework'а

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

  1. Не предполагает переписывание скомпилированного кода и инструментирование сборки.
  2. Позволяет выборочно осуществлять перехват на уровне методов.
  3. Интегрируется с выбранным DI/IoC контейнером.
  4. Приемлемый с точки зрение призводительности и продуктивности разработчика.

PostSharp - известный, зрелый иструмент. Однако, идея переписывания IL-кода не прельщает, и хочется избежать сложностей post-build настройки проектов. Заметно увеличивается время сборки проекта. Помимо этого, Postsharp - это платный инструмент, хотя в наличии есть и бесплатная Starter Edition с урезаным функционалом. Вобщем, PostSharp - это тяжелая артиллерия, без которой можно обойтись в большинстве use case'ов.

Использовать Castle.DynamicProxy напрямую слишком хлопотно, и в итоге придется создавать поверх него собственный вспомогательный слой. Но нам нужно заниматься бизнем задачами, а не созданием инфраструктуры. Ниже код создания прокси для класса либо интерфейса c указанием interceptor'ов.

public static object CreateProxy(Type interfaceType, object instanceToWrap, params IInterceptor[] interceptors)
{
    if (interfaceType.IsInterface)
        return new ProxyGenerator().CreateInterfaceProxyWithTargetInterface(
            interfaceType, 
            instanceToWrap, 
            interceptors.ToArray());

    var ctor = type.GetConstructors().OrderBy(x => x.Parameters().Count).LastOrDefault();
    var ctorArgs = ctor == null ? new object[0] : new object[сtor.Parameters().Count];
    return new ProxyGenerator().CreateClassProxyWithTarget(
        interfaceType, 
        instanceToWrap, 
        ctorArgs, 
        interceptors);
}

Если для управления компонент в приложении применяется DI/IoC контейнер, то популярные контейнеры, такие как Castle.Windsor, Autofac, StructureMap и др., предоставляют функциональность перехвата вызова компонент зарегистрированных в контейнере. Для этого используется Castle.DynamicProxy как низкоуровневый механизм, однако детали работы с ним в той или иной мере скрыты от разработчика. Рассмотрим этот вариант на примере Autofac.

builder.RegisterType<BarCommandHandler>()
    .Keyed<ICommandHandler>(typeof(BarCommand))
    .SingleInstance()
    .EnableClassInterception();
builder.RegisterType<BazCommandHandler>()
    .Keyed<ICommandHandler>(typeof(BazCommand))
    .SingleInstance()
    .EnableInterfaceInterception();

Магия состоит в использовании extension методов EnableClassInterception и EnableInterfaceInterception, которые находится в библиотеке AutofacContrib.DynamicProxy2. Исходный код можно посмотреть здесь.

Минус состоит в том, что приведенным выше образом можно сконфигурировать перехват всех методов указанного класса, но без возможности конфигурации на уровне отдельных методов. И это существенный недостаток! Редко когда нужно перехватывать все методы класса без разбора. В качестве workaround, здесь и здесь описываются подходы как реализовать это самому.

Подведя итог обзора доступных AOP решений, с одной стороны есть тяжелый и сложный, к тому же платный PostSharp, с другой стороный есть низкоуровненый механизм Castle.DynamicProxy, напрямую работать с которым будет неудобно. Наиболее привлекательным решением выглядит использование возможностей поставляемых с DI/IoC контейнерами, однако поведение и возможности варьируются в зависимости от выбранного поставщика. Например, в том же Autofac, нет out-of-the-box возможности выборочного перехвата методов.

SNAP - Simple .NET Aspect Oriented Programming

Далее рассмотрим возможности AOP фреймворка под названием SNAP. Более подробно здесь.

A .NET library to make aspect-oriented programming a Snap!

Snap (Simple .NET Aspect-Oriented Programming) was conceived out of a recurring need for flexible AOP in the wake of PostSharp's metamorphosis into a commercial product.

Not to be mistaken with a code weaver (i.e. a compile-time tool), Snap is a fully run-time executed library. Snap aims to pair with your Inversion of Control (IoC) container of choice to deliver a powerful aspect-oriented programming model.

Итак, в чем отличие этого решения от тех что обсуждались ранее? Опишем плюсы этого решения:

  • Для реализации механизма перехватов не производится инструментирование сборки на этапе пост-компиляции, вместо этого используется возможности Castle.DynamicProxy (subclass/interface proxy)
  • Интеграция с DI/IoC контейнером. На данный момент поддерживается пять реализаций: Autofac, Castle.Windsor, StructureMap, NInject, LinFu.
  • Использование единой программной модели независимо от выбранного поставщика DI/IoC, скрытие от разработчика специфики его работы с возможностями перехвата.
  • Выборочный перехват методов.
  • Проста в использовании. Не требует от разработчика понимать реализацию таких advanced концепций, как dynamic proxies, и как интегрировать их в используемый DI/IoC контейнер.

Проект выложен на github, а также доступен для установки на nuget gallery.

Реализация аспекта логгирования

Далее рассмотрим реализацию логгирования в AOP ключе используя для этого SNAP. Бизнес-логика находится в классе CalculateRouteCommandHandler, который расчитывает маршрут между двумя пунктами.

public class CalculateRouteCommandHandler
{
    private IMapService _mapService;

    public CalculateRouteCommandHandler(IMapService mapService)
    {
        _mapService = mapService;
    }

    public virtual void HandleCommand(CalculateRouteCommand command)
    {
        try
        {
            var intermediateroutes = CalculateIntermediateRoutes();
            foreach (var intermediateroute in intermediateroutes)
            {
                CalculateDistance(intermediateroute);
                CalculateTransport(intermediateroute);
                FindAlternatives(intermediateroute);
            }
        }
    }

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

public class TraceCommand : MethodInterceptor
{
    public override void InterceptMethod(IInvocation invocation, MethodBase method, Attribute attribute)
    {
        var command = (Command) invocation.Arguments.First();
        var targetAttribute = (TraceCommandAttribute) attribute;
        try
        {
            if(targetAttribute.TraceStart)
            {
                Logger.Info("Command started: {0}".FormatString(command));
            }
            invocation.Proceed();
            if(targetAttribute.TraceComplete)
            {
                Logger.Info("Command completed: {0}".FormatString(command));
            }
        }
        catch(Exception ex)
        {
            if(targetAttribute.TraceFail)
            {
                Logger.Error("Command failed: {0}".FormatString(command), ex);
            }
            throw;
        }
    }
}
Класс аспекта наследуется от базового класса MethodInterceptor, в котором есть методы BeforeInvocation(), AfterInvocation(). Если нужен доступ к информации о вызове, вызванном методе, атрибуте, то можно переопределить метод InterceptMethod, как это показано выше. Тело метода состоит из логгирования начала команды, удачного либо неудачного ее завершения. Вызов же самого целевого метода осуществляется при помощи IInvocation.Proceed().

Применение аспекта

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

public class TraceCommandAttribute : MethodInterceptAttribute
{
    public bool TraceStart { get; set; }
    public bool TraceComplete { get; set; }
    public bool TraceFail { get; set; }
}
Атрибут может содержать настройки, которые влияют на работу аспекта. В нашем случае это указание того, какие события в обработке команды нужно логгировать. Применение аспекта сводится к применению атрибута.
[TraceCommand(TraceStart = false)]
public virtual void HandleCommand(CalculateRouteCommand command)
{
    // business logic goes here
}

Конфигурация аспектов

Далее нужно сконфигурировать SNAP таким образом, чтобы он знал о реализованных аспектах и атрибутах, которые используются для применения аспектов. В простейшем случае это выглядит так (на примере Autofac).

var builder = new ContainerBuilder();

// configure aspects
SnapConfiguration.For(new AutofacAspectContainer(builder)).Configure(c =>
{
    c.IncludeNamespace("MyCommands.*");
    c.Scan(s => s.ThisAssembly().WithDefaults());
});

// configure components in container
builder.RegisterType<CalculateRouteCommandHandler>().As<ICommandHandler<CalculateRouteCommand>>();
builder.RegisterType<MapService>().As<IMapService>();
Первым делом указываем осуществлять перехват только тех классов, которые находятся в указанном namespace. Минус - это отсуствие типизации в данном случае, так как название неймспейса - это простая строка. Далее конфигурируем SNAP так, чтобы он сам определил набор аспектов и их атрибутов, используя naming convention - название класса аспекта должно совпадать с названием класса атрибута (без учета суффиксов "Interceptor", "Attribute"). Также можно подключить свою собственную стратегию автоматической конфигурации компонент, реализовав интерфейс IScanningConvention. В нашем примере default naming convention выполняется, поэтому нет необходимости явно конфигурировать TraceCommandAspect. В противном случае это можно сделать так.
SnapConfiguration.For(new AutofacAspectContainer(builder)).Configure(c =>
{
    c.IncludeNamespace("MyCommands.*");
    c.Bind<TraceCommand>().To<TraceCommandAttribute>();
});

Один метод - много аспектов

Можно ли к одному методу применить несколько аспектов? Ответ: "Да". В таком случае, порядок выполнения аспектов важен. Порядок указания атрибутов в исходном коде не может быть использован в принципе, поэтому есть два механизма конфигурации порядка вызова аспектов: для аспекта в целом, и для отдельного случая его применения.

SnapConfiguration.For(new AutofacAspectContainer(builder)).Configure(c =>
{
    c.IncludeNamespace("MyCommands.*");
    // configure ordering for any aspect usage
    c.Bind<TraceCommand>().To<TraceCommandAttribute>().Order(2);
});
// configure ordering just for this usage
[TraceCommand(TraceStart = false, Order = 2)]
// handle error first of all
[HandleError(Order = 1)]
public virtual void HandleCommand(CalculateRouteCommand command)
{
    // business logic goes here
}
Если порядок не указан тем или иным образом, аспекты выполняются в алфавитном порядке по названию класса.

Вот собственно и все, что нужно для применения aspect oriented подхода используя SNAP в качестве фреймворка.

Недостаток SNAP

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

public interface ILoggingService
{
    void Debug(string message);
    void Info(string message);
    void Warn(string message);
    void Error(string message, Exception failure);
    void Fatal(string message, Exception failure);
}
Этот интерфейс инжектится в класс аспекта и далее используется для логгирования вместо статического класса Logger.
public class TraceCommand : MethodInterceptor
{
    private ILoggingService _loggingService;

    public TraceCommand(ILoggingService loggingService)
    {
        _loggingService = loggingService;
    }
        
    public override void InterceptMethod(IInvocation invocation, MethodBase method, Attribute attribute)
    {
        var command = (Command) invocation.Arguments.First();
        _loggingService.Info("Command started: {0}".FormatString(command));
        // other aspect code goes here
    }
}
Более того, компонент ILoggingService управляется контейнером.
builder.RegisterType<LoggingService>().As<ILoggingService>();
Вопрос: А как же теперь сконфигурировать SNAP? Если позволить найти аспект автоматически, то SNAP не сможет создать экземпляр аспекта не найдя конструктора без параметров. Возможности же явно указать готовый экземпляр аспекта тоже нет. B итоге приходится возвращаться к реализации аспекта со статическим классом Logger.

Keep aspects in container

Подведя итог, проблема состоит в том, что SNAP сам занимается созданием и управлением экземпляров аспектов. В итоге, аспекты - это синглтоны, созданные на этапе конфигурации SNAP. Это влечет за собой невозможность использования аспектов, которым нужны внешние сервисы для работы. А значит, мы ограничены созданием очень простых аспектов, либо автономных и самодостаточных классов аспектов, в которых есть все, что нужно им для работы. Это нарушает Single Responsibility Principle и не поощряет использование good design practices. А хороший фреймворк хорош тогда, когда поощряет разработчика к применению лучших практик.

Печально, но по крайней мере для меня, эта проблема сводит на нет все преимущества этого фреймворка. Поэтому я решил исправить положение вещей и дать возможность контейнеру управлять классами аспектов, и позволить SNAP запрашивать экземпляры аспектов у контейнера, вместо того чтобы создавать их самому. Таким образом каждый будет заниматься тем, что у него получается лучше всего: SNAP - аспектами и перехватами, а контейнер - управлением компонентами. В ближайшем будушем я планирую закончить реализацию этой функциональности и отправить pull request в основной репозиторий.

Stay tuned!

1 comments:

corker said...

как я вижу KeepInContainer уже реализован. спасибо за статью и развитие действительно удобного AOP фреймворка!

Post a Comment