项目
版本

本文档有多个版本。请选择最适合您的选项。

UI
Database

Web 应用开发教程 - 第 10 部分:Book 到 Author 的关系

简介

我们已经为书店应用程序创建了 BookAuthor 功能。然而,目前这些实体之间没有关联。

在本教程中,我们将在 AuthorBook 实体之间建立 1 对 N 关系。

为 Book 实体添加关系

打开 Acme.BookStore.Domain 项目中的 Books/Book.cs,并将以下属性添加到 Book 实体中:

public Guid AuthorId { get; set; }

数据库与数据迁移

Book 实体添加了一个新的、必需的 AuthorId 属性。但是,数据库中的现有书籍怎么办?它们目前没有 AuthorId,当我们尝试运行应用程序时,这将是一个问题。

这是一个典型的迁移问题,具体决策取决于你的情况:

  • 如果你尚未将应用程序发布到生产环境,你可以直接删除数据库中的现有书籍,甚至可以在开发环境中删除整个数据库。
  • 你可以在数据迁移或种子阶段以编程方式更新现有数据。
  • 你可以在数据库上手动处理。

我们倾向于删除数据库 ,因为这只是一个示例项目,数据丢失并不重要。由于这个主题与 ABP 无关,我们不会深入探讨所有场景。

修改数据种子生成器

由于 AuthorIdBook 实体的必需属性,当前的数据种子生成器代码无法工作。打开 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.Dynamic.Core;
using System.Linq;
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.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.Create;
    }

    public async override Task<BookDto> GetAsync(Guid id)
    {
        var book = await Repository.GetAsync(id);
        var bookDto = ObjectMapper.Map<Book, BookDto>(book);

        var author = await _authorRepository.GetAsync(book.AuthorId);
        bookDto.AuthorName = author.Name;

        return bookDto;
    }

    public async override Task<PagedResultDto<BookDto>>
        GetListAsync(PagedAndSortedResultRequestDto input)
    {
        //如果未提供,则设置默认排序
        if (input.Sorting.IsNullOrWhiteSpace())
        {
            input.Sorting = nameof(Book.Name);
        }

        //从仓储获取 IQueryable<Book>
        var queryable = await Repository.GetQueryableAsync();

        //获取书籍
        var books = await AsyncExecuter.ToListAsync(
            queryable
                .OrderBy(input.Sorting)
                .Skip(input.SkipCount)
                .Take(input.MaxResultCount)
        );

        //转换为 DTO
        var bookDtos = ObjectMapper.Map<List<Book>, List<BookDto>>(books);

        //为相关作者获取一个查找字典
        var authorDictionary = await GetAuthorDictionaryAsync(books);

        //为 DTO 设置 AuthorName
        bookDtos.ForEach(bookDto => bookDto.AuthorName =
                            authorDictionary[bookDto.AuthorId].Name);

        //用另一个查询获取总数量(分页所需)
        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 async Task<Dictionary<Guid, Author>>
        GetAuthorDictionaryAsync(List<Book> books)
    {
        var authorIds = books
            .Select(b => b.AuthorId)
            .Distinct()
            .ToArray();

        var queryable = await _authorRepository.GetQueryableAsync();

        var authors = await AsyncExecuter.ToListAsync(
            queryable.Where(a => authorIds.Contains(a.Id))
        );

        return authors.ToDictionary(x => x.Id, x => x);
    }
}

让我们看看我们所做的更改:

  • 添加了 [Authorize(BookStorePermissions.Books.Default)] 以授权我们新添加/覆盖的方法(记住,当为类声明授权属性时,它对类的所有方法都有效)。
  • 注入了 IAuthorRepository 以便从作者表中查询。
  • 覆盖了基类 CrudAppServiceGetAsync 方法,该方法返回具有给定 id 的单个 BookDto 对象。
  • 覆盖了基类 CrudAppServiceGetListAsync 方法,该方法返回书籍列表。此代码分别从数据库查询作者,并在应用程序代码中设置作者姓名。作为替代,你可以创建一个自定义仓储方法并执行连接查询,或者利用 MongoDB API 的功能,在单个查询中获取书籍及其作者,这样性能会更高。
  • 创建了一个新方法: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,并在 NameType 列之间添加以下列定义:

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

当你运行应用程序时,你可以在表格中看到 Author 列:

bookstore-books-with-authorname-angular

创建/编辑表单

下一步是向创建/编辑表单添加作者选择(下拉菜单)。最终的 UI 将如下图所示:

bookstore-angular-author-selection

在表单的第一个元素处添加了作者下拉菜单。

打开 /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());
      }
    });
  }
}
  • 添加了对 AuthorLookupDtoObservablemap 的导入。
  • 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>

就是这样。只需运行应用程序并尝试创建或编辑作者。


在本文档中