Caching transparent

11. Januar 2012

Das Thema Caching kann man auf viele verschiedene Wege angehen. Einer davon ist ein vollkommen transparenter Caching-Layer, der Queries abfängt und entsprechend behandelt.

Über das Wie und das Was wird beim Thema Caching gerne und häufig diskutiert, auch unter eXperts. Meistens trennt man sich nach einer solchen Diskussion mit der Erkenntnis, dass es auch für Caching keine Silver Bullet gibt. Keine Lösung, die sich in jeder Situation bedingungslos bewährt.

Eine Lösung, für ein Szenario soll der folgende Beitrag vorstellen.

Ziel ist es, das Caching transparent zu implementieren. Ein Aufrufer soll gar nicht merken, dass das Ergebnis seiner Abfrage irgendwo zwischengespeichert wird. Der Cache kann damit auch jederzeit per Konfiguration zu- oder abgeschaltet werden, ohne dass der Code auf Seite des Verwenders geändert werden muss.

Um dieses Ziel zu erreichen werden alle Aufrufe an die eigentliche Datenzugriffskomponente abgefangen und entsprechend behandelt, bevor diese dann weitergereicht werden. Hierzu definieren wir zunächst ein Interface für die Datenzugriffskomponente.

   1:  public interface ICustomerRepository
   2:  {
   3:    IQueryable<Customer> Customers { get; }
   4:  }


Die Einführung von LINQ brachte das Interface IQueryable<T> mit. Dieses Interface ermöglicht die Definition einer Abfragebedingung, die erst evaluiert wird, wenn das Ergebnis der Abfrage verarbeitet wird (etwa durch den Aufruf von ToList() oder durch die Verwendung des Ergebnisses in einer foreach()-Schleife).

Um eine solche Abfragebedingung abfangen zu können muss man nun zunächst entsprechende Implementierungen von IQueryable<T> und dessen Basisinterface IQueryable erstellen.

Etwas vereinfacht könnten diese wie folgt aussehen.

   1:  public class QueryInterceptor<T> : QueryInterceptor, IQueryable<T>
   2:  {
   3:    private readonly IQueryable<T> wrapped;
   4:    public QueryInterceptor(IQueryable<T> wrapped, QueryInterceptorProvider provider)
   5:      : base(wrapped, provider)
   6:    {
   7:      this.wrapped = wrapped;
   8:    }
   9:    public IEnumerator<T> GetEnumerator()
  10:    {
  11:      var enumerable = this.Provider.Execute<IEnumerable<T>>(this.Expression);
  12:      var enumerator = enumerable.GetEnumerator();
  13:      return enumerator;
  14:    }
  15:  }
  16:   
  17:  public class QueryInterceptor : IQueryable
  18:  {
  19:    private readonly IQueryable wrapped;
  20:    private readonly QueryInterceptorProvider queryProvider;
  21:    public QueryInterceptor(IQueryable wrapped, QueryInterceptorProvider provider)
  22:    {
  23:      this.wrapped = wrapped;
  24:      this.queryProvider = provider;
  25:    }
  26:    public Type ElementType
  27:    {
  28:      get { return this.wrapped.ElementType; }
  29:    }
  30:    public Expression Expression
  31:    {
  32:      get { return this.wrapped.Expression; }
  33:    }
  34:    public IQueryProvider Provider
  35:    {
  36:      get { return this.queryProvider; }
  37:    }
  38:    public QueryInterceptorProvider QueryProvider
  39:    {
  40:      get { return this.queryProvider; }
  41:    }
  42:    IEnumerator IEnumerable.GetEnumerator()
  43:    {
  44:      var enumerable = this.Provider.Execute(this.Expression) as IEnumerable;
  45:      var enumerator = enumerable.GetEnumerator();
  46:      return enumerator;
  47:    }
  48:  }

Das genügt allerdings noch nicht ganz. LINQ verwendet zur Erstellung und Ausführung von Queries außerdem noch Implementierungen von IQueryProvider. Auch hierfür müssen wir einen Interceptor bereitstellen.

   1:  public class QueryInterceptorProvider : IQueryProvider
   2:  {
   3:    private readonly IQueryProvider wrapped;
   4:    public QueryInterceptorProvider(IQueryProvider wrapped)
   5:    {
   6:      this.wrapped = wrapped;
   7:    }
   8:    public event EventHandler<ExpressionExecuteEventArgs> Executing = delegate { };
   9:    public event EventHandler<ExpressionExecuteEventArgs> Executed = delegate { };
  10:    public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
  11:    {
  12:      var rawQuery = wrapped.CreateQuery<TElement>(expression);
  13:      var interceptor = new QueryInterceptor<TElement>(rawQuery, this);
  14:      return interceptor;
  15:    }
  16:    public IQueryable CreateQuery(Expression expression)
  17:    {
  18:      var rawQuery = this.wrapped.CreateQuery(expression);
  19:      var interceptor = new QueryInterceptor(rawQuery, this);
  20:      return interceptor;
  21:    }
  22:    public TResult Execute<TResult>(Expression expression)
  23:    {
  24:      object value;
  25:      bool handled = this.NotifyExecuting(expression, out value);
  26:      TResult result = !handled ? this.wrapped.Execute<TResult>(expression) : (TResult)value;
  27:      this.NotifyExecuted(expression, result);
  28:      return result;
  29:    }
  30:    public object Execute(Expression expression)
  31:    {
  32:      object value;
  33:      bool handled = this.NotifyExecuting(expression, out value);
  34:      object result = !handled ? this.wrapped.Execute(expression) : value;
  35:      this.NotifyExecuted(expression, result);
  36:      return result;
  37:    }
  38:    public bool NotifyExecuting(Expression expression, out object result)
  39:    {
  40:      var e = new ExpressionExecuteEventArgs { Expression = expression };
  41:      this.Executing(this, e);
  42:      if (e.Handled)
  43:      {
  44:        result = e.Result;
  45:        return true;
  46:      }
  47:      result = null;
  48:      return false;
  49:    }
  50:    public void NotifyExecuted(Expression expression, object result)
  51:    {
  52:      var e = new ExpressionExecuteEventArgs
  53:        {
  54:          Expression = expression, 
  55:          Result = result
  56:        };
  57:      this.Executed(this, e);
  58:    }
  59:  }

