Custom response caching in ASP.NET Core


Isn't it fun,

to instantly listen to a blog on the go? PLAY !

 
 

Custom_response_aspnet

Cache invalidation is the process that entries in the cache which can be replaced or removed. The server cannot force the new version of the page to be used instead of the cached page, once the page is cached in the browser. So, pages likely to change cannot be cached for a very long time at the client and we cannot change this too.

On the server-side, built-in response caching does not permit cache invalidation. Same cache duration for both for instructing the browser to cache the page, and for server-side caching. In some applications, these restrictions make the built-in response caching unusable. We will execute the cache for a single web server using IMemoryCache.

Table of Content

There are certain three methods to invalidate the cache but not all caching proxies support these methods.

Purge: It will remove the content from proxy caching immediately. If the client requests the data again, then data will be fetched from the application and stored in cache proxy. All variants of the cached content will be removed by this method.

Refresh: Even if the cached content is available, it will fetch the data from the application. The new version of data will replace the previously stored content in the cache form application. Only one variant of cached content will be affected by this method.

Ban: As the name suggested, cached content will be added to the blacklist. The client can request the blacklist, if the content matches then new content is fetched from the app and added to the cache. This method does not immediately remove cached content from the proxy cache. Instead, cached content is updated with the new client request that information.

In our blog, to allow caching to be fast we will add the middleware.

IMemoryCache

IMemoryCache is similar to the System.Runtime.Caching.MemoryCache.

public interface IMemoryCache :IDisposable
{
    bool GetValue(object key, out object value);
    ICacheEntryCreateEntryValue(object key);
    void RemoveCache(object key);
}

In MVC, it will be automatically registered. We can register memory cache in configure services method using:

services.AddMemoryCache();

IMemoryCache Example

If we have data in the cache memory then we will use the IMemoryCache to avoid getting data from the database query.

public class CustomerService
{
    private const string CustomerCacheKey = "customer-cache-key";
	
    private readonlyIMemoryCache _cache;
    private readonlyIDatabase _db;

    public CutomerService(IMemoryCache cache, IDatabasedb)
    {
        _cache = cache;
        _db = db;
    }
	
    public async Task>GetCustomers()
    {
        if (_cache.TryGet(CustomerCacheKey , out IEnumerable customers))
        {
            return customers;
        }
		
        customers= await _db.getAll(...);
		
        _cache.Set(CustomerCacheKey , customers, ...);
		
        return customers;
    }
}

When we are saving data in cache then the "Set" method provides many options to expire cache.

_cache.Set("key", item, TimeSpan.FromDays(1));
Example

Control Server Caching

To control the cached individual page, we will use the action filter attribute.

public class CacheActionAttribute :ActionFilterAttribute
{
    public int? ClientCacheDuration{ get; set; }
    public int? ServerCacheDuration{ get; set; }
    public string Tags { get; set; }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (ClientCacheDuration .HasValue)
        {
context.HttpContext.Items[Constants.ClientCacheDuration ] = ClientCacheDuration .Value;
        }

        if (ServerCacheDuration .HasValue)
        {
context.HttpContext.Items[Constants.ServerCacheDuration ] = ServerCacheDuration .Value;
        }

        if (!string.IsNullOrWhiteSpace(Tags))
        {
context.HttpContext.Items[Constants.Tags] = Tags;
        }

base.OnActionExecuting(context);
    }
}

This will add attribute to the HttpContext items collection. This method is used for communication between caching middleware and the action.

Using Middleware caching

public async Task Invoke(HttpContext context)
{
varcacheKey = CacheKeyBuild(context);

    if (_cache.TryGet(cacheKey , out CachedPage page))
    {
        await WriteResponse(context, page);

        return;
    }

ApplyClientHeaders(context);

    if (IsNotServerCachable(context))
    {
        await _next.Invoke(context);

        return;
    }            

    page = await CaptureResponse(context);

    if (page != null)
    {
varcacheServerDuration = GetCacheDuration(context, Constants.ServerDuration);

        if (cacheServerDuration.HasValue)
        {
var tags = GetCacheTags(context, Constants.Tags);

            _cache.Set(cacheKey , page, cacheServerDuration.Value, tags);
        }
    }            
}
 

We can build cache key as per our requirement. If the page is retrieved from the cache then it is written to the response.

If the page is not found then we have to do some code. If applicable, first we set the client caching header and then check if we can cache the request. Here, we will only want to GET methods if another method is used then we should invoke the middleware component.

private async Task WriteResponse(HttpContext context, CachedPage page)
{
foreach (varcacheHeader in page.Headers)
    {
context.Response.Headers.Add(cacheHeader );
    }

    await context.Response.Body.WriteAsync(page.Content, 0, page.Content.Length);
}

Now we have to capture the requested output from the middleware component.

Capture the response

