无法从Post表单创建复杂模型 – Navigation属性为空错误在HttpPost表单中。

huangapple go评论66阅读模式
英文:

Can't create complex model from a Post form - Navigation property is null error in HttpPost form

问题

我有一个使用ASP.NET MVC的项目,其中有以下模型:

public class Project
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string Title { get; set; }
    public string? Description { get; set; }
    public Helpers.Enums.Categories? Category { get; set; }

    public int UserId { get; set; }
    public User User { get; set; }

    public ICollection<Backing>? Backings { get; set; } = null!;

    [DataType(DataType.Date)]
    public DateTime DateCreated { get; set; }
    public decimal CurrentFunding { get; set; }

    [Column(TypeName = "decimal(6, 2)")]
    public decimal FinalGoal { get; set; }

    // 为了使用EF的延迟加载功能,将其声明为虚拟属性
    public virtual ICollection<ResourceURL>? ImageURLS { get; set; } = null!;
    public virtual ICollection<Reward>? Rewards { get; set; } = null!;
}

public class User
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string? FullName { get; set; }
    public string Username { get; set; }
    public string Password { get; set; }
    public string Email { get; set; }
    public ICollection<Project>? Projects { get; } = new List<Project>();
    public ICollection<Backing>? Backings { get; } = new List<Backing>();
    public int? ImageURLId { get; set; }
    public ResourceURL? ImageURL { get; set; }
    public bool HasAdminPrivileges { get; set; }
}

正如你所看到的(忽略其他模型),它们处于一对多的关系。一个用户可以拥有多个项目,但一个项目只有一个创建者用户。而且这个关系是严格的,所以一个用户可以没有项目,但一个项目必须有一个创建者。

当我创建一个项目时,遇到的问题是在Create方法中:

public IActionResult Create([Bind("Id,Title,Description,Category,UserId,DateCreated,CurrentFunding,FinalGoal")] Project project) 
{
    if (ModelState.IsValid) 
    {
        _dbService.CreateProject(project);
    }

    return RedirectToAction("Index");
}

以及我创建的服务中的CreateProject方法:

public int CreateProject(Project project) 
{
    _context.Add(project);
    return _context.SaveChanges();
}

我已经在数据库中添加了一堆User对象,但是当我访问/Create并填写表单字段时,出现了一个错误,说'User'为null并且是必需的。我以为EF Core会自动使用UserId字段来查找相关的User,但实际情况并非如此。.NET的模板代码确实可以自动找到它。

我做错了什么?我使用的是版本7,并且我的数据库是一个在上下文文件中填充的LocalDB。

我已经尝试将普通的Project传递给Create操作,而不是绑定,还尝试在将Project添加到数据库之前查找用户,但我觉得这是不正确的,因为我看到的每一段代码都会自动使用Id(作为外键)来进行“映射”。

另外,我注意到在我的Create操作中,ModelState.IsValid始终为false,所以我认为来自POST表单的信息不正确?尽管这是使用脚手架项时默认获取的表单。

英文:

I have an ASP.NET MVC project with the following model:

public class Project 
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string Title { get; set; }
    public string? Description { get; set; }
    public Helpers.Enums.Categories? Category { get; set; }

    public int UserId { get; set; }
    public User User { get; set; }

    public ICollection&lt;Backing&gt;? Backings { get; set; } = null!;

    [DataType(DataType.Date)]
    public DateTime DateCreated { get; set; }
    public decimal CurrentFunding { get; set; }

    [Column(TypeName = &quot;decimal(6, 2)&quot;)]
    public decimal FinalGoal { get; set; }

    // Virtual to benefit from EF lazy loading functionality
    public virtual ICollection&lt;ResourceURL&gt;? ImageURLS { get; set; } = null!;
    public virtual ICollection&lt;Reward&gt;? Rewards { get; set; } = null!;
}

This is another model:

public class User 
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string? FullName { get; set; }
    public string Username { get; set; }
    public string Password { get; set; }
    public string Email { get; set; }
    public ICollection&lt;Project&gt;? Projects { get; } = new List&lt;Project&gt;();
    public ICollection&lt;Backing&gt;? Backings { get; } = new List&lt;Backing&gt;();
    public int? ImageURLId { get; set; }
    public ResourceURL? ImageURL { get; set; }
    public bool HasAdminPrivileges { get; set; }
}

As you can see (ignoring the rest of the models) they are in a One to many relationship. One user can have multiple projects, but a project has a single creator user. Also the relationship is strict so a user can have no projects but a project needs a creator.

My problem comes when creating a project. This is my Create method:

