Должен ли я вводить фабрики обслуживания на свои страницы Blazor, как это?

#c# #dependency-injection #entity-framework-core #blazor #blazor-server-side

Вопрос:

У меня есть страница Блейзора, которая выглядит так.

 @inject IMyService MyService

<input value=@myValue @onchange="DoSomethingOnValueChanged">

@code
{

   private string myValue;

   private async Task DoSomethingOnValueChanged()
   {
      var myValue = await this.MyService.GetData(this.myValue);

      if (myValue != null)
      {
         myValue.SomeField = "some new value";
         await this.MyService.SaveChanges();
      }
   }

}
 

Класс обслуживания выглядит следующим образом:

 public class MyService : IMyService
{
   private MyContext context;

   public MyService(MyContext context)
   {  
      this.context = context;
   }

   public async Task<MyObject> GetData(string id)
   {
      return await this.context.MyDataObjects.FirstOrDefaultAsync(p => p.Id == id);
   }
 
   public async Task SaveChanges()
   {
      await this.context.SaveChangesAsync();
   }
}
 

Когда пользователь изменяет значение в текстовом поле, я использую свой класс обслуживания, чтобы получить некоторые данные, обновить их, а затем сохранить в базе данных с помощью контекста Entity Framework. Операции с базой данных выполняются быстро (они занимают не более нескольких секунд), но возможно, что пользователь может ввести второе значение до завершения обработки первого значения, которое затем запустит второй вызов GetData и SaveChanges пока первый еще обрабатывается.

Одна из проблем заключается в том, что это приводит к исключению A second operation was started on this context before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext.

Я могу решить эту проблему, введя контекст MyService в конструктор и создавая новый контекст каждый раз, когда делается запрос, вот так

 public class MyService 
{
   private MyContext context;

   private IDbContextFactory<MyContext> contextFactory;

   public MyService(MyContext contextFactory)
   {  
      this.contextFactory = contextFactory;
   }

   public async Task MyObject GetData(string id)
   {
      this.context?.Dispose();
      this.context = this.contextFactory.CreateDbContext();
      return await this.context.MyDataObjects.FirstOrDefaultAsync(p => p.Id == id);
   }
 
   public async Task SaveChanges()
   {
      if (this.context != null)
      {
         await this.context.SaveChangesAsync();
      }
   }
}
 

But now that problem is that if the second call to GetData happens before the first call to SaveChanges has happened, the context that the original data was attached to has been disposed, so SaveChanges on the first value will fail.

Another problem is that in the actual program, MyService has several child service dependencies that are injected, and they also use Entity Framework contexts. So many methods of MyService would cause its child services to instantiate new Entity Framework contexts as well, which leads to the same problem of some data still being in process, but its owning context was disposed.

To solve that problem, I decided to instantiate a new service each time the page’s DoSomethingOnValueChanged method is called. That code looks like this:

 public interface ITypeFactory<T>
{
   Func<T> CreateFunction { get; set; }
   
   T Create();
}

public class TypeFactory<T> : ITypeFactory<T>
{
    public Func<T> CreateFunction { get; set; }

    public T Create()
    {
        return CreateFunction();
    }
}
 

