从 SwitchData 项目中提取的实战经验 —— 一个项目里同时维护 DateTime 格式化、字符串自动 Trim、DataTable 序列化桥接,如何用 Factory 模式减少重复代码,以及 System.Text.Json 与 Newtonsoft.Json 并存时的双序列化器设计。
前言
ASP.NET Core 默认使用 System.Text.Json 作为 JSON
序列化引擎。相比
Newtonsoft.Json,它性能更好、体积更小,但也有一些痛点:
- DateTime 默认输出 ISO 8601 格式,前端常常需要
yyyy-MM-dd HH:mm:ss这样的自定义格式 - 字符串没有自动 Trim 的能力,脏数据(前后带空格)经常导致业务逻辑出错
- 对
System.Data.DataTable等 ADO.NET 类型原生不支持,需要自己桥接
本文基于 SwitchData 项目的实际代码,带你一步步实现一套完整的自定义 Converter 体系,包括:
- DateTimeJsonConverter — 自定义日期格式化
- NullableDateTimeJsonConverter — 处理可空 DateTime
- DateTimeConverterFactory — 用 Factory 合并两个 Converter
- TrimStringJsonConverter — 反序列化时自动 Trim
- DataTableJsonConverter — 用 Newtonsoft 桥接不支持的类型
- JsonHelper — 双序列化器统一封装
项目结构
所有 Converter 都放在 SwitchData.Common.Json
命名空间下:
SwitchData.Common/
└── Json/
├── DateTimeJsonConverter.cs // DateTime + 可空 DateTime + Factory
├── TrimStringJsonConverter.cs // 字符串 Trim
├── DataTableJsonConverter.cs // DataTable 桥接
└── JsonHelper.cs // 统一序列化入口
在 Program.cs 中统一注册到 MVC 的 JSON 选项里。
一、基础:DateTimeJsonConverter
最简单的 Converter 实现就是继承
JsonConverter<T>,重写 Read 和
Write 两个方法。
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SwitchData.Common.Json
{
public class DateTimeJsonConverter : JsonConverter<DateTime>
{
private readonly string _format;
public DateTimeJsonConverter(string format)
{
_format = format;
}
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return DateTime.Parse(reader.GetString());
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString(_format));
}
}
}
代码不多,关键点:
- 构造函数注入格式串:不是硬编码
yyyy-MM-dd HH:mm:ss,而是让调用方传入,这样同一个 Converter 可以复用到需要不同格式的场景 - Read 用
DateTime.Parse:兼容性好,能解析 ISO 8601、yyyy-MM-dd HH:mm:ss等多种格式 - Write 用
ToString(_format):格式化输出
可空类型几乎一样:
public class NullableDateTimeJsonConverter : JsonConverter<DateTime?>
{
private readonly string _format;
public NullableDateTimeJsonConverter(string format)
{
_format = format;
}
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null) return null;
var str = reader.GetString();
if (string.IsNullOrWhiteSpace(str)) return null;
return DateTime.Parse(str);
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(_format));
else
writer.WriteNullValue();
}
}
Read 多了 null 和空串的判断,Write 用 WriteNullValue()
输出 JSON null。
注册到 MVC
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(
new DateTimeJsonConverter("yyyy-MM-dd HH:mm:ss"));
options.JsonSerializerOptions.Converters.Add(
new NullableDateTimeJsonConverter("yyyy-MM-dd HH:mm:ss"));
});
注册之后,所有 Controller 的请求/响应里的 DateTime 和
DateTime? 都会自动套用这套格式。
二、进阶:DateTimeConverterFactory 消除重复
两个 Converter 逻辑几乎一样,唯一差别是泛型参数和 null 处理。能不能合并?
可以!JsonConverterFactory 就是干这个的:
public class DateTimeConverterFactory : JsonConverterFactory
{
private readonly string _format;
public DateTimeConverterFactory(string format)
{
_format = format;
}
// 告诉框架:我能处理 DateTime 和 DateTime?
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert == typeof(DateTime)
|| typeToConvert == typeof(DateTime?);
}
// 框架根据实际类型,让我们返回具体的 Converter 实例
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
if (typeToConvert == typeof(DateTime))
return new DateTimeJsonConverter(_format);
if (typeToConvert == typeof(DateTime?))
return new NullableDateTimeJsonConverter(_format);
throw new NotSupportedException($"Type {typeToConvert} is not supported.");
}
}
注册变成一行:
options.JsonSerializerOptions.Converters.Add(
new DateTimeConverterFactory("yyyy-MM-dd HH:mm:ss"));
Factory
的设计意图很明确:一种逻辑,多种类型。如果你以后还要支持
DateTimeOffset 或 DateOnly,只要在
CanConvert 里多加一个类型判断,再在
CreateConverter 里返回对应的 Converter 就行,Controller
的注册代码一行都不用改。
三、小技巧:TrimStringJsonConverter
业务系统里经常有这样的脏数据:用户输入 " admin "
而不是 "admin"。数据库存了脏值,查询时还要
Trim() 对比,很麻烦。用 Converter 自动处理:
public class TrimStringJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
return value?.Trim(); // null 安全
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
writer.WriteStringValue(value); // 输出不 Trim,保持原样
}
}
注意 Write 不 Trim:只在反序列化时清洗输入,序列化时保留数据原貌。
四、难点:DataTable 双序列化器桥接
System.Text.Json 对
DataTable、DataSet、DataRow 这些
ADO.NET 类型原生不支持。如果你在 Controller 返回一个
DataTable,默认会把整个内部结构序列化出来,体积巨大且前端无法使用。
SwitchData 的解法是 用 Newtonsoft.Json 处理 DataTable,再把结果喂回 System.Text.Json:
using System.Data;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SwitchData.Common.Json
{
public class DataTableJsonConverter : JsonConverter<DataTable>
{
public override DataTable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// 先把 System.Text.Json 的 reader 内容读成 JSON 字符串
using var doc = JsonDocument.ParseValue(ref reader);
string json = doc.RootElement.GetRawText();
// 再让 Newtonsoft 反序列化成 DataTable
return Newtonsoft.Json.JsonConvert.DeserializeObject<DataTable>(json);
}
public override void Write(Utf8JsonWriter writer, DataTable value, JsonSerializerOptions options)
{
// 让 Newtonsoft 先把 DataTable 序列化成 JSON 字符串
string json = Newtonsoft.Json.JsonConvert.SerializeObject(value);
// 再用 System.Text.Json 写入 UTF8 writer
using var doc = JsonDocument.Parse(json);
doc.WriteTo(writer);
}
}
}
这个”绕一圈”的写法看起来笨,实际非常聪明:
| 序列化器 | 优势 | 劣势 |
|---|---|---|
| System.Text.Json | 性能好、支持 .NET 8 特性 | DataTable 等 ADO.NET 类型不支持 |
| Newtonsoft.Json | 兼容性好、DataTable 开箱即用 | 性能稍差 |
桥接之后,Controller 里不管是返回 DataTable 还是普通的
List<T>,都走同一套
JsonSerializerOptions,外部调用者完全感知不到内部有两个序列化器在协作。
五、统一入口:JsonHelper
仅仅注册 Converter 还不够 ——
业务代码里可能需要手动序列化/反序列化,比如把对象转
JSON 存入 Redis,或者从消息队列里反序列化消息体。如果每个调用点都自己
new JsonSerializerOptions(),Converter 就丢失了。
所以需要一个统一的入口:
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Data;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace SwitchData.Common.Json
{
public static class JsonHelper
{
private const string DefaultDateTimeFormat = "yyyy-MM-dd HH:mm:ss";
// Lazy 保证线程安全的懒初始化
private static Lazy<JsonSerializerOptions> _lazyOptions = new(
() => CreateDefaultOptions(), true);
static JsonHelper()
{
// Newtonsoft 的默认设置(静态构造函数里注册,全局生效)
JsonConvert.DefaultSettings = CreateDefaultSettings;
}
/// <summary>允许外部用 MVC 注册好的 Options 覆盖默认值</summary>
public static void Configure(JsonSerializerOptions options)
{
if (_lazyOptions?.IsValueCreated == true) return; // 只允许配置一次
_lazyOptions = new Lazy<JsonSerializerOptions>(() => options, true);
}
private static JsonSerializerOptions CreateDefaultOptions()
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
options.Converters.Add(new DateTimeJsonConverter(DefaultDateTimeFormat));
options.Converters.Add(new NullableDateTimeJsonConverter(DefaultDateTimeFormat));
options.Converters.Add(new DataTableJsonConverter());
return options;
}
private static JsonSerializerSettings CreateDefaultSettings()
{
return new JsonSerializerSettings
{
Formatting = Formatting.Indented,
DateFormatString = DefaultDateTimeFormat
};
}
public static string Serialize(object obj)
{
if (obj == null || obj is DBNull) return "null";
// DataTable / IDataReader 交给 Newtonsoft 处理
if (obj is DataTable dataTable)
return JsonConvert.SerializeObject(dataTable);
if (obj is IDataReader reader)
return SerializeDataReader(reader);
// 其他类型走 System.Text.Json
return System.Text.Json.JsonSerializer.Serialize(obj, _lazyOptions.Value);
}
private static string SerializeDataReader(IDataReader reader)
{
var rows = new List<Dictionary<string, object>>();
while (reader.Read())
{
var dict = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
{
var value = reader.GetValue(i);
dict[reader.GetName(i)] = value == DBNull.Value ? null : value;
}
rows.Add(dict);
}
return System.Text.Json.JsonSerializer.Serialize(rows, _lazyOptions.Value);
}
public static T Deserialize<T>(string json)
{
if (typeof(T) == typeof(DataTable))
return (T)(object)JsonConvert.DeserializeObject<DataTable>(json);
return System.Text.Json.JsonSerializer.Deserialize<T>(json, _lazyOptions.Value);
}
}
}
几个关键设计点:
Lazy 单例
private static Lazy<JsonSerializerOptions> _lazyOptions = new(
() => CreateDefaultOptions(), true);
Lazy<T>(true) 的第二个参数是
LazyThreadSafetyMode.ExecutionAndPublication,保证多线程下只初始化一次。JsonSerializerOptions
本身就是线程安全的,全局复用性能最优。
Configure 桥接 MVC Options
Program.cs 里先注册 MVC 的
AddJsonOptions,得到一个带所有 Converter 的
JsonSerializerOptions,再传给
JsonHelper.Configure():
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new DateTimeJsonConverter(...));
options.JsonSerializerOptions.Converters.Add(new NullableDateTimeJsonConverter(...));
options.JsonSerializerOptions.Converters.Add(new DataTableJsonConverter());
JsonHelper.Configure(options.JsonSerializerOptions); // ← 关键
});
这样业务代码里调用 JsonHelper.Serialize(obj) 时,和 MVC
的 Controller 返回用的是同一套 Options,Converter
配置不会丢失。
类型路由
Serialize 方法里对 DataTable 和
IDataReader 做了显式判断,分别路由到 Newtonsoft
和手工转换逻辑:
if (obj is DataTable dataTable)
return JsonConvert.SerializeObject(dataTable);
if (obj is IDataReader reader)
return SerializeDataReader(reader);
IDataReader 直接转成
List<Dictionary<string, object>> 走
System.Text.Json,比 DataTable 更轻量。
Newtonsoft 默认设置
静态构造函数里注册
JsonConvert.DefaultSettings,让任何外部代码调用
JsonConvert.SerializeObject() 时也自动带上日期格式化:
static JsonHelper()
{
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
DateFormatString = "yyyy-MM-dd HH:mm:ss"
};
}
六、完整的 Program.cs 注册
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
// 基础选项
options.JsonSerializerOptions.WriteIndented = true;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
// 自定义 Converter
options.JsonSerializerOptions.Converters.Add(
new DateTimeJsonConverter("yyyy-MM-dd HH:mm:ss"));
options.JsonSerializerOptions.Converters.Add(
new NullableDateTimeJsonConverter("yyyy-MM-dd HH:mm:ss"));
options.JsonSerializerOptions.Converters.Add(
new DataTableJsonConverter());
options.JsonSerializerOptions.Converters.Add(
new JsonStringEnumConverter());
// 让 JsonHelper 与 MVC 共享同一套 Options
JsonHelper.Configure(options.JsonSerializerOptions);
});
七、效果验证
注册完 Converter 后,Controller 里的代码几乎不用改,效果立竿见影:
之前(默认行为)
{
"createTime": "2026-09-11T08:36:00+08:00",
"userName": " admin ",
"dataTable": {
"columns": [],
"rows": []
}
}
之后
{
"createTime": "2026-09-11 08:36:00",
"userName": "admin",
"dataTable": [
{ "id": 1, "name": "张三", "age": 25 },
{ "id": 2, "name": "李四", "age": 30 }
]
}
八、踩坑记录
坑 1:不要在 Configure 之后再修改 JsonSerializerOptions
JsonHelper.Configure 里用了
Lazy<T>,一旦被访问过就不会再初始化。如果 MVC
注册后又手动 AddConverter,JsonHelper
里的那份不会同步更新。
坑 2:DataTable JsonConverter 必须同时实现 Read 和 Write
只读不写会导致 Controller 返回 DataTable 时异常;只写不读会导致反序列化请求体里的 DataTable 时丢失数据。必须成对实现。
坑 3:TrimStringJsonConverter 会影响所有字符串属性
如果某些字符串字段需要保留前后空格(比如 Base64 编码、精确匹配的密钥),要特别注意。可以考虑用特性标记跳过,或者在 Controller 级别单独配置 Options。
坑 4:UnsafeRelaxedJsonEscaping 不是跳过所有转义
Encoder.UnsafeRelaxedJsonEscaping 是跳过 ASCII
范围外转义,让中文、emoji
直接输出。它不是跳过所有转义 —— <,
>, & 这些 HTML
敏感字符仍然会被转义,避免 XSS。
九、总结
| Converter | 场景 | 核心技巧 |
|---|---|---|
| DateTimeJsonConverter | 日期格式化 | 构造函数注入格式串 |
| NullableDateTimeJsonConverter | 可空日期 | 处理 TokenType.Null 和空串 |
| DateTimeConverterFactory | 合并重复 Converter | JsonConverterFactory + CanConvert/CreateConverter |
| TrimStringJsonConverter | 字符串清洗 | Read 时 Trim,Write 原样输出 |
| DataTableJsonConverter | ADO.NET 类型桥接 | Newtonsoft 中转,JsonDocument.Parse 桥接回 System.Text.Json |
| JsonHelper | 统一序列化入口 | Lazy 单例 + 双序列化器路由 + Configure 桥接 |
核心思想:不要想着”换掉 Newtonsoft”或”换掉
System.Text.Json”,而是让它们各取所长、协同工作。System.Text.Json
处理 90% 的日常序列化,Newtonsoft 兜底处理 DataTable
这类它不擅长的类型。JsonConverterFactory 和
Lazy<T> 是两个关键设计 ——
一个让扩展新类型变得零成本,一个让全局复用变得安全且高效。
本文所有代码来自 SwitchData 项目,一个基于 .NET 8 的网络配置数据管理系统。