Abp vNext 学习(7)

Abp vNext 学习第七弹 - 作者

参考上一篇 :Abp vNext 学习(6).

领域层

简介

在前面的章节中, 我们使用 ABP 框架轻松地构建了一些服务;

  • 使用 CrudAppService 基类, 而不是为标准的增删改查操作手工开发应用服务.
  • 使用 generic repositories 自动完成数据层功能.

对于 “作者” 部分;

  • 我们将要展示在需要的情况下, 如何 手工做一些事情.
  • 我们将要实现一些 领域驱动设计 (DDD) 最佳实践.

开发将会逐层完成, 一次聚焦一层. 在真实项目中, 你会逐个功能(垂直)开发, 如同前面的教程. 通过这种方式, 你可以体验这两种方式

作者实体

.Domain 项目中创建 Authors 文件夹 (命名空间), 在其中加入 Author 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using System;
using JetBrains.Annotations;
using Volo.Abp;
using Volo.Abp.Domain.Entities.Auditing;

namespace Acme.BookStore.Authors
{
public class Author : FullAuditedAggregateRoot<Guid>
{
public string Name { get; private set; }
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }

private Author()
{
/* This constructor is for deserialization / ORM purpose */
}

internal Author(
Guid id,
[NotNull] string name,
DateTime birthDate,
[CanBeNull] string shortBio = null)
: base(id)
{
SetName(name);
BirthDate = birthDate;
ShortBio = shortBio;
}

internal Author ChangeName([NotNull] string name)
{
SetName(name);
return this;
}

private void SetName([NotNull] string name)
{
Name = Check.NotNullOrWhiteSpace(
name,
nameof(name),
maxLength: AuthorConsts.MaxNameLength
);
}
}
}
  • FullAuditedAggregateRoot<Guid> 继承使得实体支持软删除 (指实体被删除时, 它并没有从数据库中被删除, 而只是被标记删除), 实体也具有了 审计 属性.
  • Name 属性的 private set 限制从类的外部设置这个属性. 有两种方法设置名字 (两种都进行了验证):
    • 当新建一个作者时, 通过构造函数.
    • 使用 ChangeName 方法更新名字.
  • 构造函数 和 ChangeName 方法的访问级别是 internal, 强制这些方法只能在领域层由 AuthorManager 使用. 稍后将对此进行解释.
  • Check 类是一个ABP框架工具类, 用于检查方法参数 (如果参数非法会抛出 ArgumentException).

AuthorConsts 是一个简单的类, 它位于 .Domain.Shared 项目的 Authors 命名空间 (文件夹)中:

1
2
3
4
5
6
7
namespace Acme.BookStore.Authors
{
public static class AuthorConsts
{
public const int MaxNameLength = 64;
}
}

.Domain.Shared 项目中创建这个类, 因为数据传输类 (DTOs) 稍后会再一次用到它.

AuthorManager: 领域服务

Author 构造函数和 ChangeName 方法的访问级别是 internal, 所以它们只能在领域层使用. 在 .BookStore.Domain 项目中的 Authors 文件夹 (命名空间)创建 AuthorManager 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Volo.Abp;
using Volo.Abp.Domain.Services;

namespace Acme.BookStore.Authors
{
public class AuthorManager : DomainService
{
private readonly IAuthorRepository _authorRepository;

public AuthorManager(IAuthorRepository authorRepository)
{
_authorRepository = authorRepository;
}

public async Task<Author> CreateAsync(
[NotNull] string name,
DateTime birthDate,
[CanBeNull] string shortBio = null)
{
Check.NotNullOrWhiteSpace(name, nameof(name));

var existingAuthor = await _authorRepository.FindByNameAsync(name);
if (existingAuthor != null)
{
throw new AuthorAlreadyExistsException(name);
}

return new Author(
GuidGenerator.Create(),
name,
birthDate,
shortBio
);
}

public async Task ChangeNameAsync(
[NotNull] Author author,
[NotNull] string newName)
{
Check.NotNull(author, nameof(author));
Check.NotNullOrWhiteSpace(newName, nameof(newName));

var existingAuthor = await _authorRepository.FindByNameAsync(newName);
if (existingAuthor != null && existingAuthor.Id != author.Id)
{
throw new AuthorAlreadyExistsException(newName);
}

author.ChangeName(newName);
}
}
}
  • AuthorManager 强制使用一种可控的方式创建作者和修改作者的名字. 应用层 (后面会介绍) 将会使用这些方法.

DDD 提示: 如非必须并且用于执行核心业务规则, 不要引入领域服务方法. 对于这个场景, 我们使用这个服务保证名字的唯一性.

两个方法都检查是否存在同名用户, 如果存在, 抛出业务异常 AuthorAlreadyExistsException, 这个异常定义在 .Domain 项目 (Authors 文件夹中):

1
2
3
4
5
6
7
8
9
10
11
12
13
using Volo.Abp;

namespace Acme.BookStore.Authors
{
public class AuthorAlreadyExistsException : BusinessException
{
public AuthorAlreadyExistsException(string name)
: base(BookStoreDomainErrorCodes.AuthorAlreadyExists)
{
WithData("name", name);
}
}
}

BusinessException 是一个特殊的异常类型. 在需要时抛出领域相关异常是一个好的实践. ABP框架会自动处理它, 并且它也容易本地化. WithData(...) 方法提供额外的数据给异常对象, 这些数据将会在本地化中或出于其它一些目的被使用.

打开 .Domain.Shared 项目中的 BookStoreDomainErrorCodes 并修改为:

1
2
3
4
5
6
7
namespace Acme.BookStore
{
public static class BookStoreDomainErrorCodes
{
public const string AuthorAlreadyExists = "BookStore:00001";
}
}

这里定义了一个字符串, 表示应用程序抛出的错误码, 这个错误码可以被客户端应用程序处理. 为了用户, 你可能希望本地化它. 打开 .Domain.Shared 项目中的 Localization/BookStore/en.json , 加入以下项:

1
"BookStore:00001": "There is already an author with the same name: {name}"

当 AuthorAlreadyExistsException 被抛出, 终端用户将会在UI上看到组织好的错误消息.

IAuthorRepository

AuthorManager 注入了 IAuthorRepository, 所以我们需要定义它. 在 .Domain 项目的 Authors 文件夹 (命名空间) 中创建这个新接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore.Authors
{
public interface IAuthorRepository : IRepository<Author, Guid>
{
Task<Author> FindByNameAsync(string name);

Task<List<Author>> GetListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter = null
);
}
}
  • IAuthorRepository 扩展了标准 IRepository<Author, Guid> 接口, 所以所有的标准 repository 方法对于 IAuthorRepository 都是可用的.
  • FindByNameAsyncAuthorManager 中用来根据姓名查询用户.
  • GetListAsync 用于应用层以获得一个排序的, 经过过滤的作者列表, 显示在UI上.

