一、为什么需要自定义 Converter

System.Text.Json(下称 STJ)是 .NET Core 3.0 起官方主推的高性能 JSON 库,基于 Utf8JsonReader/Utf8JsonWriter 的流式读写设计,序列化性能比 Newtonsoft.Json 快 1.3~5 倍。但它的默认行为在几个常见业务场景下并不符合预期:

  1. DateTime 序列化格式:STJ 默认输出 ISO 8601 格式(2026-10-15T10:30:00),而国内业务系统前后端约定通常是 2026-10-15 10:30:00,如果每个字段都手动格式化,既啰嗦又容易漏。
  2. 字符串前后空格:用户在表单里复制粘贴数据时经常带前后空格,落库前逐字段 Trim() 既冗余又容易遗漏。
  3. 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();
        }
    }
}

注意两个细节:

  1. Read 里要显式判断 JsonTokenType.Null——当 JSON 值为 null 时 STJ 会把控制权交给目标类型的 Converter,你必须自己返回 null;
  2. 空字符串按 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 本身不处理数据,它只负责两件事:

  1. CanConvert:声明这个工厂覆盖哪些类型;
  2. 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 的扩展核心,本文覆盖了四种典型姿势:

  1. 基础转换器(DateTimeJsonConverter):固定格式化逻辑,读写双向替换;
  2. 可空类型转换器(NullableDateTimeJsonConverter):处理 null 与空字符串的防御性解析;
  3. 横切规整(TrimStringJsonConverter):把全局字符串清洗收敛到序列化边界;
  4. 桥接转换器(DataTableJsonConverter):JsonDocument.ParseValue 拿值 + 第三方库处理 + WriteTo 写回的通用模式;
  5. 工厂模式(JsonConverterFactory):按类型动态分派,减少重复注册,支持泛型匹配。

掌握这套组合拳后,无论是统一日期格式、清洗脏数据,还是兼容遗留类型的序列化,都可以在 JSON 边界一层解决,业务代码保持干净。