Abp vNext - 应用开发系列之数据关联

在系列教程中,我们会构建一个基于ABP的Web应用程序,用于管理书籍及其作者列表

使用到的技术:

  • Entity Framework Core
  • Angular

本教程分为以下部分:

前言

在前面的部分,我们创建了Book、Author功能,但是没有关联起来,在本节中,我们创建一个一对多的Author-Book关系

Book添加关联

Book实体中添加以下属性:

1
public Guid AuthorId { get; set; 

这里没有添加Author导航属性到Book实体中,遵循DDD最佳实践,仅通过Id应用其他聚合;取决与你自己,添加了导航属性,则在获取书籍作者时不需要关联查询,而是由导航属性自动带出

数据迁移

我们向Book添加了外键关联字段AuthorId,是不为空的,但是在数据库已有的数据中,没有AuthorId数据,运行时将会报错,这是一个典型的迁移问题:

  • 可以直接删除现有数据
  • 用代码逻辑处理现有数据
  • 手动修改数据库数据

需要根据实际情况选择合适的处理方式,这里就直接将书籍数据删除

更新EF Core映射

Acme.BookStore.EntityFrameworkCore 项目文件夹下的 BookStoreDbContext 类中OnModelCreating方法,新增Book实体配置:

1
2
3
4
5
builder.Entity<Book>(b =>
{
// 其他配置
b.HasOne<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});

新增迁移

Acme.BookStore.EntityFrameworkCore 项目目录中打开命令行终端,执行以下命令:

1
dotnet ef migrations add Added_AuthorId_To_Book

更新种子数据

调整 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
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, // 关联作者
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);

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

运行.Migrator项目以执行迁移和创建种子数据

Application

调整BookAppService

Dto

调整以下Dto:

BookDto

添加以下属性

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

CreateUpdateBookDto

并添加一个 AuthorId 属性

1
public Guid AuthorId { get; set; }

AuthorLookupDto

Acme.BookStore.Application.Contracts 项目Books 文件夹中新建类 AuthorLookupDto

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

namespace Acme.BookStore.Books;

public class AuthorLookupDto : EntityDto<Guid>
{
public string Name { get; set; }
}

IBookAppService

IBookAppService中新增GetAuthorLookupAsync接口,用于页面获取作者下拉数据。

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

namespace Acme.BookStore.Books;

public interface IBookAppService :
ICrudAppService<
BookDto,
Guid,
PagedAndSortedResultRequestDto,
CreateUpdateBookDto>
{
// 获取作者数据
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync();
}

BookAppService

  • GetAuthorLookupAsync:用于查询所有作者提供给界面选择
  • 重写GetAsyncGetListAsync,关联作者信息
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
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)
{
// 查询书籍
var queryable = await Repository.GetQueryableAsync();

// 关联作者
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 };

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)
{
// 查询书籍
var queryable = await Repository.GetQueryableAsync();

// 关联作者
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
select new {book, author};

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

var queryResult = await AsyncExecuter.ToListAsync(query);

// Dto转换
var bookDtos = queryResult.Select(x =>
{
var bookDto = ObjectMapper.Map<Book, BookDto>(x.book);
bookDto.AuthorName = x.author.Name;
return bookDto;
}).ToList();

// 获取数据总数
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}";
}
}

对象映射

新增对象映射配置:

1
CreateMap<Author, AuthorLookupDto>();

单元测试

调整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
81
using System;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Shouldly;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Modularity;
using Volo.Abp.Validation;
using Xunit;

namespace Acme.BookStore.Books;

public abstract class BookAppService_Tests<TStartupModule> : BookStoreApplicationTestBase<TStartupModule>
where TStartupModule : IAbpModule
{
private readonly IBookAppService _bookAppService;
private readonly IAuthorAppService _authorAppService;

protected 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中的断言条件,以检查是否已填充作者姓名
  • 调整Should_Create_A_Valid_Book,获取Author信息以关联Book - AuthorId

用户界面

生成代理类

angular目录下执行:

1
abp generate-proxy -t ng

列表

打开 /src/app/book/book.component.html ,在ngx-datatable新增Author展示字段:

1
2
3
4
5
<ngx-datatable-column
[name]="'::Author' | abpLocalization"
prop="authorName"
[sortable]="false"
></ngx-datatable-column>

效果如下:

book list

表单

调整 /src/app/book/book.component.ts

  • 引入AuthorLookupDtoObservable
  • 新增authors$: Observable<AuthorLookupDto[]>字段
  • 构造函数获取authors$
  • 调整buildForm,新增authorId

如下所示:

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
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookService, BookDto, bookTypeOptions, AuthorLookupDto } from '@proxy/books';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Component({
selector: 'app-book',
templateUrl: './book.component.html',
styleUrls: ['./book.component.scss'],
providers: [ListService, { provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }],
})
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;

form: FormGroup;

selectedBook = {} as BookDto;

authors$: Observable<AuthorLookupDto[]>;

bookTypes = bookTypeOptions;

isModalOpen = false;

constructor(
public readonly list: ListService,
private bookService: BookService,
private fb: FormBuilder,
private confirmation: ConfirmationService
) {
this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items));
}

ngOnInit() {
const bookStreamCreator = (query) => this.bookService.getList(query);

this.list.hookToQuery(bookStreamCreator).subscribe((response) => {
this.book = response;
});
}

createBook() {
this.selectedBook = {} as BookDto;
this.buildForm();
this.isModalOpen = true;
}

editBook(id: string) {
this.bookService.get(id).subscribe((book) => {
this.selectedBook = book;
this.buildForm();
this.isModalOpen = true;
});
}

buildForm() {
this.form = this.fb.group({
authorId: [this.selectedBook.authorId || null, Validators.required],
name: [this.selectedBook.name || null, Validators.required],
type: [this.selectedBook.type || null, Validators.required],
publishDate: [
this.selectedBook.publishDate ? new Date(this.selectedBook.publishDate) : null,
Validators.required,
],
price: [this.selectedBook.price || null, Validators.required],
});
}

save() {
if (this.form.invalid) {
return;
}

const request = this.selectedBook.id
? this.bookService.update(this.selectedBook.id, this.form.value)
: this.bookService.create(this.form.value);

request.subscribe(() => {
this.isModalOpen = false;
this.form.reset();
this.list.get();
});
}

delete(id: string) {
this.confirmation.warn('::AreYouSureToDelete', 'AbpAccount::AreYouSure').subscribe((status) => {
if (status === Confirmation.Status.confirm) {
this.bookService.delete(id).subscribe(() => this.list.get());
}
});
}
}

/src/app/book/book.component.html中添加表单项:

1
2
3
4
5
6
7
8
9
<div class="form-group">
<label for="author-id">Author</label><span> * </span>
<select class="form-control" id="author-id" formControlName="authorId">
<option [ngValue]="null">Select author</option>
<option [ngValue]="author.id" *ngFor="let author of authors$ | async">
{{ author.name }}
</option>
</select>
</div>

最终效果如下:

form

book store