65.9K
CodeProject 正在变化。 阅读更多。
Home

ADO+.NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (26投票s)

2013年4月11日

CPOL

39分钟阅读

viewsIcon

65239

downloadIcon

1194

此库旨在通过解决类型不匹配问题并通过创建比DataTable更快、更易于使用的数据读取器来“替换”ADO.NET。

背景

我个人不喜欢直接使用数据库,所以我总是使用或创建某种ORM。另一件让我讨厌的是受数据库类型限制,这就是为什么我通常会避免使用现成的ORM而创建我自己的ORM:支持用户定义的类型(在.NET中),而无需更改它们(无需实现特定的接口或基类)。

当ORM能完成所有工作时,这是很棒的,但如果ORM无法执行某种类型的查询怎么办?我最终需要使用正常的ADO.NET调用,需要手动完成所有必需的转换。所以,我决定是时候做出一些新的东西了。

如果您更喜欢通过其使用来理解这个库,可以直接跳到 基本用法 主题。

这个库是做什么的?

在解释如何使用这个库之前,了解它真正做什么很重要。

  • 将读取器用作类型化的枚举器。而不是移动到下一行然后使用GetInt32GetString等按索引读取数据,而是填充一个完整的实例。并且,由于它是可枚举的,所有适用于可枚举对象的构造,包括foreach和内存中的LINQ方法都可以使用。
  • 可以使用类型化的对象填充查询的所有参数。也就是说,如果您的类有3个属性,它将使用这3个属性,并具有正确的类型,来填充同名的参数。
  • 能够使用用户定义的.NET类型,而无需更改类型本身(也就是说,类型不需要实现接口或基于特定的基类)。
  • 自动缓存最近准备好的命令以更快地重用(这是可配置的);
  • 支持递归事务——也就是说,您可以在一个事务已激活时创建一个新事务,而不会丢失外部事务的“全部或无”结果。

注意

我最初开始将此作为库的文档编写,但由于我总是以文章的形式呈现内容,所以我对其进行了一些调整,使其看起来像一篇文章。不幸的是,目前,它是一篇关于如何使用库而不是如何创建库的文章。但我确实认为它足够有用,可以按原样使用,只需根据需要添加自己的映射器即可,所以,不妨试试。

为什么这个库比直接使用ADO.NET更好?

也许您已经认为这并不算什么了不起。您可以使用DataTable,您知道如何准备命令,如何控制事务,如果性能是一个问题,您可以使用DataReader。即使它不是真的惊人,也很容易填充一个对象,比如

SomeType record = new SomeType();
record.Id = reader.GetInt32(0);
record.Name = reader.GetString(1);
return record;

那么,拥有一个库来自动化这一点有什么优势呢?

嗯,有很多,但它们可能不那么直接可见。将类型化对象集合与DataTable进行比较,我们拥有通过智能感知访问所有属性的优势,如果我们访问对象时输入错误,我们将得到一个编译时错误而不是运行时异常,我们不需要将视为object的值转换为正确的类型,因为它们已经是类型化的,这还可以避免装箱并比DataTable消耗更少的内存。

所以,显然这种方法胜过DataTable。但正如我已经展示过的,使用DataReader填充对象并不难……所以,让我们看看一些使用DataReader填充对象的“常规做法”,以充分理解为什么您可能想使用这个库。

List<SomeType> result = new List<SomeType>();
while(reader.Read())
{
  var record = new SomeType();
  record.Id = reader.GetInt32(0);
  record.Name = reader.GetString(1);
  result.Add(record);
}
return result;
List<SomeType> result = new List<SomeType>();
while(reader.Read())
{
  var record = new SomeType();
  record.Id = Convert.ToInt32(reader[0]);
  record.Name = Convert.ToString(reader[1]);
  result.Add(record);
}
return result;
List<SomeType> result = new List<SomeType>();
while(reader.Read())
{
  var record = new SomeType();
  record.Id = reader.GetInt32("Id"); // this is usually created as an extension method.
  record.Name = reader.GetString("Name"); // this is usually created as an extension method.
  result.Add(record);
}
return result;
List<SomeType> result = new List<SomeType>();
while(reader.Read())
{
  var record = new SomeType();
  record.Id = Convert.ToInt32(reader["Id"]);
  record.Name = Convert.ToString(reader["Name"]);
  result.Add(record);
}
return result;

现在,让我们关注这些实现的区别。前两个实现使用列索引,而后两个实现使用列名。在许多良好实践文档中,我们会看到作者建议使用名称,这样查询就可以更改为包含列或删除列,而我们无需调整所有索引,只需添加或删除相应的字段即可。这种优势对于两个字段来说可能不太有用,但对于30个字段/列来说,获取正确的索引可能会变得更加困难。

但我们不应止步于此。您是否注意到第一个和第三个实现使用reader.GetInt32()reader.GetString(),而第二个和第四个实现直接通过索引器读取值,然后进行Convert.ToInt32()Convert.ToString()。使用Convert的那些就是更糟糕的实现吗?

答案是否定的。有些数据库不保留列的确切类型。您可能会写入一个int,但在读取时,它被视为decimal,而GetInt32()可能会抛出异常。例如,SQL Server接受一个char作为参数存储在NCHAR(1)列中,这让我感到惊讶,但如果我使用GetChar()方法读取该值,它会抛出异常,迫使我使用GetString()[0]或使用带Convert解决方案的索引器来获得相同的结果。但是,使用索引器,可以保证读取值,而不论数据库使用的格式如何,然后将进行转换以获得正确类型的结果。因此,它更能适应来自不同数据库的各种结果。

