Введение

Поскольку несколько месяцев назад я стал фрилансером, мне посчастливилось участвовать во многих интересных проектах. Самый последний из них посвящен созданию опросов. Какая тема?

  • Должен быть настроен каталог вопросов.
  • Вопросы могут быть иерархическими, опираться на родителя. Всего один уровень.
  • Вопросы определяются типами. checkboxex, выпадающие списки, просто текст, цифры, проценты, …
  • На основе вопросов можно будет проводить опросы.
  • Когда анкета будет опубликована, изменить ее больше не разрешается.
  • Текущая анкета может быть отредактирована в любое время.
  • Не реже одного раза в год публикуется анкета

Просто грубый обзор, чтобы указать на фактическую стратегию реализации.

TL;DR

  • EF Core использует методы интерфейса по умолчанию
  • Методы часто используются для обеспечения обратной совместимости.
  • Решение, подобное многопользовательскому, после примеров приводит к исключению переполнения стека, что в наши дни трудно найти в Google с помощью этого популярного сайта.
  • Компилятор и IDE не дают никаких намеков на неправильные реализации

Стек технологий

Стек технологий — .net core 6.0, ef core 6.0. Приложение полностью размещено в Azure. Общая архитектура выглядит следующим образом.

Все сервисы развернуты через Terraform, активно используются конвейеры Azure DevOps. Все службы с соответствующими данными скрыты за частными конечными точками.

Основная идея реализации анкет

Итак, мы с командой сели вместе и обсудили возможные реализации. Анкеты должны быть доступны только для чтения при публикации, но всегда доступны для редактирования. Как выполнить это требование?

Конечно, это процесс копирования. Текущая анкета должна быть каким-то образом продублирована, чтобы отделить ее от текущей и редактируемой. Есть отношения к таблице вопросов, около двадцати таблиц. Всю информацию необходимо зафиксировать для опубликованной. Все это звучит как подход, который довольно популярен в системах, основанных на арендаторах.

Это довольно распространенное решение, поскольку оно довольно простое. Каждый стол будет осведомлен об арендаторе. И все запросы к базе данных должны обрабатывать это. К счастью, в EF Core есть функция, которая называется Глобальные фильтры запросов. Глобальные фильтры запросов — это предикаты запросов LINQ, применяемые к типам сущностей в модели метаданных. Предикат запроса — это логическое выражение, которое обычно передается оператору запроса LINQ where. EF Core автоматически применяет такие фильтры ко всем запросам LINQ, включающим эти типы сущностей. EF Core также применяет их к типам сущностей, на которые косвенно ссылаются с помощью свойства Include или навигации.

Умножение схемы используется не так часто. Полное разделение данных допустимо, но это не самый интенсивный способ использования нескольких баз данных. EF Core позволяет переключать контексты БД и даже имеет образец в своих документах.

Это максимальный вариант разделения. В этом варианте очень маловероятно, что данные одного арендатора считываются с другого. Ядро EF не поставляется с собственным образцом, но эта реализация довольно стабильна.

Будет выбран второй вариант. Вот причины:

  • На реализацию вообще не должны влиять потребности в разделении. В лучшем случае он даже не знает.
  • Производительность не должна быть затронута.
  • Не должно быть более одной базы данных. Это легко может привести к резкому увеличению затрат, что нецелесообразно для масштаба проекта.
  • Схемы допускают полное разделение, но поскольку они находятся в одной базе данных, связь между схемами упрощается.

Выполнение

Реализация DbContexts с поддержкой схемы довольно проста.

EF Core позволяет перехватывать кэширование DBContexts. Это будет сделано с реализацией IModelCacheKeyFactory.

public class SchemaAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
    public object Create(DbContext context)
            => new SchemaAwareModelCacheKey(context);
    public object Create(DbContext context, bool designTime)
            => Create(context);
}

Для этой реализации требуется реализация ModelCacheKey, которая позволяет проводить сравнение с помощью реализации equals/hashcode.

