Я не понимаю, как работать с 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
, допустим, полученных из реквеста. Ну разве не более френдли? Кто что думает? Прошу покритиковать мой код и буду благодарен за дельные замечания, которые смогли бы упростить жизнь читающим. Спасибо за внимание!
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
. Его интерфейс (как и интерфейсы репозиториев), описан на уровне предметной области. Реализация делается ниже — на уровне доступа к данным.
Что касается контроллеров, то они находятся над уровнем предметной области. Это уровень взаимодействия с пользователем или взаимодействия с внешней программой. Классическое название — уровень представления.
Айфон мало держит заряд, разбираемся с проблемой вместе с AppLab
Перевод документов на английский язык: Важность и ключевые аспекты
Как оптимизировать физику на Unity? Через Profiler на Unity я посмотрел что больше всего нагружает физика, а физичный объект у меня только водаУ меня...
Этот метод не будет работать, но как можно реализовать подобную логику?
Решил попробовать написать своего первого бота (надеюсь не последнего) для Vk, используя VKNet и на языке C#Найдя несколько гайдов, я приступил...
Хотите улучшить этот вопрос? Переформулируйте вопрос так, чтобы на него можно было дать ответ, основанный на фактах и цитатах