為什麼要換
.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 函式庫本身的預設 PropertyNamingPolicy 是 null,也就是保持原始屬性名稱不變。你單獨呼叫 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 | builder.Services.AddControllers() |
如果只是在非 Web 情境做序列化,直接帶 options 就好:
1 | using System.Text.Json; |
坑二:日期時間格式
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 | using System; |
註冊方式:
1 | var options = new JsonSerializerOptions(); |
或在 ASP.NET Core:
1 | builder.Services.AddControllers() |
順帶一提,.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,如 int、DateTime),兩套的行為要分情況講,這是遷移時最容易誤判的一點:
- 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 之後就會冒出反序列化例外。官方文件對此的建議很直接:把目標屬性改成可空(int 改 int?),或為該型別寫一個能處理 null 的 converter。遷移後若出現莫名的反序列化例外,先往這裡查。
忽略 null 屬性的序列化設定方式:
1 | using System.Text.Json; |
順帶補一句:上面講的「丟例外」針對的是實值型別。非可空的「參考型別」(reference type)預設兩邊都不會丟,要到 .NET 9 開了 RespectNullableAnnotations 才會對參考型別的 nullable 標註動真格。日常遷移踩到的幾乎都是實值型別這一種。
坑四:多型與繼承
Newtonsoft.Json 靠 TypeNameHandling 處理多型,System.Text.Json 的對應方式依 .NET 版本不同。
.NET 7 以上內建 [JsonPolymorphic] / [JsonDerivedType],夠用:
1 | using System.Text.Json.Serialization; |
.NET 6 以下沒有原生支援,要自己寫 JsonConverter<T> 覆蓋 Read / Write,工作量不低。我在一個還卡在 .NET 6 的專案上手寫過一次,光是處理 discriminator 讀寫和 fallback 就花掉大半天,這是 .NET 6 以下版本遷移時最麻煩的部分。
順帶提醒:TypeNameHandling.All / Auto 在 Newtonsoft 上是著名的反序列化攻擊面,遷移正好是把這個拔掉的好時機,別只是想著怎麼「等價搬過去」。
坑五:循環引用
這是遷移 EF Core 專案時真的很容易爆的問題,得先把兩邊的預設搞清楚。
關鍵在於:Newtonsoft.Json 預設遇到循環引用也是丟例外。它的 ReferenceLoopHandling 預設值是 Error,碰到 Order → OrderItem → Order 這種雙向導覽屬性,會丟 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 | using System.Text.Json; |
要注意它和 Newtonsoft 的 ReferenceLoopHandling.Ignore 不完全相同:Newtonsoft 是直接略過那個造成循環的物件參考,System.Text.Json 則是把循環位置塞成 null token。輸出形狀會有差,前端要先確認接得住 null。
方式二:ReferenceHandler.Preserve(.NET 5 以上)。用 $id、$ref 等 metadata 保留完整引用關係,可以完整反序列化回來,但 JSON 結構變複雜,非 System.Text.Json 的反序列化端不一定讀得懂:
1 | var options = new JsonSerializerOptions |
遷移 EF Core 專案時,建議優先試 IgnoreCycles,確認前端或下游系統能接受 null 再上線。我自己踩過的彎路是:一開始為了「完整保留結構」選了 Preserve,結果前端那邊用的不是 .NET,根本解析不了 $ref,最後還是退回 IgnoreCycles 加上幾個 DTO。早知道一開始就該回傳專用 DTO 而不是直接序列化 Entity。
遷移步驟整理
- 盤點現有用法:搜尋
JsonConvert.、JObject、JArray、[JsonProperty]等,列出需要處理的點。 - 替換套件引用:移除
Newtonsoft.Json,改用內建System.Text.Json(using System.Text.Json;)。 - 確認命名策略:在 ASP.NET Core 的
AddJsonOptions設定PropertyNamingPolicy。 - 日期格式:有非標準日期格式就補自訂
JsonConverter,或考慮換用DateOnly/TimeOnly。 - null 處理:確認
DefaultIgnoreCondition設定符合預期,特別注意反序列化遇到 null 對非可空實值型別的嚴格性。 - 多型序列化:.NET 7 以上改用
[JsonPolymorphic];.NET 6 以下評估是否值得手寫 converter 或保留 Newtonsoft.Json。 - 循環引用:有 EF Core 導覽屬性的專案加上
ReferenceHandler.IgnoreCycles,並把原本散在各處的ReferenceLoopHandling.Ignore一併清掉。 - 跑完整測試:尤其是反序列化路徑,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 是合理選擇,不必為了「官方推薦」硬換。




