剖析 ADO.NET 批处理更新(深入研究数据访问)
发布日期: 4/1/2004 | 更新日期: 4/1/2004
Dino Esposito
Wintellect
2001 年 11 月 8 日
ADO.NET 应用程序和基础数据源之间的交互基于一个具有双向信道的双体系结构。您可以使用各个特定于提供程序的命令或批处理更新过程来访问数据源,以读取和写入行。在这两种情况下,数据访问都会产生完全双向绑定,并涉及各种不同的对象和方法。您可以使用如 SqlCommand 和 OleDbCommand 等命令类来执行单个命令。可使用数据适配器对象来下载断开连接的数据,提交更新的行集。虽然 “数据集” 是数据适配器用于返回和提交记录块的容器对象,但各个命令通过数据读取器对象返回数据。
更新是通过各个命令、存储过程完成的,通常,托管提供程序理解的任何命令文本一般都被称为更新。更新命令总是执行嵌入在语句正文中的新数据。更新命令总是需要一个打开的连接,可能还需要一个正在进行的事务处理或一个新的事务处理。批处理更新则是一个略有不同的方法分支。从最高的抽象级别来看,您并不发出命令,无论它可能有多么复杂。取而代之的是,您提交在客户端修改的当前行的快照,并等待数据源批准。批处理更新背后的关键概念是数据断开连接的概念。您下载行表,通常为数据集,根据需要在客户端对它进行修改,然后将这些行的新映像提交到数据库服务器。您所作的是提交更改,而不是执行一个对数据源创建更改的命令。这就是更新(我在 July column 一文中讨论过这个问题)和批处理更新之间的本质区别。
下图说明了 ADO.NET 的双更新体系结构。
图 1. ADO.NET 应用程序和数据源之间的两个双向交互
在进一步详细讨论 ADO.NET 批处理更新之前,我需要阐明常常会导致某种误解的批处理更新模型的一个方面。虽然更新和批处理更新在 ADO.NET 内的实际实现方面有着本质的区别,但它们遵循的是同一个更新模型。更新和批处理更新都是通过直接的并且特定于提供程序的语句来完成的。当然,由于批处理更新通常涉及到更多的行,所以这些语句会被组合为一个批处理调用。批处理更新会对目标数据集的行进行从头到尾的循环,只要发现更新的行,就会发出适当的更新命令(INSERT、DELETE 或 UPDATE)。对更新的行进行通信时,将运行一个预定义的直接 SQL 命令。从本质上来说,这就是批处理更新。
这个过程是理所当然的。实际上,如果批处理更新使用完全不同的更新模型,就需要来自数据源的特殊支持。(这正是向 SQL Server 2000 提交 XML updategram 时发生的情况。)批处理更新只是一个用来简化多个行更新提交的客户端提供的软件机制。在任何情况下,每个新行提交总是通过数据源直接命令的正常通道完成的。
到目前为止,本文只提及了 SQL 命令,但这些提及的内容都明确表明了 ADO 批处理更新实现和 ADO.NET 批处理更新实现之间的一个重要区别。在 ADO 中,批处理更新只可能发生在基于 SQL 的数据源上。而在 ADO.NET 中,批处理更新则可能发生在任何种类的托管提供程序上,其中包括那些不应该通过 SQL 查询语言公开其数据的托管提供程序。现在,我们可以开始讨论 ADO.NET 批处理更新编程的关键内容了。
准备用于提交的数据集
ADO.NET 批处理更新通过数据适配器对象的 “更新” 方法进行。数据只能以每个表为基础进行提交。如果您调用 “更新” 时没有指定表名,则使用 Table 这个默认的表名。如果不存在具有该名称的表,则会产生异常。“更新” 首先检查每个表行的 RowState 属性,然后为所指定表中的每个插入行、更新行或删除行准备自定义的 INSERT、UPDATE 或 DELETE 语句。
“更新” 方法有几个超载。它可以采用数据集和数据表提供的对、某个数据表、甚至是一个 DataRow 对象数组。该方法会返回一个整数值,即成功更新的行数。
为了最大限度地减少网络通信,通常会对正在操作的数据集的一个子集调用 “更新”。毫无疑问,这个子集只包含当时已修改的行。您可以通过调用数据集的 GetChanges 方法来获得这样的子集。
if (ds.HasChanges())
{
DataSet dsChanges = ds.GetChanges();
adapter.Update(dsChanges, "MyTable");
}
另外,您可以使用 HasChanges 方法检查数据集是否发生了更改。HasChanges 返回一个布尔值。
GetChanges 返回的数据集包含当时已插入、删除或修改的行。但这里所说的当时是什么时间呢?这正是 ADO.NET 批处理更新比较复杂的一个方面,必须与表行的当前状态一起处理。
行的状态
“数据表” 中的每一行都是通过 DataRow 对象呈现的。DataRow 对象主要是作为父 “数据表” 对象的 Rows 集合的一个元素而存在的。从概念上来看,数据库行固有地链接到了某个给定表的结构。就是由于这个原因,ADO.NET 中的 DataRow 类不提供公用构造函数。创建新 DataRow 对象的唯一方式是借助于对 “数据表” 对象的某个实时实例调用名为 NewRow 的方法。刚刚创建好的行还不属于父表的 Rows 集合,但该行与此集合的关系决定了该行的状态。下表显示了 RowState 属性的一些可取值。这些值组合在了 DataRowState 枚举中。
Added
该行已添加到表中。
Deleted
该行已标记为从父表删除。
Detached
该行已创建但尚未添加到表中,或者该行已从表行的集合中删除。
Modified
该行中的某些列已更改。
Added
该行已添加到表中。
Unchanged
在创建后或上次调用 AcceptChanges 方法后未对该行进行任何更改。
每一行的 RowState 属性都会影响 HasChanges 方法的返回值以及 GetChanges 返回的子数据集的内容。
从这些可取值的范围可以看出,RowState 的值主要取决于对行已经执行的操作。ADO.NET 表基于两个方法 - AcceptChanges 和 RejectChanges - 来实现类似事务处理的提交模型。从数据源下载表时或在内存中新建表时,所有行都是没有更改的。您输入的所有更改不会立即变为永久性更改,随时都可以通过调用 RejectChanges 来回滚更改。您可以在三个级别调用 RejectChanges 方法:
•
在数据集级别上可拒绝所有更改(无论是什么更改)。
•
在数据表级别上可取消某个表中的所有更改。
•
在某个特定的行级别上可还原到该行以前的状态。
方法 AcceptChanges 能够提交所有正在进行的更改。它使得数据集会将当前值接受为新的原始值。因此,所有挂起的更改都被清除。与 RejectChanges 一样,也可以对整个数据集、某个表或某个行调用 AcceptChanges。
当您开始一个批处理更新操作时,只会考虑提交那些标记为 Added、Deleted 和 Modified 的行。如果您恰好在批处理更新之前调用了 AcceptChanges,则对数据源不进行任何持久更改。
另一方面,一旦批处理更新操作成功完成,您必须调用 AcceptChanges 来清除挂起的更改,并将当前数据集值标记为原始值。注意,如果省略了最后对 AcceptChanges 的调用,数据集中则会保留挂起的更改,从而导致在下次进行批处理更新时重新发出这些更改。
// Get changes in the DataSet
dsChanges = ds.GetChanges();
// Performs the batch update for the given table
da.Update(dsChanges, strTable);
// Clears any pending change in memory
ds.AcceptChanges();
上面的代码说明了 ADO.NET 批处理更新背后的三个主要步骤。
如果从数据集表中删除行,请注意您使用的方法是 “删除” 还是 “移除”。“删除” 方法会通过将行标记为 “删除”,执行逻辑删除。而 “移除” 方法则从 Rows 集合中物理删除该行。因此,通过 “移除” 删除的行不会标记为删除,因此在后面的批处理更新期间也不会被处理。如果您的最终删除目标是从数据源删除行,则应使用 “删除”。
更新的深入内容
有三个操作可改变表的状态:
•
插入一个新行
•
删除一个现有的行
•
更新一个现有的行
对于其中的每一个关键操作,数据适配器都会定义一个作为属性公开的自定义的命令对象。这样的属性包括 InsertCommand、DeleteCommand 和 UpdateCommand。程序员负责为这些属性分配有意义的命令对象,例如,SqlCommand 对象。
仅提供的 InsertCommand、DeleteCommand 和 UpdateCommand 属性就代表了从 ADO 到 ADO.NET 的巨大突破。利用这种属性,您可以对内存中的更新提交到数据库服务器的方式进行前所未有的控制。如果您不满意 ADO.NET 生成的更新代码,现在则可以修改这些更新代码,而不会否定批处理更新的整体特性。使用 ADO 的时候,您对库静默生成的 SQL 命令毫无控制权。而在 ADO.NET 中,利用公开显示的命令对象,您可以使用更符合用户期望的自定义存储过程或 SQL 语句来应用更新。特别是,您可以对交叉引用的表使用批处理更新系统,甚至可以诸如 Active Directory™ 或 Indexing Services 这样的非 SQL 数据提供程序为目标。
更新命令应该针对表中每个更改的行运行,并且必须非常通用,以适应不同的值。对于这种任务,非常适合使用命令参数,只要您可以将它们绑定到数据库列的值。ADO.NET 参数对象公开两个用于这种绑定的属性,例如, SourceColumn 和 SourceVersion。尤其是 SourceColumn,它表示一种指示参数值的间接方式。您可以使用列名设置 SourceColumn 属性,并且使批处理更新机制不时地提取有效值,而不是使用 Value 属性并用标量值设置它。
SourceVersion 指示应该读取列上的哪个值。默认情况下,ADO.NET 会返回行的当前值。另一种方法是,您可以选择原始值和 DataRowVersion 枚举中的所有值。
如果您希望对 Northwind 的 Employees 表中的几个列进行批处理更新,可以使用以下自定义命令。INSERT 命令的定义如下:
StringBuilder sb = new StringBuilder("");
sb.Append("INSERT Employees (firstname, lastname) VALUES(");
sb.Append("@sFirstName, @sLastName)");
da.InsertCommand = new SqlCommand();
da.InsertCommand.CommandText = sb.ToString();
da.InsertCommand.Connection = conn;
所有参数都将添加到数据适配器的 Parameters 集合并绑定到一个数据表列。
SqlParameter p1 = new SqlParameter("@sFirstName", SqlDbType.NVarChar, 10);
p1.SourceVersion = DataRowVersion.Current;
p1.SourceColumn = "firstname";
da.InsertCommand.Parameters.Add(p1);
SqlParameter p2 = new SqlParameter("@sLastName", SqlDbType.NVarChar, 30);
p2.SourceVersion = DataRowVersion.Current;
p2.SourceColumn = "lastname";
da.InsertCommand.Parameters.Add(p2);
注意,自动递增的列不应该列在 INSERT 命令的语法中,因为它们的值是由数据源生成的。
UPDATE 命令需要确定一个特定的行来应用其更改。为此,您可以使用 WHERE 子句,在该子句中对参数化的值与键字段进行比较。在这种情况下,WHERE 子句中使用的参数必须绑定到行的原始值,而不是当前值。
StringBuilder sb = new StringBuilder("");
sb.Append("UPDATE Employees SET ");
sb.Append("lastname=@sLastName, firstname=@sFirstName ");
sb.Append("WHERE employeeid=@nEmpID");
da.UpdateCommand = new SqlCommand();
da.UpdateCommand.CommandText = sb.ToString();
da.UpdateCommand.Connection = conn;
// p1 and p2 set as before
:
p3 = new SqlParameter("@nEmpID", SqlDbType.Int);
p3.SourceVersion = DataRowVersion.Original;
p3.SourceColumn = "employeeid";
da.UpdateCommand.Parameters.Add(p3);
最后,DELETE 命令需要用 WHERE 子句来确定要删除的行。在这种情况下,您需要使用行的原始版本来绑定参数值。
StringBuilder sb = new StringBuilder("");
sb.Append("DELETE FROM Employees ");
sb.Append("WHERE employeeid=@nEmpID");
da.DeleteCommand = new SqlCommand();
da.DeleteCommand.CommandText = sb.ToString();
da.DeleteCommand.Connection = conn;
p1 = new SqlParameter("@nEmpID", SqlDbType.Int);
p1.SourceVersion = DataRowVersion.Original;
p1.SourceColumn = "employeeid";
da.DeleteCommand.Parameters.Add(p1);
SQL 命令的实际结构取决于您。这些命令不一定是普通的 SQL 语句,它们可以是更有效的存储过程(如果您想采用这种方向)。如果存在某个很具体的风险 - 其他人可能更新您读取和修改的行,那么您可能想采取一些更有效的防范措施。如果是这种情况,您可以在 DELETE 和 UPDATE 命令中使用一个限制性更强的 WHERE 子句。WHERE 子句可以明确地确定行,但同时还应确保所有列仍然保留原始值。
UPDATE Employees
SET field1=@new_field1, field2=@new_field2, ???…, fieldn=@new_fieldn
WHERE field1=@old_field1 AND
field2=@old_field2 AND
:
fieldn=@old_fieldn
注意,您无需填充所有命令参数,只填充您计划使用的那些即可。如果代码要使用尚未指定的命令,则会引发异常。为批处理更新过程设置命令可能需要许多代码,但您无需在每一次进行批处理更新时都编写大量代码。实际上,在相当多的情况下,ADO.NET 都能为您自动生成有效的更新命令。
命令生成器
要利用默认命令,必须满足两个要求。首先,必须为 SelectCommand 属性分配一个有效的命令对象。您无需填充其他命令对象,但 SelectCommand 必须指向一个有效的查询语句。用于批处理更新的有效查询是返回主键列的查询。另外,该查询不得包括 INNER JOIN、计算的列,也不得引用多个表。
SelectCommand 对象中列出的列和表实际上将用于准备更新和插入语句的正文。如果不设置 SelectCommand,则无法实现 ADO.NET 命令自动生成。下面的代码说明了如何为 SelectCommand 属性编写代码。
SqlCommand cmd = new SqlCommand();
cmd.CommandText = "SELECT employeeid, firstname, lastname FROM Employees";
cmd.Connection = conn;
da.SelectCommand = cmd;
不要担心 SelectCommand 可能对性能产生影响。相关的语句只在批处理更新过程之前执行一次,但它只检索列元数据。无论您怎样编写 SQL 语句,也永远不会向调用程序返回任何行。发生这种情况的原因是,在执行时,SelectCommand 追加到以下面的代码开头的 SQL 批处理语句最后
SET FMTONLY OFF
SET NO_BROWSETABLE ON
SET FMTONLY ON
因此,查询不返回行,而返回列元数据信息。
您的代码必须满足的第二个要求与命令生成器有关。命令生成器是一个特定于托管提供程序的类,它工作在数据适配器对象之上,并自动设置其 InsertCommand、DeleteCommand 和 UpdateCommand 属性。命令生成器首先运行 SelectCommand,以收集有关所涉及表和列的足够信息,然后会创建更新命令。实际的命令创建在命令生成器类构造函数中进行。
SqlCommandBuilder cb = new SqlCommandBuilder(da);
SqlCommandBuilder 类确保指定的数据适配器可成功地用于对特定的数据源进行批处理更新。SqlCommandBuilder 利用了 SelectCommand 对象中定义的某些属性。这些属性是 Connection、CommandTimeout 和 Transaction。只要更改其中的任何属性,您就需要调用命令生成器的 RefreshSchema 方法来更改进一步批处理更新的生成命令的结构。
您可以混合使用命令生成器和自定义命令。如果 InsertCommand 属性在调用命令生成器之前指向一个有效的命令对象,生成器则只会为 DeleteCommand 和 UpdateCommand 生成代码。而非空的 SelectCommand 属性才是命令生成器得以正常工作的关键。
通常,您之所以使用命令生成器,是因为您觉得自己编写 SQL 命令太复杂了。不过,如果您希望查看生成器生成的源代码,则可以调用如 GetInsertCommand、GetUpdateCommand 和 GetDeleteCommand 这样的方法。
命令生成器是一个特定于提供程序的特性。因此,不可能期望所有类型的托管提供程序都支持它。SQL Server 7.0 和更高版本的提供程序以及 OLE DB 提供程序支持命令生成器。
命令生成器有一个很好的特性,它可以检测自动递增的字段,并相应地优化代码。尤其是,只要它有办法识别某些字段是自动递增字段,就会将自动递增字段从 INSERT 语句中提取出来。这个过程可以通过两种方式来实现。例如,您可以手动设置相应的 DataColumn 对象的 AutoIncrement 属性,或者,更好的方法是,使其基于列在数据源(如 SQL Server)中的属性自动进行。要自动继承这样的属性,请确保将数据适配器的 MissingSchemaAction 属性从默认值 Add 改为 AddWithKey。
冲突检测
批处理更新机制对并发有着很乐观的看法。每个记录在读取后并不锁定,仍然公开给其他用户用于进行读取和写入。在这种情况下,可能会发生一些潜在的不一致的情形。例如,将某一行从 SELECT 语句传递到您的应用程序之后,但在批处理更新过程真正将更改返回服务器之前,它可能进行了修改,甚至已被删除。
如果您更新服务器上数据的同时,这些数据已经被另外的某个用户修改,则可能会产生数据冲突。为了避免新的数据被覆盖,ADO.NET 命令生成器会生成带有 WHERE 子句的语句,只有当数据源行的当前状态与应用程序以前读取时的状态一致时,WHERE 子句才生效。如果这样的命令未能更新行,ADO.NET 运行时则会引发一个 DBConcurrencyException 类型的异常。
下面的代码片断说明了如何以一种更准确的方法用 ADO.NET 执行批处理更新操作。
try
{
da.Update(dsChanges, "Employees");
}
catch (DBConcurrencyException dbdcex)
{
// resolve the conflict
}
您正在使用的数据适配器的 “更新” 方法对于第一个更新失败的行会引发异常。此时,控制权又回到客户端应用程序,批处理更新过程停止。不过,仍然会执行所有以前提交的更改。这个过程代表了从 ADO 批处理更新模型到 ADO.NET 的另一个转变。
通过 DBConcurrencyException 类的 Row 属性可使用冲突更新中涉及的 DataRow 对象。这个 DataRow 对象包含行的提交值和原始值。它不包含某个给定列当前存储在数据库中的值。此值 - 即 ADO 的 UnderlyingValue 属性 - 只能通过另一个查询命令检索。
解决冲突、并且有可能继续进行批处理更新的方式是严格特定于应用程序的。如果存在您的应用程序需要继续执行更新的情况,您则应该了解一个微妙的、然而却很棘手的问题。想尽办法解决了行上的冲突之后,还必须想出一种方法来接受批处理已成功完成的内存中行的更改。如果您忽略了这个技术细节,对于以前成功更新的第一个行将产生一个新的冲突!这种情况会反复不断地发生,您的应用程序很快就会进入死锁状态。
小结
与 ADO 相比,ADO.NET 中的批处理更新功能更强大,具有更高的可访问性。在 ADO 中,批处理更新机制是一种黑盒子,我们几乎不可能深入其内部,也不可能略微改变一下您需要执行的任务。ADO.NET 中的批处理更新更偏向于一种低级的解决方案,它的实现为您进入其内部并控制事件提供了几个切入点。ADO.NET 批处理更新最棘手的部分是冲突解决。作者真心建议您尽可能将更多的时间用于测试、再测试。这种投资可通过命令生成器节省的所有时间来得到回报。
对话栏:数据表中的 Null 值
我从数据库提取数据集,一切顺利。然后我尝试将此数据集保存到 XML 文件,仍然很顺利。但将这个 XML 文件读回数据集时,问题出现了。这是因为,所有具有 NULL 值的列不能持久地保存到 XML 中。是否可以利用某种方法,使 NULL 值作为空标记添加到所得到的 XML?
这种行为是设计使然,是随着在 XML 序列化过程中保存几个字节这种最佳的意图引入的。如果这种行为发生在网络上(比如,在 XML Web 服务内),它所带来的优势会非常明显。
也就是说,可以用一个很简单的办法来解决您的问题。这个窍门就是,通过 ISNULL T-SQL 函数提取列。我们不使用以下代码:
SELECT MyColumn FROM MyTable
而应该使用:
SELECT ISNULL(MyColumn, '') FROM MyTable
在这种情况下,列的任何 NULL 值将自动变成空字符串,并且不会在数据集转换为 XML 的序列化过程中被忽略。非特定值不一定是空字符串。数值列可以使用 0 或任何其他您希望使用的逻辑空值。