.NET 5.0 RC1 發佈,離正式版發佈僅剩兩個版本

原文://dwz.win/Qf8
作者:Richard
翻譯:精緻碼農-王亮
說明:
1. 本譯文並不是完全逐句翻譯的,存在部分語句我實在不知道如何翻譯或組織就根據個人理解用自己的話表述了。
2. 本文有不少超鏈接,由於微信公眾號和頭條平台外鏈會被剔除 URL 地址,所以原來本是超鏈接的內容會顯示為純文本,如果你需要這些信息可以移步到我的知乎博客園閱讀(搜索精緻碼農可找到我)。

今天我們發佈了 .NET 5.0 Release Candidate 1 (RC1)。它是目前最接近 .NET 5.0 的一個版本,也是在 11 月正式發佈之前的兩個 RC 版本中的第一個 RC 版本。RC1 是一個「上線」版本,表示你可以在生產環境中使用它了。

與此同時,我們一直在尋找最終正式版發佈之前應該被修復的任何關鍵錯誤報告。我們需要你的反饋來幫助我們一起跨越 .NET 5.0 正式發佈這道勝利的終點線。

我們今天也發佈了 ASP.NET CoreEF Core 的 RC1 版本。

你可以下載適用於 Windows、macOS 和 Linux 的 .NET 5.0 版本

你需要最新的預覽版 Visual Studio (包括 Visual Studio for Mac) 才能使用 .NET 5.0。

.NET 5.0 有很多改進,特別是單個文件應用程序更小的容器映像更強大的 JsonSerializer API完整的可空引用類型標註、新的目標 Framework 名稱,以及對 Windows ARM64 的支持。在網絡庫、GC 和 JIT 中性能得到了極大的提高。我們花了很大的工作在 ARM64 的性能上,它有了更好的吞吐量和更小的二進制文件。.NET 5.0 包含了新的語言版本:C# 9.0 和 F# 5.0。

我們最近發佈了一些關於 5.0 新功能深入介紹的文章,你可能想看一看這些文章:

就像我在 .NET 5.0 預覽 8 文中所做的一樣,我選擇了一些特性來進行更深入的介紹,並讓你了解如何在實際使用中使用它們。這篇文章專門討論 C# 9 中的 System.Text.Json.JsonSerializerrecords(記錄)。它們是獨立的特性,但也是很好的組合,特別是如果你花費大量時間為反序列化的 JSON 對象創建 POCO 類型。

C# 9 — 記錄

記錄(原文 Record)可能是 C# 9 中最重要的新特性。它們提供了廣泛的特性集(對一種語言類型來說),其中一些需要 RC1 或更高版本(如 record.ToString())。

譯註:為了閱讀更通順,對 Record 的翻譯,本譯文根據語境的情況,有的地方用的是「Record」,有的地方用的是「記錄」。因為在一些語境下把「Record」翻譯成「記錄」容易產生數據記錄的錯誤聯想。

最簡單的理解,記錄是不可變類型。在特性方面,它們最接近元組(Tuple),可以將它們視為具有屬性且不可變的自定義元組。在如今使用元組的多數情況下,記錄可以比元組提供更好更多的功能和使用場景。

在使用 C# 時,如果你使用命名類型會使你得到最好的體驗(相對於像元組這樣的特性)。靜態類型(static typing)是該語言的設計要點,記錄使小型類型更容易使用,並在整個應用程序中可以保證類型安全。

記錄是不可變數據類型

記錄使你能夠創建不可變的數據類型,這對於定義存儲少量數據的類型非常有用。

下面是一個記錄的例子,它存儲登錄用戶信息。

public record LoginResource(string Username, string Password, bool RememberMe);

它在語義上與下面的類相似(幾乎完全相同),我即將介紹這些差異。

public class LoginResource
{
    public LoginResource(string username, string password, bool rememberMe)
    {
        Username = username;
        Password = password;
        RememberMe = rememberMe;
    }

    public string Username { get; init; }
    public string Password { get; init; }
    public bool RememberMe { get; init; }
}

init 是一個新的關鍵字,它是 set 的替代。set 允許你在任何時候給屬性分配值,init 只允許在對象構造期間給屬性賦值,它是記錄不變性所依賴的基石。任何類型都可以使用 init,正如你在前面的類定義中看到的那樣,它並不局限於在記錄中使用。

private set 看起來類似於 initprivate set 防止其他代碼(類型以外的代碼)改變數據。當類型(在構造之後)意外地改變屬性時,init 將產生編譯錯誤。private set 不能使數據不可變,因此當類型在構造後改變屬性值時,不會生成任何編譯錯誤或警告。

記錄是特殊的類

