Поделиться через


Доступ к данным

Как обойтись подручными средствами в отсутствие внешних ключей

Джули Лерман

Джули ЛерманСегодня мы поговорим об одной проблеме, с которой я сама часто сталкиваюсь в последнее время, помогая другим разработчикам: у них возникают сложности со связанными классами, определенными в Code First, и последующем их использовании в инфраструктуре Model-View-Controller (MVC). Эта проблема не специфична для Code First. Она является результатом поведения нижележащей Entity Framework (EF) и, по сути, универсальна для большинства ORM-средств. Но причина, по которой она вдруг всплыла на поверхность, кроется в том, что разработчики подходят к Code First с определенными ожиданиями. Из-за свойственной MVC высокой степени разъединения эти ожидания и создают такие сложности.

Вместо демонстрации только правильного кода я постараюсь помочь вам понять поведение EF, чтобы вы могли сами применять эти знания во многих ситуациях, которые могут встретиться вам при проектировании классов или написании кода для доступа к данным с применением API-средств EF.

Соглашение Code First предусматривает распознавание и корректное логическое определение различных отношений при наличии варьируемых комбинаций свойств в ваших классах. В этом примере, который я пишу, глядя на то, как листья на деревьях вокруг моего дома в Вермонте вполне ожидаемо меняют свою окраску, я использую Tree и Leaf в качестве связанных классов. В случае отношения «один ко многим» самый простой способ описать его в ваших классах и заставить Code First распознать ваши намерения — создать навигационное свойство в классе Tree, которое представляет какой-либо набор типов Leaf. В классе Leaf никаких свойств, указывающих на Tree, не требуется. Эти классы показаны на рис. 1.

Рис. 1. Связанные классы Tree и Leaf

public class Tree
{
  public Tree()
  {
    Leaves = new List<Leaf>();
  }

  public int TreeId { get; set; }   
  public string Type { get; set; }
  public double Lat { get; set; }
  public double Long { get; set; }
  public string Notes { get; set; }
  public ICollection<Leaf> Leaves { get; set; }
}
public class Leaf
{
  public int LeafId { get; set; }
  public DateTime FellFromTreeDate { get; set; }
  public string FellFromTreeColor { get; set; }
}

По соглашению, Code First будет известно, что необходимый внешний ключ находится в базе данных в таблице Leaf. Он предположит, что поле внешнего ключа называется Tree_TreeId, и на основе этой информации, предоставляемой в метаданных, которые создаются Code First в период выполнения, EF будет знать, как работать с запросами и выполнять обновления, используя этот внешний ключ. В EF применяется такое поведение и в случае «независимых сопоставлений» (independent associations) — единственного типа сопоставления, который мы могли бы использовать до появления Microsoft .NET Framework 4; этот вариант не требует наличия свойства внешнего ключа в зависимом классе.

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

Создание новых зависимых типов без внешнего ключа или навигационных свойств

Хотя эти классы можно легко использовать для отображения дерева и его листьев в приложении ASP.NET MVC и редактировать листья, разработчики часто сталкиваются с проблемами, создавая новые листья в MVC-приложении с типичной архитектурой. Чтобы Visual Studio автоматически создавала мои контроллеры, представления и простые репозитарии, я воспользовалась шаблоном из NuGet-пакета MVCScaffolding (mvcscaffolding.codeplex.com) и выбрала вариант «MvcScaffolding: Controller with read/write action and views, using repositories». Заметьте, что из-за отсутствия свойства внешнего ключа в классе Leaf, шаблоны Scaffolding не распознают отношение «один ко многим». Поэтому я внесла небольшие изменения в представления и контроллеры, чтобы пользователи могли переходить от дерева к его листьям; вы можете увидеть это в пакете исходного кода, который можно скачать для этой статьи.

Операция Create для Leaf принимает Leaf, возвращенный из представления Create, и сообщает репозитарию добавить его, а затем сохранить, как показано на рис. 2.

Рис. 2. Добавление и сохранение листа в репозитарии

[HttpPost]
public ActionResult Create(Leaf leaf)
{
  if (ModelState.IsValid)
  {
    leafRepository.InsertOrUpdate(leaf);
    leafRepository.Save();
    return RedirectToAction("Index",
      new { treeId = Request.Form["TreeId"] });
  }
  else
  {
    return View();
  }
}

Репозитарий принимает лист, проверяет, новый ли он, и, если да, добавляет его в экземпляр контекста, созданный в результате обратной передачи (postback):

public void InsertOrUpdate(Leaf leaf,int treeId){
  if (leaf.LeafId == default(int)) {
    // Новая сущность
    context.Leaves.Add(leaf);
  } else {
    // Имеющаяся сущность
    context.Entry(leaf).State = EntityState.Modified;
  }
}

Когда вызывается Save, инфраструктура EF создает команду Insert, которая добавляет новый лист в базу данных:

