整个购物车的数据存储都是放到redis进行存储的,同时购物车分为临时购物车和用户购物车,临时购物车就是用户没有登录时的购物车,用户购物车是用户登录了的购物车,用户登录了后会把临时购物车的购物项合并在用户自己的购物车里面并且清空临时购物车,这里最核心的是理解购物车存入redis的结构
购物车和购物项存入redis的结构用的是HashMap结构
Hash值为cartKey ,表示购物车,其中的 map结构为: Map<String skuId,String cartItem>,表示购物项
cartKey表示格式为saodaimall:cart:key,表示购物车,其中saodaimall:cart:key的saodaimall:cart:是一个固定前缀,key值有两种,如果登录了就是用户id(saodaimall:cart:1),没登录就是名为user-key的cookie的值(例如saodaimall:cart:a191459a-0f24-4e69-8dc7-a3f81de96202)
Map<String skuId,String cartItem>作为外表示用户购物车的里的购物项,购物项中skuId为商品id作为key,values作为购物项的详细信息
购物车实现流程(加入购物车->去购物车结算)
1、抽取购物车和购物项模型
package com.saodai.saodaimall.cart.vo; import org.springframework.util.CollectionUtils; import java.math.BigDecimal; import java.util.List; /** * 购物车VO模型 * 需要计算的属性需要重写get方法,保证每次获取属性都会进行计算(这里没用@Data是因为要重写get方法) **/ public class CartVo { /** * 购物项信息 */ 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() { int count = 0; if (items != null && items.size() > 0) { for (CartItemVo item : items) { count += 1; } } return count; } //获取购物车中所有商品价格总和 public BigDecimal getTotalAmount() { BigDecimal amount = new BigDecimal("0"); // 计算购物项总价 if (!CollectionUtils.isEmpty(items)) { for (CartItemVo cartItem : items) { if (cartItem.getCheck()) { amount = amount.add(cartItem.getTotalPrice()); } } } // 计算优惠后的价格 return amount.subtract(getReduce()); } public BigDecimal getReduce() { return reduce; } public void setReduce(BigDecimal reduce) { this.reduce = reduce; } }
package com.saodai.saodaimall.cart.vo; import java.math.BigDecimal; import java.util.List; /** * 购物项Vo模型(这里没用@Data是因为要重写get方法) **/ public class CartItemVo { //购物项中商品skuId private Long skuId; //是否被选中 private 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; } /** * 计算当前购物项总价 * @return */ public BigDecimal getTotalPrice() { return this.price.multiply(new BigDecimal("" + this.count)); } public void setTotalPrice(BigDecimal totalPrice) { this.totalPrice = totalPrice; } }
2、设置登录拦截器
在执行目标方法之前,判断用户的登录状态.并封装传递给controller目标请求:(封装UserInfoTo对象)
(1)获得当前登录用户的信息,用户登录了就设置用户id为userInfoTo的id(通过springsession实现了各个服务直接session共享)
(2)判断用户是不是第一次使用本网站(如果之前用过就会有一个浏览器名为user-key的cookie的值),用过就把cookie的值封装到UserInfoTo对象
(3)没有用过这个网站就需要创建一个新的cookie值(临时用户)
(4)把封装好的userInfoTo对象放到ThreadLocal中
注意:用户登录和有没有用过这个网站是两回事,因为可能是第一次登录,那就可以用过也可能没有用过这个网站
补充:
ThreadLocal 叫做本地线程变量,ThreadLocal是解决线程安全问题,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。为什么能够解决变量并发访问的冲突问题呢?因为一个ThreadLocal的变量只有当前自身线程可以访问,别的线程都访问不了
在Java的多线程编程中,为保证多个线程对共享变量的安全访问,通常会使用synchronized来保证同一时刻只有一个线程对共享变量进行操作。这种情况下可以将类变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立拷贝,不会出现一个线程读取变量时而被另一个线程修改的现象。最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等
理解:这里把用户登录信息(对象)放到ThreadLocal中也是围栏解决线程安全问题,这样每个服务都会有登录信息对象的拷贝,那么用户在用户服务中修改的这个登录信息不会影响到其他服务的登录信息,其他服务都是不同线程的登录信息的一个拷贝
业务执行之后,分配临时用户来浏览器保存
(1)从ThreadLocal中获取当前用户的值(已经经过拦截器了)
(2)如果没有用过这个网站就新创建一个临时用户,把前面创建的cookie值赋值到这个cookie里
package com.saodai.saodaimall.cart.interceptor; import com.saodai.common.vo.MemberResponseVo; import com.saodai.saodaimall.cart.to.UserInfoTo; import org.apache.commons.lang.StringUtils; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.util.UUID; import static com.saodai.common.constant.AuthServerConstant.LOGIN_USER; import static com.saodai.common.constant.CartConstant.TEMP_USER_COOKIE_NAME; import static com.saodai.common.constant.CartConstant.TEMP_USER_COOKIE_TIMEOUT; /** * 在执行目标方法之前,判断用户的登录状态.并封装传递给controller目标请求 **/ public class CartInterceptor implements HandlerInterceptor { public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>(); /*** * 目标方法执行之前 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //拦截器判断用户登录状态的封装类 UserInfoTo userInfoTo = new UserInfoTo(); HttpSession session = request.getSession(); //获得当前登录用户的信息 MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(LOGIN_USER); if (memberResponseVo != null) { //表示用户登录了就设置用户id为userInfoTo的id(用于后面做为redis的部分key值) userInfoTo.setUserId(memberResponseVo.getId()); } //判断用户是不是第一次使用本网站(如果之前用过就会有一个浏览器名为user-key的cookie的值) Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length > 0) { for (Cookie cookie : cookies) { //user-key String name = cookie.getName(); if (name.equals(TEMP_USER_COOKIE_NAME)) { //浏览器名为user-key的cookie的值 userInfoTo.setUserKey(cookie.getValue()); //标记为已是临时用户 userInfoTo.setTempUser(true); } } } //没有用过这个网站就需要创建一个新的cookie值(临时用户) if (StringUtils.isEmpty(userInfoTo.getUserKey())) { String uuid = UUID.randomUUID().toString(); userInfoTo.setUserKey(uuid); } //目标方法执行之前 toThreadLocal.set(userInfoTo); return true; } /** * 业务执行之后,分配临时用户来浏览器保存 * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { //获取当前用户的值(已经经过拦截器了) UserInfoTo userInfoTo = toThreadLocal.get(); //如果没有用过这个网站就新创建一个临时用户,把前面创建的cookie值赋值到这个cookie里 if (!userInfoTo.getTempUser()) { //创建一个cookie Cookie cookie = new Cookie(TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey()); //扩大作用域 cookie.setDomain("saodaimall.com"); //设置过期时间 cookie.setMaxAge(TEMP_USER_COOKIE_TIMEOUT); response.addCookie(cookie); } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
package com.saodai.saodaimall.cart.to; import lombok.Data; /** *拦截器判断用户登录状态的封装类 **/ @Data public class UserInfoTo { /** * 已经登录用户的id */ private Long userId; /** * 浏览器名为user-key的cookie的值 */ private String userKey; /** * 是否临时用户 */ private Boolean tempUser = false; }
package com.saodai.common.vo; import lombok.Data; import lombok.ToString; import java.io.Serializable; import java.util.Date; /** *会员信息 **/ @ToString @Data public class MemberResponseVo implements Serializable { private static final long serialVersionUID = 5573669251256409786L; private Long id; /** * 会员等级id */ private Long levelId; /** * 用户名 */ private String username; /** * 密码 */ private String password; /** * 昵称 */ private String nickname; /** * 手机号码 */ private String mobile; /** * 邮箱 */ private String email; /** * 头像 */ private String header; /** * 性别 */ private Integer gender; /** * 生日 */ private Date birth; /** * 所在城市 */ private String city; /** * 职业 */ private String job; /** * 个性签名 */ private String sign; /** * 用户来源 */ private Integer sourceType; /** * 积分 */ private Integer integration; /** * 成长值 */ private Integer growth; /** * 启用状态 */ private Integer status; /** * 注册时间 */ private Date createTime; /** * 社交登录用户的ID */ private String socialId; /** * 社交登录用户的名称 */ private String socialName; /** * 社交登录用户的自我介绍 */ private String socialBio; }
3、在config包中配置注册刚才的拦截器
package com.saodai.saodaimall.cart.config; import com.saodai.saodaimall.cart.interceptor.CartInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**"); } }
4、在config包中配置springSession实现登录信息共享
package com.saodai.saodaimall.cart.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; /** * springSession配置类(所有要使用session的服务的session配置要一致) */ @Configuration public class GulimallSessionConfig { /** * 配置session(主要是为了放大session作用域) * @return */ @Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); //放大作用域 cookieSerializer.setDomainName("saodaimall.com"); cookieSerializer.setCookieName("SAODAISESSION"); return cookieSerializer; } /** * 配置Session放到redis存储的格式为json(其实就是json序列化) * @return */ @Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }
5、加入购物车的购物项
(1)前台加入购物车按钮点击后发送请求/addCartItem给后端
<div class="box-btns-two" th:if="${item.seckillSkuVo == null }"> <a class="addToCart" href="#" th:attr="skuId=${item.info.skuId}"> 加入购物车 </a> </div>
这里没有直接写成href = "http://cart.saodaimall.com/addCartItem是因为还需要传入skuId和num两个参数给后端,其实也可以直接写,只是不好看,所以在下面的js发请求
$(".addToCart").click(function () { let skuId = $(this).attr("skuId"); let num = $("#productNum").val(); location.href = "http://cart.saodaimall.com/addCartItem?skuId=" + skuId + "&num=" + num; return false; });
(2)购物车服务的CartController来处理/addCartItem请求
/** * 添加商品到购物车 * attributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次 * attributes.addAttribute():将数据放在url后面 * @return */ @GetMapping(value = "/addCartItem") public String addCartItem(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num, RedirectAttributes attributes) throws ExecutionException, InterruptedException { cartService.addToCart(skuId, num); /** * CartItemVo cartItemVo = cartService.addToCart(skuId, num); * model.addAttribute("cartItem",cartItemVo); * 没有直接向上面一样这样写是因为要防止每一次刷新页面就再添加商品的情况(类似表单的重复提交) */ //把skuId放到作用域中是为了让下面的addToCartSuccessPage拿到skuId attributes.addAttribute("skuId",skuId); return "redirect:http://cart.saodaimall.com/addToCartSuccessPage.html"; } /** * 跳转到添加购物车成功页面 * @param skuId * @param model * @return */ @GetMapping(value = "/addToCartSuccessPage.html") public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) { //重定向到成功页面。再次查询购物车数据即可 CartItemVo cartItemVo = cartService.getCartItem(skuId); model.addAttribute("cartItem",cartItemVo); return "success"; }
分流程:
1、绑定HashMap结构的hash值,便于后面操作HashMap
2、判断Redis是否有该商品的信息
3、如果没有就添加数据,有就只需要修改购物车中的数量就行了
没有:(封装CartItemVo对象)
1>远程查询当前要添加商品的信息
2>远程查询skuAttrValues组合信息
3>把封装好的CartItemVo对象存到Redis缓存中
有:
1>从Redis中拿到当前商品的CartItemVo对象的Json字符串的值,然后转成CartItemVo对象
2>修改CartItemVo对象的购物项数量后又转成Json字符串存入Redis中
/** * 添加商品到购物车 购物车和购物项存入redis的结构用的是HashMap结构 Hash值为cartKey ,表示购物车,其中的 map结构为: Map<String skuId,String cartItem>,表示购物项 cartKey表示格式为saodaimall:cart:key,表示购物车,其中saodaimall:cart:key的saodaimall:cart:是一个固定前缀,key值有两种,如果登录了就是用户id(saodaimall:cart:1),没登录就是名为user-key的cookie的值(例如saodaimall:cart:a191459a-0f24-4e69-8dc7-a3f81de96202) Map<String skuId,String cartItem>作为外表示用户购物车的里的购物项,购物项中skuId为商品id作为key,values作为购物项的详细信息 */ @Override public CartItemVo addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException { //拿到要操作的购物车信息 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //判断Redis是否有该商品的信息 String productRedisValue = (String) cartOps.get(skuId.toString()); //如果没有就添加数据 if (StringUtils.isEmpty(productRedisValue)) { //2、添加新的商品到购物车(redis) //封装购物项 CartItemVo cartItemVo = new CartItemVo(); //开启第一个异步任务 CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> { //1、远程查询当前要添加商品的信息 R productSkuInfo = productFeignService.getInfo(skuId); SkuInfoVo skuInfo = productSkuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {}); //数据赋值操作 cartItemVo.setSkuId(skuInfo.getSkuId()); cartItemVo.setTitle(skuInfo.getSkuTitle()); cartItemVo.setImage(skuInfo.getSkuDefaultImg()); cartItemVo.setPrice(skuInfo.getPrice()); cartItemVo.setCount(num); }, executor); //开启第二个异步任务 CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> { //2、远程查询skuAttrValues组合信息 List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId); cartItemVo.setSkuAttrValues(skuSaleAttrValues); }, executor); //等待所有的异步任务全部完成 CompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get(); String cartItemJson = JSON.toJSONString(cartItemVo); cartOps.put(skuId.toString(), cartItemJson); return cartItemVo; } else { //购物车有此商品,修改数量即可 CartItemVo cartItemVo = JSON.parseObject(productRedisValue, CartItemVo.class); cartItemVo.setCount(cartItemVo.getCount() + num); //修改redis的数据 String cartItemJson = JSON.toJSONString(cartItemVo); cartOps.put(skuId.toString(),cartItemJson); return cartItemVo; } } /** * 获取到我们要操作的购物车 购物车和购物项存入redis的结构用的是HashMap结构 Hash值为cartKey ,表示购物车,其中的 map结构为: Map<String skuId,String cartItem>,表示购物项 cartKey表示格式为saodaimall:cart:key,表示购物车,其中saodaimall:cart:key的saodaimall:cart:是一个固定前缀,key值有两种,如果登录了就是用户id(saodaimall:cart:1),没登录就是名为user-key的cookie的值(例如saodaimall:cart:a191459a-0f24-4e69-8dc7-a3f81de96202) Map<String skuId,String cartItem>作为外表示用户购物车的里的购物项,购物项中skuId为商品id作为key,values作为购物项的详细信息 */ private BoundHashOperations<String, Object, Object> getCartOps() { //先从拦截器中得到当前用户信息 UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get(); //cartKey是存在redis的key值 String cartKey = ""; if (userInfoTo.getUserId() != null) { //用户登录了就用用户的id号 cartKey = CART_PREFIX + userInfoTo.getUserId(); } else { //没登录就用浏览器中名为user-key的cookie的值 cartKey = CART_PREFIX + userInfoTo.getUserKey(); } //绑定要操作的哈希值,也就是cartKey BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey); return operations; } /** * 根据skuId的值查询到redis中的购物项对象 * @param skuId * @return */ @Override public CartItemVo getCartItem(Long skuId) { //拿到要操作的购物车信息 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); String redisValue = (String) cartOps.get(skuId.toString()); CartItemVo cartItemVo = JSON.parseObject(redisValue, CartItemVo.class); return cartItemVo; }
package com.saodai.saodaimall.cart.vo; import java.math.BigDecimal; import java.util.List; /** * 购物项Vo模型(这里没用@Data是因为要重写get方法) **/ public class CartItemVo { //购物项中商品skuId private Long skuId; //是否被选中 private 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; } /** * 计算当前购物项总价 * @return */ public BigDecimal getTotalPrice() { return this.price.multiply(new BigDecimal("" + this.count)); } public void setTotalPrice(BigDecimal totalPrice) { this.totalPrice = totalPrice; } }