购物车架构与需求分析

# 购物车的架构与需求分析 ## 需求分析 ### 在线购物车和离线购物车 #### 离线购物车的定义 > 离线购物车其实是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. 客户端存储 ![image.png](https://cos.easydoc.net/13568421/files/llt5ujy5) > 如上图所示, 客户端存储就是本地存储, 本地存储最大的好处是不需要耗费系统资源, 无论是多少用户都可以存储下来, 但是, 这样虽然节省了系统资源, 不过会导致一个非常严重的后果, 即不能进行大数据分析, 个性化推荐, 这样的损失不可估量 3. 采取Redis(略, 选取) 4. WebSQL(支持力度各不相同, 不采用) ### Redis存储的数据结构 #### 购物项的分析 ![image.png](https://cos.easydoc.net/13568421/files/llt61f1j.png) > 由上述图片可知, 每一个购物项不止有一个属性, 可能存在多个, 例如图片, 标题, 价格, 销售属性...等, 因此, 我们如果想要将它们存储到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(), 否则逻辑会出错