练习 - 应用 null 安全性策略

已完成

在上一个单元中,你了解了如何在代码中表达为 Null 性意图。 在此单元中,你将学到的知识应用到现有的 C# 项目中。

注意

本模块使用 .NET CLI(命令行接口)和 Visual Studio Code 进行本地开发。 完成本模块后,你可以使用 Visual Studio (Windows)、Visual Studio for Mac (macOS) 来应用概念,或使用 Visual Studio Code(Windows、Linux 和 macOS)进行持续开发。

此模块使用 .NET 6.0 SDK。 通过在首选终端中运行以下命令,确保你已安装 .NET 6.0:

dotnet --list-sdks

将显示类似于下面的输出:

3.1.100 [C:\program files\dotnet\sdk]
5.0.100 [C:\program files\dotnet\sdk]
6.0.100 [C:\program files\dotnet\sdk]

确保列出了以 6 开头的版本。 如果未列出任何版本或未找到命令,请安装最新的 .NET 6.0 SDK

检索并检查示例代码

  1. 在命令终端,克隆示例 GitHub 存储库并切换到克隆的目录。

    git clone https://github.com/microsoftdocs/mslearn-csharp-null-safety
    cd mslearn-csharp-null-safety
    
  2. 在 Visual Studio Code 中打开项目目录。

    code .
    
  3. 使用 dotnet run 命令运行示例项目。

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    

    这将导致引发 NullReferenceException

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
       at Program.<Main>$(String[] args) in .\src\ContosoPizza.Service\Program.cs:line 13
    

    堆栈跟踪指示异常发生在 .\src\ContosoPizza.Service\Program.cs 中的第 13 行。 在第 13 行,对 pizza.Cheeses 属性调用 Add 方法。 由于 pizza.Cheesesnull,因此会引发 NullReferenceException

    using ContosoPizza.Models;
    
    // Create a pizza
    Pizza pizza = new("Meat Lover's Special")
    {
        Size = PizzaSize.Medium,
        Crust = PizzaCrust.DeepDish,
        Sauce = PizzaSauce.Marinara,
        Price = 17.99m,
    };
    
    // Add cheeses
    pizza.Cheeses.Add(PizzaCheese.Mozzarella);
    pizza.Cheeses.Add(PizzaCheese.Parmesan);
    
    // Add toppings
    pizza.Toppings.Add(PizzaTopping.Sausage);
    pizza.Toppings.Add(PizzaTopping.Pepperoni);
    pizza.Toppings.Add(PizzaTopping.Bacon);
    pizza.Toppings.Add(PizzaTopping.Ham);
    pizza.Toppings.Add(PizzaTopping.Meatballs);
    
    Console.WriteLine(pizza);
    
    /*
        Expected output:
    
        The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
        It's covered with a blend of mozzarella and parmesan cheese.
        It's layered with sausage, pepperoni, bacon, ham and meatballs.
        This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
    */
    

启用可为空上下文

现在,你将启用可为空上下文并检查其对生成的影响。

  1. 在 src/ContosoPizza.Service/ContosoPizza.Service.csproj 中,添加突出显示的行并保存更改:

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\ContosoPizza.Models\ContosoPizza.Models.csproj" />
      </ItemGroup>
    
    </Project>
    

    上述更改为整个 ContosoPizza.Service 项目启用可为空上下文。

  2. 在 src/ContosoPizza.Models/ContosoPizza.Models.csproj 中,添加突出显示的行并保存更改:

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
    </Project>
    

    上述更改为整个 ContosoPizza.Models 项目启用可为空上下文。

  3. 使用 dotnet build 命令生成示例解决方案。

    dotnet build
    

    生成成功,并出现 2 个警告。

    dotnet build
    Microsoft (R) Build Engine version 17.0.0+c9eb9dd64 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      Restored .\src\ContosoPizza.Service\ContosoPizza.Service.csproj (in 477 ms).
      Restored .\src\ContosoPizza.Models\ContosoPizza.Models.csproj (in 475 ms).
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
      ContosoPizza.Models -> .\src\ContosoPizza.Models\bin\Debug\net6.0\ContosoPizza.Models.dll
      ContosoPizza.Service -> .\src\ContosoPizza.Service\bin\Debug\net6.0\ContosoPizza.Service.dll
    
    Build succeeded.
    
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): warning CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
        2 Warning(s)
        0 Error(s)
    
    Time Elapsed 00:00:07.48
    
  4. 再次使用 dotnet build 命令生成示例解决方案。

    dotnet build
    

    这一次,生成成功,并且没有错误或警告。 上一个生成已成功完成,并出现警告。 由于源未更改,因此生成过程不会再次运行编译器。 由于生成不会运行编译器,因此没有警告。

    提示

    可以在 dotnet build 之前使用 dotnet clean 命令强制重新生成项目中的所有程序集。

  5. 在 .csproj 文件中,添加突出显示的行并保存更改。

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\ContosoPizza.Models\ContosoPizza.Models.csproj" />
      </ItemGroup>
    
    </Project>
    
    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
      </PropertyGroup>
    
    </Project>
    

    前面的更改指示每当遇到警告时编译器都会使生成失败。

    提示

    <TreatWarningsAsErrors> 的使用是可选的。 但是,我们建议使用它,因为它可确保你不会忽略任何警告。

  6. 使用 dotnet build 命令生成示例解决方案。

    dotnet build
    

    生成失败,并出现 2 个错误。

    dotnet build
    Microsoft (R) Build Engine version 17.0.0+c9eb9dd64 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      All projects are up-to-date for restore.
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    
    Build FAILED.
    
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Cheeses' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
    .\src\ContosoPizza.Models\Pizza.cs(3,28): error CS8618: Non-nullable property 'Toppings' must contain a non-null value when exiting constructor. Consider declaring the property as nullable. [.\src\ContosoPizza.Models\ContosoPizza.Models.csproj]
        0 Warning(s)
        2 Error(s)
    
    Time Elapsed 00:00:02.95
    

    将警告视为错误时,应用不再生成。 在这种情况下,这实际上是需要的,因为错误的数量很少,我们会很快解决它们。 这两个错误 (CS8618) 让你知道有些属性声明不可为 null,但尚未初始化。

