在 .NET 中反序列化多态 JSON 而不失去类型安全

发布: (2026年2月2日 GMT+8 14:30)
8 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的具体文本内容,我将按照要求保留原始链接、格式和代码块,仅翻译正文部分。谢谢!

场景

恭喜你的新项目!你刚被指派负责构建交互式法国标志性地点地图的后端。我们需要从外部来源获取数据并通过我们的 API 对外提供。

不幸的是,外部 API 结构不佳:地点以混合的 集合出现,地标商店 混在同一个数组中。

[
  {
    "Name": "Bouillon Chartier",
    "Category": "Restaurant",
    "Latitude": 48.8759,
    "Longitude": 2.3587,
    "Description": "Simple french dishes in an authentic 1950s atmosphere.",
    "OpeningHours": "11:30 - 00:00",
    "HasParking": false
  },
  {
    "Name": "Mont Saint-Michel",
    "Period": "Gothic",
    "Latitude": 48.6361,
    "Longitude": -1.5115,
    "Description": "Medieval abbey and UNESCO World Heritage Site."
  },
  {
    "Name": "Grand Café Foy",
    "Category": "Café",
    "Latitude": 48.6930,
    "Longitude": 6.1827,
    "Description": "A french café at the heart of the Place Stanislas.",
    "OpeningHours": "07:30 - 02:00",
    "HasParking": false
  },
  {
    "Name": "Cathedral of Our Lady of Strasbourg",
    "Period": "Gothic",
    "Latitude": 48.5819,
    "Longitude": 7.7513,
    "Description": "Catholic cathedral among the finest examples of Rayonnant Gothic architecture."
  }
]

每个条目都有基本的地理坐标,但 特定类型 的属性各不相同。更糟的是,没有明确的区分标识,例如 Type: "Landmark"

“上帝对象” 方法

一种快速且粗糙的解决方案是创建一个包含所有可能属性的单一类,并将可能缺失的属性标记为可空。这通常被称为 God Object(上帝对象)

public record Location(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    // Shop properties
    ShopCategory? Category,
    string? OpeningHours,
    bool? HasParking,
    // Landmark properties
    HistoricalPeriod? Period,
    decimal? EntryFee);

反序列化变得非常简单:

var json = "...";
var locations = JsonSerializer.Deserialize<List<Location>>(json);

缺点

  • 我们永远不知道正在处理哪种类型的地点 → 需要大量 null 检查。
  • 模型没有反映业务领域(商店 vs. 地标)。
  • 新增地点类型时很快会变得难以管理。

领域驱动模型

而不是为了满足反序列化器而改造模型,让我们 按原样 对数据建模。

共享基类型

public abstract record MapLocation(
    string Name,
    double Latitude,
    double Longitude,
    string Description);

具体派生类型

public record Shop(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    ShopCategory Category,
    string OpeningHours,
    bool HasParking) : MapLocation(Name, Latitude, Longitude, Description);
public record Landmark(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    HistoricalPeriod HistoricalPeriod,
    decimal? EntryFee) : MapLocation(Name, Latitude, Longitude, Description)
{
    public bool IsFreeEntry => EntryFee is null;
}

现在类型系统告诉我们一个位置是 Shop 还是 Landmark,并且我们可以编码领域规则(例如,地标可以免费进入)。

尝试直接反序列化:

var locations = JsonSerializer.Deserialize<List<MapLocation>>(json);

…抛出:

System.NotSupportedException: Deserialization of interface or abstract types is not supported.

使用自定义转换器引导序列化器

System.Text.Json 提供了 JsonConverter 来控制类型的读取和写入方式。对于我们的多态层次结构,只需要实现 读取 即可。

转换器的骨架

public sealed class MapLocationJsonConverter : JsonConverter<MapLocation>
{
    public override MapLocation? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        // 1️⃣ Load the JSON object into a JsonDocument for inspection
        using var doc = JsonDocument.ParseValue(ref reader);
        var root = doc.RootElement;

        // 2️⃣ Decide which concrete type to deserialize into
        //    – If the JSON contains a "Category" property → Shop
        //    – If it contains a "Period" property   → Landmark
        //    (Add more rules as needed)

