【雷丰阳-谷粒商城 】【分布式高级篇-微服务架构篇】【21】【购物车】


持续学习&持续更新中...

守破离


【雷丰阳-谷粒商城 】【分布式高级篇-微服务架构篇】【21】【购物车】

购物车需求描述

  • 用户可以在登录状态下将商品添加到购物车【用户购物车/在线购物车】

    • 放入数据库
    • mongodb
    • 放入 redis(采用)
    • 登录以后,会将临时购物车的数据全部合并过来,并清空临时购物车
  • 用户可以在未登录状态下将商品添加到购物车【游客购物车/离线购物车/临时购物车】

    • 放入 localstorage
    • cookie
    • WebSQL
    • (客户端存储,后台不存,后台就没法分析用户的购物偏好)
    • 放入 redis(采用,有价值的数据要放在后端存储,便于大数据分析)
    • 浏览器即使关闭,下次进入,临时购物车数据都在
  • 用户可以使用购物车一起结算下单

  • 给购物车添加商品

  • 用户可以查询自己的购物车

  • 用户可以在购物车中修改购买商品的数量。

  • 用户可以在购物车中删除商品。

  • 保存选中不选中商品的状态

  • 在购物车中展示商品优惠信息

  • 提示购物车商品价格变化

  • ...

注意:真实开发中,最好有一个Redis(集群) 专门负责购物车,不应该跟负责缓存的Redis混合起来使用

购物车数据结构

每一个购物项信息,都是一个对象,基本字段包括:

javascript 复制代码
{
	skuId: 2131241, 
	check: true, 
	title: "Apple iphone.....", 
	defaultImage: "...", 
	price: 4999, 
	count: 1, 
	totalPrice: 4999, 
	skuSaleVO: {...} 
}

购物车中不止一条数据,因此最终会是对象的数组:

json 复制代码
[
	{},
	{},
	...
]

Redis 有 5 种不同数据结构,这里选择哪一种比较合适呢?

  • 首先不同用户应该有独立的购物车,因此购物车应该以用户作为 key 来存储,Value 是 用户的购物车(所有购物项)信息。这样看来基本的k-v结构就可以了。

  • 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品 id 进行判断, 为了方便后期处理,购物车里面也应该是k-v结构,key 是商品 id,value 是这个商品的购物项信息。

  • 综上所述,我们的购物车结构是一个双层Map:Map<String, Map<String, CartItemInfo>>

  • 第一层 Map,Key 是用户 id ,Value 是用户对应的购物车

  • 第二层 Map,Key 是购物车中的商品 id,Value 是对应商品的购物项信息

Map<String k1, Map<String k2, CartItemInfo item> userCart>

  • k1:标识每一个用户的购物车
  • k2:购物项的商品id

在Redis中

  • key:用户标识
  • value:Hash(k:商品id,v:购物项详情)

数据Model抽取

java 复制代码
/**
 * 整个购物车
 * 需要计算的属性,必须重写他的get方法,保证每次获取属性都会进行计算
 */
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() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItem item : items) {
                count += 1;
            }
        }
        return count;
    }


    public BigDecimal getTotalAmount() {
        BigDecimal amount = new BigDecimal("0");
        //1、计算购物项总价
        if (items != null && items.size() > 0) {
            for (CartItem item : items) {
                if(item.getCheck()){
                    BigDecimal totalPrice = item.getTotalPrice();
                    amount = amount.add(totalPrice);
                }
            }
        }

        //2、减去优惠总价
        BigDecimal subtract = amount.subtract(getReduce());

        return subtract;
    }


    public BigDecimal getReduce() {
        return reduce;
    }

    public void setReduce(BigDecimal reduce) {
        this.reduce = reduce;
    }
}
java 复制代码
/**
 * 购物项内容
 */
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 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> getSkuAttr() {
        return skuAttr;
    }

    public void setSkuAttr(List<String> skuAttr) {
        this.skuAttr = skuAttr;
    }

    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;
    }
}

Redis本来就是<key,value>结构

所以,我们只需要BoundHashOperations<String, Object, Object> hashOperations = stringRedisTemplate.boundHashOps(CartConstant.CART_REDIS_KEY_PREFIX + key);,即可从Redis中获取一个类似于HashMap的对象,充当用户的购物车

