Работа с параллелизмом с Entity Framework в приложении ASP.NET MVC
Это очередная статья из серии статей:
- Создание модели данных Entity Framework для приложения ASP.NET MVC
- Реализация базовой CRUD-функциональности с Entity Framework в приложении ASP.NET MVC
- Сортировка, фильтрация и разбиение по страницам с Entity Framework в приложении ASP.NET MVC
- Создание сложной модели данных для приложения ASP.NET MVC
- Создание сложной модели данных для приложения ASP.NET MVC, часть 2
- Загрузка данных с Entity Framework в приложении ASP.NET MVC
- Обновление связанных данных с помощью Entity Framework в приложении ASP.NET MVC
В предыдущих уроках вы работали со связанными данными. В этом уроке мы рассмотрим вопросы одновременного доступа. Вы создадите страницы, работающие с сущностью Department, и страницы для редактирования и удаления сущностей Department будут также обрабатывать ошибки параллелизма. Результаты работы изображены на иллюстрациях.
Конфликты одновременного доступа
Конфликт одновременного доступа возникает, когда один пользователь просматривает данные об одной сущности и далее редактирует их, и в это же время другой пользователь обновляет эти же самые данные перед тем, как изменения, внесённые первым пользователем, сохраняются в базу. Если EF не настроен для обнаружения подобных конфликтов, тот, кто последним обновит базу данных, перезапишет изменения, внесённые ранее. Во многих приложения риск не критичен: если есть несколько пользователей, или несколько обновлений, или перезапись изменений не очень критична, то цена программирования, ориентированного на параллелизм, будет выше чем выгода от этого. В таком случае, настраивать приложения для обработки подобных ситуаций необязательно
Pessimistic Concurrency (Locking)
Если приложение нуждается в предотвращении случайной потери данных в результате конфликтов одновременного доступа, одним из методов решения проблемы является блокировка таблиц. Это называется пессимистичный параллелизм (pessimistic concurrency) . Например, перед загрузкой записи из базы данных, вы запрашиваете блокировку на read-only или на update доступ. Если вы блокируете таким образом доступ на изменение, ни один другой пользователь не может блокировать данную запись на доступ только-чтение или изменение, так как они получают только копию данных. Если вы блокируете запись на доступ только-чтение, другие также могут заблокировать его на доступ только-чтение, но только не на изменение.
Управление блокировками имеет свои недостатки. Программирование может быть слишком сложным, блокировки нуждаются в серьёзных ресурсах базы данных, и накладные расходы по загрузке возрастают по мере возрастания количества пользователей приложения. В связи с этим не все СУБД поддерживают пессимистичный параллелизм. Entity Framework не предоставляет встроенного механизма для обеспечения пессимистичного параллелизма, и в данном уроке этот подход не будет рассматриваться.
Optimistic Concurrency
В качестве альтернативы пессимистичному параллелизму (pessimistic concurrency) выступает оптимистичный параллелизм (optimistic concurrency) . Optimistic concurrency позволяет конфликтам одновременного доступа случиться, но позволяет адекватно среагировать на подобные ситуации. Например, Джон открывает страницу Departments Edit, изменяет значение Budget для английского филиала с $350,000.00 на $100,000.00.
Перед нажатием Джоном кнопки Save, Джейн открывает ту же страницу и изменяет значение StartDate на 1/1/1999.
Джон нажимает кнопку Save первым и видит свои изменения, и в этот момент на кнопку нажимает Джейн. Что следует за этим, зависит от того, как вы обрабатываете подобного рода ситуации. Их можно обрабатывать следующими методами:
- Можно хранить запись о том, какое свойство было отредактировано, и обновлять только соответствующие столбцы в базе данных. В примере данные не потеряются, так как разными пользователями были отредактированы разные свойства. В следующий раз при просмотре данных об английском филиале пользователь увидит изменения, внесённые и Джоном и Джейн.
Этот метод может уменьшить количество ситуаций с потерей данных, но не сможет помочь при редактировании одного свойства сущности. Однако использование этого метода нечасто можно встретить в веб-приложениях в связи с большим количеством данных, которым необходимо управлять для отслеживания старых и новых значений свойств. Управление большими массивами данных может сказаться на производительности приложения.
- Можно позволить изменениям Джейн перезаписывать изменения Джона. Тогда следующий пользователь увидит 1/1/1999 и старое значение $350,000.00. Этот сценарий называется ClientWins или LastinWins. Это происходит автоматически, если вы не меняете поведение приложения.
- Можно не обновлять базу для изменений Джейн: выдать ошибку, показать текущее состояние данных и позволить ей обновить страницу и ввести свои данные снова. Этот сценарий называется StoreWins. Мы воспользуемся данным сценарием в этом уроке. Он гарантирует, что изменения не будут потеряны, а пользователь своевременно оповещён о конфликте одновременного доступа.
Обнаружениеконфликтоводновременногодоступа
Можно разрешать подобные конфликты обработкой исключений OptimisticConcurrencyException, выбрасываемого EF. Для того, чтобы узнать, когда выбрасывать данное исключение, EF должен уметь определять момент возникновения конфликта. Поэтому необходимо правильно настроить базу данных и модель данных. Можно воспользоваться следующими вариантами для подобной настройки:
- В таблице в базе данных включить «следящий» столбец, который можно использовать для определения момента изменения записи. Затем можно включать этот столбец в операторе Where запросов Update или Delete.
Тип данных данного столбца обычно timestamp, но на самом деле он не хранит дату или время. Вместо этого, значение равно цифре, увеличивающейся на единицу при каждом обновлении данных (такой же тип данных может иметь тип rowversion в последних версиях SQL Server). В запросах Update или Delete, оператор Where включает исходное значение «следящего» столбца. Если запись в процессе обновления редактируется пользователем, значение в данном столбце отличается от исходного, поэтому запросы Update и Delete не смогут найти данную запись. Когда EF обнаруживает, что запросом Update или Delete ничего не было обновлено, он расценивает это как возникновение конфликта одновременного доступа.
- Настроить EF для включения исходных данных каждого столбца в операторе Where запросов Update и Delete.
Как один из вариантов, если ничего в записи не изменилось с момента её первой загрузки, оператор Where не возвратит запись для обновления, что EF воспримет как конфликт. Данный вариант эффективен в той же мере, как и вариант «следящего» столбца. Однако в случае наличия таблицы с множеством столбцов, в результате использования этого подхода могут возникнуть большое количество операторов Where и необходимость в управлении больших массивов данных и состояний. Данный подход не рекомендуется к использованию в большинстве случаев.
В данном уроке мы добавим «следящий» столбец к сущности Department, создадим контроллер и представления и протестируем всё в связке.
Note если вы реализуете параллелизм без «следящего» столбца, вы должны отметить все непервичные ключи атрибутом ConcurrencyCheck, определив для EF, что в операторе Where запросов Update будут включаться все столбцы
Добавление «следящего» столбца к сущности Department
В Models \ Department . cs добавьте «следящий» столбец:
[Timestamp]
public Byte[] Timestamp { get; set; }
Атрибут Timestamp определяет, что данный столбец будет включен в операторе Where запросов Update и Delete.
Созданиеконтроллера
Создайте контроллер Department и представления:
В Controllers\DepartmentController.cs добавьте using:
using System.Data.Entity.Infrastructure;
Измените LastName на FullName во всём файле (четыре вхождения) чтобы в выпадающих списках факультетских администраторов отображалось полное имя вместо фамилии.
Замените код метода HttpPost Edit на:
[HttpPost]
public ActionResult Edit(Department department)
{
try
{
if (ModelState.IsValid)
{
db.Entry(department).State = EntityState.Modified;
db.SaveChanges();
return RedirectToAction("Index");
}
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = (Department)entry.GetDatabaseValues().ToObject();
var clientValues = (Department)entry.Entity;
if (databaseValues.Name != clientValues.Name)
ModelState.AddModelError("Name", "Current value: "
+ databaseValues.Name);
if (databaseValues.Budget != clientValues.Budget)
ModelState.AddModelError("Budget", "Current value: "
+ String.Format("{0:c}", databaseValues.Budget));
if (databaseValues.StartDate != clientValues.StartDate)
ModelState.AddModelError("StartDate", "Current value: "
+ String.Format("{0:d}", databaseValues.StartDate));
if (databaseValues.InstructorID != clientValues.InstructorID)
ModelState.AddModelError("InstructorID", "Current value: "
+ db.Instructors.Find(databaseValues.InstructorID).FullName);
ModelState.AddModelError(string.Empty, "The record you attempted to edit "
+ "was modified by another user after you got the original value. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again. Otherwise click the Back to List hyperlink.");
department.Timestamp = databaseValues.Timestamp;
}
catch (DataException)
{
//Log the error (add a variable name after Exception)
ModelState.AddModelError(string.Empty, "Unable to save changes. Try again, and if the problem persists contact your system administrator.");
}
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName", department.InstructorID);
return View(department);
}
Представление отобразит исходное значение «следящего» столбца в скрытом поле. При создании экземпляра department, этот объект не будет иметь значения в свойстве Timestamp. Затем, после создания EF запроса Update, запрос будет включать оператор Where с условием поиска записи с исходным значением Timestamp.
Если запросом Update не будет обновлена ни одна запись, EF выбросит исключение DbUpdateConcurrencyException, и код в блоке catch возвратит связанную с исключением сущность Department. Эта сущность имеет исходные и новые значения свойств:
var entry = ex.Entries.Single();
var databaseValues = (Department)entry.GetDatabaseValues().ToObject();
var clientValues = (Department)entry.Entity;
Далее, код добавляет сообщение об ошибке для каждого столбца, имеющего в базе данных значения, отличающиеся от того, что ввёл пользователь на странице Edit:
if (databaseValues.Name != currentValues.Name)
ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
// ...
При ошибке отображается подробное сообщение:
ModelState.AddModelError(string.Empty, "The record you attempted to edit "
+ "was modified by another user after you got the original value. The"
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again. Otherwise click the Back to List hyperlink.");
Наконец, код устанавливает значение свойства Timestamp для объекта Department в новое значение, полученное из базы данных. Это новое значение будет сохранено в скрытом поле при обновлении страницы Edit, и при следующем нажатии Save, будут перехвачены только те ошибки параллелизма, которые возникли с момента перезагрузки страницы.
В Views \ Department \ Edit . cshtml добавьте скрытое поле для сохранения значения Timestamp, сразу после скрытого поля для свойства DepartmentID:
@Html.HiddenFor(model => model.Timestamp)
В Views \ Department \ Index . cshtml замените код, так, чтобы сдвинуть ссылки записей влево и изменить заголовки страницы и столбцов:
@model IEnumerable<ContosoUniversity.Models.Department>
@{
ViewBag.Title = "Departments";
}
<h2>Departments</h2>
<p>
@Html.ActionLink("Create New", "Create")
</p>
<table>
<tr>
<th></th>
<th>Name</th>
<th>Budget</th>
<th>Start Date</th>
<th>Administrator</th>
</tr>
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink("Edit", "Edit", new { id=item.DepartmentID }) |
@Html.ActionLink("Details", "Details", new { id=item.DepartmentID }) |
@Html.ActionLink("Delete", "Delete", new { id=item.DepartmentID })
</td>
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.Budget)
</td>
<td>
@Html.DisplayFor(modelItem => item.StartDate)
</td>
<td>
@Html.DisplayFor(modelItem => item.Administrator.FullName)
</td>
</tr>
}
</table>
Проверка работы Optimistic Concurrency
Запустите проект и щёлкните на Departments:
Щёлкните ссылку Edit и затем в новом окне браузера откройте ещё одну страницу Edit. Окна должны отображать идентичную информацию.
Измените поле в первом окне браузера и нажмите Save.
Отобразится страница Index с изменёнными данными.
Измените то же самое поле на другое значение во втором окне браузер.
Нажмите Save , чтобы увидеть сообщение об ошибке:
Нажмите Save ещё раз. Значение, которое вы ввели во втором окне браузера, сохранилось в базе данных и вы увидите, что изменения появились на странице Index.
ДобавлениестраницыDelete
Для страницы Delete вопросы параллелизма обрабатываются подобным образом. При отображении методом HttpGet Delete окна подтверждения, представление включает исходное значение Timestamp в скрытом поле. Это значение доступно методу HttpPost Delete, который вызывается, когда пользователь подтверждает удаление. Когда EF создаёт запрос Delete, этот запрос включает оператор Where с исходным значением Timestamp. Если запрос ничего не возвратил, выбрасывается исключения параллелизма, и метод HttpGet Delete вызывается с параметром ошибки, установленным в true для перезагрузки страницы подтверждения с сообщением об ошибке.
В DepartmentController . cs замените код метода HttpGet Delete на:
public ActionResult Delete(int id, bool? concurrencyError)
{
if (concurrencyError.GetValueOrDefault())
{
ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you got the original values. "
+ "The delete operation was canceled and the current values in the "
+ "database have been displayed. If you still want to delete this "
+ "record, click the Delete button again. Otherwise "
+ "click the Back to List hyperlink.";
}
Department department = db.Departments.Find(id);
return View(department);
}
Метод принимает необязательный параметр, определяющий, необходимо ли перезагрузить страницу после ошибки параллелизма. Если параметр установлен в true, сообщение об ошибке пересылается в представление в свойстве ViewBag.
Замените код метода HttpPost Delete (DeleteConfirmed) на:
[HttpPost, ActionName("Delete")]
public ActionResult DeleteConfirmed(Department department)
{
try
{
db.Entry(department).State = EntityState.Deleted;
db.SaveChanges();
return RedirectToAction("Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToAction("Delete",
new System.Web.Routing.RouteValueDictionary { { "concurrencyError", true } });
}
catch (DataException)
{
//Log the error (add a variable name after Exception)
ModelState.AddModelError(string.Empty, "Unable to save changes. Try again, and if the problem persists contact your system administrator.");
return View(department);
}
}
Изначально метод принимал только значение ID записи:
public ActionResult DeleteConfirmed(int id)
Мы изменили этот параметр на сущность Department, что даёт нам доступ к свойству Timestamp.
public ActionResult DeleteConfirmed(Department department)
Если выбрасывается ошибка параллелизма, код перезагружает страницу подтверждения с усановленным параметром ошибки.
В Views \ Department \ Delete . cshtml замените код на код, обеспечивающий форматирование и поле для сообщения об ошибке:
@model ContosoUniversity.Models.Department
@{
ViewBag.Title = "Delete";
}
<h2>Delete</h2>
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<fieldset>
<legend>Department</legend>
<div class="display-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Name)
</div>
<div class="display-label">
@Html.LabelFor(model => model.Budget)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Budget)
</div>
<div class="display-label">
@Html.LabelFor(model => model.StartDate)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.StartDate)
</div>
<div class="display-label">
@Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
</fieldset>
@using (Html.BeginForm()) {
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.Timestamp)
<p>
<input type="submit" value="Delete" /> |
@Html.ActionLink("Back to List", "Index")
</p>
}
Этот код добавляет сообщение об ошибке между заголовками h2 и h3:
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
Он заменяет LastName на FullName в поле Administrator:
<div class="display-label">
@Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
И, наконец, добавляются скрытые поля для DepartmentID и Timestamp:
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.Timestamp)
Откройте в разных окнах браузера страницу Departments Index.
В первом окне нажмите Edit и измените одно из значений, но не нажимайте Save:
Во втором окне нажмите Delete на том же факультете. Появится окно подтверждения.
Нажмите Save в первом окне браузера. Изменения подтвердятся.
Теперь нажмите Delete во втором окне браузера, чтобы увидеть сообщение об ошибке параллелизма. Данные обновятся.
Если вы нажмёте Delete еще раз, то откроется страница Index с подтверждением об удалении записи факультета.
Мы закончили вступление в обработку конфликтов одновременного доступа. Для дополнительной информации смотрите Optimistic Concurrency Patterns и Working with Property Values. В следующем уроке мы покажем вам как реализовать наследование для сущностей Instructor и Student.
--
Это перевод оригинальной статьи Handling Concurrency with the Entity Framework in an ASP.NET MVC Application. Благодарим за помощь в переводе Александра Белоцерковского.