ABP领域层构件分析

领域层构成

  • 实体(Entity) : 实体是种领域对象,它有自己的属性(状态,数据)和执行业务逻辑的方法.实体由唯一标识符(Id)表示,不同ID的两个实体被视为不同的实体.
  • 值对象(Value Object) : 值对象是另外一种类型的领域对象,使用值对象的属性来判断两个值对象是否相同,而非使用ID判断.如果两个值对象的属性值全部相同就被视为同一对象.值对象通常是不可变的,大多数情况下它比实体简单.
  • 聚合(Aggregate) 和 聚合根(Aggregate Root) : 聚合是由聚合根包裹在一起的一组对象(实体和值对象).聚合根是一种具有特定职责的实体.
  • 仓储(Repository) (接口): 仓储是被领域层或应用层调用的数据库持久化接口.它隐藏了DBMS的复杂性,领域层中只定义仓储接口,而非实现.
  • 领域服务(Domain Service) : 领域服务是一种无状态的服务,它依赖多个聚合(实体)或外部服务来实现该领域的核心业务逻辑.
  • 规约(Specification) : 规约是一种强命名 ,可重用 ,可组合 ,可测试的实体过滤器.
  • 领域事件(Domain Event) : 领域事件是当领域某个事件发生时,通知其它领域服务的方式,为了解耦领域服务间的依赖.

实体类

Entity类,或者实现IEntity接口。主要提供了主键类型的Id属性

复合主键

csharp 复制代码
//继承非泛型Entity类
public class UserRole : Entity
{
	//复合键由UserId和RoleId组成
    public Guid UserId { get; set; }

    public Guid RoleId { get; set; }
    
    public DateTime CreationTime { get; set; }

    public UserRole()
    {
            
    }
    //获取复合主键
    public override object[] GetKeys()
    {
        return new object[] { UserId, RoleId };
    }
}

说明

  1. 还需要配置复合主键(应该在配置表字段那里)
  2. 复合主键实体不可以使用 IRepository<TEntity, TKey> 接口,因为它需要一个唯一的Id属性. 但你可以使用 IRepository.

聚合根

说明

  1. AggregateRoot类继承自Entity类,所以默认有Id这个属性。
  2. AggregateRoot 类实现了 IHasExtraProperties 和 IHasConcurrencyStamp 接口,所以具备了两个能力 可扩展、乐观并发。如果不需要,可以继承BasicAggregateRoot(或BasicAggregateRoot)
  3. ABP 会默认为聚合根创建仓储。也可以通过配置使ABP也可以为所有的实体创建仓储
  4. 聚合根实体继承AggregateRoot类,或者直接实现IAggregateRoot接口
  5. 聚合中的非聚合根实体的构造函数定义成internal的(所以它只能由领域层来创建),然后在聚合根实体中定义创建此实体的方法
  6. 聚合内实体的所有属性都有protected的set.这是为了防止实体在实体外部任意改变.
  7. 聚合根也可以带有复合主键(方式参见上述复合主键),要使用非泛型的AggregateRoot基类.

以订单Order、商品OrderLine实体为例

csharp 复制代码
//继承AggregateRoot<TKey>类,
public class Order : AggregateRoot<Guid>
{
    public virtual string ReferenceNo { get; protected set; }

    public virtual int TotalItemCount { get; protected set; }

    public virtual DateTime CreationTime { get; protected set; }
	//它有一个OrderLine实体集合
    public virtual List<OrderLine> OrderLines { get; protected set; }

    protected Order()
    {

    }

    public Order(Guid id, string referenceNo)
    {
        Check.NotNull(referenceNo, nameof(referenceNo));
        
        Id = id;
        ReferenceNo = referenceNo;
        
        OrderLines = new List<OrderLine>();
    }
	//Order.AddProduct实现了业务规则将商品添加到订单中
    public void AddProduct(Guid productId, int count)
    {
        if (count <= 0)
        {
            throw new ArgumentException(
                "You can not add zero or negative count of products!",
                nameof(count)
            );
        }

        var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);

