数据点

Julie Lerman

Julie Lerman实体框架 (EF) 已经问世八年多了,并且经历了许多变化。这意味着,开发人员正着眼于使用 EF 早期版本的应用程序,以看看他们可以如何从 EF 的新设增强功能中受益。我正在与历经这一过程的一些软件团队进行合作,并与他们共享所学的教训和相关指南(位于由两部分组成的系列文章中)。上个月,我探讨了如何更新到 EF6,其中使用旧的 EF4 应用程序作为示例 (bit.ly/1jqu5yQ)。此外,对于将大型模型(EDMX 或 Code First)细分成小型实体数据模型,我提供了相关提示,因为我建议使用多个集中模型,而不是在整个应用程序中使用一个大型模型(通常,其中充满了复杂的不必要关系)。

本月,我将使用特定的示例分享有关细分小型模型的更多详细信息,然后使用该小型模型解决您从 ObjectContext API 切换到更新的 DbContext API 时遇到的某些问题。

在本文中,我使用现有的应用程序(比典型的演示应用程序多一些)展示您在重构自己的解决方案时可能遇到的实际问题类型。这是一个小型示例应用程序,出自我使用 EF4 和 ObjectContext API 编写的《Programming Entity Framework, 2nd Edition》(实体框架编程,第二版)(O’Reilly Media,2010 年)一书。它的 EDMX 模型通过使用 Database First 生成,然后在 EF 设计器中进行自定义。在此解决方案中,我已不再关注默认的 T4 代码生成模板(该模板创建从 EntityObject 类中继承的所有实体类)。相反,我使用一个创建了普通旧 CLR 对象 (POCO) 的 T4 模板,其中这些对象支持以 EF4 开头,同时我对该模板进行了一些自定义。我应该注意到这是一个客户端应用程序,因此不存在某些未连接的应用程序的挑战,其中状态跟踪更具挑战性。无论如何,您此时所看见的许多问题都适用于这两种方案。

在上个月的专栏中,为了使用 EF6 和 Microsoft .NET Framework 4.5,我升级了此解决方案,但是我并未对基本代码进行任何改动。其仍然使用原始的 T4 模板生成 POCO 类以及管理持久性的 ObjectContext 类。

应用程序的前台为其 UI 使用 Windows Presentation Foundation (WPF)。尽管对体系结构进行了分层,但是自那时起我就萌生了构建应用程序的想法。不过,当我看到更新后的代码时,我将克制自己不进行令我激动不已的全面重构。

此专栏的目标为:

  • 提取关注一项任务的小型模型。
  • 更改小型模型的代码生成,以输出更新样式的 POCO 类以及管理持久性的 DbContext 类。
  • 修复使用 ObjectContext 并因切换到 DbContext 而损坏的所有现有代码。在很多情况下,这表示创建重复的方法,然后将其修改为 DbContext 逻辑。这样,我不会破坏原始模型仍在使用的其余代码及其 ObjectContext。
  • 寻找机会使用 EF5 和 EF6 中引入的更简洁高效的功能替换逻辑。

创建旅行维护模型

在上个月的专栏中,我提供了从大型模型中识别和提取小型模型的提示。根据该指南,我检查了此应用程序的模型,虽然不是很大,但是包含对经营探险旅游业务所涉及的各种任务进行管理的实体。该模型含有用于维护以下内容的实体:按照目的地、活动、食宿、日期、其他详细信息进行定义的旅行,以及客户、客户的预订、付款和各种各样的联系信息。您可以在图 1 的左侧看到完整的模型。

The Full Model on the Left, the Constrained Model on the Right, Created Using the Entities Shown in Red in the Bigger Model
图 1 左侧是完整的模型,右侧是受限制的模型,使用较大模型中以红色显示的实体创建

应用程序的其中一个任务允许用户定义新的行程,并且使用 WPF 窗体维持现有的旅行,如图 2 中所示。

The Windows Presentation Foundation Form for Managing Trip Details
图 2 适用于管理旅行详细信息的 Windows Presentation Foundation 窗体

WPF 窗体处理的内容是旅行的定义: 选择目的地和旅馆、开始和结束日期以及各种活动。因此,我可以设想只限于满足此组任务的模型。我使用“实体数据模型”向导创建了一个新的模型(同样,如图 1 中所示),并且只选择了相关的表格。此外,该模型知道联接表负责事件和活动之间的多对多关系。

