CacheContainer

Stellt das Grundgerüst für einfaches Caching als abstrakte Basisklasse bereit.
Mit der generischen CacheContainer-Klasse kann kurzerhand mit einem Delegate ein fertiger Cache verwendet werden.

Das Problem:

Sellt euch vor Ihr benötigt zur Laufzeit die Datei ‚ExampleImage.png‘ welche sich auf einem Netzlaufwerk befindet. Je nach Qualität der Verbindung geht der Zugriff mal schneller oder langsamer. Sagen wir mal im Schnitt 1 Sekunde. Wenn Ihr nun auf dieselbe Datei mehrmals, z.b. 10 mal, zugreift sind das schon 10 Sekunden die der User warten muss.

Beispiel:
In meinem Beispiel simuliere ich die 1 Sekunde Wartezeit indem der Thread einfach 1 Sekunde wartet. Außerdem wird für Analysezwecke noch die Dauer aufgezeichnet.

static void Main(string[] args)
{
    const string path = @"D:\\ExampleImage.png";
    var startDate = DateTime.Now;

    for (int i = 0; i < 10; i++)
    {
        byte[] imageAsBytes = null;

        using (var memStream = new MemoryStream())
        using (var stream = File.OpenRead(path))
        {
            stream.CopyTo(memStream);
            // Code above will take 1 second.
            Thread.Sleep(1000);

            imageAsBytes = memStream.ToArray();
        }

        // Do something amazing with the image!
        Console.WriteLine(string.Format("#{0}: {1}", i, imageAsBytes.Count()));
    }

    Console.WriteLine(string.Format("Finished after {0} sec.", (DateTime.Now - startDate).Seconds));
    Console.ReadLine();
}

Hier bietet sich Caching an. Das bedeutet das die Datei einmalig geladen und direkt zwischengespeichert wird um bei den nächsten Zugriffen einen erheblichen Geschwindigkeitsvorteil zu haben.

Ist-Situation

Wenn einfaches Caching in Dot.Net gebraucht wird wird dies häufig mit einem Dictionary realisiert. Dafür wird einfach vor dem öffnen des Streams geprüft ob das Bild bereits im Cache enthalten ist (als Key). Sollte dies nicht der Fall sein wird das Bild nach dem Laden zu dem Cache hinzugefügt (wodurch der Key bei der nächsten Prüfung gefunden wird und ein Zugriff auf das Netzlaufwerk nicht mehr nötig ist).

private static readonly Dictionary<string, byte[]> _cache = new Dictionary<string, byte[]>();

static void Main(string[] args)
{
    const string path = @"D:\\Floor 1.jpeg";
    var startDate = DateTime.Now;

    for (int i = 0; i < 10; i++)
    {
        byte[] imageAsBytes = null;
        // !!! Check first if wanted image is already in cache !!!
        if (!_cache.TryGetValue(path, out imageAsBytes))
        {
            using (var memStream = new MemoryStream())
            using (var stream = File.OpenRead(path))
            {
                stream.CopyTo(memStream);
                // Code above will take 1 second.
                Thread.Sleep(1000);

                imageAsBytes = memStream.ToArray();
            }

            // !!! Very important to store the new value in cache !!!
            _cache.Add(path, imageAsBytes);
        }

        // Do something amazing with the image!
        Console.WriteLine(string.Format("#{0}: {1}", i, imageAsBytes.Count()));
    }

    Console.WriteLine(string.Format("Finished after {0} sec.", (DateTime.Now - startDate).Seconds));
    Console.ReadLine();
}

Mit dieser einfachen Änderung wurde ganze 9 Sekunden eingespart.

Wenn der Cache ordentlich implementiert wurde dann erfolgt der Zugriff außschließlich über Zugriffsmethoden und nicht inline (da caching häufig im größeren Rahmen Verwendung findet als nur innerhalb einer Methode). Außerdem wird so die Integrität des Caches gewährleistet.

(in unterem Beispiel wurde die Caching- und Lade-Logik in die Methode 'GetImageFromCache' ausgelagert.)

private static readonly Dictionary<string, byte[]> _cache = new Dictionary<string, byte[]>();

