功能分析
功能需求
需求描述:
- 用户可以在登录状态下将商品添加到购物车
- 用户可以在未登录状态下将商品添加到购物车
- 用户可以使用购物车一起结算下单
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
提示购物车商品价格变化,数据结构,首先分析一下购物车的数据结构
数据结构
首先分析一下购物车的数据结构

因此每一个购物车信息,都是一个对象,基本字段包括:
{id: 1,userId: '2',skuId: 2131241,check: true, // 选中状态title: "Apple iphone.....",image: "...",price: 4999,count: 1,store: true, // 是否有货saleAttrs: [{..},{..}], // 销售属性sales: [{..},{..}] // 营销信息}
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
[{...},{...},{...}]
怎么保存
由于购物车是一个读多写多的场景,为了应对高并发场景,所有购物车采用的存储方案也和其他功能,有所差别。
主流的购物车数据存储方案:
- redis(登录/未登录):性能高,代价高,不利于数据分析
- mysql(登录/未登录):性能低,成本低,利于数据分析
- cookie(未登录):未登录时,不需要和服务器交互,性能提高。其他请求会占用带宽
- localStorage/IndexedDB/WebSQL(未登录):不需要和服务器交互,不占用带宽
数据需要持久化,购物车为什么不使用MySQL,原因是此场景读多写多,MySQL性能并不是特别高。读多写少场景才适合。redis同样也可以开启持久化操作。MongoDB性能也提升不了多大,所以不使用MongoDB。
一般情况下,企业级购物车通常采用组合方案:
- cookie(未登录时) + mysql(登录时)
- cookie(未登录) + redis(登录时)
- localStorage/IndexedDB/WebSQL(未登录) + redis(登录)
- localStorage/IndexedDB/WebSQL(未登录) + mysql(登录)
随着数据价值的提升,企业越来越重视用户数据的收集,现在以上4种方案使用的越来越少。
当前大厂普遍采用:redis + mysql。
不管是否登录都把数据保存到mysql,为了提高性能可以搭建mysql集群,并引入redis。
查询时,从redis查询提高查询速度,写入时,采用双写模式
mysql保存购物车很简单,创建一张购物车表即可。
Redis有5种不同数据结构,这里选择哪一种比较合适呢?Map<String, List<String>>
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为key来存储,Value是用户的所有购物车信息。这样看来基本的
k-v结构就可以了。 - 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品id进行判断,为了方便后期处理,我们的购物车也应该是
k-v结构,key是商品id,value才是这个商品的购物车信息。
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
- 第一层Map,Key是用户id
- 第二层Map,Key是购物车中商品id,值是购物车数据
key:用户标示登录态:gulimall:cart:userId非登录态:gulimall:cart:userKeyvalue:存储一个Hash结构的值,其中该hash结构的key是SkuId,hash结构的value是商品信息,以json字符串格式存储

创建gulimall-cart工程
创建工程也就是动静分离的配置
购物车VO
/*** 购物车VO* 需要计算的属性需要重写get方法,保证每次获取属性都会进行计算*/public class CartVo {private List<CartItemVo> items; // 购物项集合private Integer countNum; // 商品件数(汇总购物车内商品总件数)private Integer countType; // 商品数量(汇总购物车内商品总个数)private BigDecimal totalAmount; // 商品总价private BigDecimal reduce = new BigDecimal("0.00");// 减免价格public List<CartItemVo> getItems() {return items;}public void setItems(List<CartItemVo> items) {this.items = items;}public Integer getCountNum() {int count = 0;if (items != null && items.size() > 0) {for (CartItemVo item : items) {count += item.getCount();}}return count;}public Integer getCountType() {return CollectionUtils.isEmpty(items) ? 0 : items.size();}public BigDecimal getTotalAmount() {BigDecimal amount = new BigDecimal("0");// 1、计算购物项总价if (!CollectionUtils.isEmpty(items)) {for (CartItemVo cartItem : items) {if (cartItem.getCheck()) {amount = amount.add(cartItem.getTotalPrice());}}}// 2、计算优惠后的价格return amount.subtract(getReduce());}public BigDecimal getReduce() {return reduce;}public void setReduce(BigDecimal reduce) {this.reduce = reduce;}}
购物项VO
/*** 购物项VO(购物车内每一项商品内容)*/public class CartItemVo {private Long skuId; // skuIdprivate Boolean check = true; // 是否选中private String title; // 标题private String image; // 图片private List<String> skuAttrValues; // 销售属性private BigDecimal price; // 单价private Integer count; // 商品件数private BigDecimal totalPrice; // 总价public Long getSkuId() {return skuId;}public void setSkuId(Long skuId) {this.skuId = skuId;}public Boolean getCheck() {return check;}public void setCheck(Boolean check) {this.check = check;}public String getTitle() {return title;}public void setTitle(String title) {this.title = title;}public String getImage() {return image;}public void setImage(String image) {this.image = image;}public List<String> getSkuAttrValues() {return skuAttrValues;}public void setSkuAttrValues(List<String> skuAttrValues) {this.skuAttrValues = skuAttrValues;}public BigDecimal getPrice() {return price;}public void setPrice(BigDecimal price) {this.price = price;}public Integer getCount() {return count;}public void setCount(Integer count) {this.count = count;}/*** 计算当前购物项总价*/public BigDecimal getTotalPrice() {return this.price.multiply(new BigDecimal("" + this.count));}public void setTotalPrice(BigDecimal totalPrice) {this.totalPrice = totalPrice;}}
流程
参照jd:

user-key是游客id,不管有没有登录都会有这个cookie信息。
两个功能:新增商品到购物车、查询购物车。
新增商品:判断是否登录
- 是:则添加商品到后台Redis中,把user的唯一标识符作为key。
- 否:则添加商品到后台Redis中,使用随机生成的user-key作为key。
查询购物车列表:判断是否登录
- 否:直接根据user-key查询redis中数据并展示
- 是:已登录,则需要先根据user-key查询redis是否有数据。
- 有:需要先合并数据(redis),而后查询。
- 否:直接去后台查询redis,而后返回。
配置拦截器
业务逻辑:1)第一次使用购物车功能,创建user-key(分配临时用户身份)2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)登录状态则获取用户购物车信息3)未登录状态,则获取临时用户身份,获取游客购物车拦截器功能:过滤器(URL拦截)=》拦截器(URL拦截)=》切面(方法拦截)1.preHandle1)获取用户登录信息userId,封装到ThreadLocal中,controller可以拿到2)用户未登录,分配userKey封装到ThreadLocal中,controller可以拿到2.postHandle1)判断客户端是否存在游客用户标识不存在则创建cookie,命令客户端保存游客信息user-key
购物车系统根据用户的登录状态,购物车的增删改处理方式不同,因此需要添加登录校验。而登录状态的校验如果在每个方法中进行校验,会造成代码的冗余,不利于维护。所以这里使用拦截器统一处理。
springboot自定义拦截器:
- 编写自定义拦截器类实现HandlerInterceptor接口(前置方法 后置方法 完成方法)
- 编写配置类(添加@Configuration注解)实现WebMvcConfigurer接口(重写addInterceptors方法)
@Componentpublic class CartInterceptor implements HandlerInterceptor {public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {UserInfoTo userInfoTo = new UserInfoTo();HttpSession session = request.getSession();MemberRespVo member = (MemberRespVo) session.getAttribute(AuthConstant.LOGIN_USER);if (member != null) {// 登录状态,封装用户ID,供controller使用userInfoTo.setUserId(member.getId());}// 获取当前请求游客用户标识user-keyCookie[] cookies = request.getCookies();if (cookies != null && cookies.length > 0) {for (Cookie cookie : cookies) {// 如果cookie有user_keyif (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {// 获取user-key值封装到user,供controller使用userInfoTo.setUserKey(cookie.getValue());// 表示已经登录,不是零时用户userInfoTo.setTempUser(true);break;}}}// 没有零时用户一定分配一个零时用户if (StringUtils.isEmpty(userInfoTo.getUserKey())) {// 无游客标识,分配游客标识userInfoTo.setUserKey(UUID.randomUUID().toString());}// 封装用户信息(登录状态userId非空,游客状态userId空)threadLocal.set(userInfoTo);return true;}/*** 业务执行之后,让浏览器保存临时用户信息** @param request* @param response* @param handler* @param modelAndView* @throws Exception*/@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {UserInfoTo userInfoTo = threadLocal.get();// 如果是零时用户if (!userInfoTo.isTempUser()) {Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());cookie.setDomain("gulimalls.com");cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);response.addCookie(cookie);}}}
拦截器定义好了,将来怎么把拦截器中获取的用户信息传递给后续的每个业务逻辑:
- public类型的公共变量。线程不安全
- request对象。不够优雅
- ThreadLocal线程变量。推荐
所以将用户信息放入ThreadLocal中
/*** 业务执行之后,让浏览器保存临时用户信息** @param request* @param response* @param handler* @param modelAndView* @throws Exception*/@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {UserInfoTo userInfoTo = threadLocal.get();// 如果是零时用户if (!userInfoTo.isTempUser()) {Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());cookie.setDomain("gulimalls.com");cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);response.addCookie(cookie);}}
注册拦截器
/*** 配置拦截器*/@Configurationpublic class GulimallWebConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");}}
添加商品到购物车
/*** 添加商品到购物车* RedirectAttributes ra* ra.addFLashAttribute();将数据放在session里面可以在页面取出,但是只能取一次* ra.addAttribute( "shuId" , skuId);将数据放在urL后面** @return*/@GetMapping("addToCart")public String addToCart(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num,RedirectAttributes redirectAttributes) throws ExecutionException, InterruptedException {cartService.addCart(skuId, num);// 重定向域(会自动拼接在路径后面)redirectAttributes.addAttribute("skuId", skuId);return "redirect:http://cart.gulimalls.com/addToCartSuccess.html";}
Hash数据类型操作对象
此时操作redis中的数据结构需要绑定hash键,抽取一个方法来绑定哈希键,以后都是操作它
这里区分了登录了的用户还是临时用户,那么作为hash的key就会不一样
/*** 查询购物车** @return*/@Overridepublic CartVo getCart() throws ExecutionException, InterruptedException {CartVo cartVo = new CartVo();UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();String cartKey = "";if (userInfoTo.getUserId() != null) {// 先判断零时购物车是否有商品String tempCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();List<CartItemVo> tempCartItemList = getCartItems(tempCartKey);if (tempCartItemList != null && tempCartItemList.size() > 0) {for (CartItemVo cartItemVo : tempCartItemList) {// 合并在用户购物车中addCart(cartItemVo.getSkuId(), cartItemVo.getCount());}// 删除临时购物车clearCart(tempCartKey);}// 使用用户的id作为购物车,此时购物车已经合并了,所以直接查cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();cartVo.setItems(getCartItems(cartKey));} else {// 此时没登录,就用cookie,游客购物车cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();// 此时游客购物车直接查询遍历即可List<CartItemVo> cartItemVoList = getCartItems(cartKey);cartVo.setItems(cartItemVoList);}return cartVo;}
实现类
注意使用了异步编排查询商品的销售属性和基本信息,此时如果redis中有数据,那么更新数据,如果没有添加数据。
@Overridepublic CartItemVo addCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {BoundHashOperations<String, Object, Object> cartOps = getCartOps();String res = (String) cartOps.get(skuId.toString());if (StringUtils.isEmpty(res)) {// 如果redis中没有购物车,添加购物车CartItemVo cartItem = new CartItemVo();// 使用异步编排CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> {// 远程调用查询sku基本信息R r = productFeignService.info(skuId);SkuInfoTo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoTo>() {});cartItem.setSkuId(skuInfo.getSkuId());// 商品IDcartItem.setTitle(skuInfo.getSkuTitle());// 商品标题cartItem.setImage(skuInfo.getSkuDefaultImg());// 商品默认图片cartItem.setPrice(skuInfo.getPrice());// 商品单价cartItem.setCount(num);// 商品件数cartItem.setCheck(true);// 是否选中}, threadPoolExecutor);CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> {// 远程调用查询sku销售属性List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);cartItem.setSkuAttrValues(skuSaleAttrValues);}, threadPoolExecutor);// 等待两个线程都完成才保存到redisCompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get();// 以json格式保存在redis中String jsonString = JSON.toJSONString(cartItem);cartOps.put(skuId.toString(), jsonString);return cartItem;} else {// 如果redis中有数据,那么就更新数量CartItemVo cartItem = JSON.parseObject(res, CartItemVo.class);cartItem.setCount(cartItem.getCount() + num);cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));return cartItem;}}
接口防刷
如果刷新cart.gulimall.com/addToCart?skuId=7&num=1该页面,会导致购物车中此商品的数量无限新增
解决方案:
/addToCart请求使用重定向给/addToCartSuccessPage.html
由/addToCartSuccessPage.html这个请求跳转”商品已成功加入购物车页面”(浏览器url请求已更改),达到防刷的目的,此时刷新是重定向过的页面,所以一致刷新也只是查询redis中的内容。
/*** 添加商品到购物车* RedirectAttributes ra* ra.addFLashAttribute();将数据放在session里面可以在页面取出,但是只能取一次* ra.addAttribute( "shuId" , skuId);将数据放在urL后面** @return*/@GetMapping("addToCart")public String addToCart(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num,RedirectAttributes redirectAttributes) throws ExecutionException, InterruptedException {cartService.addCart(skuId, num);// 重定向域(会自动拼接在路径后面)redirectAttributes.addAttribute("skuId", skuId);return "redirect:http://cart.gulimalls.com/addToCartSuccess.html";}// 使用重定向保证接口防刷,如果一致发送请求只是查询,而不是新增@GetMapping("/addToCartSuccess.html")public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) {// 查询skuId的数据CartItemVo cartItem = cartService.getCartBySkuId(skuId);model.addAttribute("cartItem", cartItem);return "success";}
查询内容实现类
@Overridepublic CartItemVo getCartBySkuId(Long skuId) {BoundHashOperations<String, Object, Object> hashOps = getCartOps();String data = (String) hashOps.get(skuId.toString());return JSON.parseObject(data, CartItemVo.class);}
购物车列表
/*** 浏览器有一个cookie; user-key;标识用户身份,一个月后过期;* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;* 浏览器以后保存,每次访间都会带上这个cookie;* <p>* 登录: session有* 没登录:按照cookie里面带来user-key来做。* 第一次:如果没有临时用户,帮忙创建—个临时用户。** @return*/@GetMapping("/cart.html")public String cartListPage(Model model) throws ExecutionException, InterruptedException {CartVo cartVo = cartService.getCart();model.addAttribute("cart", cartVo);return "cartList";}
获取购物车列表时:
- 如果用户购物车有两个,一个游客购物车,key是user-key,一个是用户购物车,key是user-id,那么此时需要合并购物车在用户购物车中,并删除游客购物车
- 如果只有一个购物车,那么直接查询遍历即可
/*** 查询购物车** @return*/@Overridepublic CartVo getCart() throws ExecutionException, InterruptedException {CartVo cartVo = new CartVo();UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();String cartKey = "";if (userInfoTo.getUserId() != null) {// 先判断零时购物车是否有商品String tempCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();List<CartItemVo> tempCartItemList = getCartItems(tempCartKey);if (tempCartItemList != null && tempCartItemList.size() > 0) {for (CartItemVo cartItemVo : tempCartItemList) {// 合并在用户购物车中addCart(cartItemVo.getSkuId(), cartItemVo.getCount());}// 删除临时购物车clearCart(tempCartKey);}// 使用用户的id作为购物车,此时购物车已经合并了,所以直接查cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();cartVo.setItems(getCartItems(cartKey));} else {// 此时没登录,就用cookie,游客购物车cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();// 此时游客购物车直接查询遍历即可List<CartItemVo> cartItemVoList = getCartItems(cartKey);cartVo.setItems(cartItemVoList);}return cartVo;}/*** 根据cartKey获取购物车所有商品** @param cartKey* @return*/private List<CartItemVo> getCartItems(String cartKey) {// 绑定购物车的key操作RedisBoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(cartKey);List<Object> values = hashOps.values();if (values != null && values.size() > 0) {List<CartItemVo> cartItemVoList = values.stream().map((obj) -> {CartItemVo cartItemVo = JSON.parseObject(String.valueOf(obj), CartItemVo.class);return cartItemVo;}).collect(Collectors.toList());return cartItemVoList;}return null;}/*** 根据cartKey删除购物车** @param cartKey*/public void clearCart(String cartKey) {redisTemplate.delete(cartKey);}
多选、更新数量、删除购物项

前端页面略,后端都是直接操作redis中的数据,对对象进行修改即可,所以流程大致一样
controller
@GetMapping("/deleteItem")public String deleteItem(@RequestParam("skuId") Long skuId) {cartService.deleteItemBySkuId(skuId);return "redirect:http://cart.gulimalls.com/cart.html";}@GetMapping("/countItem")public String countItem(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num) {cartService.changeItemCount(skuId, num);return "redirect:http://cart.gulimalls.com/cart.html";}@GetMapping("/countItem")public String checkItem(@RequestParam("skuId") Long skuId,@RequestParam("checked") Integer checked) {cartService.checkItem(skuId, checked);return "redirect:http://cart.gulimalls.com/cart.html";}
实现类
@Overridepublic void checkItem(Long skuId, Integer checked) {BoundHashOperations<String, Object, Object> cartOps = getCartOps();CartItemVo cartBySkuId = getCartBySkuId(skuId);cartBySkuId.setCheck(checked == 1 ? true : false);cartOps.put(skuId.toString(), JSON.toJSONString(cartBySkuId));}@Overridepublic void changeItemCount(Long skuId, Integer num) {BoundHashOperations<String, Object, Object> cartOps = getCartOps();CartItemVo cartBySkuId = getCartBySkuId(skuId);cartBySkuId.setCount(num);cartOps.put(skuId.toString(), JSON.toJSONString(cartBySkuId));}@Overridepublic void deleteItemBySkuId(Long skuId) {BoundHashOperations<String, Object, Object> cartOps = getCartOps();cartOps.delete(skuId.toString());}
