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

DbReaderGenerator 库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (31投票s)

2014 年 2 月 2 日

CPOL

17分钟阅读

viewsIcon

58333

downloadIcon

2017

在运行时生成 DataReader,并通过避免重复代码和使用数据库最合适的 Get 方法来获得最佳性能。

引言

去年,我发表了文章 ADO+.NET。在文中,我介绍了一个在我看来可以“取代”ADO.NET 的库。

事实上,这样的库使用了 ADO.NET,但没有向用户暴露它。我们可以说这与 WinForms 相对于 Windows API(user32 库)的做法非常相似。然而,Nicolas Dorier 告诉我,ADO+.NET 不应该试图取代 ADO.NET,而应该在它之上进行扩展。

嗯,我的一部分人仍然不同意,因为我的目的是隐藏那些引起混淆的方法和属性,但我确实明白,实际上有太多项目直接使用 ADO.NET,所以创建一个能让数据访问更轻松、更正确,而不是取代 ADO.NET 组件的东西,是件好事。

所以这次,我将介绍一个非常小的库,旨在让读取数据读取器中的值以及以非常快速且可配置的方式生成填充的记录变得更容易。

这个库究竟做了什么?

这个库有一个非常简单的目的:轻松地使用 IDataReader 行填充 .NET 对象,无需手动编写代码来执行正确的 GetSomething 调用,无需手动处理数据类型不匹配,并且速度很快。

在此基础上,它还提供了语法糖方法,允许用户轻松地枚举数据库命令,接收给定类型的实例,或者创建包含这些结果的列表。

为了让事情变得更好,整个代码都提供了扩展点,允许您指定如何处理给定的数据库列(因此您可以决定调用某个特定方法来读取该列),或者如果您不想走那么远,还有其他扩展点允许您指定如何通过数据库列查找字段或属性,或者如何考虑其实际数据库类型来读取数据库列。或者,更简单地说,可以指定如何进行必要的转换。

Using the Code

在讨论性能、解释内部工作原理或解释如何完全重新配置此解决方案之前,让我们先了解如何在基本情况下使用它。

首先(可能在应用程序初始化期间),您需要使用默认值(或者也许是其他值,但我们稍后会看到)来配置库,所以,为此,您必须调用

DbReaderGeneratorLibrary.Configure();

然后,在任何时候,当您有一个已经填充好的 IDbCommand,并且准备调用 ExecuteReader() 时,您可以使用以下方法之一

  • Enumerate<T>() - 这是一个扩展方法,它将调用 ExecuteReader() 并创建一个快速委托来读取每一行。

    作为一个 Enumerable,您可以自由使用 foreach 结构,甚至 LINQ 方法(如 FirstOrDefault()First()Single()SingleOrDefault() 等方法,因为它们不需要读取所有记录)。但如果您需要加载整个内容的方法,最好使用 ToList() 方法。

  • ToList<T>() - 嗯,这与 Enumerate() 方法非常相似,但它不是仅在请求时(枚举时)读取记录,而是立即加载所有记录。如果您确实想将所有记录都加载到内存中,则此方法比前一种更优选,但不适用于大量批处理或计划调用 First()Single() 等方法,因为当您只需要第一个记录时,读取所有记录是没有意义的。

  • EnumerateColumn<T>(columnIndex) - 创建一个只读取数据库单个列的查询并不罕见。在这种情况下,为什么我们要枚举只有一个属性的记录?为什么不能直接枚举列?

    这就是 EnumerateColumn() 所做的。我不确定我是否应该称之为 EnumerateFieldEnumerateColumn,但即使 IDataReader 有一个 GetFieldType,我还是认为使用“columns”这个词更好。

    当枚举命令以读取列时,我们通常读取查询实际选择的单个列,因此 columnIndex 的默认值为零,但如果您确实想选择多个列并决定忽略其他所有列,我们也可以读取另一个列索引。

  • ColumnToList<T>(columnIndex) - 就像 Enumerate()ToList() 方法一样,有时我们可能更喜欢将所有数据放入内存而不是迭代它。因此,ColumnToList() 方法将读取命令产生的所有行,并将这些结果放入一个列表中。

库的组织结构

我个人喜欢一个文件对应一个类型。我甚至有些极端,通常会将一个委托声明(只有一行代码)放到它自己的文件中。