        if (existingLine == null)
        {
            OrderLines.Add(new OrderLine(this.Id, productId, count));
        }
        else
        {
            existingLine.ChangeCount(existingLine.Count + count);
        }

        TotalItemCount += count;
    }
}

聚合中的非聚合根实体

csharp 复制代码
//OrderLine是一个具有组合键(OrderId和 ProductId)的实体.
public class OrderLine : Entity
{
    public virtual Guid OrderId { get; protected set; }

    public virtual Guid ProductId { get; protected set; }

    public virtual int Count { get; protected set; }

    protected OrderLine()
    {

    }

    internal OrderLine(Guid orderId, Guid productId, int count)
    {
        OrderId = orderId;
        ProductId = productId;
        Count = count;
    }

    internal void ChangeCount(int newCount)
    {
        Count = newCount;
    }

    public override object[] GetKeys()
    {
        return new Object[] {OrderId, ProductId};
    }
}

审计属性

审计接口、基类

IHasCreationTime :CreationTime

IMayHaveCreator :CreatorId

ICreationAuditedObject :实现自上述两个接口,基类CreationAuditedEntity 和CreationAuditedAggregateRoot

IHasModificationTime :LastModificationTime

IModificationAuditedObject :LastModificationTime、LastModifierId

IAuditedObject :CreationTime、CreatorId、LastModificationTime、LastModifierId。基类AuditedEntity 和 AuditedAggregateRoot

ISoftDelete :IsDeleted

IHasDeletionTime :IsDeleted、DeletionTime

IDeletionAuditedObject :IsDeleted、DeletionTime、DeleterId

IFullAuditedObject :创建人、创建时间、修改人、修改时间、是否删除、删除人、删除时间。基类FullAuditedEntity and FullAuditedAggregateRoot

说明

  1. 这些基类都有非泛型版本,可以使用 AuditedEntity 和 FullAuditedAggregateRoot 来支持复合主键

值对象类

说明

  1. 值对象最好设计为不可变的,构造函数构造即可。 即不要再给类设置SetProperty方法或者将属性的Setter公开
csharp 复制代码
//值对象类要继承ValueObject类
public class Address : ValueObject
{
    public Guid CityId { get; private set; }

    public string Street { get; private set; }

    public int Number { get; private set; }

    private Address()
    {
        
    }
    
    public Address(
        Guid cityId,
        string street,
        int number)
    {
        CityId = cityId;
        Street = street;
        Number = number;
    }

	//实现 GetAtomicValues()方法来返回原始值
    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Street;
        yield return CityId;
        yield return Number;
    }
}

值对象比较

ValueObject.ValueEquals方法用于判断两个值对象是否相等

ini 复制代码
Address address1 = ...
Address address2 = ...

if (address1.ValueEquals(address2)) //Check equality
{
    ...
}

仓储

通用仓储IRepository

ABP为每个聚合根或实体提供了 默认的通用(泛型)仓储 . 你可以在服务中注入 IRepository<TEntity, TKey> 使用标准的CRUD操作。

IRepository<TEntity, TKey> 扩展了IQueryable,所以是依赖于IQueryable的

csharp 复制代码
namespace Demo
{
    public class PersonAppService : ApplicationService
    {
    	//IRepository<TEntity, TKey> 就是通用仓储
        private readonly IRepository<Person, Guid> _personRepository;

        public PersonAppService(IRepository<Person, Guid> personRepository)
        {
            _personRepository = personRepository;
        }

        public async Task CreateAsync(CreatePersonDto input)
        {
            var person = new Person(input.Name);

            await _personRepository.InsertAsync(person);
        }

        public async Task<int> GetCountAsync(string filter)
        {
            return await _personRepository.CountAsync(p => p.Name.Contains(filter));
        }
    }
}