那么,使用索引器将值读取为object然后使用Convert更好吗?嗯,在这种情况下,是的,但对大多数情况而言,它只是速度较慢。在找到真正解决方案之前,让我们回到名称或索引方法。我们应该记住,通过列名读取列值需要“搜索”该列。对一些行(例如100行)执行此操作不成问题。但对于1000万行呢?

所以,一个更好的解决方案,结合了列名和列索引,可能是其中之一

List<SomeType> result = new List<SomeType>();
int idIndex = reader.GetOrdinal("Id");
int nameIndex = reader.GetOrdinal("Name");
while(reader.Read())
{
  var record = new SomeType();
  record.Id = reader.GetInt32(idIndex);
  record.Name = reader.GetString(nameIndex);
  result.Add(record);
}
return result;
List<SomeType> result = new List<SomeType>();
int idIndex = reader.GetOrdinal("Id");
int nameIndex = reader.GetOrdinal("Name");
while(reader.Read())
{
  var record = new SomeType();
  record.Id = Convert.ToInt32(reader[idIndex]);
  record.Name = Convert.ToString(reader[nameIndex]);
  result.Add(record);
}
return result;

在这些示例中,我们只读取一次列索引,然后使用它们。但是,您可以想象,如果我们有更多列(例如30个),我们将添加30行来获取索引,并且仍然需要30行来使用这些索引进行实际读取。这开始变得很难看了,您不觉得吗?而且我们仍然面临着使用一个版本,它速度较慢且在数据库类型不同时更安全,以及一个版本,它速度更快但对数据类型更改的抵抗力较差的问题。

在展示解决方案之前,我将继续展示一些问题。其中一个问题是我到目前为止使用了列表来存放所有结果。我这样做是因为我在许多不同的系统中看到了很多返回对象的情况,以避免将datareader返回给用户。返回列表的问题是

  • 如果我们正在进行非常大的导入,其中只需要一次处理一个记录,最终将所有记录都放在内存中……或者导致OutOfMemoryExceptions,因为列表将从开始到结束都存在于内存中;
  • 如果我们使用完一小部分记录(例如10%)后执行break,我们仍然浪费了读取所有记录并将它们放入内存的时间。想想LINQ的FirstOrDefault()方法:它只会从列表中读取一条记录,但返回列表的方法已经浪费了读取所有记录的时间。

所以,一个更好的解决方案是创建一个枚举器。由于yield return,这可以非常简单

int idIndex = reader.GetOrdinal("Id");
int nameIndex = reader.GetOrdinal("Name");
while(reader.Read())
{
  var item = new SomeType();
  item.Id = Convert.ToInt32(reader[idIndex]);
  item.Name = Convert.ToString(reader[nameIndex]);
  yield return item;
}

使用yield return,我们得到了更小的代码。而且,如果有人想将结果作为列表、数组或类似格式获取,始终可以使用ToList()等方法在该可枚举对象上获得列表。所以,如果它如此简单,为什么我一开始就展示了错误的代码呢?

答案是:许多程序员还没有习惯yield return,并且仍然生成列表。当一个库已经以正确的方式进行操作时,即使它有一定的开销,您通常也会受益于代码几乎像理想代码一样快地执行。这比有时性能最好……但有时性能极差的代码要好。而这通常是用户每次都需要编写填充对象的代码时发生的情况。

但是我们还没有解决完所有问题。我提到了SQL Server的问题,在这种情况下,我无法像读取char那样读取char,迫使我将其读取为string然后获取char。但是,在这种情况下,我仍然可以用char值填充参数。但对于不支持某些datatype的数据库怎么办?这在bool类型中尤其常见。

所以,而不是写像

// To write
parameter.Value = item.IsActive;

// To read
item.IsActive = reader.GetBoolean(columnIndexOrName);

我们可能不得不写像

// To write
if (item.IsActive)
  parameter.Value = 1;
else
  parameter.Value = 0;

// To read
item.IsActive = reader.GetInt32(columnIndexOrName) != 0;

但这并不是一个好的解决方案,因为我们可能会使用两个数据库,一个支持布尔值,另一个不支持布尔值,而我们希望保持支持布尔值的那个。所以,我们可能最终会生成辅助方法(或扩展方法),如下所示

public static void SmartSetBoolean(this IDbDataParameter parameter, bool value)
{
  if (someInfoFromTheActualConnection)
  {
    if (value)
	  parameter.Value = 1;
	else
	  parameter.Value = 0;
  }
  else
  {
    parameter.Value = value;
  }
}
public static bool SmartGetBoolean(this IDbReader reader, int/*or string...*/ columnIndex)
{
  if (someInfoFromTheActualConnection)
    return reader.GetInt32(columnIndex) != 0;

  return reader.GetBoolean(columnIndex);
}

然后,在每个写入布尔值的地方,我们需要执行类似的操作

parameter.SmartSetBoolean(item.IsActive);

然后,在每个读取布尔值的地方,我们需要执行类似的操作

item.IsActive = reader.SmartGetBoolean(columnIndex);

