本文是為了學習ABP的使用,是翻譯ABP官方文檔的一篇實戰教程,我暫時是優先翻譯自己感興趣或者比較想學習的部分,後續有時間希望能將ABP系列翻譯出來,除了自己能學習外,有可能的話希望幫助一些英文閱讀能力稍微差一點的同學(當然我自己也不一定翻譯的多好,大家共同學習)。 其實這篇文章也花了我一些時間,突 ...
本文是為了學習ABP的使用,是翻譯ABP官方文檔的一篇實戰教程,我暫時是優先翻譯自己感興趣或者比較想學習的部分,後續有時間希望能將ABP系列翻譯出來,除了自己能學習外,有可能的話希望幫助一些英文閱讀能力稍微差一點的同學(當然我自己也不一定翻譯的多好,大家共同學習)。
其實這篇文章也花了我一些時間,突然感嘆其實寫文章挺不容易的,這次雖然是翻譯,基本內容都是尊重原文的意思翻譯,但是裡面的每一句代碼我都自己寫了也運行測試了,截圖都是自己運行的結果。
這個ABP框架真的挺不錯的,已經有很多人也已經翻譯了,但是好像都是以前的,但是官網有些更新可能沒同步,而且自己翻譯覺得記憶更深刻一些。
接受來自任何小伙伴任何方面的好評與差評!!!!!!!!!!!!!
官網原文鏈接:https://aspnetboilerplate.com/Pages/Documents/Articles/Introduction-With-AspNet-Core-And-Entity-Framework-Core-Part-1/index.html
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
在本文中,我將展示如何使用以下工具創建一個簡單的跨平臺分層web應用程式:
- .Net Core作為基本的跨平臺應用程式的開發框架
- ABP(ASP.NET Boilerplate)作為啟動模板和應用框架
- ASP.NET Core作為Web 框架
- Entity FrameWork作為ORM框架
- BootStrap作為HTML/Css框架
- jQuery作為客戶端Ajax/Dom庫
- xUnit 和 Shouldly用來對伺服器端的單元/集成測試
我還將Log4Net和AutoMapper,這些已經預設包含在ABP模板中。
將要用到的技術(這些技術我們暫時都不做延伸的解釋,後續有時間會有專門的文章進行說明):
- 分層體繫結構
- 領域驅動設計(DDD)
- 依賴註入(DI)
- 集成測試
我們將要開發一個任務管理的應用程式,任務可以進行分配給某些人。在這裡,我們不用自己一層一層的去開發應用程式,而是在應用程式增長時切換到垂直層。隨著應用程式的發展,我將根據需要介紹ABP和其他框架的一些特性。
前期準備
要運行和開發此示例,請提前在機器上安裝下列工具:
- Visual Studio 2017
- SQL Server(可以將連接字元串更改為localdb)
- Visual Studio擴展:
- Bundler & Minifier
- Web Compile
創建應用程式
使用ABP的啟動模板(http://www.aspnetboilerplate.com/Templates)來創建一個名為“acme simpletaskapp”的新web應用程式。公司名稱(這裡的“Acme”)是可選的。我們選擇多頁Web應用程式(Multi Page Web Application),在這裡為了保證最基本的啟動模板功能,我們也不選擇SPA,並且禁用了身份驗證。
它創建了一個分層的解決方案,如下所示:
它包含6個以我們創建模板時輸入的項目名稱開頭的項目。
- .Core項目用於領域/業務層(實體、領域服務…)
- .Appilcation項目為應用層(dtos,應用服務…)
- .EntityFramework項目用於EF Core集成(從其他層抽象EF Core)
- .Web項目就是ASP.Net MVC
- .Tests項目用於單元測試和集成測試(直到應用層,不包括web層)
- .Web.Tests用來對ASP.NET Core的集成測試(包括web層的完整的集成測試)
運行一下應用程式,可以看到如下界面:
它包含一個頂部菜單,空的主頁和About頁面和一個切換語言下拉選項。
開發應用程式
創建一個Task實體
我想從一個簡單的Task實體開始。由於實體是域層的一部分,所以我將它添加到.Core項目中:
using Abp.Domain.Entities; using Abp.Domain.Entities.Auditing; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text; namespace Acme.SimpleTaskSystem { [Table("AppTasks")] public class Task : Entity, IHasCreationTime { public const int MaxTitleLength = 256; public const int MaxDescriptionLength = 64 * 1024; //64KB [Required] [MaxLength(MaxTitleLength)] public string Title { get; set; } [MaxLength(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) : this() { Title = title; Description = description; } } public enum TaskState : byte { Open = 0, Completed = 1 } }
- Task繼承ABP的Entity類,它預設包含int類型的Id屬性。我們可以使用通用版本Entity<TPrimaryKey>來選擇不同的PK類型。
- IHasCreationTime是一個簡單的介面,它只定義了CreationTime屬性(為CreationTime使用一個標準名稱非常好)。
- Task實體定義一個必填的Title和一個可選的Description。
- TaskState是一個簡單的定義任務狀態的枚舉。
- Clock.Now預設情況下返回DateTime.Now,但是它提供了一個抽象,所以有需要的話很容易的切換到DateTime.UtcNow。如果使用ABP框架,請用Clock.Now,而不是DateTime.Now
- 將Task實體存儲到資料庫中的AppTasks表中。
添加任務到DbContext
.EntityFrameworkCore項目預定義了一個DbContext,我們應該在DbContext中添加一個Task實體的DbSet:
using Abp.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Acme.SimpleTaskSystem.EntityFrameworkCore { public class SimpleTaskSystemDbContext : AbpDbContext { //Add DbSet properties for your entities... public DbSet<Task> Tasks { get; set; } public SimpleTaskSystemDbContext(DbContextOptions<SimpleTaskSystemDbContext> options) : base(options) { } } }
現在EF Core知道我們已經有了一個Task實體。
創建第一個資料庫遷移
我們將創建一個初始的資料庫遷移來創建資料庫和AppTasks表,從Visual Studio打開包管理器控制台並運行Add-Migration命令(預設項目必須是.EntityFrameworkCore項目):
此命令在.EntityFrameworkCore項目中創建一個Migrations文件夾,該文件夾包含遷移類和資料庫模型的快照:
自動生成的“Initial”遷移類如下所示:
using System; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; namespace Acme.SimpleTaskSystem.Migrations { public partial class Initial : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "AppTasks", columns: table => new { Id = table.Column<int>(nullable: false) .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), Title = table.Column<string>(maxLength: 256, nullable: false), Description = table.Column<string>(maxLength: 65536, nullable: true), CreationTime = table.Column<DateTime>(nullable: false), State = table.Column<byte>(nullable: false) }, constraints: table => { table.PrimaryKey("PK_AppTasks", x => x.Id); }); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "AppTasks"); } } }
創建資料庫
從包管理器控制台運行Update-Database命令創建資料庫:
這條命令將在本地sql server中創建一個名為SimpleTaskSystemDb的資料庫,並執行遷移:
現在,我有一個Task實體和併在資料庫中有相應的表,我們添加幾條示例數據:
註意,資料庫連接字元串定義在.Web項目中的appsettings.json文件中。
任務應用程式服務
應用程式服務用於向表示層公開域邏輯,應用程式被表示層通過數據傳輸對象(DTO)作為參數(如果有需要)調用,使用域對象執行某些特定的業務邏輯,並返回一個DTO到表示層(如果需要)。
我們在.Application項目中創建一個應用程式服務TaskAppService,以執行與任務相關的應用程式邏輯,首先定義一個應用程式服務的介面。
public interface ITaskAppService : IApplicationService { Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input); }
定義介面不是必須的,但是建議用介面。作為約定,在ABP中所有App服務都必須實現IApplicationService介面(它只是一個空的標記介面)。我創建了一個用於查詢任務的GetAll方法。為此,我還定義了以下dto:
public class GetAllTasksInput { public TaskState? State { get; set; } } [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; } }
- GetAllTasksInput DTO定義了GetAll方法的輸入參數,我沒有直接將狀態作為方法參數,而是將它添加到DTO對象中。這樣的話,之後我們可以在DTO中添加其他參數,而不需要影響現有的客戶端邏輯。
- TaskListDto用於返回任務數據。它繼承自定義了一個Id屬性的EntityDto(我們可以將Id添加到Dto中,而不是從EntityDto派生出來),我們定義[AutoMapFrom]屬性來創建從任務實體到TaskListDto的自動映射。這個屬性在Abp.AutoMapper nuget包中定義。
- 最後,ListResultDto是一個包含項目列表的簡單類(我們可以直接返回一個列表)。
現在我們可以去實現ITaskAppService
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Abp.Application.Services.Dto; using Abp.Domain.Repositories; using Abp.Linq.Extensions; using Microsoft.EntityFrameworkCore; namespace Acme.SimpleTaskSystem { public class TaskAppService : SimpleTaskSystemAppServiceBase, ITaskAppService { private readonly IRepository<Task> _taskRepository; public TaskAppService(IRepository<Task> taskRepository) { _taskRepository = taskRepository; } public async Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input) { var tasks = await _taskRepository .GetAll() .WhereIf(input.State.HasValue, t => t.State == input.State.Value) .OrderByDescending(t => t.CreationTime) .ToListAsync(); return new ListResultDto<TaskListDto>( ObjectMapper.Map<List<TaskListDto>>(tasks) ); } } }
- TaskAppService繼承自包含在ABP啟動模板中的SimpleTaskSystemAppServiceBase(它繼承於ABP的ApplicationService類),這不是必需的,應用程式服務可以是普通類。但是ApplicationService基類有一些預先註入的服務(如此處使用的ObjectMapper)。
- 我用依賴註入去獲得一個 repository。
- Repositories用來抽象對實體的資料庫操作,ABP為每個執行公共任務的實體創建一個預定義的存儲庫(如這裡的 IRepository<Task>), IRepository.GetAll()返回查詢實體的IQueryable。
- WhereIf 是ABP中的擴展方法,用來簡化IQueryable.Where
- ObjectMapper(來自ApplicationServiceBase類,預設情況下通過AutoMapper實現)用於將任務對象列表映射到TaskListDtos對象列表中。
測試TaskAppService
在進一步創建用戶界面之前,我想測試TaskAppService。如果您對自動化測試不感興趣,可以跳過這一部分。
啟動模板包含一個.Tests項目來測試我們的代碼。它使用EF Core提供的記憶體資料庫來代替SQL SERVER.因此我們的單元測試可以在沒有真正的資料庫下工作,它為每個測試創建一個單獨的資料庫。因此,測試是相互隔離的。我們可以使用TestDataBuilder類在運行測試之前向記憶體資料庫添加一些初始測試數據。我更改TestDataBuilder代碼如下所示:
using Acme.SimpleTaskSystem.EntityFrameworkCore; namespace Acme.SimpleTaskSystem.Tests.TestDatas { public class TestDataBuilder { private readonly SimpleTaskSystemDbContext _context; public TestDataBuilder(SimpleTaskSystemDbContext 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在何處以及如何使用。我向dbcontext添加了兩個任務(其中一個已經完成)。我可以編寫測試,假設資料庫中有兩個任務。我的第一個集成測試測試上面創建的TaskAppService.GetAll()方法:
using Shouldly; using System; using System.Collections.Generic; using System.Text; using Xunit; namespace Acme.SimpleTaskSystem.Tests { public class TaskAppService_Tests : SimpleTaskSystemTestBase { private readonly ITaskAppService _taskAppService; public TaskAppService_Tests() { _taskAppService = Resolve<ITaskAppService>(); } [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); } } }
我創建了兩個不同的tests來測試GetAll()方法,現在我們從VS打開測試資源管理器(Test\Windows\Test Explorer)來運行單元測試
兩個都成功了。註意ABP啟動模板預設安裝了xUnit 和 Shouldly ,所以我們才可以直接使用。
創建任務列表視圖
現在,我知道TaskAppService可以正常工作,我可以開始創建一個頁面來列出所有的任務。
添加一個新的菜單項
首先在頂部菜單中添加一個新的菜單
using Abp.Application.Navigation; using Abp.Localization; namespace Acme.SimpleTaskSystem.Web.Startup { /// <summary> /// This class defines menus for the application. /// </summary> public class SimpleTaskSystemNavigationProvider : 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, SimpleTaskSystemConsts.LocalizationSourceName); } } }
如上所示,Startup模板附帶兩個頁面:Home和About,我們可以修改他們,也可以自己創建新的頁面,在這裡我選擇新創建頁面。
創建TaskController 和 ViewModel
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; namespace Acme.SimpleTaskSystem.Web.Controllers { public class TasksController : SimpleTaskSystemControllerBase { private readonly ITaskAppService _taskAppService; public TasksController(ITaskAppService taskAppService) { _taskAppService = taskAppService; } public async Task<ActionResult> Index(GetAllTasksInput input) { var output = await _taskAppService.GetAll(input); var model = new IndexViewModel(output.Items); return View(model); } } }
- TasksController繼承SimpleTaskSystemControllerBase(繼承AbpController),SimpleTaskSystemControllerBase包含此應用程式中控制器的通用基本代碼。
- 為獲得任務列表我註入了ITaskAppService
- 我沒有直接將GetAll方法的結果傳遞給視圖,而是在.Web項目中創建了一個IndexViewModel類,如下所示:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace Acme.SimpleTaskSystem.Web { public class IndexViewModel { public IReadOnlyList<TaskListDto> Tasks { get; } public IndexViewModel(IReadOnlyList<TaskListDto> tasks) { Tasks = tasks; } public string GetTaskLabel(TaskListDto task) { switch(task.State) { case TaskState.Open: return "label-success"; default: return "label-default"; } } } }
這個簡單的視圖模型在其構造函數中獲取任務列表(由ITaskAppService提供)。它還具有GetTaskLabel方法,該方法將在視圖中用於為給定任務選擇Bootstrap標簽類。
創建任務列表頁
最後Index視圖頁如下所示:
@using Acme.SimpleTaskSystem.Web.Startup @model Acme.SimpleTaskSystem.Web.IndexViewModel @{ ViewBag.Title = L("TaskList"); ViewBag.ActiveMenu = PageNames.TaskList; //和SimpleTaskSystemNavigationProvider定義的菜單名字相匹配,以高亮顯示菜單項 } <h2>@L("TaskList")</h2> <div class="row"> <div> <ul class="list-group" id="TaskList"> @foreach(var task in Model.Tasks) { <li class="list-group-item"> <span class="pull-right label @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") </div> </li> } </ul> </div> </div>
我們只是簡單的使用給定的模型以及Bootstrap的 list group組件去呈現視圖。在這裡,我們使用了IndexViewModel.GetTaskLabel()方法來獲取任務的標簽類型。渲染的頁面是這樣的:
本地化
我們在視圖中使用ABP框架的L方法,用於定義本地化字元串,我們已經在.Core項目中的Localization/SourceFiles文件夾下將其定義在.json文件中。en本地化如下:
{ "culture": "en", "texts": { "HelloWorld": "Hello World!", "ChangeLanguage": "Change language", "HomePage": "HomePage", "About": "About", "Home_Description": "Welcome to SimpleTaskSystem...", "About_Description": "This is a simple startup template to use ASP.NET Core with ABP framework.", "TaskList": "TaskList", "Open": "open", "TaskState_Open": "Open", "TaskState_Completed": "Completed" } }
除了最後三行是新加的,其他全是啟動模板自帶的,我們可以根據情況進行刪除。
過濾任務
正如上面所示,TasksController實際上獲得一個GetAllTasksInput,可以用來過濾任務。我們可以在任務列表視圖中添加下拉菜單來過濾任務。這裡我們將下拉菜單添加到標題標簽中:
<h2>@L("TaskList") <span class="pull-right"> @Html.DropDownListFor( model => model.SelectedTaskState, Model.GetTasksStateSelectListItems(LocalizationManager), new { @class = "form-control", id = "TaskStateCombobox" }) </span> </h2>
然後我在 IndexViewModel中增加SelectedTaskState屬性和GetTasksStateSelectListItems方法:
public TaskState? SelectedTaskState { get; set; } public List<SelectListItem> GetTasksStateSelectListItems(ILocalizationManager localizationManager) { var list = new List<SelectListItem> { new SelectListItem { Text = localizationManager.GetString(SimpleTaskSystemConsts.LocalizationSourceName, "AllTasks"), Value = "", Selected = SelectedTaskState == null } }; list.AddRange(Enum.GetValues(typeof(TaskState)) .Cast<TaskState>() .Select(state => new SelectListItem { Text = localizationManager.GetString(SimpleTaskSystemConsts.LocalizationSourceName, $"TaskState_{state}"), Value = state.ToString(), Selected = state == SelectedTaskState }) ); return list; }
在控制器中設置SelectedTaskState:
public async Task<ActionResult> Index(GetAllTasksInput input) { var output = await _taskAppService.GetAll(input); var model = new IndexViewModel(output.Items) { SelectedTaskState = input.State }; return View(model); }
現在,我們可以運行應用程式查看視圖右上角的combobox:
現在這個combobox 只是顯示出來了,還不能用,我們現在寫一個javascript代碼當combobox值改變時重新請求和刷新任務列表。
我們在.Web項目中創建wwwroot\js\views\tasks\index.js文件:
(function ($) { $(function () { var _$taskStateCombobox = $("#TaskStateCombobox"); _$taskStateCombobox.change(function () { location.href = '/Tasks?state' + _$taskStateCombobox.val(); }); }); })(jQuery)
在視圖中引用index.js之前,我使用了VS擴展Bundler & Minifier(這是在ASP.Net Core項目中縮小文件的預設方式,在vs->工具->擴展和更新->下載)來縮小腳本:
這將在.Web項目的bundleconfig.json的文件中自動添加如下代碼:
{ "outputFileName": "wwwroot/js/views/tasks/index.min.js", "inputFiles": [ "wwwroot/js/views/tasks/index.js" ] }
並創建一個縮小的index.min.js文件
每當index.js改變時,index.min.js也會自動改變,現在我們將js文件加到對應的視圖中:
@section scripts { <environment names="Development"> <script src="~/js/views/tasks/index.js"></script> </environment> <environment names="Staging,Production"> <script src="~/js/views/tasks/index.min.js"></script> </environment> }
有了上面的代碼,我們可以在開發環境中使用index.js文件,在生產環境使用index.min.js文件,這是ASP.NET Core MVC項目中常用的方法。
自動化測試任務列表頁面
我們可以創建繼承測試,而且這已經被集成到 ASP.NET Core MVC 基礎框架中。如果對自動化測試不感興趣的小伙伴可以跳過這部分哦。
ABP框架中的 .Web.Tests項目是用來做測試的,我創建一個簡單的測試去請求TaskController.Index,然後看其如何響應:
public class TasksController_Tests: SimpleTaskSystemWebTestBase { [Fact] public async System.Threading.Tasks.Task Should_Get_Tasks_By_State() { //ACT var response = await GetResponseAsStringAsync( GetUrl<TasksController>(nameof(TasksController.Index), new { state = TaskState.Open } ) ); //assert response.ShouldNotBeNullOrWhiteSpace(); } }
GetResponseAsStringAsync和GetUrl方法是ABP框架中AbpAspNetCoreIntegratedTestBase類提供的輔助方法。我們可以直接使用Client (HttpClient的一個實例)屬性來發出請求,但是使用這些輔助類會更容易一些。
調試測試,可以看到響應HTML:
這說明index頁面響應無異常,但是我們可能還想知道返回的HTML是不是我們所想要的,有一些庫可以用來解析HTML。AngleSharp就是其中之一,它預裝在ABP啟動模板中的.Web.Tests項目中。所以我用它來檢查創建的HTML代碼:
//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().Parse(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,但是在大多數情況下,檢查基本標簽就足夠了。
後面我會更新翻譯第二部分。。。。。。