数据库集成

DBContext

打开 Acme.BookStore.EntityFrameworkCore 项目中的 BookStoreDbContext 加入 DbSet 属性:

1
public DbSet<Author> Authors { get; set; }

定位到相同项目中的 BookStoreDbContext 类中的 OnModelCreating 方法, 加入以下代码到方法的结尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
builder.Entity<Author>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Authors",
BookStoreConsts.DbSchema);

b.ConfigureByConvention();

b.Property(x => x.Name)
.IsRequired()
.HasMaxLength(AuthorConsts.MaxNameLength);

b.HasIndex(x => x.Name);
});

这和前面的 Book 实体做的一样, 所以不再细说.

创建数据库迁移

配置启动解决方案为使用 Entity Framework Core Code First Migrations. 因为我们还没有修改数据库映射配置,所以需要创建一个新的迁移并对数据库应用变更.

打开命令行终端, 切换当前目录为 .EntityFrameworkCore 项目目录, 输入以下命令:

1
dotnet ef migrations add Added_Authors

你可以在同一个命令行终端中使用以下命令对数据库应用更改:

1
dotnet ef database update

实现 IAuthorRepository

.EntityFrameworkCore 项目 (Authors 文件夹)中创建一个新类 EfCoreAuthorRepository, 粘贴以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Acme.BookStore.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;

namespace Acme.BookStore.Authors
{
public class EfCoreAuthorRepository
: EfCoreRepository<BookStoreDbContext, Author, Guid>,
IAuthorRepository
{
public EfCoreAuthorRepository(
IDbContextProvider<BookStoreDbContext> dbContextProvider)
: base(dbContextProvider)
{
}

public async Task<Author> FindByNameAsync(string name)
{
var dbSet = await GetDbSetAsync();
return await dbSet.FirstOrDefaultAsync(author => author.Name == name);
}

public async Task<List<Author>> GetListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter = null)
{
var dbSet = await GetDbSetAsync();
return await dbSet
.WhereIf(
!filter.IsNullOrWhiteSpace(),
author => author.Name.Contains(filter)
)
.OrderBy(sorting)
.Skip(skipCount)
.Take(maxResultCount)
.ToListAsync();
}
}
}
  • 继承自 EfCoreRepository, 所以继承了标准repository的方法实现.
  • WhereIf 是ABP 框架的快捷扩展方法. 它仅当第一个条件满足时, 执行 Where 查询. (根据名字查询, 仅当 filter 不为空). 你可以不使用这个方法, 但这些快捷方法可以提高效率.
  • sorting 可以是一个字符串, 如 Name, Name ASCName DESC. 通过使用 System.Linq.Dynamic.Core NuGet 包来得到支持.

应用服务层

IAuthorAppService

我们首先创建 应用服务 接口和相关的 DTOs. 在 Acme.BookStore.Application.Contracts 项目的 Authors 命名空间 (文件夹) 创建一个新接口 IAuthorAppService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;

namespace Acme.BookStore.Authors
{
public interface IAuthorAppService : IApplicationService
{
Task<AuthorDto> GetAsync(Guid id);

Task<PagedResultDto<AuthorDto>> GetListAsync(GetAuthorListDto input);

Task<AuthorDto> CreateAsync(CreateAuthorDto input);

Task UpdateAsync(Guid id, UpdateAuthorDto input);

Task DeleteAsync(Guid id);
}
}
  • IApplicationService 是一个常规接口, 所有应用服务都继承自它, 所以 ABP 框架可以识别它们.
  • Author 实体中定义标准方法用于CRUD操作.
  • PagedResultDto 是一个ABP框架中预定义的 DTO 类. 它拥有一个 Items 集合 和一个 TotalCount 属性, 用于返回分页结果.
  • 优先从 CreateAsync 方法返回 AuthorDto (新创建的作者), 虽然在这个程序中没有这么做 - 这里只是展示一种不同用法.

这个类使用下面定义的DTOs (为你的项目创建它们).

AuthorDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;
using Volo.Abp.Application.Dtos;

namespace Acme.BookStore.Authors
{
public class AuthorDto : EntityDto<Guid>
{
public string Name { get; set; }

public DateTime BirthDate { get; set; }

public string ShortBio { get; set; }
}
}
  • EntityDto<T> 只有一个类型为指定泛型参数的 Id 属性. 你可以自己创建 Id 属性, 而不是继承自 EntityDto<T>.

GetAuthorListDto

1
2
3
4
5
6
7
8
9
using Volo.Abp.Application.Dtos;

namespace Acme.BookStore.Authors
{
public class GetAuthorListDto : PagedAndSortedResultRequestDto
{
public string? Filter { get; set; }
}
}
  • Filter 用于搜索作者. 它可以是 null (或空字符串) 以获得所有用户.
  • PagedAndSortedResultRequestDto 具有标准分页和排序属性: int MaxResultCount, int SkipCountstring Sorting.

ABP 框架拥有这些基本的DTO类以简化并标准化你的DTOs. 参阅 DTO 文档 获得所有DTO类的详细信息.

CreateAuthorDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.ComponentModel.DataAnnotations;

namespace Acme.BookStore.Authors
{
public class CreateAuthorDto
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }

[Required]
public DateTime BirthDate { get; set; }

public string ShortBio { get; set; }
}
}

数据标记特性可以用来验证DTO. 参阅 验证文档 获得详细信息.

UpdateAuthorDto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.ComponentModel.DataAnnotations;

namespace Acme.BookStore.Authors
{
public class UpdateAuthorDto
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }

[Required]
public DateTime BirthDate { get; set; }

public string ShortBio { get; set; }
}
}

我们可以在创建和更新操作间分享 (重用) 相同的DTO. 虽然可以这么做, 但我们推荐为这些操作创建不同的DTOs, 因为我们发现随着时间的推移, 它们通常会变得有差异. 所以, 与紧耦合相比, 代码重复也是合理的.

AuthorAppService