但是,如果您一开始就没有计划支持其他数据库,您可能已经使用了错误的读取/写入方法,因此,如果存在300个表,这可能会成为一个非常麻烦的更改。如果您从一开始就使用了为处理这类问题而准备的解决方案,即使您当时没有需要支持多个数据库,当这种更改成为必须时,您也将避免问题。

此外,每次读取时进行测试会再次降低速度。测试越多,支持的数据库越多,速度就越慢。

那么,有解决方案吗?

是的。正如我们可以在进入while循环之前只获取一次列索引,然后继续使用这些索引一样,我们也可以只确定一次数据库是否使用真正的布尔值,并存储一个delegate来执行读取,而无需任何额外的验证。但这手工完成将使代码更加难看(并且容易出错)。

这就是为什么您可能想使用这个库。打开连接时,它将识别ADO.NET驱动程序,并为该连接创建正确的“映射”。也就是说,如果它知道不支持布尔值,它将把布尔值映射到其他东西,然后当进行读取时,它不需要“再次检查”如何读取布尔值。已经有一个delegate来执行正确的读取。因此,与其为每列或每次执行进行测试,不如只在打开连接时进行一次测试。

这种初始映射对于其他情况也有效,例如将int转换为decimal,因此它已经注册了一个映射,将int作为decimal读取(使用GetDecimal()方法,避免装箱),并且将执行类型转换(转换操作,而不是解箱操作)。这比将值作为object读取然后使用Convert类要快,并且还支持Convert类未执行的转换(例如,将布尔值转换为字符0和1,以及将字符0和1转换为布尔值)。

然后,在准备命令时,它按名称获取每个列的索引,并构建一个委托以按索引读取列并使用正确的类型设置属性(再次避免装箱)。设置值不使用反射,反射仅用于生成delegate,该delegate将具有完全的执行速度,因此,如果您读取一行或一百万行,您将只执行一次列到属性的映射。

映射的思路不止于此,因此您可以生成“映射器”来处理数据库不支持的类型。也就是说,您可以将Color映射到int,将Point映射到long,如果还有另一个映射(从intlongdecimal),那不是您的问题,它将完成。这意味着您不限于只使用所有数据库都支持的类型。您可以使用您想要的任何类型,只要它们可以转换为另一种类型(最坏的情况下,几乎所有类型都可以转换为string,但这可能会导致一些在限制主题中讨论的问题),并且您只需要注册一次转换。一个额外的优点是,类型本身不需要实现接口或基类,因此您可以转换您对象中使用的第三方类型的。

此刻,我希望您喜欢这个想法。我可以肯定地说,我曾将性能与Dapper进行了比较,并且这段代码的性能优于它(即使我调用了更多的虚拟调用,我也避免了装箱,而dapper没有)。然而,这只是为了告知您,如果您确实关心性能。Dapper不会解决与数据库类型不兼容的问题,但这个库肯定比Dapper更大。所以,这是您的选择。

事务

我考虑过的另一个非常令人头疼的问题是事务在ADO.NET中的工作方式。

理论上,我们可以在同一个连接中并行创建三个事务,并且每个命令都可以绑定到不同的事务。但大多数数据库不支持每个连接有一个以上的事务,因此,如果确实需要并行事务,通常会使用第二个连接。问题在于,如果您创建一个事务而您的命令未使用它,那么某些数据库将不会报错。实际上,这是最糟糕的错误之一,因为您创建了事务,执行了命令,提交了,然后检查数据是否存在。问题只有在发生异常时才会出现,在这种情况下,您将回滚,但执行的命令不会被回滚,因为它们未使用该事务。

我可以继续说还有很多其他问题。例如,您创建一个方法,如InsertRecord。它启动并正确使用事务。但是,然后,您想导入1000条记录。因此,在每次调用InsertRecord时,您不应该允许创建事务(因为这会在某些数据库中引发异常,或者在其他数据库中创建一个完全独立的事务)。

因此,一个最初创建自己事务的方法可能需要更改为接收外部事务。如果该方法能够接收事务或创建自己的事务,这已经是一个令人头疼的情况,因为人们可能会忘记传递事务。此外,以这种方式执行使用事务的方法将大大增加所需的代码量。

您对此有何看法?

void Insert1000Record(DatabaseConnection connection)
{
  using(var transaction = connection.StartTransaction())
  {
    for(int i=0; i<1000; i++)
      InsertRecord(connection, i);

    transaction.Commit();
  }
}

void InsertRecord(DatabaseConnection connection, int recordNumber)
{
  using(var transaction = connection.StartTransaction())
  {
    var command = connection.CreateCommand();
    var parameter = command.Parameters.Add("Number", personNumber);
    command.CommandText = "INSERT INTO Record (Number) VALUES (" + parameter.PlaceHolder + ")";
    using(var preparedCommand = command.PrepareNonQuery())
      preparedCommand.Execute();

    Log("InsertRecord " + personNumber);
    // Please, don't say I shouldn't do the log here, as this is a 
    // cross-cutting concern... I know that but I simply wanted to
    // have 2 things to be done inside the same transaction.

    transaction.Commit();
  }
}

要理解这段伪代码。启动一个事务,然后执行1000次InsertRecord的调用。但是,在InsertRecord方法内部,它再次启动一个事务,插入记录并保存日志。如果在插入第500条记录时发生异常,会发生什么?记录已提交还是未提交?毕竟,InsertRecord方法内部有一个transaction.Commit()