修复错误

有很多策略可以解决与为 Null 性相关的警告/错误。 示例包括:

  • 需要一个不可为 null 的奶酪和配料集合作为构造函数参数
  • 截获 get/set 属性并添加 null 检查
  • 表达属性可为空的意图
  • 使用属性初始化表达式通过默认(空)值内联初始化集合
  • 在构造函数中为属性分配一个默认(空)值
  1. 若要修复 Pizza.Cheeses 属性的错误,请修改 Pizza.cs 上的属性定义以添加 null 检查。 这不是没有奶酪的披萨,是吗?

    namespace ContosoPizza.Models;
    
    public sealed record class Pizza([Required] string Name)
    {
        private ICollection<PizzaCheese>? _cheeses;
    
        public int Id { get; set; }
    
        [Range(0, 9999.99)]
        public decimal Price { get; set; }
    
        public PizzaSize Size { get; set; }
        public PizzaCrust Crust { get; set; }
        public PizzaSauce Sauce { get; set; }
    
        public ICollection<PizzaCheese> Cheeses
        {
            get => (_cheeses ??= new List<PizzaCheese>());
            set => _cheeses = value ?? throw new ArgumentNullException(nameof(value));
        }
    
        public ICollection<PizzaTopping>? Toppings { get; set; }
    
        public override string ToString() => this.ToDescriptiveString();
    }
    

    在上述代码中:

    • 添加了一个新的支持字段以帮助截获名为 _cheesesgetset 属性访问器。 它被声明为可为空 (?) 并且未初始化。
    • get 访问器映射到使用 null 合并运算符 (??) 的表达式。 此表达式返回 _cheeses 字段,假设它不是 null。 如果是 null,则在返回 _cheeses 之前将 _cheeses 赋予 new List<PizzaCheese>()
    • set 访问器也映射到表达式并使用 null 合并运算符。 当使用者赋予 null 值时,会引发 ArgumentNullException
  2. 由于并非所有披萨都有配料,因此 null 可能是 Pizza.Toppings 属性的有效值。 在这种情况下,将其表达为可为空是有意义的。

    1. 修改 Pizza.cs 上的属性定义以允许 Toppings 可为空。

      namespace ContosoPizza.Models;
      
      public sealed record class Pizza([Required] string Name)
      {
          private ICollection<PizzaCheese>? _cheeses;
      
          public int Id { get; set; }
      
          [Range(0, 9999.99)]
          public decimal Price { get; set; }
      
          public PizzaSize Size { get; set; }
          public PizzaCrust Crust { get; set; }
          public PizzaSauce Sauce { get; set; }
      
          public ICollection<PizzaCheese> Cheeses
          {
              get => (_cheeses ??= new List<PizzaCheese>());
              set => _cheeses = value ?? throw new ArgumentNullException(nameof(value));
          }
      
          public ICollection<PizzaTopping>? Toppings { get; set; }
      
          public override string ToString() => this.ToDescriptiveString();
      }
      

      现在,Toppings 属性表示为可为空。

    2. 将突出显示的行添加到 ContosoPizza.Service\Program.cs

      using ContosoPizza.Models;
      
      // Create a pizza
      Pizza pizza = new("Meat Lover's Special")
      {
          Size = PizzaSize.Medium,
          Crust = PizzaCrust.DeepDish,
          Sauce = PizzaSauce.Marinara,
          Price = 17.99m,
      };
      
      // Add cheeses
      pizza.Cheeses.Add(PizzaCheese.Mozzarella);
      pizza.Cheeses.Add(PizzaCheese.Parmesan);
      
      // Add toppings
      pizza.Toppings ??= new List<PizzaTopping>();
      pizza.Toppings.Add(PizzaTopping.Sausage);
      pizza.Toppings.Add(PizzaTopping.Pepperoni);
      pizza.Toppings.Add(PizzaTopping.Bacon);
      pizza.Toppings.Add(PizzaTopping.Ham);
      pizza.Toppings.Add(PizzaTopping.Meatballs);
      
      Console.WriteLine(pizza);
      
      /*
          Expected output:
      
          The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
          It's covered with a blend of mozzarella and parmesan cheese.
          It's layered with sausage, pepperoni, bacon, ham and meatballs.
          This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
      */
      

    在前面的代码中,如果 Toppingsnull,则使用 null 合并运算符将其赋予 new List<PizzaTopping>();

运行已完成的解决方案

  1. 保存所有更改,然后生成解决方案。

    dotnet build
    

    生成完成,并且没有警告或错误。

  2. 运行应用。

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    

    该应用运行完成(没有错误)并显示以下输出:

    dotnet run --project src/ContosoPizza.Service/ContosoPizza.Service.csproj
    The "Meat Lover's Special" is a deep dish pizza with marinara sauce.
    It's covered with a blend of mozzarella and parmesan cheese.
    It's layered with sausage, pepperoni, bacon, ham and meatballs.
    This medium size is $17.99. Delivery is $2.50 more, bringing your total $20.49!
    

总结

在此单元中,你使用可为空上下文来识别和阻止代码中可能出现的 NullReferenceException