使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 创建多层 Web 应用程序(第二部分)






4.91/5 (18投票s)
一个基于 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 框架,并包含自动化测试的多层 Web 应用程序创建分步指南。
- 从 Github 仓库 获取源代码。
目录
引言
这是“使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 创建多层 Web 应用程序”文章系列的第二部分。查看其他部分
- 第一部分 - 使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 创建多层 Web 应用程序
- 第二部分(本篇)- 使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 创建多层 Web 应用程序
开发应用程序
创建 Person 实体
我将向应用程序添加 **Person** 概念,以便 **将任务分配** 给人员。因此,我定义了一个简单的 **Person 实体**
[Table("AppPersons")]
public class Person : AuditedEntity<Guid>
{
public const int MaxNameLength = 32;
[Required]
[MaxLength(MaxNameLength)]
public string Name { get; set; }
public Person()
{
}
public Person(string name)
{
Name = name;
}
}
这次,我将 Id(主键)类型设置为 **Guid**,以作演示。我还从 **AuditedEntity**(具有 CreationTime、CreaterUserId、LastModificationTime 和 LastModifierUserId 属性)继承,而不是直接继承自基类 Entity。
将 Person 关联到 Task 实体
我还向 **Task** 实体添加了 **AssignedPerson** 属性(此处仅共享更改的部分)
[Table("AppTasks")]
public class Task : Entity, IHasCreationTime
{
//...
[ForeignKey(nameof(AssignedPersonId))]
public Person AssignedPerson { get; set; }
public Guid? AssignedPersonId { get; set; }
public Task(string title, string description = null, Guid? assignedPersonId = null)
: this()
{
Title = title;
Description = description;
AssignedPersonId = assignedPersonId;
}
}
AssignedPerson 是 **可选的**。因此,任务可以分配给一个人,也可以不分配。
将 Person 添加到 DbContext
最后,我将新的 Person 实体添加到 DbContext 类中
public class SimpleTaskAppDbContext : AbpDbContext
{
public DbSet<Person> People { get; set; }
//...
}
为 Person 实体添加新的迁移
现在,我在 **程序包管理器控制台** 中运行以下命令
这会在项目中创建一个新的迁移类
public partial class Added_Person : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppPersons",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
CreationTime = table.Column<DateTime>(nullable: false),
CreatorUserId = table.Column<long>(nullable: true),
LastModificationTime = table.Column<DateTime>(nullable: true),
LastModifierUserId = table.Column<long>(nullable: true),
Name = table.Column<string>(maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppPersons", x => x.Id);
});
migrationBuilder.AddColumn<Guid>(
name: "AssignedPersonId",
table: "AppTasks",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AppTasks_AssignedPersonId",
table: "AppTasks",
column: "AssignedPersonId");
migrationBuilder.AddForeignKey(
name: "FK_AppTasks_AppPersons_AssignedPersonId",
table: "AppTasks",
column: "AssignedPersonId",
principalTable: "AppPersons",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
//...
}
我已将 ReferentialAction.Restrict 更改为 ReferentialAction.SetNull。这样做的效果是:如果我删除了一个人,分配给该人的任务将变为未分配。这在此演示中并不重要。但我想展示您可以在需要时修改迁移代码。实际上,您应该始终在将生成的代码应用到数据库之前对其进行审查。之后,我们可以 **应用迁移** 到我们的数据库。
当我们打开数据库时,我们可以看到新表和列,并添加一些测试数据
我添加了一个 Person 并将其分配给了第一个任务
在任务列表中返回分配的 Person
我将更改 **TaskAppService** 以返回已分配的 Person 信息。首先,我向 **TaskListDto** 添加了两个属性
[AutoMapFrom(typeof(Task))]
public class TaskListDto : EntityDto, IHasCreationTime
{
//...
public Guid? AssignedPersonId { get; set; }
public string AssignedPersonName { get; set; }
}
并将 Task.AssignedPerson 属性包含在查询中。刚刚添加了 **Include** 行
public class TaskAppService : SimpleTaskAppAppServiceBase, ITaskAppService
{
//...
public async Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input)
{
var tasks = await _taskRepository
.GetAll()
.Include(t => t.AssignedPerson)
.WhereIf(input.State.HasValue, t => t.State == input.State.Value)
.OrderByDescending(t => t.CreationTime)
.ToListAsync();
return new ListResultDto<TaskListDto>(
ObjectMapper.Map<List<TaskListDto>>(tasks)
);
}
}
因此,GetAll 方法将返回任务的已分配 Person 信息。由于我们使用了 AutoMapper,新属性也将自动复制到 DTO。
更改单元测试以测试已分配的 Person
此时,我们可以更改单元测试,以查看在检索任务列表时是否检索到已分配的 Person。首先,我更改了 TestDataBuilder 类中的初始测试数据,将一个 Person 分配给一个任务。
public class TestDataBuilder
{
//...
public void Build()
{
var neo = new Person("Neo");
_context.People.Add(neo);
_context.SaveChanges();
_context.Tasks.AddRange(
new Task("Follow the white rabbit", "Follow the white rabbit in order to know the reality.", neo.Id),
new Task("Clean your room") { State = TaskState.Completed }
);
}
}
然后,我将更改 TaskAppService_Tests.Should_Get_All_Tasks() 方法,以检查检索到的任务中是否有分配了 Person(请参阅最后添加的行)。
[Fact]
public async System.Threading.Tasks.Task Should_Get_All_Tasks()
{
//Act
var output = await _taskAppService.GetAll(new GetAllTasksInput());
//Assert
output.Items.Count.ShouldBe(2);
output.Items.Count(t => t.AssignedPersonName != null).ShouldBe(1);
}
注意:Count 扩展方法需要 *using System.Linq;* 语句。
在任务列表页面显示已分配的 Person 名称
最后,我们可以更改 **Tasks\Index.cshtml** 以显示 **AssignedPersonName**
@foreach (var task in Model.Tasks)
{
<li class="list-group-item">
<span class="pull-right label label-lg @Model.GetTaskLabel(task)">@L($"TaskState_{task.State}")</span>
<h4 class="list-group-item-heading">@task.Title</h4>
<div class="list-group-item-text">
@task.CreationTime.ToString("yyyy-MM-dd HH:mm:ss") | @(task.AssignedPersonName ?? L("Unassigned"))
</div>
</li>
}
运行应用程序时,我们可以在任务列表中看到它
创建任务的新应用程序服务方法
我们可以列出任务,但我们还没有 **任务创建页面**。首先,向 **ITaskAppService** 接口添加一个 **Create** 方法
public interface ITaskAppService : IApplicationService
{
//...
System.Threading.Tasks.Task Create(CreateTaskInput input);
}
并在 TaskAppService 类中实现它
public class TaskAppService : SimpleTaskAppAppServiceBase, ITaskAppService
{
private readonly IRepository<Task> _taskRepository;
public TaskAppService(IRepository<Task> taskRepository)
{
_taskRepository = taskRepository;
}
//...
public async System.Threading.Tasks.Task Create(CreateTaskInput input)
{
var task = ObjectMapper.Map<Task>(input);
await _taskRepository.InsertAsync(task);
}
}
Create 方法自动将给定的输入 **映射** 到 Task 实体,并使用存储库将其插入数据库。**CreateTaskInput** DTO 如下所示
using System;
using System.ComponentModel.DataAnnotations;
using Abp.AutoMapper;
namespace Acme.SimpleTaskApp.Tasks.Dtos
{
[AutoMapTo(typeof(Task))]
public class CreateTaskInput
{
[Required]
[MaxLength(Task.MaxTitleLength)]
public string Title { get; set; }
[MaxLength(Task.MaxDescriptionLength)]
public string Description { get; set; }
public Guid? AssignedPersonId { get; set; }
}
}
配置它以映射到 Task 实体(使用 AutoMapTo 属性)并添加数据注释以应用 验证。我们使用了 Task 实体中的常量来使用相同的最大长度。
测试任务创建服务
我在 TaskAppService_Tests 类中添加了一些集成测试来测试 Create 方法
using Acme.SimpleTaskApp.Tasks;
using Acme.SimpleTaskApp.Tasks.Dtos;
using Shouldly;
using Xunit;
using System.Linq;
using Abp.Runtime.Validation;
namespace Acme.SimpleTaskApp.Tests.Tasks
{
public class TaskAppService_Tests : SimpleTaskAppTestBase
{
private readonly ITaskAppService _taskAppService;
public TaskAppService_Tests()
{
_taskAppService = Resolve<ITaskAppService>();
}
//...
[Fact]
public async System.Threading.Tasks.Task Should_Create_New_Task_With_Title()
{
await _taskAppService.Create(new CreateTaskInput
{
Title = "Newly created task #1"
});
UsingDbContext(context =>
{
var task1 = context.Tasks.FirstOrDefault(t => t.Title == "Newly created task #1");
task1.ShouldNotBeNull();
});
}
[Fact]
public async System.Threading.Tasks.Task Should_Create_New_Task_With_Title_And_Assigned_Person()
{
var neo = UsingDbContext(context => context.People.Single(p => p.Name == "Neo"));
await _taskAppService.Create(new CreateTaskInput
{
Title = "Newly created task #1",
AssignedPersonId = neo.Id
});
UsingDbContext(context =>
{
var task1 = context.Tasks.FirstOrDefault(t => t.Title == "Newly created task #1");
task1.ShouldNotBeNull();
task1.AssignedPersonId.ShouldBe(neo.Id);
});
}
[Fact]
public async System.Threading.Tasks.Task Should_Not_Create_New_Task_Without_Title()
{
await Assert.ThrowsAsync<AbpValidationException>(async () =>
{
await _taskAppService.Create(new CreateTaskInput
{
Title = null
});
});
}
}
}
第一个测试创建了一个带有 **标题** 的任务,第二个测试创建了一个带有 **标题** 和 **已分配 Person** 的任务,最后一个测试尝试创建一个 **无效** 任务以显示 **异常** 情况。
任务创建页面
我们知道 TaskAppService.Create 工作正常。现在,我们可以创建一个页面来添加新任务。最终页面将是这样的
首先,我在 TaskController 中添加了一个 **Create** 操作,以准备上述页面
using System.Threading.Tasks;
using Abp.Application.Services.Dto;
using Acme.SimpleTaskApp.Tasks;
using Acme.SimpleTaskApp.Tasks.Dtos;
using Acme.SimpleTaskApp.Web.Models.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.Linq;
using Acme.SimpleTaskApp.Common;
using Acme.SimpleTaskApp.Web.Models.People;
namespace Acme.SimpleTaskApp.Web.Controllers
{
public class TasksController : SimpleTaskAppControllerBase
{
private readonly ITaskAppService _taskAppService;
private readonly ILookupAppService _lookupAppService;
public TasksController(
ITaskAppService taskAppService,
ILookupAppService lookupAppService)
{
_taskAppService = taskAppService;
_lookupAppService = lookupAppService;
}
//...
public async Task<ActionResult> Create()
{
var peopleSelectListItems = (await _lookupAppService.GetPeopleComboboxItems()).Items
.Select(p => p.ToSelectListItem())
.ToList();
peopleSelectListItems.Insert(0, new SelectListItem { Value = string.Empty, Text = L("Unassigned"), Selected = true });
return View(new CreateTaskViewModel(peopleSelectListItems));
}
}
}
我注入了 ILookupAppService,它用于获取 Person 的下拉列表项。虽然我可以在这里直接注入并使用 IRepository<Person, Guid>,但我更喜欢这种方式来构建更好的分层和可重用性。ILookupAppService.GetPeopleComboboxItems 在应用程序层定义,如下所示
public interface ILookupAppService : IApplicationService
{
Task<ListResultDto<ComboboxItemDto>> GetPeopleComboboxItems();
}
public class LookupAppService : SimpleTaskAppAppServiceBase, ILookupAppService
{
private readonly IRepository<Person, Guid> _personRepository;
public LookupAppService(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}
public async Task<ListResultDto<ComboboxItemDto>> GetPeopleComboboxItems()
{
var people = await _personRepository.GetAllListAsync();
return new ListResultDto<ComboboxItemDto>(
people.Select(p => new ComboboxItemDto(p.Id.ToString("D"), p.Name)).ToList()
);
}
}
**ComboboxItemDto** 是一个简单的类(在 ABP 中定义),用于传输下拉列表项数据。TaskController.Create 方法仅使用此方法,并将返回的列表转换为 **SelectListItem**(在 AspNet Core 中定义)列表,并通过 CreateTaskViewModel 类传递给视图。
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Acme.SimpleTaskApp.Web.Models.People
{
public class CreateTaskViewModel
{
public List<SelectListItem> People { get; set; }
public CreateTaskViewModel(List<SelectListItem> people)
{
People = people;
}
}
}
Create 视图显示如下
@using Acme.SimpleTaskApp.Web.Models.People
@model CreateTaskViewModel
@section scripts
{
<environment names="Development">
<script src="~/js/views/tasks/create.js"></script>
</environment>
<environment names="Staging,Production">
<script src="~/js/views/tasks/create.min.js"></script>
</environment>
}
<h2>
@L("NewTask")
</h2>
<form id="TaskCreationForm">
<div class="form-group">
<label for="Title">@L("Title")</label>
<input type="text" name="Title" class="form-control" placeholder="@L("Title")" required maxlength="@Acme.SimpleTaskApp.Tasks.Task.MaxTitleLength">
</div>
<div class="form-group">
<label for="Description">@L("Description")</label>
<input type="text" name="Description" class="form-control" placeholder="@L("Description")" maxlength="@Acme.SimpleTaskApp.Tasks.Task.MaxDescriptionLength">
</div>
<div class="form-group">
@Html.Label(L("AssignedPerson"))
@Html.DropDownList(
"AssignedPersonId",
Model.People,
new
{
@class = "form-control",
id = "AssignedPersonCombobox"
})
</div>
<button type="submit" class="btn btn-default">@L("Save")</button>
</form>
我包含了如下定义的 **create.js**
(function($) {
$(function() {
var _$form = $('#TaskCreationForm');
_$form.find('input:first').focus();
_$form.validate();
_$form.find('button[type=submit]')
.click(function(e) {
e.preventDefault();
if (!_$form.valid()) {
return;
}
var input = _$form.serializeFormToObject();
abp.services.app.task.create(input)
.done(function() {
location.href = '/Tasks';
});
});
});
})(jQuery);
让我们看看这个 JavaScript 代码做了什么
- 为表单准备 **验证**(使用 jquery validation 插件),并在单击“保存”按钮时进行验证。
- 使用 **serializeFormToObject** Jquery 插件(在解决方案的 jquery-extensions.js 中定义)将表单数据转换为 JSON 对象(我在 _Layout.cshtml 中将 jquery-extensions.js 添加为最后一个脚本文件)。
- 使用 **abp.services.task.create** 方法调用 TaskAppService.Create 方法。这是 ABP 的重要功能之一。我们可以在 Javascript 代码中像调用代码中的 Javascript 方法一样使用应用程序服务。请参阅 详细信息。
最后,我在 *任务列表* 页面添加了一个“添加任务”按钮,以便导航到 *任务创建页面*
<a class="btn btn-primary btn-sm" asp-action="Create">@L("AddNew")</a>
删除主页和关于页面
如果我们不需要,可以从应用程序中删除主页和关于页面。为此,请首先像这样修改 **HomeController**
using Microsoft.AspNetCore.Mvc;
namespace Acme.SimpleTaskApp.Web.Controllers
{
public class HomeController : SimpleTaskAppControllerBase
{
public ActionResult Index()
{
return RedirectToAction("Index", "Tasks");
}
}
}
然后删除 **Views/Home** 文件夹,并从 SimpleTaskApp**NavigationProvider** 类中删除菜单项。您还可以从本地化 JSON 文件中删除不必要的键。
源代码
您可以在此处获取最新的源代码 https://github.com/aspnetboilerplate/aspnetboilerplate-samples/tree/master/SimpleTaskSystem-Core
文章历史
- 2018-02-14:将源代码升级到 ABP v3.4 并更新了下载链接。
- 2017-07-30:在文章中将 ListResultOutput 替换为 ListResultDto。
- 2017-06-02:更改了文章和解决方案以支持 .net core。
- 2016-08-09:根据反馈修改了文章。
- 2016-08-08:首次发布。