Seit Version 6 bietet Entity Framework dem Entwickler die Möglichkeit, SQL-Befehle unter Verwendung sogenannter Command-Interceptoren abzufangen, bevor sie zur Datenbank gesendet werden. Dabei kann er auch die eigentliche Aktion unterdrücken, indem er selber ein Ergebnis für die jeweilige Operation bereitstellt.
Dieser Mechanismus drängt sich förmlich für zwei Anwendungsfälle auf: Logging und Caching. Während Logging auf dieser Basis bereits durch Entity Framework 6 implementiert wird, gibt es noch keine offizielle Caching-Implementierung. Dieser Artikel zeigt anhand eines Beispiels, wie der Entwickler Command-Interceptoren dazu nutzen kann. Dabei sei darauf hingewiesen, dass dieses Beispiel nicht in Hinblick auf den Produktions-Einsatz getestet wurde.
Implementierung
Der Dreh- und Angelpunkt der hier betrachteten Lösung ist eine Implementierung von IDbCommandInterceptor, welche den mit .NET 4 eingeführten Cache-Mechanismus nutzt. Die meiste Logik findet sich in der Methode ReaderExecuting, welche von IDbCommandInterceptor vorgegeben wird, wieder. Entity Framework ruft diese Methode auf, bevor es eine Abfrage zur Datenbank sendet.
Um den Cache zu nutzen, prüft ReaderExecuting, ob sich die Ergebnismenge der auszuführenden Abfrage bereits im Cache befindet. Dazu verwendet sie die SQL-Anweisung als Schlüssel für den Cache. Bei den Einträgen im Cache handelt es sich um DataTables. Aus diesen leitet ReaderExecuting einen DataReader ab und weist ihn an die Eigenschaft Result des übergebenen InterceptionContext-Objektes zu. Da Entity Framework nun auf diesem Weg ein Ergebnis erhält, sieht es davon ab, den SQL-Befehl an die Datenbank zu senden.
Falls die Methode ReaderExecuting den gewünschten Eintrag nicht im Cache findet, führt sie die Abfrage unter Verwendung von ExecuteQuery aus und verstaut die Ergebnismenge in einem DataTable, welchen es im Cache hinterlegt. Im Zuge dessen gibt sie auch eine mittels CreatePolicy erzeugte Cache-Policy an. Diese führt dazu, dass der Cache-Eintrag nach einer bestimmten Zeitspanne wieder aus dem Cache entfernt wird. Über den benutzerdefinierten VerySimpleChangeMonitor legt die betrachtete Logik darüber hinaus fest, dass der Cache-Eintrag auch zu entfernen ist, wenn der VerySimpleNotificationService des Interceptors dem VerySimpleChangeMonitor über ein Ereignis wissen lässt, dass sich die Daten in der Datenbank geändert haben.
Bei jedem Aufruf von NonQueryExecuted wird der VerySimpleNotificationService veranlasst, sämtliche VerySimpleChangeMonitor-Objekte darüber zu benachrichtigen, dass sich Daten geändert haben. Da Entity Framework NonQueryExecuted immer nach dem Ausführen eines DML- oder DDL-Befehls anstößt, wird der Cache nach jedem Schreibvorgang komplett geleert. Dies ist zwar einfach, führt jedoch auch zu unnötigen Invalidierungen. Eine effizientere, jedoch auch aufwändigere Lösung bestünde darin, zu prüfen, auf welche Cache-Einträge sich ein Schreibvorgang auswirkt und nur diese aus dem Cache zu entfernen. Lösungen dazu werden in weiteren Postings diskutiert.
Innerhalb der Methode ReaderExecuted hat ebenfalls eine Invalidierung des Caches zu erfolgen. Der Grund dafür ist, dass Entity Framework sowohl ReaderExecuting als auch ReaderExecuted für Batches, die neben DML-Anweisungen auch SELECT-Anweisungen enthalten, ausführt. Ein Beispiel dafür ist ein INSERT gefolgt von einem SELECT, welches die gerade eingefügten Daten retourniert.
public class SimpleCachingCommandInterceptor : IDbCommandInterceptor
{
private MemoryCache cache = MemoryCache.Default;
private DbProviderFactory factory;
private int absoluteExpirationInMinutes;
private VerySimpleNotificationService notificationService = new VerySimpleNotificationService();
private void InvalidateCache() {
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Invalidate Cache");
Console.ForegroundColor = ConsoleColor.Gray;
lock (this)
{
notificationService.Notify();
}
}
public SimpleCachingCommandInterceptor(DbProviderFactory factory, int absoluteExpirationInMinutes)
{
this.factory = factory;
this.absoluteExpirationInMinutes = absoluteExpirationInMinutes;
}
public void NonQueryExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
{
this.InvalidateCache();
}
public void NonQueryExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
{
}
public void ReaderExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<System.Data.Common.DbDataReader> interceptionContext)
{
// ReaderExecuting is also called for batches
// like insert into [...]; select @@identity
var sql = command.CommandText.ToLower();
if (!sql.StartsWith("select "))
{
return;
}
if (cache.Contains(command.CommandText))
{
var table = (DataTable)cache[command.CommandText];
interceptionContext.Result = table.CreateDataReader();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Cache hit");
Console.ForegroundColor = ConsoleColor.Gray;
}
else
{
var table = ExecuteQuery(command);
var policy = CreatePolicy(command);
cache.Add(command.CommandText, table, policy);
interceptionContext.Result = table.CreateDataReader();
}
}
private DataTable ExecuteQuery(System.Data.Common.DbCommand command)
{
var table = new DataTable();
var adapter = factory.CreateDataAdapter();
adapter.SelectCommand = command;
adapter.Fill(table);
return table;
}
private CacheItemPolicy CreatePolicy(System.Data.Common.DbCommand command)
{
var sql = command.CommandText;
var policy = new CacheItemPolicy();
policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(absoluteExpirationInMinutes);
var changeMonitor = new VerySimpleChangeMonitor(notificationService);
policy.ChangeMonitors.Add(changeMonitor);
return policy;
}
public void ReaderExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<System.Data.Common.DbDataReader> interceptionContext)
{
// ReaderExecuting is also called for batches
// like insert into [...]; select @@identity
var sql = command.CommandText.ToLower();
if (!sql.StartsWith("select "))
{
// Invalidate Cache
this.InvalidateCache();
}
}
public void ScalarExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
{
}
public void ScalarExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
{
}
}
Hilfs-Klassen zum Invalidieren von Cache-Einträgen
Der Vollständigkeit halber finden sich die beiden hier verwendeten Hilfs-Klassen VerySimpleChangeMonitor und VerySimpleNotificationService in den folgenden beiden Listings.
public class VerySimpleNotificationService
{
public event OnChange OnChange;
public void Notify()
{
if (OnChange != null)
{
OnChange(null);
}
}
}
public class VerySimpleChangeMonitor : ChangeMonitor
{
private string id = Guid.NewGuid().ToString();
private VerySimpleNotificationService simpleNotificationService;
public VerySimpleChangeMonitor(VerySimpleNotificationService service)
{
this.simpleNotificationService = service;
this.simpleNotificationService.OnChange += simpleNotificationService_OnChange;
this.InitializationComplete();
}
void simpleNotificationService_OnChange(string tableName)
{
this.simpleNotificationService.OnChange -= simpleNotificationService_OnChange;
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Cache invalidation");
Console.ForegroundColor = ConsoleColor.Gray;
this.OnChanged(this);
}
protected override void Dispose(bool disposing)
{
}
public override string UniqueId
{
get { return id; }
}
}
Command-Interceptor registrieren
Damit Entity Framework den Command-Interceptor auch verwendet, ist dieser innerhalb eines DbConfiguration-Derivats zu registrieren. Sofern es nur ein einziges DbConfiguration-Derivat im Projekt, in dem auch der Context existiert, gibt, wird dieses hiervon automatisch geladen.
public class CustomDbConfiguration : DbConfiguration
{
public CustomDbConfiguration()
{
AddInterceptor(new SimpleCachingCommandInterceptor(
SqlClientFactory.Instance, 60 * 8));
}
}