接下来,我将与我之前已在原始模型中定义的相同自定义(我的代码所依赖的自定义)应用于新模型中的实体。其中大部分为实体及其属性的名称更改;例如,“事件”变成了“旅行”,而“位置”变成了“目的地”。

虽然我正在使用 EDMX,但是您可以通过创建仅针对四个实体的新 DbContext 类来定义 Code First 的新模型。然而,在这种情况下,您要么需要定义不包含多余关系的新类型,要么使用 Fluent API 映射忽略特定的关系。我在 2013 年 1 月的“数据点”专栏“使用 DDD 界定的上下文收缩模型”(bit.ly/1isIoGE) 中,提供了有关 Code First 路径的相关指南。

消除保留有关 Code First 关系的挑战是不错的,但是在该环境中,我并不在意这一点。例如,预订以及与预订相关的所有事情(如付款和客户)现在都消失了。

此外,我还削减了食宿和活动,因为我只出于旅行维护的目的需要他们的身份和姓名。遗憾的是,有关映射到不可为空的数据库列的一些规则表示我必须保留 CustomerID 和 DestinationID。不过,我不再需要从目的地或食宿返回到旅行的导航属性,因此我将其从模型中删除了。

接着,我需要考虑如何处理代码生成的类。虽然我已有从其他模型生成的类,但是这些类关系到此模型中我不需要(或没有)的关系。因为数年来我一直关注着领域驱动设计 (DDD),因此我很开心拥有一组特定于此新模型的类以及将它们与其他生成的类分开使用。这些类位于单独的项目和不同的命名空间中。因为它们映射回常用数据库,因此我无需担心某个位置中的变化不在其他位置中显示。拥有只关注行程维护任务的类将使编码更简单,尽管这意味着我的整个解决方案中会出现一些重复。冗余是我愿意达成的折中,而且这是我在以前的项目中已重视的问题。图 3 的左侧显示模板 (BreakAway.tt) 和与大型模型相关联的生成类。它们位于其自己的项目 (BreakAwayEntities) 中。我将简要地阐释图 3 的右侧部分。

The Original Classes Generated from the T4 Template for the Large Model, Compared to the New Model and Its Generated Classes
图 3 大型模型的 T4 模板中生成的原始类,与新模型及其生成的类进行比较

根据上个月的专栏,我在创建新模型时,使用了原始的代码生成模板,这样我在此步骤期间唯一的挑战就是要确保我的应用程序在使用小型模型时能正常运行,而无需同时担心更改后的 EF API。我可以通过使用我的 BreakAwayEntities.tt 文件中的代码替换默认模板文件(TripMaintenance.Context.tt 和 TripMaintenance.tt)中的代码来实现。此外,我需要修改模板代码中的文件路径,以指向新的 EDMX 文件。

总而言之,我大约花费了一小时用于更改索引和命名空间,然后才可以使用新模型再次运行我的小应用程序和测试,不只是检索数据,还编辑和插入数据的新图形。

停用: 移至 DbContext API

现在,我拥有一个不太复杂的小型模型,并使在我的 WPF 代码中与该数据互动的任务更加简单。我已准备好应对更难的任务了: 通过使用 DbContext 替换我的 ObjectContext,这样就可以删除为了弥补直接使用 ObjectContext 的复杂程度而编写的代码。我非常欣喜的是,我只需要对新模型相关代码的较小表面区域进行推敲即可。这就像又一次折断了受伤的手臂,必须设置得当,即使应用程序的其余骨架仍保持完整且没有痛苦也须设置得当。

更新 EDMX 的代码生成模板 我将继续使用 EDMX,因此为了将旅行维护切换为 DbContext API,我必须选择一个新的代码生成模板。我可以轻松地访问我想要的那个模板: EF 6.x DbContext Generator,因为它和 Visual Studio 2013 安装在一起。首先,我将删除附加到 TripMaintenance.EDMX 文件的两个 TT 文件,然后我可以使用设计器的“添加代码生成项”工具选择新模板。(如果您对使用代码生成模板不熟悉,请参阅 bit.ly/1i7zU3Y 上的 MSDN 文档“EF 设计器代码生成模板”)。