exec sp_executesql N'insert [dbo].[Leaves]([FellFromTreeDate],
[FellFromTreeColor], [Tree_TreeId]) values (@0, @1, null)
select [LeafId]
from [dbo].[Leaves]
where @@ROWCOUNT > 0 and [LeafId] = scope_identity()',N'@0 datetime2(7),@1 nvarchar(max) ',@0='2011-10-11 00:00:00',@1=N'Pale Yellow'

Обратите внимание на значения, передаваемые во второй строке команды: @0 (для даты), @1 (для модифицированного цвета) и null. Значение null предназначено для поля Tree_TreeId. Вспомните, что в классе Leaf нет свойства внешнего ключа, которое представляло бы TreeId, поэтому передать это значение при создании отдельного листа нельзя.

Когда зависимый тип (в данном случае — Leaf) не знает о своем «ведущем» типе (Tree), операция вставки можно выполнить только одним способом: экземпляры Leaf и Tree нужно вместе добавить в контекст как часть одного графа. Это предоставит EF всю информацию, необходимую для того, чтобы вставить правильное значение во внешний ключ базы данных (например, Tree_TreeId). Но в нашем случае, когда мы имеем дело лишь с Leaf, в памяти нет никакой информации, на основе которой EF могла бы определить значение свойства ключа в Tree.

Если бы в классе Leaf было свойство внешнего ключа, жить было бы гораздо легче. Совсем не трудно хранить под рукой единственное значение при перемещениях между контроллерами и представлениями. По сути, если вы рассмотрите операцию Create на рис. 2, то заметите, что у этого метода есть доступ к значению TreeId, для которого создается Leaf.

Передавать данные внутри MVC-приложений можно самыми разными способами. Для этой демонстрации я выбрала простейший способ: сохранение TreeId в MVC ViewBag и использование полей Html.Hidden, где это необходимо. Благодаря этому значение TreeId доступно как один из элементов Request.Form представления.

Поскольку у меня есть доступ к TreeId, я могу построить граф Tree/Leaf, который будет предоставлять TreeId для команды Insert. Небольшие изменения в классе репозитария позволяют методу InsertOrUpdate принимать переменную TreeId из представления и извлекать экземпляр Tree из базы данных методом DbSet.Find. Вот измененная часть метода:

public void InsertOrUpdate(Leaf leaf,int treeId)
{
  if (leaf.LeafId == default(int)) {
    var tree=context.Trees.Find(treeId);
    tree.Leaves.Add(leaf);
  }
...

Контекст теперь отслеживает дерево и знает, что я добавляю лист к дереву. На этот раз при вызове context.SaveChanges инфраструктура EF сможет перейти от Leaf к Tree, чтобы получить значение ключа и использовать его в команде Insert.

На рис. 3 показан модифицированный код контроллера с использованием новой версии InsertOrUpdate.

Рис. 3. Новая версия InsertOrUpdate

[HttpPost]
public ActionResult Create(Leaf leaf)
{
  if (ModelState.IsValid)
  {
    var _treeId = Request.Form["TreeId"] as int;
    leafRepository.InsertOrUpdate(leaf, _treeId);
    leafRepository.Save();
    return RedirectToAction("Index", new { treeId = _treeId });
  }
  else
  {
    return View();
  }
}

Благодаря этим изменениями метод insert получает значение внешнего ключа, которое можно увидеть в параметре @2:

exec sp_executesql N'insert [dbo].[Leaves]([FellFromTreeDate],
[FellFromTreeColor], [Tree_TreeId])
values (@0, @1, @2)
select [LeafId]
from [dbo].[Leaves]
where @@ROWCOUNT > 0 and [LeafId] = scope_identity()',
N'@0 datetime2(7),@1 nvarchar(max) ,
@2 int',@0='2011-10-12 00:00:00',@1=N'Orange-Red',@2=1

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

Проблемы с обновлениями в отсутствие внешнего ключа

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

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

[Table("TreePhotos")]
public class TreePhoto
{
  public int Id { get; set; }
  public Byte[] Photo { get; set; }
  public string Caption { get; set; }
}

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

[Required]
public TreePhoto Photo { get; set; }

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

И вновь Code First определяет, что в базе данных потребуется свойство внешнего ключа и создает за меня таковое: Photo_Id. Заметьте, что оно не может содержать null-значения. Дело в том, что свойство Leaf.Photo обязательно (рис. 4).

Рис. 4. Использование соглашения Code First, класс Tree получает отличный от null внешний ключ для TreePhotos

Ваше приложение может позволять вам создавать деревья до создания фотоснимков, но в дереве все равно нужно заполнить свойство Photo. Я добавлю логику в метод InsertOrUpdate репозитария Tree для создания пустого Photo по умолчанию для новых деревьев, если фотоснимок в этот момент не предоставляется:

