问题陈述

把 DataTable 拆成字典列表再交给 Newtonsoft 是一种常见做法:

List<Dictionary<string, object>> result = new List<Dictionary<string, object>>();
foreach (DataRow dr in dt.Rows)
{
    Dictionary<string, object> a = new Dictionary<string, object>();
    foreach (DataColumn dc in dt.Columns)
    {
        a.Add(dc.ColumnName, dr[dc]);
    }
    result.Add(a);
}

JsonConvert.SerializeObject(result)

思路对——把"表格"投影成"行数组、每行是字段名到值的映射"——但只有这一段会留下几个隐患:

  1. DBNull.Value 会被原样塞进字典,Newtonsoft 默认序列化为空 JSON 对象 {},前端拿到后会判型失败。
  2. 所有值都是 object,数字可能是 int、long、decimal、double,反序列化时类型不可逆。
  3. 每行每列都要做一次装箱,百万行级 GC 压力明显。
  4. 下游若想再还原成强类型对象,必须先知道每个列的实际类型,Dictionary 自身不带这个信息。

几种方案

方案 A:扩展方法显式控制输出

把"行 → 字典"封装成可重用的扩展,并显式处理 DBNull 与类型推断:

using System.Data;
using System.Text.Json;
using System.Text.Json.Serialization;

public static class DataTableExtensions
{
    private static readonly JsonSerializerOptions s_options = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
    };

    public static List<Dictionary<string, object?>> ToRows(this DataTable table)
    {
        var rows = new List<Dictionary<string, object?>>(table.Rows.Count);
        foreach (DataRow row in table.Rows)
        {
            var dict = new Dictionary<string, object?>(table.Columns.Count, StringComparer.Ordinal);
            foreach (DataColumn col in table.Columns)
            {
                var value = row[col];
                dict[col.ColumnName] = value is DBNull ? null : value;
            }
            rows.Add(dict);
        }
        return rows;
    }

    public static string ToJson(this DataTable table) =>
        JsonSerializer.Serialize(table.ToRows(), s_options);
}

使用方式:

string json = dt.ToJson();

要点:

  • 显式把 DBNull 映射成 null,JSON 里就是 "foo": null,前端判型稳定。
  • StringComparer.Ordinal 避免大小写敏感问题,也省去列名重复时的额外开销。
  • JsonSerializerOptions 复用,避免每次序列化都重新构造。

方案 B:定义 DTO,用 DataTable 作中转

当消费方是固定的前端页面或下游服务时,强类型契约比字典可靠得多:

public sealed class OrderRow
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
    public decimal Amount { get; set; }
    public DateTime CreatedAt { get; set; }
}

public static class OrderMapper
{
    public static IReadOnlyList<OrderRow> Map(DataTable table)
    {
        var list = new List<OrderRow>(table.Rows.Count);
        foreach (DataRow r in table.Rows)
        {
            list.Add(new OrderRow
            {
                Id          = r.Field<int>("Id"),
                Customer    = r.Field<string>("Customer") ?? "",
                Amount      = r.Field<decimal>("Amount"),
                CreatedAt   = r.Field<DateTime>("CreatedAt"),
            });
        }
        return list;
    }
}

配合 ASP.NET Core Controller 时直接返回列表,框架会用 System.Text.Json 写驼峰命名的 JSON:

[HttpGet("orders")]
public IReadOnlyList<OrderRow> GetOrders()
{
    var dt = _repo.LoadOrders();
    return OrderMapper.Map(dt);
}

这种方式牺牲了一点灵活(新增列要改 DTO),换来的是类型安全、自动文档(Swagger/OpenAPI)和可测试性。

方案 C:大数据量下的流式写出

当行数达到几十万以上时,把整个列表先物化在内存里再序列化并不划算。可以直接用 Utf8JsonWriter 流式写:

public static async Task WriteJsonAsync(this DataTable table, Stream output, CancellationToken ct = default)
{
    await using var writer = new Utf8JsonWriter(output, new JsonWriterOptions { Indented = false });

    writer.WriteStartArray();
    foreach (DataRow row in table.Rows)
    {
        ct.ThrowIfCancellationRequested();
        writer.WriteStartObject();
        foreach (DataColumn col in table.Columns)
        {
            var v = row[col];
            if (v is null || v is DBNull)
            {
                writer.WriteNull(col.ColumnName);
            }
            else if (v is string s)
            {
                writer.WriteString(col.ColumnName, s);
            }
            else if (v is bool b)
            {
                writer.WriteBoolean(col.ColumnName, b);
            }
            else if (v is int i)
            {
                writer.WriteNumber(col.ColumnName, i);
            }
            else if (v is long l)
            {
                writer.WriteNumber(col.ColumnName, l);
            }
            else if (v is decimal dec)
            {
                writer.WriteNumber(col.ColumnName, dec);
            }
            else if (v is double d)
            {
                writer.WriteNumber(col.ColumnName, d);
            }
            else if (v is DateTime dt)
            {
                writer.WriteString(col.ColumnName, dt.ToString("O"));
            }
            else
            {
                writer.WriteString(col.ColumnName, v.ToString() ?? "");
            }
        }
        writer.WriteEndObject();
    }
    writer.WriteEndArray();
    await writer.FlushAsync(ct);
}

使用方式:

await dt.WriteJsonAsync(Response.Body, HttpContext.RequestAborted);

这样可以边读数据库边写 HTTP 响应体,不需要等所有行都装进内存。

常见坑点

直接序列化 DataTable: JsonSerializer.Serialize(dt) 会输出带 Column0..ColumnN、Rows[]、TableName 等冗余字段的结构,前端几乎不能直接消费。不要图省事直接序列化原始 DataTable。
DBNull 的双重身份: DataRow[i] 返回 DBNull.Value 而不是 C# 的 null。不显式转换会得到 "foo": {} 这种空对象字符串,前端判型逻辑全错位。统一在边界处把 DBNull 映射成 null。
Newtonsoft.Json 与 System.Text.Json: System.Text.Json 在 .NET Core 3.0 之后已是默认,吞吐与 AOT 友好。新项目没必要再引 Newtonsoft;老项目迁移也只需注意 TypeNameHandling、自定义 JsonConverter 与大小写策略等差异。
Jil 的现状: 备忘里那句"内容很多的时候 Jil 会出错"在 2018 年后基本没有现实意义。Jil 长期未维护,System.Text.Json 早已追平甚至超过其序列化速度,依赖 Jil 反而带来版本兼容和安全性风险。
不要把字典当成契约: Dictionary<string, object> 方便却不安全——下游拿到的 1 可能是 int、long 或 decimal,遇到 double 又会变成科学计数法。面向固定消费方时优先 DTO;面向通用探测时再考虑字典,并约定好数字类型。

选用准则

  1. 固定的下游、强类型契约 → 选方案 B(DTO + Controller 直接返回)。
  2. 需要支持任意列、临时调试或导出预览 → 选方案 A(扩展方法 + System.Text.Json)。
  3. 行数超过几十万、不能一次性加载进内存 → 选方案 C(Utf8JsonWriter 流式写出)。
  4. 任何情况下都不要直接把 DataTable 喂给 JsonSerializer 或 JsonConvert。
参考资料