public IActionResult Create([Bind(&quot;Id,Title,Description,Category,UserId,DateCreated,CurrentFunding,FinalGoal&quot;)] Project project) 
{
    if (ModelState.IsValid) 
    {
        _dbService.CreateProject(project);
    }

    return RedirectToAction(&quot;Index&quot;);
}

And here is the CreateProject method of the service I have created.

public int CreateProject(Project project) 
{
    _context.Add(project);
    return _context.SaveChanges();
}

I have seeded my database with a bunch of User objects, however when I navigate to /Create and fill in the form with the fields I get an error that 'User' is null and required. I thought EF Core would automatically find the relevant User using the UserId field but it does not. Template code from .NET does find it automatically.

What am I doing wrong ? I'm using version 7 and my database is a localdb that I have seeded in the Context file.

I have tried passing a normal Project to the Create action instead of a binding and also looking for the user before adding the Project to the database but I feel that is incorrect as every piece of code I have seen does the "mapping" of the complex objects automatically using the Ids (which act as foreign keys).

Also, I have noticed in my Create action, that ModelState.IsValid is always false so I assume the info from the post form is not correct? Even though its the default form I get when using a scaffolded item.

答案1

得分: 1

这是混合EF和MVC时常见的问题,可惜有很多简单的示例让它看起来好像应该都能正常工作。有助于将它分解为一个问题:“如果这是一个Ajax调用,你需要构建你的实体以发送到API,你会怎么做呢?”

问题出在MVC中的@Model是什么。当控制器将一个实体作为模型传递给视图时,它实际上是将实体和任何可急切加载或惰性加载的引用传递给视图引擎,以构建一个视图发送到客户端浏览器,而不是直接发送到客户端浏览器。视图引擎将创建相应的HTML控件,引用模型中的值,并嵌入执行POST调用所需的JavaScript,该调用从创建的控件中提取表单数据。例如,当编辑模型时,通常不会为行ID添加文本框等控件,因此需要使用隐藏输入来包含ID,以便JavaScript可以提取它并将其作为表单数据的一部分提交。因此,当涉及相关数据时,视图需要具有任何/所有相关实体的控件,以便重构这些实体。

因此,回到从客户端进行Ajax调用的方式,您需要能够查找实体的所有相关字段,包括客户端HTML或JavaScript对象中的任何相关实体,以便构建该实体图形以传递回服务器。如果视图引擎没有为所有这些数据构建控件,无论是隐藏的还是其他形式的,视图如何构建它呢?

另一种看待这个问题的方式是通过控制器方法本身:

public IActionResult Create([Bind("Id,Title,Description,Category,UserId,DateCreated,CurrentFunding,FinalGoal")] Project project) 
{
    //...
}

...也可以写成:

public IActionResult Create(int Id, string Title, string Description, string Category, int UserId, DateTime DateCreated, decimal CurrentFunding, decimal FinalGoal) 
{
    var project = new Project
    {
        Id = Id,
        Title = Title,
        Description = Description,
        // ...
    }
    //...
}

这本质上就是在接收来自请求的数据时,Bind表达式或使用接受Entity/DTO的操作所做的事情。传入的“实体”只是从提供的数据中构建的副本。它只能完全取决于传入的数据。当涉及到实体图(相关实体)时,情况会变得更加复杂,因为在我们可能想要插入一个新项目的情况下,我们想要进行的每个引用可能都不会插入。我们可能想要引用一个现有的Team或其他实体。问题在于以这种方式反序列化的任何实体都将被视为要插入的实体,因此即使我们反序列化了相关的Team,我们也需要显式地将我们想要关联到新Project的任何东西附加到DbContext,否则EF将尝试在我们只想引用现有实体时插入一个新的Team。

现在有一个“技巧”可以使用,使整个实体图可用于视图以进行更新并使用JavaScript发送回:

var model = @Html.Raw(Json.Encode(Model))

在Ajax示例中,您可以更新此JS模型,然后将其传递给控制器POST操作以进行更新。有三个原因不应该使用这样的方法。首先,当视图引擎构建视图时,它将尝试序列化模型的整个对象图。这意味着如果启用了延迟加载,将触摸每个属性并延迟加载,如果没有急切加载,那么未急切加载的任何属性都将为#null。这可能会导致额外的数据库调用或将更多数据发送到视图,从而降低性能。第二个原因是它向客户端暴露了您域的一切信息。检查您的HTML和JavaScript的任何人都会看到他们可能不需要也不应该看到的信息。至少它向他们提供了关于您的架构的可能对尝试恶意行为有价值的信息。第三个原因是,当您开始在控制器和视图之间传递实体模型时,您开始将系统暴露给恶意行为,例如在更新期间:

_context.Attach(model);
_context.Entry(model).State = EntityState.Modified;
_context.SaveChanges();