Startup.cs

 public void ConfigureServices(IServiceCollection services)
{
   services.AddTransient(f =>
   {
      ITypeFactory<IMyService> factory = ActivatorUtilities.CreateInstance<TypeFactory<IMyService>>(f);
      factory.CreateFunction = () => ActivatorUtilities.CreateInstance<MyService>(f);
      return factory;
   }
}
 

Затем я внедряю эту фабрику на свою страницу Razor и создаю новый экземпляр MyService при каждом DoSomethingOnValueChanged вызове.

Эта система работает и позволяет избежать проблемы использования одного и того же контекста дважды одновременно, но я обеспокоен тем, что создание новой службы, которая часто используется, приведет к заметному снижению производительности, если в службы начнет вводиться много зависимостей или если объем конфигурации модели в контексте станет действительно большим.

  1. Прямо сейчас, похоже, создание экземпляра службы занимает около 0,05 секунды. Сильно ли это ухудшится для служб со сложным графиком зависимостей или для большой конфигурации контекста EF?
  2. Является ли эта система фабрики обслуживания разумным способом решения этой проблемы? Я могу найти информацию об использовании DbContext фабрик только при поиске этого, и я не нашел ничего, что обсуждало бы использование фабрик для служб, которые используют DbContext s
  3. Есть ли лучший способ справиться с этим?

Ответ №1:

Создание экземпляра службы, подобной этой, не очень чисто. Вы можете сделать следующее:

  1. Используйте фабрику dbcontext в каждом методе. Обратите внимание, что контекст теперь ограничен для метода. ключевое слово «using» удаляет контекст, как только метод завершается, поэтому вам не нужно проверять вручную.
     public async Task<MyObject> GetData(string id)
    {
       using var ctx = this.contextFactory.CreateDbContext();
       return await ctx.MyDataObjects.FirstOrDefaultAsync(p => p.Id == id);
    }
     
  2. Передайте сущность методу обновления. Используйте dbcontext Update() для обновления и сохранения обновленной сущности
     public async Task SaveChanges(MyObject obj)
    {
       using var ctx = this.contextFactory.CreateDbContext();
       ctx.Update(obj);
       await ctx.SaveChangesAsync();
    }
     

Это должно решить ваши проблемы. Вам не нужно вручную создавать экземпляр службы и устранять связанные с ней сложности.
Несколько вещей, которые нужно запомнить:
Blazor в основном хорошо работает в отключенных сценариях. Таким образом, вы остаетесь ответственным за управление состояниями, а также за возникающие в связи с этим проблемы параллелизма. Читайте о отключенном сценарии здесь: https://docs.microsoft.com/en-us/ef/core/saving/disconnected-entities

Вы можете напрямую использовать DbContext в компоненте blazor. Читайте о OwningComponentBase здесь: https://docs.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-5.0amp;pivots=server#utility-base-component-classes-to-manage-a-di-scope-1

Комментарии:

1. Спасибо вам за ответ. Одна вещь, которая беспокоит меня при создании новых контекстов внутри каждого из методов службы, заключается в том, что у меня есть операция ProcessData , которая вызывает десятки методов Add / Get / Save службы для завершения. В этом сценарии я бы создавал десятки новых контекстов при вызове ProcessData . Как бы вы с этим справились?

2. С OwningComponentBase помощью чего бы вы рекомендовали звонить GetRequiredService каждый раз, когда мне нужен новый экземпляр службы для обработки пользовательского ввода?

3. 1. Предполагается, что DbContext должен быть легким, его легко создавать и утилизировать. Я не думаю, что вам следует слишком беспокоиться о необходимости создавать много экземпляров DbContext. На самом деле многие рекомендуют, чтобы DbContext жил как можно короче. Сосредоточьтесь на правильной абстракции единицы работы, а не на создании контекста. 2. OwningComponentBase существует, потому что вы хотите, чтобы срок службы DbContext соответствовал сроку службы компонента. Таким образом, вы создаете его только один раз (при инициализации). Просто помните о параллелизме.

4. Меня смущает OwningComponentBase ограничение продолжительности жизни. Разве регистрация службы как временной и введение ее в компонент не оказывают такого же эффекта (во всяком случае, на стороне сервера)?

5. Переходные процессы и область действия в Blazor server запутаны. Проблема в том, что для запроса не существует «запросов HTTP API». Все имеет область действия, потому что соединение SignalR всегда включено. Из документа «В ASP.NET Основные приложения, службы с областью действия, как правило, ограничены текущим запросом. После завершения запроса все ограниченные или временные службы удаляются системой DI. В серверных приложениях Blazor область запроса сохраняется в течение всего времени подключения клиента, что может привести к тому, что временные службы и службы с областью действия будут работать намного дольше, чем ожидалось…»

Ответ №2:

Я бы использовал семафор, чтобы предотвратить одновременные вызовы базы данных. Тогда вам не нужно создавать новый контекст.

Сначала я также попытался и преуспел в реализации DbContextFactory — И хотя я больше не получал ошибку » …вторая операция началась…», я понял, что мне нужно отслеживать изменения (в одном контексте) в разных службах и компонентах, чтобы предотвратить проблемы с несогласованностью базы данных.

В одном из моих компонентов у меня есть несколько полей ввода, которые должны запускать функцию обновления в @onfocusout. Если пользователь будет быстро переходить между полями, удерживая клавишу tab, приведенный ниже метод семафора «сложит» все действия по обновлению и выполнит их красиво одно за другим.

      @code {
            static Semaphore semaphore;
            //code ommitted for brevity
            
             private async Task DoSomethingOnValueChanged()
             {
                 
                try
                {
                    //First open global semaphore
                    if (!Semaphore.TryOpenExisting("GlobalSemaphore", out semaphore))
                    {
                         semaphore = new Semaphore(1, 1, "GlobalSemaphore");
                    }
    
                    while (!semaphore.WaitOne(TimeSpan.FromTicks(1)))
                    {
                        await Task.Delay(TimeSpan.FromSeconds(1));
                    }
                    //If while loop is exited or skipped, previous service calls are completed.
                    var myValue = await this.MyService.GetData(this.myValue);
                    if (myValue != null)
                    {
                        myValue.SomeField = "some new value";
                        await this.MyService.SaveChanges();
                    }     
                 
                 }
                 finally
                 {
                    try
                    {
                        semaphore.Release();
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine("ex.Message");
                    }
                }
            }
 

Комментарии:

1. Спасибо за ответ, это хорошее решение. Вы помещаете семафор в компонент Blazor (в отличие от классов обслуживания), верно?

2. @Ben — Да, это правильно, семафор используется внутри метода(ов) компонента Blazor, где он вызывает внедренную службу 👍 Однако, поскольку семафор работает глобально, вы, вероятно, также можете использовать его непосредственно во всех ваших службах. Я не тестировал это, но это также должно работать 🙂