迄今为止,Microsoft 已面向开发人员发布了两种旨在减少关系数据领域和面向对象的编程之间的阻抗失谐的产品:LINQ to SQL 和 ADO.NET 实体框架。借助其中任何一种产品,您不必编写大部分探测代码,即可实现对象持久性。但是,将这些对象关系映射 (ORM) 技术应用到面向服务的应用程序体系结构为应用程序开发人员带来了全新的挑战。例如,如何创建将对象持久性与应用程序其他部分分离的数据访问层 (DAL),以便在需要时使用一个 ORM 提供程序换出另一个提供程序。如何在客户端没有安装 LINQ to SQL 或实体框架的情况下跟踪对实体的更改?如何通过仅调用服务一次即可在单个事务中插入、更新和删除多个实体?在本文中,我将进行简要介绍并提供一些有关如何解决上述问题的建议。首先,我将基于 Northwind 示例数据库创建一个用于处理订单的 DAL。DAL 的两种实现(一种使用 LINQ to SQL,一种使用实体框架)依赖于一个接口(请参见图 1)。LINQ to SQL 和实体框架都具有基于数据库架构生成实体的工具,但是 DAL 并不使用这些实体,而是仅公开涉及到自身持久性时通常忽略的数据传输对象 (DTO)。
图 1 适用于订单处理应用程序的面向服务的体系结构(单击图像可查看大图)
LINQ to SQL 和实体框架使用不同的方法持久化断开连接的实体,具体取决于要创建新实体还是更新现有的实体。因此,为了调用合适的 API,您必须事先了解实体的状态。例如,如果您具有 UpdateOrder 方法接受带有订单明细的订单,可以为 UpdateOrder 方法添加三个参数,以接受创建、更新和删除的订单明细。但这很容易因探测订单明细而使方法签名变得混乱。
此外,您可以带外传递更改状态信息(例如在 SOAP 或 HTTP 标头中传递),但这样做会使更改跟踪与通信协议相结合。下面我将介绍另一种方法 — 将更改状态作为每个实体的数据约定的一部分,我建议使用这种方法:
[DataContract]
public enum TrackingInfo
{
[EnumMember]
Unchanged,
[EnumMember]
Created,
[EnumMember]
Updated,
[EnumMember]
Deleted
}
除了其他属性之外,每个 DTO 都有一个使用 TrackingInfo 枚举的 TrackingState 属性。双方只需同意此数据约定即可。
虽然我为每个 DTO 添加了 TrackingState 属性,但我还必须依赖没有安装 LINQ to SQL 或实体框架的客户端来跟踪创建、更新或删除的对象,以及将 TrackingState 属性设置为相应的值(请参见图 1)。我创建了一个通用更改跟踪集合来执行此任务。此集合有两种类型:一种扩展 ObservableCollection<T> 以用于 Windows Presentation Foundation (WPF) 应用程序;一种扩展 BindingList<T> 以用于 Windows 窗体应用程序。每种集合都可在从集合中删除项目之前缓存删除的项目,并且每种集合都具有 GetChanges 方法,该方法只返回插入、更新或删除的项目。
创建数据访问层
DAL 有助于将应用程序的其他部分与对象持久性的详细信息隔离开。为此,通过 DAL 公开的对象必须排除任何特定数据访问技术的其余部分。LINQ to SQL 和实体框架允许您使用命令行工具和 Visual Studio 设计器创建实体,但是这些代码生成的实体中的一些项目与它们的源相悖。
例如,为了支持关联对象的延迟加载,LINQ to SQL 使用 EntityRef<T> 和 EntitySet<T> 集合类型表示数据关系,而实体框架使用 EntityReference<T> 和 EntityCollection<T> 表示导航属性。此外,这些实体添加了旨在支持客户端服务器方案(例如使用局部方法的验证和使用 INotifyPropertyChanged 的数据绑定)的代码元素,如果这些实体只用于与 Windows Communication Foundation (WCF) 服务进行通信,则无需使用这些代码元素。
由于 LINQ to SQL 和实体框架支持使用 DataContractSerializer 对实体进行序列化(使用 DataContract 和 DataMember 属性进行标记),因此您可能希望将它们用于您的服务操作中,而无论这样做会带来多大的负担。毕竟,当您设置 WCF 服务引用时,集合类型(如 LINQ to SQL 和实体框架使用的集合类型)在客户端上显示为简单数组。
但是,如果您将 LINQ to SQL DataContext 的 SerializationMode 从“None”设置为“Unidirectional”,则只有一对多关系中的“多”方标有 DataMember 属性。这表示如果您的 Order 实体有 Customer 和 Order_Details 两个属性,只有 Order_Details 标有 DataMember 并包含在数据约定中,而 Customer 则不能如此。另一方面,实体框架包含的字段可能比您希望包含在数据约定中的要多,从而在客户端上生成用以实现特定于实体框架的功能的类,例如,EntityKey、EntityKeyMember、EntityObject 或 EntityReference。
鉴于上述原因,您应该避免通过 DAL 公开 LINQ to SQL 或实体框架工具生成的实体,改为返回简单的 DTO,它们存在的目的只是跨服务边界传送数据。例如,Order DTO(请参见图 2)只包含属性,不包括方法。所有的 DTO 均在 DataTransferObjects 命名空间中定义,以与工具生成的同名类进行区分。
图 2 表示 Northwind 中的订单的 DTO
灵活的 DAL 应该可以使 Order Service 不受基础持久性技术更改的影响。例如,假设您选择使用 SQL Server 来存储数据并且您的对象模型和数据库架构极其类似,因此,您决定使用 LINQ to SQL 持久化对象。但是,当您编写 LINQ to SQL 持久性逻辑后不久,您决定使用其他数据库系统(如 Oracle)或希望使用实体框架的映射功能来分离概念架构和逻辑架构。或许一种新的数据访问技术已推出,您希望使用该技术。如果您设计了基于插件体系结构的灵活 DAL,则应该可以根据 app.config 中的条目随时切换提供程序。
为实现此灵活性,我创建了 IDataProvider 接口,该接口具有用以检索和更新客户订单,以及检索支持的客户和产品信息的方法(请参见图 3)。本示例应用程序包括两个实现 IDataProvider 的类:使用 LINQ to SQL 的 SqlDataProvider;使用实体框架和 LINQ to Entities 的 EntityDataProvider(请参见图 1)。您仅可以为 DAL 的 app.config 文件中的“DataProvider”设置输入完全限定的类名称,并使用 Assembly 类的 CreateInstance 方法创建此类的实例,从而将其转换为 IDataProvider。Order Service 对实现 IDataProvider 而使用的类没有特别要求,因此您可以选择任何数据提供程序:
IDataProvider provider =
Assembly.GetExecutingAssembly()
.CreateInstance(Settings.Default.DataProvider, true)
as IDataProvider;
图 3 将 DAL 与接口分离
默认情况下,LINQ to SQL 和实体框架会在与项目相同的命名空间中生成实体。但是,如果将新的“LINQ to SQL Classes”项目置于 L2S 项目文件夹中,此工具会使用此文件夹名作为嵌套的命名空间,这有助于区分 LINQ to SQL 实体和 DTO 实体。同样,如果将新的“ADO.NET Entity Data Model”置于 L2E 项目文件夹中,实体框架实体也会存在于嵌套的 L2E 命名空间。另外,您可以在 LINQ to SQL 的属性窗口和实体框架可视组件设计器中指定命名空间。
此处,我选择为 LINQ to SQL 和实体框架两者生成实体,并将查询结果转换为 DTO。此时还存在以下两种可能性:完全消除工具生成的实体;使用 XML 文件映射 DTO。Entity Framework 已使用了 XML 映射文件,但是在实体框架的第一个版本中,使用简单的 C# 对象 (POCO) 根本无法实现完全的持久化透明 (PI)。在版本 1 中,您可以实现此目标,但实体必须首先实现两个接口:IEntityWithRelationships 和 IEntityWithChangeTracking,因此这种情况需要使用 IPOCO(POCO + 接口)。.希望实体框架的将来版本能够改进对 POCO 和持久化透明的支持。
LINQ to SQL 确实能够很好地支持使用 XML 映射文件将 POCO 映射到数据库架构。但是,从 L2S.Order 或 L2E.Order 投影到 DTO.Order 提供了更大程度的灵活性。例如,图 4 显示的 SqlDataProvider 的 GetOrder 方法,此方法根据 L2S.Order Customer 属性的 ContactName 属性填充 DTO.Order 的 CustomerName 属性,实际上恰恰反映了 Customer 和 Order 之间的关系。同理,我也可以根据 L2S.Order_Detail 的 Product 属性设置 DTO.OrderDetail 的 ProductName 属性。在每一种情况下,我都必须将 DataContext 的 LoadOptions 属性配置为预先加载 LINQ to SQL 实体。
图 4 GetOrder 方法使用 LINQ to SQL 框架返回 DTO.Order
现在让我们回到 EntityDataProvider 的 GetOrder 方法,该方法使用 LINQ to Entities 基于 L2E.Order 返回 DTO.Order(请参见图 5)。注意,Include 运算符用于预先加载 Order_Details 和 Order_Details.Product 属性。(有关 LINQ 运算符的详细信息,请参见数据点专栏“LINQ 的标准查询运算符”)。实体框架和 LINQ to SQL 都没有提供现成的延迟加载功能,因此您必须明确指出希望在查询结果中包含哪些相关实体。检索到指定的订单之后,您可以直接使用它来创建新的 DTO.Order。
图 5 GetOrder 方法使用 LINQ to Entities 返回 DTO.Order
持久化各个对象
LINQ to SQL 和实体框架采用不同的方法持久化断开连接的实体。例如,假设您希望创建具有一个或多个订单明细的新订单。如图 6 所示,LINQ to SQL 至少需要两行代码:一行代码调用 InsertOnSubmit 以插入订单,一行代码使用 InsertAllOnSubmit 插入订单的明细项目。而实体框架只需要您将订单添加到 ObjectContext 的 OrderSet,它会自动添加完整的对象图表(请参见图 7)。LINQ to SQL 和实体框架都采用以下操作过程:首先插入父订单、获取 OrderID 值(数据库中的标识列),然后插入子订单明细。
图 6 使用 LINQ to SQL 创建订单
图 7 使用 LINQ to Entities 创建订单
在介绍删除或更新实体之前,我需要首先介绍一下如何处理并发问题。LINQ to SQL 要求您附加一个断开连接的实体才能调用 DeleteOnSubmit。这样做系统会提示您使用 LINQ to SQL 执行最优的并发检查,如果用户尝试删除其他用户已经更改的项目,则删除将会失败,并发出 ChangeConflictException。实际上确实没有简单方法可以关闭此行为,因为如果您尝试在不调用 Attach 的情况下调用 DeleteOnSubmit,LINQ to SQL 将会引发 InvalidOperationException。但是,实体框架并不强制您在删除实体时处理更改冲突(请参阅本文附带的代码下载)。
图 8 显示了使用 LINQ to SQL 更新订单的代码。请注意,Attach 方法接受 bool 类型的 asModified 参数。对 LINQ to SQL 来说,将此参数设置为“True”表示您希望将时间戳列用于并发管理。这样做会使 LINQ to SQL 将当前的时间戳值包含到 SQL Update 语句的 WHERE 子句中。当其他用户更新此记录时,此更新不会影响任何行,并且 LINQ to SQL 将引发 ChangeConflictException。
图 8 使用 LINQ to SQL 更新订单
使用此策略,客户端既不需要保留原始值也不需要将其随更新一起提交,而是将这些值传递到 Attach 方法的其他重载。系统对此列的命名方式没有要求,只需包含时间戳数据类型即可。当您将具有时间戳列的表添加到 Visual Studio 的 LINQ to SQL 设计器之后,所有实体字段的 UpdateCheck 属性将设置为 Never,并且 LINQ to SQL 将使用时间戳来检查更改冲突。
当前,相对于 LINQ to SQL 来说,使用实体框架更新实体和管理并发稍微有些复杂,因为即使在并发管理中使用时间戳列,它也需要实体的原始值(请参见图 9)。(这一方面可能会在实体框架的下一版本中获得改善。)由于客户端未提供原始订单,您需要从数据库中检索此订单并将其 Updated 属性设置为客户端提供的值,此“原始”值才是您真正需要的值。请确保提交对 Updated 的更改,方法为:分离并重新将订单附加到对象上下文,或者调用 AcceptAllChanges。然后,调用 ApplyPropertyChanges 使用更新订单所提供的值更新原始订单。此外,还需要执行一个步骤,实体框架才能使用时间戳列进行并发检查:即,在 Order 实体上将 Updated 属性的 ConcurrencyMode 值从“None”设置为“Fixed”(请参见图 10)。
图 9 使用 LINQ to Entities 更新订单
图 10 将 Updated 的 ConcurrencyMode 设置为“Fixed”
跨服务边界跟踪更改
如果您希望仅调用服务一次即可在同一事务中持久化多个实体的更改,您需要使用一种方法来确定每个实体的更改状态。这就是示例应用程序中的每个 DTO 都包含 TrackingState 属性的原因。如果客户端已经设置了此属性,DAL 即可以使用合适的 LINQ to SQL 或实体框架 API 持久化对断开连接的实体的插入、更新和删除。这样做允许将 DTO 数组传递到服务操作进行批量更新,以便使用简单的 LINQ to Objects 查询找到插入、更新或删除的订单明细。例如,要想只获得添加到订单的订单明细,您可以使用 TrackingState Created 在集合中查询此类项目:
List<Order_Detail> insertedDetails =
(from od in order.OrderDetails
where od.TrackingState == TrackingInfo.Created
select new Order_Detail
{
OrderID = od.OrderID,
ProductID = od.ProductID,
Quantity = od.Quantity,
UnitPrice = od.UnitPrice,
Updated = od.Updated == null ? new byte[0] : od.Updated
}).ToList();
您必须将 Updated 的 null 值转换为空字节数组,因为数据库表中的时间戳列通常不能为空。获得插入的订单明细的列表后,您可以使用合适的 LINQ to SQL API 通过传递订单明细集合来添加新实体:
db.Order_Details.InsertAllOnSubmit(insertedDetails);
和您预想的一样,有 AttachAllOnSubmit 和 DeleteAllOnSubmit 方法可满足您的需要。当对 DataContext 调用 SubmitChanges 时,LINQ to SQL 会确保按正确的顺序提交这些更改(对插入项目使用父子顺序,对删除项目使用子父顺序),并且将所有的操作包含在一个事务中。您可以借助相同的方法使用 LINQ to Entities 持久化订单明细。有关适用于 LINQ to SQL 和实体框架的 UpdateOrder 的完整代码列表,请参阅下载内容。
现在,很明显就可以看出 DAL 依靠客户端跟踪每个 DTO 的更改状态。客户端上跟踪对象更改的最方便机制是泛型集合。为了使集合能够更新 TrackingState 属性,应该约束类型参数以实现接口。(您还可以使用 Reflection 获得和设置 TrackingState 属性,但泛型类型约束可提供类型安全性和更好的性能。)输入只包含 TrackingState 属性的 ITrackable 接口:
public interface ITrackable
{
TrackingInfo TrackingState { get; set; }
}
正如我前面提及到的,跟踪更改集合有两种类型:一种用于 Windows 窗体,扩展 BindingList<T>;一种用于 WPF 应用程序,扩展 ObservableCollection<T>。我并没有在这两个集合中重复更改跟踪逻辑,而是将此逻辑集中到 ChangeTrackingHelper<T> 类中,此类从 Collection<T> 派生并约束 T 以实现 ITrackable(请参见图 11)。此外,它还约束 T 以实现 INotifyPropertyChanged,以便处理每个项目的 PropertyChanged 事件以及将 TrackingState 属性设置为 Updated。
图 11 更改跟踪帮助程序类
将项目添加到集合后,其 TrackingState 将设置为 Created。项目删除后,其 TrackingState 将设置为 Deleted,并缓存在已删除项目的集合中。在进行跟踪之前,用户应该首先将集合的 Tracking 属性设置为 True,以便集合订阅每个项目的 PropertyChanged 事件。GetChanges 方法会创建一个新的 ChangeTrackingHelper 集合,其中仅包含标记为创建、更新或删除的项目。
配置客户端
我们已经了解了一组通用的跟踪更改集合,现在可以派上用场了!我已将这些集合放置在一个单独类库中,因此您只需从客户端应用程序中引用即可。但是,当您向客户端项目添加服务引用时,您必须执行一些其他步骤,集合才能使用 svcutil.exe 生成的类。虽然 Customer、Product、Order 和 OrderDetails 等类已经具有了 TrackingState 属性,但它们不会实现 ChangeTrackingHelper<T> 所需的 ITrackable 接口。
另外,必须将 TrackingInfo 枚举放置在跟踪更改集合可以找到的命名空间,但不是嵌套命名空间,因为 svcutil.exe 会在您添加服务引用时将其置于此处。最后,如果服务操作返回的对象数组在客户端应用程序中显示为跟踪更改集合,那就更好了。因为这样会使 Order.OrderDetails 的数据类型设置为 ChangeTrackingCollection<T> 或 ChangeTrackingList<T>(而非 OrderDetail 数组),从而减少向服务发送已更改的订单明细的工作量。
要设置 Windows 窗体或 WPF 客户端应用程序,使其使用跟踪更改集合跟踪实体更改并将这些更改发送至服务,需要遵循以下步骤。务必按照给定顺序执行以下步骤:
在 Windows 窗体或 WPF 客户端应用程序中,设置对 ClientChangeTracker 类库项目或程序集(ITrackable 接口和跟踪更改集合放置位置)的引用。
在 WCF 服务应用程序运行的过程中,添加指向元数据终点或 Web 服务描述语言 (WSDL) URL 的服务引用。在添加服务引用之前,引用 ClientChangeTracker 程序集非常重要。默认情况下,服务引用将重用所有引用程序集中的类型,并且由于 ClientChangeTracker 包含的 TrackingInfo 枚举具有 DataContract 和 EnumMember 序列化属性,svcutil.exe 会重用此枚举而不会将其复制到服务引用创建的 Reference.cs 代码文件中。
设置服务引用之后,为服务引用创建的每个 DTO 类添加局部类。例如,如果您的服务操作公开了 Customer、Product、Order 和 OrderDetail 对象,它们都将包含在服务引用中。如果显示项目的所有文件并展开服务引用和 Reference.svcmap 节点,您会在 Reference.cs 中看到这些类。记下它们所属的命名空间(应该与您添加服务引用时指定的位置一致)。现在将类文件添加到项目中并更改命名空间,以在 Reference.cs 中反映此命名空间。在此命名空间中,为每个 DTO 类添加公共局部类并实现 ITrackable 接口:
namespace WpfClient.OrderService
{
public partial class Customer : ITrackable { }
public partial class Product : ITrackable { }
public partial class Order : ITrackable { }
public partial class OrderDetail : ITrackable { }
}
注意 TrackingState 属性缺失的情况。由于此属性作为每个 DTO 数据约定的一部分包含在代码生成的类中,因此您无需在此处插入该属性。为了在应用程序中更加轻松地使用这些类,您还可以在这些类中插入其他代码,例如,初始化类的构造函数(使用 new 创建类时调用;WCF 创建类时不调用)。有关示例,请参阅代码下载。
最后,您可能希望将服务引用配置为使用 ChangeTrackingCollection<T> 或 ChangeTrackingList<T> 作为集合类型,而不是 System.Array。这表示当服务操作返回 Order 数组时,将在客户端上具体化为 ChangeTrackingCollection<Order> 或 ChangeTrackingList<Order>。每个 Order 的 OrderDetails 属性都有可能显示为跟踪更改集合类型。知道这有多酷了吧?要想实现这种奇妙的转变,您必须打开 Reference.svcmap 文件(显示所有项目文件并打开服务引用进行查看)然后深入研究 XML。对于 WPF 客户端,按如下所示配置 CollectionMappings 元素:
<CollectionMappings>
<CollectionMapping TypeName="ChangeTracker.ChangeTrackingCollection'1" Category="List" />
</CollectionMappings>
对于 Windows 窗体客户端,您应该将 ChangeTracker.ChangeTrackingCollection`1 替换为 ChangeTracker.ChangeTrackingList`1。
现在,在服务应用程序运行过程中,右键单击服务引用并选择“更新服务引用”,然后重新编译客户端应用程序。如果一切顺利,则会成功构建项目,您将可以继续执行操作。
完成这些步骤之后,剩下的就是为客户端应用程序编写代码以检索某些 DTO 并将它们绑定到 UI(用户可以在此处更新 DTO 并将其发送回服务以保存到数据库中)。从服务中检索对象只需要创建一个服务代理实例并调用操作来获取 Customer、Product 和 Order 即可。例如,如果服务包含的 GetOrder 方法接受 Int 类型的 OrderId,那么您的客户端代码可能如下所示:
using (OrderServiceClient proxy = new OrderServiceClient())
{
CurrentOrder = proxy.GetOrder(orderId);
}
如果您的数据绑定设置正确,订单和订单明细都应该显示在用户界面上。图 12 显示了示例应用程序在 WPF 客户端中的显示。单击“Get Order”按钮,用户可以检索某个现有订单,以及从客户订单列表中进行选择;单击“New Order”按钮可直接创建新订单;单击“Delete Order”按钮,用户可对服务器代理调用 DeleteOrder,从而删除当前订单。
图 12 示例 WPF 客户端应用程序
单击“Save Order”按钮,可以调用服务 CreateOrder 操作以持久化最新创建的订单,或调用 UpdateOrder 操作保存对现有订单的更改。这两种操作都会返回一个订单对象,其中包含用于并发管理的当前 Updated 属性和用于插入订单的新 OrderId 属性(Northwind 中 Order 表使用标识列)。获得更新订单后,您即可以启动更改跟踪并将数据绑定设置为使用订单。
但是,在保存现有的订单之前,您还需要执行一些操作。我们只希望传递创建、更新或删除的订单明细,而不想浪费网络带宽来发送未发生更改的订单明细。借助 ChangeTrackingCollection<T> 和 ChangeTrackingList<T> 可以比较轻松地实现此目的,因为它们公开的 GetChanges 方法只返回插入、修改或删除的订单明细。调用 UpdateOrder 的代码如图 13 所示。
图 13 调用 UpdateOrder
由于我已经将服务引用配置为使用 ChangeTrackingCollection<T> 作为集合类型,因此 Order 类的 OrderDetails 属性应该属于类型 ChangeTrackingCollection<OrderDetail>,并且我可以直接对其调用 GetChanges 只获得添加、修改或删除的订单细节,以传递给 UpdateOrder 操作。另外,由于 ChangeTrackingCollection<T> 可扩展 ObservableCollection<T>,因此它支持的数据绑定功能非常适用于 WPF 应用程序(例如,实现 INotifyCollectionChanged 以便在向集合添加或从集合删除项目时 UI 可以更新项目控件)。同理,由于 ChangeTrackingList<T> 可扩展 BindingList<T>,它可以作为 Windows 窗体 DataGridView 的有效数据源,以利用 BindingList<T> 实现的接口增强用户体验。
总结
现在您已经拥有了一个面向服务的灵活应用程序体系结构,在此体系结构中,客户端可以完全不考虑对象的持久化方法。我们知道,DTO 允许对对象模型的结构和形状进行更多的控制,这与允许跨服务边界传递工具生成的实体一样具有吸引力。此外,构建数据访问层可将应用程序从您使用的任何持久性技术中分离出来,从而允许您在不影响应用程序的其他部分的情况下更换数据访问实现。
LINQ to SQL 和 ADO.NET 实体框架的出现代表了 ORM 领域的一次巨大飞跃,有效地减少了对象和关系数据之间的阻抗失谐。它们支持各种方案,包括传统客户端服务器和面向服务的体系结构。但是,对于开发人员来说,使用 DTO 更新数据库需要进行更多的工作,尤其是在使用时间戳值检测并发用户之间的冲突时。但是,通过将更改状态添加到客户端和服务之间的数据约定,您可以附加与源上下文断开连接的实体。最后,我们介绍了通用的跟踪更改集合如何不负众望地管理客户端上的更改状态,使您访问服务一次即可处理批更新。
有关实体框架的详细信息,请参阅“ADO.NET:使用实体框架灵活地为数据建模”(由 Elisa Flasko 编写)。
Anthony Sneed 是开发人员培训公司 DevelopMentor 的一名讲师,他撰写并教授 .NET Framework 3.5、LINQ 和实体框架的相关课程。在他的业余时间里,他喜欢烹饪和录制家庭视频,其烹调的红辣椒很有名。您可以通过 tony@tonysneed.com 或访问博客 blog.tonysneed.com 与 Anthony Sneed 联系。