.NET 5.0 RC1 釋出,離正式版釋出僅剩兩個版本

精緻碼農發表於2020-09-16

原文:http://dwz.win/Qf8
作者:Richard
翻譯:精緻碼農-王亮
說明:本文有不少超連結,由於微信公眾號和頭條平臺外鏈會被剔除 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 = "https://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 = "https://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 = "https://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 地址是:https://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 https://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 的人,我們感謝到目前為止我們收到的所有參與和反饋。

相關文章