一次 ODBC 导出的“幽灵”空引用

text
# 一次 ODBC 导出的“幽灵”空引用:`DbProviderFactories.GetFactory(DbConnection)` 为什么返回 null

> 记录 winportal 数据导出链路中一次真实排查:同样的代码,有的数据源正常,有的数据源直接 NullReferenceException。根因不在连接字符串,也不在驱动加载,而在一个被误用的 ADO.NET API。

## 1. 背景

winportal 的数据表导出大致走这条链路:

DataSrv.ExportEntityData
└─ DataAccessClient.GeDataset(sql)
└─ new SqlUtil(driverName, connectionString).GetDataSet(sql)

text

`SqlUtil` 是一个“动态驱动”的数据库工具类:

- 数据源里保存驱动名,格式是 `命名空间.类型名, 程序集名`,例如 `System.Data.Odbc.OdbcConnection, System.Data.Odbc`;
- 构造时用反射加载程序集、`Activator.CreateInstance` 创建连接;
- 日常查询走 Dapper,只有导出需要 `DataSet` 的地方才会调用 `GetDataSet`。

`GetDataSet` 原来的实现是这样的:

```csharp
public DataSet GetDataSet(string sql)
{
    var dataSet = new DataSet();
    var factory = DbProviderFactories.GetFactory((DbConnection)_dbConnection);
    using (var command = factory.CreateCommand())
    {
        command.Connection = (DbConnection?)_dbConnection;
        command.CommandText = sql;
        using (var adapter = factory.CreateDataAdapter())
        {
            adapter.SelectCommand = command;
            adapter.Fill(dataSet);
        }
    }
    return dataSet;
}

大概意思是:用工厂创建 DbCommand 和 DbDataAdapter,让代码与具体数据库解耦。

2. 现象

数据源是databricks时,使用odbc驱动导出数据时抛 NullReferenceException,调试后确认:执行完拿到的 factory 是 null。

csharp
var factory = DbProviderFactories.GetFactory((DbConnection)_dbConnection); // null

奇怪的地方在于:

  • 连接本身是能用的,同一数据源的查询、写入都正常;
  • 并不是所有数据源都失败,MySQL、SQL Server 等数据源导出一直没问题;

3. 排查:这个 API 到底做了什么

第一反应是怀疑 _dbConnection 为 null、驱动程序集加载失败,但二者都能排除:连接实例创建成功,查询也执行过。

于是直接看 .NET 6 运行时里 System.Data.Common.dll 的实现。反编译后,DbProviderFactories.GetFactory(DbConnection) 的实现只有一行:

csharp
public static DbProviderFactory? GetFactory(DbConnection connection)
{
    ADP.CheckArgumentNull(connection, "connection");
    return connection.ProviderFactory;
}

而 DbConnection.ProviderFactory 来自一个 protected virtual 属性,基类默认实现是:

csharp
protected virtual DbProviderFactory? DbProviderFactory => null;

internal DbProviderFactory? ProviderFactory => DbProviderFactory;

结论很清晰:

DbProviderFactories.GetFactory(DbConnection) 能否拿到工厂,完全取决于驱动有没有重写 DbConnection.DbProviderFactory。基类默认返回 null,API 的返回类型本身就是可空的(DbProviderFactory?)。

不是 bug,而是契约如此——它只是把连接实例上挂着的工厂取出来而已。

各驱动实测

在 .NET 6.0.36 下,用项目里实际引用的驱动版本做了一次探测,代码类似:

csharp
var conn = (DbConnection)Activator.CreateInstance(connectionType)!;
var factory = DbProviderFactories.GetFactory(conn);
Console.WriteLine(factory?.GetType().Name ?? "NULL");

结果:

驱动 GetFactory(conn) CreateDataAdapter()
ODBC(System.Data.Odbc 8.0.0) NULL –
OLE DB(System.Data.OleDb 8.0.0) NULL –
MySql.Data 9.7.0 MySqlClientFactory MySqlDataAdapter
MySqlConnector 2.1.2 MySqlConnectorFactory MySqlDataAdapter
Microsoft.Data.SqlClient 5.1.5 SqlClientFactory SqlDataAdapter
Npgsql 6.0.7 NpgsqlFactory NpgsqlDataAdapter
Oracle.ManagedDataAccess 23.26.200 OracleClientFactory OracleDataAdapter

失败的数据源正是 ODBC。

5. 解决方案

GetDataSet 根本不需要工厂。IDbConnection.CreateCommand() + IDataReader 是所有 ADO.NET 驱动都必须实现的最小契约,用 DataTable.Load(reader) 就能把结果灌进 DataSet。

修改后的实现:

csharp
public DataSet GetDataSet(string sql)
{
    var dataSet = new DataSet();
    using IDbConnection conn = _dbConnection;
    conn.Open();
    using var command = CreateCommand(conn, sql);
    using var reader = command.ExecuteReader();
    do
    {
        var table = new DataTable();
        table.Load(reader);
        dataSet.Tables.Add(table);
    }
    while (!reader.IsClosed && reader.NextResult());
    return dataSet;
}

几个细节:

  • 必须显式 Open()。以前 DbDataAdapter.Fill 会自动开连接,换成 reader 后不再有这个隐式行为。
  • 复用类里已有的 CreateCommand,保持 30 秒命令超时、失败时关闭连接等既有行为。
  • 按结果集逐表加载(do/while + NextResult())。导出用的 Search 脚本是单条 SELECT,实际只有一张表;保留循环是为了不丢“多结果集”这一能力,同时因为 DataTable.Load 消费后 reader 可能已被关闭,循环条件里判断了 reader.IsClosed。
  • 连接用 using 释放,与 QueryTableWithSchema、Query 等方法保持一致(调用方每次都是 new SqlUtil(...) 一次性使用)。

调用方 ExportEntity 用的是 ds.Tables[0]、按列名取值,DataTable.Load 会保留列名与数据类型,因此不需要改动。

6. 为什么这样解决,而不是别的方案

排查过程中考虑过几种替代方案,逐一说明为什么不选:

方案 A:DbProviderFactories.GetFactory("System.Data.Odbc")(按固定名称取工厂)

  • .NET Core 没有 machine.config,固定名称的工厂默认未注册,必须先 RegisterFactory;
  • 各驱动的 invariant name 五花八门(System.Data.Odbc、MySql.Data.MySqlClient、Microsoft.Data.SqlClient……),等于把驱动分支从代码搬到字符串表里;

方案 B:按驱动 switch,ODBC 用 OdbcDataAdapter

  • 这种方案不太友好,每加一种数据源都要改这段代码、加编译期引用,重新制造了“数据源调整”想消除的耦合。

方案 C:工厂为空时按连接类型兜底 OdbcFactory.Instance / OleDbFactory.Instance

  • 只修了“工厂为 null”,没修 CreateDataAdapter 返回 null 的驱动(比如SQLite,这里没用到);
  • 兜底逻辑是 speculative 的,遇到新驱动又要补。

方案 D(采用):绕开工厂,用 CreateCommand + DataTable.Load

  • 只依赖 IDbConnection / IDbCommand / IDataReader,这是 ADO.NET 对所有驱动的硬性要求,没有“可选的工厂重写”这种灰色地带;
  • 一套代码覆盖 ODBC、OLE DB、MySQL、SQL Server、PostgreSQL、Oracle;

7. 验证

通过数据管理界面,点击导出数据,导入数据,均正常。

8. 总结

  • DbProviderFactories.GetFactory(DbConnection) 不是通用 API。它只是读取 DbConnection.DbProviderFactory,而该属性是 protected virtual、默认 null,驱动重写与否全凭自愿。Microsoft.Data.SqlClient、Npgsql、MySql 等重写了,ODBC/OLE DB 没有——这跟“框架是否现代”无关。
  • CreateDataAdapter() 也不可靠。基类默认返回 null。任何依赖 DbDataAdapter 的通用代码都要准备面对它。

参考:

text

本文为原创文章,采用 CC BY-NC 4.0 许可协议,转载请注明出处。

订阅更新

新文章发布时邮件通知,随时可退订