為什麼要換

.NET Core 之後,Microsoft 把 System.Text.Json 直接內建進框架,不用另外裝 NuGet 套件,少一個外部相依。效能上,官方 benchmark 顯示序列化速度比 Newtonsoft.Json 快約 2 倍,記憶體分配也更省。對大多數 Web API 專案來說,日常夠用。

真正讓人猶豫的是行為差異。兩套函式庫有幾個預設值不一樣,遷移時踩到的坑大多出在這裡。我前後把幾個中型 API 專案換過來,下面這幾個點是實際撞到、查到根因才搞定的。


坑一:JSON 輸出變成小駝峰

問題現象

把序列化從 Newtonsoft.Json 換成 System.Text.Json 之後,原本 "UserName" 這類 Pascal Case 輸出,跑到 ASP.NET Core Web API 回傳時突然變成 "userName",前端開始炸。

真正的原因

這個問題有個很容易踩的誤區:System.Text.Json 函式庫本身的預設 PropertyNamingPolicynull,也就是保持原始屬性名稱不變。你單獨呼叫 JsonSerializer.Serialize() 不帶 options,輸出會是 "UserName",不會變小駝峰。

問題出在 ASP.NET Core Web API 的 pipeline。AddControllers() 內部預設會把 PropertyNamingPolicy 設成 JsonNamingPolicy.CamelCase,所以從 Controller 回傳的 JSON 才會變 camelCase。踩坑的點是 ASP.NET Core 的預設,不是 System.Text.Json 本身。我一開始也以為是函式庫的鍋,寫了一段純序列化的測試才確認原始屬性名根本沒被動過。

解法

想讓 API 回傳 PascalCase(和 Newtonsoft.Json 舊行為一致),在 Program.cs 調整:

1
2
3
4
5
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = null; // 保持原始屬性名稱
});

如果只是在非 Web 情境做序列化,直接帶 options 就好:

1
2
3
4
5
6
7
8
using System.Text.Json;

var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null
};

string json = JsonSerializer.Serialize(myObject, options);

坑二:日期時間格式

System.Text.Json 預設按 ISO 8601-1:2019 extended profile 輸出,例如 "2024-06-27T18:43:20+08:00"。Newtonsoft.Json 預設也是 ISO 8601,例如 "2024-06-27T18:43:20+08:00",這部分通常沒差。

差別在自訂格式。Newtonsoft.Json 允許用 DateFormatString 直接指定格式字串(如 "yyyy-MM-dd"),System.Text.Json 沒有對應的設定選項,要寫自訂 JsonConverter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

public class DateOnlyJsonConverter : JsonConverter<DateTime>
{
private const string Format = "yyyy-MM-dd";

public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return DateTime.ParseExact(reader.GetString()!, Format, CultureInfo.InvariantCulture);
}

public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
}
}

註冊方式:

1
2
var options = new JsonSerializerOptions();
options.Converters.Add(new DateOnlyJsonConverter());

或在 ASP.NET Core:

1
2
3
4
5
builder.Services.AddControllers()
.AddJsonOptions(opts =>
{
opts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
});

順帶一提,.NET 6 之後有了 DateOnly / TimeOnly 型別,如果你的場景就是「只要日期不要時間」,直接用 DateOnly 比硬寫 converter 處理 DateTime 乾淨很多。


坑三:null 值處理

序列化這一邊,兩套函式庫的預設結果一樣:null 屬性都會被寫進 JSON。底層機制不同,但輸出看起來相同。

反序列化才是會咬人的地方。先看序列化的對照:

序列化 null 行為 Newtonsoft.Json System.Text.Json
預設 NullValueHandling.Include(寫入 null) DefaultIgnoreCondition = Never(寫入 null)
忽略 null NullValueHandling.Ignore DefaultIgnoreCondition = WhenWritingNull

反序列化遇到 JSON 裡的 null 要塞進非可空的「實值型別」(value type,如 intDateTime),兩套的行為要分情況講,這是遷移時最容易誤判的一點:

  • Newtonsoft.Json 預設(NullValueHandling.Include)也會丟例外,訊息類似 Error converting value {null} to type 'System.Int32'。它「不丟例外」只發生在一個特定組合:NullValueHandling 設成 Ignore,且 JSON 裡是 null 對上非可空實值型別,此時 Newtonsoft 會直接略過、保留該屬性原本的值。
  • System.Text.Json 在同樣的情境下還是會丟 JsonException,沒有對應的「靜默略過」開關。

換句話講,如果你原本的 Newtonsoft 專案靠 NullValueHandling.Ignore 默默吃掉這些 null,遷移到 System.Text.Json 之後就會冒出反序列化例外。官方文件對此的建議很直接:把目標屬性改成可空(intint?),或為該型別寫一個能處理 null 的 converter。遷移後若出現莫名的反序列化例外,先往這裡查。

忽略 null 屬性的序列化設定方式:

1
2
3
4
5
6
7
using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

順帶補一句:上面講的「丟例外」針對的是實值型別。非可空的「參考型別」(reference type)預設兩邊都不會丟,要到 .NET 9 開了 RespectNullableAnnotations 才會對參考型別的 nullable 標註動真格。日常遷移踩到的幾乎都是實值型別這一種。


坑四:多型與繼承

Newtonsoft.Json 靠 TypeNameHandling 處理多型,System.Text.Json 的對應方式依 .NET 版本不同。

