一次 ODBC 导出的“幽灵”空引用
# 一次 ODBC 导出的“幽灵”空引用:`DbProviderFactories.GetFactory(DbConnection)` 为什么返回 null
> 记录 winportal 数据导出链路中一次真实排查:同样的代码,有的数据源正常,有的数据源直接 NullReferenceException。根因不在连接字符串,也不在驱动加载,而在一个被误用的 ADO.NET API。
## 1. 背景
winportal 的数据表导出大致走这条链路:
DataSrv.ExportEntityData
└─ DataAccessClient.GeDataset(sql)
└─ new SqlUtil(driverName, connectionString).GetDataSet(sql)
`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。
var factory = DbProviderFactories.GetFactory((DbConnection)_dbConnection); // null
奇怪的地方在于:
- 连接本身是能用的,同一数据源的查询、写入都正常;
- 并不是所有数据源都失败,MySQL、SQL Server 等数据源导出一直没问题;
3. 排查:这个 API 到底做了什么
第一反应是怀疑 _dbConnection 为 null、驱动程序集加载失败,但二者都能排除:连接实例创建成功,查询也执行过。
于是直接看 .NET 6 运行时里 System.Data.Common.dll 的实现。反编译后,DbProviderFactories.GetFactory(DbConnection) 的实现只有一行:
public static DbProviderFactory? GetFactory(DbConnection connection)
{
ADP.CheckArgumentNull(connection, "connection");
return connection.ProviderFactory;
}
而 DbConnection.ProviderFactory 来自一个 protected virtual 属性,基类默认实现是:
protected virtual DbProviderFactory? DbProviderFactory => null;
internal DbProviderFactory? ProviderFactory => DbProviderFactory;
结论很清晰:
DbProviderFactories.GetFactory(DbConnection)能否拿到工厂,完全取决于驱动有没有重写DbConnection.DbProviderFactory。基类默认返回null,API 的返回类型本身就是可空的(DbProviderFactory?)。
不是 bug,而是契约如此——它只是把连接实例上挂着的工厂取出来而已。
各驱动实测
在 .NET 6.0.36 下,用项目里实际引用的驱动版本做了一次探测,代码类似:
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。
修改后的实现:
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的通用代码都要准备面对它。
参考:
DbProviderFactories.GetFactory官方文档- .NET 6
System.Data.Common.DbProviderFactories/DbConnection反编译实现(本文排查依据)