How to change/create custom FileProvider in .NET Core that's domain dependant (i.e. one web app serving multiple site rendering logics)

The task you described is not quite simple. The main problem here is not to get current HttpContext, it could be easily done with IHttpContextAccessor. The main obstacle you will face is that Razor View Engine makes heavy use of the caches.

The bad news are that request domain name is not a part of the key in those caches, only view subpath belongs to a key. So if you request a view with subpath /Views/Home/Index.cshtml for domain1, it will be loaded, compiled and cached. Then you request a view with the same path but within domain2. You expect to get another view, specific for domain2, but Razor does not care, it will not even call your custom FileProvider, since the cached view will be used.

There are basically 2 caches used by Razor:

First one is ViewLookupCache in RazorViewEngine declared as:

protected IMemoryCache ViewLookupCache { get; }

Well, the things are getting worse. This property is declared as non-virtual and does not have a setter. So it's not quite easy to extend RazorViewEngine with view cache that has a domain as part of the key. RazorViewEngine is registered as singleton and is injected into PageResultExecutor class which is also registered as singleton. So we don't have a way of resolving new instance of RazorViewEngine for each domain, so that it has its own cache. Seems like the easiest workaround for this problem is to set the property ViewLookupCache (despite the fact that it does not have a setter) to the multi-tenant implementation of IMemoryCache. Setting the property without a setter is possible however it's a very dirty hack. At the moment I propose such workaround to you, God kills a kitten. However I don't see a better option to bypass RazorViewEngine, it just is not flexible enough for this scenario.

The second Razor cache is _precompiledViewLookup in RazorViewCompiler:

private readonly Dictionary<string, CompiledViewDescriptor> _precompiledViews;

This cache is stored as private field, however we could have new instance of RazorViewCompiler for each domain, since it's intantiated by IViewCompilerProvider which we could implement in multi-tenant way.

So keeping all this in mind, let's do the job.

MultiTenantRazorViewEngine class

public class MultiTenantRazorViewEngine : RazorViewEngine
{
    public MultiTenantRazorViewEngine(IRazorPageFactoryProvider pageFactory, IRazorPageActivator pageActivator, HtmlEncoder htmlEncoder, IOptions<RazorViewEngineOptions> optionsAccessor, RazorProject razorProject, ILoggerFactory loggerFactory, DiagnosticSource diagnosticSource)
        : base(pageFactory, pageActivator, htmlEncoder, optionsAccessor, razorProject, loggerFactory, diagnosticSource)
    {
        //  Dirty hack: setting RazorViewEngine.ViewLookupCache property that does not have a setter.
        var field = typeof(RazorViewEngine).GetField("<ViewLookupCache>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
        field.SetValue(this, new MultiTenantMemoryCache());

        //  Asserting that ViewLookupCache property was set to instance of MultiTenantMemoryCache
        if (ViewLookupCache .GetType() != typeof(MultiTenantMemoryCache))
        {
            throw new InvalidOperationException("Failed to set multi-tenant memory cache");
        }
    }
}

MultiTenantRazorViewEngine derives from RazorViewEngine and sets ViewLookupCache property to instance of MultiTenantMemoryCache.

MultiTenantMemoryCache class

public class MultiTenantMemoryCache : IMemoryCache
{
    //  Dictionary with separate instance of IMemoryCache for each domain
    private readonly ConcurrentDictionary<string, IMemoryCache> viewLookupCache = new ConcurrentDictionary<string, IMemoryCache>();

    public bool TryGetValue(object key, out object value)
    {
        return GetCurrentTenantCache().TryGetValue(key, out value);
    }

    public ICacheEntry CreateEntry(object key)
    {
        return GetCurrentTenantCache().CreateEntry(key);
    }

    public void Remove(object key)
    {
        GetCurrentTenantCache().Remove(key);
    }

    private IMemoryCache GetCurrentTenantCache()
    {
        var currentDomain = MultiTenantHelper.CurrentRequestDomain;
        return viewLookupCache.GetOrAdd(currentDomain, domain => new MemoryCache(new MemoryCacheOptions()));
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            foreach (var cache in viewLookupCache)
            {
                cache.Value.Dispose();
            }
        }
    }
}

MultiTenantMemoryCache is an implementation of IMemoryCache that separates cache data for different domains. Now with MultiTenantRazorViewEngine and MultiTenantMemoryCache we added domain name to the first cache layer of the Razor.

MultiTenantRazorPageFactoryProvider class

public class MultiTenantRazorPageFactoryProvider : IRazorPageFactoryProvider
{
    //  Dictionary with separate instance of IMemoryCache for each domain
    private readonly ConcurrentDictionary<string, IRazorPageFactoryProvider> providers = new ConcurrentDictionary<string, IRazorPageFactoryProvider>();

    public RazorPageFactoryResult CreateFactory(string relativePath)
    {
        var currentDomain = MultiTenantHelper.CurrentRequestDomain;
        var factoryProvider = providers.GetOrAdd(currentDomain, domain => MultiTenantHelper.ServiceProvider.GetRequiredService<DefaultRazorPageFactoryProvider>());
        return factoryProvider.CreateFactory(relativePath);
    }
}