internal class SchemaAwareModelCacheKey : ModelCacheKey
{
   private readonly string _schema;
   private readonly Type _dbContextType;
   private readonly bool _designTime;
   public string Schema => _schema;
   public Type DbContextType => _dbContextType;
   public bool DesignTime => _designTime;
   public SchemaAwareModelCacheKey(DbContext context)
            : base(context)
   {
      _schema = (context as ApplicationDbContext)?.Schema;
      _dbContextType = context.GetType();
      _designTime = false;
   }
   public SchemaAwareModelCacheKey(DbContext context, bool designTime): base(context, designTime)
   {
      _schema = (context as ApplicationDbContext)?.Schema;
      _dbContextType = context.GetType();
      _designTime = designTime;
   }
   protected virtual bool Equals(SchemaAwareModelCacheKey other)
   {
      return _dbContextType == other.DbContextType &&
             _designTime == other.DesignTime &&
             _schema == other.Schema;
   }
   public override bool Equals(object obj)
        => (obj is SchemaAwareModelCacheKey otherAsKey) &&  Equals(otherAsKey);
   public override int GetHashCode()
   {
      var hash = new HashCode();
      hash.Add(_dbContextType);
      hash.Add(_designTime);
      hash.Add(_schema);
      return hash.ToHashCode();
   }
}

Имея это на месте, необходимо предоставить схему DbContext. Я решил, что определение заголовка должно передавать информацию о схеме из приложения в серверную часть, которая реализована как промежуточное программное обеспечение.

public class SchemaAwareDbContextMiddleware
{ 
     private readonly RequestDelegate _next;
     private const string SCHEMA_NAME = "x-db-schema";
     public SchemaAwareDbContextMiddleware(RequestDelegate next)
     {
         _next = next;
     }
     public async Task Invoke(HttpContext httpContext, IDbContextSchema dbContextSchema)
     {
        IHeaderDictionary headers = httpContext.Request.Headers;
        var schema = httpContext.Request.Headers[SCHEMA_NAME].ToString();
        dbContextSchema.SetSchema(schema);
        await _next.Invoke(httpContext);
     }
}

Реализация IDbContextSchema используется для передачи информации от контроллеров в контекст базы данных, а также для обработки информации схемы в EF Core.

public interface IDbContextSchema
{
     string Schema { get; }
     void SetSchema(string schema);
}

ApplicationDbContextSchema просто реализует IDbContextSchema и делает ее доступной как для ПО промежуточного слоя, так и для DbContext.

public class ApplicationDbContextSchema : IDbContextSchema
{
     public ApplicationDbContextSchema(string schema)
     {
         Schema = schema;
     }
     public string Schema { get; private set; }
     public void SetSchema(string schema)
     {
         Schema = schema;
     }
}

ApplicationDbContext необходимо внедрить IDbContextSchema для инициализации схемы.

public class ApplicationDbContext : DbContext, IDbContextSchema
{
    public const string DEFAULT_SCHEMA = "default";
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
                       IDbContextSchema schema = null) : base(options)
    {
       if (schema != null && !string.IsNullOrWhiteSpace(schema.Schema))
       {
           Schema = schema.Schema;
       }
       else
       {
           Schema = DEFAULT_SCHEMA;
       }
    }
    public string Schema { get; }
}

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

Исключение переполнения стека, это было некоторое время назад

В последние годы я в основном читал о переполнении стека вместо того, чтобы получать исключения переполнения стека. Что случилось? Это выглядело так.

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:49190
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5255
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: 
Stack overflow.
Repeat 15815 times:
--------------------------------
   at Microsoft.EntityFrameworkCore.Infrastructure.IModelCacheKeyFactory.Create(Microsoft.EntityFrameworkCore.DbContext)   at Microsoft.EntityFrameworkCore.Infrastructure.IModelCacheKeyFactory.Create(Microsoft.EntityFrameworkCore.DbContext, Boolean)
--------------------------------
   at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.GetModel(Microsoft.EntityFrameworkCore.DbContext, Microsoft.EntityFrameworkCore.ModelCreationDependencies, Boolean)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.CreateModel(Boolean)
   at Microsoft.EntityFrameworkCore.Internal.DbContextServices.get_Model()
   at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkServicesBuilder+<>c.<TryAddCoreServices>b__8_4(System.IServiceProvider)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(Microsoft.Extensions.DependencyInjection.ServiceLookup.FactoryCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverLock)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
...
 

На самом деле я был очень доволен такой реализацией. У меня уже была часть DevOps, автоматически создающая идемпотентные сценарии из EF Core и автоматически применяющая их для нескольких схем. Код был структурирован удобным и понятным образом. А потом я попробовал это, приложение вылетело, и у меня были эти вопросительные знаки над моей головой.

Что случилось? Откуда эта рекурсия?

Я начал искать. Конфигурацию, методы я изменил недавно. Отлажено. Посмотрел образцы. Разницы не увидел. Взглянул на исходники EF Core. На самом деле это реализация интерфейса IModelCacheKeyFactory.