是时候实现 IAuthorAppService 接口了. 在 Acme.BookStore.Application 项目的 Authors 命名空间 (文件夹) 中创建一个新类 AuthorAppService :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore.Authors
{
[Authorize(BookStorePermissions.Authors.Default)]
public class AuthorAppService : BookStoreAppService, IAuthorAppService
{
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;

public AuthorAppService(
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_authorRepository = authorRepository;
_authorManager = authorManager;
}

//...SERVICE METHODS WILL COME HERE...
}
}
  • [Authorize(BookStorePermissions.Authors.Default)] 是一个检查权限(策略)的声明式方法, 用来给当前用户授权. 参阅 授权文档 获得详细信息. BookStorePermissions 类在后文会被更新, 现在不需要担心编译错误.
  • BookStoreAppService 派生, 这个类是一个简单基类, 可以做为模板. 它继承自标准的 ApplicationService 类.
  • 实现上面定义的 IAuthorAppService .
  • 注入 IAuthorRepositoryAuthorManager 以使用服务方法.

现在, 我们逐个介绍服务方法. 复制这些方法到 AuthorAppService 类.

GetAsync

1
2
3
4
5
public async Task<AuthorDto> GetAsync(Guid id)
{
var author = await _authorRepository.GetAsync(id);
return ObjectMapper.Map<Author, AuthorDto>(author);
}

这个方法根据 Id 获得 Author 实体, 使用 对象到对象映射 转换为 AuthorDto. 这需要配置AutoMapper, 后面会介绍.

GetListAsync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public async Task<PagedResultDto<AuthorDto>> GetListAsync(GetAuthorListDto input)
{
if (input.Sorting.IsNullOrWhiteSpace())
{
input.Sorting = nameof(Author.Name);
}

var authors = await _authorRepository.GetListAsync(
input.SkipCount,
input.MaxResultCount,
input.Sorting,
input.Filter
);

var totalCount = input.Filter == null
? await _authorRepository.CountAsync()
: await _authorRepository.CountAsync(
author => author.Name.Contains(input.Filter));

return new PagedResultDto<AuthorDto>(
totalCount,
ObjectMapper.Map<List<Author>, List<AuthorDto>>(authors)
);
}
  • 为处理客户端没有设置的情况, 在方法的开头设置默认排序是 “根据作者名”.
  • 使用 IAuthorRepository.GetListAsync 从数据库中获得分页的, 排序的和过滤的作者列表. 我们已经在教程的前一章中实现了它. 再一次强调, 实际上不需要创建这个方法, 因为我们可以从数据库中直接查询, 这里只是演示如何创建自定义repository方法.
  • 直接查询 AuthorRepository , 得到作者的数量. 如果客户端发送了过滤条件, 会得到过滤后的作者数量.
  • 最后, 通过映射 Author 列表到 AuthorDto 列表, 返回分页后的结果.

CreateAsync

1
2
3
4
5
6
7
8
9
10
11
12
13
[Authorize(BookStorePermissions.Authors.Create)]
public async Task<AuthorDto> CreateAsync(CreateAuthorDto input)
{
var author = await _authorManager.CreateAsync(
input.Name,
input.BirthDate,
input.ShortBio
);

await _authorRepository.InsertAsync(author);

return ObjectMapper.Map<Author, AuthorDto>(author);
}
  • CreateAsync 需要 BookStorePermissions.Authors.Create 权限 (另外包括 AuthorAppService 类声明的 BookStorePermissions.Authors.Default 权限).
  • 使用 AuthorManager (领域服务) 创建新作者.
  • 使用 IAuthorRepository.InsertAsync 插入新作者到数据库.
  • 使用 ObjectMapper 返回 AuthorDto , 代表新创建的作者.

DDD提示: 一些开发者可能会发现可以在 _authorManager.CreateAsync 插入新实体. 我们认为把它留给应用层是更好的设计, 因为应用层更了解应该何时插入实体到数据库(在插入实体前可能需要额外的工作. 如果在领域层插入, 可能需要额外的更新操作). 但是, 你拥有最终的决定权.

UpdateAsync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Authorize(BookStorePermissions.Authors.Edit)]
public async Task UpdateAsync(Guid id, UpdateAuthorDto input)
{
var author = await _authorRepository.GetAsync(id);

if (author.Name != input.Name)
{
await _authorManager.ChangeNameAsync(author, input.Name);
}

author.BirthDate = input.BirthDate;
author.ShortBio = input.ShortBio;

await _authorRepository.UpdateAsync(author);
}
  • UpdateAsync 需要额外的 BookStorePermissions.Authors.Edit 权限.
  • 使用 IAuthorRepository.GetAsync 从数据库中获得作者实体. 如果给定的id没有找到作者, GetAsync 抛出 EntityNotFoundException, 这在web应用程序中导致一个 404 HTTP 状态码. 在更新操作中先获取实体再更新它, 是一个好的实践.
  • 如果客户端请求, 使用 AuthorManager.ChangeNameAsync (领域服务方法) 修改作者姓名.
  • 因为没有任何业务逻辑, 直接更新 BirthDateShortBio, 它们可以接受任何值.
  • 最后, 调用 IAuthorRepository.UpdateAsync 更新实体到数据库.

EF Core 提示: Entity Framework Core 拥有 change tracking 系统并在unit of work 结束时 自动保存 任何修改到实体 (你可以简单地认为APB框架在方法结束时自动调用 SaveChanges). 所以, 即使你在方法结束时没有调用 _authorRepository.UpdateAsync(...) , 它依然可以工作. 如果你不考虑以后修改EF Core, 你可以移除这一行.

DeleteAsync

1
2
3
4
5
[Authorize(BookStorePermissions.Authors.Delete)]
public async Task DeleteAsync(Guid id)
{
await _authorRepository.DeleteAsync(id);
}
  • DeleteAsync 需要额外的 BookStorePermissions.Authors.Delete 权限.
  • 直接使用repository的 DeleteAsync 方法.

权限定义

你还不能编译代码, 因为它需要 BookStorePermissions 类定义中一些常数.

打开 Acme.BookStore.Application.Contracts 项目中的 BookStorePermissions 类 (在 Permissions 文件夹中), 修改为如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace Acme.BookStore.Permissions
{
public static class BookStorePermissions
{
public const string GroupName = "BookStore";

public static class Books
{
public const string Default = GroupName + ".Books";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
}

// *** ADDED a NEW NESTED CLASS ***
public static class Authors
{
public const string Default = GroupName + ".Authors";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
}
}
}

然后打开同一项目中的 BookStorePermissionDefinitionProvider, 在 Define 方法的结尾加入以下行:

1
2
3
4
5
6
7
8
9
10
11
var authorsPermission = bookStoreGroup.AddPermission(
BookStorePermissions.Authors.Default, L("Permission:Authors"));

authorsPermission.AddChild(
BookStorePermissions.Authors.Create, L("Permission:Authors.Create"));

