Blazor Server - технология для простого написания Web-систем на платформе .Net. Для удобной работы с базами данных была создана библиотека Entity Framework, которая позволяет работать программисту напрямую с моделями, не задумываясь об SQL-запросах. Но всё ли так хорошо, если соединить Blazor и EF?

Проблема №1 - два потока не могут одновременно работать с одним DbContext

A second operation started on this context before a previous operation completed. Any instance members are not guaranteed to be thread-safe.

Что это значит?

Для того чтобы понять из-за чего возникает такая ошибка, нужно знать одну вещь - DbContext не является Thread-save, то есть нельзя одновременно использовать один и тот же контекст в двух разных потоках (Из официальной документации Microsoft). В Blazor-е такая ошибка возникает зачастую из-за того, что несколько компонентов, которые во время инициализации обращаются к одному DbContext, создаются одновременно.

Пример

Представим, что Вам нужно написать страницу, на которой пользователь должен указать своё учебное заведение (ему нужно выбрать страну, город и само учебное заведение), а также образование, которое он уже получил (направление и специальность). Вся информация, которую выбирает пользователь хранится в БД. Для того, чтобы структура Вашей страницы была более понятной, Вы разделяете её на два компонента - один занимается местоположением, другой - образованием. Для этого Вы в каждом компоненте с помощью стандартной системы Blazor-овского Dependency Injection получаете DbContext, затем переопределяете метод OnInitialized (или OnInitializedAsync), где с помощью полученного контекста достаёте нужную Вам информацию из базы данных. Как итог, после запуска приложения и открытия этой страницы, дочерние компоненты создаются и работают с БД одновременно и Вы получаете вышеупомянутую ошибку.

Как решить?

Есть несколько вариантов решений:

  1. Получить всю нужную информацию из базы данных в родительском компоненте и передать её дочерним в качестве параметра. Кроме того, нужно убедиться, что у пользователя не будет возможности одновременно запустить методы, которые будут работать с базой данных - для этого можно использовать bool-переменную IsLoading, привязав её ко всем дочерним компонентам.
    Минус данного способа - усложнение структуры страницы и невозможность выполнять операции с базой данных асинхронно

  2. Создавать на каждый метод или компонент, который может выполняться асинхронно, новый DbContext. Такой функционал можно реализовать через DbContextFactory или IServiceScopeFactory.
    Минус данного способа - повышенное потребление ресурсов

Итог

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

Проблема №2 - не удается отследить экземпляр объекта, так как объект с таким Id уже существует

The instance of entity type cannot be tracked because another instance of this type with the same key is already being tracked

Что это значит?

Это значит, что у вас большие проблемы. Дело в том, что для того чтобы DbContext мог отследить изменения моделей, у него есть список TrackedEntities (Из официальной документации Microsoft). Изначально этот список пустой. Но после он будет наполняться моделями, которые взаимодействовали с этим контекстом. В случае если в этом списке уже будет нужная Вам модель и Вы создадите её копию, то после взаимодействия этой копии с DbContext, этот контекст не сможет её добавить к себе в список TrackedEntities чем и вызовет исключение.

Пример

Воспользуемся предыдущим примером. Допустим, Вы всё таки решили выбрать второй способ решения, а именно использовать IServiceScopeFactory и для каждого метода создавать новый DbContext. После того как пользователь укажет все выбранные им данные, Вам нужно сохранить их в базу данных (назовем модель, где будет хранится эта информация как UserInformation). В компоненте, где пользователь указывает своё образование, Вы сделали такой механизм - сначала пользователь выбирает направление, а после выбора, из БД вытягиваются специальности для выбранного направления. Но Вы сделали ошибку, из-за которой при инициализации компонента вытягивается список направлений, в каждом из которых уже есть список специализаций. Таким образом получается вот какой казус - специальности, которые привязаны к направлению, и специальности, которые доставались отдельно - в БД это хоть и одинаковые модели, но в памяти будут хранится как разные объекты (они были вытянуты из БД с помощью разных DbContext). Затем, если Вы попробуете сохранить выбранное направление и выбранную специализацию в модели UserInformation, там у Вас будет храниться привязанное направление со списком специализаций и выбранная специализация (Важно: модель выбранной специализации в UserInformation и та же модель в списке специализаций выбранного направления это два разных объекта). То есть, когда Вы будете сохранять в БД UserInformation через Insert/Update в новом DbContext, контекст попытается отследить все вложенные модели. Допустим, сначала он отследит выбранное направление с привязанными к нему специализациями. Потом он попытается сделать тоже самое с выбранной специализацией, но обнаружит, что такую модель он уже отслеживает, а что делать с его копией - не понятно. Именно поэтому у Вас вылетит вышеупомянутая ошибка.