嗯,在这个库中实际发生的是内部事务实际上并没有提交。它们的Commit()调用只是告诉外部事务在该内部事务中没有问题。如果内部事务未提交,则外部事务无法提交。尝试提交外部事务而内部事务未提交会引发异常。

所以,任何时候您需要事务,只需调用StartTransaction()。事实上,它不会每次都创建一个新对象,它只会增加/减少内部事务的计数。有了这个,您可以随时创建事务,只要您有2个或更多的动作应该是事务性的,即使您不知道这些动作是否会成为另一个事务的一部分。此外,您永远不需要为命令设置事务。如果您为连接启动了事务,所有执行都将使用该事务。更简单,不是吗?

驱动程序、工厂、连接和类型映射

ADO.NET驱动程序并不是真正非常标准化。有些使用参数占位符为@ParameterName,有些使用参数占位符为:ParameterName。此外,ParameterName本身也是一个问题,因为有些驱动程序期望@:是参数名称的一部分,而有些则不是。

此库不解析查询或修正它们,但它试图提供一种简单的方法来获取占位符和参数的正确名称。创建Parameter时,必须提供不带占位符信息的参数名称。也就是说,ParameterNameParameterName,而不是@ParameterName。驱动程序会自动修正IDbParameter的名称以包含任何必要的修改。但由于您仍然需要创建查询,因此您可以从创建的参数的属性中获取PlaceHolder。因此,如果占位符是@ParamenterName:ParameterName甚至是{ParameterName},只要驱动程序提供它,您就可以从创建的参数中获取它。

驱动程序?什么驱动程序?

我所说的驱动程序不是ADO.NET驱动程序,而是为此库制造的驱动程序。实际上,您可以创建自己的驱动程序并直接在应用程序中注册它们,并且可以选择使用“default”驱动程序(即,只有基本映射,并将参数名称和占位符用作@,因为这是最常见的方法)。如果您需要或想实现自己的驱动程序,请实现IDatabaseDriver接口。在其中,您必须提供按连接字符串打开连接的方法,创建正确的IDbDataParameter的方法,以及返回未修改的名称(如ParameterName)如何适配为IDbDataParameter.Name以及如何创建占位符的方法。

Factory

工厂绑定到驱动程序,我们可以说它是驱动程序的用户视图。默认情况下,给定名称的工厂将使用同名的驱动程序。驱动程序是“低级别”的,与ADO.NET紧密相关。工厂不是。工厂是ADO+.NET抽象,它允许您打开类型化的数据库连接,并允许您进行工厂级别的配置。最重要的是工厂级别的配置是类型映射。在这里,您可以指定如何为该工厂创建的所有连接进行到intboolean或任何其他类型的转换。驱动程序本身可以注册基本转换(我提供的所有驱动程序都支持相同的数据库原始类型)。但是,由用户注册应用程序相关的任何转换。这就是为什么不同的数据库可以具有不同的转换。您可以为Firebird工厂注册从布尔值到int的转换,但实际上为SqlServer工厂保留布尔值作为布尔值。

Connection

ADO+.NET连接与ADO.NET连接非常相似(实际上,它在内部使用一个)。区别在于,由此连接创建的命令是类型化的命令(实际上,您可以使用foreach而不是调用ExecuteReader()然后处理该读取器)。在连接本身中,您还可以进一步配置类型映射。这样做的目的是让同一驱动程序的两个数据库(例如,Firebird)使用不同的数据类型转换。这在从旧模式导入数据到新模式时可能非常有用。也许在布尔值之前是一个int,现在它是一个char。谁知道您的具体需求?

类型映射

正如您可能已经注意到的,一些类型映射已经在默认驱动程序中完成,而一些则需要您来完成。

所有类型映射都是通过专用于单个数据类型的类完成的。有一些事件,如果您有能够生成许多转换的代码(例如,这用于枚举和可空类型),您可以即时创建类型映射,但类型映射实例始终绑定到单个转换。这是为了性能和简洁。

您可能需要使用两个主要的类来创建自己的类型映射。如果您创建的类型映射器知道如何直接从数据读取器和数据库参数获取和设置值,那么将使用DataTypeMapper泛型类。这是驱动程序用于数据库直接支持的类型的。但用户更有可能想要使用UserTypeMapper类。使用它,您可以指定如何将.NET类型(数据库不支持)转换为.NET类型(数据库已支持)。例如,将Point转换为long,其中数据库支持long但显然不支持Point

ADO+.NET原始类型

在这里,我们不是谈论.NET原始类型或所有数据库真正支持的类型。在这里,我们谈论的是所有ADO.NET驱动程序(无论是文章附带的,还是您决定创建的)都预期支持的类型。它们之所以是原始类型,是因为通过支持所有这些类型,可以预期任何用户映射都能正常工作,因为用户映射通常是基于这些原始类型之一。因此,这些原始类型是

  • bool
  • byte
  • byte[]
  • char
  • 日期
  • 日期时间
  • decimal
  • double
  • Guid
  • ImmutableArray<byte>
  • Int16(short)
  • Int32(int)
  • Int64(long)
  • 字符串
  • 时间
  • 任何可空类型(例如:bool?byte?int?等)
  • 任何底层类型为intenum

用户类型

我多次提到过用户类型。用户类型是指数据库不支持的任何类型。像WPF Color这样的struct不是应用程序用户创建的,但它是用户类型,因为它不是为数据库准备的。但是,作为“用户类型”,您可以创建一种转换(例如,从Colorint),并在使用参数值和枚举查询时使用Color