但我最近一直在“玩弄”不同的代码和库呈现方式,我知道的是,许多用户更喜欢将单个文件添加到他们的项目中,而不是添加很多文件,或者更糟糕的是,需要向他们的解决方案中添加新项目和/或 DLL 引用。所以,我决定只将内容分成两个文件

  • DbReaderGeneratorDefinition.cs:此文件包含所有接口、委托和“默认”定义,但没有实际的实现。这种设计的目的是允许您在编写自己的实现时替换默认实现,而无需重写依赖于此库的代码。这与我在文章 远程框架的架构 中介绍的设计非常相似。
  • DbReaderGeneratorDefaultImplementation.cs:此文件是 DbReaderGenerator 的实际实现。嗯,至少是所有接口和扩展点的默认实现。我非常相信,在您自己的项目中,您会尝试替换该文件中提供的类中的至少一个。

嗯……实际上还有第三个文件。DbReaderExtensions.cs 是我在 **使用代码** 主题中介绍的方法所在的位置。我一直不确定应该将包含这些方法的类放在哪里。考虑到它是一个默认实现,我考虑将其放在默认实现文件中。但考虑到它可以与任何配置一起工作,我认为应该将其放在定义文件中……最后,我将其放在了自己的文件中,但我仍然希望解决方案中的文件不要太多,如果您真的想这样做,可以随时将内容合并到一个文件中。

性能

我总是听到(并读到)人们说性能并不重要。然而,在我工作的几乎所有地方,主要问题都是性能。而性能出现问题的常见地方是

  • 沟通
  • 数据库访问

考虑到本文不涉及通信(并且数据库有其自身的优化通信方式),那么只有一件事情可以尝试改进:数据库访问。

实际上,使用ADO.NET直接进行数据库访问会受到这些(以及其他)因素的影响

  • 如果使用 DataTable/DataSet,所有值类型列(int、boolean、tiny int、char 等)都会被装箱,这会占用更多内存,并且在读取数据时总是需要强制类型转换。
  • 如果使用数据读取器,考虑到某些数据库会更改实际数据类型,使用 GetValue() 方法(也会进行装箱)然后使用 Convert 类中的方法来实际接收正确类型的数据是一种不常见的做法。
  • yield return 出现之前,编写一次读取一条记录的枚举器非常困难,因此人们习惯于读取所有数据并将其放入列表中。现在,即使有了 yield return,继续读取所有数据并将其放入列表(也许是因为人们只是遵循旧的“标准”)仍然是一种常见的做法,这对于大量批处理来说可能非常麻烦。

所以我的目的是帮助用户避免所有这些问题。实际上,代码在运行时生成一个委托来读取整行数据并填充一个已有的对象,因此我们可以说读取整行与手动编写的代码相比,只增加了一个虚拟方法调用。

但是,与用户手动编写代码不同的是,它实际上能够分析数据库的类型并使用正确的 GetSomething 方法。请注意,对于 SQL Server 读取 char,您实际上不能使用 GetChar() 方法,您必须将其读取为 string 然后获取第一个字符,但这并非适用于其他数据库。

因此,通过运行时生成的代码,您可以受益于使用适合不同数据库的正确访问方法的灵活性,而无需付出性能损失,也无需为每个数据库编写不同的代码。作为额外的优势,您可以避免编写大量重复代码,即使您不使用不同的数据库,也可以避免复制粘贴引起的错误,并保证始终使用正确的模式。

我唯一的性能比较是与 Dapper 进行数千条记录的多次读取。当 Dapper 花费 1.9 秒时,此解决方案花费 1.2 秒。但我这样做只是为了确保它速度很快,因为 Dapper 更完整,它可以帮助您填充参数,而此解决方案在用户数据类型和配置方面更完整(实际上,我计划发表一篇关于填充查询参数的解决方案的文章,作为可以与此解决方案结合的独立解决方案)。

EnumerateSingleInstance<T>

我已经列出了您可以与 IDbCommand 一起使用的扩展方法。但如果您真的关心性能,还有一个额外的方法,称为 EnumerateSingleInstance

此方法始终返回所有数据库行的同一实例(甚至可以将此实例作为参数接收)。它的目的是在 foreach 块中使用,因为记录不会在该块之外使用,因此它避免了在每个数据库行上创建新实例的开销,从而有效地提高了速度,并减轻了垃圾回收器的压力。

