Как изменить / создать собственный FileProvider в.NET Core, который зависит от домена (то есть одно веб-приложение, обслуживающее несколько логик рендеринга сайта)

В настоящее время я создаю мультитенантное веб-приложение с использованием.NET Core. И сталкивается с проблемой:

1) Веб-приложение предоставляет различные представления и логику на основе набора доменных имен.

2) Представления являются представлениями MVC и хранятся в хранилище BLOB-объектов Azure.

3) Множественные сайты используют одни и те же контроллеры.NET Core MVC, поэтому только представления Razor отличаются небольшими логическими схемами.

Вопросы.... А) Возможно ли это? Я создал MiddleWare для манипулирования, однако я не мог назначить FileProviders на уровне контекста должным образом, потому что поставщик файлов должен зависеть от домена.

Б) Или вместо того, чтобы думать и пытаться использовать FileProvider, есть ли другой способ достичь того, чего я хочу достичь?

Большое спасибо!!!

1 ответ

Решение

Задача, которую вы описали, не совсем проста. Основная проблема здесь не в том, чтобы получить ток HttpContext, это может быть легко сделано с IHttpContextAccessor, Главное препятствие, с которым вы столкнетесь, заключается в том, что Razor View Engine интенсивно использует кэши.

Плохая новость заключается в том, что запрашиваемое доменное имя не является частью ключа в этих кешах, только ключ просмотра относится к подпути. Так что если вы запрашиваете представление с подпути /Views/Home/Index.cshtml для domain1 он будет загружен, скомпилирован и кэширован. Затем вы запрашиваете представление с тем же путем, но внутри домена2. Вы ожидаете получить другое представление, специфичное для domain2, но Razor это не волнует, оно даже не вызовет ваш кастом FileProvider, поскольку будет использоваться кэшированное представление.

Обычно Razor использует 2 кэша:

Первый ViewLookupCache в RazorViewEngine объявлен как:

protected IMemoryCache ViewLookupCache { get; }

Ну, все становится хуже. Это свойство объявлено как не виртуальное и не имеет установщика. Так что это не совсем легко продлить RazorViewEngine с кешем представления, который имеет домен как часть ключа. RazorViewEngine зарегистрирован как синглтон и вводится в PageResultExecutor класс, который также зарегистрирован как синглтон. Таким образом, у нас нет способа разрешения нового экземпляра RazorViewEngine для каждого домена, чтобы у него был свой кеш. Похоже, самый простой способ решения этой проблемы - установить свойство ViewLookupCache (несмотря на тот факт, что он не имеет установщика) для мультитенантной реализации IMemoryCache, Установка свойства без установщика возможна, однако это очень грязный хак. В данный момент я предлагаю такой обходной путь, Бог убивает котенка. Однако я не вижу лучшего способа обойти RazorViewEngine, это просто недостаточно гибко для этого сценария.

Второй Razor кеш есть _precompiledViewLookup в RazorViewCompiler:

private readonly Dictionary<string, CompiledViewDescriptor> _precompiledViews;

Этот кеш хранится как приватное поле, однако у нас может быть новый экземпляр RazorViewCompiler для каждого домена, так как он IViewCompilerProvider который мы могли бы реализовать мультитенантным способом.

Итак, помня обо всем, давайте сделаем свою работу.

MultiTenantRazorViewEngine класс

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 происходит от RazorViewEngine и устанавливает ViewLookupCache свойство к экземпляру MultiTenantMemoryCache,

Класс MultiTenantMemoryCache

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 это реализация IMemoryCache который разделяет данные кеша для разных доменов. Теперь с MultiTenantRazorViewEngine а также MultiTenantMemoryCache мы добавили доменное имя в первый слой кэша Razor.

Класс MultiTenantRazorPageFactoryProvider

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 создает отдельный экземпляр DefaultRazorPageFactoryProvider так что у нас есть отличный пример RazorViewCompiler для каждого домена. Теперь мы добавили доменное имя во второй слой кэша Razor.

MultiTenantHelper класс

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 обеспечивает доступ к текущему запросу и доменному имени этого запроса. К сожалению, мы должны объявить его как статический класс со статическим средством доступа для IHttpContextAccessor, Промежуточное программное обеспечение как Razor, так и статических файлов не позволяет устанавливать новый экземпляр FileProvider для каждого запроса (см. ниже в Startup учебный класс). Вот почему IHttpContextAccessor не вводится в FileProvider и доступен как статическое свойство.

Класс MultiTenantFileProvider

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);
    }
}

Эта реализация MultiTenantFileProvider это только для образца. Вы должны поместить свою реализацию на основе хранилища BLOB-объектов Azure. Вы можете получить доменное имя текущего запроса, позвонив MultiTenantHelper.CurrentRequestDomain, Вы должны быть готовы к тому, что GetFileInfo() метод будет вызван во время запуска приложения из app.UseMvc() вызов. Это происходит для /Pages/_ViewImports.cshtml а также /_ViewImports.cshtml файлы, которые импортируют пространства имен, используемые всеми другими представлениями. поскольку GetFileInfo() вызывается не в любом запросе, IHttpContextAccessor.HttpContext вернусь null, Так что вы должны иметь собственную копию _ViewImports.cshtml для каждого домена и для этих начальных вызовов возврат IFileInfo с Exists установлен в false, Или сохранить PhysicalFileProvider в бритве FileProviders коллекция, чтобы эти файлы могли быть общими для всех доменов. В моем примере я использовал прежний подход.

Конфигурация (класс запуска)

В ConfigureServices() Метод, который мы должны:

  1. Заменить реализацию IRazorViewEngine с MultiTenantRazorViewEngine,
  2. Заменить реализацию IViewCompilerProvider с MultiTenantRazorViewEngine.
  3. Заменить реализацию IRazorPageFactoryProvider с MultiTenantRazorPageFactoryProvider,
  4. Ясная бритва FileProviders сбор и добавление собственного экземпляра 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);
    });
}

В Configure() Метод, который мы должны:

  1. Установить экземпляр MultiTenantHelper.ServiceProvider,
  2. Задавать FileProvider для статических файлов промежуточного программного обеспечения к экземпляру 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?}");
    });
}

Пример проекта на GitHub

Вы можете проверить Saaskit здесь, в этой статье, на предмет мультиарендности

https://benfoster.io/blog/asp-net-core-themes-and-multi-tenancy

для поиска представлений вы можете следовать концепции @CodeFuller, переопределив MultiTenantFileProvider

но в вы должны зарегистрировать его, как показано ниже

     services.AddRazorPages().AddRazorRuntimeCompilation(options =>
        {
            options.FileProviders.Clear();
            options.FileProviders.Add(new TenantFileProvider());
        });
Другие вопросы по тегам