Entity Framework中的混合样式开发
Entity Framework 中有三种开发风格:Database-First(数据库优先)、Model-First(模型优先)和 Code-First(代码优先)。在本文中,我将展示一种使用 Entity Framework 的混合开发风格。
引言
Entity Framework 是微软的 ORM 框架。微软为其提供了三种开发风格:Database-First、Model-First 和 Code-First。在微软的示例项目中,它们看起来都很有前景。但是,在我最近的项目中,我无法完全遵循其中的任何一种。最终,我使用了一种混合风格来开发应用程序。
Entity Framework 中的开发风格
微软为企业应用开发提供了三种 Entity Framework 开发风格。
Database-First 开发(数据库优先)
Entity Framework 中的 Database-First 开发是先有数据库,然后从中生成实体数据模型(Entity Data Model)。在面向对象编程(Object-Oriented Programming)受到关注之前,大多数企业应用程序(我们过去称之为 MIS,即管理信息系统)都是从数据模型开始的。这样做的原因是因为数据库是应用程序的核心部分。大多数企业应用主要处理数据,几乎只包含 CRUD 操作。此外,大多数业务规则都存储在数据库中。关系型数据库,如 Sybase、DB2、Oracle 或 SQL Server,都建立在相同的关系数据库概念之上,并使用 SQL 作为查询语言。它们已经被使用了几十年,大多数开发人员都熟悉它们并知道如何使用。即使是现在,仍有大量的企业应用是围绕数据库构建的。因此,当面向对象编程流行起来,开发人员转向围绕领域模型构建应用程序的想法时,他们仍然希望从数据模型生成领域模型,以节省时间和精力,或平滑过渡。Database-First 开发有很多优点。它可以直接使用任何现有系统的数据模型。它让大多数有数据库经验的开发人员在过渡时感到舒适。即使开发人员能够以领域模型的方式思考,他们有时仍然希望先有数据模型,因为数据模型工具易于使用。
优点
- 对于有数据库经验的开发人员来说更容易。
- 对于升级那些已经有成熟数据模型的遗留系统来说更容易。
- 对于主要处理数据且仅有 CRUD 操作的系统来说更容易。
缺点
- 生成的领域模型通常不是很成熟。开发人员需要在实体数据模型设计器中对其进行微调。实体数据模型的局限性不允许开发人员做太多改进。
- 需要相应的分部类(partial class)来为领域模型添加领域规则方法或附加属性。
- 领域模型与 Entity Framework 绑定。因此,在不需要 Entity Framework 的其他地方使用领域模型并不容易。
- 一个硬核的面向对象开发者不会走这条路,因为他们希望以领域模型的方式思考。
Model-First 开发(模型优先)
当 Entity Framework 团队开始设计这个 ORM 工具时,他们真的想拿出一些对应用程序设计者和架构师来说更容易的东西。因此,他们创建了一个非常复杂的实体数据模型设计器,允许应用程序设计者和架构师在其中创建可视化的实体数据模型。然后他们可以使用这个实体数据模型来生成类和表。
优点
- 允许应用程序设计者和架构师首先思考和设计模型。
- 该模型与数据库无关,因此可用于在任何关系数据库中生成表。
- 这个非常复杂的实体数据模型设计器对应用程序设计者和架构师来说更简单。它节省了在 XML 文件中定义模型的时间。
- 对于没有太多 Entity Framework 经验的新手来说更容易。
缺点
- 生成领域类后,你仍然需要为相应的领域类创建一个分部类,以添加业务规则方法和属性。
- 领域模型与 Entity Framework 绑定。因此,在不需要 Entity Framework 的地方使用领域模型并不容易。
Code-First 开发(代码优先)
来自 Java 阵营或曾使用过 NHibernate 的开发人员已经习惯于将领域类设计为 POJO 或 POCO。这样,他们可以完全控制领域模型的外观及其行为,而无需在持久化机制的限制上做出太多牺牲。这就是为什么经验丰富的开发人员会使用 Code-First 开发风格。Entity Framework 中的 Code-First 开发可以产生一个非常干净的领域模型。这个领域模型可以很容易地在其他应用程序中重用,或与其他持久化机制结合使用。
优点
- 可以实现复杂的领域需求。
- 可以拥有一个用 POCO 表示的非常干净、优雅的领域模型。
- 不与 Entity Framework 绑定,因此领域模型可以轻松地用于其他应用程序或与其他持久化机制一起使用。
缺点
- 在创建领域类方面需要更多精力。
- 需要使用特性(attribute)或实体数据模型将领域模型映射到数据模型。
- 对于 Entity Framework 或面向对象编程的新手来说不是一个好的选择。只有经验丰富的面向对象开发人员才能真正很好地掌握它。
混合风格开发
Entity Framework 中的每种开发风格都有其优缺点。我个人更喜欢 Code-First 开发风格,因为从长远来看,一个像样的领域模型对于企业应用程序至关重要。但我不想手动输入每一个类,特别是当数据模型已经存在或者在需求分析阶段创建数据模型更容易时。因此,我实际做的是从 Entity Framework 的 Database-First 开发风格开始,然后将数据模型演化成纯 POCO 风格的领域模型,最终切换到 Code-First 开发风格。
Data Model
当系统已经有一个相当完整的数据模型,或者当数据模型更容易与业务人员沟通时,我通常会先从数据模型开始。只需使用 Visio 或任何数据模型工具,我就可以快速完成数据模型并在数据库中生成表。在这里,我有一个示例员工管理系统,其数据模型如下:
实体数据模型
在 Visual Studio 中创建一个类库项目。然后在其中添加一个名为 DataModel.edmx 的 ADO.NET 实体数据模型项。
按照向导的步骤,选择“从数据库生成”选项。
然后,为数据库创建一个新的连接字符串,并为实体连接设置命名。
接着,选择你想要包含在实体数据模型中的表并提供命名空间。我在这里通常会做的一件事是取消选中“在模型中包含外键列”选项。这样做的原因是为了避免将外键标识字段添加到实体数据模型中。实体数据模型使用导航属性来表示当前实体与另一个实体之间的关联。在模型中包含外键ID字段是为了某些特殊情况或性能原因而做出的权衡。我真的不喜欢它,因为它会污染我的模型,并让开发人员对应该使用哪一个感到困惑。
最后,点击完成按钮以生成实体数据模型。
修复实体数据模型
如果你将刚刚生成的实体数据模型与数据模型进行比较,你会想知道为什么它们如此不同。在数据模型中,我们有一个用于存储员工信息的 employee 表。员工在表中由 EmployeeID 标识,在现实生活中由 Name 标识。一个员工可能有一个经理,而经理也是一个员工。因此,Employee 表中有一个 ManagerID 列来引用自身。员工有一个 SalaryTypeID 字段,代表薪资类型。它引用 SalaryType 表以获取详细的薪资类型信息,例如按小时或按年薪。员工必须属于一个部门,一个部门可以包含多个员工。根据数据模型的解释,从数据模型自动生成的实体数据模型并不能真正反映我们的想法。在 Employee 实体中有两个不清晰的导航字段:Employee1 和 Employee2。
Employee1 的多重性(Multiplicity)是 “*(Many)”
Employee2 的多重性(Multiplicity)是 “0..1 (Zero or One)”
从属性详细信息中,我们可以看出 Employee1 代表一个员工集合,也意味着当前员工负责的所有员工。Employee2 代表当前员工的经理。因此,我们应该将导航字段的名称更改为 Subordinators 和 Manager 以反映其真实含义。
SalaryType 实体的情况有所不同。在 SalaryType 实体上有一个 employees 导航字段。它代表所有具有相同薪资类型的员工。然而,如果你有一些企业应用的经验,你会知道薪资类型作为一个查找实体通常只需要从业务实体中引用。直接从查找实体获取所有业务实体并不那么重要。即使查找实体中没有集合属性,你也可以通过 LINQ 查询来完成。从 SalaryType 实体中移除 employees 导航属性将简化你的实体数据模型,并使模型更专注于重要的业务问题。SalaryType 实体中的另一个问题是 SalaryType1
属性。SalaryType 表中的 SalaryType 列用于表示薪资的类型。然而,由于 .NET 类的“限制”,属性名不能与类名相同,实体数据模型生成向导将不合法的名称更改为在末尾加上数字1。从开发者的角度来看,salarytype1
属性看起来很丑。我们应该将其更改为“Type”以使其更有意义。以下是修改后的 SalaryType 实体
Department 实体是唯一不需要修复的实体。Department 有一个 Employees
集合,代表当前部门中的所有员工。Employee 实体也有一个 Department
属性来反向引用该部门。这是一种父子关系。
POCO 风格的领域模型
如果你在这里停下,你将会得到一个相当不错的 Database-First 应用程序。你的领域模型可以在构建过程中从实体数据模型自动生成。但我们的目标是拥有一个由 POCO 风格的类组成且不依赖于 Entity Framework 的领域模型。这种领域模型可以很容易地在不同的应用程序中与其他持久化技术(如 ADO.NET 或 NHibernate)一起重用。你还可以有不同的存储库系统,比如文件。那么,我们如何获得 POCO 风格的领域模型呢?我们要做的第一件事是清除“自定义工具”(Custom Tool)。

