Разумное АОП для поклонников IOC-контейнеров
Как на момент прочтения этой статьи, так и сейчас меня не устраивают ни 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. Нам доступно два варианта:
- создание прокси по интерфейсной ссылке (в этом случае будет использоваться композиция)
- создание прокси для класса (будет создан наследник)
Основное отличие для нас будет заключаться в том, что для модификации методов класса, они должны быть объявлены доступными (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);
}
Минусы
Я вижу четыре объективных минуса данного подхода:
- Не интуитивность
- Пересечение с инфраструктурным кодом других фреймворков
- Зависимость от IOC-контейнера
- Производительность
Не интуитивность
Проще всего разобраться с таким структурированием кода людям, знакомым с концепциями функционального программирования. С изрязным количеством оговорок подход можно назвать напоминающим «композицию». Криво спроектированные перехватчики могут быть причиной изрядного количества не очевидных багов и проблем с производительностью.
Пересечение с инфраструктурным кодом других фреймворков
Как я говорил в начале, атрибуты 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. Очевидно, что за магию с рефлексией и динамической компиляцией приходится платить:
- Временем создания объекта: ~2,000 ns. Не принципиально, если службы создаются один раз, а за лайфтайм «протухающих» зависимостей, таких как контекст бд отвечает другой объект
- Временем выполнения операций: так-же примерно ~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 |
Поэтому, если в среднем ваши операции выполняются не менее чем 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↑
↓
… и именно поэтому последующая смена одного или другого обходится дорого.