MultiTenantRazorPageFactoryProvider creates separate instance of DefaultRazorPageFactoryProvider so that we have a distinct instance of RazorViewCompiler for each domain. Now we have added domain name to the second cache layer of the Razor.

MultiTenantHelper class

public static class MultiTenantHelper
{
    public static IServiceProvider ServiceProvider { get; set; }

    public static HttpContext CurrentHttpContext => ServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext;

    public static HttpRequest CurrentRequest => CurrentHttpContext.Request;

    public static string CurrentRequestDomain => CurrentRequest.Host.Host;
}

MultiTenantHelper provides access to current request and domain name of this request. Unfortunately we have to declare it as static class with static accessor for IHttpContextAccessor. Both Razor and static files middleware do not allow to set new instance of FileProvider for each request (see below in Startup class). That's why IHttpContextAccessor is not injected into FileProvider and is accessed as static property.

MultiTenantFileProvider class

public class MultiTenantFileProvider : IFileProvider
{
    private const string BasePath = @"DomainsData";

    public IFileInfo GetFileInfo(string subpath)
    {
        if (MultiTenantHelper.CurrentHttpContext == null)
        {
            if (String.Equals(subpath, @"/Pages/_ViewImports.cshtml") || String.Equals(subpath, @"/_ViewImports.cshtml"))
            {
                //  Return FileInfo of non-existing file.
                return new NotFoundFileInfo(subpath);
            }

            throw new InvalidOperationException("HttpContext is not set");
        }

        return CreateFileInfoForCurrentRequest(subpath);
    }

    public IDirectoryContents GetDirectoryContents(string subpath)
    {
        var fullPath = GetPhysicalPath(MultiTenantHelper.CurrentRequestDomain, subpath);
        return new PhysicalDirectoryContents(fullPath);
    }

    public IChangeToken Watch(string filter)
    {
        return NullChangeToken.Singleton;
    }

    private IFileInfo CreateFileInfoForCurrentRequest(string subpath)
    {
        var fullPath = GetPhysicalPath(MultiTenantHelper.CurrentRequestDomain, subpath);
        return new PhysicalFileInfo(new FileInfo(fullPath));
    }

    private string GetPhysicalPath(string tenantId, string subpath)
    {
        subpath = subpath.TrimStart(Path.AltDirectorySeparatorChar);
        subpath = subpath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
        return Path.Combine(BasePath, tenantId, subpath);
    }
}

This implementation of MultiTenantFileProvider is just for sample. You should put your implementation based on Azure Blob Storage. You could get domain name of current request by calling MultiTenantHelper.CurrentRequestDomain. You should be ready that GetFileInfo() method will be called during application startup from app.UseMvc() call. It happens for /Pages/_ViewImports.cshtml and /_ViewImports.cshtml files which import namespaces used by all other views. Since GetFileInfo() is called not within any request, IHttpContextAccessor.HttpContext will return null. So you should either have own copy of _ViewImports.cshtml for each domain and for these initial calls return IFileInfo with Exists set to false. Or to keep PhysicalFileProvider in Razor FileProviders collection so that those files could be shared by all domains. In my sample I've used former approach.

Configuration (Startup class)

In ConfigureServices() method we should:

  1. Replace implementation of IRazorViewEngine with MultiTenantRazorViewEngine.
  2. Replace implementation of IViewCompilerProvider with MultiTenantRazorViewEngine.
  3. Replace implementation of IRazorPageFactoryProvider with MultiTenantRazorPageFactoryProvider.
  4. Clear Razor's FileProviders collection and add own instance of MultiTenantFileProvider.
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    var fileProviderInstance = new MultiTenantFileProvider();
    services.AddSingleton(fileProviderInstance);
    services.AddSingleton<IRazorViewEngine, MultiTenantRazorViewEngine>();

    //  Overriding singleton registration of IViewCompilerProvider
    services.AddTransient<IViewCompilerProvider, RazorViewCompilerProvider>();
    services.AddTransient<IRazorPageFactoryProvider, MultiTenantRazorPageFactoryProvider>();
    //  MultiTenantRazorPageFactoryProvider resolves DefaultRazorPageFactoryProvider by its type
    services.AddTransient<DefaultRazorPageFactoryProvider>();

    services.Configure<RazorViewEngineOptions>(options =>
    {
        //  Remove instance of PhysicalFileProvider
        options.FileProviders.Clear();
        options.FileProviders.Add(fileProviderInstance);
    });
}

In Configure() method we should:

  1. Set instance of MultiTenantHelper.ServiceProvider.
  2. Set FileProvider for static files middleware to instance of MultiTenantFileProvider.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseBrowserLink();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
    }

    MultiTenantHelper.ServiceProvider = app.ApplicationServices.GetRequiredService<IServiceProvider>();

    app.UseStaticFiles(new StaticFileOptions
    {
        FileProvider = app.ApplicationServices.GetRequiredService<MultiTenantFileProvider>()
    });

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

Sample Project on GitHub