购物车架构与需求分析
# 购物车的架构与需求分析
## 需求分析
### 在线购物车和离线购物车
#### 离线购物车的定义
> 离线购物车其实是JD早期的功能, 离线购物车就是在我们没有登陆的情况下, 即游客状态, 我们可以将商品加入购物车, 此时的购物车就是离线购物车, **就算我们关闭浏览器, 购物车的内容仍然存在**
#### 在线购物车的定义
> 与离线购物车定义相悖的就是在线购物车, 即我们在登录状态下, 将商品加入的购物车就是在线购物车
#### 联系
> 如果我们是游客状态, 离线购物车的内容可以一直保持, 无论是否关闭浏览器, 一旦我们登录, 需要将离线购物车的数据合并到在线购物车, 并且将离线购物车的内容清空
#### 购物车的功能
- 用户可以使用购物车一起结算下单
- 给购物车添加商品
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 选中不选中商品
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
> **简称CRUD**
## 技术选型
### 在线购物车的技术选型
#### 在线购物车的性质
> 在线购物车最大的特点是**读写频繁**, 即需要频繁的读取数据和更新数据, 而且数据需要**持久化**
#### 权衡利弊
> 根据读写频繁的特性, 由以下集中方案
1. 采用MySQL
> 采用MySQL主要是基于持久化的方面, 但是MySQL有一个致命的缺点, 即数据会被持久化到磁盘, 一旦数据量大且并发量大, 读取速度慢, 直接造成MySQL服务压力变大, 对于其他功能影响很大
> 因此, 不建议使用MySQL
2. 采取Redis
> 采取Redis主要是基于其读写频繁的特性, 因为Redis是非关系型数据库, 数据存储在内存中, 无论是检索或者写入都非常的快, 非常的符合特性, 且Redis也支持AOF和RDB两种模式的持久化, 完美契合项目开发需求, 不仅如此, Redis还支持多种数据结构
> 即使采取了持久化策略会一定程度上影响性能, 但是对于MySQL或MongoDb这些数据库而言, 效率高太多
> 这里建议使用单独的Redis, 因为Redis的业务可能有缓存, 验证码校验, 共享session等业务, 如果给这些业务也给持久化, 会大幅度影响系统性能, 因此需要一个 单独的Redis
### 离线购物车的技术选型
#### 离线购物车的特性
> 离线购物车和在线购物车的特性几乎一致, 不再赘述
#### 权衡利弊
1. 采取MySQL(略)
2. 客户端存储

> 如上图所示, 客户端存储就是本地存储, 本地存储最大的好处是不需要耗费系统资源, 无论是多少用户都可以存储下来, 但是, 这样虽然节省了系统资源, 不过会导致一个非常严重的后果, 即不能进行大数据分析, 个性化推荐, 这样的损失不可估量
3. 采取Redis(略, 选取)
4. WebSQL(支持力度各不相同, 不采用)
### Redis存储的数据结构
#### 购物项的分析