但是,由于它总是返回同一实例,您不能使用期望返回不同实例的方法,例如,使用 LINQ 方法(如 ToArray())是没有意义的,因为返回的数组长度是正确的,但只包含一个实例(它将包含最后读取记录的值)。

所以,如果您想要最高的性能,就使用此方法,但请谨慎使用。

IDbReaderGenerator

在这个库中,一切都始于 IDbReaderGenerator。当我们调用 Enumerate()ToList() 时,实际发生的是,这些方法将调用 ExecuteReader(),然后请求使用 IDbReaderGenerator 生成一个记录填充器,然后,它们将遍历所有行,调用生成的委托,并 yield return 记录或将它们添加到列表中。

因此,我们可以说一切都始于 IDbReaderGenerator

但是默认实现实际上会做两件事

  • 用结果缓存装饰内部解决方案,这样同一查询的后续执行就不会浪费时间重新生成委托;
  • 通过 IDbReaderExpressionGenerator 生成的表达式编译一个委托。

所以,我们的用户起点不是架构起点。架构起点是 IDbReaderExpressionGenerator

IDbReaderExpressionGenerator

这是这个库的核心。IDbReaderExpressionGenerator 负责生成表示适当调用的单个表达式。实际上,有两种类型的表达式可以生成

  • 填充器:“填充器”表达式的目的是填充一个已存在的实例,因此它接收该实例作为输入,而不是在每次调用时生成新结果。这样做的优点是允许用户避免创建新记录,如果他们一次只想在内存中保留一个记录;
  • 列读取器:此类型的表达式经过优化,用于读取单个列,因此它们直接返回值,而不是填充记录实例。

扩展点

实际上,指向 DbReaderGeneratorDbReaderExpressionGenerator 的第一个扩展点是接口。由于它们最初是接口,它们的实现可以完全被替换或装饰。

如前所述,默认的 DbReaderGenerator 实际上是一个装饰器,它缓存由重定向到 DbReaderExpressionGenerator 的实现生成的结果。因此,让我们看看 DbReaderExpressionGenerator 的可用扩展点。

DbReaderExpressionGenerator 的默认实现有两个构造函数。其中一个接收一个委托来生成表达式,用于

  • 访问数据库列
  • 访问 .NET 成员(字段或属性)
  • 将数据库类型转换为 .NET 成员类型(如果需要)

请注意,生成成员访问表达式是最简单的,实际上是使用以下代码实现的

(command, reader, columnIndex, instanceVariable) =>
{
  string memberName = reader.GetName(columnIndex);
  var member = instanceVariable.Type.GetMember(memberName);
  if (member == null || member.Length != 1)
    throw new InvalidOperationException("Can't find a single member named: " + memberName);

  return Expression.MakeMemberAccess(instanceVariable, member[0]);
};

这可能是您想要替换的代码,如果您有不同的命名规则。

如果您看到此代码,数据库列的名称用于查找同名的 .NET 成员(即同名的字段或属性)。

但我知道许多情况下,用户希望使用不同的名称来命名他们的属性,无论是通过属性还是其他特定于应用程序的规则。因此,只需用一个查找成员具有不同规则的委托替换此委托,一切都会完成。

另请注意,如果查询有一个找不到的列名,则会抛出异常。实际上,如果返回一个 null 表达式,它将简单地忽略该数据库列,但仍然能够用其他列填充对象,因此这些是您可以尝试的潜在扩展。

另外两个参数(readColumnGenerator 和 conversionGenerator)

readColumnGenerator 的默认实现将尝试发现数据库中列的类型,是否有一个等效的 GetColumnType 方法在读取器上。

也就是说,Int32 将使用 GetInt32() 方法,String 将使用 GetString() 方法,依此类推。如果存在,它将使用该方法进行读取。它不关心这是否与目标字段或属性的类型匹配,因为这是转换生成器的责任。

因此,如果这些类型不匹配,转换生成器负责生成适当的转换调用。嗯,默认实现使用 Convert 类,尝试查找合适的 ToSomeType,或者,如果找不到,则使用 ChangeType() 方法。我计划发布另一篇文章,展示如何使用真正可扩展的数据类型转换解决方案来支持各种转换(类似于 可扩展的 IoC 容器,但用于转换表达式)。

如果数据库列不应设置字段或属性怎么办?