正如我剛才提到的,LoginResource 的記錄變體和類變體幾乎是相同的。類定義是記錄的一個語義相同的子集,記錄提供了更多特殊的行為。

為了讓我們的想法達成一致,如前所述,下面的比較是一個記錄和一個使用 init 代替 set 修飾屬性的類之間的區別。

有哪些共同點:

  • 構造函數
  • 不變性
  • 複製語義(記錄本質是類)

有哪些不同點:

  • 記錄相等是基於內容的,類相等是基於對象標識;
  • 記錄提供了一個基於內容 GetHashCode() 實現;
  • 記錄提供了一個IEquatable<T>的實現,它使用 GetHashCode() 唯一性作為行為機制,為記錄提供基於內容的相等語義;
  • 記錄重寫(override)了 ToString(),打印的是記錄的內容。

記錄和(使用 init 的)類之間的差異可以在 LoginResource 作為記錄和 LoginResource 作為類的反編譯代碼中可以看到。

我將向你展示一些有差異的代碼:

using System;
using System.Linq;
using static System.Console;

var user = "Lion-O";
var password = "jaga";
var rememberMe = true;
LoginResourceRecord lrr1 = new(user, password, rememberMe);
var lrr2 = new LoginResourceRecord(user, password, rememberMe);
var lrc1 = new LoginResourceClass(user, password, rememberMe);
var lrc2 = new LoginResourceClass(user, password, rememberMe);

WriteLine($"Test record equality -- lrr1 == lrr2 : {lrr1 == lrr2}");
WriteLine($"Test class equality  -- lrc1 == lrc2 : {lrc1 == lrc2}");
WriteLine($"Print lrr1 hash code -- lrr1.GetHashCode(): {lrr1.GetHashCode()}");
WriteLine($"Print lrr2 hash code -- lrr2.GetHashCode(): {lrr2.GetHashCode()}");
WriteLine($"Print lrc1 hash code -- lrc1.GetHashCode(): {lrc1.GetHashCode()}");
WriteLine($"Print lrc2 hash code -- lrc2.GetHashCode(): {lrc2.GetHashCode()}");
WriteLine($"{nameof(LoginResourceRecord)} implements IEquatable<T>: {lrr1 is IEquatable<LoginResourceRecord>} ");
WriteLine($"{nameof(LoginResourceClass)}  implements IEquatable<T>: {lrr1 is IEquatable<LoginResourceClass>}");
WriteLine($"Print {nameof(LoginResourceRecord)}.ToString -- lrr1.ToString(): {lrr1.ToString()}");
WriteLine($"Print {nameof(LoginResourceClass)}.ToString  -- lrc1.ToString(): {lrc1.ToString()}");

public record LoginResourceRecord(string Username, string Password, bool RememberMe);

public class LoginResourceClass
{
    public LoginResourceClass(string username, string password, bool rememberMe)
    {
        Username = username;
        Password = password;
        RememberMe = rememberMe;
    }

    public string Username { get; init; }
    public string Password { get; init; }
    public bool RememberMe { get; init; }
}

注意:你將注意到 LoginResource 類型以 Record 和 Class 結束,該模式並不是新的命名約定,這樣命名只是為了在樣本中有相同類型的記錄和類變體,請不要這樣命名你的類。

此代碼的輸出如下:

rich@thundera records % dotnet run
Test record equality -- lrr1 == lrr2 : True
Test class equality  -- lrc1 == lrc2 : False
Print lrr1 hash code -- lrr1.GetHashCode(): -542976961
Print lrr2 hash code -- lrr2.GetHashCode(): -542976961
Print lrc1 hash code -- lrc1.GetHashCode(): 54267293
Print lrc2 hash code -- lrc2.GetHashCode(): 18643596
LoginResourceRecord implements IEquatable<T>: True
LoginResourceClass  implements IEquatable<T>: False
Print LoginResourceRecord.ToString -- lrr1.ToString(): LoginResourceRecord { Username = Lion-O, Password = jaga, RememberMe = True }
Print LoginResourceClass.ToString -- lrc1.ToString(): LoginResourceClass

記錄的語法

有多種用於聲明記錄的模式,用於滿足不同場景的使用。在玩過每個模式之後,你開始會對每種模式的好處有一個感性的認識。你還將看到,它們不是不同的語法,而是選項的連續體(continuum of options)。

第一個模式是最簡單的 —— 一行代碼 —— 但是提供的靈活性最小,它適用於具有少量必需屬性(必需屬性,即初始化時必需給作為參數的屬性傳值)的記錄。

以下用前面展示的 LoginResource 記錄作為此模式的一個示例。就這麼簡單,一行代碼就是整個定義:

public record LoginResource(string Username, string Password, bool RememberMe);

構造遵循帶參數的構造函數的要求(包括允許使用可選參數):