仓储方法

  1. GetAsync:获取满足条件/指定Id的唯一实体(不存在或多条会抛异常)
  2. FindAsync:获取满足条件/指定Id的唯一实体(不存在返回null、多条会抛异常),参数2是includeDetails = true
  3. GetListAsync:获取所有/满足指定条件的实体,参数2是includeDetails = false
    1. IReadOnlyList应该不可写,不可增删项,或给某项重新赋值,但是可以更改每项的里面的内容
    2. 查找不到不会抛异常,也不会返回null,会返回一个空的list
  1. GetCountAsync:获取数据库里所有实体的数量
  2. GetPagedListAsync:返回一个指定长度的实体列表。 他拥有 skipCount、maxResultCount 、sorting 参数
  3. InsertAsync,插入实体
  4. UpdateAsync,更新实体。参数不能是null,需要判空
  5. DeleteAsync:删除实体,或删除满足条件的实体。注意,实体若含有软删除,此方法不会真正删除,而是标记为"已删除"
    1. 参数2,autoSave,是否立即保存到数据库。
  1. HardDeleteAsync:用于删除带软删除特征的实体
  2. CountAsync,满足条件的实体的数量

批量操作

  1. InsertManyAsync
  2. UpdateManyAsync:当列表容器中没有数据时,不会报错。但列表中有null时,会报错
csharp 复制代码
//不会报错
await _hotLadleRecordRepository.UpdateManyAsync(new List<HotLadleRecord>());

//会报错
await _hotLadleRecordRepository.UpdateManyAsync(new List<HotLadleRecord>() { null });
  1. DeleteManyAsync

扩展方法

  1. ContainsAsync
  2. AnyAsync、AllAsync
  3. CountAsync、LongCountAsync
  4. FirstAsync、FirstOrDefaultAsync
  5. LastAsync、LastOrDefaultAsync
  6. SingleAsync、SingleOrDefaultAsync
  7. MinAsync、MaxAsync
  8. SumAsync
  9. AverageAsync
  10. ToListAsync、ToArrayAsync

其他方法

  1. GetQueryableAsync:获取一个IQueryable对象,可以对其执行LINQ
  2. EnsureExistsAsync:根据Id/条件,确保实体存在,不存在抛异常
csharp 复制代码
namespace Demo
{
    public class PersonAppService : ApplicationService
    {
        private readonly IRepository<Person, Guid> _personRepository;

        public PersonAppService(IRepository<Person, Guid> personRepository)
        {
            _personRepository = personRepository;
        }

        public async Task<List<PersonDto>> GetListAsync(string filter)
        {
            // 获取 IQueryable<Person>
            IQueryable<Person> queryable = await _personRepository.GetQueryableAsync();

            // 创建一个查询,LINQ
            var query = from person in queryable
                where person.Name == filter
                orderby person.Name
                select person;

        	// 创建一个查询,LINQ扩展方法
            var people = queryable
                .Where(p => p.Name.Contains(filter))
                .OrderBy(p => p.Name)
                .ToList();

            // 执行查询
            var people = query.ToList();

            // 转DTO并返回给客户端
            return await people.Select(p => new PersonDto {Name = p.Name}).ToListAsync();
        }
    }
}

基础仓储:不依赖IQueryable

一般不需要管这部分,了解一下

某些ORM提供程序或数据库系统可能不支持IQueryable接口,这样就不能使用IRepository<TEntity, TKey> ,因为它是基于 IQueryable 的。

这时,可以继承或实现IBasicRepository<TEntity, TPrimaryKey> 、IBasicRepository 、BasicRepositoryBase,来自定义仓储

只读仓储

看名字,应该是只支持数据库中取数据,不能修改数据库中数据。

IReadOnlyRepository<TEntity, TKey> 与 IReadOnlyBasicRepository<Tentity, TKey>接口

无主键仓储

如果你的实体没有id主键 (例如, 它可能具有复合主键) ,则需要使用IRepository,而无法使用IRepository<TEntity, TKey>。

这个仓储里面,不支持根据Id的操作方法,因为它没有主键

自定义仓储

此处需要参考文档中的引用部分,进行扩展