Capture the page response to swap default response body stream with MemoryStream.

private async TaskCaptureResponsePage(HttpContext context)
{
varresponsePageStream = context.Response.Body;

    using (varbufferMemory = new MemoryStream())
    {
        try
        {
context.Response.Body = bufferMemory ;

            await _next.Invoke(context);
        }
        finally
        {
context.Response.Body = responsePageStream ;
        }

        if (bufferMemory .Length == 0) return null;

var bytes = bufferMemory .ToArray(); 

responsePageStream .Write(bytes, 0, bytes.Length);

        if (context.Response.StatusCode != 200) return null;

        return BuildCachedPage(context, bytes);
    }
}

Client Caching

When we have to send cache header to the browser, we will use the simple approach.\

public void SendClientHeaders(HttpContext context)
{
context.Response.OnStarting(() =>
    {
varcacheDurationClient = GetCacheDuration(context, Constants.ClientCacheDuration );

        if (cacheDurationClient .HasValue&&context.Response.StatusCode == 200)
        {
            if (cacheDurationClient == TimeSpan.Zero)
            {
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
                {
NoCache = true,
NoStore = true,
MustRevalidate = true
                };
context.Response.Headers["Expires"] = "0";
            }
            else
            {
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
MaxAge = cacheDurationClient
                };
            }
        }

        return Task.CompletedTask;
    });            
}

ClientCacheDuration is coming from the action filter. Now we will invalidate the cache.

Cache Invalidation

We will remove all the cache by custom approach before they naturally expire.

public void RemoveCache(string cacheKey)
{
    _cache.Remove(Constants.CacheKeyPrefix + cacheKey);
}

public void RemoveByTag(string tag)
{
    if (_cache.TryGetValue(Constants.CacheTagPrefix + tag, out CancellationTokenSourcetokenSource))
    {
tokenSource.Cancel();

        _cache.Remove(Constants.CacheTagPrefix + tag);
    }            
}

public void RemoveAll()
{
RemoveByTag(Constants.AllTag);
}

As we can see here, removing a single cache entry is simple by sending a cache key. RemoveAll and RemoveByTag method requires the CancellationTokenSource from the cache, and call the cancel method. Cancel method expires all token that is issued by the source. As a result, it will remove cache entries.

Conclusion

In this blog, we have done the caching using the IMemoryCache instead of IDistributedCache. Because IDistributedCache reduces the functionality and it does not support the token expiration. We have seen how to use middleware for cache response. To save the page in the memory cache, we have used the action filter.

Custom_response_aspnet

Cache invalidation is the process that entries in the cache which can be replaced or removed. The server cannot force the new version of the page to be used instead of the cached page, once the page is cached in the browser. So, pages likely to change cannot be cached for a very long time at the client and we cannot change this too.

On the server-side, built-in response caching does not permit cache invalidation. Same cache duration for both for instructing the browser to cache the page, and for server-side caching. In some applications, these restrictions make the built-in response caching unusable. We will execute the cache for a single web server using IMemoryCache.

Table of Content

There are certain three methods to invalidate the cache but not all caching proxies support these methods.

Purge: It will remove the content from proxy caching immediately. If the client requests the data again, then data will be fetched from the application and stored in cache proxy. All variants of the cached content will be removed by this method.

Refresh: Even if the cached content is available, it will fetch the data from the application. The new version of data will replace the previously stored content in the cache form application. Only one variant of cached content will be affected by this method.

Ban: As the name suggested, cached content will be added to the blacklist. The client can request the blacklist, if the content matches then new content is fetched from the app and added to the cache. This method does not immediately remove cached content from the proxy cache. Instead, cached content is updated with the new client request that information.

In our blog, to allow caching to be fast we will add the middleware.

IMemoryCache

IMemoryCache is similar to the System.Runtime.Caching.MemoryCache.

public interface IMemoryCache :IDisposable
{
    bool GetValue(object key, out object value);
    ICacheEntryCreateEntryValue(object key);
    void RemoveCache(object key);
}

In MVC, it will be automatically registered. We can register memory cache in configure services method using:

services.AddMemoryCache();

IMemoryCache Example

If we have data in the cache memory then we will use the IMemoryCache to avoid getting data from the database query.

public class CustomerService
{
    private const string CustomerCacheKey = "customer-cache-key";
	
    private readonlyIMemoryCache _cache;
    private readonlyIDatabase _db;

    public CutomerService(IMemoryCache cache, IDatabasedb)
    {
        _cache = cache;
        _db = db;
    }
	
    public async Task>GetCustomers()
    {
        if (_cache.TryGet(CustomerCacheKey , out IEnumerable customers))
        {
            return customers;
        }
		
        customers= await _db.getAll(...);
		
        _cache.Set(CustomerCacheKey , customers, ...);
		
        return customers;
    }
}

When we are saving data in cache then the "Set" method provides many options to expire cache.