public void InsertOrUpdate(Tree tree)
{
  if (tree.TreeId == default(int)) {
    if (tree.Photo == null)
    {
      tree.Photo = new TreePhoto { Photo = new Byte[] { 0 },
                                   Caption = "No Photo Yet" };
    }
    context.Trees.Add(tree);
}
...

Более серьезная проблема, на которой я хочу теперь сосредоточиться, — влияние на обновления. Вообразите, что у вас есть Tree и необходимый для него Photo уже хранится в базе данных. Вы хотите иметь возможность редактировать Tree и взаимодействовать с Photo вам незачем. Вы извлекаете Tree, возможно, используя код вроде context.Tre¬¬es.Find(someId). Когда приходит пора для сохранения изменений, вы получаете ошибку проверки из-за того, что Tree требует Photo. Но у Tree есть фотоснимок! Он в базе данных! В чем дело?

Вот в чем суть проблемы: когда вы впервые выполняете запрос на получение таблицы, игнорируя связанный Photo, из базы данных возвращаются только скалярные значения Tree, а Photo равен null (рис. 5).

Рис. 5. Экземпляр Tree, извлеченный из базы данных без своего Photo

И MVC Model Binder, и EF имеют возможность проверять аннотацию Required. Когда дело доходит до сохранения отредактированного Tree, его Photo все равно будет равно null. Если вы позволяете MVC выполнять проверку ModelState.IsValid в коде контроллера, она обнаружит, что Photo отсутствует. IsValid будет равен false, и контроллер даже не подумает вызывать репозитарий. В моем приложении проверка Model Binder убрана, а ответственность за любые проверки на серверной стороне переложена на код репозитария. Когда репозитарий вызывает SaveChanges, проверка EF обнаружит отсутствие Photo, и это приведет к генерации исключения. Но в репозитарии есть возможность обработать это исключение.

Если в классе Tree есть свойство внешнего ключа, например int PhotoId, которое было обязательным (что позволяет избавиться от потребности в навигационном свойстве Photo), то значение внешнего ключа из базы данных использовалось бы для заполнения свойства PhotoId в экземпляре Tree. Дерево было бы допустимым, и SaveChanges смог бы отправить базе данных команду Update. Другими словами, при наличии свойства внешнего ключа Tree был бы корректен даже без экземпляра Photo.

Но без внешнего ключа вам опять потребуется какой-то механизм для предоставления Photo до сохранения изменений. Если ваши классы и контекст Code First настроены на выполнение отложенной загрузки, любое упоминание Photo в вашем коде заставит EF загрузить его экземпляр из базы данных. Я все еще придерживаюсь старомодного стиля, когда дело доходит до отложенной загрузки, поэтому скорее всего предпочла бы явную загрузку из базы данных. В новой строке кода (последней в следующем примере, где я вызываю Load) использует метод DbContext для загрузки связанных данных:

public void InsertOrUpdate(Tree tree)
{
  if (tree.TreeId == default(int)) {
  ...
  } else {
    context.Entry(tree).State = EntityState.Modified;
    context.Entry(tree).Reference(t => t.Photo).Load();
  }
}

Это полностью удовлетворяет EF. Tree успешно пройдет проверку, потому что Photo на месте, и EF отправит Update базе данных для модификации Tree. Главное здесь в том, чтобы Photo не был равен null; один из способов добиться этого я вам показала.

Точка сравнения

Если бы у класса Tree просто было свойство PhotoId, ничего из этого не понадобилось бы. Прямой эффект свойства PhotoId типа int заключается в том, что свойство Photo больше не нуждается в аннотации Required. Как у значимого типа у него всегда должно быть некое значение, удовлетворяющее требованию того, что у Tree должно быть Photo, даже если оно не представлено экземпляром. Пока в PhotoId есть некое значение, это требование соблюдается, поэтому следующий код работает:

public class Tree
{
  // ...прочие свойства
  public int PhotoId { get; set; }
  public TreePhoto Photo { get; set; }
}

Когда метод Edit контроллера извлекает Tree из базы данных, заполняется скалярное свойство PhotoId. Если вы требуете от MVC (или другой используемой вами инфраструктуры) передавать это значение, когда возникает необходимость в обновлении Tree, то EF останется в неведении о null-значении свойства Photo.

Проще, но никакой магии

Хотя группа разработки EF предусмотрела гораздо больше API-логики, помогающей в отключенных сценариях, все равно вы обязаны понимать, как работает EF и каковы ее ожидания, когда вы перемещаете данные. Да, кодирование значительно проще, если вы включаете в свои классы внешние ключи, но это ваши классы и вам лучше судить о том, что они должны представлять собой и чего в них быть не должно. Тем не менее, если бы я контролировала ваш код, то наверняка потребовала бы от вас убедить меня в том, что ваши причины для исключения свойств внешних ключей перевешивают преимущества их включения. При наличии внешних ключей EF будет выполнять часть работы за вас. Но в их отсутствие вы должны сами заботиться о том, чтобы ваши отключенные приложения вели себя именно так, как вы ожидаете.

Исходный код можно скачать по ссылке.


Джули Лерман (Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором очень популярной книги «Programming Entity Framework» (O’Reilly Media, 2010). Вы также можете читать ее заметки в twitter.com/julielerman.

Выражаю благодарность за рецензирование статьи экспертам Джеффу Дерштадту (Jeff Derstadt) и Рику Штралу (Rick Strahl).