1、接口

csharp 复制代码
//扩展 IRepository<Person, Guid> 以使用已有的通用仓储功能
public interface IPersonRepository : IRepository<Person, Guid>
{
    Task<Person> FindByNameAsync(string name);
}

2、实现

csharp 复制代码
public class PersonRepository : EfCoreRepository<MyDbContext, Person, Guid>, IPersonRepository
{
	//依赖于数据库访问提供程序,如dbContext
    public PersonRepository(IDbContextProvider<TestAppDbContext> dbContextProvider) 
        : base(dbContextProvider)
    {

    }

    public async Task<Person> FindByNameAsync(string name)
    {
        var dbContext = await GetDbContextAsync();
        return await dbContext.Set<Person>()
            .Where(p => p.Name == name)
            .FirstOrDefaultAsync();
    }
}

SaveChanges的替代方法

由于经常需要在插入,更新或删除实体后保存更改,相应的仓储方法有一个可选的 autoSave 参数

csharp 复制代码
public async Task<int> CreateAsync(string name)
{
    var category = new Category {Name = name};
    await _categoryRepository.InsertAsync(category, autoSave: true);
    return category.Id;
}

执行IQueryable或异步方法

1、直接引用EF Core包

Volo.Abp.EntityFrameworkCore

不推荐,会造成代码污染

2、使用IRepository异步扩展方法

如在仓储中使用 CountAsync 和 FirstOrDefaultAsync 方法

3、使用IAsyncQueryableExecuter

IAsyncQueryableExecuter 是一个用于异步执行 IQueryable 对象的服务,不依赖于实际的数据库提供程序。

可以通过构造函数注入此服务,ApplicationService 和 DomainService 基类已经预属性注入了 AsyncExecuter 属性,所以你可直接使用.

ini 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Linq;

namespace AbpDemo
{
    public class ProductAppService : ApplicationService, IProductAppService
    {
        private readonly IRepository<Product, Guid> _productRepository;
        private readonly IAsyncQueryableExecuter _asyncExecuter;

        public ProductAppService(
            IRepository<Product, Guid> productRepository,
            IAsyncQueryableExecuter asyncExecuter)
        {
            _productRepository = productRepository;
            _asyncExecuter = asyncExecuter;
        }

        public async Task<ListResultDto<ProductDto>> GetListAsync(string name)
        {
            //Obtain the IQueryable<T>
            var queryable = await _productRepository.GetQueryableAsync();
            
            //Create the query
            var query = queryable
                .Where(p => p.Name.Contains(name))
                .OrderBy(p => p.Name);

            //Run the query asynchronously
            List<Product> products = await _asyncExecuter.ToListAsync(query);

            //...
        }
    }
}

4、自定义仓储方法

领域服务

领域服务的生命周期是 瞬态 的,它们会自动注册到依赖注入服务.

下述情况,需要使用领域服务

  • 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑.
  • 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合

DomainService

从 DomainService 基类派生领域服务或直接实现 IDomainService 接口

  • ABP 框架自动将类注册为瞬态生命周期到依赖注入系统.
  • 你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如 ILogger and IGuidGenerator).
arduino 复制代码
using Volo.Abp.Domain.Services;
namespace MyProject.Issues
{
    public class IssueManager : DomainService
    {
        
    }
}

在应用服务中使用时,可以直接注入manager

和应用服务的比较

  • 应用程序服务实现应用程序的 用例 (典型 Web 应用程序中的用户交互), 而领域服务实现 核心的、用例独立的领域逻辑.
  • 应用程序服务获取/返回 数据传输对象, 领域服务方法通常获取和返回 领域对象 (实体, 值对象).
  • 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用.

规约

如下包,abp模板已经默认安装

Volo.Abp.Specifications

虽然可以使用lambda表达式替代规约,但是规约更适合下述情况

  • 可复用:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改"优质顾客"的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式.
  • 可组合:可以组合多个规约来创建新规约.这是另一种可复用性.
  • 命名:PremiumCustomerSpecification 更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约.
  • 可测试:规约是一个单独(且易于)测试的对象.