专门化

通常,在读取数据时,我们不应关心特定的约束(例如,它是VARCHAR(255)还是VARCHAR(100)),但某些数据库在准备参数时需要这种专门化。

此库不允许您设置类型化参数的SizePrecisionScale。类型映射器有责任在初始化参数时填充这些值。因此,如果您需要VARCHAR(255)VARCHAR(100),并且希望驱动程序使用特定大小来准备参数,则建议您选择一种作为string,并创建一个用户类型(如String100),该类型将使用VARCHAR(100)

最初,您可能会觉得这很烦人,尤其是在您只想使用此库来访问使用不同字符串大小的旧数据库时。但这是为了简单起见。而不是查找类型,然后查找属性和可能的自定义属性,只使用类型来完成所有事情。当与真正的ORM和编辑器结合使用时,您可能会发现这对于保持强模式非常有益。

完整的ORM

此库不是完整的ORM,这是故意的。也就是说,它进行映射,所以您可以将其视为ORM,但它不会为您构建查询。它的目的是简化参数的填充和类型化对象数据的读取,这些对象比数据表更快、内存消耗更少、更容易使用。

可以使用此库创建完整的ORM(我已经在这样做了),但完整ORM的问题在于它们极大地改变了您与数据库交互的方式,并且经常对连接、聚合值甚至存储过程调用提供有限的支持。有了这个库,您可以自由地执行任何您想要的SQL,但您在处理数据类型转换、事务、参数命名和命令准备/重用方面会遇到的麻烦会少得多。

限制

此库解决了许多与数据类型转换相关的问题,但它也有局限性……或者更确切地说,您必须注意您进行的转换。

当您注册自己的数据类型映射器时,尤其是在将不支持的类型重定向到受支持的类型时,您必须记住,读取和写入字段值并不是您对字段所做的唯一操作。您通常会比较它们,您可以在ORDER BY子句中使用它们,并且您可以直接在数据库中对它们进行数学运算。

例如,很容易创建一个数据类型映射器将int值映射为string。考虑到您将2存储为“2”,将10存储为“10”,您将

  • 导致ORDER BY或大于/小于比较出错(对于字符串,顺序是“1”、“10”、“2”,就像“b”、“ba”、“c”一样),这对于int值来说是不正确的。
  • 避免算术运算。也就是说,通常您可以在SQL中执行IntField1 + IntField2,但由于这些字段实际上是string,您不能这样做。

所以,我们可以说有一个限制。ADO+.NET不保证您的转换将起作用,但如果您只是进行了一个糟糕的转换,您可能会立即注意到它。但如果您的转换有效但丢失了一些特性(例如顺序错误),那么,您可能要过很长时间才注意到有问题。

这是一个bug吗?

当然不是。ADO+.NET已经允许您使用原本不支持的类型。但这取决于您使用保留相同特性的转换,或者,如果不可能,则避免在数据库上执行特定操作(如算术和ORDER BY),而是在.NET代码中执行这些操作(如果需要)。

例如,可以将DateTimeDateTime存储为string。我将只选择Date来展示转换如何是功能性的,但具有错误的order by行为,以及如何是功能性的并具有良好的行为。

假设当您将Date转换为string时,您可以将其存储在以下格式之一:MM-dd-yyyy, yyyy-MM-dd。我保留-是为了更容易理解日期,实际上您可以去掉它以节省空间,但这不会改变我想展示的行为。

无论您选择哪种格式,只要它是一致的(也就是说,您以该格式保存,然后以该格式读取),插入数据、更新数据甚至通过相等运算符搜索数据都将正常工作。但第一种格式的order by已损坏。让我们看三个排序日期,因为数据库将对字符串进行排序

01-01-2000,然后是11-30-1998,然后是12-31-1999

1998-11-30,1999-12-31,然后是2000-01-01

在第一种情况下,月份比年份更重要,用于排序。只需更改格式即可解决这种情况。然而,将date存储为string会避免进行任何数学运算。所以,如果您想支持将DateTime相加来生成DateTime,这可能不是推荐的格式。事实上,所有数据库都支持Date + Time并获得DateTime吗?这是您需要在数据库级别执行的操作吗?

如果您的答案是否然后是,您可能需要考虑将DateTime存储为Int64(或具有足够高精度的某个数字)。格式可以是yyyyMMddHHmmss。然后,您应该只将Date存储为yyyyMMdd000000,将Time只存储为HHmmss。在这种情况下,如果您只是将DateTime的数值相加,您最终将得到一个DateTime的数值。这还不完美,因为如果您想支持TimeSpan的加减法,您可能会在十二月加一个月,然后得到第13个月。如果您也想纠正这种情况,您应该存储DateTimeTimeSpanTicks。在这种情况下,所有算术运算都将正常工作,您将拥有完整的DateTime精度,但如果您查看数据库中的值,您将看不到全面的格式。这完全取决于您想直接使用数据库的程度,以及您更愿意简单地读取数据并在.NET中执行所有操作的程度。

示例

主要示例远非一个可用的应用程序。它只是一个将创建自己的数据库(您应该安装SQLServerCe,或者编辑配置文件使用真实的SQLServer实例或Firebird),使用所有数据库原始类型以及Point类型创建一个表,插入两条记录,然后读取这两条记录以查看所有转换是否正常工作。

