一、为什么需要自定义 Converter
System.Text.Json(下称 STJ)是 .NET Core 3.0 起官方主推的高性能 JSON
库,基于 Utf8JsonReader/Utf8JsonWriter
的流式读写设计,序列化性能比 Newtonsoft.Json 快 1.3~5
倍。但它的默认行为在几个常见业务场景下并不符合预期:
- DateTime 序列化格式:STJ 默认输出 ISO 8601
格式(
2026-10-15T10:30:00),而国内业务系统前后端约定通常是2026-10-15 10:30:00,如果每个字段都手动格式化,既啰嗦又容易漏。 - 字符串前后空格:用户在表单里复制粘贴数据时经常带前后空格,落库前逐字段
Trim()既冗余又容易遗漏。 - DataTable 序列化:很多存量系统用
DataTable承载查询结果,STJ 会把它当普通对象处理,序列化出一堆CaseSensitive、Columns等元数据属性,而 Newtonsoft.Json 天然支持DataTable。
这三个问题的统一解法就是自定义
JsonConverter。本文基于一个真实项目的完整实现,依次讲解三种典型
Converter 的写法,最后用 JsonConverterFactory
工厂模式收尾。
二、JsonConverter 的基本结构
STJ 中所有自定义转换器都继承抽象类
JsonConverter<T>,只需实现两个方法:
public abstract class JsonConverter<T>
{
// 反序列化:从 Utf8JsonReader 中读取 token,返回 T 类型对象
public abstract T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options);
// 序列化:把 T 类型对象写入 Utf8JsonWriter
public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);
}
两个关键点:
Read的参数ref Utf8JsonReader是 ref struct,不能存成字段、不能跨方法持有,只能在当前调用栈内消费;- reader 是游标式的,
Read被调用时游标已经停在目标值的位置,你只需要读取”当前值”,不需要自己跳 token。
三、实战一:DateTime 固定格式转换器
先解决最常见的需求——统一日期格式:
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));
}
}
实现思路非常直白:
- 写入时:
writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss")),把默认的 ISO 8601 替换成业务格式; - 读取时:前端可能传
2026-10-15 10:30:00也可能传标准 ISO 格式,用DateTime.Parse兼容两种写法。
四、实战二:可空类型的坑——DateTime 和 DateTime? 是两种类型
很多人在这里踩坑:注册了 DateTimeJsonConverter
后,DateTime?
类型的字段居然不走这个转换器。原因很简单——DateTime
和 Nullable<DateTime> 是两个不同的 Type,STJ
按类型精确匹配 Converter,可空类型必须单独写一个:
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)
{
// JSON null 显式处理
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
var str = reader.GetString();
// 空字符串也按 null 处理,兼容前端直接传 "" 的场景
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里要显式判断JsonTokenType.Null——当 JSON 值为null时 STJ 会把控制权交给目标类型的 Converter,你必须自己返回null;- 空字符串按
null处理是防御性设计:前端日期控件清空后经常提交""而不是null,直接Parse会抛异常。
五、实战三:全局字符串 Trim
表单数据带空格是高频问题,与其在每个赋值处 Trim,不如在
JSON 入口统一拦截:
public class TrimStringJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
return value?.Trim();
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
}
Read 时对所有入参字符串去空格,Write
时原样输出。一行代码解决了整个系统的字符串清洗问题——这也是 Converter
模式的价值所在:把横切的数据规整逻辑收敛到序列化边界。
六、实战四:DataTable 与 Newtonsoft 的桥接
这是最有技巧性的一个场景。STJ 不支持
DataTable,而项目里存量代码大量依赖 Newtonsoft 的
DataTable
序列化能力,直接迁移不现实。解法是写一个”桥接”转换器:框架层面统一用
STJ,遇到 DataTable 时内部委托给 Newtonsoft:
public class DataTableJsonConverter : JsonConverter<DataTable>
{
public override DataTable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// 1. 用 JsonDocument 把 reader 的内容解析为 DOM
using var doc = JsonDocument.ParseValue(ref reader);
string json = doc.RootElement.GetRawText();
// 2. 再交给 Newtonsoft 反序列化为 DataTable
return Newtonsoft.Json.JsonConvert.DeserializeObject<DataTable>(json);
}
public override void Write(Utf8JsonWriter writer, DataTable value, JsonSerializerOptions options)
{
// 1. 用 Newtonsoft 序列化为字符串
string json = Newtonsoft.Json.JsonConvert.SerializeObject(value);
// 2. 解析后写入 STJ 的 writer
using var doc = JsonDocument.Parse(json);
doc.WriteTo(writer);
}
}
这里的实现模式值得细品:
JsonDocument.ParseValue(ref reader):reader 是流式的、只能向前读,想把”当前位置”的值拿出来,就需要先用JsonDocument把它物化成 DOM(文档对象模型),再通过GetRawText()拿到原始 JSON 字符串;JsonDocument必须用using释放:它内部有内存池租约,不释放会造成内存泄漏;doc.WriteTo(writer):反向操作时,Newtonsoft 产出的 JSON 字符串解析成 DOM 后整体写入 STJ 的 writer,避免手工拼接 JSON 导致转义错误。
这个”借力”思路可以推广到任何 STJ 暂不支持的类型:先用
JsonDocument
把值拿出来,委托给成熟的第三方库处理,再把结果写回去。
七、进阶:JsonConverterFactory 工厂模式
上面的 DateTimeJsonConverter 和
NullableDateTimeJsonConverter
逻辑高度相似,却要写两个类、注册两次。STJ 提供了
JsonConverterFactory 来解决这个问题:
flowchart TD
A[序列化器遇到目标类型] --> B{遍历已注册 Converter}
B --> C{是 Factory?}
C -- 是 --> D[调用 CanConvert 判断]
D -- 命中 --> E[调用 CreateConverter<br/>动态创建对应 Converter]
D -- 未命中 --> B
C -- 否 --> F[直接调用 CanConvert]
F -- 命中 --> G[使用该 Converter]
F -- 未命中 --> B
E --> G
把两个 DateTime 转换器合并成一个工厂:
public class DateTimeConverterFactory : JsonConverterFactory
{
private readonly string _format;
public DateTimeConverterFactory(string format)
{
_format = format;
}
// 决定这个工厂"管"哪些类型
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 by DateTimeConverterFactory.");
}
}
JsonConverterFactory
本身不处理数据,它只负责两件事:
CanConvert:声明这个工厂覆盖哪些类型;CreateConverter:命中后按类型分派到具体的JsonConverter<T>实例。
除了减少重复代码,工厂还有个隐藏优势:CanConvert
可以基于类型做泛型匹配(比如”所有实现 IList<>
的类型”),这是普通 Converter 做不到的。
八、注册:全局配置与共享 Options
在 ASP.NET Core 中通过 AddJsonOptions 全局注册:
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; // 反序列化忽略大小写
options.JsonSerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; // 中文不转义
options.JsonSerializerOptions.Converters.Add(new DateTimeConverterFactory("yyyy-MM-dd HH:mm:ss"));
options.JsonSerializerOptions.Converters.Add(new DataTableJsonConverter());
});
如果是自建的序列化工具类,注意用 Lazy<T> 缓存
Options——JsonSerializerOptions
的元数据缓存是按实例隔离的,每次 new 一个 Options
都会重建整套类型元数据缓存,反复创建会显著拖慢序列化:
public static class JsonHelper
{
private const string DefaultDateTimeFormat = "yyyy-MM-dd HH:mm:ss";
private static Lazy<JsonSerializerOptions> _lazyOptions = new(() => CreateDefaultOptions(), true);
public static string Serialize(object obj)
{
if (obj == null || obj is DBNull)
{
return "null";
}
return JsonSerializer.Serialize(obj, _lazyOptions.Value);
}
public static T Deserialize<T>(string json)
{
return JsonSerializer.Deserialize<T>(json, _lazyOptions.Value);
}
}
另外还有第三种注册方式——特性标注,适合只对个别属性生效的场景:
public class OrderDto
{
[JsonConverter(typeof(DateTimeJsonConverter), "yyyy-MM-dd")]
public DateTime CreatedAt { get; set; }
}
九、踩坑总结
| 坑 | 说明 |
|---|---|
DateTime? 不走转换器 |
可空类型是独立 Type,必须单独提供
JsonConverter<DateTime?> 或用工厂 |
| Converter 顺序 | 多个 Converter
都能匹配同一类型时,先注册的优先;[JsonConverter]
特性优先级高于全局注册 |
| null 的处理 | 值为 null 时 STJ 也会调用你的
Read,需要自己判断 JsonTokenType.Null 返回
null |
JsonDocument 不释放 |
内部有内存池租约,必须 using,否则内存泄漏 |
| 反复 new JsonSerializerOptions | 元数据缓存按实例隔离,应全局共享一个 Options(推荐
Lazy<T> 单例) |
中文被转义成 \u4e2d\u6587 |
默认 Encoder 会转义非 ASCII 字符,需设置
JavaScriptEncoder.UnsafeRelaxedJsonEscaping |
十、总结
自定义 JsonConverter 是 System.Text.Json
的扩展核心,本文覆盖了四种典型姿势:
- 基础转换器(
DateTimeJsonConverter):固定格式化逻辑,读写双向替换; - 可空类型转换器(
NullableDateTimeJsonConverter):处理null与空字符串的防御性解析; - 横切规整(
TrimStringJsonConverter):把全局字符串清洗收敛到序列化边界; - 桥接转换器(
DataTableJsonConverter):JsonDocument.ParseValue拿值 + 第三方库处理 +WriteTo写回的通用模式; - 工厂模式(
JsonConverterFactory):按类型动态分派,减少重复注册,支持泛型匹配。
掌握这套组合拳后,无论是统一日期格式、清洗脏数据,还是兼容遗留类型的序列化,都可以在 JSON 边界一层解决,业务代码保持干净。