authorsPermission.AddChild(
BookStorePermissions.Authors.Edit, L("Permission:Authors.Edit"));

authorsPermission.AddChild(
BookStorePermissions.Authors.Delete, L("Permission:Authors.Delete"));

最后, 在 Acme.BookStore.Domain.Shared 项目中的 Localization/BookStore/en.json 加入以下项, 用以本地化权限名称:

1
2
3
4
"Permission:Authors": "Author Management",
"Permission:Authors.Create": "Creating new authors",
"Permission:Authors.Edit": "Editing the authors",
"Permission:Authors.Delete": "Deleting the authors"

简体中文翻译请打开zh-Hans.json文件 ,并将”Texts”对象中对应的值替换为中文.

对象到对象映射

AuthorAppService 使用 ObjectMapperAuthor 对象 转换为 AuthorDto 对象. 所以, 我们需要在 AutoMapper 配置中定义映射.

打开 Acme.BookStore.Application 项目中的 BookStoreApplicationAutoMapperProfile 类, 加入以下行到构造函数:

1
CreateMap<Author, AuthorDto>();

数据种子

如同图书管理部分所做的, 在数据库中生成一些初始作者实体. 不仅当第一次运行应用程序时是有用的, 对自动化测试也是很有用的.

打开 Acme.BookStore.Domain 项目中的 BookStoreDataSeederContributor, 修改文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore
{
public class BookStoreDataSeederContributor
: IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;

public BookStoreDataSeederContributor(
IRepository<Book, Guid> bookRepository,
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_authorManager = authorManager;
}

public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() <= 0)
{
await _bookRepository.InsertAsync(
new Book
{
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);

await _bookRepository.InsertAsync(
new Book
{
Name = "The Hitchhiker's Guide to the Galaxy",
Type = BookType.ScienceFiction,
PublishDate = new DateTime(1995, 9, 27),
Price = 42.0f
},
autoSave: true
);
}

// ADDED SEED DATA FOR AUTHORS

if (await _authorRepository.GetCountAsync() <= 0)
{
await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"George Orwell",
new DateTime(1903, 06, 25),
"Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)."
)
);

await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'."
)
);
}
}
}
}

你现在可以运行 .DbMigrator 控制台应用程序, 迁移 数据库 schema 并生成 种子 初始数据.

测试作者应用服务

最后, 你可以为 IAuthorAppService 写一些测试. 在 Acme.BookStore.Application.Tests 项目的 Authors 命名空间(文件夹)中加入一个名为 AuthorAppService_Tests 新类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
using System;
using System.Threading.Tasks;
using Shouldly;
using Xunit;

namespace Acme.BookStore.Authors
{
public class AuthorAppService_Tests : BookStoreApplicationTestBase
{
private readonly IAuthorAppService _authorAppService;

public AuthorAppService_Tests()
{
_authorAppService = GetRequiredService<IAuthorAppService>();
}

[Fact]
public async Task Should_Get_All_Authors_Without_Any_Filter()
{
var result = await _authorAppService.GetListAsync(new GetAuthorListDto());

result.TotalCount.ShouldBeGreaterThanOrEqualTo(2);
result.Items.ShouldContain(author => author.Name == "George Orwell");
result.Items.ShouldContain(author => author.Name == "Douglas Adams");
}

[Fact]
public async Task Should_Get_Filtered_Authors()
{
var result = await _authorAppService.GetListAsync(
new GetAuthorListDto {Filter = "George"});

result.TotalCount.ShouldBeGreaterThanOrEqualTo(1);
result.Items.ShouldContain(author => author.Name == "George Orwell");
result.Items.ShouldNotContain(author => author.Name == "Douglas Adams");
}

[Fact]
public async Task Should_Create_A_New_Author()
{
var authorDto = await _authorAppService.CreateAsync(
new CreateAuthorDto
{
Name = "Edward Bellamy",
BirthDate = new DateTime(1850, 05, 22),
ShortBio = "Edward Bellamy was an American author..."
}
);

authorDto.Id.ShouldNotBe(Guid.Empty);
authorDto.Name.ShouldBe("Edward Bellamy");
}

[Fact]
public async Task Should_Not_Allow_To_Create_Duplicate_Author()
{
await Assert.ThrowsAsync<AuthorAlreadyExistsException>(async () =>
{
await _authorAppService.CreateAsync(
new CreateAuthorDto
{
Name = "Douglas Adams",
BirthDate = DateTime.Now,
ShortBio = "..."
}
);
});
}

//TODO: Test other methods...
}
}

完成应用服务方法的测试, 它们应该很容易理解.

UI:用户界面

作者列表页面

Acme.BookStore.Web 项目的 Pages/Authors 文件夹下创建一个新的razor页面, Index.cshtml, 修改文件内容如下.

Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Permissions
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<BookStoreResource> L
@inject IAuthorizationService AuthorizationService
@model IndexModel

@section scripts
{
<abp-script src="/Pages/Authors/Index.js"/>
}

<abp-card>
<abp-card-header>
<abp-row>
<abp-column size-md="_6">
<abp-card-title>@L["Authors"]</abp-card-title>
</abp-column>
<abp-column size-md="_6" class="text-right">
@if (await AuthorizationService
.IsGrantedAsync(BookStorePermissions.Authors.Create))
{
<abp-button id="NewAuthorButton"
text="@L["NewAuthor"].Value"
icon="plus"
button-type="Primary"/>
}
</abp-column>
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" id="AuthorsTable"></abp-table>
</abp-card-body>
</abp-card>

IndexModel.cshtml.cs

1
2
3
4
5
6
7
8
9
10
11
12
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace Acme.BookStore.Web.Pages.Authors
{
public class IndexModel : PageModel
{
public void OnGet()
{

}
}
}