var login = new LoginResource("Lion-O", "jaga", true);

如果你喜歡,也可以用 target typing:

LoginResource login = new("Lion-O", "jaga", true);

下面這個語法使所有屬性都是可選的,為記錄提供了一個隱式無參數構造函數。

public record LoginResource
{
    public string Username {get; init;}
    public string Password {get; init;}
    public bool RememberMe {get; init;}
}

使用對象初始化構造,可以像下面這樣:

LoginResource login = new()
{
    Username = "Lion-O",
    TemperatureC = "jaga"
};

如果你想讓這兩個屬性成為必需的,而另一個屬性是可選的,這最後一個模式如下所示:

public record LoginResource(string Username, string Password)
{
    public bool RememberMe {get; init;}
}

可以像下面這樣不指定 RememberMe 構造:

LoginResource login = new("Lion-O", "jaga");

也可以指定 RememberMe 構造:

LoginResource login = new("Lion-O", "jaga")
{
    RememberMe = true
};

不要認為記錄只用於不可變數據。你可以置入公開可變屬性,如下面的示例所示,該示例報告了關於電池的信息。ModelTotalCapacityAmpHours 屬性是不可變的,而 RemainingCapacityPercentange 是可變的。

using System;

Battery battery = new Battery("CR2032", 0.235)
{
    RemainingCapacityPercentage = 100
};

Console.WriteLine (battery);

for (int i = battery.RemainingCapacityPercentage; i >= 0; i--)
{
    battery.RemainingCapacityPercentage = i;
}

Console.WriteLine (battery);

public record Battery(string Model, double TotalCapacityAmpHours)
{
    public int RemainingCapacityPercentage {get;set;}
}

它輸出如下結果:

rich@thundera recordmutable % dotnet run
Battery { Model = CR2032, TotalCapacityAmpHours = 0.235, RemainingCapacityPercentage = 100 }
Battery { Model = CR2032, TotalCapacityAmpHours = 0.235, RemainingCapacityPercentage = 0 }

無損式記錄修改

不變性提供了顯著的好處,但是您很快就會發現需要對記錄進行改變的情況。你怎麼能在不放棄不變性的前提下做到這一點呢?with 表達式滿足了這一需求。它支持根據相同類型的現有記錄創建新記錄。你可以指定你想要的不同的新值,並且從現有記錄複製所有其他屬性。

讓我們把用戶名轉換成小寫,這是用戶名在我們假定的一個用戶數據庫中的存儲方式。但是,為了進行診斷,需要使用原始用戶名大小寫。假設以前面示例中的代碼為例,它可能像下面這樣:

LoginResource login = new("Lion-O", "jaga", true);
LoginResource loginLowercased = lrr1 with {Username = login.Username.ToLowerInvariant()};

login 記錄沒有被更改,事實上這也是不允許的。轉換隻影響了 loginLowercased,除了將小寫轉換為 loginLowercased 之外,其它與 login 是相同的。

我們可以使用內置的 ToString() 檢查 width 是否完成了預期的工作:

Console.WriteLine(login);
Console.WriteLine(loginLowercased);

此代碼輸出如下結果:

LoginResource { Username = Lion-O, Password = jaga, RememberMe = True }
LoginResource { Username = lion-o, Password = jaga, RememberMe = True }

我們可以進一步了解 with 是如何工作的,它將所有值從一條記錄複製到另一條記錄。這不是一個記錄依賴於另一個記錄的模型。事實上,with 操作完成後,兩個記錄之間就沒有關係了,只在對記錄的構建時有意義。這意味着對於引用類型,副本只是引用的副本;對於值類型,是複製值。

你可以在下面的代碼中看到這種語義:

Console.WriteLine($"Record equality: {login == loginLowercased}");
Console.WriteLine($"Property equality: Username == {login.Username == loginLowercased.Username}; Password == {login.Password == loginLowercased.Password}; RememberMe == {login.RememberMe == loginLowercased.RememberMe}");

它輸出如下結果:

Record equality: False
Property equality: Username == False; Password == True; RememberMe == True

記錄的實例

對記錄進行擴展是很容易的。讓我們假設一個新的 LastLoggedIn 屬性,它可以直接添加到 LoginResource。那是個好的設想,記錄不像傳統的接口那樣脆弱,除非你想讓該新屬性在創建時作為構造函數所必需的參數。

在這個案例中,現在我想使 LastLoggedIn 是必需的。想像一下,代碼庫非常大,把這個修改反應到所有創建 LoginResource 的地方工作量是巨大的。相反,我們將用這個新屬性創建一個擴展 LoginResource 的新 Record。現有代碼將在 LoginResource 方面工作,新代碼將在新 Record 上工作,然後可以假設 LastLoggedIn 屬性已經賦值。根據常規繼承規則,接受 LoginResource 的代碼將同樣輕鬆地接受新的 Record。