public static byte[] GetImageFromCache(string imagePath)
{
    if (String.IsNullOrEmpty(imagePath)) throw new ArgumentException("imagePath");

    byte[] imageAsBytes;
    // !!! Check first if wanted image is already in cache !!!
    if (!_cache.TryGetValue(imagePath, out imageAsBytes))
    {
        using (var memStream = new MemoryStream())
        using (var stream = File.OpenRead(imagePath))
        {
            stream.CopyTo(memStream);
            // Code above will take 1 second.
            Thread.Sleep(1000);

            imageAsBytes = memStream.ToArray();

            // !!! Very important to store the new value in cache !!!
            _cache.Add(imagePath, imageAsBytes);
        }
    }
    return imageAsBytes;
}

In der Main-Methode wird nun einfach die eben erstellte Methode aufgerufen (was dort nebenbei die Lesbarkeit des Codes erhöht).

static void Main(string[] args)
{
    const string path = @"D:\\Floor 1.jpeg";
    var startDate = DateTime.Now;

    for (int i = 0; i < 10; i++)
    {
        byte[] imageAsBytes = GetImageFromCache(path);

        // Do something amazing with the image!
        Console.WriteLine(string.Format("#{0}: {1}", i, imageAsBytes.Count()));
    }

    Console.WriteLine(string.Format("Finished after {0} sec.", (DateTime.Now - startDate).Seconds));
    Console.ReadLine();
}

Das ist jetzt nur ein kompaktes Beispiel im kleinen Rahmen. Derartige Caches habe ich bereits sehr oft implementiert und immer separat (was Redunanz zur Folge hatte). Manche Caches mussten dann auch ThreadSafe sein und andere sollten sich selbst bereinigen wenn ein gecachtes Objekt ungültig oder veraltet ist. Außerdem besteht bei obigen Code immer die Gefahr das ein unwissender Kollege das originalle Dictionary direkt manipuliert und mir somit meine Integrität versaut.

Soll-Situation

Als Lösung habe ich das Caching-Verhalten, zusammen mit dem Dictionary, als Klasse gekapselt.
Bevor ich die Details dieser Klasse erkläre möchte ich vorerst das Ergebnis beim Verwenden dieser demonstrieren.

public static CacheContainer<string, byte[]> ImageCache { get; private set; }

static void Main(string[] args)
{
    const string path = @"D:\\Floor 1.jpeg";
    var startDate = DateTime.Now;

    // Initialize the Cache.
    ImageCache = new CacheContainer<string, byte[]>((key) =>
    {
        if (String.IsNullOrEmpty(key)) return null;

        byte[] imageAsBytes = null;

        using (var memStream = new MemoryStream())
        using (var stream = File.OpenRead(key))
        {
            stream.CopyTo(memStream);
            // Code above will take 1 second.
            Thread.Sleep(1000);

            imageAsBytes = memStream.ToArray();
        }
        return new CacheCallbackResult<byte[]>(true, imageAsBytes);
    });

    for (int i = 0; i < 10; i++)
    {
        // Get image from cache or load it from disc.
        var imageAsBytes = ImageCache.GetValue(path);

        // Do something amazing with the image!
        Console.WriteLine(string.Format("#{0}: {1}", i, imageAsBytes.Count()));
    }

    Console.WriteLine(string.Format("Finished after {0} sec.", (DateTime.Now - startDate).Seconds));
    Console.ReadLine();
}

Das private Dictionary-Cache-Feld musste nun einem Property weichen welcher die neue Cache-Klasse bereitstellt. Das Property wird direkt nach betreten der Main-Methode initialisiert. Der Konstruktor erwartet dabei ein Delegate welches einen Key als Parameter und als Result ein Object vom Typ CacheCallbackResult erwartet (dazu später mehr). Kurz erklärt passiert in dem oben erstellten Delegate das gleiche wie in der Methode 'GetImageFromCache', aber mit dem Unterschied das die Prüfung des Methodenvertrags keinen Fehler wirft sondern stattdessen null zurück gibt.

Anschließend wird die Schleife 10mal aufgerufen welche die Methode ‚GetValue‘ von dem Cache-Objekt aufruft.

Von der Code-Menge gesehen hat sich wenig geändert, aber unter der Haube hat sich sehr viel getan.
Die Zugriffe gewährleisten nun immer das die Integrität erhalten bleibt, ThreadSafe und zusätzliche Methoden mit denen der Cache wieder geleert werden kann.