_cache.Set("key", item, TimeSpan.FromDays(1));
Example

Control Server Caching

To control the cached individual page, we will use the action filter attribute.

public class CacheActionAttribute :ActionFilterAttribute
{
    public int? ClientCacheDuration{ get; set; }
    public int? ServerCacheDuration{ get; set; }
    public string Tags { get; set; }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (ClientCacheDuration .HasValue)
        {
context.HttpContext.Items[Constants.ClientCacheDuration ] = ClientCacheDuration .Value;
        }

        if (ServerCacheDuration .HasValue)
        {
context.HttpContext.Items[Constants.ServerCacheDuration ] = ServerCacheDuration .Value;
        }

        if (!string.IsNullOrWhiteSpace(Tags))
        {
context.HttpContext.Items[Constants.Tags] = Tags;
        }

base.OnActionExecuting(context);
    }
}

This will add attribute to the HttpContext items collection. This method is used for communication between caching middleware and the action.

Using Middleware caching

public async Task Invoke(HttpContext context)
{
varcacheKey = CacheKeyBuild(context);

    if (_cache.TryGet(cacheKey , out CachedPage page))
    {
        await WriteResponse(context, page);

        return;
    }

ApplyClientHeaders(context);

    if (IsNotServerCachable(context))
    {
        await _next.Invoke(context);

        return;
    }            

    page = await CaptureResponse(context);

    if (page != null)
    {
varcacheServerDuration = GetCacheDuration(context, Constants.ServerDuration);

        if (cacheServerDuration.HasValue)
        {
var tags = GetCacheTags(context, Constants.Tags);

            _cache.Set(cacheKey , page, cacheServerDuration.Value, tags);
        }
    }            
}
 

We can build cache key as per our requirement. If the page is retrieved from the cache then it is written to the response.

If the page is not found then we have to do some code. If applicable, first we set the client caching header and then check if we can cache the request. Here, we will only want to GET methods if another method is used then we should invoke the middleware component.

private async Task WriteResponse(HttpContext context, CachedPage page)
{
foreach (varcacheHeader in page.Headers)
    {
context.Response.Headers.Add(cacheHeader );
    }

    await context.Response.Body.WriteAsync(page.Content, 0, page.Content.Length);
}

Now we have to capture the requested output from the middleware component.

Capture the response

Capture the page response to swap default response body stream with MemoryStream.

private async TaskCaptureResponsePage(HttpContext context)
{
varresponsePageStream = context.Response.Body;

    using (varbufferMemory = new MemoryStream())
    {
        try
        {
context.Response.Body = bufferMemory ;

            await _next.Invoke(context);
        }
        finally
        {
context.Response.Body = responsePageStream ;
        }

        if (bufferMemory .Length == 0) return null;

var bytes = bufferMemory .ToArray(); 

responsePageStream .Write(bytes, 0, bytes.Length);

        if (context.Response.StatusCode != 200) return null;

        return BuildCachedPage(context, bytes);
    }
}

Client Caching

When we have to send cache header to the browser, we will use the simple approach.\

public void SendClientHeaders(HttpContext context)
{
context.Response.OnStarting(() =>
    {
varcacheDurationClient = GetCacheDuration(context, Constants.ClientCacheDuration );

        if (cacheDurationClient .HasValue&&context.Response.StatusCode == 200)
        {
            if (cacheDurationClient == TimeSpan.Zero)
            {
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
                {
NoCache = true,
NoStore = true,
MustRevalidate = true
                };
context.Response.Headers["Expires"] = "0";
            }
            else
            {
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
MaxAge = cacheDurationClient
                };
            }
        }

        return Task.CompletedTask;
    });            
}

ClientCacheDuration is coming from the action filter. Now we will invalidate the cache.

Cache Invalidation

We will remove all the cache by custom approach before they naturally expire.

public void RemoveCache(string cacheKey)
{
    _cache.Remove(Constants.CacheKeyPrefix + cacheKey);
}

public void RemoveByTag(string tag)
{
    if (_cache.TryGetValue(Constants.CacheTagPrefix + tag, out CancellationTokenSourcetokenSource))
    {
tokenSource.Cancel();

        _cache.Remove(Constants.CacheTagPrefix + tag);
    }            
}

public void RemoveAll()
{
RemoveByTag(Constants.AllTag);
}

As we can see here, removing a single cache entry is simple by sending a cache key. RemoveAll and RemoveByTag method requires the CancellationTokenSource from the cache, and call the cancel method. Cancel method expires all token that is issued by the source. As a result, it will remove cache entries.

Conclusion

In this blog, we have done the caching using the IMemoryCache instead of IDistributedCache. Because IDistributedCache reduces the functionality and it does not support the token expiration. We have seen how to use middleware for cache response. To save the page in the memory cache, we have used the action filter.