在 .NET 中反序列化多态 JSON 而不失去类型安全
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 包含了 Shop 和 Landmark 对象的混合体,每个对象都拥有正确的强类型属性。
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 的挑战。
- God‑object 方法 – 一种快速但凌乱的解决方案,牺牲了类型安全,导致调试更困难,并且代码中充斥着 null 检查。
- 继承 + 自定义
JsonConverter– 一种更稳健的解决方案,虽然需要稍多的前期工作,但能够充分利用 .NET 的类型安全和可维护性。
Photo by David Clode on Unsplash