65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (55投票s)

2016 年 8 月 1 日

CPOL

11分钟阅读

viewsIcon

144737

downloadIcon

1402

一个循序渐进的指南,介绍如何使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 框架以及自动化测试来创建 Web 应用程序。

引言

这是“使用 ASP.NET Core、Entity Framework Core 和 ASP.NET Boilerplate 创建多层 Web 应用程序”系列文章的**第一部分**。查看其他部分

在本文中,我将展示如何使用以下工具创建一个简单的跨平台**分层** Web 应用程序

我还将使用 Log4NetAutoMapper,它们默认包含在 ABP 的 启动模板 中。我们将使用以下技术

这里要开发的**项目是一个简单的任务管理应用程序**,可以给人员分配任务。我不会一层一层地开发应用程序,而是采用垂直方向,随着应用程序的增长而改变不同的层次。随着应用程序的增长,我将根据需要引入 ABP 和其他框架的一些功能。

先决条件

要运行/开发示例应用程序,您的机器上应安装以下工具

创建应用程序

我使用 ABP 的启动模板(http://www.aspnetboilerplate.com/Templates)创建了一个名为“**Acme.SimpleTaskApp**”的新 Web 应用程序。创建模板时,公司名称(此处为“Acme”)是可选的。我还选择了 **多页面 Web 应用程序**,因为我不想在本文中使用 SPA,并且**禁用了身份验证**,因为我想要最基本的启动模板

Template creation aspnetboilerplate

它会创建一个分层解决方案,如下所示: 

Startup template projects

它包含 6 个项目,以我输入的项目名称开头

  • **.Core** 项目用于领域/业务层(实体、领域服务...)
  • **.Application** 项目用于应用程序层(DTO、应用程序服务...)
  • **.EntityFramework** 项目用于 EF Core 集成(将 EF Core 从其他层抽象出来)。
  • **.Web** 项目用于 ASP.NET MVC 层。
  • **.Tests** 项目用于单元和集成测试(直到应用程序层,不包括 Web 层)
  • **.Web.Tests** 项目用于 ASP.NET Core 集成测试(包括 Web 层的完整集成测试)。

运行应用程序时,您可以看到模板的用户界面

Template Home Page

它包含一个顶部菜单、空的“主页”和“关于”页面以及一个语言切换下拉菜单。

开发应用程序

创建任务实体

我希望从一个简单的 **Task** 实体开始。由于实体是**领域层**的一部分,我将其添加到 **.Core** 项目中

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Abp.Domain.Entities;
using Abp.Domain.Entities.Auditing;
using Abp.Timing;

namespace Acme.SimpleTaskApp.Tasks
{
    [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
    }
}
  • 我从 ABP 的基类 ** Entity** 派生,该类默认包含 **Id** 属性(类型为 **int**)。我们可以使用泛型版本 **Entity<TPrimaryKey>** 来选择不同的主键类型。
  • **IHasCreationTime** 是一个简单的接口,仅定义 **CreationTime** 属性(使用标准名称表示创建时间是一个好习惯)。
  • **Task** 实体定义了一个必需的 **Title** 和一个可选的 **Description**。
  • **TaskState** 是一个简单的枚举,用于定义任务的状态。
  • **Clock.Now** 默认返回 DateTime.Now。但它提供了一个抽象,这样我们就可以在将来轻松地切换到 DateTime.UtcNow。在与 ABP 框架一起工作时,请始终使用 Clock.Now 而不是 DateTime.Now。
  • 我希望将 Task 实体存储在数据库的 **AppTasks** 表中。

将任务添加到 DbContext

**.EntityFrameworkCore** 项目包含一个预定义的 **DbContext**。我应该在 DbContext 中为 Task 实体添加一个 **DbSet**

public class SimpleTaskAppDbContext : AbpDbContext
{
    public DbSet<Task> Tasks { get; set; }

    public SimpleTaskAppDbContext(DbContextOptions<SimpleTaskAppDbContext> options) 
        : base(options)
    {

    }
}

现在,EF Core 知道我们有一个 Task 实体了。

创建第一个数据库迁移 

我们将创建一个初始数据库迁移来创建数据库和 AppTasks 表。我从 **Visual Studio** 打开 **Package Manager Console**,然后运行 **Add-Migration** 命令(默认项目必须是 .EntityFrameworkCore 项目)。

Entity Framework Core Add Migration

此命令会在 .EntityFrameworkCore 项目中创建一个 **Migrations** 文件夹,其中包含一个迁移类和数据库模型的快照。

EF Core initial migration

**自动生成的**“Initial”迁移类如下所示:

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),
                CreationTime = table.Column<DateTime>(nullable: false),
                Description = table.Column<string>(maxLength: 65536, nullable: true),
                State = table.Column<byte>(nullable: false),
                Title = table.Column<string>(maxLength: 256, nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_AppTasks", x => x.Id);
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(
            name: "AppTasks");
    }
}

当我们将迁移执行到数据库时,此代码用于创建 AppTasks 表(有关迁移的更多信息,请参阅 Entity Framework 文档)。