在某些情况下,数据库中的列可能不会直接反映为字段或属性。如果是这种情况,您可以使用 DbReaderExpressionGenerator 的备用构造函数。这种备用构造函数仍然需要 ReadColumnGeneratorConversionGenerator(它们由 GenerateColumnReader 方法使用),但它不是尝试构建路径(读取数据库列并设置字段/属性),而是调用一个委托来执行此操作。这意味着您可以实际读取一个列并调用一个方法,如果这适合您的情况。

我可以说,我可以想象这种情况发生在最后一个列是 IsReadOnly 这样的列,负责调用 MakeReadOnly() 方法。

缓存

实际上,为读取 IDataReader 生成的代码速度很快。问题是生成这样的读取器需要时间(虽然不算太多,但我们也可以说这可能是一个问题)。因此,最好的做法是缓存生成的读取器。问题是:我们如何缓存它们?

当读取 Person 表时,您可能总是使用一个对象(如 Person),该对象始终使用相同的列列表和相同的顺序,无论使用的 ORDER BYWHERE 是什么。如果是这种情况,可以为目标类型生成一个生成器,并在不同的 select 子句中重用它,因为不同的 ORDER BYWHERE 子句不会影响返回的列。

但是,如果您将相同的目标对象与不同的表或 SELECT 子句中的不同列顺序一起使用,则无法重用 IDataReader 读取器,因此必须使用新的读取器。实际上,如果您使用不同的数据库,相同的 select 实际上可能返回不同类型的列,因此生成的读取器也无法在这些情况下使用。

因此,为了尝试解决这些问题,默认缓存只会为一个完全相同的 SQL 子句重用缓存的生成器,考虑到它来自具有完全相同的连接字符串的连接,当然,也是为了完全相同的目标类型。执行此类缓存的类实际上允许您说您不想考虑 SQL 子句或连接字符串,从而更频繁地重用缓存的生成器,但这需要用户自行请求并保证他们不会使用相同的对象来读取不同的表、数据库,或只是具有不同列顺序的 select 语句。

然而,即使您不太关心频繁重用缓存,还有一个问题:缓存的项目永远不会被收集,因此,如果您正在构建不同的 SQL 子句(可能是因为 where 子句使用字符串连接而不是参数,这本身就是一个问题),缓存可能会变得太庞大。通过使用弱字典来改进缓存并不难,但创建一个好的弱字典并不容易,当然也不小,所以为了避免提供一个庞大的解决方案,我只是写了一个简单的非弱字典。但在使用它时请记住这个特性,所以您可能更倾向于重用查询以用于不同的 SQL,或者您可能更倾向于编写自己的具有不同规则的缓存,因为这是一个重要的问题。

示例

示例应用程序使用此解决方案和 Dapper 进行速度和转换比较,使用模拟的命令和数据读取器。我让它使用模拟的命令和数据读取器,因为我不想强迫用户创建一个实际的数据库来进行测试,但我试图使用所有重要的方法,以便可以看到该库的用法。

模拟读取器实际上将“int”列视为 decimal 列,因此有必要进行一些转换才能使值正常工作,并且对于枚举,Dapper 会失败,而此解决方案会成功。

重要的是要注意,此应用程序运行速度非常快,可以处理数百万条记录,因为它实际上不会花费时间查询实际数据库,所以我们可以看到两个映射器都非常快。在实际情况中,大部分时间都花在实际查询和通过 TCP/IP 接收数据上,但我在此处进行了速度比较,以证明这不会使事情变慢,同时通过使用正确的 get 方法和轻松进行转换(如果需要)来提供帮助。

未来

我不会承诺什么,但将来我计划提供 ConversionGenerator 的实现,它允许用户轻松注册新转换,而无需提供全新的实现,我还计划提供一个填充数据库参数的解决方案(实际上 Dapper 有这个功能,但此解决方案没有)。

我的目的是让这些解决方案中的每一个都能独立存在,这样您就可以在不使用其他解决方案的情况下使用它们,同时仍然提供更类似于 Dapper 工作方式的语法糖方法,因此您可以轻松执行查询,提供类型化的参数并轻松接收类型化的结果,这当然会使用这个快速的读取解决方案,并在需要时,也会使用快速的解决方案来填充参数和进行可配置的数据类型转换。

© . All rights reserved.