Soms zijn het maar kleine wijzigingen die je code net dat extra beetje cleaner kunnen maken. Een tijdje terug was ik bezig met het implementeren van caching. Hierbij schreef ik regelmatig code die controleert of data in de cache zit. Zo ja, dan wordt deze data teruggegeven. Zo nee, dan wordt de data opgehaald en in de cache geplaatst. De code ziet er steeds ongeveer zo uit:

public SomeObject GetSomeObject() 
{
    string cacheKey = "SomeCacheKey";
    SomeObject thingToCache;
    if (!cache.TryGet(cacheKey, out thingToCache))
    {
        // do stuff to fetch the data
        // ..
        thingToCache = ...;
        cache.Set(cacheKey, thingToCache);
    }
    return thingToCache;
}

In dit voorbeeld verwijst cache naar een eenvoudige caching module, die gebruik maakt van Microsoft’s MemoryCache class. De module implementeert de volgende interface:

bool TryGet<T>(string cacheKey, out T value);
void Set(string cacheKey, object value);

De TryGet methode probeert een waarde uit de cache te halen via de opgegeven cache key. Als dit lukt, dan geeft de methode true terug en wijst de gevonden waarde toe aan de out parameter. Als het niet lukt, dan komt er false terug en blijft de out parameter leeg. Dit is een relatief eenvoudige manier om het Cache-Aside pattern te implementeren. Set spreekt voor zich: hiermee schrijf je een object weg in de cache onder de opgegeven cache key.

Na verloop van tijd merkte ik dat ik dit patroon telkens opnieuw aan het schrijven was. Iedere keer als gegevens gecacht moesten worden, kwam dit patroon weer terug. Het zijn maar een paar regels, maar toch zat deze duplicatie me niet lekker. Het volgt namelijk niet het DRY principe: Don’t Repeat Yourself.

Als je dezelfde code telkens herhaalt, is er een voor de hand liggende refactoring die je kunt toepassen om de herhaling te voorkomen: Extract Method, oftewel het verplaatsen van de code naar een aparte methode. Het lastige in dit caching patroon is echter dat er drie zaken variabel zijn. Voor elk van deze drie zaken moet een generieke oplossing worden verzonnen. Het gaat om:

  1. Een variabele waarde, de cache key
  2. Een variabel type, namelijk het type van het object dat je wilt cachen
  3. Een variabele serie statements, namelijk de logica die je moet uitvoeren om de data op te halen

Gelukkig biedt C# voor elk van deze drie variabele zaken een bijpassende constructie om dit generiek te kunnen maken:

  1. Waardes kun je heel eenvoudig toekennen aan een variabele die je doorgeeft tussen methodes.
  2. Voor variable types zijn er de generics. Dit zie je ook al in de TryGet methode van de ICache interface.
  3. Een serie statements groepeer je in methode, die je met behulp van de Func<T> delegate kunt doorgegeven tussen methodes.

Door deze drie constructies te combineren, is het mogelijk om een methode te schrijven die het bovenstaande patroon op een generieke manier implementeert. Dit is het eindresultaat:

 1public interface ICache
 2{
 3    T Get<T>(string cacheKey, Func<T> retrieveData);
 4}
 5
 6public class Cache : ICache
 7{
 8    private static readonly DateTimeOffset CACHE_DURATION = DateTimeOffset.UtcNow.AddDays(1);
 9    private MemoryCache memoryCache;
10
11    public Cache()
12    {
13        memoryCache = MemoryCache.Default;
14    }
15
16    public T Get<T>(string cacheKey, Func<T> retrieveData)
17    {
18        T value;
19        if (!TryGet(cacheKey, out value))
20        {
21            value = retrieveData();
22            Set(cacheKey, value);
23        }
24        return value;
25    }
26
27    private void Set(string cacheKey, object value)
28    {
29        memoryCache.Add(cacheKey, value, CACHE_DURATION);
30    }
31
32    private bool TryGet<T>(string cacheKey, out T value)
33    {
34        if (memoryCache.Contains(cacheKey))
35        {
36            value = (T)memoryCache.Get(cacheKey);
37            return true;
38        }
39        value = default(T);
40        return false;
41    }
42}

Op regel 16 zie je de cacheKey terugkomen. Deze waarde wordt als argument meegegeven aan de methode.

Het tweede argument van de methode is een delegate. Op deze manier hoeft de methode niet te weten hoe hij aan de data moet komen die gecacht moet worden. Je kunt dit overlaten aan de code die deze methode aanroept, die dit als een Func<T> meegeeft.

Regel 18 definieert een lege ‘placeholder’ voor de gecachte data. Deze wordt in regel 19 gevuld door de TryCache methode als de data in de cache zit, of in regel 38 door een standaard waarde als de data niet in de cache zit, met behulp van C#’s default keyword.

Regels 21 en 22 worden alleen uitgevoerd als de waarde niet in de cache zit. Hier wordt de meegegeven Func<T> aangeroepen om de data op te halen, en het resultaat ervan wordt opgeslagen in de cache in regel 22.

Verder zie je het gebruik van generics terug. Door zowel het return type als het type van de Func<T> generiek te maken, is deze methode te gebruiken voor ieder mogelijk type dat je maar wilt cachen.

En met deze constructie is het patroon netjes geïsoleerd in de nieuwe Get methode. De aanroep ziet er alsvolgt uit:

return cache.Get("MyCacheKey", () => {
    SomeObject data = ...;    // Write logic to fetch the data when it is not in the cache.
    return data;
});

Hiermee is een patroon van 4 regels dat zich telkens herhaalt, teruggebracht tot één enkele methode-aanroep.