功能分析
功能需求
需求描述:
- 用户可以在登录状态下将商品添加到购物车
- 用户可以在未登录状态下将商品添加到购物车
- 用户可以使用购物车一起结算下单
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
提示购物车商品价格变化,数据结构,首先分析一下购物车的数据结构
数据结构
首先分析一下购物车的数据结构
因此每一个购物车信息,都是一个对象,基本字段包括:
{
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:userKey
value:
存储一个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; // 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;
}
/**
* 计算当前购物项总价
*/
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.preHandle
1)获取用户登录信息userId,封装到ThreadLocal中,controller可以拿到
2)用户未登录,分配userKey封装到ThreadLocal中,controller可以拿到
2.postHandle
1)判断客户端是否存在游客用户标识
不存在则创建cookie,命令客户端保存游客信息user-key
购物车系统根据用户的登录状态,购物车的增删改处理方式不同,因此需要添加登录校验。而登录状态的校验如果在每个方法中进行校验,会造成代码的冗余,不利于维护。所以这里使用拦截器统一处理。
springboot自定义拦截器:
- 编写自定义拦截器类实现HandlerInterceptor接口(前置方法 后置方法 完成方法)
- 编写配置类(添加@Configuration注解)实现WebMvcConfigurer接口(重写addInterceptors方法)
@Component
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();
@Override
public 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-key
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
// 如果cookie有user_key
if (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
*/
@Override
public 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
*/
@Override
public 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);
}
}
注册拦截器
/**
* 配置拦截器
*/
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public 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
*/
@Override
public 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中有数据,那么更新数据,如果没有添加数据。
@Override
public 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());// 商品ID
cartItem.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);
// 等待两个线程都完成才保存到redis
CompletableFuture.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";
}
查询内容实现类
@Override
public 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
*/
@Override
public 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操作Redis
BoundHashOperations<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";
}
实现类
@Override
public 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));
}
@Override
public 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));
}
@Override
public void deleteItemBySkuId(Long skuId) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
cartOps.delete(skuId.toString());
}