Объясните пожалуйста, как работает Update в Entity Framework

192
23 декабря 2021, 18:40

Я не понимаю, как работать с Entity Framework. После Laravel и его ОРМ мне это кажется совершенно недружелюбной технологией с кучей граблей. У меня есть сущность (модель):

    public class Funnel
    {
        public int FunnelId { get; set; }
        public Category Category { get; set; }
        public List<AdvertiserFunnel> AdvertiserFunnels { get; set; }
        public bool IsActive { get; set; }
        public bool IsChoiceAllApps { get; set; }
        public List<string> PackageIds { get; set; }
        public List<PushTemplate> PushTemplates { get; set; }
        public DateTime CreatedAt { get; set; }
        public string Title { get; set; }
        public Funnel()
        {
            CreatedAt = DateTime.UtcNow;
        }
    }

Как видите, есть связи один-ко-многим (например, Category), есть многие-ко-многим (AdvertiserFunnels).

У меня есть репозиторий. С чистым контекстом в контроллерах я не работаю, вот метод сохранения:

        public void Save(Funnel funnel)
        {
            if (funnel.FunnelId == 0)
            {
                _applicationDbContext.Funnels.Add(funnel);
            }
            else
            {
                _applicationDbContext.Funnels.Update(funnel);
            }
            _applicationDbContext.SaveChanges();
        }

Я делаю Update. И у меня есть два варианта. Вот первый, где я обновляю поле IsActive. Это банальный чекбокс на UI, при активации которого отправляется ajax на такой метод:

 public IActionResult SwitchActiveState([FromBody] JObject json)
        {
            int funnelId;
            bool isActive;
            try
            {
                funnelId = json.SelectToken("templateId").ToObject<int>();
                isActive = json.SelectToken("isActive").ToObject<bool>();
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                return BadRequest();
            }
            var item =
                _funnelRepository.Funnels
                    .FirstOrDefault(x => x.FunnelId == funnelId);
            if (item == null)
            {
                return NotFound();
            }
            item.IsActive = isActive;
            _funnelRepository.Save(item);
            return Ok();
        }

Запускаю все это, вызываю обновление и все работает.

А теперь, я хочу обновить поле AdvertiserFunnels. Это уже форма редактирования, где у меня есть select multiple для данного поля. Вот такой код на обновление:

 public IActionResult Save([FromBody] FunnelViewModel viewModel)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            var advertisersTask = _advertiserRepository.Advertisers
                .Where(x => viewModel.Advertisers.Select(y => y.AdvertiserId).Contains(x.AdvertiserId)).ToListAsync();
            var categoryTask = _categoryRepository.Categories
                .FirstOrDefaultAsync(x => viewModel.Category.CategoryId == x.CategoryId);
            Task.WhenAll(advertisersTask, categoryTask);
            var advertisers = advertisersTask.Result;
            var category = categoryTask.Result;
            if (!advertisers.Any())
            {
                ModelState.TryAddModelError("Advertisers", "Нет ни одного рекла");
            }
            if (category == null)
            {
                ModelState.TryAddModelError("Category", "Необходимо выбрать категорию");
            }
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            var advertisersFunnels = new List<AdvertiserFunnel>();
            var funnel = new Funnel();
            funnel.Category = category;
            funnel.IsActive = viewModel.IsActive;
            funnel.IsChoiceAllApps = viewModel.IsChoiceAllApps;
            funnel.Title = viewModel.Title;
            funnel.PackageIds = viewModel.Applications.Select(x => x.AppId).ToList();
            funnel.FunnelId = viewModel.FunnelId;
            foreach (var advertiser in advertisers)
            {
                advertisersFunnels.Add(new AdvertiserFunnel
                {
                    Funnel = funnel,
                    Advertiser = advertiser,
                });
            }
            funnel.AdvertiserFunnels = advertisersFunnels;
            _funnelRepository.Save(funnel);
            return Ok();
        }

Запускаю обновление и:

Ладно, думаю, может так надо в репозитории написать:

...
            else
            {
                _applicationDbContext.AdvertiserFunnels.AttachRange(funnel.AdvertiserFunnels);
                _applicationDbContext.Funnels.Update(funnel);
            }