保存购物项就可以这样写:hashOperations .put(skuId.toString(), JSON.toJSONString(cartItem));

获取购物项可以这样写:CartItem cartItem = JSON.parseObject(hashOperations.get(skuId.toString()), CartItem.class);

实现流程(参照京东)

user-key 是随机生成的 id,不管有没有登录都会有这个 cookie 信息。

  • 浏览器有一个cookie;user-key;标识用户身份,一个月后过期;
  • 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;user-key 这个 Cookie
  • 浏览器以后保存,每次访问都会带上这个cookie;
  • 登录:session中有用户信息
  • 没登录:按照cookie里面带来的user-key来做
  • 第一次使用购物车页面:如果没有临时用户user-key,就帮忙创建一个临时用户user-key。

ThreadLocal---同一个线程共享数据:(Map<Thread,Object> threadLocal )

java 复制代码
@ToString
@Data
public class UserInfoTo {
    private Long userId;
    private String userKey; //一定会封装,user-key 是随机生成的 id,不管有没有登录都会有这个 cookie 信息。

    private boolean flag = false; // 只需要让浏览器保存一次user-key这个cookie即可
}
java 复制代码
public class CartConstant {
    public static final String TEMP_USER_COOKIE_NAME = "user-key";
    public static final int TEMP_USER_COOKIE_TIMEOUT = 60*60*24*30; // Cookie的有效期 一个月后过期
}
java 复制代码
/**
 * 判断用户的登录状态。并封装传递(用户信息)给 controller。命令浏览器保存user-key这个Cookie
 */
public class GulimallCartInterceptor implements HandlerInterceptor {

    //   ThreadLocal: 同一个线程共享数据,可以让Controller等,快速得到用户信息UserInfoTo
    public static final ThreadLocal<UserInfoTo> THREAD_LOCAL = new ThreadLocal<>();