何时不要使用

  • 没有业务含义的表达式:不要对与业务无关的表达式和操作使用规约
  • 报表:如果只是创建报表,不要创建规约,而是直接使用 IQueryable 和LINQ表达式.你甚至可以使用普通SQL、视图或其他工具生成报表。DDD不关心报表,因此从性能角度来看,查询底层数据存储的方式可能很重要。

规约的使用

定义规约:创建一个由 Specification 派生的新规约类

IsSatisfiedBy 方法可以用于检查单个对象是否满足规约

ToExpression 将规约转换为表达式,规约可以与And、Or、Not以及AndNot扩展方法组合使用

csharp 复制代码
    public class Customer : AggregateRoot<Guid>
    {
        public string Name { get; set; }

        public byte Age { get; set; }

        public long Balance { get; set; }

        public string Location { get; set; }
    }

    //可以直接实现ISpecification<T>接口,但是基类Specification<T>做了大量简化.
    public class Age18PlusCustomerSpecification : Specification<Customer>
    {
        public override Expression<Func<Customer, bool>> ToExpression()
        {
        	//只需通过定义一个lambda表达式来定义规约
            return c => c.Age >= 18;

        	
        	return (customer) => (customer.Balance >= 100000);
        }

    }

    public class CustomerService : ITransientDependency
    {
        public async Task BuyAlcohol(Customer customer)
        {
        	//IsSatisfiedBy 方法可以用于检查单个对象是否满足规约
            if (!new Age18PlusCustomerSpecification().IsSatisfiedBy(customer))
            {
            	//如果顾客不满足年龄规定,则抛出异常
                throw new Exception(
                    "这位顾客不满足年龄规定!"
                );
            }
            
            //TODO...
        }
    }
csharp 复制代码
    public class CustomerManager : DomainService, ITransientDependency
    {
        private readonly IRepository<Customer, Guid> _customerRepository;

        public CustomerManager(IRepository<Customer, Guid> customerRepository)
        {
            _customerRepository = customerRepository;
        }

        public async Task<List<Customer>> GetCustomersCanBuyAlcohol()
        {
            var queryable = await _customerRepository.GetQueryableAsync();
            var query = queryable.Where(
            	//ToExpression() 方法可用于将规约转化为表达式.
            	//通过这种方式,你可以使用规约在数据库查询时过滤实体.
                new Age18PlusCustomerSpecification().ToExpression()
            );

        	//实际上,没有必要使用 ToExpression() 方法,因为规约会自动转换为表达式
            var query = queryable.Where(
                new Age18PlusCustomerSpecification()
            );
            
            return await AsyncExecuter.ToListAsync(query);
        }
    }

    public class CustomerManager : DomainService, ITransientDependency
    {
        private readonly IRepository<Customer, Guid> _customerRepository;

        public CustomerManager(IRepository<Customer, Guid> customerRepository)
        {
            _customerRepository = customerRepository;
        }

        public async Task<int> GetAdultPremiumCustomerCountAsync()
        {
            return await _customerRepository.CountAsync(
            	//规约可以与And、Or、Not以及AndNot扩展方法组合使用
                new Age18PlusCustomerSpecification()
                .And(new PremiumCustomerSpecification()).ToExpression()
            );
        }
    }

创建一个可以复用的规约,派生自AndSpecification

csharp 复制代码
public class AdultPremiumCustomerSpecification : AndSpecification<Customer>
{
    public AdultPremiumCustomerSpecification() 
        : base(new Age18PlusCustomerSpecification(),
               new PremiumCustomerSpecification())
    {
    }
}

public async Task<int> GetAdultPremiumCustomerCountAsync()
{
    return await _customerRepository.CountAsync(
        new AdultPremiumCustomerSpecification()
    );
}

项目中应用,通过规约查询冷坯、热坯

数据过滤

软删除过滤器