如果您创建自己的驱动程序(例如,用于Oracle)来测试所有原始类型是否正常工作,它可能会很有用。此外,通过示例,您可以看到如何执行查询、如何填充参数,甚至如何注册自己的类型映射(在本例中,用于将Point转换为long)。

当然,我缺少更好的例子,这是我计划随着时间添加的内容。所以,我希望您不要使用实际的示例作为不相信该库的原因。

第二个示例更不可用。它使用“Fake”数据库(也就是说,我实现了IDbCommandIDbDataReader等接口),仅用于以最快的方式返回记录。然后,我使用Dapper和此库读取这些记录。由于没有实际读取,测得的时间仅显示此库使用的虚拟调用的开销,以及Dapper使用的虚拟调用+装箱的开销。在实际情况中,两者之间的速度差异会较小,因为花费在虚拟调用上的时间比花费在实际查询上的时间要少。

基本用法

using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  // Executing "non query" commands.
  var command = connection.CreateCommand();
  command.CommandText = "INSERT INTO Table(Id, Name) VALUES (@Id, @Name)";
  command.Parameters.Add("Id", 1);
  command.Parameters.Add("Name", "Test");
  using(var preparedCommand = command.PrepareNonQuery())
    preparedCommand.Execute();
}

这个非常基本的示例将简单地使用应用程序配置文件中找到的连接字符串打开一个连接。

如果在OpenFromConfig()调用中未提供参数,将使用名称“Default”来搜索连接字符串。

拥有连接后,将创建一个命令。我没有处置该命令不是错误。命令本身是不可处置的,并且与ADO.NET命令没有关联。在该命令中,我们可以设置SQL并添加参数。然后,我们准备命令。正是在此时,将创建一个ADO.NET命令。因此,这个命令必须被处置。

正如您所见,我使用了PrepareNonQuery()。这与ADO.NET略有不同,在ADO.NET中,您可以创建一个命令,可以选择准备它,然后使用任何不同的Execute()方法,在ADO+.NET中,我们应该在准备命令时决定我们要执行哪种类型的调用。在这种情况下,这是一个插入操作,所以我们知道不会有结果(因此,它是NonQuery版本)。

最后,我执行了命令。在此示例中,我没有使用该执行的结果,但正如正常的ADO.NET ExecuteNonQuery()调用一样,返回一个int,告诉我们有多少记录受到了影响。

这个例子是有效的,您在进行首次测试时可能会使用类似的方法。然而,它远未充分利用ADO+.NET库的潜力。所以,让我们看看如何使其更好。

参数占位符

我使用了@Id@Name作为参数占位符。这应该适用于大多数ADO.NET驱动程序,但有些驱动程序使用:而不是@

所以,我应该在那些驱动程序中使用:Id:Name。那么,我们如何重写CommandText以使其独立于数据库工作呢?

让我们看一个更正过的先前示例

using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  var idParameter = command.Parameters.Add("Id", 1);
  var nameParameter = command.Parameters.Add("Name", "Test");
  command.CommandText = "INSERT INTO Table(Id, Name) VALUES 
         (" + idParameter.PlaceHolder + ", " + nameParameter.PlaceHolder + ")";
  using(var preparedCommand = command.PrepareNonQuery())
    preparedCommand.Execute();
}

在这种情况下,参数在填充SQL之前被创建,然后idParameter.PlaceHoldernameParameter.PlaceHolder被用于填充SQL,而不是直接填充@Id@Name

注意1:添加到SQL字符串中的是PlaceHolder,而不是parameter.Value。没有SQL注入的风险,因为数据仍然作为普通参数传递。事实上,对于普通驱动程序,SQL仍然包含@Id@Name,但对于使用:的驱动程序,将是:Id:Name

注意2:如果您在同一个C#语句中进行所有操作,那么使用+运算符连接string不是错误的。编译器可以识别这一点并使用string.Concat()方法。如果以不同语句进行大量Append()调用,StringBuilder会更好。此外,我无法确定string.Format()是更好的解决方案,因为它需要解析格式字符串,并且还需要开发人员计算参数。也许在insert(它已经分隔了列和值)中没有太大区别,但在update中,执行"someColumn=" + someColumnParameter.PlaceHolder肯定比"someColumn={23}"然后将someColumnParameter.PlaceHolder放在正确的位置更容易。

创建参数并通过使用对象属性填充它们

与从已创建的参数中获取PlaceHolders不同,可以通过给定对象的public属性来填充所有参数。

using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  command.CommandText = "INSERT INTO Table(A, B, C, D, E, F, G, H, I, J) 
                         VALUES (@A, @B, @C, @D, @E, @F, @G, @H, @I, @J)";
  command.Parameters.CreateFromTypeProperties(typeof(TypeWithManyProperties));
  using(var preparedCommand = command.PrepareNonQuery())
  {
    preparedCommand.Parameters.FillFromObject(objectWithManyProperties);
    preparedCommand.Execute();
  }
}

嗯,SQL显然不是真正的SQL,我永远不会创建名为A、B、C等的列。它只是一个例子,说明有很多属性。在这种情况下,而不是手动创建许多参数,我通过调用Parameters.CreateFromTypeProperties一次性创建所有参数。