    /**
     * 目标方法执行之前
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserInfoTo userInfoTo = new UserInfoTo();

//        request是SpringSession已经包装过的
        MemberRespVo member = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if (null != member) { // 登录了
            userInfoTo.setUserId(member.getId());
//            利用GULISESSION这个Cookie(SpringSession配置的),也就是sessionId作为user-key合适吗?
//            不合适
//            虽然能判断并获取这个信息,但是你使用了认证服务的信息,符合微服务分模块开发吗?
//            而且这样用的话,会增加复杂性
//            比如在用户没有登陆的情况下,userInfoTo的user-key一定会被设置过了,并且浏览器也保存了这个user-key
//            然后用户再次登录,不能将这个信息作为user-key直接使用了,又得给UserInfoTo再增添一个字段,比如叫userSessionId,反正很麻烦
//            Cookie[] cookies = request.getCookies();
//            if (cookies != null && cookies.length > 0) {
//                for (Cookie cookie : cookies) {
//                    if (cookie.getName().equals("GULISESSION")) {
//                        System.out.println(cookie.getName() + "====>" + cookie.getValue());
//                        break;
//                    }
//                }
//            }
        }

//        判断浏览器有没有带来user-key这个Cookie
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
//                  浏览器有带来user-key这个cookie(不是第一次使用购物车页面)
                if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
                    userInfoTo.setUserKey(cookie.getValue());
                    // 浏览器已经保存了user-key这个Cookie,那么就不需要浏览器再次保存了,如果不设置,那么这个Cookie会无限续期
                    userInfoTo.setFlag(true);
                    break;
                }
            }
        }

//        只要没有user-key,不管你有没有登录,就代表是第一次使用购物车页面,都给你生成一个user-key
        if (StringUtils.isEmpty(userInfoTo.getUserKey())) { // 是第一次使用购物车页面
            String userKey = UUID.randomUUID().toString();
            userInfoTo.setUserKey(userKey);
        }

        THREAD_LOCAL.set(userInfoTo);

        return true;
    }

    /**
     * 业务执行之后;分配临时用户,让浏览器保存user-key
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserInfoTo userInfoTo = THREAD_LOCAL.get();

        //如果没有临时用户一定要让浏览器保存一个临时用户
        if (!userInfoTo.isFlag()) {
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
            cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
            cookie.setDomain("gulimall.com");
            response.addCookie(cookie); // 让浏览器保存user-key这个Cookie
        }
    }

}
java 复制代码
@Configuration
public class GulimallCartWebConfig implements WebMvcConfigurer {
    /**
     * 添加拦截器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new GulimallCartInterceptor()).addPathPatterns("/**");
    }
}

代码实现

两个功能比较重要:新增商品到购物车、查询购物车。

新增商品:判断是否登录

  • 是:则添加商品到后台 Redis 中,把 user 的唯一标识符作为 key。
  • 否:则添加商品到后台 redis 中,使用随机生成的 user-key 作为 key。
  • 购物车里有该商品,更改数量即可
  • 购物车里没有该商品, 添加新商品到购物车

查询购物车列表:判断是否登录

  • 否:直接根据 user-key 查询 redis 中数据并展示
  • 是:已登录,则需要先根据 user-key 查询 redis 是否有数据。
    • 有:合并离线购物车数据到登录用户的购物车,而后查询 redis。
    • 否:直接去后台查询 redis,而后返回。
java 复制代码
    private BoundHashOperations<String, Object, Object> getCurrentUserCartHashOps() {
        UserInfoTo userInfoTo = GulimallCartInterceptor.THREAD_LOCAL.get();
        String userKey = userInfoTo.getUserKey();
        Long userId = userInfoTo.getUserId();
        BoundHashOperations<String, Object, Object> hashOperations;
        if (userId != null) { // 登录了,操作登录购物车
            hashOperations = stringRedisTemplate.boundHashOps(CartConstant.CART_REDIS_KEY_PREFIX + userId);
        } else { // 没登陆,操作离线购物车
            hashOperations = stringRedisTemplate.boundHashOps(CartConstant.CART_REDIS_KEY_PREFIX + userKey);
        }
        return hashOperations;
    }

    private List<CartItem> listCartItems(Object userInfoKey) {
        BoundHashOperations<String, Object, Object> ops = stringRedisTemplate.boundHashOps(CartConstant.CART_REDIS_KEY_PREFIX + userInfoKey);
        List<Object> values = ops.values();
        if (null != values && values.size() > 0)
            return values.stream().map(item -> JSON.parseObject(item.toString(), CartItem.class)).collect(Collectors.toList());
        return null;
    }

添加商品到购物车:

java 复制代码
    @Override
    public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
        BoundHashOperations<String, Object, Object> cartOps = getCurrentUserCartHashOps();
        Object o = cartOps.get(skuId.toString());
        if (o != null) {
            // 购物车里有该商品,更改数量即可
            String cartItemJSON = o.toString();
            CartItem cartItem = JSON.parseObject(cartItemJSON, CartItem.class);
            cartItem.setCount(cartItem.getCount() + num);
            cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
            return cartItem;
        } else {
            // 购物车里没有该商品 添加新商品到购物车

            CartItem cartItem = new CartItem();

            //1、远程查询当前要添加的商品的信息
            CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
                R skuInfo = productFeignService.getSkuInfo(skuId);
                SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                });
                cartItem.setCheck(true);
                cartItem.setCount(num);
                cartItem.setImage(data.getSkuDefaultImg());
                cartItem.setTitle(data.getSkuTitle());
                cartItem.setSkuId(skuId);
                cartItem.setPrice(data.getPrice());
            }, executor);

            //2、远程查询sku的组合信息
            CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> {
                List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
                cartItem.setSkuAttr(values);
            }, executor);

            CompletableFuture.allOf(getSkuInfoTask, getSkuSaleAttrValues).get();
            String s = JSON.toJSONString(cartItem);
            cartOps.put(skuId.toString(), s);

            return cartItem;
        }
    }

获取用户的购物车:

java 复制代码
    @Override
    public Cart getCart() throws ExecutionException, InterruptedException {
        UserInfoTo userInfoTo = GulimallCartInterceptor.THREAD_LOCAL.get();
        Long userId = userInfoTo.getUserId();
        String userKey = userInfoTo.getUserKey();

        Cart cart = new Cart();

        if (userId != null) {
            // 如果用户有离线购物车,需要合并离线购物车到登录购物车,并且清空临时购物车
            List<CartItem> tempCartItems = listCartItems(userKey);
            if (null != tempCartItems && tempCartItems.size() > 0) {
                for (CartItem tempCartItem : tempCartItems) {
                    addToCart(tempCartItem.getSkuId(), tempCartItem.getCount()); // 合并离线购物车的购物项到登录购物车
                }
                CompletableFuture.runAsync(() -> {
                    stringRedisTemplate.delete(CartConstant.CART_REDIS_KEY_PREFIX + userKey); // 清空临时购物车
                }, executor);
            }
            // 登录了,展示登录购物车
            List<CartItem> loginCartItems = listCartItems(userId);
            cart.setItems(loginCartItems);
        } else {
            // 没登陆,展示离线购物车
            List<CartItem> tempCartItems = listCartItems(userKey);
            cart.setItems(tempCartItems);
        }

        return cart;
    }

controller使用:

java 复制代码
   /**
     * 浏览器有一个cookie;user-key;标识用户身份,一个月后过期;
     * 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;user-key这个Cookie
     * 浏览器以后保存,每次访问都会带上这个cookie;
     * <p>
     * 登录:session有用户信息
     * 没登录:按照cookie里面带来user-key来做
     * 第一次使用购物车页面:如果没有临时用户user-key,帮忙创建一个临时用户user-key。
     */
    @GetMapping("/cart.html")
    public String cartListPage(Model model) throws ExecutionException, InterruptedException {
//        Cart cart = cartService.getCart();
//        model.addAttribute("cart",cart);
//        UserInfoTo userInfoTo = GulimallCartInterceptor.THREAD_LOCAL.get();
//        System.out.println("CartController ===> " + userInfoTo);

        Cart cart = cartService.getCart();
        model.addAttribute("cart", cart);
        return "cartList";
    }