Die Basis-Klasse

Die Klasse wurde auf zwei Ebenen aufgeteilt. Die Parent-Klasse ist abstrakt und arbeitet ohne Delegate. Sie gewährleistet die Stabilität des Caches für alle erbenden Klassen.

public abstract class CacheContainerBase<T1, T2>
{
    private readonly object _sync = new object();
    private readonly Dictionary<T1, T2> _cacheDict = new Dictionary<T1, T2>();

    // .:: Methods - Essential

    public T2 GetValue(T1 key)
    {
        T2 value;
        if (!TryGetValue(key, out value))
        {
            throw new KeyNotFoundException(string.Format("The given key ({0}) is not supported to get a value.", (object)key ?? "NULL"));
        }
        return value;
    }

    public bool TryGetValue(T1 key, out T2 value)
    {
        bool hasGotValue = false;
        lock (_sync)
        {
            // Check if key exists in cache.
            hasGotValue = _cacheDict.TryGetValue(key, out value);
            if (!hasGotValue)
            {
                // Then get the value from abstract method which an inherited class ensures to return something.
                hasGotValue = TryGetNonCachedValue(key, out value);

                // Cache the result.
                if (hasGotValue)
                {
                    _cacheDict.Add(key, value);
                }
            }
        }
        return hasGotValue;
    }

    protected abstract bool TryGetNonCachedValue(T1 key, out T2 value);
}

Zwei generische Klassenparameter werden vorausgesetzt (Key und Value).
Das private Feld ‚_sync‘ soll die Klasse ThreadSafe machen und ‚_cacheDict‘ ist das Kernelement der ganzen Klasse und sollte aus dem Beispiel der Ist-Situation noch bekannt sein.

Die Methode ‚GetValue‘ versucht einen Wert anhand des übergebenen Keys zu finden indem sie die Methode ‚TryGetValue‘ aufruft. Sollte der Key nicht erlaubt sein fliegt eine ‚KeyNotFoundException‘-Exception.

‚TryGetValue‘ gibt ebenfalls einen Wert anhand des Keys zurück (über ‚out‘ Parameter) wirft aber keine Exception sondern gibt stattdessen false zurück.

Um einen Wert zu finden wird zuerst im Cache nachgesehen ob er bereits existiert. Wenn nicht wird die abstrakte Method ‚TryGetNonCachedValue‘ aufgerufen.

Um einer erbenden Klasse mehr Kontrolle über den Cache zu geben ohne das dieser gefährdet wird stellen wir weitere Methoden zu Manipulation bereit.

// .:: Methods - Clear Cache

protected void ClearCache()
{
    lock (_sync)
        _cacheDict.Clear();
}
protected void ClearCache(IEnumerable keys)
{
    if (keys == null) throw new ArgumentNullException("keys");

    lock (_sync)
    {
        foreach (var key in keys)
        {
            _cacheDict.Remove(key);
        }
    }
}

Die überladene ‚ClearCache‘-Methode macht das was Sie sagt. Entweder werden alle Einträge aus dem Cache entfernt oder nur für bestimmte Keys.

// .:: Methods - Cache Contains Key/Value

protected bool CacheContainsKey(T1 key)
{
    lock (_sync)
        return _cacheDict.ContainsKey(key);
}
protected bool CacheContainsValue(T2 value)
{
    lock (_sync)
        return _cacheDict.ContainsValue(value);
}

‚CacheContainsKey‘ oder ‚CacheContainsValue‘ geben je true zurück bei passender Übereinstimmung.

Zu erwähnen ist das alle vier Methoden das selbe Objekt für lock verwenden welches auch die Methode ‚TryGetValue‘ verwendet. Das führt dazu das jeder asynchrone Zugriff über alle Methoden hinweg synchronisiert wird und damit die Integrität des Caches auch bei asynchroner Verwendung sichergestellt ist.

Außerdem sind die vier Methoden als protected markiert um einer möglichen Child-Klasse Kontrolle über das Objekt zu geben ohne dabei nach außen Funktionalität zu öffnen (ein praktisches Anwendungsbeispiel wird später erläutert).

Cache-Klasse mithilfe von Delegate