        if (root.TryGetProperty("Category", out _))
        {
            // Deserialize as Shop
            return JsonSerializer.Deserialize<Shop>(root.GetRawText(), options);
        }

        if (root.TryGetProperty("Period", out _))
        {
            // Deserialize as Landmark
            return JsonSerializer.Deserialize<Landmark>(root.GetRawText(), options);
        }

        // Fallback – could throw or return null
        throw new JsonException("Unable to determine MapLocation subtype.");
    }

    // We are not interested in writing in this example, but we must implement it.
    public override void Write(
        Utf8JsonWriter writer,
        MapLocation value,
        JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, (object)value, options);
}

注册转换器

var options = new JsonSerializerOptions
{
    Converters = { new MapLocationJsonConverter() },
    PropertyNameCaseInsensitive = true
};

var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);

现在 locations 包含了 ShopLandmark 对象的混合体,每个对象都拥有正确的强类型属性。

Source:

回顾

方法优点缺点
God Object (Location)简单,一行代码完成反序列化大量 null,缺乏类型安全,难以扩展
领域模型 + 自定义转换器强类型,贴合真实业务模型,可扩展代码稍多,需要维护转换器逻辑

在处理没有鉴别器的多态 JSON 时,自定义 JsonConverter(根据传入的负载检查并选择合适的具体类型)是 .NET 的惯用方案。

祝编码愉快! 🚀

=> throw new NotImplementedException();

public override void Write(
    Utf8JsonWriter writer,
    MapLocation value,
    JsonSerializerOptions options) =>
    throw new NotImplementedException();
}

如果我们的数据中有显式的鉴别器,例如 $type,我们本可以使用

[JsonDerivedType(typeof(...), typeDiscriminator: "...")]

并省去这整段代码。这里的难点在于鉴别器是 隐式 的——它体现在属性的有无上。

Read 方法

我们需要在 JSON 中找到一个能够判断当前条目是 Shop 还是 Landmark 的值。没有唯一的“正确”答案;在本例中,我们把包含 HistoricalPeriod 属性的条目视为 Landmark,其余的视为 Shop

public override MapLocation? Read(
    ref Utf8JsonReader reader,
    Type typeToConvert,
    JsonSerializerOptions options)
{
    // 先解析一次 JSON 片段,以便检查。
    using var jsonDoc = JsonDocument.ParseValue(ref reader);
    var root = jsonDoc.RootElement;

    // 对象是否包含 "Period" 属性?
    var hasHistoricalPeriod = root.TryGetProperty("Period", out _);

    // 反序列化为相应的具体类型。
    return hasHistoricalPeriod
        ? root.Deserialize<Landmark>(options)
        : root.Deserialize<Shop>(options);
}

注意: 这里使用 JsonDocument 仅为简化实现。对于大数据集,建议采用流式处理,以避免将整个片段加载进 DOM 带来的内存开销。

再次注册转换器

var options = new JsonSerializerOptions
{
    Converters = { new MapLocationJsonConverter() },
};

var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);

再次运行代码后,List 中只会包含相应具体类型的实例——成功啦!

我们达成的目标

  • 现在可以直接使用 .NET 的强类型来消费这些项。
  • 即使没有显式的鉴别器,也实现了多态反序列化。

回顾

在本文中,我们解决了在没有显式判别器的情况下反序列化多态 JSON 的挑战。

  1. God‑object 方法 – 一种快速但凌乱的解决方案,牺牲了类型安全,导致调试更困难,并且代码中充斥着 null 检查。
  2. 继承 + 自定义 JsonConverter – 一种更稳健的解决方案,虽然需要稍多的前期工作,但能够充分利用 .NET 的类型安全和可维护性。

Photo by David Clode on Unsplash

Back to Blog

相关文章

阅读更多 »

C# 中的方法和参数使用

定义和调用方法 csharp static void Greet { Console.WriteLine'Hello!'; } static void Main { Greet; // 方法调用 } // 输出: Hello! 方法...