也许您会觉得在这种情况下,您必须先创建参数,然后必须在准备命令时填充它们,这有点奇怪。但事实上,奇怪的是允许创建已经填充了值的参数的版本。要准备查询,只需要知道参数名称及其类型。值并不重要。

当命令被准备好时,它的Parameters是只读的。您不能再向preparedCommand添加参数,并且向command添加新参数不会影响它。您也不能删除参数。但是您可以填充它们的值。如果您想添加许多记录,您应该只创建一次参数(在command中),然后您只需要用新值(在preparedCommand中)填充它们,并多次调用Execute()

在这种情况下,我仍然手动填充了参数占位符。这不是您应该做的,但如果您需要通过参数获取参数占位符,那么手动创建所有参数会更有用。这是我们的下一个例子。我只想展示这个例子,以便您可以看到有这样的选项(如果您只是迁移ADO.NET应用程序到ADO+.NET,并且一开始不想重写所有带有固定参数名的SQL,但仍然想移除不必要和重复的代码,这可能很有用)。

手动创建参数并通过属性填充值

using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  var idParameter = command.Parameters.Add<int>("Id");
  var nameParameter = command.Parameters.Add<string>("Name");
  command.CommandText = "INSERT INTO Table(Id, Name) VALUES 
                        (" + idParameter.PlaceHolder + ", " + nameParameter.PlaceHolder + ")";
  using(var preparedCommand = command.PrepareNonQuery())
  {
    preparedCommand.Parameters.FillFromObject(someObjectWithIdAndName);
    preparedCommand.Execute();
  }
}

在这种情况下,我们结合了最后两种情况。我们仍然手动创建参数,为它们提供正确的名称和类型。但是,然后,我们不再使用参数来填充值,我们只是从现有对象填充所有值。

事实上,我认为如果您手动创建所有参数,您也应该手动填充所有参数,因为这是最快的解决方案。但也许您已经有了一个能够正确生成SQL的代码。所以,如果您只想用一个对象一次性填充所有参数,请使用FillFromObject()方法,所有工作都已为您完成。

读取数据

之前的示例侧重于填充参数。但您可能会认为读取数据更有power。所以,看这个例子

// Considering a class named Record with public properties Id and Name
using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  command.CommandText = "SELECT Id, Name FROM Table";
  using(var preparedCommand = command.Prepare<Record>())
  {
    foreach(Record record in preparedCommand)
    {
      // Do something with the record.
    }
  }
}

枚举单个列

Prepare<T>()方法期望填充类型为T的实例。也就是说,每列将填充该类型中的一个属性。

但如果您只想选择一列怎么办?创建一个实例来填充单个属性有点奇怪(并且会降低性能)。直接返回该列的值会更好。

PrepareSingleColumn<TSingleColumn>()方法就是这样做的。下面的示例显示了如何获取表中所有Id

using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  command.CommandText = "SELECT Id FROM Table";
  using(var preparedCommand = command.PrepareSingleColumn<int>())
  {
    foreach(int id in preparedCommand)
    {
      // Do something with the id.
    }
  }
}

ToArray() 和 ToAddCollectionSlim()

ADO.NET连接默认不允许同时有两个活动的DataReader,ADO+.NET也继承了这个限制。

例如,如果您想在枚举第一个命令时枚举第二个命令,您将需要一个第二个连接,或者您需要将第一个查询的所有记录读入内存。

我见过很多人在其他ORM中通过调用ToList()扩展方法来解决这类问题。这是可行的,但通常LINQ的ToArray()方法更快。

嗯,在ADO+.NET中,ToArray()甚至更快,因为它被明确实现,并避免生成枚举器和内部状态机。此外,ToArray()在读取大型集合时不会进行许多中间复制/重调大小操作,如List甚至LINQ那样。只进行一次复制,从AddCollectionSlim<T>到结果数组。

但是,如果您只打算迭代数组一次,直接使用该AddCollectionSlim仍然更快。所以,如果您需要将所有结果缓冲到内存中以调用foreach,请选择使用ADO+.NET命令的ToAddCollectionSlim()方法。

注意:正如我之前讨论过的,将所有记录放入内存可能会有问题并导致OutOfMemoryException,因此您应该谨慎使用此资源。也许使用ToAddCollectionSlim()可以解决您的问题,也许您确实需要处理第二个连接。

AddCollectionSlim是如何工作的?

这个话题在这个文章中有点“不合适”,因为它不是ADO+.NET特有的资源。它是ADO+.NET正在使用并且您可以使用的资源,而无需引用ADO+.NET本身。

但由于我不认为AddCollectionSlim值得单独写一篇文章(它会太短),我将在这里介绍它,以便那些想了解它是如何工作的人。为了理解它,让我们先回顾一下List<T>是如何工作的。

列表分配一个固定大小的内部数组(在.NET 4.0中,第一个数组仅在添加第一个项目时分配,大小为4,如果您使用默认构造函数)。然后,当您添加项目时,新项目将被放在Count的位置,然后Count直接加一。

但是,当您尝试在内部数组已满时添加项目,会创建一个新数组(大小是前一个的两倍),然后将所有项目从一个数组复制到另一个数组,然后再添加下一个项目。

即使LINQ的ToArray()方法不使用List来完成其工作,它也使用完全相同的逻辑。然后,最后,如果数组没有完全填充(例如,一个大小为16,包含9个项目的数组),就需要进行一次新的复制(返回一个大小为9的数组)。

