24.购物车
环境搭建
整理静态资源
排除数据库
导入common模块依赖
<dependency> <groupId>com.wuyimin.gulimall</groupId> <artifactId>gulimall-common</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency>
配置,名字配置在properties里面
网关配置
需求分析
购物车分为离线购物车和在线购物车
- 离线购物车可以购买商品,在用户登录以后,离线购物车被清空,所有商品转移到在线购物车
- 即使浏览器关闭,离线购物车的数据还要在
购物车功能:
- crud商品
- 修改商品的购买数量
- 在购物车中展示商品的优惠信息
- 提示购物车商品的价格变化
- 是否选中商品
存储(读写并发):放入redis--持久化策略
skuId:123 check:true title:"Apple“ defaultImage:”xxx“ price:1999 count:1 totalPrice:1999 skuSaleVo:{..}
Key值:cart:1 代表一号用户的购物车
Value值:hash 哈希有key-value值
数据结构Map<String cart,Map<Long itemId,CartInfo>>
Vo编写--Cart和CartItem
@Data public class CartItem { private Long skuId; private Boolean check=true; private String title; private String image; private List<String> skuAttr;//套餐信息 private BigDecimal price; private Integer count; private BigDecimal totalPrice; public BigDecimal getTotalPrice(){ return price.multiply(new BigDecimal(""+this.count));//总价 } }
public class Cart { List<CartItem> items; private Integer countNum;//商品的总数 private Integer countType;//有几种不同的商品 private BigDecimal totalAmount;//商品总价 private BigDecimal reduce=new BigDecimal("0.00");//减免的价格 public List<CartItem> getItems() { return items; } public void setItems(List<CartItem> items) { this.items = items; } public Integer getCountNum() { int count=0; if(items!=null&&items.size()>0){ for (CartItem item : items) { count+=item.getCount(); } } return count; } public Integer getCountType() { if(items!=null&&items.size()>0){ return items.size(); } return null; } public BigDecimal getTotalAmount() { BigDecimal amount = new BigDecimal("0"); if(this.items != null && this.items.size() > 0){ for (CartItem item : this.items) { if(item.getCheck()){ BigDecimal totalPrice = item.getTotalPrice(); amount = amount.add(totalPrice); } } } return amount.subtract(this.getReduce()); } public BigDecimal getReduce() { return reduce; } public void setReduce(BigDecimal reduce) { this.reduce = reduce; } }
ThreadLocal用户验证
cart模块引入redis依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
配置redis地址
session配置
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
session配置类
@EnableRedisHttpSession
@Configuration
public class RedisSessionConfig { @Bean // redis的json序列化 public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } @Bean // cookie public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("GULISESSIONID"); // cookie的键 serializer.setDomainName("gulimall.com"); // 扩大session作用域,也就是cookie的有效域 return serializer; } }
session配置文件
UserInfoVo
@ToString @Data public class UserInfoVo { private Long userId; private String userKey; private boolean hasCookie=false;//是否登录过 }
配置拦截器,ThreadLocal用于同一个线程共享数据,关于情况threadLocal内存泄漏的问题
具体流程:
- 不管是已经登录的用户还是未登录的用户,使用购物车的时候都会储存一个临时购物车数据
- 未使用过购物车的用户在第一次获得购物车数据的时候会在pre里往vo放置一个uuid值,在post里把这个值传入cookie
- 已经使用过购物车的用户会在pre里把vo的判断是否有cookie的属性置为true,在post里如果判断为true的话,就不会重新放置cookie
/** * @ Author wuyimin * @ Date 2021/8/24-10:20 * @ Description 执行controller方法之前,判断用户的登录信息,封装传递给controller目标请求 */ //@Component//在配置中创建了就不用放在容器中了 public class CartInterceptor implements HandlerInterceptor { //其实是一个Map<Thread,T> public static ThreadLocal<UserInfoVo> threadLocal=new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession();//获得session MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);//获得用户数据 UserInfoVo userInfoVo = new UserInfoVo(); if(memberRespVo!=null){ //用户登录了 userInfoVo.setUserId(memberRespVo.getId()); } //离线购物车内容,通过cookie的key-value来区别用户,来到这里的用户是已经获得了cookie的临时用户 Cookie[] cookies = request.getCookies(); if (cookies!=null&&cookies.length>0) { for (Cookie cookie : cookies) { if(cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)){ userInfoVo.setUserKey(cookie.getValue()); userInfoVo.setHasCookie(true); break; } } } //如果是第一次登录的临时用户 if(StringUtils.isEmpty(userInfoVo.getUserKey())){ String uuid= UUID.randomUUID().toString(); userInfoVo.setUserKey(uuid); } //在目标方法执行之前 threadLocal.set(userInfoVo); // 还有一个登录后应该删除临时购物车的逻辑没有实现 return true; } //业务执行之后:分配一个临时用户让浏览器保存 @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { //不加判断就是一直延长过期时间 UserInfoVo userInfoVo = threadLocal.get(); //不是临时用户就代表以前没有注册过--第一次登录的临时用户或者已经登录的用户 if(!userInfoVo.isHasCookie()){ Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoVo.getUserKey()); cookie.setDomain("gulimall.com");//整个作用域都有效 cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); response.addCookie(cookie); } //用完了threadLocal记得要清空,防止内存泄露,key为若引用,value为强引用,如果GC会导致key被收走了,value还在 //线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal设定的value值被持有,导致内存泄露。 threadLocal.remove(); } }
拦截器配置
@Configuration public class GulimallWebConfig implements WebMvcConfigurer {//和网络有关的都要实现这个接口 //添加拦截器的配置 @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");//拦截所有请求 } }
测试的Controller
@Controller public class CartController { @GetMapping("/cart.html") public String cartListPage(HttpSession session){ UserInfoVo userInfoVo = CartInterceptor.threadLocal.get(); System.out.println(userInfoVo); return "cartList"; } }
打印
清除redis和页面cookie之后,切换用户登录再测试
添加购物车
远程接口,根据skuid把相关属性拼装成颜色:蓝色,版本:256G的形式返回
@GetMapping("/stringlist/{skuId}") public List<String> getSkuAttrValues(@PathVariable("skuId") Long skuId){ List<String> list= skuSaleAttrValueService.getSkuSaleAttrValuesAsStringList(skuId); return list; }
具体方法
<select id="getSkuSaleAttrValuesAsStringList" resultType="java.lang.String"> select concat(attr_name,":",attr_value) from pms_sku_sale_attr_value where sku_id=#{skuId} </select>
把之前Product服务里的线程池相关配置拿过来,做异步编排远程调用
cart模块远程接口
@FeignClient("gulimall-product") public interface ProductFeignService { @RequestMapping("/product/skuinfo/info/{skuId}") R info(@PathVariable("skuId") Long skuId); @GetMapping("/product/skusaleattrvalue/stringlist/{skuId}") List<String> getSkuAttrValues(@PathVariable("skuId") Long skuId); }
@Slf4j @Service public class CartServiceImpl implements CartService { @Autowired StringRedisTemplate redisTemplate; @Autowired ProductFeignService productFeignService; @Autowired ThreadPoolExecutor threadPoolExecutor; @Override public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //1.远程查询当前要添加的商品信息 CartItem cartItem = new CartItem(); CompletableFuture<Void> skuInfoFuture = CompletableFuture.runAsync(() -> { R info = productFeignService.info(skuId); if (info.getCode() != 0) { log.error("商品服务远程调用失败"); } SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() { }); cartItem.setCheck(true); cartItem.setCount(num); cartItem.setImage(skuInfo.getSkuDefaultImg()); cartItem.setTitle(skuInfo.getSkuTitle()); cartItem.setSkuId(skuId); cartItem.setPrice(skuInfo.getPrice()); },threadPoolExecutor);//使用自己的线程池进行调度 CompletableFuture<Void> attrsFuture = CompletableFuture.runAsync(() -> { //2.远程查询展示组合信息 List<String> skuAttrValues = productFeignService.getSkuAttrValues(skuId); cartItem.setSkuAttr(skuAttrValues); }, threadPoolExecutor); CompletableFuture.allOf(skuInfoFuture,attrsFuture).get();//等待两个步骤都完成再封装 String s = JSON.toJSONString(cartItem);//转成Json给redis cartOps.put(skuId.toString(),s); return cartItem; } /** * 获取到要操作的购物车 * * @return */ private BoundHashOperations<String, Object, Object> getCartOps() { //操作购物车 UserInfoVo userInfoVo = CartInterceptor.threadLocal.get(); String key = ""; if (userInfoVo.getUserId() != null) {//使用登录后的购物车 key = CartConstant.CART_PREFIX + userInfoVo.getUserId(); } else {//使用离线购物车 key = CartConstant.CART_PREFIX + userInfoVo.getUserKey(); } //绑定哈希操作 BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(key); return hashOps; } }
细化购物车--如果已经有这个商品了,就不需要远程调用去查了
@Override public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //1.远程查询当前要添加的商品信息 String res = (String) cartOps.get(skuId.toString()); if (StringUtils.isEmpty(res)) { CartItem cartItem = new CartItem(); CompletableFuture<Void> skuInfoFuture = CompletableFuture.runAsync(() -> { R info = productFeignService.info(skuId); if (info.getCode() != 0) { log.error("商品服务远程调用失败"); } SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() { }); cartItem.setCheck(true); cartItem.setCount(num); cartItem.setImage(skuInfo.getSkuDefaultImg()); cartItem.setTitle(skuInfo.getSkuTitle()); cartItem.setSkuId(skuId); cartItem.setPrice(skuInfo.getPrice()); }, threadPoolExecutor);//使用自己的线程池进行调度 CompletableFuture<Void> attrsFuture = CompletableFuture.runAsync(() -> { //2.远程查询展示组合信息 List<String> skuAttrValues = productFeignService.getSkuAttrValues(skuId); cartItem.setSkuAttr(skuAttrValues); }, threadPoolExecutor); CompletableFuture.allOf(skuInfoFuture, attrsFuture).get();//等待两个步骤都完成再封装 String s = JSON.toJSONString(cartItem);//转成Json给redis cartOps.put(skuId.toString(), s); return cartItem; }else{ //如果以前已经有了那么就直接加数量就行 CartItem cartItem = JSON.parseObject(res, CartItem.class); cartItem.setCount(cartItem.getCount()+num); //更新一下redis cartOps.put(skuId.toString(),JSON.toJSONString(cartItem)); return cartItem; } }
出现的问题,如果不停的刷新页面会不停的增加商品
//添加完数据之后直接重定向到另一个请求,这样就不会造成刷新这个页面重复添加数据,因为刷新的是下一个请求 @GetMapping("/addToCart") public String addToCart(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num, RedirectAttributes redirectAttributes) throws ExecutionException, InterruptedException { CartItem cartItem = cartService.addToCart(skuId, num); redirectAttributes.addAttribute("skuId", skuId);//给下面的请求传参数,model无法传参 return "redirect:http://cart.gulimall.com/addToCartSuccess.html";//重定向要写完整域名 } @GetMapping("/addToCartSuccess.html") public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) { //重定向到成功页面,再次查询购物车数据即可 CartItem cartItem = cartService.getCartItem(skuId); model.addAttribute("item", cartItem); return "success"; }
@Override public CartItem getCartItem(Long skuId) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); String o = (String) cartOps.get(skuId.toString()); CartItem cartItem = JSON.parseObject(o, CartItem.class); return cartItem; }
获取,合并购物车
@GetMapping("/cart.html") public String cartListPage(Model model) { Cart cart=cartService.getCart(); model.addAttribute("cart",cart); return "cartList"; }
//获得购物车 @Override public Cart getCart() throws ExecutionException, InterruptedException { Cart cart = new Cart(); UserInfoVo userInfoVo=CartInterceptor.threadLocal.get(); String tempKey = CartConstant.CART_PREFIX + userInfoVo.getUserKey(); String loginKey = CartConstant.CART_PREFIX + userInfoVo.getUserId(); if(userInfoVo.getUserId()!=null){ //用户登录 //如果临时购物车的数据还没有进行合并 List<CartItem> tempCartItems = getCartItems(tempKey); if(tempCartItems!=null&&tempCartItems.size()>0){ for (CartItem item : tempCartItems) { //todo 可优化部分 addToCart(item.getSkuId(), item.getCount()); } //清空临时购物车 clearCart(tempKey); } //获取登录后的购物车数据,此时已经包含了临时购物车的数据 List<CartItem> cartItems = getCartItems(loginKey); cart.setItems(cartItems); }else{ List<CartItem> cartItems = getCartItems(tempKey); cart.setItems(cartItems); } return cart; }
//清空购物车 @Override public void clearCart(String key) { redisTemplate.delete(key); }
//获取购物车的购物项根据key值,这里不能使用之前的获取ops方法,因为在用户登录的时候,用以前的方法只能获得用户登录购物车,无法获得临时购物车 private List<CartItem> getCartItems(String key) { BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(key); List<Object> values = hashOps.values(); if(values!=null&&values.size()>0){ List<CartItem> cartItems = values.stream().map(o -> { CartItem cartItem = JSON.parseObject(o.toString(), CartItem.class); return cartItem; }).collect(Collectors.toList()); return cartItems; } return null; }
勾选和改变购物项数量,后端操作几乎一样
这一块其实应该交给前端来做,这里只做了勾选
购物车controller
@GetMapping("/checkItem") public String checkItem(@RequestParam("skuId") Long skuId,@RequestParam("check") Integer check){ cartService.checkItem(skuId,check); return "redirect:http://cart.gulimall.com//cart.html";//重定向到购物车列表页 }
方法
//勾选购物项 @Override public void checkItem(Long skuId, Integer check) { CartItem cartItem = getCartItem(skuId); cartItem.setCheck(check==1); String jsonString = JSON.toJSONString(cartItem); BoundHashOperations<String, Object, Object> cartOps = getCartOps(); cartOps.put(skuId.toString(),jsonString); }
删除购物项
@GetMapping("/deleteItem") public String deleteItem(@RequestParam("skuId") Long skuId){ cartService.deleteItem(skuId); return "redirect:http://cart.gulimall.com//cart.html";//重定向到购物车列表页 }
@Override public void deleteItem(Long skuId) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); cartOps.delete(skuId.toString()); }