使用可为 null 引用类型

C# 8 引入了一项名为“可为 null 引用类型 (NRT)的新功能,该功能允许对引用的类型进行注释,指示它们包含 null 是否有效。 如果你不熟悉此功能,建议你通过阅读 C# 文档来熟悉它。默认情况下,可为 null 的引用类型在新项目模板中处于启用状态,但在现有项目中保持禁用状态,除非明确选择加入。

本页介绍 EF Core 对可为 null 引用类型的支持,并描述使用它们的最佳做法。

必需和可选属性

必需和可选属性页面是介绍必需和可选属性及其与可为 null 引用类型的交互的主要文档。 建议首先阅读该页面。

注意

在现有项目上启用可为 null 的引用类型时应谨慎:以前配置为可选属性的引用类型属性现在将配置为必需属性,除非它们显式注释为可为 null。 管理关系数据库架构时,这可能会导致生成迁移,从而改变数据库列的为 Null 性。

不可为 null 属性和初始化

启用可为 null 引用类型后,C# 编译器会针对任何取消初始化、不可为 null 的属性发出警告,因为这些属性将包含 null。 因此,不能使用以下常见的实体类型编写方式:

public class Customer
{
    public int Id { get; set; }

    // Generates CS8618, uninitialized non-nullable property:
    public string Name { get; set; }
}

如果使用的是 C# 11 或更高版本,则需要成员身份以提供完美解决方案解决此问题:

public required string Name { get; set; }

编译器现在保证当代码实例化 Customer 时,它会始终初始化其 Name 属性。 由于映射到该属性的数据库列不可为 null,因此由 EF 加载的任何实例也始终包含一个非 null 名称。

如果使用较旧版本的 C#,构造函数绑定是确保初始化不可为 null 属性的一种替代方法:

public class CustomerWithConstructorBinding
{
    public int Id { get; set; }
    public string Name { get; set; }

    public CustomerWithConstructorBinding(string name)
    {
        Name = name;
    }
}

遗憾的是,在某些情况下,不能选择构造函数绑定;例如,导航属性不能以这种方式初始化。 在这些情况下,只需在 null 包容运算符的帮助下初始化该属性为 null(但请参阅下文了解详细信息):

public Product Product { get; set; } = null!;

所需的导航属性

必需的导航属性使情况变得更加棘手:尽管给定主体始终存在依赖项,但它可能被某个特定查询加载,也可能不被加载,这取决于程序中该点的需求(请参阅不同的数据加载模式)。 同时,让这些属性为 null 可能不可取,因为这将强制所有对它们的访问需检查 null,即使在加载已知导航时,因此不能 null

这并不是问题! 只要正确加载了所需的依赖项(例如通过 Include),访问其导航属性会保证始终返回非 null。 另一方面,应用程序可以选择通过检查是否导航为 null 检查是否已加载关系。 在此情况下,让导航为 null 是合理的。 这意味着需要从依赖项到主体的导航:

  • 如果认为在未加载时访问导航是程序员错误,则应不可为 null。
  • 如果可以接受应用程序代码检查导航以确定是否加载关系,则应为 null。

如果希望采用更严格的方法,可以使用具有可为 null 的支持字段的不可为 null 的属性:

private Address? _shippingAddress;

public Address ShippingAddress
{
    set => _shippingAddress = value;
    get => _shippingAddress
           ?? throw new InvalidOperationException("Uninitialized property: " + nameof(ShippingAddress));
}

只要正确加载导航,就可以通过属性访问依赖项。 但是,如果在未事先正确加载相关实体的情况下访问属性,则会抛出 InvalidOperationException,因为 API 协定使用不正确。

注意

包含对多个相关实体的引用的集合导航应始终不可为 null。 空集合意味着不存在相关实体,但列表本身不应为 null

DbContext 和 DbSet

使用 EF 时,常见情况是具有上下文类型的未初始化 DbSet 属性:

public class MyContext : DbContext
{
    public DbSet<Customer> Customers { get; set;}
}

尽管这通常会导致编译器发出警告,但 EF Core 7.0 及更高版本会禁止显示此警告,因为 EF 会通过反射自动初始化这些属性。

在旧版 EF Core 上,可以如下所示解决此问题:

public class MyContext : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();
}

另一种策略是使用不可为 null 的自动属性,但要将它们初始化为 null,并使用 null 包容运算符 (!) 来消除编译器警告。 DbContext 基本构造函数确保所有 DbSet 属性都将被初始化,并且永远不会在这些属性中看到 null。

处理可选关系时,可能会出现编译器警告,而这里不可能出现实际的 null 引用异常。 转换和执行 LINQ 查询时,EF Core 保证在不存在可选相关实体的情况下,将直接忽略任何指向它的导航属性,而非引发异常。 但是,编译器不知道此 EF Core 保证,并生成警告,就好像 LINQ 查询是在内存中使用 LINQ to Objects 执行的一样。 因此,需要使用 null 包容运算符 (!) 来通知编译器,不可能出现实际的 null 值:

var order = context.Orders
    .Where(o => o.OptionalInfo!.SomeProperty == "foo")
    .ToList();

在可选导航属性中包含多个级别的关系时,会出现类似的问题:

var order = context.Orders
    .Include(o => o.OptionalInfo!)
    .ThenInclude(op => op.ExtraAdditionalInfo)
    .Single();

如果你发现自己经常这样做,并且所涉及的实体类型主要(或专门)用于 EF Core 查询,请考虑将导航属性设为不可为 null,并通过 Fluent API 或数据注释将其配置为可选。 这将删除所有编译器警告,同时保持关系可选;但是,如果在 EF Core 之外遍历实体,尽管属性注释为不可为 null,但还是有可能观察到 null 值。

旧版本中的限制

在 EF Core 6.0 之前,适用的限制如下:

  • 公共 API 图面不会针对为 Null 性进行注释(公共 API 是“无视 null”),因此在打开 NRT 功能的情况下,公共 API 有时会很难使用。 这主要包括 EF Core 公开的异步 LINQ 运算符(例如 FirstOrDefaultAsync)。 从 EF Core 6.0 开始,公共 API 针对为 Null 性进行了完整注释。
  • 反向工程不支持 C# 8 可为 null 引用类型 (NRT):EF Core 会始终生成假定该功能关闭的 C# 代码。 例如,可为 null 的文本列被构建为类型为 string 而不是 string? 的属性,并且使用 Fluent API 或数据注释来配置是否需要某个属性。 如果使用较旧版本的 EF Core,仍可以编辑已搭建基架的代码,并将这些代码替换为 C# 为 Null 性注释。