Index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
$(function () {
var l = abp.localization.getResource('BookStore');
var createModal = new abp.ModalManager(abp.appPath + 'Authors/CreateModal');
var editModal = new abp.ModalManager(abp.appPath + 'Authors/EditModal');

var dataTable = $('#AuthorsTable').DataTable(
abp.libs.datatables.normalizeConfiguration({
serverSide: true,
paging: true,
order: [[1, "asc"]],
searching: false,
scrollX: true,
ajax: abp.libs.datatables.createAjax(acme.bookStore.authors.author.getList),
columnDefs: [
{
title: l('Actions'),
rowAction: {
items:
[
{
text: l('Edit'),
visible:
abp.auth.isGranted('BookStore.Authors.Edit'),
action: function (data) {
editModal.open({ id: data.record.id });
}
},
{
text: l('Delete'),
visible:
abp.auth.isGranted('BookStore.Authors.Delete'),
confirmMessage: function (data) {
return l(
'AuthorDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
acme.bookStore.authors.author
.delete(data.record.id)
.then(function() {
abp.notify.info(
l('SuccessfullyDeleted')
);
dataTable.ajax.reload();
});
}
}
]
}
},
{
title: l('Name'),
data: "name"
},
{
title: l('BirthDate'),
data: "birthDate",
dataFormat: "datetime"
}
]
})
);

createModal.onResult(function () {
dataTable.ajax.reload();
});

editModal.onResult(function () {
dataTable.ajax.reload();
});

$('#NewAuthorButton').click(function (e) {
e.preventDefault();
createModal.open();
});
});

简单来说, 这个JavaScript页面:

  • 创建了一个具有 操作, 姓名生日 列的数据表格.
    • Actions 列用来添加 编辑删除 操作.
  • 使用 abp.ModalManager 打开 新建编辑 模态表单.

这块代码与以前创建的图书页面非常相似, 所以我们不再赘述.

本地化

这个页面使用了一些需要声明的本地化键. 打开 Acme.BookStore.Domain.Shared 项目中 Localization/BookStore 文件夹下的 en.json 文件, 加入以下条目:

1
2
3
4
5
"Menu:Authors": "Authors",
"Authors": "Authors",
"AuthorDeletionConfirmationMessage": "Are you sure to delete the author '{0}'?",
"BirthDate": "Birth date",
"NewAuthor": "New author"

简体中文翻译请打开zh-Hans.json文件 ,并将”Texts”对象中对应的值替换为中文.

注意我们加入了额外的键. 它们会在下面的小节中被使用.

加入主菜单

打开 Acme.BookStore.Web 项目的 Menus 文件夹中的 BookStoreMenuContributor.cs , 在 ConfigureMainMenuAsync 方法的结尾加入以下代码:

1
2
3
4
5
6
7
8
if (await context.IsGrantedAsync(BookStorePermissions.Authors.Default))
{
bookStoreMenu.AddItem(new ApplicationMenuItem(
"BooksStore.Authors",
l["Menu:Authors"],
url: "/Authors"
));
}

运行应用程序

运行并登录应用程序. 因为你还没有权限, 所以不能看见菜单项. 转到 Identity/Roles 页面, 点击 操作 按钮并选择管理员角色权限操作。

如你所见, 管理员角色还没有作者管理权限. 单击复选框并保存, 赋予权限. 刷新页面后, 你会在主菜单中的图书商店下看到作者菜单项。

页面是完全可以工作的, 除了 新建作者操作/编辑, 因为它们还没有实现 .

提示: 如果你在定义一个新权限后运行 .DbMigrator 控制台程序, 它会自动将这些权限赋予管理员角色, 你不需要手工赋予权限.

新建模态窗口

Acme.BookStore.Web 项目的 Pages/Authors 文件夹下创建一个 razor 页面 CreateModal.cshtml, 修改它的内容如下:

CreateModal.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model CreateModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<form asp-page="/Authors/CreateModal">
<abp-modal>
<abp-modal-header title="@L["NewAuthor"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Author.Name" />
<abp-input asp-for="Author.BirthDate" />
<abp-input asp-for="Author.ShortBio" />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</form>

之前我们已经使用ABP框架的 动态表单开发了图书页面. 这里可以使用相同的方法, 但我们希望展示如何手工完成它. 实际上, 没有那么手工化, 因为在这个例子中我们使用了 abp-input 标签简化了表单元素的创建.

你当然可以使用标准Bootstrap HTML结构, 但是这需要写很多代码. abp-input 自动添加验证, 本地化和根据数据类型生成标准元素.

CreateModal.cshtml.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;

namespace Acme.BookStore.Web.Pages.Authors
{
public class CreateModalModel : BookStorePageModel
{
[BindProperty]
public CreateAuthorViewModel Author { get; set; }

private readonly IAuthorAppService _authorAppService;

public CreateModalModel(IAuthorAppService authorAppService)
{
_authorAppService = authorAppService;
}

public void OnGet()
{
Author = new CreateAuthorViewModel();
}

public async Task<IActionResult> OnPostAsync()
{
var dto = ObjectMapper.Map<CreateAuthorViewModel, CreateAuthorDto>(Author);
await _authorAppService.CreateAsync(dto);
return NoContent();
}

public class CreateAuthorViewModel
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }

[Required]
[DataType(DataType.Date)]
public DateTime BirthDate { get; set; }

[TextArea]
public string ShortBio { get; set; }
}
}
}

这个页面模型类注入和使用 IAuthorAppService 创建新作者. 它和图书创建模型类之间主要的区别是这个模型类为视图模型声明了一个新类 CreateAuthorViewModel, 而不是重用 CreateAuthorDto.

这么做的主要原因是展示在页面中如何使用不同的模型. 但还有一个好处: 我们为类成员添加了两个不存在于 CreateAuthorDto 中的特性:

  • BirthDate 添加 [DataType(DataType.Date)] 特性, 这会在UI为这个属性显示一个日期选择控件.
  • ShortBio 添加 [TextArea] 特性, 这会显示一个多行文本框, 而不是标准文本框.

通过这种方式, 可以根据UI需求定制视图模型类, 而无需修改DTO. 这么做的一个结果是: 使用 ObjectMapperCreateAuthorViewModel 映射到 CreateAuthorDto. 为了完成映射, 需要在 BookStoreWebAutoMapperProfile 构造函数中加入新的映射代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Acme.BookStore.Authors; // ADDED NAMESPACE IMPORT
using Acme.BookStore.Books;
using AutoMapper;

namespace Acme.BookStore.Web
{
public class BookStoreWebAutoMapperProfile : Profile
{
public BookStoreWebAutoMapperProfile()
{
CreateMap<BookDto, CreateUpdateBookDto>();

// ADD a NEW MAPPING
CreateMap<Pages.Authors.CreateModalModel.CreateAuthorViewModel,
CreateAuthorDto>();
}
}
}

当你重新运行应用程序后, 点击”新建作者” 按钮会打开一个新的模态窗口。

编辑模态窗口

Acme.BookStore.Web 项目的 Pages/Authors 文件夹下创建一个 razor 页面 EditModal.cshtml, 修改它的内容如下:

EditModal.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<form asp-page="/Authors/EditModal">
<abp-modal>
<abp-modal-header title="@L["Update"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Author.Id" />
<abp-input asp-for="Author.Name" />
<abp-input asp-for="Author.BirthDate" />
<abp-input asp-for="Author.ShortBio" />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</form>