這個新 Record 可以基於前面演示的任何 LoginResource 變體,它將基於以下內容:

public record LoginResource(string Username, string Password)
{
    public bool RememberMe {get; init;}
}

新的 Record 將是如下這樣的:

public record LoginWithUserDataResource(string Username, string Password, DateTime LastLoggedIn) : LoginResource(Username, Password)
{
    public int DiscountTier {get; init};
    public bool FreeShipping {get; init};
}

我將 LastLoggedIn 設置為一個必需的屬性,並利用這個機會添加了附加的且可選的屬性,這些屬性可能設置也可能沒有設置值。通過擴展 LoginResource 記錄,還定義了可選的 RememberMe 屬性。

記錄的構造輔助

其中一個不是很直觀的模式是建模輔助(modeling helpers),你希望使用它作為記錄構造的一部分(譯註:用來輔助創建記錄實例)。讓我們來換個體重測量的示例。體重的測量用的是一個聯網的秤,重量以公斤為單位,但是在某些情況下,體重需要以磅作為單位顯示。

可以使用以下記錄聲明:

public record WeightMeasurement(DateTime Date, int Kilograms)
{
    public int Pounds {get; init;}

    public static int GetPounds(int kilograms) => kilograms * 2.20462262;
}

對應的構造是這樣的:

var weight = 200;
WeightMeasurement measurement = new(DateTime.Now, weight)
{
    Pounds = WeightMeasurement.GetPounds(weight)
};

在本例中,需要說明的是 weight 是本地變量,不可能在對象初始化器中訪問 Kilograms 屬性。也有必要將 GetPounds 定義為靜態方法,因為不可能在對象初始化器中調用實例(它還未構造完成)方法。

記錄和可空性

語法上,記錄是具有可空性(Nullability)的對嗎?既然記錄是不可變的,那 null 從何而來呢?如果初始值就是 null,那就一直是 null,這樣的數據有什麼意義呢?

讓我們來看一個沒有使用可空性的程序:

using System;
using System.Collections.Generic;

Author author = new(null, null);

Console.WriteLine(author.Name.ToString());

public record Author(string Name, List<Book> Books)
{
    public string Website {get; init;}
    public string Genre {get; init;}
    public List<Author> RelatedAuthors {get; init;}
}

public record Book(string name, int Published, Author author);

這個程序編譯時將拋出一個 NullReference 異常,因為 author.Name 是 null(譯者疑問:真的是編譯時報錯而不是運行時報錯嗎?期待大家親測)。

為了更進一步說明這一點,下面的代碼無法編譯通過,因為 author.Name 初始值為 null,然後是不能更改的,因為屬性是不可變的。

Author author = new(null, null);
author.Name = "Colin Meloy";

我要更新我的 project 文件,以啟用可空性。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

我現在看到如下的一堆警告:

/Users/rich/recordsnullability/Program.cs(8,21): warning CS8618: Non-nullable property 'Website' \n
must contain a non-null value when exiting constructor. Consider declaring the property as \n
nullable. [/Users/rich/recordsnullability/recordsnullability.csproj]

我用我用可空修飾符更新了 Author 記錄,這些可空修飾符描述了我打算如何使用該記錄。

public record Author(string Name, List<Book> Books)
{
    public string? Website {get; init;}
    public string? Genre {get; init;}
    public List<Author>? RelatedAuthors {get; init;}
}

我仍然得到了關於 null 的警告,之前看到的 Author 的 null 構造。

/Users/rich/recordsnullability/Program.cs(5,21): warning CS8625: Cannot convert null literal \n
to non-nullable reference type. [/Users/rich/recordsnullability/recordsnullability.csproj]

這很好,因為這是我想防止的情況。現在,我將向你展示這個程序的一個更新版本,它很好地利用了可空性的好處。

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;


Author lord = new Author("Karen Lord")
{
    Website = "//karenlord.wordpress.com/",
    RelatedAuthors = new()
};

lord.Books.AddRange(
    new Book[]
    {
        new Book("The Best of All Possible Worlds", 2013, lord),
        new Book("The Galaxy Game", 2015, lord)
    }
);

lord.RelatedAuthors.AddRange(
    new Author[]
    {
        new ("Nalo Hopkinson"),
        new ("Ursula K. Le Guin"),
        new ("Orson Scott Card"),
        new ("Patrick Rothfuss")
    }
);

Console.WriteLine($"Author: {lord.Name}");
Console.WriteLine($"Books: {lord.Books.Count}");
Console.WriteLine($"Related authors: {lord.RelatedAuthors.Count}");