“自定义工具”的设置默认为 EntityModelCodeGenerator。此设置用于告诉编译器从实体数据模型生成领域类。通过清除它,我们将不再用它来生成领域类。原因是我们想使用另一个代码生成器模板,即 ASP.NET POCO 实体生成器。这个模板用于为我们创建 POCO 风格的领域实体类。一个 POCO 风格的领域实体只包含领域实体属性和方法。其中没有与 Entity Framework 相关的代码。你可以在 DataModel.edmx 上右键单击鼠标以弹出上下文菜单,然后选择“添加代码生成项…”菜单以打开“添加新项”窗口。选择 ADO.NET POCO 实体生成器,并将生成器模板命名为 DataModel.tt。
注意:如果你直接在项目中添加此模板,而不是从 DataModel.edmx 上下文菜单创建它,那么在尝试生成领域类时会收到错误。这是因为从 DataModel.edmx 添加的模板已经设置了相关的 DataModel 文件位置。
在你的项目中有了 POCO 实体生成器模板后,你会看到一系列自动生成的领域实体类。在更改 DataModel.tt 后,这些领域实体类将被重新生成。
一个 Department
领域实体类看起来是这样的
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated from a template.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace Demo.Data
{
public partial class Department
{
#region Primitive Properties
public virtual int DepartmentID
{
get;
set;
}
public virtual string Name
{
get;
set;
}
#endregion
#region Navigation Properties
public virtual ICollection<Employee> Employees
{
get
{
if (_employees == null)
{
var newCollection = new FixupCollection<Employee>();
newCollection.CollectionChanged += FixupEmployees;
_employees = newCollection;
}
return _employees;
}
set
{
if (!ReferenceEquals(_employees, value))
{
var previousValue = _employees as FixupCollection<Employee>;
if (previousValue != null)
{
previousValue.CollectionChanged -= FixupEmployees;
}
_employees = value;
var newValue = value as FixupCollection<Employee>;
if (newValue != null)
{
newValue.CollectionChanged += FixupEmployees;
}
}
}
}
private ICollection<Employee> _employees;
#endregion
#region Association Fixup
private void FixupEmployees(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (Employee item in e.NewItems)
{
item.Department = this;
}
}
if (e.OldItems != null)
{
foreach (Employee item in e.OldItems)
{
if (ReferenceEquals(item.Department, this))
{
item.Department = null;
}
}
}
}
#endregion
}
}
在上面的代码中你会注意到几件事
- 在类文件的顶部有一个注释块,提醒开发者不要在此文件中添加自己的代码,因为生成器可能会覆盖所有更改。
- Department 类的命名空间是
Demo.Data
。这是我的仓储实现层的名称。 - Department 类是一个分部类(partial class)。因此,我可以在 Demo.Data 项目中添加另一个 Department 分部类,以拥有自己的属性或方法,而不会被代码生成器覆盖。
- 有一个关联修复(Association Fixup)部分,其中包含用于修正 Department 和 Employee 之间父子关系的代码。
我喜欢用 ASP.NET POCO 实体生成器生成的所有领域实体类,但我不喜欢将领域实体类与仓储层绑定的想法。因此我做了一个大的改动。我修改了生成器模板,以生成我想要的类代码。然后我将它们全部复制到 Demo.Models 项目中。
经过修改的生成器模板后,Department
类看起来是这样的
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace Demo.Models.Domain
{
public class Department
{
#region Primitive Properties
public virtual int DepartmentID
{
get;
set;
}
public virtual string Name
{
get;
set;
}
#endregion
#region Navigation Properties
public virtual ICollection<Employee> Employees
{
get
{
if (_employees == null)
{
var newCollection = new FixupCollection<Employee>();
newCollection.CollectionChanged += FixupEmployees;
_employees = newCollection;
}
return _employees;
}
set
{
if (!ReferenceEquals(_employees, value))
{
var previousValue = _employees as FixupCollection<Employee>;
if (previousValue != null)
{
previousValue.CollectionChanged -= FixupEmployees;
}
_employees = value;
var newValue = value as FixupCollection<Employee>;
if (newValue != null)
{
newValue.CollectionChanged += FixupEmployees;
}
}
}
}
private ICollection<Employee> _employees;
#endregion
#region Association Fixup
private void FixupEmployees(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (Employee item in e.NewItems)
{
item.Department = this;
}
}
if (e.OldItems != null)
{
foreach (Employee item in e.OldItems)
{
if (ReferenceEquals(item.Department, this))
{
item.Department = null;
}
}
}
}
#endregion
}
}
不同之处在于
- department.cs 文件顶部的注释块被移除了。
- 类的命名空间被更改为
Demo.Models.Domain
。 - 这个类不再是分部类。
这样做的原因是为了使领域实体类独立于 Entity Framework 和仓储实现。在此之后,分部类就不那么重要了。
将实体类移动到 Demo.Models 项目后,你可以从 Demo.Data 项目中删除 DataModel.tt 和 DataModel.Context.tt 生成器模板。DataModel.Context.tt 用于生成 Entity Framework 上下文代码,但因为我将在这里使用仓储模式并有自己的方式来管理上下文,所以我也不需要自动生成的上下文代码。
验证领域实体类
验证领域实体类的最简单方法是使用单元测试在不同场景中使用它们。这里的一个例子是插入一个员工层级结构。我想插入一个薪资类型为“按小时”的员工 Henry,然后添加另一个员工 Andy,他的薪资类型也是“按小时”并为 Henry 工作。
[TestMethod]
public void InsertEmployeeHierarchy_Employee_Success()
{
// Arrange
// Act
Department department =
Demo.Models.Registry.RepositoryFactory.GetDepartmentRepository().Find(
d => d.Name == "Sales");
SalaryType salaryType =
Demo.Models.Registry.RepositoryFactory.GetSalaryTypeRepository().Find(
s => s.Type == "By Hour");
IEmployeeRepository employeeRepository =
Demo.Models.Registry.RepositoryFactory.GetEmployeeRepository();
Employee employee = new Employee();
employee.Name = "Henry";
employee.Department = department;
employee.SalaryType = salaryType;
employeeRepository.Create(employee);
Employee subordinator = new Employee();
subordinator.Name = "Andy";
subordinator.Department = department;
subordinator.SalaryType = salaryType;
subordinator.Manager = employee;
employeeRepository.Create(employee);
IContext context = Demo.Models.Registry.Context;
context.SaveChanges();
context.Clear();
// Assert
Department assertDepartment =
Demo.Models.Registry.RepositoryFactory.GetDepartmentRepository().Find(
d => d.Name == "Sales");
SalaryType assertSalaryType =
Demo.Models.Registry.RepositoryFactory.GetSalaryTypeRepository().Find(
s => s.Type == "By Hour");
IEmployeeRepository assertEmployeeRepository =
Demo.Models.Registry.RepositoryFactory.GetEmployeeRepository();
Employee assertEmployee1 =
assertEmployeeRepository.Find(e => e.Name == "Henry");
Assert.IsNotNull(assertEmployee1);
Assert.IsTrue(assertEmployee1.Department ==
assertDepartment && assertEmployee1.SalaryType == assertSalaryType);
Employee assertEmployee2 = assertEmployeeRepository.Find(e => e.Name == "Andy");
Assert.IsNotNull(assertEmployee2);
Assert.IsTrue(assertEmployee2.Department ==
assertDepartment && assertEmployee2.SalaryType == assertSalaryType);
Assert.IsTrue(assertEmployee2.Manager == assertEmployee1);
// Away
IEmployeeRepository awayEmployeeRepository =
Demo.Models.Registry.RepositoryFactory.GetEmployeeRepository();
Employee awayEmployee1 = awayEmployeeRepository.Find(d => d.Name == "Andy");
awayEmployeeRepository.Delete(awayEmployee1);
Employee awayEmployee2 = awayEmployeeRepository.Find(d => d.Name == "Henry");
awayEmployeeRepository.Delete(awayEmployee2);
IContext awayContext = Demo.Models.Registry.Context;
awayContext.SaveChanges();
awayContext.Clear();
}
编译并运行它。宾果,成功了!领域模型仍然可以使用 Entity Framework 将数据持久化到数据库并从中检索数据。然而,这完全不是必需的。如果我们愿意,可以用 NHibernate 实现一个仓储。
后续开发
在使用 Entity Framework 的 Database-First 开发风格将数据模型演化为 POCO 领域模型之后,你应该切换到 Entity Framework 的 Code-First 开发风格来继续改进领域模型。从那时起,领域模型、数据模型和实体数据模型都彼此独立。所有的变更都需要在所有地方手动进行。如果你觉得 Entity Framework 代码生成器工具和反向工程工具不能再使用了,这很糟糕,但无论如何,你的领域模型的复杂性已经超出了那些工具的限制,除非你想要一个与 Entity Framework 绑定的丑陋、不正确、不可扩展的领域模型。
摘要
尽管微软提供了三种 Entity Framework 开发风格供我们选择,但我们应该真正根据应用程序或项目的情况来明智地选择它们。通过混合不同的开发风格,你可以用更少的精力和更好的结果来处理一个复杂的项目。
Using the Code
该代码是在 Visual Studio 2010 中开发的。为了运行它,你需要在 SQL Server 中创建 EntityFrameworkMixedStyleDevelopment 数据库,并运行 CreateTables.sql 来生成所有表。此外,你需要更新解决方案中所有的连接字符串,使其连接到你的本地数据库。
<add name="EntityFrameworkMixedStyleDevelopmentEntities"
connectionString="metadata=res://*/DataModel.csdl|
res://*/DataModel.ssdl|res://*/DataModel.msl;
provider=System.Data.SqlClient;
provider connection string="Data Source=LAPTOP;Initial
Catalog=EntityFrameworkMixedStyleDevelopment;
Integrated Security=True;MultipleActiveResultSets=True""
providerName="System.Data.EntityClient" />