曾幾何時,在.NET的世界里,Newtonsoft.Json
如同一位德高望重的王者,無人不曉。直到有一天,一位名叫System.Text.Json
(后文簡稱STJ)的新貴悄然登場。它出身名門(.NET官方),身懷絕技(號稱性能超群),本應是明日之星,卻被無數開發者貼上了“坑王”、“難用”、“反人類”的標簽。
無數個深夜,開發者們為了解決一個看似簡單的JSON序列化問題,從STJ切換回NSJ,嘴里念叨著:“STJ,勸退了。”然后默默地Install-Package Newtonsoft.Json
,仿佛這才是解決問題的唯一“騷操作”。
但是,這一切真的公平嗎?時過境遷,如今.NET 10的預覽版都已發布,STJ早已不是當年的吳下阿蒙。那些曾經讓你抓狂的“坑”,有多少只是因為誤解了它的設計哲學?有多少早已被新版本填平?
今天,就讓我們一起為STJ來一場轟轟烈烈的“正名運動”,讓你徹底告別因它而起的“996”!
告別加班:STJ與Newtonsoft行為對齊實戰
很多時候,我們覺得STJ“不好用”,僅僅是因為它的默認行為和牛頓不一樣。STJ的設計哲學是:性能優先、安全第一、嚴格遵守RFC 8259規范。而牛頓則更傾向于靈活方便、兼容并包。下面我們就通過一個個小故事和代碼示例,看看如何通過簡單的配置,讓STJ的行為像我們熟悉的老朋友牛頓一樣。
1. 大小寫問題:前端傳的name
,我C#的Name
怎么就收不到了?
背景故事:
小王剛接手一個前后端分離的項目,前端用JS,遵循駝峰命名(camelCase),傳來一個JSON:{"name": "張三", "age": 18}
。后端的C#模型用的是帕斯卡命名(PascalCase):public class User { public string Name { get; set; } public int Age { get; set; } }
。結果用STJ一反序列化,user.Name
和user.Age
全都是null
和0
!小王抓耳撓腮,查了半天才發現是大小寫匹配問題,差點就要加班調試一晚上了。
騷操作揭秘:
STJ為了極致性能,默認是區分大小寫的。而牛頓默認是不區分的。我們只需一個配置項就能解決問題。
using System.Text.Json;
var jsonFromJs = "{\"name\": \"張三\", \"age\": 18}";
var optionsDefault = new JsonSerializerOptions();
var userDefault = JsonSerializer.Deserialize<User>(jsonFromJs, optionsDefault);
Console.WriteLine($"默認行為: Name = {userDefault.Name}");
var optionsInsensitive = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var userInsensitive = JsonSerializer.Deserialize<User>(jsonFromJs, optionsInsensitive);
Console.WriteLine($"開啟不區分大小寫: Name = {userInsensitive.Name}");
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
小貼士: 在ASP.NET Core的Web API項目中,默認已經幫你開啟了PropertyNameCaseInsensitive = true
,所以你可能根本沒遇到過這個問題,但如果你手動調用JsonSerializer
,就需要注意了。
2. 命名策略:我的UserName
怎么就不能變成userName
?
背景故事:
小李的后端API返回的JSON字段都是Pascal風格,比如{"UserName": "Lisi", "IsEnabled": true}
。前端小伙伴抱怨說這不符合JS社區的規范,希望能統一用駝峰命名{"userName": "Lisi", "isEnabled": true}
。小李心想,難道要把所有C#屬性名都改成小寫開頭?這也太不優雅了!
騷操作揭秘:
當然不用!STJ提供了命名策略(Naming Policy),讓你輕松轉換。
using System.Text.Json;
var user = new User { UserName = "Lisi", IsEnabled = true };
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默認輸出:\n" + jsonDefault);
var optionsCamelCase = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var jsonCamelCase = JsonSerializer.Serialize(user, optionsCamelCase);
Console.WriteLine("\n駝峰輸出:\n" + jsonCamelCase);
public class User
{
public string UserName { get; set; }
public bool IsEnabled { get; set; }
}
小貼士: 同樣,在ASP.NET Core中,默認也幫你配置了駝峰命名策略。這就是為什么你的API天生就符合前端規范。
3. 注釋和尾隨逗號:這JSON怎么就不“合法”了?
背景故事:
老張需要處理一批由其他系統生成的JSON配置文件,這些文件里竟然帶了注釋,而且數組末尾還可能有個多余的逗號,比如 [1, 2, 3, /*這是注釋*/]
。Newtonsoft.Json
處理這些文件毫無壓力,但System.Text.Json
一上來就拋出JsonException
,直接罷工。
騷操作揭秘:
STJ嚴格遵守RFC 8259規范,該規范不允許注釋和尾隨逗號。但為了兼容性,它也提供了開關。
using System.Text.Json;
var nonStandardJson = @"{
""name"": ""帶注釋的JSON"",
""data"": [
1,
2,
3,
]
}";
try
{
JsonSerializer.Deserialize<object>(nonStandardJson);
}
catch (JsonException ex)
{
Console.WriteLine("默認行為,果然報錯了: " + ex.Message);
}
var tolerantOptions = new JsonSerializerOptions
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
var deserializedObject = JsonSerializer.Deserialize<object>(nonStandardJson, tolerantOptions);
Console.WriteLine("\n開啟兼容模式后,成功解析!");
4. null
值的處理:滿屏的null
看著好煩!
背景故事:
小趙的API返回的用戶信息里,有些字段是可選的,比如MiddleName
。當這些字段沒有值時,序列化出的JSON里會包含"middleName": null
。這不僅增加了網絡傳輸的數據量,前端同學也覺得處理起來很麻煩,他們希望null
值的字段干脆就不要出現在JSON里。
騷操作揭秘:
牛頓通過NullValueHandling.Ignore
可以輕松實現,STJ同樣可以。
using System.Text.Json;
using System.Text.Json.Serialization;
var user = new User { FirstName = "San", LastName = "Zhang", MiddleName = null };
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默認輸出:\n" + jsonDefault);
var optionsIgnoreNull = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
var jsonIgnoreNull = JsonSerializer.Serialize(user, optionsIgnoreNull);
Console.WriteLine("\n忽略null值輸出:\n" + jsonIgnoreNull);
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string MiddleName { get; set; }
}
5. 帶引號的數字:"age": "30"
也能是數字?
背景故事:
小孫在對接一個非常“古老”的第三方API,返回的JSON里,所有的數字都是用字符串表示的,例如{"age": "30"}
。STJ在反序列化到int Age
屬性時直接拋出異常,因為它認為"30"
是字符串,不是數字。難道還得先反序列化成string
再手動int.Parse
?
騷操作揭秘:
不用那么麻煩,STJ早就想到了這種不規范但常見的情況。
using System.Text.Json;
using System.Text.Json.Serialization;
var jsonWithQuotedNumber = @"{""Age"": ""30""}";
try
{
JsonSerializer.Deserialize<User>(jsonWithQuotedNumber);
}
catch (JsonException ex)
{
Console.WriteLine("默認行為,報錯了: " + ex.Message);
}
var optionsAllowQuotedNumbers = new JsonSerializerOptions
{
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
var user = JsonSerializer.Deserialize<User>(jsonWithQuotedNumber, optionsAllowQuotedNumbers);
Console.WriteLine($"\n開啟兼容模式后,Age = {user.Age}");
public class User
{
public int Age { get; set; }
}
6. 循環引用:我和我的老板,誰先序列化?
背景故事:
小錢在使用Entity Framework時,遇到了經典難題:Employee
對象有個Manager
屬性,Manager
對象又有個DirectReports
列表包含了這個Employee
。一序列化,就陷入了“你中有我,我中有你”的無限循環,最終JsonException
爆棧。
騷操作揭秘:
這是STJ在.NET 5和.NET 6中重點解決的問題。現在我們有兩種選擇。
using System.Text.Json;
using System.Text.Json.Serialization;
var manager = new Employee { Name = "老板" };
var employee = new Employee { Name = "小錢", Manager = manager };
manager.DirectReports = new List<Employee> { employee };
try
{
JsonSerializer.Serialize(employee);
}
catch (JsonException ex)
{
Console.WriteLine("默認行為,循環引用報錯: " + ex.Message);
}
var optionsIgnoreCycles = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
WriteIndented = true
};
var jsonIgnoreCycles = JsonSerializer.Serialize(employee, optionsIgnoreCycles);
Console.WriteLine("\n忽略循環引用輸出:\n" + jsonIgnoreCycles);
public class Employee
{
public string Name { get; set; }
public Employee Manager { get; set; }
public List<Employee> DirectReports { get; set; }
}
小貼士: 還有一個ReferenceHandler.Preserve
選項,它會通過$id
和$ref
元數據來完整保留對象圖,適合需要完美往返(round-trip)序列化的場景,但生成的JSON通用性較差。對于Web API,IgnoreCycles
通常是更好的選擇。
7. 枚舉變字符串:別再給我返回0
和1
了!
背景故事:
小周的API里有個Gender
枚舉,序列化后默認變成了數字0
或1
。前端每次都要查文檔才知道0
是Male
,1
是Female
。這溝通成本也太高了!
騷操作揭秘:
一個轉換器就能搞定,讓你的枚舉變得可讀。
using System.Text.Json;
using System.Text.Json.Serialization;
var user = new User { Gender = Gender.Male };
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默認輸出:\n" + jsonDefault);
var optionsEnumAsString = new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() },
WriteIndented = true
};
var jsonEnumAsString = JsonSerializer.Serialize(user, optionsEnumAsString);
Console.WriteLine("\n枚舉轉字符串輸出:\n" + jsonEnumAsString);
public class User
{
public Gender Gender { get; set; }
}
public enum Gender { Male, Female }
8. 讓JSON回歸人類可讀:與中文和AI友好相處
背景故事: 我興沖沖地序列化了一個包含中文的對象,準備發給新接入的AI大模型。結果一看日志,"騷操作"
變成了 "\u9A9A\u64CD\u4F5C"
!我當時就懵了,這不僅我看著費勁,AI能看懂嗎?Token數暴增暫且不說,理解上出現偏差怎么辦?難道又要退回Newtonsoft?
騷操作揭秘: 這可能是對STJ誤解最深的一點。STJ默認這樣做,是出于極致的安全考慮。它的默認編碼器JavaScriptEncoder.Default
會轉義所有非ASCII字符以及HTML敏感字符(如<
, >
, &
),這是為了防止當你的JSON被不當地嵌入到HTML <script>
標簽中時,引發XSS(跨站腳本)攻擊。它遵循的是“默認安全”的最高原則。
然而,在如今API交互的時代,我們通常通過Content-Type: application/json
來通信,數據并不會直接嵌入HTML。特別是在與大模型(LLM)交互時,保持中文字符的原樣不僅能讓我們人類更容易閱讀和調試,更能讓AI準確無誤地理解語義,同時顯著減少Token消耗。這時,我們可以明確地告訴STJ:“我清楚我的使用環境是安全的,請別轉義!”
var escapedJson = JsonSerializer.Serialize("騷操作");
var options = new JsonSerializerOptions()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var readableJson = JsonSerializer.Serialize("騷操作", options);
你看,STJ不是“坑”,它只是個安全感爆棚、需要你明確指令的“直男”而已。
9. JsonDocument
難用?你可能找錯了“對標對象”!
背景故事: 小鄭需要動態地構建一個復雜的JSON對象,或者在反序列化后對JSON結構進行一些增刪改查。他懷念著Newtonsoft.Json
里JObject
、JArray
的靈活與強大,于是他找到了STJ里的JsonDocument
。結果他驚奇地發現,這玩意兒居然是只讀的!“這怎么用?連個屬性都不能改,簡直是反人類設計!” 小鄭抱怨道,差點就把STJ拉進了黑名單。
騷操作揭秘: 這是一個經典的“指鹿為馬”式誤解。JsonDocument
的設計目標是對標高性能的只讀文檔模型,它的核心優勢是低內存占用和極速查詢,它通過Utf8JsonReader
直接操作原始的UTF-8字節流,避免了將整個JSON字符串物化為.NET對象,因此性能極佳。但它的使命是“讀”,而不是“寫”。
真正的JObject
/JArray
對標物,是.NET 6中隆重推出的**JsonNode
及其派生類JsonObject
、JsonArray
!這是一個可變的、功能完善的文檔對象模型(DOM)**,它提供了你所期望的一切動態操作能力。
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Encodings.Web;
Console.WriteLine("--- 使用JsonNode ---");
var rootNode = new JsonObject();
rootNode["message"] = "Hello, JsonNode!";
rootNode["user"] = new JsonObject
{
["name"] = "小鄭",
["isActive"] = true
};
var scores = new JsonArray(88, 95, 100);
rootNode["scores"] = scores;
Console.WriteLine("添加屬性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
rootNode["user"]["isActive"] = false;
Console.WriteLine("\n修改屬性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
var finalOptions = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var finalJson = rootNode.ToJsonString(finalOptions);
Console.WriteLine("\n最終輸出(兼容中文):\n" + finalJson);
所以,下次當你想動態操作JSON時,請記住口訣:要讀就用JsonDocument
,要寫就用JsonNode
!
快速參考:行為對齊配置映射表
為了方便大家快速查找,我把上面的騷操作整理成了一個表格:
Newtonsoft.Json 行為 | System.Text.Json 默認行為 | System.Text.Json 配置 (JsonSerializerOptions ) | 備注 |
---|
反序列化大小寫不敏感 | 區分大小寫 | PropertyNameCaseInsensitive = true | ASP.NET Core默認已開啟此選項。 |
駝峰命名策略 | PascalCase (與C#屬性名一致) | PropertyNamingPolicy = JsonNamingPolicy.CamelCase | ASP.NET Core默認已開啟此選項。 |
忽略JSON注釋 | 拋出異常 | ReadCommentHandling = JsonCommentHandling.Skip |
|
忽略尾隨逗號 | 拋出異常 | AllowTrailingCommas = true |
|
序列化時忽略null值 | 包含null值 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
|
反序列化帶引號的數字 | 拋出異常 | NumberHandling = JsonNumberHandling.AllowReadingFromString | 例如,將"123" 反序列化為int 類型。 |
處理循環引用 | 拋出異常 | ReferenceHandler = ReferenceHandler.IgnoreCycles | IgnoreCycles 將循環點置為null ,是API場景的常用選擇。 |
枚舉序列化為字符串 | 序列化為數字 | 添加 new JsonStringEnumConverter() 到 Converters 集合 | Newtonsoft.Json 中也有類似的StringEnumConverter 。 |
中文字符轉義 | 轉義非ASCII字符 | Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping | 適用于API和AI交互,減少Token消耗。 |
動態操作JSON (JObject ) | JsonDocument (只讀) | 使用 JsonNode / JsonObject / JsonArray | .NET 6及以后版本可用,功能對標JObject 。 |
STJ的進化之路:從追趕者到引領者
通過上面的例子,我們看到大部分所謂的“坑”其實都是可以通過JsonSerializerOptions
輕松配置的。但我們也要承認,STJ在早期版本中確實存在一些功能上的“天坑”。幸運的是,微軟的開發團隊堪稱“基建狂魔”,他們用一個個版本的迭代,不僅填平了所有坑,更鋪就了一條通往未來的高速公路。
讓我們沿著時間的脈絡,回顧STJ這場精彩的逆襲之戰:
.NET 5 - 奠定基礎,補齊核心短板
這是STJ發布后的第一個“大招版本”,一口氣解決了從Newtonsoft.Json
遷移過來的最大痛點,讓STJ在許多真實場景中變得“堪用”。
- 循環引用處理: 引入
ReferenceHandler.Preserve
,讓處理EF Core等復雜對象圖不再是噩夢。 - 非公共成員支持: 帶來了
[JsonInclude]
和[JsonConstructor]
特性,終于可以序列化私有屬性和使用私有構造函數了,解救了無數依賴封裝性的開發者。 - 非字符串鍵字典: 開始原生支持
Dictionary<int, T>
這類常見結構,不再拋出惱人的NotSupportedException
。
.NET 6 - 性能飛躍,擁抱現代范式
如果說.NET 5讓STJ“能用”,那么.NET 6則讓它真正“好用”和“快用”,并為未來的AOT(預編譯)時代打下堅實基礎。
- 源碼生成器 (
JsonSerializerContext
): 這是STJ的“核武器”!通過在編譯時生成無反射的序列化代碼,極大地提升了性能、降低了內存占用,并成為Blazor Wasm AOT和Native AOT等場景下的唯一選擇。 - 可變DOM (
JsonNode
): 提供了官方的JObject
/JArray
替代品,讓動態解析和構建復雜的JSON對象變得簡單而類型安全。
.NET 7 - 功能完備,實現高級定制
這個版本標志著STJ在功能上基本追平了Newtonsoft.Json
,尤其是在高級和復雜的定制場景下,給了開發者充足的信心。
- 安全的多態序列化: 推出基于
[JsonPolymorphic]
和[JsonDerivedType]
的白名單式多態支持,功能強大且從設計上根除了Newtonsoft.Json
中TypeNameHandling
的安全隱患。 - 終極定制武器 (
IJsonTypeInfoResolver
): 引入了對標IContractResolver
的接口,允許開發者在運行時動態修改類型的序列化“契約”,實現了諸如動態添加/刪除屬性、實現復雜條件序列化等高級“騷操作”。
.NET 8 - 精益求精,優化開發體驗
在功能已經非常完善的基礎上,.NET 8更側重于對現有功能的打磨和對開發者體驗的優化。
- 源碼生成器增強: 完美支持C# 11的
required
和init
屬性,讓不可變模型和源碼生成器能更好地協同工作。 - 內置更多常用類型支持: 如
Half
, Int128
, UInt128
等,并改進了對接口層次結構的支持。
.NET 9 - 生態集成,引領行業標準
從.NET 9開始,我們能清晰地看到STJ已經不再滿足于追趕,而是開始作為.NET生態的核心組件,主動引領和定義標準。
- JSON Schema導出器 (
JsonSchemaExporter
): 這是一項里程碑式的更新!現在可以直接從你的C#類型生成標準的JSON Schema文檔。這意味著與OpenAPI、Swagger等API工具鏈的集成將變得空前簡單,自動化客戶端生成、API文檔和數據驗證都將因此受益。 - 更靈活的格式化選項: 允許自定義縮進字符和大小,滿足各種代碼風格和顯示需求。
- 更強的類型安全: 默認將更嚴格地遵守C#的可空引用類型注解,進一步減少運行時錯誤。
綜上所述,System.Text.Json
的進化之路,是一部清晰的從滿足基本需求,到追求極致性能,再到實現功能完備,并最終引領生態發展的成長史。那些關于它的陳舊“坑論”,早已被滾滾向前的版本車輪碾得粉碎。
?轉自https://www.cnblogs.com/sdcb/p/19010852
該文章在 2025/8/1 8:50:40 編輯過