今天我们发布了 .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())。

最简单的理解,记录是不可变类型。在特性方面,它们最接近元组(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 对象流的支持。是的,在未来的版本中。

你可以在你自己的机器上试试,下面的 .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> (反)序列化的改进特别令人印象深刻,这些变化将在高性能应用程序中体现出来。

.NET 5.0 RC1 发布,离正式版发布仅剩两个版本-LMLPHP

属性查找 —— 命名约定

使用 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 基准测试中花费了大量的精力来改进 .NET 的性能。使用 TechEmpower JSON 基准来验证这些 JsonSerializer 改进是有意义的。现在性能提高了约 19%,一旦我们将条目更新到 .NET 5.0 将提高 .NET 在基准测试中的排行位置。这个版本的目标是与 netty 相比更具竞争力,netty 是常见的 Java Webserver。

dotnet/runtime #37976 中详细介绍了这些更改和性能度量。这里有两套基准,第一个是使用团队维护的 JsonSerializer 性能基准测试来验证性能。观察到有约 8% 的改善;第二个是 TechEmpower 的,它测量了满足 TechEmpower JSON 基准测试要求的三种不同方法。我们在官方基准测试中使用的是SerializeWithCachedBufferAndWriter

.NET 5.0 RC1 发布,离正式版发布仅剩两个版本-LMLPHP

如果我们看一下 Min 列,我们可以做一些简单的数学计算:153.3/128.6 = ~1.19,有了 19% 的提升。

结束

我希望你喜欢本文对记录和 JsonSerializer 的深入介绍,它们只是 .NET 5.0 众多改进中的两个。这篇预览 8 的文章涵盖了更多的新特性,这为 5.0 的价值提供了更广阔的视角。

正如你所知道的,我们目前阶段没有在 .NET 5.0 中继续添加新特性了。我利用后面的预览和 RC 版本发布的文章来涵盖我们已经添加的所有功能的介绍。你希望我在 RC2 发布的博客文章中介绍哪些内容?我想从你们那知道我应该关注什么。

请在评论中分享你使用 RC1 的体验,感谢所有安装了 .NET 5.0 的人,我们感谢到目前为止我们收到的所有参与和反馈。

09-16 10:33