请注意,我已对原始模板进行了自定义。我需要在新模板中重复进行的关键自定义是删除向导航属性中注入虚拟关键字的代码。这将确保不会触发延迟加载,因为我的代码依赖这一行为。如果您使用 T4 模板,并且对原始模型进行了某些自定义,则这一步骤非常重要,应牢记在心。(您可以访问 bit.ly/1jKg4jB 观看我为 MSDN 创建的展示编辑 EDMX T4 模板的旧视频)。

最后的细节是要注意我为一些实体和 ObjectContext 创建的部分类。我将相关的类复制到新模型项目中,并且确保它们与新生成的类绑定在一起。

修复 AddObject、AttachObject 和 DeleteObject 方法 现在,是时候查看损坏情况了。虽然许多损坏的代码特定于我针对 ObjectContext API 进行编码的方式,但是有一组对于大部分应用程序而言很常用的简单定位方法,因此我首先从这部分入手。

ObjectContext API 提供了多种添加、附加以及从 ObjectSet 删除对象的方法。例如,要添加某个项目(如名为“newTrip”的旅行实例),您可以直接使用 ObjectContext:

context.AddObject("Trips",newTrip)

或者您可以在 ObjectSet 中执行此操作:

_context.Trips.AddObject(newTrip)

ObjectContext 方法有点拙劣,因为其要求字符串识别实体所属的组。 ObjectSet 方法较为简单,因为已对组进行定义,但是其仍然使用繁琐的术语: AddObject、AttachObject 和 DeleteObject。 通过 DbContext,就只有一种方法,即凭借 DbSet。 这些方法名称简化为“Add”、“Attach”和“Remove”,以更好地模拟收集方法,例如:

_context.Trips.Add(newTrip);

请注意,DeleteObject 变成了 Remove。 这可能造成困扰,因为虽然“Remove”能够更好地与收集保持一致,但是它不会真正地描述该方法的含义(即,最终删除数据库中的记录)。 我看过开发人员错误地认为 Collection.Remove 和 DbSet.Remove 的结果相同,即认为是向 EF 指明从数据库中删除的目标实体。 但 Collection.Remove 不会这样做。 因此,使用该方法时一定要小心。

我处理的问题的其余部分特定于我在原始应用程序中使用 ObjectContext 的方式。 虽然它们并非您将遇到的必然相同的问题,但是查看这些特定的中断以及修复它们的方法将有助于您对在自己的解决方案中切换到 DbContext 时可能遇到的所有问题做好准备。

修复上下文自定义部分类中的代码 通过创建包含我的新模型的项目,开始进行修复。 解决该项目的问题后,我可以对付依赖该项目的项目。 我最初为了扩展 ObjectContext 而创建的部分类将首先失去作用。

部分类中的其中一个自定义方法是 ManagedEntities,这可以帮助我查看上下文跟踪的实体。 ManagedEntities 依赖于我创建的扩展方法: GetObjectStateEntries 的无参数重载。 我使用该重载造成了编译器错误:

public IEnumerable<T> ManagedEntities<T>() {
  var oses = ObjectStateManager.GetObjectStateEntries();
  return oses.Where(entry => entry.Entity is T)
             .Select(entry => (T)entry.Entity);
 }

与其修复基本的 GetObjectStateEntries 扩展方法,还不如直接废除它和 ManagedEntities 方法,因为我可以替换为使用 DbContext API 在 DbSet 类上所拥有的一个名为“Local”的方法。

我可以采用两种方法进行此次重构。 一个是找到使用新模型(调用 ManagedEntities)的代码的所有内容,并用 DbSet.Local 方法将其替换。 以下是使用 ManagedEntities 循环访问上下文跟踪的所有旅行的代码示例:

foreach (var trip in _context.ManagedEntities<Trip>())

我可以使用以下代码进行替换:

foreach (var trip in _context.Trips.Local.ToList())

请注意,由于 Local 返回 ObservableCollection,因此我添加了 ToList,以提取其中的实体。

另外,如果我的代码含有对 ManagedEntities 的许多调用,则我可以只更改 ManagedEntities 之后的逻辑,无需对使用它的所有位置都进行编辑。 因为该方法是泛型方法,所以无法像使用 Trips DbSet 那样简单地直接使用,但是更改仍然很简单:

public IEnumerable<T> ManagedEntities<T>() where T : class  {
  return Set<T>().Local.ToList();
}