创建数据库

为了创建数据库,我从 Package Manager Console 运行 **Update-Database** 命令

EF Update-Database command

此命令在本地 SQL Server 中创建了一个名为 **SimpleTaskAppDb** 的数据库,并执行了迁移(目前只有一个“Initial”迁移)。

Created Database

现在,我有一个 Task 实体以及数据库中的对应表。我在表中输入了几个示例任务。

AppTasks table

请注意,数据库 **连接字符串** 在 **.Web** 应用程序的 **appsettings.json** 文件中定义。

任务应用程序服务

应用程序服务用于将领域逻辑暴露给表示层。应用程序服务通过一个(如果需要)**数据传输对象**(DTO)作为参数从表示层调用,使用领域对象执行一些特定的业务逻辑,然后返回一个 DTO 给表示层(如果需要)。

我在 **.Application** 项目中创建了第一个应用程序服务 **TaskAppService**,用于执行与任务相关的应用程序逻辑。首先,我想为应用程序服务定义一个接口

public interface ITaskAppService : IApplicationService
{
    Task<ListResultDto<TaskListDto>> GetAll(GetAllTasksInput input);
}

定义接口不是必需的,但建议这样做。按照惯例,所有应用程序服务**都应**实现 ABP 的 **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 对象中,而不是直接将 **state** 定义为方法参数。这样,我就可以在以后向此 DTO 添加其他参数,而不会破坏现有的客户端(我们可以直接向方法添加 state 参数)。
  • **TaskListDto** 用于返回任务数据。它从 **EntityDto** 派生,该类仅定义了一个 **Id** 属性(我们可以将 Id 添加到我们的 Dto 中,而不必派生自 EntityDto)。我们定义了 [**AutoMapFrom**] 属性来创建从 Task 实体到 TaskListDto 的 AutoMapper 映射。此属性定义在 Abp.AutoMapper nuget 包中。
  • 最后,**ListResultDto** 是一个简单的类,包含一个项目列表(我们可以直接返回 List<TaskListDto>)。

现在,我们可以实现 **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 Acme.SimpleTaskApp.Tasks.Dtos;
using Microsoft.EntityFrameworkCore;

namespace Acme.SimpleTaskApp.Tasks
{
    public class TaskAppService : SimpleTaskAppAppServiceBase, 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** 派生自启动模板中包含的 **SimpleTaskAppAppServiceBase**(该类派生自 ABP 的 ApplicationService 类)。这不是必需的,应用程序服务可以是普通的类。但是 **ApplicationService** 基类包含一些预注入的服务(如此处使用的 ObjectMapper)。
  • 我使用了 依赖注入 来获取一个 仓库
  • **仓库**用于抽象实体的数据库操作。ABP 为每个实体创建一个预定义的仓库(如这里的 IRepository<Task>),用于执行常见任务。这里使用的 IRepository.GetAll() 返回一个 **IQueryable** 来查询实体。
  • **WhereIf** 是 ABP 的一个扩展方法,用于简化 IQueryable.Where 方法的条件使用。
  • **ObjectMapper**(来自 ApplicationService 基类,默认通过 AutoMapper 实现)用于将 Task 对象列表映射到 TaskListDtos 对象列表。

测试 TaskAppService

在继续创建用户界面之前,我想**测试** TaskAppService。如果您对自动化测试不感兴趣,可以跳过此部分。

启动模板包含 **.Tests** 项目来测试我们的代码。它使用 EF Core 的 **InMemory** 数据库提供程序而不是 SQL Server。因此,我们的单元测试可以在没有真实数据库的情况下运行。它为**每个测试创建一个独立的数据库**。因此,测试是**相互隔离**的。我们可以在运行测试之前使用 **TestDataBuilder** 类向 **InMemory** 数据库添加一些初始测试数据。我已修改 TestDataBuilder,如下所示:

public class TestDataBuilder
{
    private readonly SimpleTaskAppDbContext _context;

    public TestDataBuilder(SimpleTaskAppDbContext 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 方法。

public class TaskAppService_Tests : SimpleTaskAppTestBase
{
    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);
    }
}

我创建了两个不同的测试来测试 GetAll 方法,如下所示。现在,我可以打开 Test Explorer(从 VS 的主菜单中选择 Test\Windows\Test Explorer)并运行单元测试。

Test explorer

所有测试都成功了。最后一个是启动模板中预先构建的测试,我们暂时可以忽略它。

请注意:ABP 启动模板默认安装了 xUnitShouldly。因此,我们使用它们来编写我们的测试。

任务列表视图

现在,我知道 TaskAppService 工作正常。我可以开始创建一个页面来列出所有任务了。

添加新的菜单项

