需求

把 IEnumerable<T> 转成 DataTable 常见于老项目对接:

  • 用 SqlBulkCopy 把 DTO 列表批量写入数据库
  • 用 DataSet 喂给报表控件(旧 RDLC、FastReport)
  • 把对象导出为 CSV / Excel(NPOI)

完整实现

using System.Data;
using System.Reflection;

public static class EnumerableToDataTable
{
    public static DataTable ToDataTable<T>(this IEnumerable<T> source)
    {
        var dt = new DataTable(typeof(T).Name);

        var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);

        // 第一步:根据属性创建列
        foreach (var p in props)
        {
            var colType = p.PropertyType;
            // Nullable<T> 取底层类型
            if (colType.IsGenericType && colType.GetGenericTypeDefinition() == typeof(Nullable<>))
                colType = Nullable.GetUnderlyingType(colType)!;
            dt.Columns.Add(p.Name, colType);
        }

        // 第二步:填充数据
        foreach (var item in source)
        {
            var row = dt.NewRow();
            foreach (var p in props)
            {
                var v = p.GetValue(item);
                row[p.Name] = v ?? DBNull.Value;     // null → DBNull
            }
            dt.Rows.Add(row);
        }
        return dt;
    }
}

使用:

var people = new[]
{
    new { Id = 1, Name = "Alice", Birth = new DateTime(1990, 1, 1) },
    new { Id = 2, Name = "Bob",   Birth = (DateTime?)null },
};

var dt = people.ToDataTable();

容易踩的三个坑

1. Nullable<T> 没取底层类型

如果直接用 p.PropertyType 作为列类型,DateTime? 字段在 DataTable 里会变成 Nullable<DateTime>(依然 OK)。但混用:类里有 DateTime? 又有 int 时:

  • 用底层类型 DateTime 列,可以存 DBNull 与非空值;
  • 用 Nullable<DateTime> 列也行——DataTable 内部对 Nullable<T> 是支持的。

关键是要显式取底层类型,否则对接 SqlBulkCopy 时某些数据库驱动会拒绝 Nullable<T> 列。

2. null 没转 DBNull.Value

DataRow 的列不接受 C# 的 null,必须用 DBNull.Value。否则:

row[p.Name] = v ?? DBNull.Value;   // ✅ 正确
row[p.Name] = v;                    // ❌ "The value for Column ... is DBNull."

3. 用匿名类型时反射拿不到属性

匿名类型 new { Id = 1, ... } 在同一程序集内反射 GetProperties 是拿得到的(编译器把它们生成成 internal 类型 + 属性)。但跨程序集或动态加载场景会失败——这时改用具名类。

性能优化

反射取属性列表每次都走 GetProperties,大数据量(百万级)会变慢。两种优化:

缓存属性

private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _cache = new();

static PropertyInfo[] GetProps(Type t) =>
    _cache.GetOrAdd(t, type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance));

委托代替 GetValue

// 编译期把 (T, PropertyInfo) 转成 (T) → object
static Func<T, object> MakeGetter<T>(PropertyInfo p)
{
    var param = Expression.Parameter(typeof(T));
    var access = Expression.Property(param, p);
    return Expression.Lambda<Func<T, object>>(
        Expression.Convert(access, typeof(object)), param).Compile();
}

单元测试

[Fact]
public void Should_Handle_Nullable_And_Nulls()
{
    var src = new[]
    {
        new Person { Id = 1, Name = "A", Birth = new DateTime(2000, 1, 1) },
        new Person { Id = 2, Name = "B", Birth = null },
    };

    var dt = src.ToDataTable();

    Assert.Equal(2, dt.Rows.Count);
    Assert.Equal(typeof(int), dt.Columns["Id"].DataType);
    Assert.Equal(typeof(DateTime), dt.Columns["Birth"].DataType);

    Assert.Equal(DBNull.Value, dt.Rows[1]["Birth"]);
}

record Person { public int Id; public string Name; public DateTime? Birth; }

使用准则

  1. 用 Nullable.GetUnderlyingType 取底层类型,列类型统一一致。
  2. 始终 v ?? DBNull.Value,不要相信调用方会过滤 null。
  3. 大数据量场景:缓存属性 + 用表达式树生成 getter。
  4. 导出 SqlBulkCopy 前关闭 DataTable 的约束 / 索引,避免写库时再做无意义的校验。
参考资料