最重要的是,我的“旅行管理”逻辑不再依赖于重载的 GetObjectStateEntries 扩展方法。 我可以不对该方法进行任何改动,以便于我最初的 ObjectContext 可以继续使用它。

从长远来看,在使用 DbContext API 时,我通过扩展方法执行的许多技巧变得无关紧要了。 它们是那类 DbContext API 添加简单的方法(像 Local 方法)以对其执行相关操作的常见所需模式。

我在部分类中发现的另一个损坏的方法是我用于将实体的跟踪状态设置为“已修改”的方法。 同样,我被迫使用一些不明显的 EF 代码执行该操作。 失败的代码行如下所示:

ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);

我可以使用更为直接的 DbContext.Entry().State 属性替换这个代码。 因为该代码位于扩展 DbContext 的部分类中,因此我可以直接访问“Entry”方法,而不是从 TripMaintenanceContext 的实例中访问。 新的代码行如下所示:

Entry(entity).State = EntityState.Modified;

我的部分类中的最后一个方法(也是损坏的)使用 ObjectSet.ApplyCurrentValues 方法修复跟踪实体的状态,该实体使用相同类型的其他(未跟踪的)实例的值。 ApplyCurrentValues 使用您传入的实例的标识值,在更改跟踪器重查找匹配的实体,然后使用传入到对象的值对其进行更新。

DbContext 中不存在等效于 ApplyCurrentValues 的内容。 虽然 Db­Context 允许您使用 Entry().CurrentValues().Set 执行类似的值替换,但是这要求您已获得访问跟踪实体的权限。 没有简单的方法可用于创建一个泛型方法来查找跟踪的实体,以替换该功能。 但并非毫无希望。 通过利用从 DbContext 访问 ObjectContext 逻辑的功能,可以继续使用特殊的 ApplyCurrentValues 方法。 请记住,DbContext 是 ObjectContext 的包装器,并且 API 提供深入了解针对特定情况的基础 ObjectContext 的方法,如 IObjectContextAdapter。 我向部分类中添加了一个简单的属性 Core,以易于重复使用:

public ObjectContext Core {
  get {
    return (this as IObjectContextAdapter).ObjectContext;
  }}

然后,我修改了部分类的相关方法,以继续使用 ApplyCurrentValues,同时从 Core 属性调用 CreateObjectSet。 这为我提供了一个 ObjectSet,以便我可以继续使用 ApplyCurrentValues:

public void UpdateEntity<T>(T modifiedEntity) where T : class {
  var set = Core.CreateObjectSet<T>();
  set.ApplyCurrentValues(modifiedEntity);
}

通过此次最终更改,我的模型项目可进行编译。 现在,是时候处理我的 UI 和模型之间的层中的损坏代码。

针对 AsNoTracking 查询和 Lambda 的 MergeOption.NoTracking 指定 EF 不应跟踪结果是避免不必要的处理和提高性能的重要方法。 在我的应用程序中有多个我在其中查询数据的位置,其中这些数据仅用作参考列表。 以下是一个示例,我在其中检索只读“旅行”的列表及其“目的地”信息(显示在我的 UI 上的 ListBox 中)。 使用旧的 API,您需要在查询上设置 MergeOption,然后再执行。 以下是我必须编写的难以理解的代码:

var query = _context.Trips.Include("Destination");
query.MergeOption = MergeOption.NoTracking;
_trips = query.OrderBy(t=>t.Destination.Name).ToList();

DbSet 可以通过更简单的方法实现,即使用它的 AsNoTracking 方法。 虽然我这样做,但是我可以去掉 Include 方法中的字符串,因为 DbContext 最终向其中添加了使用 Lambda 表达式的功能。 修订后的代码如下:

 

_trips= _context.Trips.AsNoTracking().Include(t=>t.Destination)
        .OrderBy(t => t.Destination.Name)
        .ToList();

DbSet.Local 再次派上用场 我的代码中有许多位置要求我查明:当我所拥有的是实体的标识值时,实体是否受到跟踪。 我编写了一个实现此功能的辅助方法(如图 4 中所示),而且您可以看到我希望压缩此代码的原因。 切勿费劲尝试对其解密;这没有什么价值。

图 4 多亏了新的 DbSet.Local 方法,我的 IsTracked 辅助方法变得无关紧要了

