На переднем крае

Перехватчики в Unity

Дино Эспозито

image: Dino EspositoВ прошлый раз я кратко ознакомил вас с механизмом перехвата, применяемым в контейнере встраивания зависимостей в Unity 2.0. После иллюстрации основных принципов аспектно-ориентированного программирования (AOP) я представил конкретный пример перехвата, который, по-видимому, очень близок к нынешним потребностям многих разработчиков.

Вам никогда не хотелось расширить поведение существующего блока кода, вообще никак не затрагивая исходный код? Или, может быть, вам нужно было просто выполнять некий дополнительный код вокруг существующего?

AOP было создано в расчете на подход, при котором основной код изолируется от других задач, горизонтально пересекающих базовую прикладную логику. Unity 2.0 предоставляет инфраструктуру на основе Microsoft .NET Framework 4 для достижения этих целей удивительно быстрым и простым способом.

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

AOP в Unity

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

Теперь вообразите слегка другую ситуацию. Во-первых, как быть, если вы не независимый консультант, а работаете полный день в компании? Чем больше запросов на изменения вы получаете, тем меньше времени у вас остается на текущий проект и, что хуже, вы рискуете создать новые (и не обязательно нужные) ветви своей кодовой базы. Значит, вы будете рады любым решениям, которые позволят бесшовно добавлять новые поведения без модификации уже имеющегося исходного кода.

Наконец, представьте, что кто-то сообщает о найденной ошибке или серьезной проблеме с производительностью. Вам нужно изучить проблему и устранить ее, но вы хотите сделать это, не нарушая работу приложения. И в этом случае было бы лучше добавлять новые поведения бесшовно и без вмешатель��тва в исходный код.

Во всех этих ситуациях вам поможет AOP.

В прошлый раз я продемонстрировал, как добавить пред- и постобработку вокруг существующего метода, не изменяя сам метод, с помощью API перехвата в Unity 2.0. В этой демонстрации на скорую руку, тем не менее, было сделано несколько допущений.

Во-первых, она работала с типом, зарегистрированным в инфраструктуре Unity Inversion of Control (IoC); при этом его экземпляр создавался через уровень фабрики Unity.

Во-вторых, срез точек (pointcut) определялся только через интерфейс. На жаргоне AOP срез точек представляет набор участков в теле целевого класса, куда инфраструктура будет встраивать дополнительные поведения по требованию. Срез точек на основе интерфейса означает, что в период выполнения через встраивание кода можно будет расширять лишь члены этого интерфейса.

В-третьих, я в основном сосредоточился на конфигурационных параметрах, включающих поддержку перехвата, и проигнорировал текущий API, позволяющий программно конфигурировать Unity.

В остальной части этой статьи я исследую текущий API и альтернативные способы определения перехватчиков Unity.

Перехватываемые экземпляры

Чтобы добавить новое поведение к существующему или только что созданному экземпляру класса, вы должны захватить определенный контроль над фабрикой. Иначе говоря, AOP не имеет отношения к магии, и вы никогда не сможете подключиться к обычному CLR-классу, экземпляр которого создан стандартным оператором new:

var calculator = new Calculator();

То, как инфраструктура AOP получает контроль над конкретным экземпляром, может заметно варьироваться. В Unity вы можете прибегнуть к некоторым явным вызовам, которые возвращают прокси оригинального объекта или хранят все за кулисами инфраструктуры IoC. По этой причине большинство инфраструктур IoC поддерживает средства AOP. Два примера таких инфраструктур — Spring.NET и Unity. AOP вместе IoC обеспечивают простое и эффективное создание кода.

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

var calculator = new Calculator();
var calculatorProxy = Intercept.ThroughProxy<ICalculator>(calculator,
  new InterfaceInterceptor(), new[] { new LogBehavior() });
Console.WriteLine(calculatorProxy.Sum(2, 2));

В конечном счете вы получаете перехватываемый прокси, который обертывает ваш исходный объект. В данном случае я предполагаю, что класс Calculator реализует интерфейс ICalculator. Чтобы класс мог быть перехватываемым, он должен либо реализовать интерфейс, либо наследовать от MarshalByRefObject. Если класс наследует от MarshalByRefObject, перехватчик должен иметь тип TransparentProxyInterceptor:

var calculator = new Calculator();
var calculatorProxy = Intercept.ThroughProxy(calculator,
  new TransparentProxyInterceptor(), new[] { new LogBehavior() });
Console.WriteLine(calculatorProxy.Sum(2, 2));

Класс Intercept также содержит метод NewInstance, который позволяет создать перехватываемый объект более прямолинейным способом. Вот как им пользоваться:

var calculatorProxy = Intercept.NewInstance<Calculator>(
  new VirtualMethodInterceptor(), new[] { new LogBehavior() });

Заметьте, когда вы используете NewInstance, компонент перехватчика должен быть немного другим — ни InterfaceInterceptor, ни TransparentProxyInterceptor, а объектом VirtualMethodInterceptor. Так сколько же типов перехватчиков существует в Unity?