public class CacheContainer<T1, T2> : CacheContainerBase<T1, T2>
{
    private readonly Func<T1, CacheCallbackResult> _tryGetNonCachedValueFunc;

    public CacheContainer(Func<T1, CacheCallbackResult> tryGetNonCachedValueFunc)
    {
        if (tryGetNonCachedValueFunc == null)
            throw new ArgumentNullException("tryGetNonCachedValueFunc");
        _tryGetNonCachedValueFunc = tryGetNonCachedValueFunc;
    }

    // .:: Methods - Essential

    protected override bool TryGetNonCachedValue(T1 key, out T2 value)
    {
        var result = _tryGetNonCachedValueFunc.Invoke(key);
        value = result == null
            ? default(T2)
            : result.Value;
        return result == null
            ? false
            : result.KeyIsValid;
    }
}

Die Child-Klasse überschreibt die abstrakte Methode vom Parent (TryGetNonCachedValue). Dort wird lediglich ein Delegate aufgerufen welches im Konstruktor der Klasse zwingend erforderlich ist.

Das Delegate muss als Parameter den Key (T1) entgegennehmen und den Value gekapselt in der Klasse CacheCallbackResult zurückgeben (dazu später mehr). Is das Result NULL oder aber dessen Property ‚IsKeyValid‘ false ist zu erwarten das der Key im Parameter nicht gültig ist und es kann false zurückgegeben werden.

// .:: Methods - Protected to Public

public new void ClearCache()
{
    base.ClearCache();
}
public new void ClearCache(IEnumerable keys)
{
    base.ClearCache(keys);
}

public new bool CacheContainsKey(T1 key)
{
    return base.CacheContainsKey(key);
}
public new bool CacheContainsValue(T2 value)
{
    return base.CacheContainsValue(value);
}

Da die Child-Klasse nach außen recht geschloßen ist empfehle ich die vier Methoden vom Parent welche protected sind nach außen freizugeben. Dies ist enorm hilfreich für Debugging-Zwecke.

Übrigens wird in der Child-Klasse komplett auf das ‚lock‘-Schlüßelwort verzichten da der Parent sich bereits darum kümmert.

CacheCallbackResult

Welcher Delegatetyp eignet sich am besten wenn man eine Methoden-Signatur benötigt welche einen Parameter und zwei Rückgabewerte verlangt? Bei einer normalen Methode würde man wohl einfach einen out Parameter verwenden was folgender Delegate-Signatur entspricht.

delegate bool TryGetNonCachedValueDelegate(T1 key, out T2 value);

Leider unterstützen Inline-Delegates keine 'out'-Parameter. Es währe sehr schade wenn der CacheContainer keine Inline-Schreibweise unterstützden würde. Daher bin ich zum Entschluss gekommen das man beide Results einfach in folgender Wrapper-Klasse zurückgibt und auf das 'out' verzichtet.

public class CacheCallbackResult
{
    public CacheCallbackResult(bool keyIsValid, T value)
    {
        this.KeyIsValid = keyIsValid;
        this.Value = value;
    }
    public bool KeyIsValid { get; private set; }
    public T Value { get; private set; }
}

Der erste Parameter 'keyIsValid' enthält die Information ob der Key eine gültige Struktur aufweist (ungültige Keys geben natürlich keinen Wert zurück und werden nicht im Cache gehalten).
Als zweiten, generischen, Parameter 'value' wird der Value erwartet welcher zu dem Key gefunden wurde.

Der komplette Code

Den kompletten Code könnt Ihr unten (mit Kommentaren) finden. Ich habe dort noch zwei Ergänzung in der Klasse ‚CacheContainerBase‘ gemacht welche ebenfalls hilfreich sind.

1. Ein zweiter Cache welcher sich alle Invaliden Keys merkt.
2. Ein Property ‚CanCacheInvalidKeys‘ welches bei false obiges Verhalten unterdrückt.

CacheContainerBase

/// <summary>
/// Provide basic caching by key in a thread safe way.
/// </summary>
/// <typeparam name="T1">The key type.</typeparam>
/// <typeparam name="T2">The value type.</typeparam>
public abstract class CacheContainerBase<T1, T2>
{
    // .:: Fields

    /// <summary>
    /// Sync all method calls to avoid async problems.
    /// </summary>
    private readonly object _sync = new object();

