资讯详情

[Skr-Shop]购物车之架构设计

skr shop是一群底层码农,因为工作中项目折磨的精神失常,程序员的骄傲:别人设计的系统都是一坨shit,我的设计是宇宙中最强大的,所以我决定制作一只设计不编码的电子商务设计手册。

上一篇文章 购物车设计需求分析 描述了购物车的一般需求。本文的重点是如何实现架构设计(业务) 系统架构)。

说明

架构设计可分为三个层次:

  • 业务架构

  • 系统架构

  • 技术架构

快速简单地解释下三个结构的含义;当我们得到购物车的需求时,我们说Golang实现,存储Redis;这描述了技术架构;我们对购物车代码项目进行代码分层、设计规范和依赖系统规划,称为系统架构;

什么是商业结构?商业结构本质上是对系统结构的文本和语言描述;这是什么意思?当我们得到需求时,我们必须首先与需求方沟通,建立统一的认知。例如:标准术语(购物车中的商品与商品系统中的商品的含义不同);建立每个人都能理解的模型,购物车、用户、商品和订单之间的互动,以及它们各自的功能。

业务架构分析有很多方法,比如领域驱动设计,但不是唯一的业务架构分析方法,也不是最好的。适合你的是最好的。我们常用的物理关系图UML图也属于业务架构领域;

这里需要强点的是,无论你如何建模设计,有设计总比没有设计好。其次,你必须在代码中反映建模的内容。

借助对业务结构的分析 DDD (领域驱动设计)思想;或者那句话适合的就是最好的

业务架构

通过以往的需求分析,我们已经明确了我们的购物车应该做什么。让我们来看看一个典型的用户操作购物车的过程。

17d3edce1da24cec30369817e1d4d07f.png

用户旅程

在此过程中,用户使用购物车载体完成商品的购买过程;流动数据是商品,购物车载体稳定。这是我们系统中的稳定点和变化点。

商品的流动方式可能多种多样,如从不同的地方加入购物车,以不同的方式加入购物车,购物车的生命周期不同;但这个过程是稳定的,必须让购物车存在商品,然后结算订单。

购物车中商品的生命周期如下:

过程

按照这个过程,我们来看看每个阶段的相应操作。

操作过程对应

这里需要注意的是,我们可以把这个操作放在购物车的添加操作中,但因为这部分非常不稳定和多变。我们将其独立,方便后续扩展,不影响相对稳定的购物车阶段。

根据以上三个阶段,DDD概念,应该被称为实体,它们构成了购物车的整体领域;今天我们不谈论这些概念,先跳过,然后有机会单独解释。

加车前

通过流程分析,我们总结了系统所需的操作接口和这些接口对应的实体。现在我们来看看加车前主要做什么;

事实上,在加车前,主要是对准备加入的购物车商品进行各纬度检查,检查是否符合要求。

在让用户加车之前,我们首先解决的是用户在哪里销售,然后进行验证?因为从不同的渠道购买相同的商品有不同的情况,比如小米手机,我们是通过秒杀买,还是通过朋友众筹买,还是直接在商场买,价格不同,但实际上他是同一种商品;

第二个问题是否具备购买资格,或者上面提到的,不是每个人都可以添加秒杀和众筹的加车操作,而是具备现有资格。那么资格考试也放在这里;

第三个问题是验证购买商品的商品属性,如是否上下架、库存、限购数量等。

而且你会发现这里的验证条件可能很多变。如何构建方便扩展的代码?

加车的验证

在整个加车过程中,根据来源区分不同的验证是很重要的。我们有两种选择。

方法一:通过战略模式 门面模式的方式来搞定。策略就是根据不同的加车来源进行不同的验证,门面就是根据不同的来源封装一个个策略;

方法2:通过责任链模式,但这里需要改变。在执行过程中,该链可以选择跳过某些节点,如无库存或众筹验证的秒杀;

