Spiga

云原生电商微服务实战4:品类微服务

2024-10-03 14:48:20

用户服务开发好之后,本来是要先介绍认证中心的。由于写了几篇了还没有看到电商业务的痕迹,于是今天先介绍品类微服务,做一期业务方面的设计与实现,下回再介绍认证中心。

一、品类与品牌

任何一个电商平台,品类是最先存在的一个业务功能。比如京东它最开始的定位就一个家电行业的垂直电商,后来最大了才有更多的其他品类。而现实中,可能会真正存在一个专门的品类管理处的部门,他们的职责可能是研究公司战略方向的。品类微服务不仅仅只是一个名称,它可能有专门的单独品类首页,有专门的营销策略,商品的存活的、推荐等等,因而品类对于电商业务来说是非常重要的。

品牌决定了电商平台具体卖什么东西,不同的平台品牌同意可能有不同的业务。比如营销方式、合作模式、分成方式等等。

对电商平台访问者来说,他们可能只是搜索商品,选择满意的商品后就下单了。他们知道品类和品牌的存在,但不会考虑到品类和品牌还有那么多具体的后台业务逻辑。而对于电商平台来说,品类和品牌是真正存在的可能还是非常复杂的业务。

接着我们来访问一下京东网站:

  • 可以看到左侧有一个很大的品类选项。展开品类后又有二级或三级选择。随便点击一个三级品类选项后,可以看到URL地址:https://list.jd.com/list.html?cat=652,654,834 cat对应的值就是一个三级分类,分别提供了3个id值。

  • 接着我们再在搜索框上随便搜个结果,可以看到品牌的筛选筛选项,品牌下面还会动态出现一些跟搜索的内容相关参数的筛选项,这些选择项都是动态的。比如我们搜索手机,出现的选择项是CPU、内存,电池,而我们搜索衣服时,出现的选择项变成了颜色、尺码、适用年龄。

根据分析京东的页面,我们大致可以了解到几个信息:

  1. 京东的品类有3级分类。

  2. 选择具体的品类后,才有关联的品牌。比如选择衣服后才有衣服对应的品牌,选择手机后才有手机的品牌。

  3. 筛选项是根据选择的品类后,动态出现的,也就是说筛选项会根据不同的品类有不同的内容,甚至还有高级选项。

于是我们就有了电商平台第一个业务相关的微服务品类微服务:

  • 为什么品类服务要单独出来了,合并到商品服务里面不行吗?

    微服务的划分没有标准答案,业务不是很复杂时,可以把品类服务合并到商品服务里面。而我们这里单独出来,是因为电商业务最终可能需要按不同品类做不同的分析报表,单独的品类微服务以后更容易独立出来做分析。

  • 品牌服务为什么又不单独分出来了呢?

    实际中品牌服务也是可以独立微服务化的,我们这里至少电商学习案例。品牌服务就跟品类服务合并了,就不把与商品分类属性等相关的内容搞得太复杂了,有一个微服务案例即可。

接着我们再看一下品类微服务的设计。

二、品类微服务

  1. 实现基础品类服务,先看代码

    //领域层
    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读取。

  2. 品类参数的实现,接着我们来看品类参数的实现

    //参数分组
    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类中也有缓存的实现,原理和前面介绍品类是相对稳定的数据一样,参数也同样是相对稳定的。

  3. 品牌的实现,我们这里品牌服务就比较简单了,数据就是一个简单的品牌名称而已,实现也就是基础的CURD,我们就略过了。

  4. 品类微服务还有一个很重要的领域类,叫做规格类,这个类等到将商品微服务的时候再来介绍。

  5. 品类微服务主要的功能基本就实现了,最后我们插入一下基础数据,执行Migrations/Scripts下面的脚本。

  6. 与用户微服务不同的是,品类微服务的领域层实体的设计是贫血模型,这里并没有像用户实体那样将字段的set方法都设置成私有方法。

    • 我们在设计时并不一定需要统一去使用一套设计理念。使用充血还是贫血模型,主要还是看业务的需求。
    • 用户属性我们不需要在领域层以为的地方随意修改,甚至每次用户属性的修改都还希望能有变更记录,这类数据通常我们可以设计成充血模型,每个属性的变化都是一个具体的业务方法。这样数据更安全,也更好维护。
    • 我们这里的品类微服务目前的业务规则相对来说就比较简单了,基本就是一些CURD的操作。数据的变化历史也不是那么重要,这种业务的实现就可以放宽限制。用例层可以任意操作数据,数据库保存最后的数据状态即可。所以我们把品类微服务的实体类设计成贫血模型。