Web 应用开发教程 - 第 10 部分:Book 到 Author 的关系
简介
我们已经为书店应用程序创建了 Book 和 Author 功能。然而,目前这些实体之间没有关联。
在本教程中,我们将在 Author 和 Book 实体之间建立 1 对 N 关系。
为 Book 实体添加关系
打开 Acme.BookStore.Domain 项目中的 Books/Book.cs,并将以下属性添加到 Book 实体中:
public Guid AuthorId { get; set; }
在本教程中,我们倾向于不向
Book类添加指向Author实体的导航属性(例如public Author Author { get; set; })。这是为了遵循 DDD 最佳实践(规则:仅通过 id 引用其他聚合)。不过,你可以添加这样的导航属性并为 EF Core 配置它。这样做的好处是,在获取书籍及其作者时(如下所示),你不需要编写连接查询,这会使你的应用代码更简单。
数据库与数据迁移
为 Book 实体添加了一个新的、必需的 AuthorId 属性。但是,数据库中的现有书籍怎么办?它们目前没有 AuthorId,当我们尝试运行应用程序时,这将是一个问题。
这是一个典型的迁移问题,具体决策取决于你的情况:
- 如果你尚未将应用程序发布到生产环境,你可以直接删除数据库中的现有书籍,甚至可以在开发环境中删除整个数据库。
- 你可以在数据迁移或种子阶段以编程方式更新现有数据。
- 你可以在数据库上手动处理。
我们倾向于删除数据库 (你可以在 Package Manager Console 中运行 Drop-Database 命令),因为这只是一个示例项目,数据丢失并不重要。由于这个主题与 ABP 无关,我们不会深入探讨所有场景。
更新 EF Core 映射
定位到 Acme.BookStore.EntityFrameworkCore 项目的 EntityFrameworkCore 文件夹下的 BookStoreDbContext 类中的 OnModelCreating 方法,并按如下所示修改 builder.Entity<Book> 部分:
builder.Entity<Book>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Books", BookStoreConsts.DbSchema);
b.ConfigureByConvention(); //自动配置基类属性
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
// 添加关系的映射
b.HasOne<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});
添加新的 EF Core 迁移
启动解决方案配置为使用 Entity Framework Core Code First Migrations。由于我们已经更改了数据库映射配置,我们应该创建一个新的迁移并将更改应用到数据库。
在 Acme.BookStore.EntityFrameworkCore 项目目录下打开命令行终端,并键入以下命令:
dotnet ef migrations add Added_AuthorId_To_Book
这应该会创建一个新的迁移类,其 Up 方法中包含以下代码:
migrationBuilder.AddColumn<Guid>(
name: "AuthorId",
table: "AppBooks",
type: "uniqueidentifier",
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表的外键。
如果你使用 Visual Studio,你可能希望在 Package Manager Console (PMC) 中使用
Add-Migration Added_AuthorId_To_Book -c BookStoreDbContext和Update-Database -Context BookStoreDbContext命令。在这种情况下,请确保Acme.BookStore.HttpApi.Host是启动项目,并且Acme.BookStore.EntityFrameworkCore是 PMC 中的 Default Project。
修改数据种子生成器
由于 AuthorId 是 Book 实体的必需属性,当前的数据种子生成器代码无法工作。打开 Acme.BookStore.Domain 项目中的 BookStoreDataSeederContributor,并按如下所示进行修改:
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 创作了文学评论和诗歌、小说和论战新闻;最著名的作品是寓言中篇小说《动物农场》(1945)和反乌托邦小说《一九八四》(1949)。"
)
);
var douglas = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams 是一位英国作家、编剧、散文家、幽默作家、讽刺作家和剧作家。Adams 倡导环保和保护,热爱跑车、技术创新和苹果 Macintosh,并自称是'激进的无神论者'。"
)
);
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
);
}
}
唯一的改动是我们设置了 Book 实体的 AuthorId 属性。
在执行
DbMigrator之前,请删除现有书籍或删除数据库。有关更多信息,请参阅上面的 数据库与数据迁移 部分。
现在,你可以运行 .DbMigrator 控制台应用程序来迁移 数据库架构并填充初始数据。
应用层
我们将修改 BookAppService 以支持作者关系。
数据传输对象
让我们从 DTO 开始。
BookDto
打开 Acme.BookStore.Application.Contracts 项目中 Books 文件夹内的 BookDto 类,并添加以下属性:
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
最终的 BookDto 类应如下所示:
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 属性:
public Guid AuthorId { get; set; }
AuthorLookupDto
在 Acme.BookStore.Application.Contracts 项目的 Books 文件夹内创建一个新类 AuthorLookupDto:
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 的新方法,如下所示:
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace Acme.BookStore.Books;
public interface IBookAppService :
ICrudAppService< //定义 CRUD 方法
BookDto, //用于展示书籍
Guid, //书籍实体的主键
PagedAndSortedResultRequestDto, //用于分页/排序
CreateUpdateBookDto> //用于创建/更新书籍
{
// 添加新方法
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync();
}
这个新方法将被 UI 用来获取作者列表,并填充下拉列表以选择书籍的作者。
BookAppService
打开 Acme.BookStore.Application 项目中 Books 文件夹内的 BookAppService 类,并将文件内容替换为以下代码:
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, //Book 实体
BookDto, //用于展示书籍
Guid, //书籍实体的主键
PagedAndSortedResultRequestDto, //用于分页/排序
CreateUpdateBookDto>, //用于创建/更新书籍
IBookAppService //实现 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)
{
//从仓储获取 IQueryable<Book>
var queryable = await Repository.GetQueryableAsync();
//准备一个连接 books 和 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 };
//执行查询并获取带有作者的书籍
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)
{
//从仓储获取 IQueryable<Book>
var queryable = await Repository.GetQueryableAsync();
//准备一个连接 books 和 authors 的查询
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);
//将查询结果转换为 BookDto 对象列表
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}";
}
}
让我们看看我们所做的更改:
- 添加了
[Authorize(BookStorePermissions.Books.Default)]以授权我们新添加/覆盖的方法(记住,当为类声明授权属性时,它对类的所有方法都有效)。 - 注入了
IAuthorRepository以便从作者表中查询。 - 覆盖了基类
CrudAppService的GetAsync方法,该方法返回具有给定id的单个BookDto对象。- 使用了一个简单的 LINQ 表达式来连接 books 和 authors,并为给定的书籍 id 一起查询它们。
- 使用
AsyncExecuter.FirstOrDefaultAsync(...)来执行查询并获取结果。这是一种在不依赖数据库提供程序 API 的情况下使用异步 LINQ 扩展的方法。请查阅 仓储文档 以了解我们为什么使用它。 - 如果请求的书籍不在数据库中,则抛出
EntityNotFoundException,这会导致HTTP 404(未找到)结果。 - 最后,使用
ObjectMapper创建了一个BookDto对象,然后手动分配了AuthorName。
- 覆盖了基类
CrudAppService的GetListAsync方法,该方法返回书籍列表。逻辑与上一个方法类似,因此你可以轻松理解代码。 - 创建了一个新方法:
GetAuthorLookupAsync。这个方法简单地获取所有作者。UI 使用此方法填充下拉列表,并在创建/编辑书籍时选择作者。
对象到对象映射配置
我们引入了 AuthorLookupDto 类,并在 GetAuthorLookupAsync 方法中使用了对象映射。因此,我们需要在 Acme.BookStore.Application 项目的 BookStoreApplicationMappers.cs 文件中添加一个新的映射定义:
[Mapper]
public partial class AuthorToAuthorLookupDtoMapper : MapperBase<Author, AuthorLookupDto>
{
public override partial AuthorLookupDto Map(Author source);
public override partial void Map(Author source, AuthorLookupDto destination);
}
单元测试
由于我们对 AuthorAppService 进行了一些更改,一些单元测试将会失败。打开 Acme.BookStore.Application.Tests 项目中 Books 文件夹内的 BookAppService_Tests,并按如下所示更改内容:
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()
{
//执行
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);
//断言
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();
//执行
var result = await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
AuthorId = firstAuthor.Id,
Name = "New test book 42",
Price = 10,
PublishDate = System.DateTime.Now,
Type = BookType.ScienceFiction
}
);
//断言
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,因为它是必需的了。
用户界面
服务代理生成
由于 HTTP API 已更改,你需要更新 Angular 客户端 服务代理。在运行 generate-proxy 命令之前,你的主机必须已启动并运行。
在 angular 文件夹中运行以下命令(你可能需要停止 Angular 应用程序):
abp generate-proxy -t ng
此命令将更新 /src/app/proxy/ 文件夹下的服务代理文件。
书籍列表
书籍列表页面的更改很简单。打开 /src/app/book/book.component.html,并在 Name 和 Type 列之间添加以下列定义:
<ngx-datatable-column
[name]="'::Author' | abpLocalization"
prop="authorName"
[sortable]="false"
></ngx-datatable-column>
当你运行应用程序时,你可以在表格中看到 Author 列:
创建/编辑表单
下一步是向创建/编辑表单添加作者选择(下拉菜单)。最终的 UI 将如下图所示:
在表单的第一个元素处添加了作者下拉菜单。
打开 /src/app/book/book.component.ts,并按如下所示更改内容:
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;
public readonly list = inject(ListService);
private readonly bookService = inject(BookService);
private readonly fb = inject(FormBuilder);
private readonly confirmation = inject(ConfirmationService);
constructor() {
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());
}
});
}
}
- 添加了对
AuthorLookupDto、Observable和map的导入。 - 在
selectedBook之后添加了字段authors$: Observable<AuthorLookupDto[]>;。 - 在构造函数中添加了
this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items));。 - 在
buildForm()函数中添加了authorId: [this.selectedBook.authorId || null, Validators.required],。
打开 /src/app/book/book.component.html,并在书籍名称表单组之前添加以下表单组:
<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>
就是这样。只需运行应用程序并尝试创建或编辑作者。
抠丁客