EditModal.cshtml.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;

namespace Acme.BookStore.Web.Pages.Authors
{
public class EditModalModel : BookStorePageModel
{
[BindProperty]
public EditAuthorViewModel Author { get; set; }

private readonly IAuthorAppService _authorAppService;

public EditModalModel(IAuthorAppService authorAppService)
{
_authorAppService = authorAppService;
}

public async Task OnGetAsync(Guid id)
{
var authorDto = await _authorAppService.GetAsync(id);
Author = ObjectMapper.Map<AuthorDto, EditAuthorViewModel>(authorDto);
}

public async Task<IActionResult> OnPostAsync()
{
await _authorAppService.UpdateAsync(
Author.Id,
ObjectMapper.Map<EditAuthorViewModel, UpdateAuthorDto>(Author)
);

return NoContent();
}

public class EditAuthorViewModel
{
[HiddenInput]
public Guid Id { get; set; }

[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }

[Required]
[DataType(DataType.Date)]
public DateTime BirthDate { get; set; }

[TextArea]
public string ShortBio { get; set; }
}
}
}

这个类与 CreateModal.cshtml.cs 类似, 主要不同是:

  • 使用 IAuthorAppService.GetAsync(...) 方法从应用层获取正在编辑的作者.
  • EditAuthorViewModel 拥有一个额外的 Id 属性, 它被 [HiddenInput] 特性标记, 会为这个属性在页面上创建一个隐藏输入框.

这个类要求在 BookStoreWebAutoMapperProfile 类中添加两个对象映射声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using AutoMapper;

namespace Acme.BookStore.Web
{
public class BookStoreWebAutoMapperProfile : Profile
{
public BookStoreWebAutoMapperProfile()
{
CreateMap<BookDto, CreateUpdateBookDto>();

CreateMap<Pages.Authors.CreateModalModel.CreateAuthorViewModel,
CreateAuthorDto>();

// ADD THESE NEW MAPPINGS
CreateMap<AuthorDto, Pages.Authors.EditModalModel.EditAuthorViewModel>();
CreateMap<Pages.Authors.EditModalModel.EditAuthorViewModel,
UpdateAuthorDto>();
}
}
}

作者与图书的关系

我们已经为图书管理应用程序创建了 图书作者 功能. 然而, 这些实体间还没有关联.

在本章, 我们会在 作者图书 实体间建立 1 对 N 的关系.

在图书实体中加入关系

打开 Acme.BookStore.Domain 项目中的 Books/Book.cs, 在 Book 实体中加入下列属性:

1
public Guid AuthorId { get; set; }

在本章中, 我们选择不在 Book 类中加入 Author 实体的 导航属性 (例如 public Author Author { get; set; }). 这是为了遵循 DDD 最佳实践 (规则: 仅通过id引用其它聚合对象). 但是, 你自己可以添加这样的导航属性, 并为EF Core配置它. 这样, 你在获取图书和它们的作者时就不需要写join查询了(如同下面我们做的一样), 这会使代码更简洁一些.

数据库 & 数据迁移

Book 实体新增一个不为空的 AuthorId 属性. 但是, 数据库中已存在的图书怎么办? 它们没有 AuthorIds, 当我们尝试运行应用程序时会出问题.

这是一个 典型的迁移问题, 解决方案依赖于你的具体情况;

  • 如果你还没有发布应用程序到生产环境, 你可以直接删除数据库中的图书数据, 甚至你可以删除开发环境中的整个数据库.
  • 你可以在数据迁移或生成种子阶段使用代码更新已有数据.
  • 你可以手工处理这些数据.

我们倾向于 删除数据库 (你可以在 Package Manager 控制台中运行 Drop-Database, 因为这只是个示例项目, 数据丢失无关紧要.

1
dotnet ef database drop

更新 EF Core 映射

定位到 Acme.BookStore.EntityFrameworkCore 项目的 EntityFrameworkCore 文件夹下的 BookStoreDbContext 类的 OnModelCreating 方法, 修改 builder.Entity<Book> 部分如下:

1
2
3
4
5
6
7
8
9
builder.Entity<Book>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Books", BookStoreConsts.DbSchema);
b.ConfigureByConvention(); //auto configure for the base class props
b.Property(x => x.Name).IsRequired().HasMaxLength(128);

// ADD THE MAPPING FOR THE RELATION
b.HasOne<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});

新增 EF Core 迁移

启动解决方案被配置为使用 Entity Framework Core Code First Migrations. 因为我们修改了数据库映射配置, 我们需要新建一个迁移并应用于数据库.

Acme.BookStore.EntityFrameworkCore 项目的文件目录打开命令行终端, 输入命令:

1
dotnet ef migrations add Added_AuthorId_To_Book

这会创建一个新的迁移类, 在它的 Up 方法中使用下列方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
migrationBuilder.AddColumn<Guid>(
name: "AuthorId",
table: "AppBooks",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));

migrationBuilder.CreateIndex(
name: "IX_AppBooks_AuthorId",
table: "AppBooks",
column: "AuthorId");

migrationBuilder.AddForeignKey(
name: "FK_AppBooks_AppAuthors_AuthorId",
table: "AppBooks",
column: "AuthorId",
principalTable: "AppAuthors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
  • AppBooks 表增加一个 AuthorId 字段 .
  • 根据 AuthorId 字段新建一个索引.
  • 声明到 AppAuthors 表的外键.

修改数据种子

因为 AuthorIdBook 实体的不可为空属性, 当前的数据种子代码不能工作. 打开 Acme.BookStore.Domain 项目中的 BookStoreDataSeederContributor, 修改成以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
using System;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore
{
public class BookStoreDataSeederContributor
: IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;

public BookStoreDataSeederContributor(
IRepository<Book, Guid> bookRepository,
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_authorManager = authorManager;
}

public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() > 0)
{
return;
}

var orwell = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"George Orwell",
new DateTime(1903, 06, 25),
"Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)."
)
);

var douglas = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'."
)
);

await _bookRepository.InsertAsync(
new Book
{
AuthorId = orwell.Id, // SET THE AUTHOR
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);

await _bookRepository.InsertAsync(
new Book
{
AuthorId = douglas.Id, // SET THE AUTHOR
Name = "The Hitchhiker's Guide to the Galaxy",
Type = BookType.ScienceFiction,
PublishDate = new DateTime(1995, 9, 27),
Price = 42.0f
},
autoSave: true
);
}
}
}

唯一的区别是设置 Book 实体的 AuthorId 属性.

执行 DbMigrator 前删除已有图书或数据库. 参阅上面的 数据库 & 数据迁移 小节获取详细信息.

