6 July 2011

Postsharp. Решаем задачу кэширования

.NETC#
Translation
Original author: Matthew Growes
Иногда попадаются такие ситуации, в которых нет никакой возможности ускорить работу некоторой операции. Она может зависеть от какого-то сервиса, который располагается на внешнем web сервере, или это может быть операция, которая дает высокую нагрузку на процессор. Или же это могут быть быстрые операции, однако, их параллельная работа может высосать из вашего компьютера все ресурсы производительности. Существует огромное количество причин чтобы использовать кэширование. Следует отметить, что PostSharp, изначально не предоставляет решений для вас какого-либо фреймворка кэширования, просто он позволяет сделать эту задачу на порядки быстрее, без каких-либо занудных действий, типа расстановки кода, отвечающего за кэширование по всему исходному тексту программы. Он позволяет решить эту задачу элегантно, вынося задачи в классы и позволяя их повторно использовать.



Предположим, я хочу узнать на сайте автосалона, сколько стоят автомобили, которые выставлены на продажу в этом автосалоне. Для этого я буду использовать приложение, которое будет загружать с сервера прайс-лист салона, который предназначен для автомобилей определенной марки, модели и года выпуска. Если же значения прайс-листа (в рамках нашего примера) меняются слишком часто, я буду использовать web-сервис для получения значений этого прайс-листа. Пусть, при этом, web-сервис слишком медленный, а я хочу запросить слишком много автомобилей. Как вы понимаете, я не могу сделать быстрее чужой web-сервис, но я могу закэшировать возвращаемые данные от магазина, уменьшив, таким образом, количество запросов.
Поскольку одна из основных возможностей PostSharp'a — «перехват» вызова метода, т.е. внедрение в метод таким образом, что мы можем выполнить свой код как до, так и после работы тела метода, воспользуемся этим фреймворком для реализации задачи кэширования:
[Serializable]
public class CacheAttribute : MethodInterceptionAspect
{
    [NonSerialized]
    private static readonly ICache _cache;
    private string _methodName;

    static CacheAttribute()
    {
        if(!PostSharpEnvironment.IsPostSharpRunning)
        {
            // one minute cache
            _cache = new StaticMemoryCache(new TimeSpan(0, 1, 0));
            // use an IoC container/service locator here in practice
        }
    }

    public override void CompileTimeInitialize(MethodBase method, AspectInfo aspectInfo)
    {
        _methodName = method.Name;
    }

    public override void OnInvoke(MethodInterceptionArgs args)
    {
        var key = BuildCacheKey(args.Arguments);
        if (_cache[key] != null)
        {
            args.ReturnValue = _cache[key];
        }
        else
        {
            var returnVal = args.Invoke(args.Arguments);
            args.ReturnValue = returnVal;
            _cache[key] = returnVal;
        }
    }

    private string BuildCacheKey(Arguments arguments)
    {
        var sb = new StringBuilder();
        sb.Append(_methodName);
        foreach (var argument in arguments.ToArray())
        {
            sb.Append(argument == null ? "_" : argument.ToString());
        }
        return sb.ToString();
    }
}

* This source code was highlighted with Source Code Highlighter
.


Я сохраняю имя метода во время компиляции и инициализирую в run-time сервис кэширования. В качестве ключа для кэширования я буду использовать имя метода, а также значения всех параметров метода, перечисленных через пробел (см. код метода BuildCacheKey), который будет уникальным для каждого метода и каждого набора параметров. В методе OnInvoke я проверяю, существует ли полученный ключ в кэше, и использую значение из кэша, если ключ уже существует. В противном случае, я вызываю код оригинального метода, чтобы положить в кэш результат работы до следующего вызова.
В моем примере есть метод GetCarValue, который предназначен для имитации вызова web-сервиса чтобы получить информацию по автомобилю. Этот метод имеет параметры, которые могут принимать самые разные значения, потому он может возвращать различные результаты каждый раз, когда он вызывается (в нашем примере только в тех случая, когда будет отсутствовать закэшированное значение):
[Cache]
public decimal GetCarValue(int year, CarMakeAndModel carType)
{
    // simulate web service time
    Thread.Sleep(_msToSleep);

    int yearsOld = Math.Abs(DateTime.Now.Year - year);
    int randomAmount = (new Random()).Next(0, 1000);
    int calculatedValue = baselineValue - (yearDiscount*yearsOld) + randomAmount;
    return calculatedValue;
}

* This source code was highlighted with Source Code Highlighter
.

