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:
- Replace implementation of
IRazorViewEngine
withMultiTenantRazorViewEngine
. - Replace implementation of
IViewCompilerProvider
with MultiTenantRazorViewEngine. - Replace implementation of
IRazorPageFactoryProvider
withMultiTenantRazorPageFactoryProvider
. - Clear Razor's
FileProviders
collection and add own instance ofMultiTenantFileProvider
.
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:
- Set instance of
MultiTenantHelper.ServiceProvider
. - Set
FileProvider
for static files middleware to instance ofMultiTenantFileProvider
.
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