Vor und nach der Evaluierung einer Abfragebedingung feuert der QueryInterceptorProvider die Executing und Executed Events, auf die der Cache reagieren wird. Die zu diesen Events gehörenden ExpressionExecuteEventArgs sehen wie folgt aus.

   1:  public class ExpressionExecuteEventArgs : EventArgs
   2:  {
   3:    public bool Handled { get; set; }
   4:    public object Result { get; set; }
   5:    public Expression Expression { get; set; }
   6:  }

Diese EventArgs liefern die Expression, die die Abfragebedingung bildet. Diese Expression wird verwendet um einen eindeutigen Key für den Cache zu erzeugen. Ein Aufruf von ToString() auf der Expression reicht hierfür leider nicht. Warum das so ist und was sich dagegen tun lässt beschreibt Pete Montgomery in seinem Blog.

Mit diesen Komponenten ist es nun nur noch ein kleiner Schritt zum CachingCustomerRepository. Dieses verwendet die mit .NET 4.0 hinzugekommen Strukturen aus dem System.Runtime.Caching-Namespace. Denkbar ist aber auch jede andere Cache-Implementierung wie beispielsweise der AppFabric-Cache.

   1:  public class CachingCustomerRepository : ICustomerRepository
   2:  {
   3:    private readonly ICustomerRepository inner;
   4:    private readonly ObjectCache cache;
   5:    private QueryInterceptor<Customer> customers;
   6:    public CachingCustomerRepository(ICustomerRepository inner)
   7:    {
   8:      this.inner = inner;
   9:      this.cache = new MemoryCache(typeof(CachingCustomerRepository).FullName);
  10:      this.customers = new QueryInterceptor<Customer>(this.inner.Customers, new QueryInterceptorProvider(this.inner.Customers.Provider));
  11:      this.customers.QueryProvider.Executing += this.OnQueryExecuting;
  12:      this.customers.QueryProvider.Executed += this.OnQueryExecuted;
  13:    }
  14:    public IQueryable<Customer> Customers
  15:    {
  16:      get { return this.customers; }
  17:    }
  18:    private void OnQueryExecuted(object sender, ExpressionExecuteEventArgs e)
  19:    {
  20:      string cacheKey = e.Expression.GetCacheKey();
  21:      IQueryable<Customer> cachedResult = this.cache[cacheKey] as IQueryable<Customer>;
  22:      if (cachedResult == null)
  23:      {
  24:        var evaluatedQueryable = ((IEnumerable<Customer>)e.Result).ToList().AsQueryable();
  25:        this.cache[cacheKey] = evaluatedQueryable;
  26:      }
  27:    }
  28:    private void OnQueryExecuting(object sender, ExpressionExecuteEventArgs e)
  29:    {
  30:      string cacheKey = e.Expression.GetCacheKey();
  31:      IQueryable<Customer> cachedResult = this.cache[cacheKey] as IQueryable<Customer>;
  32:      if (cachedResult != null)
  33:      {
  34:        e.Handled = true;
  35:        e.Result = cachedResult;
  36:      }
  37:  }

Diese Klasse wrappt eine beliebige andere Implementierung von ICustomerRepository und erweitert deren Fähigkeiten vollkommen transparent um das Caching von Abfrageergebnissen. Dazu wird die Customers-Property in einen QueryInterceptor gepackt. Für die Executing/Executed-Events des zugehörigen QueryInterceptorProviders werden zwei Listener registriert. Der erste prüft vor der Abfrage, ob ein entsprechendes Ergebnis schon im Cache vorliegt und bedient die Abfrage gegebenenfalls aus dem Cache. Der zweite nimmt das Ergebnis in den Cache auf, falls es dort nicht schon vorhanden ist.

Die vorgestellte Lösung ist eine technische Machbarkeitsstudie. Sie legt sich vollkommen transparent um den Datenzugriff und verbirgt somit den Caching-Mechanismus komplett vor dem Aufrufer. Dadurch wird er von der Komplexität des Caching isoliert.

Allerdings hat er so auch keine Möglichkeit mit dem Wissen um den Kontext einer Abfrage Optimierungen beim Caching vorzunehmen.

Welche Performancegewinne sich mit einem sehr expliziten Umgang mit dem Caching erreichen lassen zeigt dieses Video. Es beschreibt, wie StackOverflow mit geringem Einsatz von Hardware die Antwortzeiten seiner Seiten kurz hält.

Und stets wiederkehrende Fragen muss der Verwender trotzdem für das jeweilige Szenario klären:

  • Muss tatsächlich jede Abfrage gecached werden?
  • Können Teile der Ergebnisse im Cache für andere Abfragen wiederverwendet werden?
  • Wiederholen sich Abfragen häufig genug um den Aufwand für das Caching zu rechtfertigen?
  • Was passiert bei Änderungen, beispielsweise durch ein Update(Customer)?
  • Bringt das Caching für das geplante Szenario tatsächlich Effizienzgewinne?
  • Beeinflusst das Caching die Konsistenz der Ergebnisse?

Wie bereits ganz zu Beginn angesprochen gibt es eben leider auch für das Caching keine allgemeingültige Lösung.