public record Author(string Name)
{
    private List<Book> _books = new();

    public List<Book> Books => _books;

    public string? Website {get; init;}
    public string? Genre {get; init;}
    public List<Author>? RelatedAuthors {get; init;}
}

public record Book(string name, int Published, Author author);

這個程序編譯沒有出現警告。

你可能會對下面這句話感到疑惑:

lord.RelatedAuthors.AddRange(

Author.RelatedAuthors 可以為空,編譯器可以看到 RelatedAuthors 屬性是在前面幾行設置的,因此它知道 RelatedAuthors 引用是非空的。

但是,想像一下如果這個程序是這樣的:

Author GetAuthor()
{
    return new Author("Karen Lord")
    {
        Website = "//karenlord.wordpress.com/",
        RelatedAuthors = new()
    };
}

Author lord = GetAuthor();

當類型構造在一個單獨的方法中時,編譯器不能智能地知道 RelatedAuthors 是非空的。在這種情況下,將需要以下兩種模式之一:

lord.RelatedAuthors!.AddRange(

if (lord.RelatedAuthors is object)
{
    lord.RelatedAuthors.AddRange( ...
}

這是一個關於記錄可空性的冗長演示,只是想說明它不會改變使用可空引用類型的任何體驗。

另外,您可能已經注意到,我將 Author 記錄上的 Books 屬性改為一個初始化的 get-only 屬性,而不是記錄構造函數中的一個必需參數。這是因為 AuthorBooks 之間存在一種循環關係(譯註:Author 含有List<Book>類型的導航屬性,Book 也包含 Author 類型的導航屬性)。不變性和循環引用可能會導致頭痛。在本例中,這是可以的,只是意味着需要在 Book 對象之前創建所有 Author 對象。因此,不可能在 Author 構造中提供一組完全初始化好的 Book 對象作為 Author 構建的一部分,我們所能期待的最好結果就是一個空的 List<Book>。因此,初始化一個作為 Author 構建的一部分的空 List<Book> 似乎是最好的選擇。沒有規則規定所有這些屬性都必須是 init 的形式,我(示例中)之所以這樣做是為了示範。

我們將轉移到 JSON 序列化的話題。這個帶有循環引用的示例與稍後將在 JSON 對象圖部分中的保存引用有關。JsonSerializer 支持循環引用的對象圖,但不支持帶有參數化構造函數的類型。你可以將 Author 對象序列化為 JSON,但不能將其反序列化為當前定義的 Author 對象。如果 Author 不是記錄或者沒有循環引用,那麼序列化和反序列化都可以使用 JsonSerializer。

System.Text.Json

System.Text.Json 在 .NET 5.0 中得到了顯著的改進,提高了性能和可靠性,並使熟悉 Newtonsoft.Json 的人更容易採用它。它還支持將 JSON 對象反序列化為記錄,這是本文之前的文章介紹過的 C# 新特性。

如果你想將 System.Text.Json 作為 Newtonsoft.Json 的替代品,可以看這個 遷移指南,該指南闡明了這兩者 API 之間的關係。System.Text.Json 旨在涵蓋與 Newtonsoft.Json 相同的大多數場景,但是它並不是用來替代該流行的 Json 庫的,也不是為了實現與流行的 Json 庫相同的功能。我們試圖在性能和可用性之間保持平衡,並在設計選擇中偏向於性能。

HttpClient 擴展方法

JsonSerializer 擴展方法現在公開到 HttpClient 上了,極大地簡化了同時使用這兩個 API。這些擴展方法消除了複雜性,並為你處理各種場景,包括處理內容流和驗證內容媒體類型。Steve Gordon 很好地解釋了使用基於 System.Net.Http.Json 的 HttpClient 發送和接收 JSON 的好處。

下面的示例使用新的 GetFromJsonAsync<T>() 擴展方法將天氣預報的 JSON 數據反序列化為 Forecast 記錄。

using System;
using System.Net.Http;
using System.Net.Http.Json;

string serviceURL = "//localhost:5001/WeatherForecast";
HttpClient client = new();
Forecast[] forecasts = await client.GetFromJsonAsync<Forecast[]>(serviceURL);

foreach(Forecast forecast in forecasts)
{
    Console.WriteLine($"{forecast.Date}; {forecast.TemperatureC}C; {forecast.Summary}");
}

// {"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"}
public record Forecast(DateTime Date, int TemperatureC, int TemperatureF, string Summary);

這段代碼非常緊湊!它依賴於來自 C# 9 的頂層程序和記錄,以及新的 GetFromJsonAsync<T>() 擴展方法。如此近距離使用 foreachawait 可能會讓你懷疑我們是否會添加對 JSON 對象流的支持。是的,在未來的版本中。

譯註:上面作者所說的「近距離」我覺得意思是指反序列化時就近聲明需要的記錄類型,比單獨創建 Model 類放在單獨的文件中「近」許多。

你可以在你自己的機器上試試,下面的 .NET SDK 命令將使用 WebAPI 模板創建一個天氣預報服務。默認情況下,它的服務 URL 地址是://localhost:5001/WeatherForecast,與本示例中使用的 URL 相同。

rich@thundera ~ % dotnet new webapi -o webapi
rich@thundera ~ % cd webapi
rich@thundera webapi % dotnet run

先確保你已經運行了 dotnet dev-certs https --trust,否則客戶端和服務器之間的將不能正常握手通訊。如果有問題,請參見 Trust the ASP.NET Core HTTPS development certificate.

然後你可以運行前面的例子:

rich@thundera ~ % git clone //gist.github.com/3b41d7496f2d8533b2d88896bd31e764.git weather-forecast
rich@thundera ~ % cd weather-forecast
rich@thundera weather-forecast % dotnet run
9/9/2020 12:09:19 PM; 24C; Chilly
9/10/2020 12:09:19 PM; 54C; Mild
9/11/2020 12:09:19 PM; -2C; Hot
9/12/2020 12:09:19 PM; 24C; Cool
9/13/2020 12:09:19 PM; 45C; Balmy

改進了對不可變類型的支持

定義不可變類型有多種模式,記錄只是最新的一種(比如下文示例中的一個 Struct 類型),JsonSerializer 現在支持不可變類型了。

在本例中,你將看到使用不可變結構類型的序列化:

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

var json = "{\"date\":\"2020-09-06T11:31:01.923395-07:00\",\"temperatureC\":-1,\"temperatureF\":31,\"summary\":\"Scorching\"} ";
var options = new JsonSerializerOptions()
{
    PropertyNameCaseInsensitive = true,
    IncludeFields = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var forecast = JsonSerializer.Deserialize<Forecast>(json, options);

Console.WriteLine(forecast.Date);
Console.WriteLine(forecast.TemperatureC);
Console.WriteLine(forecast.TemperatureF);
Console.WriteLine(forecast.Summary);

var roundTrippedJson = JsonSerializer.Serialize<Forecast>(forecast, options);

Console.WriteLine(roundTrippedJson);

public struct Forecast{
    public DateTime Date {get;}
    public int TemperatureC {get;}
    public int TemperatureF {get;}
    public string Summary {get;}
    [JsonConstructor]
    public Forecast(DateTime date, int temperatureC, int temperatureF, string summary) => (Date, TemperatureC, TemperatureF, Summary) = (date, temperatureC, temperatureF, summary);
}

注意:JsonConstructor 特性需要指定與 struct 一起使用的構造函數。對於類,如果只有一個構造函數,那麼該特性就不是必需的,記錄也是如此。

它的輸出如下:

rich@thundera jsonserializerimmutabletypes % dotnet run
9/6/2020 11:31:01 AM
-1
31
Scorching
{"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"}

支持記錄

JsonSerializer 對記錄的支持幾乎與我剛才對不可變類型的支持相同。這裡我想展示的不同之處是將一個 JSON 對象反序列化為一個記錄,該記錄公開一個參數化的構造函數和一個可選的 init 屬性。

下面是一個包含了該記錄定義的程序:

using System;
using System.Text.Json;

Forecast forecast = new(DateTime.Now, 40)
{
    Summary = "Hot!"
};

string forecastJson = JsonSerializer.Serialize<Forecast>(forecast);
Console.WriteLine(forecastJson);
Forecast? forecastObj = JsonSerializer.Deserialize<Forecast>(forecastJson);
Console.Write(forecastObj);

public record Forecast (DateTime Date, int TemperatureC)
{
    public string? Summary {get; init;}
};

它的輸出如下:

rich@thundera jsonserializerrecords % dotnet run
{"Date":"2020-09-12T18:24:47.053821-07:00","TemperatureC":40,"Summary":"Hot!"}
Forecast { Date = 9/12/2020 6:24:47 PM, TemperatureC = 40, Summary = Hot! }

改進了 Dictionary<K,V> 的支持

JsonSerializer 現在支持具有非字符串鍵的字典。你可以在下面的示例中看到它的樣子。在 .NET Core 3.0 中,這段代碼可以編譯,但會拋出 NotSupportedException 異常。

using System;
using System.Collections.Generic;
using System.Text.Json;

Dictionary<int, string> numbers = new ()
{
    {0, "zero"},
    {1, "one"},
    {2, "two"},
    {3, "three"},
    {5, "five"},
    {8, "eight"},
    {13, "thirteen"},
    {21, "twenty one"},
    {34, "thirty four"},
    {55, "fifty five"},
};

var json = JsonSerializer.Serialize<Dictionary<int, string>>(numbers);

Console.WriteLine(json);

var dictionary = JsonSerializer.Deserialize<Dictionary<int, string>>(json);

Console.WriteLine(dictionary[55]);

它的輸出如下:

rich@thundera jsondictionarykeys % dotnet run
{"0":"zero","1":"one","2":"two","3":"three","5":"five","8":"eight","13":"thirteen","21":"twenty one","34":"thirty four","55":"fifty five"}
fifty five

支持字段

JsonSerializer 現在支持字段,這個變化是由 @YohDeadfall 貢獻的,感謝他!

你可以在下面的示例中看到它的樣子,在 .NET Core 3.0 中,JsonSerializer 無法對使用字段的類型進行序列化或反序列化。對於具有字段且無法更改的現有類型來說,這是個問題,有了這個變化,這個問題就解決了。

using System;
using System.Text.Json;

var json = "{\"date\":\"2020-09-06T11:31:01.923395-07:00\",\"temperatureC\":-1,\"temperatureF\":31,\"summary\":\"Scorching\"} ";
var options = new JsonSerializerOptions()
{
    PropertyNameCaseInsensitive = true,
    IncludeFields = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var forecast = JsonSerializer.Deserialize<Forecast>(json, options);

Console.WriteLine(forecast.Date);
Console.WriteLine(forecast.TemperatureC);
Console.WriteLine(forecast.TemperatureF);
Console.WriteLine(forecast.Summary);

var roundTrippedJson = JsonSerializer.Serialize<Forecast>(forecast, options);

Console.WriteLine(roundTrippedJson);

public class Forecast{
    public DateTime Date;
    public int TemperatureC;
    public int TemperatureF;
    public string Summary;
}

它的輸出如下:

rich@thundera jsonserializerfields % dotnet run
9/6/2020 11:31:01 AM
-1
31
Scorching
{"date":"2020-09-06T11:31:01.923395-07:00","temperatureC":-1,"temperatureF":31,"summary":"Scorching"}

保留 JSON 對象圖中的引用

JsonSerializer 增加了對在 JSON 對象圖中保留(循環)引用的支持。它通過存儲在將 JSON 字符串反序列化回對象時可以重新構建的 id 來實現這一點。

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

Employee janeEmployee = new()
{
    Name = "Jane Doe",
    YearsEmployed = 10
};

Employee johnEmployee = new()
{
    Name = "John Smith"
};

janeEmployee.Reports = new List<Employee> { johnEmployee };
johnEmployee.Manager = janeEmployee;

JsonSerializerOptions options = new()
{
    // NEW: globally ignore default values when writing null or default
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
    // NEW: globally allow reading and writing numbers as JSON strings
    NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString,
    // NEW: globally support preserving object references when (de)serializing
    ReferenceHandler = ReferenceHandler.Preserve,
    IncludeFields = true, // NEW: globally include fields for (de)serialization
    WriteIndented = true,};

string serialized = JsonSerializer.Serialize(janeEmployee, options);
Console.WriteLine($"Jane serialized: {serialized}");

Employee janeDeserialized = JsonSerializer.Deserialize<Employee>(serialized, options);
Console.Write("Whether Jane's first report's manager is Jane: ");
Console.WriteLine(janeDeserialized.Reports[0].Manager == janeDeserialized);

public class Employee
{
    // NEW: Allows use of non-public property accessor.
    // Can also be used to include fields "per-field", rather than globally with JsonSerializerOptions.
    [JsonInclude]
    public string Name { get; internal set; }

    public Employee Manager { get; set; }

    public List<Employee> Reports;

    public int YearsEmployed { get; set; }

    // NEW: Always include when (de)serializing regardless of global options
    [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
    public bool IsManager => Reports?.Count > 0;
}

性能

JsonSerializer 的性能在 .NET 5.0 中得到了顯著提高。Stephen Toub 在他的 .NET 5 的性能改進 一文中介紹了一些 JsonSerializer 的改進,我將在這裡再介紹一些。

集合的(反)序列化

我們對大型集合做了顯著的改進(反序列化時為 1.15x-1.5x,序列化時為 1.5x-2.4x+)。你可以在 dotnet/runtime #2259 中更詳細地看到這些改進。

與 .NET 5.0 和 .NET Core 3.1 相比,List<int> (反)序列化的改進特別令人印象深刻,這些變化將在高性能應用程序中體現出來。

屬性查找 —— 命名約定

使用 JSON 最常見的問題之一是命名約定與 .NET 設計準則不匹配。JSON 屬性通常是 camelCase,.NET 屬性和字段通常是 PascalCase。你使用的 JSON 序列化器負責在命名約定之間架橋。這不是輕易就能做到的,至少對 .NET Core 3.1 來說不是。但在 .NET 5.0 中,這種實現成本現在可以忽略不計了。

允許缺少屬性和不區分大小寫的代碼在 .NET 5.0 中得到了極大的改進,在某些情況下它要快 1.75 倍

下面是一個簡單的四屬性測試類的基準測試,它的屬性名為大於 7 位元組。

3.1 性能
|                            Method |       Mean |   Error |  StdDev |     Median |        Min |        Max |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|---------------------------------- |-----------:|--------:|--------:|-----------:|-----------:|-----------:|-------:|------:|------:|----------:|
| CaseSensitive_Matching            |   844.2 ns | 4.25 ns | 3.55 ns |   844.2 ns |   838.6 ns |   850.6 ns | 0.0342 |     - |     - |     224 B |
| CaseInsensitive_Matching          |   833.3 ns | 3.84 ns | 3.40 ns |   832.6 ns |   829.4 ns |   841.1 ns | 0.0504 |     - |     - |     328 B |
| CaseSensitive_NotMatching(Missing)| 1,007.7 ns | 9.40 ns | 8.79 ns | 1,005.1 ns |   997.3 ns | 1,023.3 ns | 0.0722 |     - |     - |     464 B |
| CaseInsensitive_NotMatching       | 1,405.6 ns | 8.35 ns | 7.40 ns | 1,405.1 ns | 1,397.1 ns | 1,423.6 ns | 0.0626 |     - |     - |     408 B |

5.0 性能
|                            Method |     Mean |   Error |  StdDev |   Median |      Min |      Max |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|---------------------------------- |---------:|--------:|--------:|---------:|---------:|---------:|-------:|------:|------:|----------:|
| CaseSensitive_Matching            | 799.2 ns | 4.59 ns | 4.29 ns | 801.0 ns | 790.5 ns | 803.9 ns | 0.0985 |     - |     - |     632 B |
| CaseInsensitive_Matching          | 789.2 ns | 6.62 ns | 5.53 ns | 790.3 ns | 776.0 ns | 794.4 ns | 0.1004 |     - |     - |     632 B |
| CaseSensitive_NotMatching(Missing)| 479.9 ns | 0.75 ns | 0.59 ns | 479.8 ns | 479.1 ns | 481.0 ns | 0.0059 |     - |     - |      40 B |
| CaseInsensitive_NotMatching       | 783.5 ns | 3.26 ns | 2.89 ns | 783.5 ns | 779.0 ns | 789.2 ns | 0.1004 |     - |     - |     632 B |

TechEmpower 改進

譯註:TechEmpower 是一家主要做基準測試的公司,它會定期提供各種 Web 應用程序框架性能指標的測試和比較,覆蓋了許多的語言框架,包括 C#,Go,Python,Java,Ruby,PHP 等。測試基於雲和物理硬件,測試的性能則包括純文本響應、序列化 JSON 對象、單個/多個數據庫查詢、數據庫更新、Fortunes 測試等等。

我們在 TechEmpower 基準測試中花費了大量的精力來改進 .NET 的性能。使用 TechEmpower JSON 基準來驗證這些 JsonSerializer 改進是有意義的。現在性能提高了約 19%,一旦我們將條目更新到 .NET 5.0 將提高 .NET 在基準測試中的排行位置。這個版本的目標是與 netty 相比更具競爭力,netty 是常見的 Java Webserver。

dotnet/runtime #37976 中詳細介紹了這些更改和性能度量。這裡有兩套基準,第一個是使用團隊維護的 JsonSerializer 性能基準測試來驗證性能。觀察到有約 8% 的改善;第二個是 TechEmpower 的,它測量了滿足 TechEmpower JSON 基準測試要求的三種不同方法。我們在官方基準測試中使用的是SerializeWithCachedBufferAndWriter

如果我們看一下 Min 列,我們可以做一些簡單的數學計算:153.3/128.6 = ~1.19,有了 19% 的提升。

結束

我希望你喜歡本文對記錄和 JsonSerializer 的深入介紹,它們只是 .NET 5.0 眾多改進中的兩個。這篇預覽 8 的文章涵蓋了更多的新特性,這為 5.0 的價值提供了更廣闊的視角。

正如你所知道的,我們目前階段沒有在 .NET 5.0 中繼續添加新特性了。我利用後面的預覽和 RC 版本發佈的文章來涵蓋我們已經添加的所有功能的介紹。你希望我在 RC2 發佈的博客文章中介紹哪些內容?我想從你們那知道我應該關注什麼。

請在評論中分享你使用 RC1 的體驗,感謝所有安裝了 .NET 5.0 的人,我們感謝到目前為止我們收到的所有參與和反饋。