Abp会在查询数据库时会自动过滤软删除的实体.ISoftDelete

禁用过滤器

两种方法:方法1如下,使用时禁用。方法2通过配置过滤器选项禁用

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;

namespace Acme.BookStore
{
    public class MyBookService : ITransientDependency
    {
    	//使用 IDataFilter 服务控制数据过滤.
        private readonly IDataFilter _dataFilter;
        private readonly IRepository<Book, Guid> _bookRepository;

        public MyBookService(
            IDataFilter dataFilter,
            IRepository<Book, Guid> bookRepository)
        {
            _dataFilter = dataFilter;
            _bookRepository = bookRepository;
        }

        public async Task<List<Book>> GetAllBooksIncludingDeletedAsync()
        {
            //临时禁用 ISoftDelete 过滤器
        	//始终与 using 搭配使用,确保代码块执行后将过滤重置为之前的状态)
            using (_dataFilter.Disable<ISoftDelete>())
            {
                return await _bookRepository.GetListAsync();
            }
        }
    }
}

IDataFilter.Enable 方法可以启用过滤. 可以嵌套使用 Enable 和 Disable 方法定义内部作用域.

过滤器选项

csharp 复制代码
//AbpDataFilterOptions 用于设置数据过滤系统选项.
Configure<AbpDataFilterOptions>(options =>
{
    //禁用了 ISoftDelete 过滤
    options.DefaultStates[typeof(ISoftDelete)] = new DataFilterState(isEnabled: false);
});

自定义数据过滤

1、首先为过滤定义一个接口 (如 ISoftDelete 和 IMultiTenant) 然后用实体实现它

csharp 复制代码
//IIsActive 接口可以过滤活跃/消极数据,任何实体都可以实现它
public interface IIsActive
{
    bool IsActive { get; }
}

public class Book : AggregateRoot<Guid>, IIsActive
{
    public string Name { get; set; }

    public bool IsActive { get; set; } //Defined by IIsActive
}

2、重写你的 DbContext 的 ShouldFilterEntity 和 CreateFilterExpression 方法

csharp 复制代码
// IsActiveFilterEnabled 属性用于检查是否启用了 IIsActive
protected bool IsActiveFilterEnabled => DataFilter?.IsEnabled<IIsActive>() ?? false;

//检查给定实体是否实现 IIsActive 接口,在必要时组合表达式.
protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
{
    if (typeof(IIsActive).IsAssignableFrom(typeof(TEntity)))
    {
        return true;
    }

    return base.ShouldFilterEntity<TEntity>(entityType);
}

protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>()
{
    var expression = base.CreateFilterExpression<TEntity>();

    if (typeof(IIsActive).IsAssignableFrom(typeof(TEntity)))
    {
        Expression<Func<TEntity, bool>> isActiveFilter =
            e => !IsActiveFilterEnabled || EF.Property<bool>(e, "IsActive");
        expression = expression == null 
            ? isActiveFilter 
            : CombineExpressions(expression, isActiveFilter);
    }

    return expression;
}

如果IsActiveFilterEnabled为false,则表达式结果直接为true(即不应用IsActive过滤,所有实体都符合条件)。

如果IsActiveFilterEnabled为true,则检查实体e的"IsActive"属性值。只有当"IsActive"为true时,表达式结果才为true。

如果expression为null,则将isActiveFilter表达式赋值给expression。

如果expression不为null,则调用CombineExpressions方法来组合expression和isActiveFilter,并将结果赋值给expression。

相关推荐
码上一元2 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
枫叶_v4 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
杜杜的man5 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布5 小时前
Java中Properties的使用详解
java·开发语言·后端
2401_857610036 小时前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
杨哥带你写代码8 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_8 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
背水9 小时前
初识Spring
java·后端·spring
晴天飛 雪9 小时前
Spring Boot MySQL 分库分表
spring boot·后端·mysql
weixin_537590459 小时前
《Spring boot从入门到实战》第七章习题答案
数据库·spring boot·后端