Несколько заметок об этом аспекте:
  • Я также мог бы воспользоваться OnMethodBoundaryAspect вместо MethodInterceptionAspect: оба подхода будут корректными. Просто в этом случае, я выбрал MethodInterceptionAspect, чтобы упростить себе выбор, покрывая требования к программе
  • Помните, что поскольку нет никакого смысла загружать и инициализировать кэш во время работы PostSharp (не во время работы приложения), мы должны поставить проверку, запущен PostSharp или нет. Еще один способ загружать зависимости — разместить код в RuntimeInitialize.
  • Этот аспект не предоставляет возможным использовать 'out' и 'ref' параметры в задачах кэширования. Конечно же, это возможно сделать, однако мне кажется, что параметры 'out' и 'ref' не должны использоваться в таких задачах, и если вы со мной согласны, давайте не будем тратить время на их реализацию.

Проверки во время компиляции


Всегда существуют варианты, когда кэширование не является хорошей идеей. Например, когда метод возвращает Stream, IEnumerable, IQueryable, и т.п.
интерфейсы. Поэтому, такие значения нельзя кэшировать. Чтобы сделать такие проверки, необходимо переопределить метод CompileTimeValidate, например, так:
public override bool CompileTimeValidate(MethodBase method)
{
    var methodInfo = method as MethodInfo;
    if(methodInfo != null)
    {
        var returnType = methodInfo.ReturnType;
        if(IsDisallowedCacheReturnType(returnType))
        {
            Message.Write(SeverityType.Error, "998",
             "Methods with return type {0} cannot be cached in {1}.{2}",
             returnType.Name, _className, _methodName);
            return false;
        }
    }
    return true;
}

private static readonly IList DisallowedTypes = new List
             {
                 typeof (Stream),
                 typeof (IEnumerable),
                 typeof (IQueryable)
             };
private static bool IsDisallowedCacheReturnType(Type returnType)
{
    return DisallowedTypes.Any(t => t.IsAssignableFrom(returnType));
}

* This source code was highlighted with Source Code Highlighter
.


Таким образом, если какой-либо разработчик попробует применить кэширование к методам, которые не должны быть закэшированы, он получит сообщение обошибке компиляции. Кстати, если вы используете для какого-то типа IsAssignableFrom, вы также покрываете классы и интерфейсы, которые наслудеются от него. Т.е. в нашем случае также будут покрыты такие типы как FileStream, IEnumerable, и т.п.

Многозадачность


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

Простым решением решить эту проблему будет использование «lock» каждый раз, когда кэш будет использоваться. Однако, блокировка — это дорогая и медленная операция и лучше если мы будем сначала проверять существование ключа в кэше, и только после этого блокировать. Однако в этом случае существует вероятность что несколько потоков смогут одновременно пройти проверку на отсутствие ключа в кэше и уйти на расчет этого ключа, поэтому мы должны проверить существование ключа дважды (double-checked locking), вне заблокированного кода и внутри его:
[Serializable]
public class CacheAttribute : MethodInterceptionAspect
{
    [NonSerialized] private object syncRoot;

    public override void RuntimeInitialize(MethodBase method)
    {
        syncRoot = new object();
    }

    public override void OnInvoke(MethodInterceptionArgs args)
    {
        var key = BuildCacheKey(args.Arguments);
        if (_cache[key] != null)
        {
            args.ReturnValue = _cache[key];
        }
        else
        {
            lock (syncRoot)
            {
                if (_cache[key] == null)
                {
                    var returnVal = args.Invoke(args.Arguments);
                    args.ReturnValue = returnVal;
                    _cache[key] = returnVal;
                }
                else
                {
                    args.ReturnValue = _cache[key];
                }
            }
        }
    }
}

* This source code was highlighted with Source Code Highlighter
.


Выглядит как немного повторяющеся, однако в этом случае, это отличное решение улучшить производительность для высоко-нагруженных решений. Вместо того чтобы блокировать кэш, я блокирую некий private объект, который специфичен только для метода, к которому применен аспект. Все это приводит к минимизации количества блокировок при использовании кэша.
Надеюсь, вы не сбиты с толку? Проблемы параллельного выполнения задач многих могут сбить с толку, однако во многих приложениях это реальность. Вооружившись этим аспектом, вам больше не придется заботиться о собственных ошибках или ошибках разработчиков с маленьким количеством опыта. Либо об ошибках новых разработчиков или разработчиков с 30-летним стажем разработки на COBOL и видящих C# в первый раз :). По факту, они должны знать, каким образом необходимо обрамлять методы аспектом «Cache», и им нет необходимости знать, как должна быть реализована эта технология. И они не должны знать как сделать свои методы потоко-безопасными. Они смогут сконцентрироваться только на свом куске кода, не отвлекаясь на реализацию сопутствующих задач.

Ссылки:
Tags:AOPpostsharpАОПкэшированиеcachecaching
Hubs: .NET C#
+2
2.4k 18
Comments 11