...

Возвращаюсь на свой чекбокс (о котором речь шла ранее), тыкаю и:

Да что ж такое. Может так надо:

 else
            {
                if (funnel.AdvertiserFunnels != null)
                {
                    _applicationDbContext.AdvertiserFunnels.AttachRange(funnel.AdvertiserFunnels);
                }
                _applicationDbContext.Funnels.Update(funnel);
            }

Тыкаю - работает. Я что то не так пишу? Почему так много проверок и каких-то безумных костылей получается? Я покажу, как это выглядело бы в Laravel (это php-фреймворк, если меня читают матерые дотнетчики и такие господа просто не знают про существование какого-то Laravel). Итак:

$funnel = Funnel::where('id', '=', $funnelId); // $funnelId, допустим, я получил из реквеста и это int
$funnel->isActive = true;
$funnel->save(); 

Все. Это первый случай. И все работает! Теперь многие-ко-многим:

$funnel = Funnel::where('id', '=', $funnelId);
$funnel->advertisers()->sync($arrayWithAdvertisersIds);

, где advertisers() - это метод, устанавливаемый в модели Funnel чтоб показать ОРМ, что это отношение многие-ко-многим. А $arrayWithAdvertisersIds это массив int идентификаторов сущности Advertiser, допустим, полученных из реквеста. Ну разве не более френдли? Кто что думает? Прошу покритиковать мой код и буду благодарен за дельные замечания, которые смогли бы упростить жизнь читающим. Спасибо за внимание!

Answer 1

Entity Framework реализует паттерн Единица работы (Unit of Work, UoW). Его назначение — представлять бизнес-транзакции на уровне предметной области (не на уровне доступа к данным).

Единица работы сохраняет набор изменений, например, добавление, изменение и удаление записей. Все изменения вносятся в базу при вызове метода SaveChanges. Копии записей БД хранятся в единице работы в одном из нескольких состоянии: не изменённые, изменённые, новые и удалённые.

Такой подход очень удобен в некоторых сценариях, но накладывает определённые ограничения. Мы не можем просто удалить записи в базе. Мы должны их загрузить и пометить, как удалённые. В некоторых случаях можно сконструировать запись в Единице работы и пометить её, как будто она загружена из базы, после чего "удалить".

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

Итак, простой способ что-то сделать с существующей записью — сначала её загрузить.

Удаление

    public IAsyncResult DeleteById(int id)
    {
        var record = dbContext.Funnels.SingleOrDefault(x => x.Id == id);
        if (record == null)
            return NotFound();
        dbContext.Funnels.Remove(record);
        dbContext.SaveChanges();
        . . .
    }

Обновление

    public IAsyncResult UpdateById(int id, FunnelModel model)
    {
        var record = dbContext.Funnels.SingleOrDefault(x => x.Id == id);
        if (record == null)
            return NotFound();
        record.Foo = model.Foo;
        record.Bar = model.Bar;
        record.Baz = model.Baz;
        dbContext.SaveChanges();
        . . .
    }

Обратите внимание, что при обновлении вам не надо явно помечать запись изменённой, то есть явно взывать Update. EF при загрузке из базы сохраняет её в состоянии "без изменений", а перед сохранением самостоятельно ищет все изменённые записи.

Если вам кажется, что это медленно — загружать записи чтобы их удалить или обновить, то вы правы. Unit of Work не про скорость работы, а про удобство при определённых сценариях.

При создании записи мы ничего не читаем, мы просто вызываем метод Add:

Создание

    public IAsyncResult Create(FunnelModel model)
    {
        dbContext.Records.Add(new Funnel
        {
            Foo = model.Foo,
            Bar = model.Bar,
            Baz = model.Baz,
        });
        dbContext.SaveChanges();
        . . .
    }

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

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

        var record = dbContext.Funnels.SingleOrDefault(x => x.Id == id);
        if (record == null)
            return NotFound();
        record.AdvertiserFunnels.Add(new AdvertiserFunnel { . . . });

Здесь мы загружаем запись Funnel из базы, но не загружаем связанные с ней записи AdvertiserFunnels. Мы создаём новую связанную запись, и, возможно это приведёт к ошибке. Например, EF может удалить старые записи, поскольку их нет в его образе базы.

