使用 DataReader 来提高速度并减少内存使用
作者:Rick Dobson
相关技术:ADO.NET、C#、数据库开发
难度:★★★☆☆
读者类型:.NET开发人员、数据库开发人员
[导读]谈及数据库连接时,.NET的拥护者会力推数据适配器和数据集所提供的离线访问的优势。每每在这个时候,DataReader就会被这二者的光芒所掩盖。但是,正如Rick Dobson在此处演示的那样,DataReader绝非常物—它们提供对数据源的只进、只读连线访问,而且它们不支持数据操作。那么,为什么还要使用如此束缚人的东西呢?答案是“性能”,对于入门者来说,使用DataReader要快很多;另一个好处是占用的内存较少—DataReader可让您在获得数据的同时就对它进行处理,每次一行。所以,DataReader特别适用于处理过于庞大以致于无法加载到内存中的数据。
为了从DataReader获得最大的利益,您需要了解它的功能和限制。由于DataReader具有定义完善的限定,您还可以了解一下如何利用其他.NET实体(例如“数组”)来补充DataReader的功能,并从中获益。本文通过在三个范畴中各自的一些示例应用程序来回顾DataReader的功能。首先,我将展示生成、填充并配置DataReader以便用于Windows窗体控件的有效代码模式;第二,对示例突出说明了如何使用类型化数据来计算表达式,这将反映DataReader中列的数据类型的特性;最后,我将比较通过DataReader来检索分层数据的两种技术,来为本文划上句号。
从DataReader到列表框
您可以用指向数据源的DataReader来轻松地填充列表框。本部分的示例通常也应用于组合框控件。您应首先为Command对象创建DataReader并调用ExecuteReader方法,该方法通常是内建的。ExecuteReader方法可以接受CommandBehavior枚举来自定义Command的行为以及它所关联的DataReader。本部分中的两个示例突出了DataReader及其Command对象之间的相互作用,并提供了有关窗体和控件管理的其他有趣的应用程序的详细信息。请参阅HCVSDataReaders项目(源代码中的HVSO4-07Dobon.exe),以访问每个示例的所有代码。
显示原始的DataReader数据
HCVSDataReaders项目中的第一个DataReader示例是在Form1上Button1的Click事件中,ADONETObjects类中的两个方法和Form1后面的DataReaderForTable函数过程也在该事件中。为方便起见,ADONETObjects类驻留在HCVSDataReaders项目中。图1显示单击Populate from DataReader按钮后的窗体。按钮的Click事件过程用从SQL Server Northwind数据库中的Employees表选定的列值来填充列表框。
图1
SqlDataReader类有很多特殊方法,用于从各种专用的.NET和SQL Server数据格式中获取数据。不过,对于简单的应用程序来说,您无须考虑它们。所有DataReader都需要做的事情是,接受从任何非字符串数据类型到字符串的默认转换,然后将一个经过计算的字符串表达式添加到列表框。这就是下面的代码所要做的事情,它来自Button1_Click过程。一个While循环逐行读取,每次创建一个包含四个对drd1 DataReader的引用的str2表达式。这些引用中有两个是数字实例。值甚至可以为空(如2号雇员的ReportsTo列值)。不过,对每一行来说,该表达式都是成功的。您可以按名称或基于零的索引来指定列。
Do While drd1.Read
Dim str2 As String = _
"Employee " & drd1("EmployeeID") & _
", " & drd1("FirstName") & _
" " & drd1("LastName") & _
" reports to: " & drd1("ReportsTo")
ListBox1.Items.Add(str2)
Loop
第一个示例中最有趣的部分可能是如何先创建drd1 DataReader。Button1的Click事件过程将drd1创建为一个SqlDataReader类,并将我创建的名为ataReaderForTable的函数的返回值指定给它。它传递Employees表的名称—DataReaderForTable为其开发了一个DataReader。
Dim drd1 As SqlClient.SqlDataReader = _
DataReaderForTable("Employees")
DataReaderForTable过程创建DataReader的步骤有三个。
Dim drd1 As SqlClient.SqlDataReader
Dim ADOObjs As New ADONETObjects
'Specify connection object
Dim cnn1 As SqlClient.SqlConnection = _
ADOObjs.MakeNorthwindConnection
'Specify a command object
Dim str1 As String = _
"SELECT * FROM " & TableName
Dim cmd1 As _
SqlClient.SqlCommand = _
ADOObjs.MakeACommand(cnn1, str1)
'Open cnn1 and create the drd1 DataReader
'with the ExecuteReader method cnn1.Open()
drd1 = cmd1.ExecuteReader _
(CommandBehavior.CloseConnection)
Return drd1
首先,它用我的ADONETObjects类的MakeNorthwindConnection方法创建一个到Northwind数据库的连接。其次,我为DataReader创建一个Command对象。DataReaderForTable过程将两个参数传递到我的ADONETObjects的MakeACommand方法,以返回一个新的Command对象。这些参数用于为传递到DataReaderForTable过程的TableName参数中的所有行提取所有列的SQL语句,以及MakeNorthwindConnection方法返回的Connection对象。在第三步中,该过程用ExcecuteReader方法为Command对象实际创建DataReader。使用CommandBehavior.CloseConnection枚举,可以使Button1_Click过程关闭返回到它的DataReader,而无须操作关联的Connection对象。这是因为枚举指示.NET Framework在DataReader关闭时自动关闭Connection对象。DataReaderForTable过程通过返回实例化的DataReader来结束。顺便提一下,DataReaderForTable过程有一个共享访问模式声明,以便整个HCVSDataReaders项目的其他模块中的过程可以调用它。
处理DataReader数
至少可以在两个方面改进ListBox1的内容。第一,没有EmployeeID值来指示Andrew Fuller向谁报告。这不是一个错误,因为它不向列表框中的其他成员报告。但是,空白仍可能会使人产生困惑。第二,ListBox1按照经理的EmployeeID来指定雇员的经理。通过用经理的姓来替代其EmployeeID,可以提高ListBox1内容的可读性。Button2_Click过程以使用Button1_Click过程处理这两种问题的方法来填充ListBox1。图2显示在单击Populate from array按钮后改进的输出。名为Andrew Fuller的雇员的行表明他在列表中没有主管。ListBox1中所有其他雇员的项显示主管的姓而非EmployeeID。
图2
将主管的EmployeeID列值转换为姓时遇到的主要难题之一是,DataReader一次只分析某个雇员的一行。为了转换主管的EmployeeID列值,应用程序需要将每个EmployeeID值都链接到姓。通过将来自DataReader的值存储到字符串的数组中,过程可以查找与EmployeeID值相匹配的姓。(当然,这一特定问题也可以通过在查询中创建一个更复杂的Select语句来解决,但是,就演示将数组与DataReader配合使用而言,我将为您展示如何在客户端解决这个问题。)以下Button2_Click过程的代码片段显示如何用来自drd1 DataReader的值填充字符串值的MyEmps数组,这是以Button1_Click中的同一方法定义的。
Const RowsCount As Integer = 99
Dim MyEmps(RowsCount, 3) As String
Do While drd1.Read
If int1 <= RowsCount Then
For int2 = 0 To drd1.FieldCount() - 1
Select Case drd1.GetName(int2)
Case "EmployeeID"
MyEmps(int1, 0) = drd1(int2)
Case "FirstName"
MyEmps(int1, 1) = drd1(int2)
Case "LastName"
MyEmps(int1, 2) = drd1(int2)
Case "ReportsTo"
'ToString method forces conversion --
'even for DBNull value to string
MyEmps(int1, 3) = drd1(int2).ToString
End Select
Next
int1 += 1
Else
MessageBox.Show( _
"Reset RowsCount to a larger number and re-run.", _
"Terminal Error Message", _
MessageBoxButtons.OK, MessageBoxIcon.Exclamation)
Exit Sub
End If
Loop
MyEmps数组有4列,用于储存EmployeeID、FirstName、LastName和ReportsTo列值。对于Northwind数据库的默认行数(9行)来说,其最大的行数规范绰绰有余。阅读行的While循环中具有执行各种任务的代码。For循环可循环访问所有列值,以便为MyEmps中的存储选择DataReader列值的一个子集。FieldCount属性返回DataReader的列数。Select...End Select语句用GetName方法检查DataReader的列名称,以标识要将列值存储到哪个MyEmps列。除了ReportsTo列值以外,这段代码将应用默认的Visual Basic .NET转换技术,将SQL Server数据格式转换为MyEmps数组中的.NET字符串格式。由于ReportsTo列可以包含空值(DBNULL),因此这个过程必须显式指定的ToString方法,以将DBNULL强制为字符串值—即空字符串("")。在收集MyEmps数组中的所有drd1列值以后,Button2_Click将关闭DataReader并释放这些资源。下面显示的主代码片段将依次通过MyEmps的行来计算字符串表达式,以便显示在ListBox1中。该代码再一次顺序通过MyEmps数组来查找匹配ReportsTo列值的LastName列值,而非只显示行的原始ReportsTo列值。在进入循环以将ReportsTo列值解码为LastName列值之前,该代码会确定第4列中的ReportsTo值是否为空字符串。
For int1 = 0 To 99
If MyEmps(int1, 0) <> "" Then
If MyEmps(int1, 3) <> "" Then
strSupvrEmpID = MyEmps(int1, 3)
For int2 = MyEmps.GetLowerBound(0) To _
MyEmps.GetUpperBound(0)
If MyEmps(int2, 0) = strSupvrEmpID Then
strEmpID = MyEmps(int2, 2)
Exit For
End If
Next
Else
strEmpID = " no one in list box"
End If
str1 = "EmployeeID" & MyEmps(int1, 0) & _
", " & MyEmps(int1, 1) & " " & _
MyEmps(int1, 2) & " reports to: " & _
strEmpID
ListBox1.Items.Add(str1)
Else
Exit For
End If
Next
处理类型化数据
Form1的应用程序将每个DataReader列的内容转换为一个字符串值,而不考虑数据源中列的基础数据类型是什么。有时您需要使用原始数据类型,比如当您需要对列值执行数值或数据算法时。如果您还不了解基础数据类型,那么在表达式中使用它们之前,您需要使用一种技术来找出原始数据类型。
报告列名称和数据类型
Form2中的Button1_Click过程演示了一种写出任何DataReader的列名称和数据类型的技术。尽管.NET为此任务提供了其他方法,但该技术构建于您对DataReader的了解以及如何将它们与数组一起使用的基础之上。该过程首先根据Form1中的DataReaderForTable过程,为Northwind数据库中的Orders表创建DataReader。由于DataReaderForTable过程是用共享访问模式声明的,因此Form2可用以下代码调用:
Dim drd1 As SqlClient.SqlDataReader = _
Form1.DataReaderForTable("Orders")
您还需要一个数组来保存drd1 DataReader的列名称和数据类型。数组列将保存为drd1 DataReader指定列的名称及其数据类型的字符串值。下面的代码显示了如何应用Array类的CreateInstance共享方法,以创建一个名为OrdersColNamesTypes的数组。在drd1 DataReader中有多少列,这个数组就有多少行,另外还有两个列。For循环可循环访问DataReader的列,以便用列名称和数据类型元数据填充该数组。SetValue方法为数组元素指定值。您可以从上述示例了解如何使用GetName方法返回列名称。这个过程阐释了如何应用GetDataTypeName方法来恢复DataReader中列的原生数据类型名称。
Dim OrdersColNamesTypes As Array = _
Array.CreateInstance(GetType(String), _
drd1.FieldCount, 2)
For int1 As Integer = 0 To drd1.FieldCount - 1
OrdersColNamesTypes.SetValue _
(drd1.GetName(int1), int1, 0)
OrdersColNamesTypes.SetValue _
(drd1.GetDataTypeName(int1), int1, 1)
Next
图3
如您在图3中看到的那样,Form2上的Button1_Click过程的最后代码片段只是依次通过OrdersColNamesTypes数组中每个连续行的列值,并将列名称和数据类型打印到“Output”窗口。图中的报表表明Order表有14列,Order表的第一列名为OrderID,数据类型为SQL Server int。其他列包含变化和固定长度的字符串数据类型(nvarchar和nchar)以及datetime和money数据类型。
执行算法
对DataReader列值执行算法的窍门是,将它们保存为与其原生数据库数据类型相匹配的Visual Basic .NET数据类型。不过,数组会将所有元素成员强制转换为同一类型。使用数组存储DataReader的值,但仍保持数据源数据类型的一种方法是,将DataReader列值保存到一个具有Object数据类型元素的数组中。从本质上说,这个过程将DataReader列值“装箱”为(而非将其强制转换为)另一种数据类型的Object实例。稍后,您可以通过将数组元素指定给用适当数据类型声明的变量,来恢复基本的基础数据格式。从本质上说,这个指定取消装箱已封装了的数据类型。Form2之后的代码包括一个过程—PopArray,它将DataReader列值装箱到一个带有Object元素的数组中。如果您对这个过程的详细信息感兴趣,请查看HCVSDataReaders项目中的PopArray列表。在本文中,PopArray过程的一个主要目的是,用Windows应用程序中的Orders表的列值来演示integer和datetime算法。Form2的Button2_Click过程有两个主代码片段。第一个演示了如何计算Orders数组中第一行和最后一行OrderID列值之间的差,这将镜像化Northwind数据库中的Orders表。在开始执行第一个主代码片段之前,该过程会调用PopArray过程来填充Orders数组,正如您所知道的,Orders表有830行。对名为int1和int2的两个变量的指定为Orders数组第一列中的第一行和最后一行取消装箱了的OrderID列值。WriteLine方法的参数包括一个从其他Integer变量中减去一个Integer变量的简单表达式。
Dim Orders As Array = PopArray("Orders", 830)
Dim int1 As Integer = _
Orders(Orders.GetLowerBound(0), _
Orders.GetLowerBound(1))
Dim int2 As Integer = _
Orders(Orders.GetUpperBound(0), _
Orders.GetLowerBound(1))
Console.WriteLine(ControlChars.CrLf & _
"An example with integer arithmetic:")
Console.WriteLine( _
"There are {2} order numbers between " & _
"the first order number({0}) and the " & _
"last order number({1})", _
int1, int2, int2 - int1)
Button2_Click的第二个主代码片段对Orders数组第一行中的ShippedDate和RequiredDate列值执行datetime算法。这段代码将两列取消装箱为Date数据类型,而不是将Object元素取消装箱为Integer数据类型的变量。您可以交替使用Date和Datetime关键字,在Visual Basic .NET中指定datetime值。DateDiff函数计算两个datetime变量之间的天数差。Console类的WriteLine方法将结果显示在“Output”窗口中。
'Demonstrate arithmetic with dates
Dim datRequired As Date = Orders(0, 4)
Dim datShipped As Date = Orders(0, 5)
Console.WriteLine(ControlChars.CrLf & _
"An example with date arithmetic")
Console.WriteLine( _
"Required date({1}) - ShippedDate({0}) " & _
"= {2} days", _
datShipped.ToString("M/d/yyyy"), _
datRequired.ToString("M/d/yyyy"), _
DateDiff(DateInterval.Day, datShipped, _
datRequired))
生成分层数据
对应用程序而言,对分层数据(如属于某个订单的行项)的需求使用是很常见的。最后的两个示例展示了两种通过DataReader返回分层数据的方法。一种方法演示了如何使用专用的MSDataShape提供程序。第二种方法在本文前面所演示的工具的基础上,使用了更多常规工具。另外,通过在相关表中添加值的查找功能以及阐释datetime和currency值的格式化语法,可以使第二种技术建立在第一种之上。
使用MSDataShape provider
正如我之前指出的,MSDataShape provider是一种用于返回分层数据的专用provider。这个provider要回溯到Visual Basic 6,但Microsoft发表了一篇知识库文章,描述如何在Visual Basic .NET和ADO.NET中使用MSDataShape provider(http://support.microsoft.com/default.aspx?scid=kb;[LN];308045)。虽然MSDataShape provider在返回分层结果集方面格外有效,但它依赖于SQL的子集以及专用关键字和其他语法约定。另外,这个provider不能与.NET SQL Server data provider一起使用。甚至在您处理SQL Server数据库的时候,将被迫改为使用OleDb .NET data provider。使用MSDataShape provider建立到数据库的连接与使用其他的略有不同。下面的代码来自Form3中的Button1_Click过程。请注意,该代码在OleDb命名空间中指定了一个Connection对象。尽管服务器、集成安全性和初始目录的最后3个参数与SqlConnection对象连接字符串的那些参数一样,但最初的两个参数截然不同。最前面的参数指定了MSDataShape provider,该provider与第二个参数中指定的SQLOLEDB data provider协同工作。
New OleDb.OleDbConnection( _
"Provider=MSDataShape;Data Provider=SQLOLEDB;" & _
"server=(local);Integrated Security=SSPI;" & _
"Initial Catalog=northwind")
接下来的3段代码块阐释了指定Command对象的语法,该对象基于Northwind数据库的Orders表和Order Details表生成分层结果集。
Dim cmd1 As OleDb.OleDbCommand = _
New OleDb.OleDbCommand( _
"SHAPE {SELECT OrderID, OrderDate " & _
"FROM Orders " & _
"WHERE OrderID=" & TextBox1.Text & "} " & _
" APPEND ({SELECT OrderID, ProductID, " & _
"UnitPrice, Quantity, Discount " & _
"FROM [Order Details]} " & _
" RELATE OrderID TO OrderID)", cnn1)
cnn1.Open()
Dim drd1 As OleDb.OleDbDataReader = _
cmd1.ExecuteReader(CommandBehavior.CloseConnection)
drd1.Read()
Console.WriteLine("{0}, {1}", _
drd1(0), drd1(1))
Dim drd2 As OleDb.OleDbDataReader = drd1(2)
Do While drd2.Read
Console.WriteLine("{0}, {1}, {2}, {3}, {4}", _
drd2(0), drd2(1), drd2(2), drd2(3), drd2(4))
Loop
请注意专用关键字SHAPE、APPEND和RELATE。SHAPE子句的SQL语句指定主结果集的行。这个语句引用TextBox1的Text属性,该属性应该始终指定有效的OrderID列值。APPEND子句的SQL语句指定分层结果集的明细成员的结果集。RELATE子句指示在哪些列上匹配主数据源和明细数据源中的行。在实例化Command对象后,代码将通过打开cnn1 Connection对象来准备生成几个DataReader。drd1 DataReader从主数据源返回数据,drd2 DataReader从明细数据源提取数据。主数据源的Console.WriteLine语句打印主数据源的前两个列值,它们是OrderID和OrderDate。明细数据源的Console.WriteLine语句打印Order Details表的所有行,其OrderID匹配TextBox1中显示的值。
图4
图4 显示在单击Shape按钮后的Form3。窗体下的“Output”窗口表示分层结果集。第一行显示主数据源的行,包括OrderID和OrderDate列值;接下来的3行显示OrderID值为10248的订单的明细行项目;第二列和第三列是用于ProductID和UnitPrice列值的。打印ProductID列值(而非ProductName列值)使得辨别每个行项目引用了哪个产品变得更困难。此外,从输出不能明显看出UnitPrice列值是货币值。
用常规工具返回分层结果集
返回分层数据的第二个示例依赖于常规工具,如那些已经在本文中展示过的工具的改编本。第二个示例的详细代码显示在Button2_Click过程中,以及HCVSDataReaders项目的Form3模块中名为ComputerArrayIndex的相关过程中。返回分层数据的第二种方法基于Northwind数据库中的Orders、Order Details和Products表的关联DataReader,创建了三个数组。以这种方法使用数组可以减少数据库服务器上的负载,这是因为它允许应用程序关闭DataReader及其到数据源的关联Connection。下面的代码片段来自Form3模块中的Button2_Click,它阐释了用来生成Orders数组的方法。Form2中的PopArray过程已经在前面简要描述过了。它基于Northwind数据库的DataReader生成数组,您将要阅读的最大行数以及表名称也会被传递给它。顺便说一下,PopArray过程在填充数组后会关闭它的DataReader。ComputeArrayIndex过程从一个二维数组(如Orders)的第一列生成一个一维数组—IdxOrders。
Dim intMaxOrdersRows = 830
Orders = Form2.PopArray("Orders", _
intMaxOrdersRows)
IdxOrders = ComputeArrayIndex(Orders, _
intMaxOrdersRows)
一维索引数组可以加速二维数组中行的查找,其速度快于在二维数组中扫描所有行,以查找匹配某个条件的值。这是因为Visual Basic .NET为它的Array类提供了一个IndexOf共享方法,该方法可返回与一维数组中的某个值相对应的索引。下面的代码示例显示了将此方法用于IdxOrders数组以便从Orders数组恢复OrderID和OrderDate列值的语法。该代码片段还设置了OrderDate列值的格式,以排除datetime值的不相关时间段。
Dim intIdx As Integer = _
Array.IndexOf(IdxOrders, _
Integer.Parse(TextBox1.Text))
Console.WriteLine("{0}, {1}", _
Orders(intIdx, 0), _
DateTime.Parse( _
Orders(intIdx, 3)).ToString("M/dd/yy"))
图5
图5显示图4中出现的OrderID值在Button2_Click过程中的最终输出。请注意,此过程执行对ProductID值的查询,并改为显示ProductName列值。基于ProductID列值恢复ProductName列值的查找逻辑,是本文第二个示例中基于ReportsTo列值查找LastName列值的代码以及上述代码片段的扩展。将UnitPrice的格式设置为货币值的方法只需调用常见的FormatCurrency函数。虽然您可以使用更为可靠的方法来设置货币值的格式,但知道Visual Basic .NET支持常见且易于使用的FormatCurrency函数也是很好的。
小结
对于对远程数据源的数据访问来说,DataReader是一种快速、灵活且强大的工具。本文突出说明了.NET应用程序中DataReader的3个特定类型的应用程序,实际上还有许多其他的应用程序。在您的自定义解决方案中使用DataReader可以使这些方案运行得更快,甚至还可能会加强您的.NET基本开发技能。通过将DataReader与数组协同使用,您通常能够从其获得附加价值。