Как решить?

  1. Использовать подход с DTO моделями. Таким образом Вы привязываетесь не к объектам из БД, а к их копиям. После изменений копий Вы заново достаёте из БД нужные Вам модели и переносите в них данные из копий.

    Минус данного способа - более сложная структура проекта, повышенное потребление ресурсов

  2. Не допускать копий. Если к контексту привязать модель, которую Вы получили из другого контекста, он будет уже использовать не новый объект, а тот что Вы к нему привязали.

    Минус данного способа - Вы не всегда сможете отследить повторение (особенно если у Вас сложная структура моделей и логика взаимодействия с ними), а это значит, что ошибка может возникнуть на проде в самый не подходящий момент.

  3. Не использовать различные контексты (первый вариант первой проблемы).

Итог

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

Так что же использовать?

На самом деле, всё зависит от сложности проекта. Но всё же, если Вы хотите избавиться от всех подобных проблем, используйте IServiceScopeFactory и DTO модели.

Комментарии (10)


  1. AntonAntonov88
    03.04.2022 21:55
    +9

    Особенности EF не зависят от того используется он в Blazer или в обычном консольном приложении.


    1. UnderNickOfficial Автор
      04.04.2022 13:34

      Это верно, но эта статья создана для тех, кто видит такое исключение впервые и именно на Blazor, так как например при создании API-сервера такие ошибки почти не возникают.


      1. dmirtyO
        06.04.2022 08:34

        Здесь важно время существования контекста, создаваемого контейнером DI.

        По этому критерию можно разделить на две группы:

        • консольки, MVC, web-api: один экземпляр на запрос, Singleton / Transient работают как ожидается;

        • blazor server, win form, win service: один экземпляр на приложение, Singleton/Transient эквивалентны.

        Вы рассматриваете только вариант несколько контекстов на одной странице.

        Гораздо чаще (и болезнее) вариант многопользовательской работы.

        Поэтому использование фабрики в случае blazor server – практически безальтернативно.

         


  1. GrafGenerator
    04.04.2022 08:41
    +3

    Но при чем здесь конкретно blazor server? Напороться на такую проблему приходилось, я думаю, каждому, кто хоть когда использовал EF, хоть с blazor, хоть без него.

    А по сути проблемы - ну да, а что вы хотели, если есть два контекста, которые работают с одними и теми же сущностями, вы получите конфликт.

    Решение, в принципе, очевидно: не трекать сущности на чтение, держать один контекст и трекать всё него при выполнении обозначенной бизнес-операции.

    И кстати, а почему вы не упомянули .AsNoTracking()?


    1. UnderNickOfficial Автор
      04.04.2022 10:39

      Решение с одним контекстом не всегда можно использовать. Особенно если у вас большой проект и вам нужно разделить страницу на множество компонентов, для которых нужны свои данные из БД. Вытягивать все данные в родительском компоненте и после передавать их в качестве параметра - не всегда удобно. Например, если у вас вложенная структура и нужно передать данные 4-му дочернему компоненту. По поводу .AsNoTracking() - он выполняет тот же функционал, что и удаление контекста.


      1. dmirtyO
        05.04.2022 19:13

        Попробуйте каскадный параметр.

        Он специально для простого доступа из глубины дерева компонентов предназначен.


  1. makar_crypt
    04.04.2022 08:42

    я правильно понял проблему?

    что есть список айдишников userIds = [1,2,3]

    делается рендер "карточек" передавая в параметре только userID

    {loop}

    <UserCard :userID ="i" >

    {/loop}

    А во время рендера этих карточек получается идет запрос в DB ?

    onRender( db.getUserByID(i) )

    И получается что если рендерить сразу 50 пользователей то это 50 запросов к бд вместо одного ?


    1. UnderNickOfficial Автор
      04.04.2022 13:42

      Проблема именно в том, что все эти 50 запросов будут выполнены одновременно, из разных потоков и в одном контексте. А так как DbContext не является thread-safe, то есть не позволяет двум потокам одновременно с ним работать, возникает первая ошибка.


  1. Dr9vik
    04.04.2022 08:44

    Создавать на каждый метод или компонент, который может выполняться асинхронно, новый DbContext. Такой функционал можно реализовать через DbContextFactory или IServiceScopeFactory.

    а зачем?
    или у вас все классы бизнес логики синглтоны?


    1. Vey-Di
      04.04.2022 10:19
      +1

      При использовании DbContextFactory когда вы захотите сохранить изменения вы можете столкнутся со второй ошибкой что описана в в этом посте.