    /**
     * 添加商品到购物车
     * http://cart.gulimall.com/addToCart?skuId=1&num=1
     * 
     * RedirectAttributes ra
     *      ra.addFlashAttribute();将数据放在session里面可以在页面取出,但是只能取一次
     *      ra.addAttribute("skuId",skuId);将数据放在url后面
     */
    @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);
        return "redirect:http://cart.gulimall.com/addToCartSuccess"; //重定向到成功页面,防止用户刷新页面再次提交数据添加到购物车
    }

    /**
     * 跳转到成功页
     */
    @GetMapping("/addToCartSuccess")
    public String addToCartSuccess(@RequestParam("skuId") Long skuId, Model model) {
        //添加商品到购物车成功,再次查询购物车数据即可
        CartItem item = cartService.getCartItem(skuId);
        model.addAttribute("item", item);
        return "success";
    }

参考

雷丰阳: Java项目《谷粒商城》Java架构师 | 微服务 | 大型电商项目.


本文完,感谢您的关注支持!


相关推荐
Lee川5 小时前
深度拆解:基于面向对象思维的“就地编辑”组件全模块解析
javascript·架构
勤劳打代码5 小时前
Flutter 架构日记 — 状态管理
flutter·架构·前端框架
子兮曰11 小时前
后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!
前端·javascript·架构
卓卓不是桌桌13 小时前
如何优雅地处理 iframe 跨域通信?这是我的开源方案
javascript·架构
Qlly13 小时前
DDD 架构为什么适合 MCP Server 开发?
人工智能·后端·架构
用户881586910911 天前
AI Agent 协作系统架构设计与实践
架构
鹏北海1 天前
Qiankun 微前端实战踩坑历程
前端·架构
货拉拉技术1 天前
货拉拉海豚平台-大模型推理加速工程化实践
人工智能·后端·架构
茶杯梦轩2 天前
从零起步学习RabbitMQ || 第三章:RabbitMQ的生产者、Broker、消费者如何保证消息不丢失(可靠性)详解
分布式·后端·面试
RoyLin2 天前
libkrun 深度解析:架构设计、模块实现与 Windows WHPX 后端
架构