复杂业务关系实现:图书与作者的关联设计(DDD实战)
哈喽,我是黑棠
在之前的章节中,我们分别实现了图书和作者的CRUD功能(CRUD功能(点击回顾)、权限控制(点击回顾))。
但在真实业务场景中,图书和作者是典型的多对一关系(多名作者可以写多本书,一本书只能属于一名作者)。
本章将带你基于 领域驱动设计(DDD) 思想,一步步实现这种关联关系,重点讲解如何在领域层、应用层和数据库层正确地建模和处理关联,并通过ABP框架的特性简化开发。
一、设计原则:DDD下的关联关系建模
在DDD中,关联关系的建模需要遵循以下原则:
- 聚合根边界 :图书(
Book)和作者(Author)都是独立的聚合根,因为它们都有自己的生命周期和业务规则。 - 通过ID关联 :在聚合根之间,我们通常不直接引用整个实体 ,而是通过ID进行关联。这样可以保持聚合根的独立性,避免跨聚合根的强耦合。
- 导航属性 :为了查询方便,可以在聚合根内部添加导航属性,但这只是"查询视图",不影响领域模型的本质。
- 领域规则优先 :关联关系的建立必须满足领域规则(例如,图书必须属于一个已经存在的作者)。

二、领域层实现:更新实体和规则
1. 更新Book实体
我们需要在Book实体中添加与作者的关联。根据DDD原则,我们将:
- 添加
AuthorId属性(外键),用于存储关联的作者ID。 - 添加
Author导航属性(可选),用于在查询时方便地加载作者信息。 - 更新构造函数,确保创建图书时必须指定作者ID。
文件路径 :src/Acme.BookStore.Domain/Books/Book.cs
csharp
using Acme.BookStore.Domain.Authors; // 引入Author聚合根
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp;
namespace Acme.BookStore.Domain.Books;
public class Book : AuditedAggregateRoot<Guid>
{
// 图书名称
public string Name { get; private set; } = string.Empty;
// 图书类型
public BookType Type { get; set; }
// 图书价格
public decimal Price { get; set; }
// 出版日期
public DateTime? PublishDate { get; set; }
// ############## 新增关联部分 ##############
// 作者ID(外键),通过ID关联作者聚合根
public Guid AuthorId { get; private set; }
// 保护构造函数(EF Core使用)
protected Book() { }
// 更新构造函数,添加AuthorId参数
public Book(Guid id, string name, BookType type, decimal price, Guid authorId) : base(id)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
Type = type;
Price = Check.Positive(price, nameof(price));
AuthorId = Check.NotNull(authorId, nameof(authorId)); // 确保作者ID不为空
}
// 更新图书信息的方法,允许修改作者ID
public void Update(string name, BookType type, decimal price, Guid authorId, DateTime? publishDate = null)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
Type = type;
Price = Check.Positive(price, nameof(price));
AuthorId = Check.NotNull(authorId, nameof(authorId));
PublishDate = publishDate;
}
}
关键说明:
AuthorId:这是与作者关联的核心,它存储了作者的唯一标识。通过ID关联可以避免Book实体直接依赖Author实体的完整状态,保持了聚合根的独立性。- 构造函数和
Update方法 :我们将AuthorId添加到了构造函数和Update方法中,确保在创建或修改图书时,必须指定一个有效的作者ID,这体现了领域规则。
2. 更新Author实体(可选)
虽然在这个场景中,我们主要是在图书端维护关联,但为了查询方便,也可以在Author实体中添加一个导航属性,指向该作者的所有图书。
文件路径 :src/Acme.BookStore.Domain/Authors/Author.cs
csharp
using Volo.Abp.Domain.Entities.Auditing;
using Volo.Abp;
using System.Collections.Generic;
namespace Acme.BookStore.Domain.Authors;
public class Author : AuditedAggregateRoot<Guid>
{
public string Name { get; private set; } = string.Empty;
public DateTime? BirthDate { get; set; }
// ############## 新增导航属性 ##############
// 该作者的所有图书(导航属性,用于查询)
public ICollection<Book> Books { get; private set; } = new List<Book>();
// ##########################################
protected Author() { }
public static Author Create(string name, DateTime? birthDate = null)
{
Check.NotNullOrWhiteSpace(name, nameof(name));
return new Author
{
Id = Guid.NewGuid(),
Name = name,
BirthDate = birthDate
};
}
public void ChangeName(string newName)
{
Name = Check.NotNullOrWhiteSpace(newName, nameof(newName));
}
}
关键说明:
Books导航属性:这是一个集合导航属性,表示该作者所写的所有图书。同样,这主要用于查询(例如,查询作者时同时加载他的所有图书)。
3. 更新数据库映射(EF Core)
现在我们需要告诉EF Core如何将这种关联关系映射到数据库。
文件路径 :src/Acme.BookStore.EntityFrameworkCore/EntityFrameworkCore/BookStoreDbContext.cs
在OnModelCreating方法中,添加对Book实体关联的配置:
csharp
using Acme.BookStore.Domain.Books;
using Acme.BookStore.Domain.Authors;
using Volo.Abp.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Acme.BookStore.EntityFrameworkCore;
[ReplaceDbContext(typeof(IAbpDbContext))]
public class BookStoreDbContext : AbpDbContext<BookStoreDbContext>
{
public DbSet<Book> Books { get; set; } = null!;
public DbSet<Author> Authors { get; set; } = null!;
public BookStoreDbContext(DbContextOptions<BookStoreDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 配置Book实体
modelBuilder.Entity<Book>(b =>
{
b.ToTable("Books");
b.Property(x => x.Name).HasMaxLength(100).IsRequired();
// ############## 配置关联关系 ##############
// 配置Book与Author的多对一关系
b.HasOne(x => x.Author) // Book有一个Author
.WithMany(x => x.Books) // Author有多个Book
.HasForeignKey(x => x.AuthorId) // 外键是AuthorId
.OnDelete(DeleteBehavior.Restrict); // 删除作者时,禁止删除关联的图书(或根据业务需求配置为Cascade)
// ##########################################
});
// 配置Author实体(如果需要)
modelBuilder.Entity<Author>(a =>
{
a.ToTable("Authors");
a.Property(x => x.Name).HasMaxLength(100).IsRequired();
});
}
}
关键说明:
HasOne.WithMany:这是EF Core中配置多对一关系的标准方式。HasForeignKey:指定了外键属性是AuthorId。OnDelete:这是一个非常重要的配置,它定义了删除父实体(Author)时,对子实体(Book)的影响。DeleteBehavior.Cascade:删除作者时,同时删除该作者的所有图书。DeleteBehavior.Restrict:禁止删除有图书关联的作者。这通常更符合业务逻辑(你不能删除一个还写了书的作者)。DeleteBehavior.SetNull:删除作者时,将关联图书的AuthorId设置为null。这需要AuthorId字段是可空的(Guid?),但在我们的设计中,AuthorId是必填的,所以不适用。
4. 执行数据库迁移
关联关系和映射都已更新,现在我们需要将这些变更应用到数据库。
-
打开终端 :在ABP Studio中,右键点击
Acme.BookStore.EntityFrameworkCore项目 → 选择"Open Terminal"。 -
创建迁移 :
powershelldotnet ef migrations add Added_Author_Relation_To_Book -
应用迁移 :
powershelldotnet run --project src/Acme.BookStore.DbMigrator或者使用EF Core命令直接更新数据库(不推荐用于生产环境,
DbMigrator项目是ABP推荐的方式):powershelldotnet ef database update
执行成功后,数据库中的Books表将新增一个AuthorId列,并创建一个外键约束指向Authors表的Id列。
三、应用层实现:支持关联查询和操作
1. 更新DTOs
为了在API中返回和接收关联数据,我们需要更新相关的DTO。
(1)更新BookDto(查询返回用)
我们希望在查询图书详情时,能同时看到作者的信息(至少是作者名称)。
文件路径 :src/Acme.BookStore.Application.Contracts/Books/BookDto.cs
csharp
using Acme.BookStore.Domain.Shared.BookTypes;
using Volo.Abp.Application.Dtos;
using System;
namespace Acme.BookStore.Application.Contracts.Books;
public class BookDto : AuditedEntityDto<Guid>
{
public string Name { get; set; } = string.Empty;
public BookType Type { get; set; }
public decimal Price { get; set; }
public DateTime? PublishDate { get; set; }
// ############## 新增关联字段 ##############
// 作者ID
public Guid AuthorId { get; set; }
// 作者名称(用于显示,不用于输入)
public string? AuthorName { get; set; }
// ##########################################
}
关键说明:
- 我们添加了
AuthorId和AuthorName。AuthorName是一个"派生"字段,它不是直接存储在Book实体中的,而是从关联的Author实体中获取的,用于在UI上友好显示。
(2)更新CreateUpdateBookDto(创建和更新用)
在创建或更新图书时,我们需要通过AuthorId来指定其作者。
文件路径 :src/Acme.BookStore.Application.Contracts/Books/CreateUpdateBookDto.cs
csharp
using Acme.BookStore.Domain.Shared.BookTypes;
using System.ComponentModel.DataAnnotations;
namespace Acme.BookStore.Application.Contracts.Books;
public class CreateUpdateBookDto
{
[Required(ErrorMessage = "图书名称不能为空")]
[MaxLength(100, ErrorMessage = "图书名称最长不能超过100字")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "请选择图书类型")]
public BookType Type { get; set; }
[Required(ErrorMessage = "图书价格不能为空")]
[Range(0.01, double.MaxValue, ErrorMessage = "图书价格必须大于0")]
public decimal Price { get; set; }
public DateTime? PublishDate { get; set; }
// ############## 新增关联字段 ##############
[Required(ErrorMessage = "请选择作者")]
public Guid AuthorId { get; set; }
// ##########################################
}
关键说明:
- 我们只添加了
AuthorId,而不是AuthorName或整个AuthorDto。这遵循了"通过ID关联"的原则,客户端只需要传递作者的ID即可。
2. 更新对象映射(AutoMapper)
我们需要配置AutoMapper,告诉它如何从Book实体(包含Author导航属性)映射到BookDto(包含AuthorName)。
文件路径 :src/Acme.BookStore.Application/BookStoreApplicationAutoMapperProfile.cs
csharp
using Acme.BookStore.Application.Contracts.Books;
using Acme.BookStore.Domain.Books;
using Acme.BookStore.Application.Contracts.Authors;
using Acme.BookStore.Domain.Authors;
using AutoMapper;
namespace Acme.BookStore.Application;
public class BookStoreApplicationAutoMapperProfile : Profile
{
public BookStoreApplicationAutoMapperProfile()
{
// 配置Book到BookDto的映射
CreateMap<Book, BookDto>()
// 自定义映射:将Book.Author.Name映射到BookDto.AuthorName
.ForMember(dest => dest.AuthorName, opt => opt.MapFrom(src => src.Author!.Name)); // 使用!运算符表示我们确保Author已被加载
CreateMap<CreateUpdateBookDto, Book>();
// 其他映射...
CreateMap<Author, AuthorDto>();
}
}
关键说明:
ForMember和MapFrom:这是AutoMapper的高级用法,用于处理复杂的映射关系。我们明确告诉AutoMapper,BookDto的AuthorName字段应该来自Book实体的Author导航属性的Name字段。src.Author!.Name:!是C#的空值包容运算符。在这里使用它是因为我们知道,在执行这个映射时,Author导航属性已经通过Include被加载了,所以它不会是null。
3. 更新BookAppService
最后,我们来更新应用服务,以支持关联操作和查询。
文件路径 :src/Acme.BookStore.Application/Books/BookAppService.cs
csharp
using Acme.BookStore.Application.Contracts.Books;
using Acme.BookStore.Domain.Books;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Acme.BookStore.Domain.Authors;
using Volo.Abp;
using Microsoft.EntityFrameworkCore;
namespace Acme.BookStore.Application.Books;
public class BookAppService :
CrudAppService<Book, BookDto, Guid, PagedAndSortedResultRequestDto, CreateUpdateBookDto>,
IBookAppService
{
// 注入Author仓储,用于验证作者是否存在
private readonly IRepository<Author, Guid> _authorRepository;
public BookAppService(
IRepository<Book, Guid> repository,
IRepository<Author, Guid> authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
}
// 重写CreateAsync方法,添加作者存在性验证
public override async Task<BookDto> CreateAsync(CreateUpdateBookDto input)
{
// 1. 验证作者是否存在
var authorExists = await _authorRepository.AnyAsync(a => a.Id == input.AuthorId);
if (!authorExists)
{
// 如果作者不存在,抛出业务异常
throw new BusinessException("BookStore:Author:NotFound", new string[] { input.AuthorId.ToString() });
}
// 2. 调用基类方法创建图书
return await base.CreateAsync(input);
}
// 重写UpdateAsync方法,同样添加作者存在性验证
public override async Task<BookDto> UpdateAsync(Guid id, CreateUpdateBookDto input)
{
// 1. 验证作者是否存在
var authorExists = await _authorRepository.AnyAsync(a => a.Id == input.AuthorId);
if (!authorExists)
{
throw new BusinessException("BookStore:Author:NotFound", new string[] { input.AuthorId.ToString() });
}
// 2. 调用基类方法更新图书
return await base.UpdateAsync(id, input);
}
// 重写GetListAsync方法,使用Include加载Author导航属性,以填充AuthorName
public override async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input)
{
// 使用Repository.WithDetails()来加载导航属性
var queryable = await Repository.WithDetailsAsync(b => b.Author);
// 应用排序
queryable = ApplySorting(queryable, input);
// 获取总计数
var totalCount = await queryable.CountAsync();
// 应用分页
var items = await queryable
.Skip(input.SkipCount)
.Take(input.MaxResultCount)
.ToListAsync();
// 映射到DTO并返回
return new PagedResultDto<BookDto>(
totalCount,
ObjectMapper.Map<List<Book>, List<BookDto>>(items)
);
}
// 重写GetAsync方法,确保加载Author
public override async Task<BookDto> GetAsync(Guid id)
{
var book = await Repository.WithDetailsAsync(b => b.Author);
var bookWithAuthor = await book.FirstOrDefaultAsync(b => b.Id == id);
if (bookWithAuthor == null)
{
throw new EntityNotFoundException(typeof(Book), id);
}
return ObjectMapper.Map<Book, BookDto>(bookWithAuthor);
}
}
关键说明:
- 注入
IRepository<Author, Guid>:为了验证传入的AuthorId是否有效,我们需要查询作者仓储。 CreateAsync和UpdateAsync中的验证 :在创建或更新图书之前,我们先检查input.AuthorId对应的作者是否存在。如果不存在,就抛出一个BusinessException。这是非常重要的领域规则验证。GetListAsync和GetAsync中的WithDetailsAsync:这是ABP框架提供的一个非常方便的扩展方法。它用于在查询时显式加载 指定的导航属性(b => b.Author),以避免著名的"N+1查询问题"。只有这样,我们在AutoMapper映射时才能拿到Author的信息。- 注意 :ABP的
CrudAppService基类在默认情况下不会自动加载导航属性,所以我们需要重写GetListAsync和GetAsync方法来实现这一点。
- 注意 :ABP的
4. 添加作者下拉列表接口(可选但推荐)
在UI界面上,创建或编辑图书时,通常会有一个作者下拉列表供用户选择。我们需要提供一个接口来获取所有作者的简化信息(ID和Name)。
(1)在IAuthorAppService中定义接口
文件路径 :src/Acme.BookStore.Application.Contracts/Authors/IAuthorAppService.cs
csharp
using Acme.BookStore.Application.Contracts.Authors;
using Volo.Abp.Application.Services;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace Acme.BookStore.Application.Contracts.Authors;
public interface IAuthorAppService :
ICrudAppService<AuthorDto, Guid, PagedAndSortedResultRequestDto, CreateUpdateAuthorDto>
{
// 新增接口:获取作者下拉列表
Task<List<AuthorLookupDto>> GetAuthorLookupAsync();
}
(2)创建AuthorLookupDto
文件路径 :src/Acme.BookStore.Application.Contracts/Authors/AuthorLookupDto.cs
csharp
namespace Acme.BookStore.Application.Contracts.Authors;
public class AuthorLookupDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}
(3)在AuthorAppService中实现接口
文件路径 :src/Acme.BookStore.Application/Authors/AuthorAppService.cs
csharp
using Acme.BookStore.Application.Contracts.Authors;
using Acme.BookStore.Domain.Authors;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using AutoMapper;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Acme.BookStore.Application.Authors;
public class AuthorAppService :
CrudAppService<Author, AuthorDto, Guid, PagedAndSortedResultRequestDto, CreateUpdateAuthorDto>,
IAuthorAppService
{
public AuthorAppService(IRepository<Author, Guid> repository, IMapper mapper)
: base(repository, mapper)
{
}
// 实现获取作者下拉列表的方法
public async Task<List<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await Repository.GetListAsync();
return ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors);
}
}
(4)添加AutoMapper映射
文件路径 :src/Acme.BookStore.Application/BookStoreApplicationAutoMapperProfile.cs
csharp
// 在Profile构造函数中添加
CreateMap<Author, AuthorLookupDto>();
四、测试验证
启动项目,访问Swagger UI(https://localhost:44359/swagger),测试以下接口:
-
创建图书 :调用
POST /api/app/book,传入包含AuthorId的JSON数据。json{ "name": "ABP框架实战", "type": 1, "price": 89.00, "publishDate": "2024-01-15T00:00:00", "authorId": "a1b2c3d4-e5f6-7890-abcd-1234567890ab" // 替换为一个真实存在的作者ID }- 如果
authorId不存在,应该返回400错误,提示"作者不存在"。 - 如果
authorId存在,应该成功创建图书。
- 如果
-
查询图书列表 :调用
GET /api/app/book。- 返回的
BookDto列表中,每个对象都应该包含AuthorId和AuthorName字段。
- 返回的
-
查询单个图书 :调用
GET /api/app/book/{id}。- 返回的
BookDto应该包含AuthorId和AuthorName字段。
- 返回的
-
获取作者下拉列表 :调用
GET /api/app/author/author-lookup。- 应该返回一个包含所有作者
Id和Name的列表。
- 应该返回一个包含所有作者
五、总结与进阶
本章详细讲解了如何在ABP框架中,基于DDD思想实现图书与作者的多对一关联关系。核心要点包括:
- 领域建模 :在聚合根(
Book)中通过AuthorId(外键)关联另一个聚合根(Author),并添加导航属性用于查询。 - 数据库映射 :使用EF Core的
HasOne.WithMany配置多对一关系,并合理设置OnDelete行为。 - 应用层处理 :
- 通过DTO传递关联ID(
AuthorId)。 - 使用AutoMapper的
ForMember自定义映射,将导航属性的字段(Author.Name)映射到DTO的扁平字段(AuthorName)。 - 重写
GetListAsync和GetAsync,使用WithDetailsAsync显式加载导航属性,避免N+1问题。 - 在创建和更新时,验证关联实体(作者)是否存在。

通过这些步骤,你不仅实现了功能,更重要的是,你实践了如何在复杂业务场景中保持领域模型的清晰度和一致性。这为你构建更大型、更可维护的应用程序打下了坚实的基础。
- 通过DTO传递关联ID(
本文首发于CSDN:[黑棠会长],转载请注明来源。
关注我,一起用轻松的方式读懂前沿科技。