通过综合分析,我选择了责任链模式。粘贴核心代码

// 每个验证逻辑要实现的接口 type Handler interface {  Skipped(in interface{}) bool // 判断是否跳过  HandleRequest(in interface{}) error // 各种验证都在这里进行 }   // 责任链节点 type RequestChain struct {  Handler  Next *RequestChain }   // 设置handler func (h *RequestChain) SetNextHandler(in *RequestChain) *RequestChain {  h.Next = in  return in }

关于设计模式,你可以看看我的朋友github:https://github.com/TIGERB/easy-tips/tree/master/go/src/patterns

购物车

在加车之前,现在让我们来看看购物车。正如我们之前讨论过的,购物车可能有多种形式,如存储多种商品一起结算,立即结算。因此,购物车必须根据渠道选择购物车的类型。

这部分操作相对稳定。让我们选择一些更重要的操作来谈谈我们的想法。

加入购物车

通过前置条件验证,我们会发现这部分逻辑在加车时变得非常轻。要做的主要是以下部分的逻辑。

加入购物车

这里有几个聪明的地方,首先是获取商品的逻辑,因为它也将用于前面的验证,所以前面的访问将继续通过参数传输,所以这里不需要在图书馆或呼叫服务;

其次,我们需要获取当前用户现有的购物车数据,然后添加添加的产品。这是一个类似的合并操作,原始产品存在,相当于一个数量;需要注意产品是否与现有产品有父子关系,加入后是否有可能改变活动规则,如:原购买2送1礼品,现添加3,送2礼品;

注:这里的添加不是直接在购物车上更改数量,而是直接在列表和详细信息页面上添加。

通过将合并后的购物车数据,通过营销活动检查确认ok之后,直接回写到存储中。

合并购物车

为什么会有合并购物车的操作?由于一般电子商务允许游客身份操作,用户登录后需要合并。

这里合并的许多部分的逻辑是添加购物车重用的逻辑。例如,合并后的数据需要检查是否合法,然后重复存储。所以你可以看到这里的相关性。设计方法在某种程度上应该是通用的。

购物车列表

购物车列表这是一个非常重要的界面,原则上购物车界面会提供两种类型,一种是简版,一种是完整版;

简版列表界面主要用于类似的接口PC获取主页右上角等简单信息;完整版本将用于购物列表。

在实践中,购物车不仅仅是一个读取界面。因为我们都知道商品信息和活动信息都在不断变化。因此,每个读取界面都必须检查当前购物车中数据的合法性,然后在发现不一致后重复原始存储的数据。

购物车列表

还有一些方法可以在每个接口中检查数据的合法性。我建议,为了性能考虑,一些接口可以适当地放宽检查,然后在获得列表时进行完整的检查。例如,添加接口,我只会测试我添加的商品的合法性,永远不会检查整个购物车。因为列表操作通常在操作后调用,所以此时将进行验证,两者重复操作,所以只取后者。

结算

结算包括两部分:结算页面的详细信息和提交订单。结算页面可以说是购物车列表上的一个包装,因为结算页面和列表页面之间最大的区别是用户需要选择分销地址(虚拟商品),这将产生更清晰的价格信息,其他基本相同。因此,在设计购物车列表接口时,必须考虑足够的通用性。

这里需要注意的另一件事是:立即购买,我们也将通过结算页面接口实现,但内部仍将调用添加接口,将商品添加到购物车中;有三点需要注意。首先,添加操作在服务内部完成,服务调用器不需要感知添加操作的存在;其次,购物车在Redis中的Key独立于普通购物车,否则,两者之间的货物耦合在一起非常困难;最后,立即购买的购物车应考虑账户多终端登录,数据不能相互影响,这里可以使用每个端uuid作为购物车的标志,避免这种情况

购物车的最后一步是生成订单,这一步最要紧的是需要给购物车加,避免提交过程中数据被篡改,多说一句,很多人写的Redis分布式锁代码都存在缺陷,大家一定要注意原子性的问题,这类文章网络上很多不再赘述。

