本文是 Introduction With AspNet Core And Entity Framework Core Part 1 的翻译版本,有少量改动,可以参考原文
本文将介绍如何从数据库设计开始,设计并实现一个基本的任务管理应用。这个应用具有查询任务,修改任务指派人员等简单功能。
系统准备
Visual Studio 2017
SQL Server or MySql (修改abp数据库为mysql)
VS扩展(用于前端资源打包,再具体用到时展开):
从这里下载模板(http://www.aspnetboilerplate.com/Templates),取一个自己喜欢的名字,比如“myAbpBasic”,不要勾选include login...选项,并下载到本地。由于不熟悉spa框架angularjs等,所以选择了mpa应用。下载完成后解压,用vs2017打开sln。
项目结构如下图
现在可以运行这个项目,可以看到一个不包含登录功能的简单应用,有首页,about页,右上角还有多语言选项。
我们设想有一个任务实体,每个任务有执行人这个属性,执行人id字段作为外键关联到执行人表。首先我们先实现任务实体的增删改查功能。
数据实体属于领域层的范围,所以我们将实体添加到Core这个项目。
Task实体代码如下:
using Abp.Domain.Entities;
using Abp.Domain.Entities.Auditing;
using Abp.Timing;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using myAbpBasic.People;
namespace myAbpBasic.Tasks
{
[Table("AppTasks")]
public class Task : Entity, IHasCreationTime
{
public const int MaxTitleLength = 256;
public const int MaxDescriptionLength = 64 * 1024; //64KB
[Required]
[StringLength(MaxTitleLength)]
public string Title { get; set; }
[StringLength(MaxDescriptionLength)]
public string Description { get; set; }
public DateTime CreationTime { get; set; }
public TaskState State { get; set; }
public Task()
{
CreationTime = Clock.Now;
State = TaskState.Open;
}
public Task(string title, string description = null, Guid? assignedPersonId = null)
: this()
{
Title = title;
Description = description;
}
}
public enum TaskState : byte
{
Open = 0,
Completed = 1
}
}
这里,我们定义了以下信息:
定义好entity之后,我们需要告诉dbcontext有Task这么一个实体类型。
DbContext类代码如下:
using Abp.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using myAbpBasic.People;
using myAbpBasic.Tasks;
namespace myAbpBasic.EntityFrameworkCore
{
public class myAbpBasicDbContext : AbpDbContext
{
//Add DbSet properties for your entities...
public DbSet Tasks { get; set; }
public myAbpBasicDbContext(DbContextOptions options)
: base(options)
{
}
}
}
由于默认为code-first模式,所以定义好entity后我们需要生成migration并同步到数据库
打开程序包管理器控制台
务必选择项目为EFCore项目
执行完成后会在当前项目Migrations文件夹下生成增量文件(多余文件是后续教程中生成的),文件内容如下:
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
namespace myAbpBasic.Migrations
{
public partial class task : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppTasks",
columns: table => new
{
Id = table.Column(nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Title = table.Column(maxLength: 256, nullable: false),
Description = table.Column(maxLength: 65536, nullable: true),
CreationTime = table.Column(nullable: false),
State = table.Column(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppTasks", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppTasks");
}
}
}
继续回到程序包管理器,选择EFCore项目,执行“Update-Database”命令即可。完成后数据库中会产生AppTask表,并和我们在Entity中定义的字段一致。
随意添加几条数据,用于后面实现查询页面数据展示
注:连接字符串位于Web项目appsettings.json配置文件
{
"ConnectionStrings": {
"Default": "Server=192.168.8.150;Port=3306;Database=abp;Uid=root;Pwd=123456;SslMode=none;"
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}
这里所说的服务不同于常见的微服务,服务化,而是一个应用层的另一个说法,因为我们在应用层也会抽象接口,并定义接口实现,该接口可以被展现层调用,所以有时候也称应用层为应用服务层。
应用服务层的作用是从领域层获取数据实体,做一些业务逻辑处理,并将数据转换为Data Transfer Object(DTO)实体,然后传递给展现层。展现层不存在数据实体,数据实体在到达应用服务层后就消失了,转换为Dto后到达展现层。
我们在应用层定义一个ITaskAppService接口,TaskAppService实现,相关的Dto模型,一般而言,把task相关的都放在一个文件夹下
在ITaskAppService中定义一个GetAll查询接口
using Abp.Application.Services;
using Abp.Application.Services.Dto;
using myAbpBasic.Tasks.Dto;
using System.Threading.Tasks;
namespace myAbpBasic.Tasks
{
public interface ITaskAppService : IApplicationService
{
Task> GetAll(GetAllTasksInput input);
}
}
官方文档提到:定义接口并不是必须的,但我们建议这么做。约定俗成地,所有应用服务必须实现IApplicationService接口(一个空的接口标记,猜测用于依赖注入)。
同时我们也定义了相关的Dto模型。官方文档把dto分别放在多个类中,此处我把所有task相关的dto模型放在TasksDto类中。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
using Abp.Application.Services.Dto;
using Abp.AutoMapper;
using Abp.Domain.Entities.Auditing;
namespace myAbpBasic.Tasks.Dto
{
[AutoMapFrom(typeof(Task))]
public class TaskListDto : EntityDto, IHasCreationTime
{
public string Title { get; set; }
public string Description { get; set; }
public DateTime CreationTime { get; set; }
public TaskState State { get; set; }
}
public class GetAllTasksInput
{
public TaskState? State { get; set; }
}
}
下面是ITaskAppService的具体实现
using Abp.Application.Services.Dto;
using Abp.Domain.Repositories;
using Abp.Linq.Extensions;
using Microsoft.EntityFrameworkCore;
using myAbpBasic.Tasks.Dto;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace myAbpBasic.Tasks
{
public class TaskAppService : myAbpBasicAppServiceBase, ITaskAppService
{
private readonly IRepository _taskRepository;
public TaskAppService(IRepository taskRepository)
{
_taskRepository = taskRepository;
}
public async Task> 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(
ObjectMapper.Map>(tasks)
);
}
}
}
单元测试,在开发过程中是及其重要的,它可以局部验证我们的函数是否有正确的输出,避免在整合前端时才发现bug,可以极大地从总体上提高开发效率。此处可以详细展开单元测试、自动化测试的相关内容,但是限于篇幅,只介绍ABP的测试项目如何使用。阅读本节前,请先了解单元测试的相关基本概念。
回到整个解决方案,test文件夹下有两个测试项目,分别对应后台AppService层和前端Web层的测试
ABP已经包含了测试项目用于测试代码。它使用了EF Core提供的内存数据库(EF Core In-Memory Database Provider),而不是直接使用SQL Server(真实数据库数据不可控),这样我们的测试工作就可以脱离真实数据库进行。各个测试之间的内存数据库都是隔离的,不会相互影响,这样测试用例之间是相互隔离的。我们用TestDataBuilder类向内存数据库中写入初始数据用于运行测试,代码如下:
using myAbpBasic.EntityFrameworkCore;
using myAbpBasic.People;
using myAbpBasic.Tasks;
namespace myAbpBasic.Tests.TestDatas
{
public class TestDataBuilder
{
private readonly myAbpBasicDbContext _context;
public TestDataBuilder(myAbpBasicDbContext context)
{
_context = context;
}
public void Build()
{
_context.Tasks.AddRange(
new Task("Follow the white rabbit", "Follow the white rabbit in order to know the reality."),
new Task("Clean your room") { State = TaskState.Completed }
);
}
}
}
如果想理解TestDataBuilder是在什么地方如何使用的,请阅读项目源码。 在上述代码中,我们往内存数据库的AppTask表写入两条数据,分别Title为“Follow the white rabbit”和“Clean your room”的两条记录,其中第二条状态为已完成。所以当编写测试用例时,可以认为数据库中已经存在这两条数据。我们先创建下面两个单元测试(代码位于TaskAppService_Tests.cs):
using myAbpBasic.Tasks;
using myAbpBasic.Tasks.Dto;
using Shouldly;
using System.Linq;
using Abp.Runtime.Validation;
using Xunit;
namespace myAbpBasic.Tests.Tasks
{
public class TaskAppService_Tests : myAbpBasicTestBase
{
private readonly ITaskAppService _taskAppService;
public TaskAppService_Tests()
{
_taskAppService = Resolve();
}
[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);
}
[Fact]
public async System.Threading.Tasks.Task Should_Get_Filtered_Tasks()
{
//Act
var output = await _taskAppService.GetAll(new GetAllTasksInput { State = TaskState.Open });
//Assert
output.Items.ShouldAllBe(t => t.State == TaskState.Open);
}
}
}
译者注:测试用例一般包含两部分代码,Act数据操作,以及Assert结果验证。如第一个用例,Act获取了Task表所有数据,Assert验证数据条数是否为2.第二个用例,Act获取状态为open的所有记录,Assert验证是否全部为open状态的记录。
测试代码左侧会有快捷按钮(xUnit插件提供的功能),也可以通过测试-窗口-测试资源管理器查看所有测试项目,进行测试。
注意:ABP集成了xUnit和Shouldly插件。xUnit是测试框架,和MSTest作用相同,但功能更丰富。Shouldly是轻量断言(Assertion)框架,它将焦点放在当断言失败时如何简单精准的给出很好的错误信息。还提供了ShouldBe,ShouldNotBe,ShouldAllBe等扩展方法。这里是更详细的官方文档。
应用服务开发、测试完成后,我们就正式开始前端展现层开发了。
给顶部菜单创建一个新的页面
using Abp.Application.Navigation;
using Abp.Localization;
namespace myAbpBasic.Web.Startup
{
///
/// This class defines menus for the application.
///
public class myAbpBasicNavigationProvider : NavigationProvider
{
public override void SetNavigation(INavigationProviderContext context)
{
context.Manager.MainMenu
.AddItem(
new MenuItemDefinition(
PageNames.Home,
L("HomePage"),
url: "",
icon: "fa fa-home"
)
).AddItem(
new MenuItemDefinition(
PageNames.About,
L("About"),
url: "Home/About",
icon: "fa fa-info"
)
).AddItem(
new MenuItemDefinition(
"TaskList",
L("TaskList"),
url: "Tasks",
icon: "fa fa-tasks"
)
);
}
private static ILocalizableString L(string name)
{
return new LocalizableString(name, myAbpBasicConsts.LocalizationSourceName);
}
}
}
初始模板包含了两个页面,首页Home,关于About。在SetNavigation方法中新增一项AddItem.分别定义菜单名称,本土化翻译后的名称,指向地址,图标。本土化翻译将在后面详细解释。
在Web项目创建TasksController
using Microsoft.AspNetCore.Mvc;
using myAbpBasic.Tasks;
using myAbpBasic.Tasks.Dto;
using myAbpBasic.Web.Models;
using System.Threading.Tasks;
using myAbpBasic.Common;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.Linq;
using Abp.Application.Services.Dto;
namespace myAbpBasic.Web.Controllers
{
public class TasksController : myAbpBasicControllerBase
{
private readonly ITaskAppService _taskAppService;
public TasksController(ITaskAppService taskAppService)
{
_taskAppService = taskAppService;
}
public async Task Index(GetAllTasksInput input)
{
var output = await _taskAppService.GetAll(input);
var model = new IndexViewModel(output.Items);
return View(model);
}
}
}
using System;
using Microsoft.AspNetCore.Mvc.Rendering;
using myAbpBasic.Tasks;
using System.Collections.Generic;
using System.Linq;
using Abp.Localization;
using myAbpBasic.Tasks.Dto;
namespace myAbpBasic.Web.Models
{
public class IndexViewModel
{
public IReadOnlyList Tasks { get; }
public IndexViewModel(IReadOnlyList tasks)
{
Tasks = tasks;
}
public string GetTaskLabel(TaskListDto task)
{
switch (task.State)
{
case TaskState.Open:
return "label-success";
default:
return "label-default";
}
}
}
}
这个简单的视图模型在它的构造函数取得了任务数据集合,它同时也提供GetTaskLabel方法,用于MVC视图中获取任务状态对应的Bootstrap样式。
在TasksController控制器的Index方法上右键-创建视图,代码如下:
@model myAbpBasic.Web.Models.IndexViewModel
@{
ViewBag.Title = L("TaskList");
ViewBag.ActiveMenu = "TaskList"; //Matches with the menu name in SimpleTaskAppNavigationProvider to highlight the menu item
}
@section scripts
{
}
@L("TaskList")
@foreach (var task in Model.Tasks)
{
-
@L($"TaskState_{task.State}")
@task.Title
@task.CreationTime.ToString("yyyy-MM-dd HH:mm:ss")
}
这里只是简单的将控制器返回的模型转换为视图中的Bootstrap list group组件。我们使用刚才视图模型提供的GetTaskLabel()方法来获取label样式。运行起来,渲染后的页面样式如下:
你看到的样式可能与上图有出入,比如Task List上有方括号,也不会出现Add New按钮和右侧的All Tasks筛选框。没关系,这都会在后面小节讲到。这时候你会发现,右上角的Localization并不会起作用,下面我们就讲讲怎么使用Localization功能。
ABP基础框架Abp.AspNetCore.Mvc.Views.AbpRazorPage类提供了一个L方法,用于本土化翻译。翻译文本定义在Core项目/Localization/Source文件夹,默认只包含了英语和土耳其语。
打开myAbpBasic.json,并新增任务视图中用到的三个新的本土化索引(最后三个)
{
"culture": "en",
"texts": {
"HelloWorld": "Hello World!",
"ChangeLanguage": "Change language",
"HomePage": "HomePage",
"About": "About",
"Home_Description": "Welcome to SimpleTaskApp...",
"About_Description": "This is a simple startup template to use ASP.NET Core with ABP framework.",
"TaskList": "Task List",
"TaskState_Open": "Open",
"TaskState_Completed": "Completed"
}
}
Abp的本土化翻译功能并不复杂,想了解更多可以参考官方文档 Localization Document
拓展阅读:我们修改一下以支持中文
复制一个myAbpBasic.json,并重命名为myAbpBasic-zh-Hans.json。修改myAbpBasicLocalizationConfigurer
using System.Reflection;
using Abp.Configuration.Startup;
using Abp.Localization;
using Abp.Localization.Dictionaries;
using Abp.Localization.Dictionaries.Json;
using Abp.Reflection.Extensions;
namespace myAbpBasic.Localization
{
public static class myAbpBasicLocalizationConfigurer
{
public static void Configure(ILocalizationConfiguration localizationConfiguration)
{
localizationConfiguration.Languages.Add(new LanguageInfo("en", "English", "famfamfam-flags england", isDefault: true));
localizationConfiguration.Languages.Add(new LanguageInfo("tr", "Türkçe", "famfamfam-flags tr"));
localizationConfiguration.Languages.Add(new LanguageInfo("zh-Hans", "简体中文", "famfamfam-flags cn"));
localizationConfiguration.Sources.Add(
new DictionaryBasedLocalizationSource(myAbpBasicConsts.LocalizationSourceName,
new JsonEmbeddedFileLocalizationDictionaryProvider(
typeof(myAbpBasicLocalizationConfigurer).GetAssembly(),
"myAbpBasic.Localization.SourceFiles"
)
)
);
}
}
}
这样运行起来右上角就有了中文的选项,最后把zh-Hans.json文件翻译为中文就可以了!
{
"culture": "zh-Hans",
"texts": {
"HelloWorld": "Hello World!",
"ChangeLanguage": "更换语言",
"HomePage": "主页",
"About": "关于",
"Home_Description": "欢迎来到myAbpBasic...。我是谁??",
"About_Description": "这特么就是一个简单的基于ASP.Net Core的初始模板!没别的卵东西!!",
"TaskList": "任务列表",
"TaskState_Open": "进行中",
"TaskState_Completed": "已完成",
"AllTasks": "全部",
"Unassigned": "未指派",
"AddNew": "添加",
"NewTask": "添加任务",
"Title": "标题",
"Description": "描述",
"AssignedPerson": "指派人员",
"Save": "保存",
"PersonList": "人员列表"
}
}
刚才我们加载了全部的任务列表,控制器Index方法的参数GetAllTasksInput可以用于传递过滤条件。
现在增加一个下拉框过滤,可以按状态过滤任务。在index视图h2中添加以下代码:
@L("TaskList")
@Html.DropDownListFor(
model => model.SelectedTaskState,
Model.GetTasksStateSelectListItems(LocalizationManager),
new
{
@class = "form-control",
id = "TaskStateCombobox"
})
修改IndexViewModel,增加SelectedTaskState属性和GetTasksStateSelectListItems方法:
using System;
using Microsoft.AspNetCore.Mvc.Rendering;
using myAbpBasic.Tasks;
using System.Collections.Generic;
using System.Linq;
using Abp.Localization;
using myAbpBasic.Tasks.Dto;
namespace myAbpBasic.Web.Models
{
public class IndexViewModel
{
public IReadOnlyList Tasks { get; }
public IndexViewModel(IReadOnlyList tasks)
{
Tasks = tasks;
}
public string GetTaskLabel(TaskListDto task)
{
switch (task.State)
{
case TaskState.Open:
return "label-success";
default:
return "label-default";
}
}
//以下为本次新增代码
public TaskState? SelectedTaskState { get; set; }
public List GetTasksStateSelectListItems(ILocalizationManager localizationManager)
{
var list = new List
{
new SelectListItem
{
Text = localizationManager.GetString(myAbpBasicConsts.LocalizationSourceName, "AllTasks"),
Value = "",
Selected = SelectedTaskState == null
}
};
list.AddRange(Enum.GetValues(typeof(TaskState))
.Cast()
.Select(state =>
new SelectListItem
{
Text = localizationManager.GetString(myAbpBasicConsts.LocalizationSourceName, $"TaskState_{state}"),
Value = state.ToString(),
Selected = state == SelectedTaskState
})
);
return list;
}
}
}
修改控制器中Index方法,设置State
public async Task Index(GetAllTasksInput input)
{
var output = await _taskAppService.GetAll(input);
var model = new IndexViewModel(output.Items)
{
SelectedTaskState = input.State,
};
return View(model);
}
这时候运行项目已经会出现下拉框,但改变后数据并不会改变。我们需要写js代码来触发页面刷新
(function ($) {
$(function () {
var _$taskStateCombobox = $('#TaskStateCombobox');
_$taskStateCombobox.change(function() {
location.href = '/Tasks?state=' + _$taskStateCombobox.val();
});
});
})(jQuery);
下一步是把js文件引入到页面中。此处需要介绍一下 Bundler & Minifier 插件,这是一个打包程序方便将js,css,html文件混淆的插件。选择需要混淆的index.js,右键选择Minify File.
操作完成后,程序将自动在Web项目根目录下bundleconfig.json文件中添加以下内容:
{
"outputFileName": "wwwroot/js/views/tasks/index.min.js",
"inputFiles": [
"wwwroot/js/views/tasks/index.js"
]
}
同时创建了index.min.js
回到index视图,引入js文件的代码如下:
@section scripts
{
}
这样我们在开发环境使用的是index.js,在生产环境就使用index.min.js.
我们可以为mvc项目创建集成测试,这样我们就能测试全部服务端代码。如果你对自动化测试没有兴趣,可以跳过本节。
P.S. 译者不建议跳过,完善的单元测试、集成测试可以显著提高开发完成度,减少后续测试时间。
测试项目为Web.Tests,创建TasksController_Tests
ABP的AbpAspNetCoreIntegratedTestBase类提供了一些基础方法来发起http请求、获取请求地址。这些封装的方法可以简化代码,提高开发效率。
从接口返回的response为html,我们用ABP预置的AngleSharp插件来解析html。完整代码如下:
using AngleSharp.Html.Parser;
using Microsoft.EntityFrameworkCore;
using myAbpBasic.Tasks;
using myAbpBasic.Web.Controllers;
using Shouldly;
using System.Linq;
using Xunit;
namespace myAbpBasic.Web.Tests.Controllers
{
public class TasksController_Tests : myAbpBasicWebTestBase
{
[Fact]
public async System.Threading.Tasks.Task Should_Get_Tasks_By_State()
{
//Act
var response = await GetResponseAsStringAsync(
GetUrl(nameof(TasksController.Index), new
{
state = TaskState.Open
}
)
);
//Assert
response.ShouldNotBeNullOrWhiteSpace();
//Get tasks from database
var tasksInDatabase = await UsingDbContextAsync(async dbContext =>
{
return await dbContext.Tasks
.Where(t => t.State == TaskState.Open)
.ToListAsync();
});
//Parse HTML response to check if tasks in the database are returned
var document = new HtmlParser().ParseDocument(response);
var listItems = document.QuerySelectorAll("#TaskList li");
//Check task count
listItems.Length.ShouldBe(tasksInDatabase.Count);
//Check if returned list items are same those in the database
foreach (var listItem in listItems)
{
var header = listItem.QuerySelector(".list-group-item-heading");
var taskTitle = header.InnerHtml.Trim();
tasksInDatabase.Any(t => t.Title == taskTitle).ShouldBeTrue();
}
}
}
}
你还可以从HTML中发掘更多信息,但是绝大多数情况下,检查一些标志性的标签就已经足够了。
本篇介绍了开发时如何编写数据库、实体、应用服务、MVC的相关代码,但只是简单的单表查询、过滤,并未涉及多表关联、增删改等功能,下一篇将介绍这些内容。