持续学习&持续更新中...
守破离
【雷丰阳-谷粒商城 】【分布式高级篇-微服务架构篇】【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架构师 | 微服务 | 大型电商项目.
本文完,感谢您的关注支持!