加锁成功之后,我们这里有多种做法,一种是按照DB涉及组织数据开始写表,这适用于业务量要求不大,比如订单每秒下单量不超过2000K的;那如果你的系统并发要求非常高怎么办?

其实也很简单,高性能的三大法宝之一:异步;我们提交的时候直接将数据快照写入MQ中,然后通过异步的方式进行消费处理,可以通过通过控制消费者的数量来提升处理能力。这种方法虽然性能提升,但是复杂度也会上升,大家需要根据自己的实际情况来选择。

关于业务架构的设计,到此告一段落,接下来我们来看系统架构。

系统架构

系统结构主要包含,如何将业务架构映射过来,以及输出对应输入参数、输出参数的说明。由于输入、输出针对各自业务来确定的,而且没有什么难度,我们这里就只说如何将业务架构映射到系统架构,以及系统架构中最核心的Redis数据结构选择以及存储的数据结构设计。

代码结构

下面的代码目录是按照 Golang 来进行设计的。我们来看看如何将上面的业务架构映射到代码层面来。

├── addproducts.go
├── cartlist.go
├── mergecart.go
├── entity
│   ├── cart
│   │   ├── add.go
│   │   ├── cart.go
│   │   └── list.go
│   ├── order
│   │   ├── checkout.go
│   │   ├── order.go
│   │   └── submit.go
│   └── precart
├── event
│   └── sendorder.go
├── facade
│   ├── activity.go
│   └── product.go
└── repo

外层有 entityeventfacaderepo这四个目录,职责如下:

: 存放的是我们前面分析的购物领域的三个实体;所有主要的操作都在这三个实体上;

: 这是用来处理产生的事件,比如刚刚说的如果我们提交订单采用异步的方式,那么该目录就该完成的是如何把数据发送到MQ中去;

: 这儿目录是干嘛的呢?这主要是因为我们的服务还需要依赖像商品、营销活动这些服务,那么我们不应该在实体中直接调用它,因为第三方可能存在变动,或者有增加、减少,我们在这里进行以下简单的封装(设计模式中的门面模式);

: 这个目录从某种程度上可以理解为 Model层,在整个领域服务中,如果与持久化打交道,都通过它来完成。

最后外层的几个文件,就是我们所提供的领域服务,供应用层来进行调用的。

为了保证内容的紧凑,我这里放弃了对整个微服务的目录介绍,只单独介绍了领域服务,后续会单独成文介绍下微服务的整个系统架构。

通过上面的划分,我们完成了两件事情:

  1. 业务架构分析的结构在系统代码中都有映射,他们彼此体现。这样最大的好处是,保证设计与代码的一致性,看了文档你就知道对应的代码在哪里;

  2. 每个目录各自的关注点都进行了分离,更内聚,更容易开发与维护。

Redis存储

现在来看,我们选择Redis作为购物商品数据的存储,我们要解决两个问题,一是我们需要存哪些数据?二是我们用什么结构来存?

网络上很多写购物车的都是只保存一个商品id,真实场景是很难满足需求的。你想想,一个商品id如何记住用户选择的赠品?用户上次选择的活动?以及购买的商品渠道?

综合比较通用的场景,我给出一个参考结构:

// 购物车数据
type ShoppingData struct {
  Item       []*Item `json:"item"`
  UpdateTime int64   `json:"update_time"`
  Version    int32   `json:"version"`
}


