EF Core非空导航属性 - 最佳实践

4
在我的数据库模型中,我有一个导航属性,它不能为null。然而,由于变量可能尚未加载,因此在运行时它可以为null。
public class Car {

    private Brand? _brand = null
    public Brand Brand {
        set => _brand = value;
        get => _brand ?? throw new InvalidOperationException("Uninitialized property: " + nameof(Brand ));
    }

    public string GetBrandLocation(){
        return this.Brand.Location;
    }

}

根据 Microsoft文档, 这个问题可以通过在getter中抛出异常来解决。但是,所有在getter中抛出的异常都不会被全局异常处理器捕获。一旦异常被抛出,代码的执行立即停止。
根据 Microsoft建议, 应该避免在getters中抛出异常。同时,这个 Stackoverflow线程也建议不要在getters中使用异常。
另一种方法是使属性可为空。但是如果这样做,我必须在每个单独的函数中检查属性是否为空,这似乎不够DRY(Don't Repeat Yourself)。
public class Car {

    private Brand? _brand = null
    public Brand? Brand {
        set => _brand = value;
        get => _brand;
    }

    public string GetBrandLocation(){

        if(this.Brand == null){
            throw new InvalidOperationException("Uninitialized property: " + nameof(Brand ));
        }

        return this.Brand.Location;
        
    }

}

因为我们有一个庞大的代码库,这可能会变得非常混乱。特别是在链式调用中,像这样:
return Car.Brand.Adress.Street

我有什么遗漏吗?在这种情况下,什么被认为是最佳实践?

“我有遗漏什么吗?” - 是的:即使在 C# 10.0(和11?)版本中,#nullable 注解仍然不足以覆盖像 EF 实体类型这样的_可变_对象。我不知道 EFCore 团队具体推荐什么,但在我的情况下,我使用 T4 工具生成接口类型和不可变类来表示带有非空导航属性的“已加载...”实体 - 尽管它们是不可变的且本身不是实体类型。结果可能因人而异。 - Dai
我有什么遗漏吗?是的:即使在C# 10.0(和11?)中,#nullable注释仍然不足以覆盖EF实体类型等_可变_对象。我不知道EFCore团队具体推荐什么,但在我的情况下,我使用T4工具生成接口类型和不可变类来表示具有非空导航属性的“已加载...”实体 - 尽管它们是不可变的并且本身不是实体类型。结果可能因人而异。 - Dai
2个回答

1
如果您通常尝试访问空对象的属性,编译器将抛出一个System.NullReferenceException异常,您可以在全局处理这些错误。但是,如果您想要抛出自己的异常和消息,您必须在访问属性时明确检查,并且在我看来,最简单和清晰的方法是使用扩展方法,如下所示。
public static class Extensions
{
    public static T EnsureNotNull<T>(this T? type, string propertyName) where T : class
    {
        if (type == null)
        {
            throw new InvalidOperationException($"Uninitialized property: {propertyName}");
        }

        return type;
    }
}

使用方法如下:
 return car.Brand.EnsureNotNull("brand").Adress.Street;

...然而,这怎么算是一种改进呢?你仍然面临着意外异常的风险:事实上,它是InvalidOperationException而不是NullReferenceException,最终并没有什么区别。然而,不同的方法(尤其是使用类不变量的方法)可以确保完全避免异常的发生。 - Dai
“此外,你能保证属性永远不会为空吗?” - 是的,我可以。这就是不可变类型、类不变式(以及构造函数)的用途。我的回答证明了这一点。 - Dai
为什么会出乎意料呢?考虑一个可能在项目中任何地方接受Car参数的函数:这个函数无法知道Car.Make导航属性是否已加载。假设函数的内部逻辑需要Car.Make属性已加载,但任何人都可以调用该函数并传递一个带有未加载Make属性的无效Car,将导致它意外崩溃,因为C#/.NET目前不允许函数声明其前提条件。这就是著名的“代码合约”功能所关注的问题,但它在.NET Core+中并不存在。 - Dai
一个可能为空的属性并不是一件异常的事情 这是谁说的?这完全取决于情况。你不能说这不是一个例外就不是一个异常的事情 - Mohammad Aghazadeh
我们需要谈论你所说的很多事情,但不幸的是,这里无法继续。 - Mohammad Aghazadeh
显示剩余11条评论

1
在我的数据库模型中,我有一个导航属性,它不能为null。然而,由于变量可能尚未加载,因此在运行时它可以为null。
没错。即使底层的外键约束(和NOT NULL列约束)要求在数据库中始终存在有效引用,EF导航属性仍然需要可为空,因为从简单的事实来看,加载相关数据是不必要的。
在这种情况下,与FK列对应的实体类属性将是非空类型,但任何引用导航属性都必须始终是可空引用类型(即使用?标记)。(注意:这不适用于集合导航属性,那是另一回事)。
根据微软文档,可以通过在getter中抛出异常来解决这个问题。但是,在getter中抛出的所有异常都不会被全局异常处理程序捕获。在抛出异常后,代码的执行立即停止。
我同意。我对EF的文档甚至建议这样做感到失望。虽然这并不是什么新鲜事:自从他们开始认真建议人们使用`= null!`来初始化DbContext属性(说实话,这真的很愚蠢)。
另一种方法是,我也可以将属性设置为可为空。但如果这样做,我就必须在每个单独的函数中检查属性是否为空,这似乎不太符合DRY原则。
是的,但只有当你在应用程序中使用`class Car` EF类型本身作为传递数据的DTO时才会如此。
...但如果你相反地设计一个新的、独立的不可变的DTO类,具有非空属性,并且具有验证这些类不变量的构造函数,那么这将非常好。
你还可以使用DTO和实体类型之间的隐式转换来减少一些摩擦,比如将DTO传递给期望实体的方法,或者甚至在Linq查询、DbSet等中使用DTO。
如果这是你的EF实体类:
public class Car
{
    [Key]
    [DatabaseGenerated( Identity )]
    public Int32 CarId { get; set; } // CarId int NOT NULL IDENTITY PRIMARY KEY

    public Int32 MakeId { get; set; } // MakeId int NOT NULL CONSTRAINT FK_Car_Make FOREIGN KEY REFERENCES dbo.Makes ( MakeId )

    public Brand? Make { get; set; } // Navigation property for FK_Car_Make 
}

如果你想表示一个带有已加载的“Make”属性的“Car”,只需将以下内容添加到你的项目中:

...然后你就可以:

public class LoadedCarWithMake
{
    public LoadedCarWithMake( Car car, Make make )
    {
        this.Car  = car  ?? throw new ArgumentNullException(nameof(car));
        this.Make = make ?? throw new ArgumentNullException(nameof(make));

        // Ensure `make` corresponds to `car.Make`:
        if( !Object.ReferenceEquals( car.Make, make ) ) throw new ArgumentException( message: "Mismatched Car.Make", paramName: nameof(make) );
    }

    public Car  Car  { get; } // Immutable property, though `Car` is mutable.
    public Make Make { get; } // Immutable property, though `Make` is mutable.

    // Forward other members:
    public Int32 CarId  => this.Car.CarId;
    public Int32 MakeId => this.Car.MakeId;

    // Implicit conversion via reference-returns:
    public static implicit operator Car( LoadedCarWithMake self ) => self.Car;
}

现在,即使该Car实体的Make导航属性被重新设置为null或更改,也没有关系,因为它不会影响使用LoadedCarWithMake的消费者,因为LoadedCarWithMake.Make永远不会是null
您还需要添加一个加载器方法,例如:
public static async Task<LoadedCarWithMake> LoadCarWithMakeAsync( this MyDbContext db, Int32 carId )
{
    Car carWithMake = await db.Cars
        .Include( c => c.Make )
        .Where( c => c.CarId == carId )
        .SingleAsync()
        .ConfigureAwait(false);

    return new LoadedCarWithMake( car, car.Make! );
}

如果所有这些额外的重复代码看起来很乏味,别担心:通常你不需要手动编写它们。使用像T4或Roslyn Code Generation这样的工具自动生成这些"Loaded..."类型非常简单 - 我只希望EF团队能够将其包含在内部,以造福大家。
你可以进一步改进,为每个实体类型定义接口(例如,你可以有IReadOnlyCar和IReadOnlyMake),这些接口不包含任何导航属性,只包含只读的标量/值属性。然后,LoadedCarWithMake也可以实现IReadOnlyCar接口。

非常感谢您详细的回答,对我帮助很大。如果我正确理解您的回答,每个导航属性和其所有组合都有一个单独的“Loaded”类。如果我现在有3个导航属性,那么意味着必须有7个不同的“Loaded”类,对吗?这样会变得非常混乱,是吗? - M4SX5
1
非常感谢您详细的回答,对我帮助很大。如果我正确理解您的回答,每个导航属性和其所有组合都有一个单独的“Loaded”类。如果我现在有3个导航属性,那么意味着必须有7个不同的“Loaded”类,对吗?这样会不会很快变得非常混乱呢? - undefined
非常感谢您详细的回答,对我帮助很大。如果我正确理解您的回答,每个导航属性和其所有组合都有一个单独的“Loaded”类。如果我现在有3个导航属性,那么意味着必须有7个不同的“Loaded”类,对吗?这样会不会很快变得混乱不堪呢? - M4SX5
@M4SX5 不,每个场景只需要一个Loaded...类:一个Loaded...类可以用来断言多个导航属性已加载,例如LoadedCarWithMakeAndOwnerAndInsuranceInfo(实际上,您可能会使用更简洁的名称 - 而且好消息是,使用Roslyn代码生成器,您可以检查每个(非动态)IQueryable的所有.Include()调用,并将其用于自动生成代码。 - Dai
@M4SX5 不,每个场景只需要一个Loaded...类:一个Loaded...类可以用来断言多个导航属性已加载,例如LoadedCarWithMakeAndOwnerAndInsuranceInfo(在实际应用中,您可能会使用更简洁的名称 - 而且好消息是,使用Roslyn代码生成器,您可以检查每个(非动态)IQueryable的所有.Include()调用,并将其用于自动代码生成。 - Dai
@M4SX5 不,每个场景只需要一个“Loaded...”类:一个“Loaded...”类可以用来断言多个导航属性已加载,例如LoadedCarWithMakeAndOwnerAndInsuranceInfo(在实际应用中,您可能会使用更简洁的名称 - 而且好消息是,使用Roslyn代码生成器,您可以检查每个(非动态)IQueryable的所有.Include()调用,并将其用于自动代码生成。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接