像这样的代码信任来自请求的数据的一切,如果这些数据来自HTML控件的值或序列化的JS对象,它可能会被篡改并覆盖您不希望的数据。当涉及引用现有实体时,它仍然具有相同的问题。所有传入的东西都是一个分离的、非跟踪的数据副本,被视为一个实体。

我能提供的最佳建议是不要在控制器和视图之间传递实体。将实体投影为DTO或ViewModel,表示视图需要的数据结构,并使用POST返回DTO/ViewModel,在验证传入数据后加载相关实体(们)或创建新实体,将这些值复制过去,并保存实体。这可以避免意外/不受欢迎的篡改风险,同时减小了服务器与客户端之间传输的负载大小。

英文:

This is a common problem when mixing EF and MVC, and sadly there are a lot of simple examples out there that make it look like it should all just work. It helps to break it down to a question of "what would you do if this was an Ajax call and you needed to construct your entity to send to the API?"

The trouble stems from what @Model is in MVC. When a controller passes an entity as a model to a View, it is passing the entity and any eager or lazy-loadable references to the view engine to build a view to send to the client browser, not to the client browser itself. The view engine will create the respective HTML controls referencing the values from the model, and embed the Javascript necessary to perform a POST call for the Form, which extracts the form data from the controls it creates. For instance when editing a model you typically won't have a textbox or such for the row ID, so you need a hidden input to contain the ID so the Javascript can extract it to submit as part of the form data. So when it comes to related data, the view needs controls with any/all related entities to draw upon to reconstruct those as well.

So going back to how you would make an Ajax call from the client, you need to be able to find all relevant fields for the entity, including any related entities from the client HTML or Javascript objects in order to construct that entity graph to pass back to the server. If the view engine didn't construct controls, hidden or otherwise for all of that data, how is the view going to construct it?

Another way to look at it is with the controller method itself:

public IActionResult Create([Bind(&quot;Id,Title,Description,Category,UserId,DateCreated,CurrentFunding,FinalGoal&quot;)] Project project) 
{
    //...
}

... could also be written as:

public IActionResult Create(int Id, string Title,string Description, string Category, int UserId, DateTime DateCreated, decimal CurrentFunding, decimal FinalGoal) 
{
    var project = new Project
    {
        Id = Id,
        Title = Title,
        Description = Description,
        // ...
    }
    //...
}

That is in essence all the Bind expression or using an Action that accepts an Entity/DTO does when it receives data from a request. The "entity" coming in is just a constructed copy from the data that is provided. It is only as complete as the data coming in. This gets extra complicated when dealing with graphs of entities (related entities) because where we might want to insert a new project, not every reference we want to make will also be an insert. We may want to reference an existing Team or other entity. The trouble is that any entity that gets deserialized this way will be treated as a new entity to be inserted, so even if we de-serialize a related Team, we would need to explicitly attach anything we want to relate to a new Project to the DbContext otherwise EF will try inserting a new Team when we just want to reference an existing one.

Now there is one "hack" you can use to make an entire entity graph available to a view to be able to update and send back using Javascript:

var model = @Html.Raw(Json.Encode(Model))

In the Ajax example you could update this JS model then pass it to a Controller POST action to be updated. There are three reasons you should not use something like this. Firstly, this will attempt to serialize the entire object graph of the model when the view engine constructs the view. This means that if you have lazy loading enabled, every property will be touched and lazy loading if it hasn't been eager loaded. If lazy loading is disabled then anything not eager loaded will be #null. This can result in a lot of extra database calls and/or a lot more data being sent to the view, slowing down performance. The second reason is that it exposes everything about your domain to the client. Anyone inspecting your HTML and Javascript will see information they probably don't need and should never see. At a minimum it gives them information about your schema that could be valuable for attempting malicious actions. The third reason is that when you start relying on passing entity models back and forth between controller and view, you start exposing your system to malicious actions like during an update:

_context.Attach(model);
_context.Entry(model).State = EntityState.Modified;
_context.SaveChages();

Code like this trusts everything about the data coming in, and if that data comes from values in HTML controls or a serialized JS object, it can be tampered with and overwrite data you don't expect. It still has the exact same problem when it comes to referencing existing entities as well. Everything coming in is a detached, non-tracked copy of data cast as an Entity.

The best advice I can offer is to not pass entities between controllers and views. Project entities into DTOs or ViewModels that represent the data structure your view needs, and pass back a DTO/ViewModel with your POST where you load the relevant entity(ies) or create the new entity after validating the data coming in, copy those values across, and save the entity. This avoids the risk of unexpected/unintended tampering, plus it reduces the payload size travelling to and from the server.

huangapple
  • 本文由 发表于 2023年7月17日 22:21:19
  • 转载请务必保留本文链接:https://go.coder-hub.com/76705410.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定