public static bool IsTracked<TEntity>(this ObjectContext context,
  Expression<Func<TEntity, object>> keyProperty, int keyId)
  where TEntity : class
{
  var keyPropertyName =
    ((keyProperty.Body as UnaryExpression)
    .Operand as MemberExpression).Member.Name;
  var set = context.CreateObjectSet<TEntity>();
  var entitySetName = set.EntitySet.EntityContainer.Name + 
    "." + set.EntitySet.Name;
  var key = new EntityKey(entitySetName, keyPropertyName, keyId);
  ObjectStateEntry ose;
  if (context.ObjectStateManager.TryGetObjectStateEntry(key, out ose))
  {
    return true;
  }
  return false;
}

以下是我的应用程序中名为“IsTracked”代码示例(此代码传递我要寻找的“旅行”的值):

_context.IsTracked<Trip>(t => t.TripID, tripId)

多亏了我早前使用的相同的 DbSet.Local 方法,因此可以使用以下代码进行替换:

_context.Trips.Local.Any(d => d.TripID == tripId))

然后,我可以删除 IsTracked 方法! 您是否在继续跟踪我到目前为止可以删除多少代码?

我为应用程序编写的另一个方法 (AddActivity) 不仅仅要验证某个实体是否已被跟踪,还有更多其他事项。 它需要获得该实体(另一个 Local 任务可以在这方面帮助我)。 AddActivity 方法(图 5)将一个活动添加到一个特定的旅行中,该旅行使用我为 ObjectContext API 编写的难以理解且不明显的代码。 这会涉及到“旅行”和“活动”之间的多对多的关系。 将某个活动实例附加到跟踪的旅行会促使 EF 开始跟踪该活动,因此我需要防止出现重复的上下文,并防止我的应用程序出现异常。 在我的方法中,我尝试检索该实体的 ObjectStateEntry。 TryGetObjectStateEntry 会立刻执行两个小技巧。 第一,如果找到该条目,它会返回布尔值;第二,返回找到的条目或 null。 如果该条目不为 null,我使用它的实体附加到旅行;否则,附加传入到我的 AddActivity 方法中的实体。 仅仅描述那一方面很费劲。

图 5 使用 ObjectContext API 的原始 AddActivity 方法

public void AddActivity(Activity activity)
{
  if (_context.GetEntityState(activity) == EntityState.Detached)
  {
    ObjectStateEntry existingOse;
    if (_context.ObjectStateManager
        .TryGetObjectStateEntry(activity, out existingOse))
    {
      activity = existingOse.Entity as Activity;
    }
    else     {
      _context.Activities.Attach(activity);
     }
  }
  _currentTrip.Activities.Add(activity);
}

我努力思考了很长一段时间,以便找到一个有效的方法来这一逻辑,同时以大约相同长度的代码结尾。 请记住,这是多对多的关系,并且确实需要谨慎处理。 不过,该代码更易于编写和阅读。 您根本不必纠结于 ObjectStateManager。 以下是我更新的方法的一部分,可以使用与我早前在“目的地”中所使用的类似的模式:

var trackedActivity=_context.Activities.Local
    .FirstOrDefault(a => a.ActivityID == activity.ActivityID);
if (trackedActivity != null) {
  activity = trackedActivity;
}
else {
  _context.Activities.Attach(activity);
}

任务完成

通过最后一个修复,我的所有测试均已通过,并且我可以成功地使用“旅行维护”窗体的所有功能。现在是时候寻找利用新 EF6 功能的机会了。

更重要的是,此部分应用程序上的任何进一步维护都可以被简化,这是因为使用 ObjectContext 难于执行的许多任务通过 DbContext API 执行时变得较为容易了。通过着眼于这个小型模型,我学到了许多可用于其他任何 ObjectContext 到 DbContext 转换的内容,并且为未来做好了更加充足的准备。

同样重要的是,明智地选择哪些代码应该更新以及哪些代码应该单独留下。通常,我将我知道未来需要维护的功能列为目标,而且我不想为了与难度更大的 API 进行互动而担心。如果具有从未再次触及而且随着 EF 的演变仍在发挥作用的代码,则在深入研究这一挑战之前,我一定会三思而后行。虽然是最好的安排,但是断臂仍然非常痛苦。 

Julie Lerman* 是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 .NET 主题的演示。她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。通过她的 Twitter(网址为 twitter.com/julielerman)关注她,并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。*

衷心感谢以下 Microsoft 技术专家对本文的审阅: Andrew Oakley