Как на момент прочтения этой статьи, так и сейчас меня не устраивают ни 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<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);
}
Можно даже воспользоваться готовым решением.
Минусы
Я вижу четыре объективных минуса данного подхода:
- Не интуитивность
- Пересечение с инфраструктурным кодом других фреймворков
- Зависимость от 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.
Комментарии (29)
Razaz
11.07.2016 11:43Не вижу проблемы в зависимости от IoC контейнера. Контейнер выбирается как раз за специфические фичи. Так же как и логгер собственно.
lair
11.07.2016 11:48+1… и именно поэтому последующая смена одного или другого обходится дорого.
Razaz
11.07.2016 12:44Согласен. Но согласитесь глупо выбирать контейнер или логгер и не пользоваться его фичами, за которые его же и выбирали :) И эти компоненты обычно не меняются часто, если вообще меняются.
lair
11.07.2016 12:50+1Но согласитесь глупо выбирать контейнер или логгер и не пользоваться его фичами, за которые его же и выбирали
Глупо. А вот не дать этим фичам просочиться в клиентский код — уже не глупо.
И эти компоненты обычно не меняются часто, если вообще меняются.
Они не меняются часто именно потому, что их дорого поменять. Я сейчас как раз думаю, как бы так изолировать код от логгера, чтобы в случае необходимости логгер сменить.
Razaz
11.07.2016 12:59Что вы подразумеваете под просочиться и под клиентский код ;)?
По второму пункту думал примерно так же как и вы. Абстракция над логгером прожила 3 года. Потом перешли на Serilog и она превратилась в тыкву :)marshinov
11.07.2016 13:05Например, когда начинают использовать инъекции в property с атрибутом [Dependency]. Моя ненависть к Unity началась с этого.
Razaz
11.07.2016 13:08Нууу это ай ай ай конечно. Но абстракция от контейнера вас тут не спасет.
Вот тут на почитать: https://simpleinjector.org/blog/2016/07/working-around-the-asp-net-core-di-abstraction/
lair
11.07.2016 13:10+1Что вы подразумеваете под просочиться и под клиентский код
Я имею в виду, что когда у меня в системе есть 100500 мест, использующих логгирование, все они начинают зависеть от (например)
Serilog.ILogger
. И в этот момент выбрасывание серилога начинает стоить денег.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.
lair
11.07.2016 13:22+1А есть ли смысл выбрасывать? Я за 12 лет менял логгер 3 раза. 3!
Ну вот, раз в четыре года в среднем. У меня, кстати, приблизительно та же статистика. А на большом приложении это правда дорого.
При том что в принципе даже абстракция вас не спасет, так как Serilog использует свое форматирование.
Ну так абстракция форматирование тоже должна абстрагировать.
Я, заметим, не говорю, что абстракция всех спасет. Просто ее отсутствие может представлять проблему. Типичный компромис (который я для себя на конкретном текущем проекте пока сбалансировать не могу).
Razaz
11.07.2016 13:28+1Я как раз пришел к выводу, что излишние абстракции не помогают, а наоборот вредят в среднесрочной и долгосрочной перспективе. Сам использую простые обертки для более удобного DI типа ILogger. Но фишки Serilog используются во всю. И в принципе их просто так не заменить. Но вот за них то Serilog и выбран :)
Форматирование очень тяжело абстрагировать, так как старое поколение (Log4Net, NLog2) не являются структурными логгерами, а SLAB — тихий ужас :)
Я свой проект веде уже 4 года и пришел к выводу, что уменьшение количества абстракций положительно сказывается на проекте :Dlair
11.07.2016 13:30+1Я свой проект веде уже 4 года и пришел к выводу, что уменьшение количества абстракций положительно сказывается на проекте
… если вам так повезло, что вы не собираетесь никогда менять уже абстрагированное.
Razaz
11.07.2016 13:33+1Я не загадываю. Просто понимаю, смена основных инфраструктурных компонентов не бывает бесплатной, сколько бы не абстрагировались от них :) KISS and YAGNI :)
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. А второй вызывает желание форкнуть логгер и выпилить странный синтаксис, вред от которого превышает пользу.
Вы каким путём идёте?lair
11.07.2016 16:11Мы пока не идем никаким путем, мы размышляем. Но одна из веток размышления выглядит как "взять семантически подходящий синтаксис (Serilog-овский выглядит подходящим), затем адаптировать его для тех, кто его не поддерживает".
centur
12.07.2016 15:00Мы написали абстракции над серилогом только для того чтобы иметь возможность настраивать декораторы для каждого типа сообщения. Какие-то события подробнее логгируются, кто-то добавляет специфические Enrichers при вызове. Есть некоторые костыли в прокидывании динамически формируемого набора Enrichers с доступом к контексту класа, передаем Func внутрь вызова логера. А так да, согласен, структурный логгер это то что нужно, после всех "старичков"...
marshinov
11.07.2016 13:22Мы вообще пошли радикальным путем и написали абстракцию от инфраструктуры: https://github.com/hightechtoday/costeffectivecode. Теперь нам все-равно откуда будут данные — из БД, соц.сети, Yandex Market'а. Берем и пишем реализацию IQuery. Так-же пофиг на ORM клиента, потому что она в итоге все-равно скрыта за IQuery. И ILog и IMapper, Pub/Sub тоже наши. Для наших задач подход себя оправдал на все 100%.
Razaz
11.07.2016 13:31+1Если у вас heavy logic — то возможно имеет смысл. У меня в проектах в основном такой логики нет и поэтому стараемся минимизировать абстракции И максимально использовать фичи тех компонентов, что используются.
marshinov
11.07.2016 13:36Причины было три:
- да, часто вагон и маленькая тележка БЛ.
- более чем в половине случаев работа с унаследованными системами, нужен какой-то repair-toolkit для рефакторинга
- необходимость в быстром прототипировании
Razaz
11.07.2016 13:39+1Поэтому и имеет смысл, если нет необходимости в специфике. Плюс у вас фреймворк — тоесть вы решаете обобщенные задачи, поэтому вам необходимо предоставлять интерфейсы для интеграции. Для продукта, который не является фреймворком, смысла в этом не особо много.
divan-9th
11.07.2016 18:23+1На мой взгляд реализация через докораторы гораздо наглядней и менее зависит от IOC. Вот прекрасный
пример
Отличительной особенностью данного подхода от реализации паттерна «декоратор» средствами ООП является возможность добавлять вспомогательную функциональность к любым типам без необходимости создавать наследников. Подход также решает проблему множественного наследования.
А при чем тут множественное наследование?
marshinov
11.07.2016 18:40Так тоже можно. Разница в том, что логирование любых методов с помощью одного декоратора не сделать, а так можно.
lair
В Unity interception просто свой.
marshinov
Да, возможно. Unity уж больно медленный, поэтому много лет его не использую
lair
Да я тоже его не использую, но для полноты картины.