你现在可以运行 .DbMigrator 控制台应用程序, 迁移 数据库 schema 并生成 种子 初始数据.

应用层

我们将修改 BookAppService, 支持作者关系.

数据传输对象

让我们从DTOs开始.

BookDto

打开 Acme.BookStore.Application.Contracts 项目的 Books 文件夹下的 BookDto 类, 添加如下属性:

1
2
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }

最终的 BookDto 类应该如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using Volo.Abp.Application.Dtos;

namespace Acme.BookStore.Books
{
public class BookDto : AuditedEntityDto<Guid>
{
public Guid AuthorId { get; set; }

public string AuthorName { get; set; }

public string Name { get; set; }

public BookType Type { get; set; }

public DateTime PublishDate { get; set; }

public float Price { get; set; }
}
}
CreateUpdateBookDto

打开 Acme.BookStore.Application.Contracts 项目的 Books 文件夹下的 CreateUpdateBookDto 类, 添加 AuthorId 属性:

1
public Guid AuthorId { get; set; }
AuthorLookupDto

Acme.BookStore.Application.Contracts 项目的 Books 文件夹下新建一个类 AuthorLookupDto:

1
2
3
4
5
6
7
8
9
10
using System;
using Volo.Abp.Application.Dtos;

namespace Acme.BookStore.Books
{
public class AuthorLookupDto : EntityDto<Guid>
{
public string Name { get; set; }
}
}

它会被一个将要添加到 IBookAppService 的新方法使用.

IBookAppService

打开 Acme.BookStore.Application.Contracts 项目的 Books 文件夹下的 IBookAppService 接口, 添加一个名为 GetAuthorLookupAsync 的新方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;

namespace Acme.BookStore.Books
{
public interface IBookAppService :
ICrudAppService< //Defines CRUD methods
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto> //Used to create/update a book
{
// ADD the NEW METHOD
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync();
}
}

这个新方法将被UI用来获取作者列表, 填充一个下拉框. 使用这个下拉框选择图书作者.

BookAppService

打开 Acme.BookStore.Application 项目的 Books 文件夹下的 BookAppService 类, 更新为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore.Books
{
[Authorize(BookStorePermissions.Books.Default)]
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
private readonly IAuthorRepository _authorRepository;

public BookAppService(
IRepository<Book, Guid> repository,
IAuthorRepository authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
GetPolicyName = BookStorePermissions.Books.Default;
GetListPolicyName = BookStorePermissions.Books.Default;
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Delete;
}

public override async Task<BookDto> GetAsync(Guid id)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();

//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
where book.Id == id
select new { book, author };

//Execute the query and get the book with author
var queryResult = await AsyncExecuter.FirstOrDefaultAsync(query);
if (queryResult == null)
{
throw new EntityNotFoundException(typeof(Book), id);
}

var bookDto = ObjectMapper.Map<Book, BookDto>(queryResult.book);
bookDto.AuthorName = queryResult.author.Name;
return bookDto;
}

public override async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();

//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
select new {book, author};

//Paging
query = query
.OrderBy(NormalizeSorting(input.Sorting))
.Skip(input.SkipCount)
.Take(input.MaxResultCount);

//Execute the query and get a list
var queryResult = await AsyncExecuter.ToListAsync(query);

//Convert the query result to a list of BookDto objects
var bookDtos = queryResult.Select(x =>
{
var bookDto = ObjectMapper.Map<Book, BookDto>(x.book);
bookDto.AuthorName = x.author.Name;
return bookDto;
}).ToList();

//Get the total count with another query
var totalCount = await Repository.GetCountAsync();

return new PagedResultDto<BookDto>(
totalCount,
bookDtos
);
}

public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await _authorRepository.GetListAsync();

return new ListResultDto<AuthorLookupDto>(
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors)
);
}

private static string NormalizeSorting(string sorting)
{
if (sorting.IsNullOrEmpty())
{
return $"book.{nameof(Book.Name)}";
}

if (sorting.Contains("authorName", StringComparison.OrdinalIgnoreCase))
{
return sorting.Replace(
"authorName",
"author.Name",
StringComparison.OrdinalIgnoreCase
);
}

return $"book.{sorting}";
}
}
}

我们做了以下修改:

  • 给所有新建/覆写的方法增加 [Authorize(BookStorePermissions.Books.Default)] 进行授权(当授权特性应用于类时, 它对这个类的所有方法有效).
  • 注入 IAuthorRepository, 从作者中查询.
  • 覆写基类 CrudAppServiceGetAsync 方法. 这个方法根据给定的 id 返回单一 BookDto 对象.
    • 使用一个简单的LINQ表达式关联图书和作者, 根据给定的图书id查询, 查询结果同时包含图书和作者.
    • 使用 AsyncExecuter.FirstOrDefaultAsync(...) 执行查询并得到一个结果. 这是一种无需依赖database provider API, 使用异步LINQ扩展的方法. 参阅 repository文档以理解我们为什么使用它.
    • 如果请求的图书在数据库中不存在, 抛出一个 EntityNotFoundException, 这会导致一个 HTTP 404 (not found) 状态码.
    • 最后, 使用 ObjectMapper创建一个 BookDto 对象, 然后手工给 AuthorName 赋值.
  • 覆写 CrudAppService 基类的 GetListAsync 方法, 返回图书列表. 逻辑与前一个方法类似, 所以很容易理解.
  • 新建一个方法: GetAuthorLookupAsync. 这个方法只是简单地获取所有作者. UI使用这个方法填充一个下拉框, 当编辑图书时用来选择作者.

对象到对象映射映射

引入 AuthorLookupDto 类, 在 GetAuthorLookupAsync 方法中使用对象映射. 所以, 我们需要在 Acme.BookStore.Application 项目的 BookStoreApplicationAutoMapperProfile.cs 文件中加入一个新的映射定义.

1
CreateMap<Author, AuthorLookupDto>();

单元测试

因为修改了 AuthorAppService, 一些单元测试失败了. 打开 Acme.BookStore.Application.Tests 项目的 Books 目录中的 BookAppService_Tests, 修改成以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
using System;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Shouldly;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Validation;
using Xunit;