Перехватчики экземпляра и типа

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

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

В случае перехвата экземпляра код приложения сначала создает целевой объект, используя традиционную фабрику (или оператор new), а затем взаимодействует с ним через прокси, предоставляемым Unity.

В случае перехвата типа приложение создает целевой объект через API или Unity, потом работает с этим экземпляром. (Создать объект напрямую оператором new и добиться перехвата типа нельзя.) Однако целевой объект вовсе не относится к исходному типу. Реальный тип является производным (наследование осуществляется Unity «на лету») и включает логику перехвата (рис. 1).

image: Instance Interceptor and Type Interceptor

Рис. 1. Перехватчики экземпляра и типа

InterfaceInterceptor и TransparentProxyInterceptor — вот два Unity-перехватчика, которые относятся к категории перехватчиков экземпляра. VirtualMethodInterceptor относится к категории перехватчиков типа.

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

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

VirtualMethodInterceptor способен перехватывать виртуальные методы — как открытые, так и защищенные. Этот перехватчик применим только к новым экземплярам.

Стоит отметить, что перехват экземпляра применим к любым открытым методам экземпляра, но не к конструкторам. Это вполне очевидно в данном сценарии, где перехват применяется к существующему экземпляру, но не столь очевидно в случае нового экземпляра. Реализация перехвата экземпляра такова, что конструктор уже выполнен к моменту, когда код приложения возвращается к объекту, с которым он должен работать. В итоге любая перехватываемая операция обязательно следует за созданием экземпляра.

При перехвате типа используется динамическая генерация кода для возврата объекта, который наследует от исходного типа. При этом любые открытые и защищенные виртуальные методы переопределяются для поддержки перехвата. Рассмотрим следующий код:

var calculatorProxy = Intercept.NewInstance<Calculator>(
  new VirtualMethodInterceptor(), new[] { new LogBehavior() });

Класс Calculator выглядит так:

public class Calculator {
  public virtual Int32 Sum(Int32 x, Int32 y) {
    return x + y;
  }
}

На рис. 2 показано настоящее имя типа, которые получается в результате динамического анализа переменной calculatorProxy.

image: Actual Type After Type Interception

Рис. 2. Реальный тип после перехвата типа

Также следует отметить, что есть и другие значительные отличия перехвата экземпляра от перехвата типа, например перехват своих вызовов самим объектом. Если некий метод вызывает другой метод того же объекта при использовании перехвата типа, то этот «самовызов» можно перехватить, поскольку логика перехвата находится в этом же объекте. Однако в случае экземпляра перехват происходит, только если вызов идет через прокси. Самовызовы, конечно, не осуществляются через прокси, и поэтому перехват в этом варианте невозможен.

Использование IoC-контейнера

В примере из прошлой статьи я использовал IoC-контейнер библиотеки Unity, который обеспечивал создание объекта. IoC-контейнер — это дополнительная оболочка вокруг операции создания объекта, которая повышает гибкость приложения. Это в еще большей мере справедливо, если речь идет об инфраструктурах IoC с дополнительными AOP-средствами. Более того, гибкость кода обгоняет мое воображение, если IoC-контейнеры также комбинируются с автономным конфигурированием (offline configuration). Но начнем с примера, где Unity-контейнер используется с текущей конфигурацией на основе кода (code-based fluent configuration):

// Configure the IoC container
var container = UnityStarter.Initialize();

// Start the application
var calculator = container.Resolve<ICalculator>();
var result = calculator.Sum(2, 2);

Любой код, необходимый для начальной загрузки контейнера, можно изолировать в отдельном классе и вызывать его один раз при запуске приложения. Код начальной загрузки инструктирует контейнер, как разрешать типы, встроенные в приложение, и как обрабатывать перехват. Вызов метода Resolve скрывает от вас все детали реализации. На рис. 3 показана возможная реализация кода начальной загрузки (bootstrap code).

Рис. 3. Начальная загрузка Unity

public class UnityStarter {
  public static UnityContainer Initialize() {
    var container = new UnityContainer();

    // Enable interception in the current container 
    container.AddNewExtension<Interception>();

    // Register ICalculator with the container and map it to 
    // an actual type. In addition, specify interception details.
    container.RegisterType<ICalculator, Calculator>(
      new Interceptor<VirtualMethodInterceptor>(),
      new InterceptionBehavior<LogBehavior>());

    return container;
  }
}

Приятно, что этот код можно переместить в отдельную сборку и загружать или изменять динамически. Еще важнее, что вы получаете единую точку для конфигурирования Unity. Это не удастся, если вы будете использовать класс Intercept, который ведет себя как «интеллектуальная» фабрика и требует подготовительных операций при каждом его применении. Поэтому, если вам нужна поддержка AOP в своих приложениях, делайте это через IoC-контейнер. То же решение можно реализовать еще более гибким способом, выделив все детали конфигурации в файл app.config (или web.config, если это веб-приложение). Тогда код начальной загрузки будет содержать следующие две строки:

var container = new UnityContainer();
container.LoadConfiguration();

На рис. 4 показан сценарий (скрипт), который должен присутствовать в конфигурационном файле. В нем я регистрирую два поведения для типа ICalculator. Это означает, что любые вызовы открытых членов интерфейса будут подвергаться пред- и постобработке LogBehavior и BinaryBehavior.

Рис. 4. Добавление деталей перехвата через конфигурацию

<unity xmlns="https://schemas.microsoft.com/practices/2010/unity">
  <assembly name="SimplestWithConfigIoC"/>
  <namespace name="SimplestWithConfigIoC.Calc"/>
  <namespace name="SimplestWithConfigIoC.Behaviors"/>

  <sectionExtension 
    type="Microsoft.Practices.Unity.
      InterceptionExtension.Configuration.
      InterceptionConfigurationExtension,     
      Microsoft.Practices.Unity.Interception.Configuration" />

  <container>
    <extension type="Interception" />

    <register type="ICalculator" mapTo="Calculator">
      <interceptor type="InterfaceInterceptor"/>
      <interceptionBehavior type="LogBehavior"/>
      <interceptionBehavior type="BinaryBehavior"/>
    </register>

    <register type="LogBehavior">
    </register>

    <register type="BinaryBehavior">
    </register>

  </container>
</unity>

Заметьте, поскольку LogBehavior и BinaryBehavior являются конкретными типами, вам на самом деле вообще не требуется их регистрировать. Unity по умолчанию будет автоматически работать для них.

Поведения

В Unity поведения (behaviors) — это объекты, которые на деле реализуют операции, горизонтально пересекающие иерархию. Поведение (класс, реализующий интерфейс IInterceptionBehavior) переписывает цикл выполнения перехваченного метода и может модифицировать параметры метода или возвращаемые значения. Поведения способны даже полностью предотвращать вызов метода или вызывать его многократно.

Поведение состоит из трех методов. На рис. 5 показано поведение-пример, которое перехватывает метод Sum и перезаписывает его возвращаемое значение как двоичную строку. Метод WillExecute просто обеспечивает оптимизацию прокси. Если он возвращает false, поведение не будет выполняться.

Рис. 5.Пример поведения

public class BinaryBehavior : IInterceptionBehavior {
  public IEnumerable<Type> GetRequiredInterfaces() {
    return Type.EmptyTypes;
  }

  public bool WillExecute {
    get { return true; }
  }

  public IMethodReturn Invoke(
    IMethodInvocation input, 
    GetNextInterceptionBehaviorDelegate getNext) {

    // Perform the operation
    var methodReturn = getNext().Invoke(input, getNext);

    // Grab the output
    var result = methodReturn.ReturnValue;

    // Transform
    var binaryString = ((Int32)result).ToBinaryString();

    // For example, write it out
    Console.WriteLine("Rendering {0} as binary = {1}", 
      result, binaryString);

    return methodReturn;
  }
}

На самом деле все немного тоньше. Invoke будет вызываться всегда, поэтому ваше поведение все равно будет выполняться, даже если вы возвращаете false. Однако при создании прокси или производного типа, если все поведения, зарегистрированные для этого типа, возвращают из WillExecute значение false, сам прокси не создается и вы будете вновь иметь дело с «чистым» объектом. Так что все дело в оптимизации создания прокси.

Метод GetRequiredInterfaces позволяет поведению добавлять новые интерфейсы в целевой объект; интерфейсы, возвращаемые этим методом, добавляются к прокси. Таким образом, центральное место в поведении занимает метод Invoke. Ввод параметра дает вам доступ к вызываемому методу целевого объекта. Параметр getNext является для вас делегатом, который обеспечивает переход к следующему поведению в конвейере и в конечном счете выполняет метод целевого объекта.

Метод Invoke определяет логику, применяемую для выполнения вызова открытого метода целевого объекта. Заметьте, что все перехватываемые методы целевого объекта будут выполняться в соответствии с логикой, выраженной в Invoke.

Как быть, если вам нужно использовать более специфичные правила соответствия (matching rules)? При обычном перехвате, о котором я рассказал в этой статье, все, что вы можете сделать, — написать пачку выражений IF, чтобы знать, какой метод вызывается на самом деле, например:

if(input.MethodBase.Name == "Sum") {
  ...
}

В следующий раз мы продолжим с этого места и обсудим более эффективные способы применения перехвата и определения правил соответствия для перехватываемых методов.

Дино Эспозито (Dino Esposito) — автор книги «Programming ASP.NET MVC» (Microsoft Press, 2010) и соавтор «Microsoft .NET: Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. С ним можно связаться через его блог weblogs.asp.net/despos.

Выражаю благодарность за рецензирование статьи эксперту Крису Таваресу (Chris Tavares)