问题陈述
把 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)
思路对——把"表格"投影成"行数组、每行是字段名到值的映射"——但只有这一段会留下几个隐患:
DBNull.Value会被原样塞进字典,Newtonsoft 默认序列化为空 JSON 对象{},前端拿到后会判型失败。- 所有值都是
object,数字可能是int、long、decimal、double,反序列化时类型不可逆。 - 每行每列都要做一次装箱,百万行级 GC 压力明显。
- 下游若想再还原成强类型对象,必须先知道每个列的实际类型,
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;面向通用探测时再考虑字典,并约定好数字类型。
选用准则
- 固定的下游、强类型契约 → 选方案 B(DTO + Controller 直接返回)。
- 需要支持任意列、临时调试或导出预览 → 选方案 A(扩展方法 +
System.Text.Json)。 - 行数超过几十万、不能一次性加载进内存 → 选方案 C(
Utf8JsonWriter流式写出)。 - 任何情况下都不要直接把
DataTable喂给JsonSerializer或JsonConvert。