namespace Acme.BookStore.Books
{
public class BookAppService_Tests : BookStoreApplicationTestBase
{
private readonly IBookAppService _bookAppService;
private readonly IAuthorAppService _authorAppService;

public BookAppService_Tests()
{
_bookAppService = GetRequiredService<IBookAppService>();
_authorAppService = GetRequiredService<IAuthorAppService>();
}

[Fact]
public async Task Should_Get_List_Of_Books()
{
//Act
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);

//Assert
result.TotalCount.ShouldBeGreaterThan(0);
result.Items.ShouldContain(b => b.Name == "1984" &&
b.AuthorName == "George Orwell");
}

[Fact]
public async Task Should_Create_A_Valid_Book()
{
var authors = await _authorAppService.GetListAsync(new GetAuthorListDto());
var firstAuthor = authors.Items.First();

//Act
var result = await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
AuthorId = firstAuthor.Id,
Name = "New test book 42",
Price = 10,
PublishDate = System.DateTime.Now,
Type = BookType.ScienceFiction
}
);

//Assert
result.Id.ShouldNotBe(Guid.Empty);
result.Name.ShouldBe("New test book 42");
}

[Fact]
public async Task Should_Not_Create_A_Book_Without_Name()
{
var exception = await Assert.ThrowsAsync<AbpValidationException>(async () =>
{
await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
Name = "",
Price = 10,
PublishDate = DateTime.Now,
Type = BookType.ScienceFiction
}
);
});

exception.ValidationErrors
.ShouldContain(err => err.MemberNames.Any(m => m == "Name"));
}
}
}
  • 修改 Should_Get_List_Of_Books 中的断言条件, 从 b => b.Name == "1984" 修改为 b => b.Name == "1984" && b.AuthorName == "George Orwell", 检查用户名是否被填充.
  • 修改 Should_Create_A_Valid_Book 方法, 当新建图书时, 设置 AuthorId, 因为它现在是不可为空的了.

用户页面

图书列表

图书列表页面的修改很小. 打开 Acme.BookStore.Web 项目上的 Pages/Books/Index.js, 在 name and type 列之间加入如下列定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
{
title: l('Name'),
data: "name"
},

// ADDED the NEW AUTHOR NAME COLUMN
{
title: l('Author'),
data: "authorName"
},

{
title: l('Type'),
data: "type",
render: function (data) {
return l('Enum:BookType:' + data);
}
},
...

运行应用程序, 你会在表格中看到 Author 列:

CreateModal

打开 Acme.BookStore.Web 项目中的 Pages/Books/CreateModal.cshtml.cs, 修改文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;

namespace Acme.BookStore.Web.Pages.Books
{
public class CreateModalModel : BookStorePageModel
{
[BindProperty]
public CreateBookViewModel Book { get; set; }

public List<SelectListItem> Authors { get; set; }

private readonly IBookAppService _bookAppService;

public CreateModalModel(
IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}

public async Task OnGetAsync()
{
Book = new CreateBookViewModel();

var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}

public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.CreateAsync(
ObjectMapper.Map<CreateBookViewModel, CreateUpdateBookDto>(Book)
);
return NoContent();
}

public class CreateBookViewModel
{
[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }

[Required]
[StringLength(128)]
public string Name { get; set; }

[Required]
public BookType Type { get; set; } = BookType.Undefined;

[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;

[Required]
public float Price { get; set; }
}
}
}
  • Book 属性的类型从 CreateUpdateBookDto 修改为这个文件中新定义的 CreateBookViewModel 类. 这个修改的主要动机是根据UI需求自定义模型类. 我们不希望在 CreateUpdateBookDto 类中使用UI相关的 [SelectItems(nameof(Authors))][DisplayName("Author")] 特性.
  • 新增 Authors 属性, 在 OnGetAsync 方法中使用前面定义的 IBookAppService.GetAuthorLookupAsync 方法填充它.
  • 修改 OnPostAsync 方法, 映射 CreateBookViewModel 对象到 CreateUpdateBookDto 对象, 因为 IBookAppService.CreateAsync 需要一个这种类型的参数.

EditModal

打开 Acme.BookStore.Web 项目中的 Pages/Books/EditModal.cshtml.cs, 修改文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;

namespace Acme.BookStore.Web.Pages.Books
{
public class EditModalModel : BookStorePageModel
{
[BindProperty]
public EditBookViewModel Book { get; set; }

public List<SelectListItem> Authors { get; set; }

private readonly IBookAppService _bookAppService;

public EditModalModel(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}

public async Task OnGetAsync(Guid id)
{
var bookDto = await _bookAppService.GetAsync(id);
Book = ObjectMapper.Map<BookDto, EditBookViewModel>(bookDto);

var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}

public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.UpdateAsync(
Book.Id,
ObjectMapper.Map<EditBookViewModel, CreateUpdateBookDto>(Book)
);

return NoContent();
}

public class EditBookViewModel
{
[HiddenInput]
public Guid Id { get; set; }

[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }

[Required]
[StringLength(128)]
public string Name { get; set; }

[Required]
public BookType Type { get; set; } = BookType.Undefined;

[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;

[Required]
public float Price { get; set; }
}
}
}
  • Book 属性的类型从 CreateUpdateBookDto 修改为这个文件中新定义的 EditBookViewModel 类, 和我们前面所做的创建模型的修改一样.
  • 移动新类 EditBookViewModelId 属性.
  • 新增 Authors 属性, 在 OnGetAsync 方法中使用前面定义的 IBookAppService.GetAuthorLookupAsync 方法填充它.
  • 修改 OnPostAsync 方法, 映射 EditBookViewModel 对象到 CreateUpdateBookDto 对象, 因为 IBookAppService.UpdateAsync 需要一个这种类型的参数.

这些修改需要对 EditModal.cshtml 进行一些小修改. 移除 <abp-input asp-for="Id" /> 标签, 因为我们不再需要它了 (因为它被移动到 EditBookViewModel 中了). EditModal.cshtml 的最终内容应为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<abp-dynamic-form abp-model="Book" asp-page="/Books/EditModal">
<abp-modal>
<abp-modal-header title="@L["Update"].Value"></abp-modal-header>
<abp-modal-body>
<abp-form-content />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</abp-dynamic-form>

对象到对象映射配置

以下修改需要定义一些对象到对象映射. 打开 Acme.BookStore.Web 项目中的 BookStoreWebAutoMapperProfile.cs, 在构造函数中添加下列映射定义:

1
2
3
CreateMap<Pages.Books.CreateModalModel.CreateBookViewModel, CreateUpdateBookDto>();
CreateMap<BookDto, Pages.Books.EditModalModel.EditBookViewModel>();
CreateMap<Pages.Books.EditModalModel.EditBookViewModel, CreateUpdateBookDto>();

你可以运行应用程序, 尝试新建或更新一本书. 你将在新建/更新表单上看到一个下拉框, 使用它指定图书的作者。

Demo Codes

github commit
total code