namespace Microsoft.EntityFrameworkCore.Infrastructure
{
    //
    // Summary:
    //     Creates keys that uniquely identifies the model for a given context. This is
    //     used to store and lookup a cached model for a given context.
    //     The service lifetime is Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton.
    //     This means a single instance is used by many Microsoft.EntityFrameworkCore.DbContext
    //     instances. The implementation must be thread-safe. This service cannot depend
    //     on services registered as Microsoft.Extensions.DependencyInjection.ServiceLifetime.Scoped.
    //
    // Remarks:
    //     See EF Core model caching for more information.
    public interface IModelCacheKeyFactory
    {
        //
        // Summary:
        //     Gets the model cache key for a given context.
        //
        // Parameters:
        //   context:
        //     The context to get the model cache key for.
        //
        // Returns:
        //     The created key.
        [Obsolete("Use the overload with most parameters")]
        object Create(DbContext context)
        {
            return Create(context, designTime: true);
        }
//
        // Summary:
        //     Gets the model cache key for a given context.
        //
        // Parameters:
        //   context:
        //     The context to get the model cache key for.
        //
        //   designTime:
        //     Whether the model should contain design-time configuration.
        //
        // Returns:
        //     The created key.
        object Create(DbContext context, bool designTime)
        {
            return Create(context);
        }
    }
}

Я был действительно удивлен реализацией в интерфейсе. Эта особенность C# 8.0 меня совершенно не зацепила. Я растерялся, так как не ожидал.

Но ждать. Посмотрите подробнее на реализацию этого интерфейса. Оба метода вызывают другой. Это выглядит как идеальная причина для исключения переполнения стека. Почему они это сделали?

Краткая история плюс почему так происходит?

Понять, почему команда EF Core реализовала это именно так, довольно просто. Второй метод с поддержкой двух параметров designTime был недоступен в первой версии интерфейса. Чтобы не нарушать обратную совместимость, решили просто реализовать. Я просто реализовал один интерфейс и подумал, что есть только один метод.

Я не знал, что у меня есть интерфейс с двумя методами, а я реализовал только один. Магический метод интерфейса по умолчанию.

Это особенность, однозначно. Меньше работы, меньше мыслей. Но все это не объясняет, почему вообще не взялась за мою реализацию. Я удостоверился, что актуальная «самая» важная строка доступна. Но код не попал в мою настоящую реализацию.

Как это всегда бывает, когда-нибудь наступает момент расслабления. До удара f5 уже понятно, что это решение. И тогда он работает нормально. Каковы были различия?

Вот реализация, которая работает как шарм:

В этом была разница. Я использовал ApplicationDbContext, мое унаследованное от DbContext вместо DbContext.

Заключение

Реализация интерфейса с методами интерфейса по умолчанию возвращается к реализации интерфейса, когда какой-либо тип не подходит. Даже производные.

На самом деле у меня не было мысли сделать что-то не так. ApplicationDbContext является производным от DbContext, который на самом деле должен работать. Компилятор ничего не говорит о проблемах. Явная реализация этого типа интерфейса невозможна, так как не разрешена дизайном. Из-за реализации интерфейса также невозможно ничего переопределить.

Такая реализация — довольно хорошо спрятанная жемчужина, хороший повод для долгих поисков.

Конечно, эти методы интерфейса по умолчанию — хорошая идея с разных точек зрения:

  • обратная совместимость
  • избегать реализаций абстрактных классов, которые имеют более сильный контракт, чем интерфейс, при распространении
  • меньше кода для потребителей интерфейса
  • эта функция позволяет C# взаимодействовать с API-интерфейсами, предназначенными для Android (Java) и iOs (Swift), которые поддерживают аналогичные функции.
  • добавление реализации интерфейса по умолчанию обеспечивает элементы языковой функции черты (https://en.wikipedia.org/wiki/Trait_(computer_programming)).
  • возможно наследование от нескольких интерфейсов, в то время как абстрактный класс — только одиночное наследование. Я почти уверен, что раньше мне не нужна была эта функция.
  • В интерфейсах есть ко- и контравариантность, а не в классах C#.

А недостатки?

На самом деле мне нравится идея четкого контракта, который представляет собой не что иное, как это. Методы интерфейса по умолчанию добавляют несколько уровней сложности, особенно. когда эта функция известна далеко не каждому разработчику. Думаю, со временем это будет решено. Надеюсь, C# не станет слишком многофункциональным. Кроме того, новая функциональность, допускающая обнуление, приводит к большому количеству шума. Это здесь также делает это.

Каковы ваши впечатления от методов интерфейса по умолчанию?

Первоначально опубликовано на http://codingsoul.org.