Follow up - Making Redirects with more performant code

After yesterday's blog, I've had some really useful feedback from colleagues and Umbraco Community members and I thought I'd post the new improved code.

The first suggestion was to just use the generic ILogger rather than having the middleware dependent on Serilog logging. I had used Serilog because I thought, for some reason, the generic ILogger didn't save in to the logs in Umbraco. I was wrong and I've updated the code to reflect that.

Next up was about performance. It was suggested that I look at using a Lazy Property and I'll be honest, I've never heard of this before, so I asked Github Copilot to make my code more performant and here is what it suggested :

  • Lazy Loading with Asynchronous Initialization
    • Lazy<Task<ConcurrentDictionary<string, string>>> ensures that the redirects are loaded asynchronously and only when first needed.
  • Concurrent Dictionary:
    • ConcurrentDictionary<string, string> is used to ensure thread-safe access to the redirects.
  • Asynchronous File I/O:
    • await File.ReadAllLinesAsync(filePath) reads the file asynchronously to avoid blocking threads.
  • Minimize Logging:
    • Logging is kept to a minimum, and you can adjust the logging level based on the environment (e.g., more verbose in development, less in production).

Here is what the code now looks like :

using System.Collections.Concurrent;

namespace MySite.Web.Infrastructure.Middleware
{
    public class RedirectMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<RedirectMiddleware> _logger;
        private readonly Lazy<Task<ConcurrentDictionary<string, string>>> _redirects;

        public RedirectMiddleware(RequestDelegate next, ILogger<RedirectMiddleware> logger)
        {
            _next = next;
            _logger = logger;
            _redirects = new Lazy<Task<ConcurrentDictionary<string, string>>>(LoadRedirectsAsync);
        }

        public async Task InvokeAsync(HttpContext context)
        {
            var redirects = await _redirects.Value;

            if (redirects.TryGetValue(context.Request.Path.Value, out var targetUrl))
            {
                var queryString = context.Request.QueryString.ToString();
                if (!string.IsNullOrEmpty(queryString))
                {
                    targetUrl = $"{targetUrl}{queryString}";
                }

                _logger.LogInformation($"Redirecting from {context.Request.Path} to {targetUrl}");
                context.Response.Redirect(targetUrl, true);
                return;
            }

            await _next(context);
        }

        private async Task<ConcurrentDictionary<string, string>> LoadRedirectsAsync()
        {
            var redirects = new ConcurrentDictionary<string, string>();
            var filePath = Path.Combine(Directory.GetCurrentDirectory(), "redirects.csv");

            if (File.Exists(filePath))
            {
                var lines = await File.ReadAllLinesAsync(filePath);
                foreach (var line in lines)
                {
                    var parts = line.Split(',');
                    if (parts.Length == 2)
                    {
                        redirects.TryAdd(parts[0].Trim(), parts[1].Trim());
                    }
                }
            }

            return redirects;
        }
    }
}

Updated on : 03 September 2024

Since the original blog, I've updated this code even more! It now uses memory cache so that I'm only reading the csv file once and there are a couple of other performance tweaks I've made.

public class RedirectMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RedirectMiddleware> _logger;
    private readonly IMemoryCache _memoryCache;

    public RedirectMiddleware(RequestDelegate next, ILogger<RedirectMiddleware> logger, IMemoryCache memoryCache)
    {
        _next = next;
        _logger = logger;
        _memoryCache = memoryCache;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (_memoryCache.TryGetValue("redirects", out ConcurrentDictionary<string, string>? redirects) == false)
        {
            redirects = await LoadRedirectsAsync();
        }

        if (context.Request.Path.Value != null && redirects != null &&
            redirects.TryGetValue(context.Request.Path.Value, out string targetUrl))
        {
            var queryString = context.Request.QueryString.ToString();
            if (!string.IsNullOrEmpty(queryString))
            {
                targetUrl += queryString;
            }

            _logger.LogInformation($"Redirecting from {context.Request.Path} to {targetUrl}");
            context.Response.Redirect(targetUrl, true);
            return;
        }

        await _next(context);
    }

    private async Task<ConcurrentDictionary<string, string>> LoadRedirectsAsync()
    {
        var filePath = Path.Combine(Directory.GetCurrentDirectory(), "redirects.csv");
        var redirects = new ConcurrentDictionary<string, string>();
        if (File.Exists(filePath))
        {
            var lines = await File.ReadAllLinesAsync(filePath);

            foreach (var line in lines)
            {
                var parts = line.Split(',');
                if (parts.Length == 2)
                {
                    redirects.TryAdd(parts[0].Trim(), parts[1].Trim());
                }
            }
        }
        return _memoryCache.Set("redirects", redirects);
    }
}
Published on : 29 August 2024