ABP框架04.复杂业务关系实现(DDD实战)

复杂业务关系实现:图书与作者的关联设计(DDD实战)

哈喽,我是黑棠

在之前的章节中,我们分别实现了图书和作者的CRUD功能(CRUD功能(点击回顾)、权限控制(点击回顾))。

但在真实业务场景中,图书和作者是典型的多对一关系(多名作者可以写多本书,一本书只能属于一名作者)。

本章将带你基于 领域驱动设计(DDD) 思想,一步步实现这种关联关系,重点讲解如何在领域层、应用层和数据库层正确地建模和处理关联,并通过ABP框架的特性简化开发。

一、设计原则:DDD下的关联关系建模

在DDD中,关联关系的建模需要遵循以下原则:

  1. 聚合根边界 :图书(Book)和作者(Author)都是独立的聚合根,因为它们都有自己的生命周期和业务规则。
  2. 通过ID关联 :在聚合根之间,我们通常不直接引用整个实体 ,而是通过ID进行关联。这样可以保持聚合根的独立性,避免跨聚合根的强耦合。
  3. 导航属性 :为了查询方便,可以在聚合根内部添加导航属性,但这只是"查询视图",不影响领域模型的本质。
  4. 领域规则优先 :关联关系的建立必须满足领域规则(例如,图书必须属于一个已经存在的作者)。

二、领域层实现:更新实体和规则

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. 执行数据库迁移

关联关系和映射都已更新,现在我们需要将这些变更应用到数据库。

  1. 打开终端 :在ABP Studio中,右键点击Acme.BookStore.EntityFrameworkCore项目 → 选择"Open Terminal"。

  2. 创建迁移

    powershell 复制代码
    dotnet ef migrations add Added_Author_Relation_To_Book
  3. 应用迁移

    powershell 复制代码
    dotnet run --project src/Acme.BookStore.DbMigrator

    或者使用EF Core命令直接更新数据库(不推荐用于生产环境,DbMigrator项目是ABP推荐的方式):

    powershell 复制代码
    dotnet 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; }
    // ##########################################
}

关键说明

  • 我们添加了AuthorIdAuthorNameAuthorName是一个"派生"字段,它不是直接存储在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>();
    }
}

关键说明

  • ForMemberMapFrom :这是AutoMapper的高级用法,用于处理复杂的映射关系。我们明确告诉AutoMapper,BookDtoAuthorName字段应该来自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);
    }
}

关键说明

  1. 注入IRepository<Author, Guid> :为了验证传入的AuthorId是否有效,我们需要查询作者仓储。
  2. CreateAsyncUpdateAsync中的验证 :在创建或更新图书之前,我们先检查input.AuthorId对应的作者是否存在。如果不存在,就抛出一个BusinessException。这是非常重要的领域规则验证
  3. GetListAsyncGetAsync中的WithDetailsAsync :这是ABP框架提供的一个非常方便的扩展方法。它用于在查询时显式加载 指定的导航属性(b => b.Author),以避免著名的"N+1查询问题"。只有这样,我们在AutoMapper映射时才能拿到Author的信息。
    • 注意 :ABP的CrudAppService基类在默认情况下不会自动加载导航属性,所以我们需要重写GetListAsyncGetAsync方法来实现这一点。

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),测试以下接口:

  1. 创建图书 :调用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存在,应该成功创建图书。
  2. 查询图书列表 :调用GET /api/app/book

    • 返回的BookDto列表中,每个对象都应该包含AuthorIdAuthorName字段。
  3. 查询单个图书 :调用GET /api/app/book/{id}

    • 返回的BookDto应该包含AuthorIdAuthorName字段。
  4. 获取作者下拉列表 :调用GET /api/app/author/author-lookup

    • 应该返回一个包含所有作者IdName的列表。

五、总结与进阶

本章详细讲解了如何在ABP框架中,基于DDD思想实现图书与作者的多对一关联关系。核心要点包括:

  • 领域建模 :在聚合根(Book)中通过AuthorId(外键)关联另一个聚合根(Author),并添加导航属性用于查询。
  • 数据库映射 :使用EF Core的HasOne.WithMany配置多对一关系,并合理设置OnDelete行为。
  • 应用层处理
    • 通过DTO传递关联ID(AuthorId)。
    • 使用AutoMapper的ForMember自定义映射,将导航属性的字段(Author.Name)映射到DTO的扁平字段(AuthorName)。
    • 重写GetListAsyncGetAsync,使用WithDetailsAsync显式加载导航属性,避免N+1问题。
    • 在创建和更新时,验证关联实体(作者)是否存在。

      通过这些步骤,你不仅实现了功能,更重要的是,你实践了如何在复杂业务场景中保持领域模型的清晰度和一致性。这为你构建更大型、更可维护的应用程序打下了坚实的基础。

本文首发于CSDN:[黑棠会长],转载请注明来源。

关注我,一起用轻松的方式读懂前沿科技。

相关推荐
鸽芷咕2 小时前
KingbaseES 时序数据库:国产化替代浪潮下的技术突围与实践路径
数据库·sql·时序数据库·金仓数据库
MonkeyBananas2 小时前
VS 中创建并安装.NET Framework Windows 服务教程
windows·.net
阿蒙Amon2 小时前
C#每日面试题-简述类型实例化底层过程
java·面试·c#
a2155833202 小时前
oracle 修改字符集
数据库·oracle
難釋懷2 小时前
基于Redis实现短信登录
数据库·redis·缓存
OnYoung2 小时前
实战:用OpenCV和Python进行人脸识别
jvm·数据库·python
明天…ling2 小时前
sql注入笔记总结
java·数据库·sql
qq_417129252 小时前
Python深度学习入门:TensorFlow 2.0/Keras实战
jvm·数据库·python
fengfuyao9852 小时前
C#实现指纹识别
开发语言·c#