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 包来得到支持.

Razor 页面

虽然安全的 HTTP API和应用服务阻止未授权用户使用服务, 但他们依然可以导航到图书管理页面. 虽然当页面发起第一个访问服务器的AJAX请求时会收到授权异常, 但为了更好的用户体验和安全性, 我们应该对页面进行授权.

打开 BookStoreWebModuleConfigureServices 方法中加入以下代码:

1
2
3
4
5
6
Configure<RazorPagesOptions>(options =>
{
options.Conventions.AuthorizePage("/Books/Index", BookStorePermissions.Books.Default);
options.Conventions.AuthorizePage("/Books/CreateModal", BookStorePermissions.Books.Create);
options.Conventions.AuthorizePage("/Books/EditModal", BookStorePermissions.Books.Edit);
});

现在未授权用户会被重定向至登录页面.

隐藏新建图书按钮

图书管理页面有一个 新建图书 按钮, 当用户没有 图书新建 权限时就不可见的.

打开 Pages/Books/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
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Permissions
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.Localization
@model IndexModel
@inject IStringLocalizer<BookStoreResource> L
@inject IAuthorizationService AuthorizationService
@section scripts
{
<abp-script src="/Pages/Books/Index.js"/>
}

<abp-card>
<abp-card-header>
<abp-row>
<abp-column size-md="_6">
<abp-card-title>@L["Books"]</abp-card-title>
</abp-column>
<abp-column size-md="_6" class="text-right">
@if (await AuthorizationService.IsGrantedAsync(BookStorePermissions.Books.Create))
{
<abp-button id="NewBookButton"
text="@L["NewBook"].Value"
icon="plus"
button-type="Primary"/>
}
</abp-column>
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" id="BooksTable"></abp-table>
</abp-card-body>
</abp-card>
  • 加入 @inject IAuthorizationService AuthorizationService 以访问授权服务.
  • 使用 @if (await AuthorizationService.IsGrantedAsync(BookStorePermissions.Books.Create)) 检查图书创建权限, 条件显示 新建图书 按钮.

JavaScript端

图书管理页面中的图书表格每行都有操作按钮. 操作按钮包括 编辑删除 操作:

如果用户没有权限, 应该隐藏相关的操作. 表格行中的操作有一个 visible 属性, 可以设置为 false 隐藏操作项.

打开 .Web 项目中的 Pages/Books/Index.js, 为 Edit 操作加入 visible 属性:

1
2
3
4
5
6
7
{
text: l('Edit'),
visible: abp.auth.isGranted('BookStore.Books.Edit'), //CHECK for the PERMISSION
action: function (data) {
editModal.open({ id: data.record.id });
}
}

Delete 操作进行同样的操作:

1
visible: abp.auth.isGranted('BookStore.Books.Delete'),
  • abp.auth.isGranted(...) 检查前面定义的权限.
  • visible 也可以是一个返回 bool 值的函数. 这个函数可以稍后根据某些条件计算.

菜单项

即使我们在图书管理页面的所有层都控制了权限, 应用程序的主菜单依然会显示. 我们应该隐藏用户没有权限的菜单项.

打开 BookStoreMenuContributor 类, 找到下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
context.Menu.AddItem(
new ApplicationMenuItem(
"BooksStore",
l["Menu:BookStore"],
icon: "fa fa-book"
).AddItem(
new ApplicationMenuItem(
"BooksStore.Books",
l["Menu:Books"],
url: "/Books"
)
)
);

替换为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var bookStoreMenu = new ApplicationMenuItem(
"BooksStore",
l["Menu:BookStore"],
icon: "fa fa-book"
);

context.Menu.AddItem(bookStoreMenu);

//CHECK the PERMISSION
if (await context.IsGrantedAsync(BookStorePermissions.Books.Default))
{
bookStoreMenu.AddItem(new ApplicationMenuItem(
"BooksStore.Books",
l["Menu:Books"],
url: "/Books"
));
}

Demo Codes

github commit

下一章

参阅的Abp vNext 学习(7)