AddCollectionSlim从不进行这种复制。该类型保存对第一个和实际的引用。第一个块默认大小为32。然后,当块已满且调用新Add时,将分配一个大小加倍的新块并将其设置为实际块,但之前的块会保留在那里,其下一个块字段仅引用新创建的块。因此,根本没有复制。新项目将继续添加到“实际块”(即,最新创建的块),在迭代时,只需从第一个块开始,达到块的末尾后,转到下一个块并继续该过程直到结束。

这样,您拥有的项目越多,AddCollectionSlimList或LINQ的实际ToArray实现相比就会越快,因为它会避免所有中间复制。

最大性能

有一种情况与将所有值存储到数组或AddCollectionSlim完全相反,并且在大型导出中相对常见。

您不想将所有记录放入内存,因为这会消耗太多内存。此外,您完全确定一个项目不会被使用两次(也就是说,在foreach中,您不会将foreach中声明的变量的值复制到另一个比foreach作用域更长的变量中)。

因此,在这种情况下,您不是在每次交互时分配一个新对象(这会需要稍后收集),而是可以分配一个对象,并在每次交互时,让读取器一次又一次地填充同一个对象。

这是一种相对“不安全”的技术,因为如果您将该对象存储到另一个变量(或将其交给另一个线程),那么,在下一次迭代中,该对象将具有不正确的值。但如果您确定不会以错误的方式使用它,这可能是读取非常大的结果集的最高效方式。

要使用此资源,只需提供一个委托,该委托在准备命令时始终返回同一个对象。因此,代码可能看起来像

// Considering a class named Record with public properties Id and Name
using(var connection = DatabaseConnection.OpenFromConfig("connectionStringName"))
{
  var command = connection.CreateCommand();
  command.CommandText = "SELECT Id, Name FROM Table";
  Record sameRecord = new Record();
  using(var preparedCommand = command.Prepare<Record>(() => sameRecord))
  {
    foreach(Record record in preparedCommand)
    {
      // Do something with the record.
      // the record variable will have the exactly same 
      // instance as the sameRecord variable.
    }
  }
}

添加对用户类型的支持

使用ADO+.NET,填充参数可能会更容易,但尚未达到最佳状态。

读取数据可能更有趣,但您不想只使用ADO+.NET原始类型。您希望Color就是ColorDriverLicenseNumber就是DriverLicenseNumber,或者简单地说,您希望任何您创建的类型都被视为该类型,您不想只使用数据库支持的类型(无论是特定于您的数据库的类型还是ADO+.NET原始类型)。因此,要支持所有您需要的类型,您有几种选择

  1. 您可以创建一个额外程序来执行转换并重定向到数据库存储的属性。在这种情况下,您需要两个POCO类,一个与数据库绑定(即使理论上POCO类独立于其数据库表示),另一个真正表示您想要的类型,并且您需要从一个转换为另一个(这几乎和直接使用ADO.NET一样糟糕,所以我不推荐);
  2. 您可以使用两个属性,一个使用数据库格式并且是持久化的,另一个使用转换后的(.NET特定)格式,在读取和写入时进行转换(所以您仍然有偏差的POCO类);
  3. 您可以创建一个UserTypeMapper,它将告诉您如何将任何特定格式转换为数据库支持的格式(在这种情况下,您的POCO类将完全不知道如何持久化它们)。

我认为,通过括号中的注释,您知道应该使用第三个选项。那么,它是如何工作的呢?

创建映射器

要创建映射器,您应该继承自UserTypeMapper<TUser, TDatabase>,其中TUser是数据库不支持的数据类型(在这种情况下,是Color),而TDatabase是数据库已支持的类型(在这种情况下,是int,因为普通的颜色是32位,可以存储为int)。

在此类中,您需要实现两个方法

  • ConvertFromDotNetToDatabase
  • ConvertFromDatabaseToDotNet

我认为这些名称很清楚。当然,您需要知道如何进行转换,但我认为Colorint以及intColor是一个众所周知的转换(我们的重点是了解如何创建转换器,而不是实际的转换)。

然后,您还应该创建一个接收参数的构造函数。该参数是DatabaseTypeMapper<TDatabase>(在这种情况下,TDatabaseint)。

您需要这样做,因为在转换之后,UserTypeMapper<TUser, TDatabase>会将调用重定向到该映射器。然而,这是您需要做的事情,因为如果您全局注册转换器,构造函数就会被调用。

注册用户类型映射器

要注册用户类型映射器,以便所有工厂的所有连接都支持它,只需调用DatabaseTypeMappersShared.GlobalRegisterUserTypeMapper(),并将您新映射器类型的typeof()作为参数传递。

版本历史

  • 2013年5月5日:添加了添加对用户类型的支持主题
  • 2013年4月21日:添加了基本用法主题
  • 2013年4月16日:添加了一种更简单的注册用户类型映射器的方法。此外,将DatabaseDotNetTypeMapper重命名为UserTypeMapper,更改了泛型参数顺序以使其看起来更自然,并添加了使用备用名称的示例。现在还可以从ADO+.NET连接创建纯ADO.NET命令(您可能需要它,例如,如果您想使用来自读取器的字节流)。
  • 2013年4月12日:向返回值的命令添加了对CommandBehavior的支持,并将默认CommandBehavior设置为SequentialAccessSingleResult
  • 2013年4月11日:初始版本
© . All rights reserved.