    /// <summary>
    /// The cache for valid keys.
    /// </summary>
    private readonly Dictionary<T1, T2> _cacheDict = new Dictionary<T1, T2>();

    // The hashset is the better collection for the job of caching invalid keys, but not available in pcl.
    //private readonly HashSet<T1> _cache_InvalidKeys = new HashSet<T1>();
    /// <summary>
    /// The cache for invalid keys.
    /// </summary>
    private readonly Dictionary<T1, byte> _cache_InvalidKeys = new Dictionary<T1, byte>();


    // .:: Constructor

    public CacheContainer()
    {
        this.CanCacheInvalidKeys = true;
    }


    // .:: Properties

    /// <summary>
    /// Set to false in order to avoid caching for keys which are not valid to request.
    /// </summary>
    protected bool CanCacheInvalidKeys { get; set; }


    // .:: Methods - Essential

    /// <summary>
    /// Get the value which belongs to given key.
    /// </summary>
    /// <param name="key">The expected value belongs to this key.</param>
    /// <exception cref="KeyNotFoundException">The key is not valid for this cache.</exception>
    public T2 GetValue(T1 key)
    {
        T2 value;
        if (!TryGetValue(key, out value))
        {
            throw new KeyNotFoundException(string.Format("The given key ({0}) is not supported to get a value.", (object)key ?? "NULL"));
        }
        return value;
    }
    /// <summary>
    /// Get the value which belongs to given key.
    /// </summary>
    /// <param name="key">The expected value belongs to this key.</param>
    /// <param name="value">The expected value.</param>
    /// <returns>Returns true if key is valid. False if key is invalid to this cache.</returns>
    public bool TryGetValue(T1 key, out T2 value)
    {
        bool hasGotValue = false;
        lock (_sync)
        {
            // 1. Check if key exists in cache.
            hasGotValue = _cacheDict.TryGetValue(key, out value);
            if (!hasGotValue)
            {
                // 2. Check if key exists in invalid key cache.
                bool isKeyInvalid = this.CanCacheInvalidKeys
                    ? _cache_InvalidKeys.ContainsKey(key)
                    : false;
                if (isKeyInvalid)
                {
                    hasGotValue = false;
                    value = default(T2);
                }
                else
                {
                    // 3. Then get the value from abstract method which an inherited class ensures to return something.
                    hasGotValue = TryGetNonCachedValue(key, out value);

                    // 3.1 Cache the result.
                    if (hasGotValue)
                    {
                        _cacheDict.Add(key, value);
                    }
                    // 3.2 Cache the result in invalid keys.
                    else if (this.CanCacheInvalidKeys)
                    {
                        _cache_InvalidKeys.Add(key, byte.MinValue);
                    }
                }
            }
        }
        return hasGotValue;
    }


    /// <summary>
    /// This method is only called if given key was not found in cache.
    /// The abstract method should return a value (out parameter) which belongs to given key 
    /// and a bool which is true when given key was valid.
    /// The returned value will be cached then.
    /// </summary>
    /// <param name="key">The expected value belongs to this key.</param>
    protected abstract bool TryGetNonCachedValue(T1 key, out T2 value);


    // .:: Methods - Clear Cache

    /// <summary>
    /// Clear all cached entries.
    /// </summary>
    /// <remarks> Clear in normal cache and invalid key cache.</remarks>
    protected void ClearCache()
    {
        lock (_sync)
        {
            _cacheDict.Clear();
            _cache_InvalidKeys.Clear();
        }
    }
    /// <summary>
    /// Clear only cached entries which depends to given keys.
    /// </summary>
    /// <param name="keys">Remove only this keys from cache.</param>
    /// <remarks> Clear in normal cache and invalid key cache.</remarks>
    protected void ClearCache(IEnumerable<T1> keys)
    {
        if (keys == null) throw new ArgumentNullException("keys");

        lock (_sync)
        {
            foreach (var key in keys)
            {
                // 1. Remove from cache.
                if (!_cacheDict.Remove(key))
                {
                    // 2. Remove from invalid key cache.
                    _cache_InvalidKeys.Remove(key);
                }
            }
        }
    }


    // .:: Methods - Cache Contains Key/Value