Большим плюсом Единицы работы и конкретно EF является то, что он позволяет работать с группой записей, как с единым целым. В DDD есть понятие агрегат — это такой сложный объект, состоящий из нескольких простых сущностей, которые отображаются на таблицы.

Чтобы избегать проблем, надо попросить EF загружать основную запись Funnel вместе с навигационными свойствами, которые мы планируем изменить. Делается это с помощью метода Include.

        var record = dbContext.Funnels
                              .Include(x => x.AdvertiserFunnels)
                              .SingleOrDefault(x => x.Id == id);
        if (record == null)
            return NotFound();
        record.AdvertiserFunnels.Add(new AdvertiserFunnel { . . . });

Теперь EF точно знает, какие дочерние записи лежат в базе и может безболезненно создать новую.

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

UPDATE

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

class FooService
{
   public void MakeSomething()
   {
       using (var connection = new SqlConnection(_connectionString))
       using (var command = connection.CreateCommand();
       {
           . . .
           using (var dataReader = command.ExecuteReader())
           {
               var foo = Foo.CreatFromDataReader(dataReader);
               . . .
           }
       }
   }
}

У этого кода две проблемы. Первая в том, что предметная область теперь завязана на SQL. Обычно этот аргумент не принимают, часто ли приходится менять нижний уровень? Мой опыт подсказывает, что да, приходится, но не часто.

Вторая проблема в том, что классы предметной области начинают слишком много знать про детали хранения. Эта информация им не нужна, но мы не можем без неё обходится. Класс Foo должен знать про System.Data.IDataReader и должен уметь себя оттуда читать.

Если нам потребуется транзакция, то мы сможем сделать её непосредственно для SQL-подключения.

Инвертировав зависимость, и введя интерфейс репозитория, мы уберём эти детали с уровня предметной области. Репозиторий — это большое хранилище наших сущностей, которое не помещается в память — такого знания достаточно на уровне предметной области.

Всё здорово, но методы репозитория могут обеспечивать атомарность и согласованность только на уровне отдельных агрегатов.

Агрегаты — это составные сущности. Скажем, заказ в магазине в базе хранится в двух таблицах: сам заказ и его позиции. Но на уровне кода Java/C#/Python у вас будет один агрегат, корневой сущностью которого будет заказ, и кроме заказа там будут ещё сущности-позиции заказа.

Репозиторий заказа может внести несколько правок в один заказ в рамках одной транзакции. А что делать, если у нас есть сценарий, где задействованы разные агрегаты?

Понятно, где это делать — если у нас несколько аграгатов, которыми мы оперируем, значит, у нас какой-то бизнес-сценарий. Для бизнес-сценариев мы заводим классы служб (классы сервисов). Но не создавать же там SqlTransaction вручную?

Чтобы спрятать транзакцию как раз и используют паттерн Unit of Work. Его интерфейс (как и интерфейсы репозиториев), описан на уровне предметной области. Реализация делается ниже — на уровне доступа к данным.

Что касается контроллеров, то они находятся над уровнем предметной области. Это уровень взаимодействия с пользователем или взаимодействия с внешней программой. Классическое название — уровень представления.

READ ALSO
Низкий FPS из за физики | Процедурный MeshColider

Низкий FPS из за физики | Процедурный MeshColider

Как оптимизировать физику на Unity? Через Profiler на Unity я посмотрел что больше всего нагружает физика, а физичный объект у меня только водаУ меня...

152
Передача в функцию параметра с неопределенным типом C#

Передача в функцию параметра с неопределенным типом C#

Этот метод не будет работать, но как можно реализовать подобную логику?

157
Некорректная работа VK бота

Некорректная работа VK бота

Решил попробовать написать своего первого бота (надеюсь не последнего) для Vk, используя VKNet и на языке C#Найдя несколько гайдов, я приступил...

81
Где взять знания? [закрыт]

Где взять знания? [закрыт]

Хотите улучшить этот вопрос? Переформулируйте вопрос так, чтобы на него можно было дать ответ, основанный на фактах и цитатах

269