从 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 体系,包括:

  1. DateTimeJsonConverter — 自定义日期格式化
  2. NullableDateTimeJsonConverter — 处理可空 DateTime
  3. DateTimeConverterFactory — 用 Factory 合并两个 Converter
  4. TrimStringJsonConverter — 反序列化时自动 Trim
  5. DataTableJsonConverter — 用 Newtonsoft 桥接不支持的类型
  6. 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>,重写 ReadWrite 两个方法。

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 的请求/响应里的 DateTimeDateTime? 都会自动套用这套格式。

二、进阶: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 的设计意图很明确:一种逻辑,多种类型。如果你以后还要支持 DateTimeOffsetDateOnly,只要在 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.JsonDataTableDataSetDataRow 这些 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 方法里对 DataTableIDataReader 做了显式判断,分别路由到 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 注册后又手动 AddConverterJsonHelper 里的那份不会同步更新。

坑 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 这类它不擅长的类型。JsonConverterFactoryLazy<T> 是两个关键设计 —— 一个让扩展新类型变得零成本,一个让全局复用变得安全且高效。

本文所有代码来自 SwitchData 项目,一个基于 .NET 8 的网络配置数据管理系统。