 首先,我向顶部菜单添加了一个新项。

public class SimpleTaskAppNavigationProvider : NavigationProvider
{
    public override void SetNavigation(INavigationProviderContext context)
    {
        context.Manager.MainMenu
            .AddItem(
                new MenuItemDefinition(
                    "Home",
                    L("HomePage"),
                    url: "",
                    icon: "fa fa-home"
                    )
            ).AddItem(
                new MenuItemDefinition(
                    "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, SimpleTaskAppConsts.LocalizationSourceName);
    }
}

启动模板带有两个页面:主页和关于,如上所示。我们可以更改它们或创建新页面。我暂时保留它们,然后创建了一个新的菜单项。

创建 TaskController 和 ViewModel

我在 **.Web** 项目中创建一个新的控制器类 **TasksController**,如下所示:

public class TasksController : SimpleTaskAppControllerBase
{
    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);
    }
}
  • 我从 **SimpleTaskAppControllerBase**(它派生自 AbpController)派生,该类包含此应用程序中控制器的常用基类代码。
  • 我注入了 **ITaskAppService** 以获取任务列表。
  • 我没有直接将 GetAll 方法的结果传递给视图,而是在 .Web 项目中创建了一个 IndexViewModel 类,如下所示:
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 视图如下所示:

@model Acme.SimpleTaskApp.Web.Models.Tasks.IndexViewModel

@{
    ViewBag.Title = L("TaskList");
    ViewBag.ActiveMenu = "TaskList"; //Matches with the menu name in SimpleTaskAppNavigationProvider to highlight the menu item
}

<h2>@L("TaskList")</h2>

<div class="row">
    <div>
        <ul class="list-group">
            @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 的 列表组 组件渲染视图。这里,我们使用了 IndexViewModel.GetTaskLabel() 方法来获取任务的标签类型。渲染后的页面将如下所示:

Task list

本地化

我们在视图中使用了视图来自 ABP 框架的 **L** 方法。它用于本地化字符串。我们在 **.Core** 项目的 **Localization/Source** 文件夹中将本地化字符串定义为 **.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"
  }
}

大多数文本都来自启动模板,可以删除。我只添加了**最后 3 行**并在上面的视图中使用。虽然使用 ABP 的本地化非常简单,但您可以查看 本地化文档以获取有关本地化系统的更多信息。

筛选任务

如上所示,TaskController 实际上接收一个 **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 class IndexViewModel
{
    //...

    public TaskState? SelectedTaskState { get; set; }

    public List<SelectListItem> GetTasksStateSelectListItems(ILocalizationManager localizationManager)
    {
        var list = new List<SelectListItem>
        {
            new SelectListItem
            {
                Text = localizationManager.GetString(SimpleTaskAppConsts.LocalizationSourceName, "AllTasks"),
                Value = "",
                Selected = SelectedTaskState == null
            }
        };

        list.AddRange(Enum.GetValues(typeof(TaskState))
                .Cast<TaskState>()
                .Select(state =>
                    new SelectListItem
                    {
                        Text = localizationManager.GetString(SimpleTaskAppConsts.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);
}

现在,我们可以运行应用程序,在视图的右上角看到组合框。

Task list

我添加了组合框,但它还不能工作。我将编写一个简单的 JavaScript 代码来在组合框值更改时重新请求/刷新任务列表页面。所以,我在 **.Web** 项目中创建了 wwwroot\js\views\tasks\index.js 文件。

(function ($) {
    $(function () {

        var _$taskStateCombobox = $('#TaskStateCombobox');

        _$taskStateCombobox.change(function() {
            location.href = '/Tasks?state=' + _$taskStateCombobox.val();
        });

    });
})(jQuery);

在将此 JavaScript 文件包含到我的视图之前,我使用了 ** Bundler & Minifier** VS 扩展(这是 ASP.NET Core 项目中压缩文件的默认方法)来压缩脚本。

Minify js

这会将以下行添加到 .Web 项目的 **bundleconfig.json** 文件中:

{
  "outputFileName": "wwwroot/js/views/tasks/index.min.js",
  "inputFiles": [
    "wwwroot/js/views/tasks/index.js"
  ]
}

并创建一个脚本的压缩版本。

Minified js file

每当我更改 index.js 时,index.min.js 都会自动重新生成。现在,我可以将 JavaScript 文件包含到我的页面中。

@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 : SimpleTaskAppWebTestBase
{
    [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 的实例)属性来发起请求。但使用这些快捷方式方法会更方便。有关更多信息,请参阅 ASP.NET Core 的 集成测试文档

当我调试测试时,我可以看到响应的 HTML。

Web test

这表明 Index 页面在没有发生任何异常的情况下返回了响应。但是……我们可能希望做得更多,检查返回的 HTML 是否符合我们的预期。有一些库可以用来解析 HTML。 AngleSharp 就是其中之一,它已预装在 ABP 启动模板的 .Web.Tests 项目中。因此,我使用它来检查生成的 HTML 代码。

public class TasksController_Tests : SimpleTaskAppWebTestBase
{
    [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();

        //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。但在大多数情况下,检查基本标签就足够了。

第二部分

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

源代码

您可以在此处获取最新的源代码: 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-08:添加了第二篇文章的链接。
  • 2016-08-01:首次发布。
© . All rights reserved.