作者:Angela Crocker、Andy Olsen和Edward Jezierski
适用于:
Microsoft®.NET 应用程序
摘要:
学习向 Microsoft .NET 应用程序公开数据的最佳方式,以及如何实现一个有效的策略以便在分布式应用程序的层间传递数据。(本文包含一些指向英文站点的链接。)
目录
- 简介
- 将关系数据映射到业务实体
- 实现数据访问逻辑组件
- 实现业务实体
- 事务处理
- 验证
- 异常管理
- 授权与安全性
- 部署
- 附录及其合作者
数据访问逻辑组件是一个无状态类,也就是说,所交换的所有消息都可以独立解释。调用之间不存在状态。数据访问逻辑组件为访问单一数据库(某些情况下可以是多个数据库,例如水平数据库分区)中的一个或多个相关表提供方法。通常,数据访问逻辑组件中的这些方法将调用存储过程以执行相应操作。
数据访问逻辑组件的主要目标之一是从调用应用程序中隐藏数据库的调用及格式特性。数据访问逻辑组件为这些应用程序提供封装的数据访问服务。具体地说,数据访问逻辑组件处理以下实现细节:
- 管理和封装锁定模式
- 正确处理安全性和授权问题
- 正确处理事务处理问题
- 执行数据分页
- 必要时执行数据相关路由
- 为非事务性数据的查询实现缓存策略(如果适用)
- 执行数据流处理和数据序列化
本节后面将详细讨论其中的某些问题。
数据访问逻辑组件的应用方案
图5 所示为从各种应用程序类型(包括 Windows 窗体应用程序、ASP.NET 应用程序、XML Web services 和业务过程)中调用数据访问逻辑组件的方式。根据应用程序的部署方式,这些调用可以是本地的,也可以是远程的。
图5:数据访问逻辑组件的应用方案(单击缩略图以查看大图像)
实现数据访问逻辑组件类
数据访问逻辑组件使用 ADO.NET 执行 SQL 语句或调用存储过程。有关数据访问逻辑组件类的示例,请参阅附录中的 如何定义数据访问逻辑组件类。
如果您的应用程序包含多个数据访问逻辑组件,可以使用数据访问助手组件来简化数据访问逻辑组件类的实现。该组件可以帮助管理数据库连接、执行 SQL 命令以及缓存参数。数据访问逻辑组件仍然封装访问特定业务数据所需的逻辑,而数据访问助手组件则专注于数据访问 API 的开发和数据连接配置,从而帮助减少代码的重复。Microsoft提供了 Data Access Application Block for .NET,当使用 Microsoft SQL Server™ 数据库时,可在您的应用程序中将其用作一个通用的数据访问助手组件。图 6 所示为使用数据访问助手组件帮助实现数据访问逻辑组件的方法。
图6:使用数据访问助手组件实现数据访问逻辑组件
当存在所有数据访问逻辑组件公用的实用程序功能时,可以定义一个基本类以从中继承和扩展数据访问逻辑组件。
将数据访问逻辑组件类设计为可以为不同类型的客户端提供一致的接口。如果将数据访问逻辑组件设计为与当前及潜在的业务过程层的实现要求相兼容,可以减少必须实现的附加接口、接触面或映射层的数目。
要支持广泛的业务过程和应用程序,请考虑以下技术以便将数据传入和传出数据访问逻辑组件方法:
以下各节将说明用于将业务实体数据传入和传出数据访问逻辑组件的各种方式以及每种方式的优缺点。这些信息有助于您根据自己特定的应用程序方案做出相应选择。
将标量值作为输入和输出传递
这种方法的优点如下:
- 抽象。调用程序只需要知道定义业务实体的数据,而不需要知道业务实体的具体类型或具体结构。
序列化。标量值本身支持序列化。
- 内存使用效率高。标量值只传递实际需要的数据。
- 性能。当处理实例数据时,标量值具有比本文所述的其他方法更高的性能。
这种方法的缺点如下:
- 紧密耦合与维护。架构的更改可能需要修改方法签名,这会影响调用代码。
- 实体集合。要向数据访问逻辑组件保存或更新多个实体,必须进行多次单独的方法调用。这在分布式环境中会给性能带来很大影响。
- 支持开放式并发。要支持开放式并发,必须在数据库中定义时间戳列并将其作为数据的一部分。
将XML字符串作为输入和输出传递
这种方法的优点如下:
这种方法的缺点如下:
- 需要重新分析 XML 字符串。必须在接收端重新分析 XML 字符串。很大的 XML 字符串会影响性能。
- 内存使用效率低。XML 字符串比较繁琐,因而在需要传递大量数据时会降低内存使用效率。
- 支持开放式并发。要支持开放式并发,必须在数据库中定义时间戳列并将其作为 XML 数据的一部分。
将DataSet作为输入和输出传递
这种方法的优点如下:
- 固有功能。DataSet 提供了内置功能,可以处理开放式并发(以及数据适配器)并支持复杂的数据结构。此外,有类型的 DataSet 还提供了数据验证支持。
- 业务实体集合。DataSet 是为处理复杂的关系集合而设计的,因此不需要再编写自定义代码来实现这一功能。
- 维护。更改架构不会影响方法签名。然而,如果使用的有类型的 DataSet 和程序集具有严格名称,则必须按照新版本重新编译数据访问逻辑组件类,或在全局程序集缓存中使用发布者策略,或在配置文件中定义一个 <bindingRedirect>元素。
- 序列化。DataSet 本身支持 XML 序列化,并且可以跨层序列化。
这种方法的缺点如下:
- 性能。实例化和封送处理 DataSet 会增加运行时负担。
- 表示单个业务实体。DataSet 是为处理一组数据而设计的。如果您的应用程序主要处理实例数据,则标量值或自定义实体是更好的方法,后者不会影响性能。
将自定义业务实体组件作为输入和输出传递
这种方法的优点如下:
- 维护。更改架构不会影响数据访问逻辑组件方法签名。然而,如果业务实体组件包含在严格命名的程序集中,就会出现与有类型的 DataSet 同样的问题。
- 业务实体集合。可以将自定义业务实体组件的数组和集合传入和传出方法。
这种方法的缺点如下:
- 支持开放式并发。要方便地支持开放式并发,必须在数据库中定义时间戳列并将其作为实例数据的一部分。
- 集成限制。当使用自定义业务实体组件作为数据访问逻辑组件的输入时,调用程序必须知道业务实体的类型,而这会限制不使用 .NET 的调用程序的集成。然而,如果调用程序使用自定义业务实体组件作为数据访问逻辑组件的输出,则上述问题并不会限制集成。例如,Web 方法可以返回从数据访问逻辑组件返回的自定义业务实体组件,并使用 XML 序列化自动将该业务实体组件序列化为 XML。
将数据读取器作为输出返回
这种方法的优点如下:
- 性能。当需要快速呈现数据时,这种方法具有性能优势,并且可以使用表示层代码部署数据访问逻辑组件。
这种方法的缺点如下:
- 远程。建议不要在远程方案中使用数据读取器,因为它可能会使客户端应用程序与数据库保持长时间的连接。
配合使用数据访问逻辑组件与存储过程
可以使用存储过程执行数据访问逻辑组件支持的许多数据访问任务。
优点
- 存储过程通常可以改善性能,因为数据库能够优化存储过程使用的数据访问计划并为以后的重新使用缓存该计划。
- 可以在数据库内分别设置各个存储过程的安全保护。管理员可以授予客户端执行某个存储过程的权限,而不授予任何基础表访问权限。
- 存储过程可以简化维护,因为修改存储过程通常比修改所部署的组件中的硬编码 SQL 语句要容易。然而,随着在存储过程中实现的业务逻辑的增多,上述优势会逐渐减弱。
- 存储过程增大了从基础数据库架构进行抽象的程度。存储过程的客户端与存储过程的实现细节和基础架构是彼此分离的。
- 存储过程会降低网络流量。应用程序可以按批执行 SQL 语句而不必发出多个 SQL 请求。
- 尽管存储过程具有上述优点,但仍有某些情况不适合使用存储过程。
缺点
配合使用数据访问逻辑组件与存储过程的建议
配合使用数据访问逻辑组件与存储过程时,请考虑以下建议:
管理锁定和并发
某些应用程序在更新数据库数据时采用“后进有效”(Last in Wins) 法。使用“后进有效”法更新数据库时不会将更新与原始记录相比较,因此可能会覆盖掉自上次刷新记录以来其他用户所做的所有更改。然而,有时应用程序却需要在执行更新之前确定数据自最初读取以来是否被更改。
数据访问逻辑组件可以实现管理锁定和并发的代码。管理锁定和并发的方法有两种:
- 保守式并发。为进行更新而读取某行数据的用户可以在数据源中对该行设置一个锁定。在该用户解除锁定之前,其他任何用户都不能更改该行。
- 开放式并发。用户在读取某行数据时不锁定该行。其他用户可以在同一时间自由访问该行。当用户要更新某行数据时,应用程序必须确定自该行被读取以来其他用户是否进行过更改。尝试更新已经过更改的记录会导致并发冲突。
使用保守式并发
保守式并发主要用于数据争用量大以及通过锁定来保护数据的成本低于发生并发冲突时回滚事务的成本的环境。如果锁定时间很短(例如在编程处理的记录中),则实现保守式并发效果最好。
保守式并发要求与数据库建立持久连接,并且因为记录可能被锁定较长时间,因此当用户与数据进行交互时,不能提供可缩放的性能。
使用开放式并发
开放式并发适用于数据争用量低或要求只读访问数据的环境。开放式并发可以减少所需锁定的数量,从而降低数据库服务器的负荷,提高数据库的性能。
开放式并发在 .NET 中被广泛使用以满足移动和脱机应用程序的需要。在这种情况下,长时间锁定数据是不可行的。此外,保持记录锁定还要求与数据库服务器的持久连接,这在脱机应用程序中是不可能的。
测试开放式并发冲突
测试开放式并发冲突的方法有多种:
- 使用分布式时间戳。分布式时间戳适用于不要求协调的环境。在数据库的每个表中添加一个时间戳列或版本列。时间戳列与对表内容的查询一起返回。当试图更新时,数据库中的时间戳值将与被修改行中的原始时间戳值进行比较。如果这两个值匹配,则执行更新,同时时间戳列被更新为当前时间以反映更新。如果这两个值不匹配,则发生开放式并发冲突。
- 保留原始数据值的副本。在查询数据库的数据时保留原始数据值的一个副本。在更新数据库时,检查数据库的当前值是否与原始值匹配。
- 原始值保存在 DataSet 中,当更新数据库时,数据适配器可以使用该原始值执行开放式并发检查。
- 使用集中的时间戳。在数据库中定义一个集中的时间戳表,用于记录对任何表中的任何行的更新。例如,时间戳表可以显示以下信息:“2002 年 3 月 26 日下午 2:56 约翰更新了表 XYZ 中的行 1234”。
集中的时间戳适用于签出方案以及某些脱机客户端方案,其中可能需要明确的锁定所有者和替代管理。此外,集中的时间戳还可以根据需要提供审核。
手动实现开放式并发
请考虑以下 SQL 查询:
SELECT Column1, Column2, Column3 FROM Table1
|
要在更新 Table1 的行时测试开放式并发冲突,可以发出以下 UPDATE 语句:
UPDATE Table1 Set Column1 = @NewValueColumn1,
Set Column2 = @NewValueColumn2,
Set Column3 = @NewValueColumn3
WHERE Column1 = @OldValueColumn1 AND
Column2 = @OldValueColumn2 AND
Column3 = @OldValueColumn3
|
如果原始值与数据库中的值匹配,则执行更新。如果某个值被修改,WHERE 子句将无法找到相应匹配,从而更新将不会修改该行。您可以对此技术稍加变化,即只对特定列应用 WHERE 子句,使得如果自上次查询以来特定字段被更新,则不覆盖数据。
注意:请始终返回一个唯一标识查询中的一行的值,例如一个主关键字,以用于 UPDATE 语句的 WHERE 子句。这样可以确保 UPDATE 语句更新正确的行。
如果数据源中的列允许空值,则可能需要扩展 WHERE 子句,以便检查本地表与数据源中匹配的空引用。例如,以下 UPDATE 语句将验证本地行中的空引用(或值)是否仍然与数据源中的空引用(或值)相匹配。
UPDATE Table1 Set Column1 = @NewColumn1Value
WHERE (@OldColumn1Value IS NULL AND Column1 IS NULL) OR Column1 =
@OldColumn1Value
|
使用数据适配器和 DataSet 实现开放式并发
可以配合使用 DataAdapter.RowUpdated 事件与前面所述技术以通知您的应用程序发生了开放式并发冲突。每当试图更新 DataSet 中的修改过的行时,都将引发 RowUpdated 事件。可以使用 RowUpdated 事件添加特殊处理代码,包括发生异常时的处理、添加自定义错误信息以及添加重试逻辑。
RowUpdated 事件处理程序接收一个 RowUpdatedEventArgs 对象,该对象具有 RecordsAffected 属性,可以显示针对表中的一个修改过的行的更新命令会影响多少行。如果把更新命令设置为测试开放式并发,则当发生开放式并发冲突时,RecordsAffected 属性将为 0。设置 RowUpdatedEventArgs.Status 属性以表明要采取的操作;例如,可以把该属性设置为 UpdateStatus.SkipCurrentRow 以跳过对当前行的更新,但是继续更新该更新命令中的其他行。
使用数据适配器测试并发错误的另一种方法是在调用 Update 方法之前把 DataAdapter.ContinueUpdateOnError 属性设置为 true。完成更新后,调用 DataTable 对象的 GetErrors 方法以确定哪些行发生了错误。然后,使用这些行的 RowError 属性找到特定的详细错误信息。
以下代码示例显示了 Customer 数据访问逻辑组件如何检查并发冲突。该示例假设客户端检索到了一个 DataSet 并修改了数据,然后把该 DataSet 传递给了数据访问逻辑组件中的 UpdateCustomer 方法。UpdateCustomer 方法将通过调用以下存储过程来更新相应的客户记录;仅当客户 ID 与公司名称未被修改时存储过程才能更新该客户记录:
CREATE PROCEDURE CustomerUpdate
{
@CompanyName varchar(30),
@oldCustomerID varchar(10),
@oldCompanyName varchar(30)
}
AS
UPDATE Customers Set CompanyName = @CompanyName
WHERE CustomerID = @oldCustomerID AND CompanyName = @oldCompanyName
GO
|
在 UpdateCustomer 方法中,以下代码示例将一个数据适配器的 UpdateCommand 属性设置为测试开放式并发,然后使用 RowUpdated 事件测试开放式并发冲突。如果遇到开放式并发冲突,应用程序将通过设置要更新的行的 RowError 来表明开放式并发冲突。注意,传递给 UPDATE 命令中的 WHERE 子句的参数值被映射到 DataSet 中各相应列的原始值。
// CustomerDALC 类中的 UpdateCustomer 方法
public void UpdateCustomer(DataSet dsCustomer)
{
// 连接到 Northwind 数据库
SqlConnection cnNorthwind = new SqlConnection(
"Data source=localhost;Integrated security=SSPI;Initial
Catalog=northwind");
// 创建一个数据适配器以访问 Northwind 中的 Customers 表
SqlDataAdapter da = new SqlDataAdapter();
// 设置数据适配器的 UPDATE 命令,调用存储过程“UpdateCustomer”
da.UpdateCommand = new SqlCommand("CustomerUpdate", cnNorthwind);
da.UpdateCommand.CommandType = CommandType.StoredProcedure;
// 向数据适配器的 UPDATE 命令添加两个参数,
// 为 WHERE 子句指定信息(用于检查开放式并发冲突)
da.UpdateCommand.Parameters.Add("@CompanyName", SqlDbType.NVarChar, 30,
"CompanyName");
// 将 CustomerID 的原始值指定为第一个 WHERE 子句参数
SqlParameter myParm = da.UpdateCommand.Parameters.Add(
"@oldCustomerID", SqlDbType.NChar, 5,
"CustomerID");
myParm.SourceVersion = DataRowVersion.Original;
// 将 CustomerName 的原始值指定为第二个 WHERE 子句参数
myParm = da.UpdateCommand.Parameters.Add(
"@oldCompanyName", SqlDbType.NVarChar, 30,
"CompanyName");
myParm.SourceVersion = DataRowVersion.Original;
// 为 RowUpdated 事件添加一个处理程序
da.RowUpdated += new SqlRowUpdatedEventHandler(OnRowUpdated);
// 更新数据库
da.Update(ds, "Customers");
foreach (DataRow myRow in ds.Tables["Customers"].Rows)
{
if (myRow.HasErrors)
Console.WriteLine(myRow[0] + " " + myRow.RowError);
}
}
// 处理 RowUpdated 事件的方法。 如果登记该事件但不处理它,
// 则引发一个 SQL 异常。
protected static void OnRowUpdated(object sender, SqlRowUpdatedEventArgs
args)
{
if (args.RecordsAffected == 0)
{
args.Row.RowError = "遇到开放式并发冲突";
args.Status = UpdateStatus.SkipCurrentRow;
}
}
|
当在一个 SQL Server 存储过程中执行多个 SQL 语句时,出于性能原因,可以使用 SET NOCOUNT ON 选项。此选项将禁止 SQL Server 在每次执行完一条语句时都向客户端返回一条消息,从而可以降低网络流量。然而,这样将不能像前面的代码示例那样检查 RecordsAffected 属性。RecordsAffected 属性将始终为 1。另一种方法是在存储过程中返回 @@ROWCOUNT 函数(或将它指定为一个输出参数);@@ROWCOUNT 包含了存储过程中上一条语句完成时的记录数目,并且即使使用了 SET NOCOUNT ON,该函数也会被更新。因此,如果存储过程中执行的上一条 SQL 语句是实际的 UPDATE 语句,并且已经指定 @@ROWCOUNT 作为返回值,则可以对应用程序代码进行如下修改:
// 向数据适配器的 UPDATE 命令添加另一个参数来接收返回值。
// 可以任意命名该参数。
myParm = da.UpdateCommand.Parameters.Add("@RowCount", SqlDbType.Int);
myParm.Direction = ParameterDirection.ReturnValue;
// 将 OnRowUpdated 方法修改为检查该参数的值
// 而不是 RecordsAffected 属性。
protected static void OnRowUpdated(object sender, SqlRowUpdatedEventArgs
args)
{
if (args.Command.Parameters["@RowCount"].Value == 0)
{
args.Row.RowError = "遇到开放式并发冲突";
args.Status = UpdateStatus.SkipCurrentRow;
}
}
|
COM互操作性
如果希望数据访问逻辑组件类能够被 COM 客户端调用,则建议按前面所述的原则定义数据存取逻辑组件,并提供一个包装组件。然而,如果希望 COM 客户端能够访问数据访问逻辑组件,请考虑以下建议:
- 将该类及其成员定义为公共。
- 避免使用静态成员。
- 在托管代码中定义事件-源接口。
- 提供一个不使用参数的构造函数。
- 不要使用重载的方法,而使用多个名称不同的方法。
- 使用接口公开常用操作。
- 使用属性为类和成员提供附加 COM 信息。
- 在 .NET 代码引发的所有异常中包含 HRESULT 值。
- 在方法签名中使用自动兼容的数据类型。
|