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






4.93/5 (55投票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 应用程序
在本文中,我将展示如何使用以下工具创建一个简单的跨平台**分层** Web 应用程序
- .Net Core 作为基础跨平台应用程序开发框架。
- ASP.NET Boilerplate (ABP) 作为启动模板和应用程序框架。
- ASP.NET Core 作为 Web 框架。
- Entity Framework Core 作为 ORM 框架。
- Twitter Bootstrap 作为 HTML&CSS 框架。
- jQuery 作为客户端 AJAX/DOM 库。
- xUnit 和 Shouldly 用于服务器端单元/集成测试。
我还将使用 Log4Net 和 AutoMapper,它们默认包含在 ABP 的 启动模板 中。我们将使用以下技术
这里要开发的**项目是一个简单的任务管理应用程序**,可以给人员分配任务。我不会一层一层地开发应用程序,而是采用垂直方向,随着应用程序的增长而改变不同的层次。随着应用程序的增长,我将根据需要引入 ABP 和其他框架的一些功能。
先决条件
要运行/开发示例应用程序,您的机器上应安装以下工具
- Visual Studio 2017
- SQL Server (您可以将连接字符串更改为 localdb)
- Visual Studio 扩展
创建应用程序
我使用 ABP 的启动模板(http://www.aspnetboilerplate.com/Templates)创建了一个名为“**Acme.SimpleTaskApp**”的新 Web 应用程序。创建模板时,公司名称(此处为“Acme”)是可选的。我还选择了 **多页面 Web 应用程序**,因为我不想在本文中使用 SPA,并且**禁用了身份验证**,因为我想要最基本的启动模板
它会创建一个分层解决方案,如下所示:
它包含 6 个项目,以我输入的项目名称开头
- **.Core** 项目用于领域/业务层(实体、领域服务...)
- **.Application** 项目用于应用程序层(DTO、应用程序服务...)
- **.EntityFramework** 项目用于 EF Core 集成(将 EF Core 从其他层抽象出来)。
- **.Web** 项目用于 ASP.NET MVC 层。
- **.Tests** 项目用于单元和集成测试(直到应用程序层,不包括 Web 层)
- **.Web.Tests** 项目用于 ASP.NET Core 集成测试(包括 Web 层的完整集成测试)。
运行应用程序时,您可以看到模板的用户界面
它包含一个顶部菜单、空的“主页”和“关于”页面以及一个语言切换下拉菜单。
开发应用程序
创建任务实体
我希望从一个简单的 **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 项目)。
此命令会在 .EntityFrameworkCore 项目中创建一个 **Migrations** 文件夹,其中包含一个迁移类和数据库模型的快照。
**自动生成的**“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** 命令
此命令在本地 SQL Server 中创建了一个名为 **SimpleTaskAppDb** 的数据库,并执行了迁移(目前只有一个“Initial”迁移)。
现在,我有一个 Task 实体以及数据库中的对应表。我在表中输入了几个示例任务。
请注意,数据库 **连接字符串** 在 **.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)并运行单元测试。
所有测试都成功了。最后一个是启动模板中预先构建的测试,我们暂时可以忽略它。
请注意:ABP 启动模板默认安装了 xUnit 和 Shouldly。因此,我们使用它们来编写我们的测试。
任务列表视图
现在,我知道 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() 方法来获取任务的标签类型。渲染后的页面将如下所示:
本地化
我们在视图中使用了视图来自 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);
}
现在,我们可以运行应用程序,在视图的右上角看到组合框。
我添加了组合框,但它还不能工作。我将编写一个简单的 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 项目中压缩文件的默认方法)来压缩脚本。
这会将以下行添加到 .Web 项目的 **bundleconfig.json** 文件中:
{
"outputFileName": "wwwroot/js/views/tasks/index.min.js",
"inputFiles": [
"wwwroot/js/views/tasks/index.js"
]
}
并创建一个脚本的压缩版本。
每当我更改 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。
这表明 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:首次发布。