云原生电商微服务实战4:品类微服务
2024-10-03 14:48:20用户服务开发好之后,本来是要先介绍认证中心的。由于写了几篇了还没有看到电商业务的痕迹,于是今天先介绍品类微服务,做一期业务方面的设计与实现,下回再介绍认证中心。
一、品类与品牌
任何一个电商平台,品类是最先存在的一个业务功能。比如京东它最开始的定位就一个家电行业的垂直电商,后来最大了才有更多的其他品类。而现实中,可能会真正存在一个专门的品类管理处的部门,他们的职责可能是研究公司战略方向的。品类微服务不仅仅只是一个名称,它可能有专门的单独品类首页,有专门的营销策略,商品的存活的、推荐等等,因而品类对于电商业务来说是非常重要的。
品牌决定了电商平台具体卖什么东西,不同的平台品牌同意可能有不同的业务。比如营销方式、合作模式、分成方式等等。
对电商平台访问者来说,他们可能只是搜索商品,选择满意的商品后就下单了。他们知道品类和品牌的存在,但不会考虑到品类和品牌还有那么多具体的后台业务逻辑。而对于电商平台来说,品类和品牌是真正存在的可能还是非常复杂的业务。
接着我们来访问一下京东网站:
-
可以看到左侧有一个很大的品类选项。展开品类后又有二级或三级选择。随便点击一个三级品类选项后,可以看到URL地址:https://list.jd.com/list.html?cat=652,654,834 cat对应的值就是一个三级分类,分别提供了3个id值。
-
接着我们再在搜索框上随便搜个结果,可以看到品牌的筛选筛选项,品牌下面还会动态出现一些跟搜索的内容相关参数的筛选项,这些选择项都是动态的。比如我们搜索手机,出现的选择项是CPU、内存,电池,而我们搜索衣服时,出现的选择项变成了颜色、尺码、适用年龄。
根据分析京东的页面,我们大致可以了解到几个信息:
-
京东的品类有3级分类。
-
选择具体的品类后,才有关联的品牌。比如选择衣服后才有衣服对应的品牌,选择手机后才有手机的品牌。
-
筛选项是根据选择的品类后,动态出现的,也就是说筛选项会根据不同的品类有不同的内容,甚至还有高级选项。
于是我们就有了电商平台第一个业务相关的微服务品类微服务:
-
为什么品类服务要单独出来了,合并到商品服务里面不行吗?
微服务的划分没有标准答案,业务不是很复杂时,可以把品类服务合并到商品服务里面。而我们这里单独出来,是因为电商业务最终可能需要按不同品类做不同的分析报表,单独的品类微服务以后更容易独立出来做分析。
-
品牌服务为什么又不单独分出来了呢?
实际中品牌服务也是可以独立微服务化的,我们这里至少电商学习案例。品牌服务就跟品类服务合并了,就不把与商品分类属性等相关的内容搞得太复杂了,有一个微服务案例即可。
接着我们再看一下品类微服务的设计。
二、品类微服务
-
实现基础品类服务,先看代码
//领域层 public class Category : AuditWithUserEntity, IAggregateRoot { public string Name { get; set; } = null!; public long ParentId { get; set; } public bool IsParent { get; set; } public int Sort { get; set; } public ICollection<CategoryBrand>? CategoryBrands { get; set; } } public class CategoryBrand : BaseAuditEntity { public long CategoryId { get; set; } public long BrandId { get; set; } } //数据映射 public class CategoryBrandConfiguration : IEntityTypeConfiguration<CategoryBrand> { public void Configure(EntityTypeBuilder<CategoryBrand> builder) { builder.ToTable("tb_category_brand"); builder.Property(e => e.Id) .HasColumnType("bigint(20)"); builder.Property(e => e.CategoryId) .HasColumnType("bigint(20)") .HasComment("品类id"); builder.Property(e => e.BrandId) .HasColumnType("bigint(20)") .HasComment("品牌id"); // 联合唯一约束 builder.HasIndex(e => new { e.CategoryId, e.BrandId }) .IsUnique(); } } //获取分类及其所有父类 Handler public class GetCategoryAndParentsQueryHandler(CategoryDbContext dbContext, IFusionCache cache) : IQueryHandler<GetCategoryAndParentsQuery, Result<List<CategoryDto>>> { public async Task<Result<List<CategoryDto>>> Handle(GetCategoryAndParentsQuery request, CancellationToken cancellationToken) { // 从缓存中获取所有品类 var allCategories = await cache.GetOrSetAsync($"{nameof(Category)}", async token => await dbContext.Categories.AsNoTracking().ToListAsync(token), options => options.SetDurationInfinite(), //长期缓存 token: cancellationToken); var category = allCategories.FirstOrDefault(c => c.Id == request.Id); if (category is null) return Result.NotFound(); var categoryDtos = new List<CategoryDto>(); // 添加目标类别 categoryDtos.Add(new CategoryDto(category.Id, category.Name)); // 递归查找所有父类 var categoryLookup = allCategories.ToDictionary(c => c.Id, c => c); var currentCategoryId = request.Id; while (categoryLookup.TryGetValue(currentCategoryId, out var currentCategory)) { var parentCategoryId = currentCategory.ParentId; if (categoryLookup.TryGetValue(parentCategoryId, out var parentCategory)) { categoryDtos.Insert(0, new CategoryDto(parentCategory.Id, parentCategory.Name)); currentCategoryId = parentCategoryId; } else { break; } } return Result.Success(categoryDtos); } }
-
有了品类才能选择品牌,但同一个品牌可能会有多中品类的产品。比如小米既是手机品类的品牌,又有是一些生活电器的品牌。因此品类的领域实体有品类类,同时还有品类与品牌关系类。
-
同一个品类下,同品牌肯定只有一个,我们需要建立这个业务约束。所以我们在CategoryBrandConfiguration类下有代码
builder.HasIndex(e => new { e.CategoryId, e.BrandId }).IsUnique();
-
GetCategoryAndParentsQueryHandler是具体的查询业务获取分类及其所有父类的实现,这里单独贴出这个方法是因为这个业务后存在一个递归关系。也就是说访问者访问网站时一般会选择最末级的一个分类,而页面上可能需要从最末级的分类依次去查找它的父级分类信息。
如果单纯从业务规则去写代码,开发者可能会在一次查询返回结果后,继续写递归查询,这将是一个非常危险的信号。
由于品类在电商平台业务中,它的数据是相对稳定的,一般电商平台确定后很少去改变品类。而品类数据又是频繁被访问的,因此这类数据最适合的就是长期缓存起来,所以我们在GetCategoryAndParentsQueryHandler中看到缓存的实现,options.SetDurationInfinite()意思是缓存长期有效。数据缓存后,再在内存中递归就没有性能问题了。
我们这里使用的是IFusionCache来实现的缓存,它的规则是先从本地内存读取缓存对象,如果没有时再去redis读取。
-
-
品类参数的实现,接着我们来看品类参数的实现
//参数分组 public class ParameterGroup : AuditWithUserEntity { public string Name { get; set; } = null!; public long CategoryId { get; set; } public ICollection<ParameterKey>? ParameterKeys { get; set; } } //参数键 public class ParameterKey : AuditWithUserEntity { public string Name { get; set; } = null!; public long ParameterGroupId { get; set; } public ParameterGroup? ParameterGroup { get; set; } public long CategoryId { get; set; } } //数据映射 public class ParameterKeyConfiguration : IEntityTypeConfiguration<ParameterKey> { public void Configure(EntityTypeBuilder<ParameterKey> builder) { builder.ToTable("tb_param_key"); builder.Property(s => s.Id) .HasColumnType("bigint(20)") .HasComment("参数Id"); builder.Property(s => s.Name) .IsRequired() .HasColumnType("varchar(256)") .HasComment("参数名称"); builder.Property(s => s.ParameterGroupId) .HasColumnType("bigint(20)") .HasComment("所属分组"); builder.Property(s => s.CategoryId) .HasColumnType("bigint(20)") .HasComment("所属分类"); builder.HasOne(s => s.ParameterGroup) .WithMany(s => s.ParameterKeys); builder.HasIndex(s => s.CategoryId); } } //获取分类下的参数 public class GetParemetersByCategoryQueryHandler(CategoryDbContext dbContext, IFusionCache cache) : IQueryHandler<GetParemetersByCategoryQuery, Result<List<ParameterGroupDto>>> { public async Task<Result<List<ParameterGroupDto>>> Handle(GetParemetersByCategoryQuery request, CancellationToken cancellationToken) { // 从缓存中获取参数 var key = $"{nameof(ParameterKey)}:{request.CategoryId}"; var parameterDto = await cache.GetOrSetAsync<List<ParameterGroupDto>?>(key, async token => { var parameterKeys = await dbContext.ParameterGroups.AsNoTracking() .Include(group => group.ParameterKeys) .Where(group => group.CategoryId == request.CategoryId) .ToListAsync(token); return parameterKeys.Select(sg => new ParameterGroupDto { Id = sg.Id, Name = sg.Name, ParameterKeys = sg.ParameterKeys?.Select(s => new ParameterKeyDto(s.Id, s.Name)).ToList() }).ToList(); }, options => options.SetDurationInfinite(), token: cancellationToken); if (parameterDto is null || parameterDto.Count == 0) return Result.NotFound(); return Result.Success(parameterDto); } }
-
小型电商平台设计ParameterKey实体可能就满足业务了,京东是大型的电商平台,不同品类的参数可能也很多,因此可以将参数进行分组。我们这里就同时设计了ParameterGroup和ParameterKey。
-
关于数据库是否需要设计外键,这个可能各有各的说法。这里我说一下我的观念:
是否需要设计外键,主要要考虑的就是性能问题,设计了外键可能会带来性能上的开销。但在数据库的设计中安全>性能,外键是数据约束规则,它是一个安全项。在安全和性能都需要考虑的情况下,优先考虑的是安全。除非业务确实有高并发的要求,或者有最终一致性的要求,才去考虑取消外键约束。
当然,并不是所有非高并发或最终一致性的关联数据都去考虑外键。只有业务关系在同一个聚合类,而且是密切相关,数据都是同时出现的时候,这样的外键约束是可以添加的。我们这里ParameterGroup和ParameterKey就添加了导航数据,同时数据库会自己生成外键约束。
builder.HasOne(s => s.ParameterGroup).WithMany(s => s.ParameterKeys);
-
GetParemetersByCategoryQueryHandler类中也有缓存的实现,原理和前面介绍品类是相对稳定的数据一样,参数也同样是相对稳定的。
-
-
品牌的实现,我们这里品牌服务就比较简单了,数据就是一个简单的品牌名称而已,实现也就是基础的CURD,我们就略过了。
-
品类微服务还有一个很重要的领域类,叫做规格类,这个类等到将商品微服务的时候再来介绍。
-
品类微服务主要的功能基本就实现了,最后我们插入一下基础数据,执行Migrations/Scripts下面的脚本。
-
与用户微服务不同的是,品类微服务的领域层实体的设计是贫血模型,这里并没有像用户实体那样将字段的set方法都设置成私有方法。
- 我们在设计时并不一定需要统一去使用一套设计理念。使用充血还是贫血模型,主要还是看业务的需求。
- 用户属性我们不需要在领域层以为的地方随意修改,甚至每次用户属性的修改都还希望能有变更记录,这类数据通常我们可以设计成充血模型,每个属性的变化都是一个具体的业务方法。这样数据更安全,也更好维护。
- 我们这里的品类微服务目前的业务规则相对来说就比较简单了,基本就是一些CURD的操作。数据的变化历史也不是那么重要,这种业务的实现就可以放宽限制。用例层可以任意操作数据,数据库保存最后的数据状态即可。所以我们把品类微服务的实体类设计成贫血模型。