// 单个商品item元素
type Item struct {
  ItemId       string          `json:"item_id"`
  ParentItemId string          `json:"parent_item_id,omitempty"` // 绑定的父item id
  OrderId      string          `json:"order_id,omitempty"`       // 绑定的订单号
  Sku          int64           `json:"sku"`
  Spu          int64           `json:"spu"`
  Channel      string          `json:"channel"`
  Num          int32           `json:"num"`
  Status       int32           `json:"status"`
  TTL          int32           `json:"ttl"`                     // 有效时间
  SalePrice    float64         `json:"sale_price"`              // 记录加车时候的销售价格
  SpecialPrice float64         `json:"special_price,omitempty"` // 指定价格加购物车
  PostFree     bool            `json:"post_free,omitempty"`     // 是否免邮
  Activities   []*ItemActivity `json:"activities,omitempty"`    // 参加的活动记录
  AddTime      int64           `json:"add_time"`
  UpdateTime   int64           `json:"update_time"`
}


// 活动
type ItemActivity struct {
  ActID    string `json:"act_id"`
  ActType  string `json:"act_type"`
  ActTitle string `json:"act_title"`
}

重点说一下 Item 这个结构,item_id 这个字段是标记购物车中某个商品的唯一标记,因为我们之前说过,同一个sku由于渠道不同,那么在购物车中会是两个不同的item;接下来的 parent_item_id 字段是用来标记父子关系的,这里将可能存在的树结构转成了顺序结构,我们不管是父商品还是子商品,都采用顺序存储,然后通过这个字段来进行关联;有些同学可能会奇怪,为什么会存order id这个字段呢?大家关注下自己的日常业务,比如:再来一单、定金预售等,这种一定是与某个订单相关联的,不管是为了资格验证还是数据统计。剩下的字段都是一些非常常规的字段,就不在一一介绍了;

字段的类型,大家根据自己的需要进行修改。

接下来该说怎么选择Redis的存储结构了,Redis常用的 Hash Table、集合、有序集合、链表、字符串 五种,我们一个个来分析。

首先购车一定有一个key来标记这个购物车属于哪个用户的,为了简化,我们的key假设是:uid:cart_type

我们先来看如果用 Hash Table;我们添加时,需要用到如下命令:HSET uid:cart_type sku ShoppingData;看起来没问题,我们可以根据sku快速定位某个商品然后进行相关的修改等,但是注意,ShoppingData是一个json串,如果用户购物车中有非常多的商品,我们用 HGETALL uid:cart_type 获取到的时间复杂度是O(n),然后代码中还需要一一反序列化,又是O(n)的复杂度。

如果用集合,也会遇到类似的问题,每个购物车看做一个集合,集合中的每个元素是 ShoppingData ,取到代码中依然需要逐一反序列化(反序列化是成本),关于有序集合与链表就不在分析,大家可以按照上面的思路去尝试下问题所在。

看起来我们没得选,只有使用String,那我们来看一下String的契合度是什么样子。首先SET uid:cart_type ShoppingDataArr;我们把购物车所有的数据序列化成一个字符串存储,每次取出来的时间复杂度是O(1),序列化、反序列化都只需要一次。看来是非常不错的选择。但是在使用中大家还是有几点需要注意。

  1. 单个Value不能太大,要不然就会出现大key问题,所以一般购物车有上限限制,比如item不能超过多少个;

  2. 对redis的操作性能提升上来了,但是代码的就是修改单个item时的不便,必须每次读取全部然后找到对应的item进行修改;这里我们可以把从redis中的数据读取出来后,在内存中构建一个HashTable,来减少每次遍历的复杂度;

网上也看到很多Redis数据结构组合使用来保存购物车数据的,但是无疑增加了网络开销,相比起来还是String最经济划算。

总结

至此对于购物车的实现设计算是完结了,其中关于订单表的设计会单独放到订单模块去讲。

对于整个购物车服务,虽然没有写的详细到某个具体的接口,但是分析到这一步,我相信大家心中都是有沟壑的,能够结合自己的业务去实现它。

文中有些很有意思的地方,建议大家动手去做做看,有任何问题,我们随时交流。

  • 改编版的责任链模式

  • Redis的分布式事务锁实现

接下来终于要到订单部分的设计了,希望大家继续关注我们。

标签: skr内置弹簧自复位位移传感器

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台