> 由上述图片可知, 每一个购物项不止有一个属性, 可能存在多个, 例如图片, 标题, 价格, 销售属性...等, 因此, 我们如果想要将它们存储到Redis, 我们可以采取Json串的形式
#### 购物车的分析
> 由上图可知, 一个购物车可能存在多个购物项, 因此, 存储的结构应该类似于一个集合或者数组来存储到多个数据项
#### `list`数据结构
> **说在前面, 不能用`list`数据结构**
> `list`数据结构类似于一个普通的数组, 存储的逻辑完全是按照先后顺序, 如果, 如果我们将某个数据项改变了, 就需要将整个数组都遍历一次才能找到对应的数据项进行修改, **时间复杂度`O(N)`**, 效率很低
#### `hash`数据结构
> **说在前面, 最终采用`hash`数据结构**
> 因为hash数据结构类似于Java的Map集合, 都是以键值对的形式存储, 在修改的时候, 可以通过键快速定位目标, **说在前面, 不能用`list`数据结构**, 效率很高
### 架构总结
> 在Redis中, 需要采取持久化策略, 同时, 每一个数据结构都是hash, key作为用户ID或临时用户ID, value是hash类型的购物车, hash中key是购物项的ID, value是购物项的JSON串
## 相关VO
```java
/**
* @className: com.junjie.bitmall.cart.vo.CartVO
* @description: 购物车
* @author: 江骏杰
* @create: 2023-08-27 16:14
*/
public class CartVO {
/**
* 购物车中所有购物项的详细数据
*/
private List<CartItemVO> cartItemVOS;
/**
* 商品的总和
*/
private Integer countNum;
/**
* 商品类型的总和
*/
private Integer countType;
/**
* 购物车所有商品价格的总计
*/
private BigDecimal totalAmount;
/**
* 折扣价
*/
private BigDecimal reduce;
public List<CartItemVO> getCartItemVOS() {
return cartItemVOS;
}
/**
* 获取商品总和
* @return
*/
public Integer getCountNum() {
int countNum = 0;
if (cartItemVOS != null && !cartItemVOS.isEmpty()) {
for (CartItemVO cartItemVO : cartItemVOS) {
countNum += cartItemVO.getCount();
}
}
return countNum;
}
/**
* 获取商品类型总和
* @return
*/
public Integer getCountType() {
int countType = 0;
if (cartItemVOS != null && !cartItemVOS.isEmpty()) {
for (CartItemVO cartItemVO : cartItemVOS) {
countType += 1;
}
}
return countType;
}
/**
* 获取所有商品的价格总和, 注意需要减去折扣
* @return
*/
public BigDecimal getTotalAmount() {
BigDecimal totalAmount = new BigDecimal(0);
if (cartItemVOS != null && !cartItemVOS.isEmpty()) {
for (CartItemVO cartItemVO : cartItemVOS) {
if (cartItemVO.getCheck()) // 选中才计算
totalAmount = totalAmount.add(cartItemVO.getTotalPrice());
}
}
totalAmount = totalAmount.subtract(reduce);
return totalAmount;
}
public BigDecimal getReduce() {
return reduce;
}
public void setCartItemVOS(List<CartItemVO> cartItemVOS) {
this.cartItemVOS = cartItemVOS;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
```
```java
/**
* @className: com.junjie.bitmall.cart.vo.CartItemVO
* @description: 购物项
* @author: 江骏杰
* @create: 2023-08-27 16:15
*/
public class CartItemVO {
/**
* 商品ID
*/
private Long skuId;
/**
* 选中状态, 默认为True(默认选中)
*/
private Boolean check = true;
/**
* 标题
*/
private String title;
/**
* 图片
*/
private String image;
/**
* 销售属性
*/
private List<String> skuAttr;
/**
* 单价
*/
private BigDecimal price;
/**
* 数量
*/
private Integer count;
/**
* 该商品的总价
* 总价只能动态计算, 不能被设置
*/
private BigDecimal totalPrice;
public Long getSkuId() {
return skuId;
}
public Boolean getCheck() {
return check;
}
public String getTitle() {
return title;
}
public String getImage() {
return image;
}
public List<String> getSkuAttr() {
return skuAttr;
}
public BigDecimal getPrice() {
return price;
}
public Integer getCount() {
return count;
}
public BigDecimal getTotalPrice() {
return this.price.multiply(new BigDecimal(count));
}
public CartItemVO setSkuId(Long skuId) {
this.skuId = skuId;
return this;
}
public CartItemVO setCheck(Boolean check) {
this.check = check;
return this;
}
public CartItemVO setTitle(String title) {
this.title = title;
return this;
}
public CartItemVO setImage(String image) {
this.image = image;
return this;
}
public CartItemVO setSkuAttr(List<String> skuAttr) {
this.skuAttr = skuAttr;
return this;
}
public CartItemVO setPrice(BigDecimal price) {
this.price = price;
return this;
}
public CartItemVO setCount(Integer count) {
this.count = count;
return this;
}
}
```
> 这里的get方法需要自定义, 所以不能用@Data
## 离线购物车的实现思路
> 为了达到即使退出浏览器, 重新登陆浏览器仍然能够获取之前的离线购物车, 这里采用了Cookie, 通过Cookie保存了临时账户信息, 即临时账户的ID, 通过临时账户ID查询Redis, 从而实现离线购物车
# 前置工作
## 拦截器
```java
public class CartInterceptor implements HandlerInterceptor {
public static final ThreadLocal<UserBO> USER_INFO_ID = ThreadLocal.withInitial(UserBO::new);
/**
* 执行方法前对请求的拦截, 主要判断登录信息
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(); // 这个session是重写后的, 主要操作Redis
MemberVO memberVO = (MemberVO) session.getAttribute(UserConstant.SESSION_USER_NAME);
UserBO userBO = USER_INFO_ID.get(); // 根据源码, 这个会自动创建, 上面已经写了初始化规则
if (memberVO != null) { // 用户已经登陆了
userBO.setUserId(memberVO.getId());
}
// 无论是否登录, 都需要有临时用户信息
Cookie[] cookies = request.getCookies();
if (cookies != null) { // 对cookies来个非空判断
for (Cookie cookie : cookies) {
if (cookie.getName().equals(CookieConstant.COOKIE_USER_KEY)) { // 有对应的cookie
userBO.setUserKey(cookie.getValue());
userBO.setIsExistsTempUser(true);
}
}
}
// 最后继续判断临时用户是否存在, 若不存在则手动创建一个临时用户的id
if (StringUtils.isBlank(userBO.getUserKey())) { // 临时用户不存在
userBO.setUserKey(UUID.randomUUID().toString());
}
return true; // 无论如何都放行
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserBO userBO = USER_INFO_ID.get();
if (!userBO.getIsExistsTempUser()) { // 不存在临时用户, 需要将对应的信息存储到Cookie中
Cookie cookie = new Cookie(CookieConstant.COOKIE_USER_KEY, userBO.getUserKey());
cookie.setDomain("bitmall.com"); // 设置域名
cookie.setMaxAge(CookieConstant.COOKIE_USER_KEY_EXPIRED_TIME);
response.addCookie(cookie);
}
// 必须remove原来的值, 否则线程复用会带来问题
USER_INFO_ID.remove();
}
}
```
```java
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor())
.addPathPatterns("/**")
.order(0);
}
}
```
### 亮点分析
#### 这里为什么要使用ThreadLocal?
> 因为购物车的很多行为, 例如CRUD都需要判断是否登录, 从而才能确定操作的而是离线购物车还是在线购物车, 因此, 登录状态需要在这些行为中传递, 因为该拦截器拦截了所有的请求, 所以无论什么请求都书记创建ThreadLocal
> ThreadLcoal的特性是线程间独立, 线程内共享, 因此, 利用该特性, 可以在ThreadLoacl中存储登陆状态信息, 让每一次操作能够快速地读取到登陆状态, 而不影响他人
### 拦截器响应Cookie的判断
> 如果没有对应的判断, 每次都发一个cookie, 新的cookie会把旧的覆盖掉, 从而每次访问都会自动续期
> 若加以判断, 则不会自动续期
# BUG修复
## ThreadLocal的使用问题
> 我们发现, 如果将当前账户退出了, 即删除了Session里面的信息, 购物车里面的数据我们仍然访问的是登陆过的账户的数据, 并非游客的数据, 这是为什么呢?
> 可以看向拦截器, 我们使用了ThreadLocal实现了用户信息的共享, 但是, 由于ThreadLocal的底层原理可知, 每一个线程都都有一个独立的ThreadLocalMap集合, 且常规Spring服务下, 都默认会使用线程池, 这会导致一个非常严重的问题, 即线程复用, 如果线程复用, 里面的ThreadLocalMap里面的数据就会被复用, 这样就会在逻辑上平白无故的获取别人的购物车, 非常危险
> 总结, 如果我们使用ThreadLcoal, 且在线程池的场景下, 必须remove(), 否则逻辑会出错