    /// <summary>
    /// Check if given key is present in cache.
    /// </summary>
    /// <param name="key">The key which is present in cache.</param>
    /// <remarks> Check in normal cache and invalid key cache.</remarks>
    protected bool CacheContainsKey(T1 key)
    {
        lock (_sync)
        {
            return _cacheDict.ContainsKey(key)
                ? true
                : _cache_InvalidKeys.ContainsKey(key);
        }
    }
    /// <summary>
    /// Check if given value is present in cache.
    /// </summary>
    /// <param name="value">The value which is present in cache.</param>
    protected bool CacheContainsValue(T2 value)
    {
        lock (_sync)
            return _cacheDict.ContainsValue(value);
    }
}

CacheContainer

/// <summary>
/// Provide basic caching by key.
/// Get value is supported by a delegate from outside (no inheritance is required).
/// </summary>
/// <typeparam name="T1">The key type.</typeparam>
/// <typeparam name="T2">The value type.</typeparam>
/// <remarks>
/// Hinweis auf delegate und anwendungsbeispiel
/// und warum alle protected methoden public wurden
/// </remarks>
public class CacheContainer<T1, T2> : CacheContainerBase<T1, T2>
{
    /// <summary>
    /// Delegate which return a value for given key.
    /// </summary>
    private readonly Func<T1, CacheCallbackResult<T2>> _tryGetNonCachedValueFunc;

    /// <summary>
    /// Cache by key. Value is provided by given delegate.
    /// </summary>
    /// <param name="tryGetNonCachedValueFunc">The deleagte which returns a value for given key as 
    /// the 'CacheCallbackResult'-object (or the flase 'KeyIsValid'-flag).</param>
    public CacheContainer(Func<T1, CacheCallbackResult<T2>> tryGetNonCachedValueFunc)
    {
        if (tryGetNonCachedValueFunc == null) throw new ArgumentNullException("tryGetNonCachedValueFunc");
        _tryGetNonCachedValueFunc = tryGetNonCachedValueFunc;
    }

    // .:: Methods - Essential

    /// <summary>
    /// Overridden method which returns the value from key by invoke given delegate.
    /// </summary>
    /// <param name="key">The key to get the value.</param>
    /// <param name="value">The value which is excpected by given key.</param>
    /// <returns>False if given key is not supported.</returns>
    protected override bool TryGetNonCachedValue(T1 key, out T2 value)
    {
        var result = _tryGetNonCachedValueFunc.Invoke(key);
        value = result == null
            ? default(T2)
            : result.Value;
        return result == null
            ? false
            : result.KeyIsValid;
    }

    // .:: Methods - Protected to Public

    /// <summary>
    /// Clear all cached entries.
    /// </summary>
    /// <remarks> Clear in normal cache and invalid key cache.</remarks>
    public new void ClearCache()
    {
        base.ClearCache();
    }
    /// <summary>
    /// Clear only cached entries which depends to given keys.
    /// </summary>
    /// <param name="keys">Remove only this keys from cache.</param>
    /// <remarks> Clear in normal cache and invalid key cache.</remarks>
    public new void ClearCache(IEnumerable<T1> keys)
    {
        base.ClearCache(keys);
    }

    /// <summary>
    /// Check if given key is present in cache.
    /// </summary>
    /// <param name="key">The key which is present in cache.</param>
    public new bool CacheContainsKey(T1 key)
    {
        return base.CacheContainsKey(key);
    }
    /// <summary>
    /// Check if given value is present in cache.
    /// </summary>
    /// <param name="value">The value which is present in cache.</param>
    public new bool CacheContainsValue(T2 value)
    {
        return base.CacheContainsValue(value);
    }
}

/// <summary>
/// A helper class for CacheContainer to support a 'TryGetValue'-Behavior.
/// </summary>
/// <typeparam name="T"></typeparam>
public class CacheCallbackResult<T>
{
    public CacheCallbackResult(bool keyIsValid, T value)
    {
        this.KeyIsValid = keyIsValid;
        this.Value = value;
    }
    public CacheCallbackResult(Exception ex)
    {
        this.Exception = ex;
    }
    public bool KeyIsValid { get; private set; }
    public T Value { get; private set; }
    public Exception Exception { get; set; }
}

Themen:

- Themen: C#, Modul