.NET 7 以上內建 [JsonPolymorphic] / [JsonDerivedType],夠用:

1
2
3
4
5
6
7
8
9
10
11
12
using System.Text.Json.Serialization;

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Dog), typeDiscriminator: "dog")]
[JsonDerivedType(typeof(Cat), typeDiscriminator: "cat")]
public abstract class Animal
{
public string Name { get; set; } = string.Empty;
}

public class Dog : Animal { public string Breed { get; set; } = string.Empty; }
public class Cat : Animal { public bool IsIndoor { get; set; } }

.NET 6 以下沒有原生支援,要自己寫 JsonConverter<T> 覆蓋 Read / Write,工作量不低。我在一個還卡在 .NET 6 的專案上手寫過一次,光是處理 discriminator 讀寫和 fallback 就花掉大半天,這是 .NET 6 以下版本遷移時最麻煩的部分。

順帶提醒:TypeNameHandling.All / Auto 在 Newtonsoft 上是著名的反序列化攻擊面,遷移正好是把這個拔掉的好時機,別只是想著怎麼「等價搬過去」。


坑五:循環引用

這是遷移 EF Core 專案時真的很容易爆的問題,得先把兩邊的預設搞清楚。

關鍵在於:Newtonsoft.Json 預設遇到循環引用也是丟例外。它的 ReferenceLoopHandling 預設值是 Error,碰到 OrderOrderItemOrder 這種雙向導覽屬性,會丟 JsonSerializationException: Self referencing loop detected for property。很多人以為 Newtonsoft「天生就會略過循環」,那是因為專案裡早就設了 ReferenceLoopHandling.Ignore(EF Core 專案幾乎是標配),不是預設行為。

System.Text.Json 預設碰到循環同樣丟例外,訊息是 JsonException: A possible object cycle was detected。真正的遷移差異不是「一邊丟一邊不丟」,而是你原本在 Newtonsoft 設好的 ReferenceLoopHandling.Ignore 沒有自動帶過來,得在 System.Text.Json 這邊重新設一次對應選項。

System.Text.Json 有兩種處理方式。

方式一:ReferenceHandler.IgnoreCycles(.NET 6 以上,推薦)。循環引用的屬性會輸出為 null,JSON 結構乾淨,大多數情境夠用:

1
2
3
4
5
6
7
using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};

要注意它和 Newtonsoft 的 ReferenceLoopHandling.Ignore 不完全相同:Newtonsoft 是直接略過那個造成循環的物件參考,System.Text.Json 則是把循環位置塞成 null token。輸出形狀會有差,前端要先確認接得住 null。

方式二:ReferenceHandler.Preserve(.NET 5 以上)。用 $id$ref 等 metadata 保留完整引用關係,可以完整反序列化回來,但 JSON 結構變複雜,非 System.Text.Json 的反序列化端不一定讀得懂:

1
2
3
4
var options = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.Preserve
};

遷移 EF Core 專案時,建議優先試 IgnoreCycles,確認前端或下游系統能接受 null 再上線。我自己踩過的彎路是:一開始為了「完整保留結構」選了 Preserve,結果前端那邊用的不是 .NET,根本解析不了 $ref,最後還是退回 IgnoreCycles 加上幾個 DTO。早知道一開始就該回傳專用 DTO 而不是直接序列化 Entity。


遷移步驟整理

  1. 盤點現有用法:搜尋 JsonConvert.JObjectJArray[JsonProperty] 等,列出需要處理的點。
  2. 替換套件引用:移除 Newtonsoft.Json,改用內建 System.Text.Jsonusing System.Text.Json;)。
  3. 確認命名策略:在 ASP.NET Core 的 AddJsonOptions 設定 PropertyNamingPolicy
  4. 日期格式:有非標準日期格式就補自訂 JsonConverter,或考慮換用 DateOnly / TimeOnly
  5. null 處理:確認 DefaultIgnoreCondition 設定符合預期,特別注意反序列化遇到 null 對非可空實值型別的嚴格性。
  6. 多型序列化:.NET 7 以上改用 [JsonPolymorphic];.NET 6 以下評估是否值得手寫 converter 或保留 Newtonsoft.Json。
  7. 循環引用:有 EF Core 導覽屬性的專案加上 ReferenceHandler.IgnoreCycles,並把原本散在各處的 ReferenceLoopHandling.Ignore 一併清掉。
  8. 跑完整測試:尤其是反序列化路徑,System.Text.Json 比 Newtonsoft.Json 嚴格很多。

結語

把幾個中型 API 專案換完,最花時間的不是寫 converter,而是搞清楚每個「預設值」到底是誰設的。命名策略其實是 ASP.NET Core pipeline 在動手腳、循環引用兩邊預設都丟例外(差的是你原本設過的 Ignore 沒帶過來)、null 對非可空實值型別兩邊也都會丟。把這三個「以為有差、其實是設定差」的點理清楚,剩下的就是體力活。

實際成果是:兩個 .NET 8 專案完全拔掉 Newtonsoft.Json,少一個相依、序列化路徑的記憶體分配也降下來。.NET 7 以上的專案基本上沒理由繼續掛著 Newtonsoft.Json;.NET 6 以下如果有複雜的多型需求,保留 Newtonsoft.Json 是合理選擇,不必為了「官方推薦」硬換。

參考資料