购物车技术详解
业务介绍
主要是商城的购物车模块,游客具有添加商品到临时购物车的权限,在用户登录后会吧临时购物车的内容清空合并到登录用户的购物车中去。购物车本身能够添加,改变数量,和选择选中的功能。
技术点分析
-
登录会通过认证服务存一个httpsession,购物车模块则需要获取到这个,进行后续业务处理
-
会通过ThreadLocal类对获取的登录信息加工处理
-
拦截器对方法执行前后拦截处理
-
ResponseBodyAdvice类对json响应进行在执行之后对response处理
-
业务中也会用到异步编排获取商品信息
代码详解
业务代码描述
教程中代码均为前后端不分离代码,且并没有采用流行的vue来写前端。对代码进行改造成后端只返回json
cart模块
- 首先对于登录和未登录的用户都要进行创建一个临时的购物车,这里我们使用拦截器处理
基于springboot我们拦截器需要配置mvc的请求拦截
GulimallWebConfig
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor())//注册拦截器
.addPathPatterns("/**");
}
}
- 拦截器内实现对登录用户的session获取,springsession
配置springsession参考springsession篇
-
创建一个ThreadLocal类存储用户信息
-
如果第一次使用购物车功能,都会给一个临时的用户身份
-
浏览器以后保存,每次访问都会带上这个cookie;
-
登录:session有 user-key为登录的id
-
没登录:按照cookie里面带来user-key来做
-
没登录:按照cookie也没有就随机创建一个uuid作为user-key作为临时登录用户
CartInterceptor
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(AuthServerConstant.LOGIN_USER);
if (memberResponseVo != null) {
//用户登录了
userInfoTo.setUserId(memberResponseVo.getId());
}
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
//user-key
String name = cookie.getName();
if (name.equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
userInfoTo.setUserKey(cookie.getValue());
//标记为已是临时用户
userInfoTo.setTempUser(true);
}
}
}
//如果没有临时用户一定分配一个临时用户
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();
//如果没有临时用户一定保存一个临时用户
if (!userInfoTo.getTempUser()) {
//创建一个cookie
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
//扩大作用域
cookie.setDomain("gulimall.com");
//设置过期时间
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {}
}
-
由于我对后端接口返回的数据都是json返回,响应和请求在拦截器 postHandle执行之前已经被提交了,因此我们需要对执行cookie存入的操作转移
-
查阅资料后发现 ResponseBodyAdvice可以对这种响应和请求提交之前进行操作,于是有了下面的代码
ResponseAdvice
@ControllerAdvice("com.xfwang.gulimall.cart.controller")
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Class aClass) {
return true;
}
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
//获取当前用户的值
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
HttpServletResponse res = ((ServletServerHttpResponse) serverHttpResponse).getServletResponse();
//如果没有临时用户一定保存一个临时用户
if (!userInfoTo.getTempUser()) {
//创建一个cookie
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
//扩大作用域
cookie.setDomain("gulimall.com");
//设置过期时间
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
res.addCookie(cookie);
}
return o;
}
}
- 这里的核心业务代码我们只对购物车的合并(临时用户的购物车在登录后合并到登录用户的购物车内)
以及添加购物车进行分析
核心业务代码
CartController
@RestController
public class CartController {
@Resource
private CartService cartService;
/**
* 获取当前用户的购物车商品项
* @return
*/
@GetMapping(value = "/currentUserCartItems")
public R getCurrentCartItems() {
List<CartItemVo> cartItemVoList = cartService.getUserCartItems();
return R.ok().setData(cartItemVoList);
}
/**
* 去购物车页面的请求
* 浏览器有一个cookie:user-key 标识用户的身份,一个月过期
* 如果第一次使用购物车功能,都会给一个临时的用户身份:
* 浏览器以后保存,每次访问都会带上这个cookie;
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做
* 第一次,如果没有临时用户,自动创建一个临时用户
*
* @return
*/
@GetMapping(value = "/cart")
public R cartListPage() throws ExecutionException, InterruptedException {
//快速得到用户信息:id,user-key
CartVo cartVo = cartService.getCart();
//获取当前用户的值
return R.ok().setData(cartVo);
}
/**
* 添加商品到购物车
* attributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次
* attributes.addAttribute():将数据放在url后面
* @return
*/
@GetMapping(value = "/addCartItem")
public R addCartItem(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num) throws ExecutionException, InterruptedException {
cartService.addToCart(skuId,num);
return R.ok();
}
...
}
CartServiceImpl
@Slf4j
@Service("cartService")
public class CartServiceImpl implements CartService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductFeignService productFeignService;
@Autowired
private ThreadPoolExecutor executor;
@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;
}
}
@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;
}
/**
* 获取用户登录或者未登录购物车里所有的数据
* @return
* @throws ExecutionException
* @throws InterruptedException
*/
@Override
public CartVo getCart() throws ExecutionException, InterruptedException {
CartVo cartVo = new CartVo();
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
if (userInfoTo.getUserId() != null) {
//1、登录
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
//临时购物车的键
String temptCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
//2、如果临时购物车的数据还未进行合并
List<CartItemVo> tempCartItems = getCartItems(temptCartKey);
if (tempCartItems != null) {
//临时购物车有数据需要进行合并操作
for (CartItemVo item : tempCartItems) {
addToCart(item.getSkuId(),item.getCount());
}
//清除临时购物车的数据
clearCartInfo(temptCartKey);
}
//3、获取登录后的购物车数据【包含合并过来的临时购物车的数据和登录后购物车的数据】
List<CartItemVo> cartItems = getCartItems(cartKey);
cartVo.setItems(cartItems);
} else {
//没登录
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
//获取临时购物车里面的所有购物项
List<CartItemVo> cartItems = getCartItems(cartKey);
cartVo.setItems(cartItems);
}
return cartVo;
}
/**
* 获取到我们要操作的购物车
* @return
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
//先得到当前用户信息
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
String cartKey = "";
if (userInfoTo.getUserId() != null) {
//gulimall:cart:1
cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
} else {
cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
}
//绑定指定的key操作Redis
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
return operations;
}
/**
* 获取购物车里面的数据
* @param cartKey
* @return
*/
private List<CartItemVo> getCartItems(String cartKey) {
//获取购物车里面的所有商品
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
List<Object> values = operations.values();
if (values != null && values.size() > 0) {
List<CartItemVo> cartItemVoStream = values.stream().map((obj) -> {
String str = (String) obj;
CartItemVo cartItem = JSON.parseObject(str, CartItemVo.class);
return cartItem;
}).collect(Collectors.toList());
return cartItemVoStream;
}
return null;
}
@Override
public List<CartItemVo> getUserCartItems() {
List<CartItemVo> cartItemVoList = new ArrayList<>();
//获取当前用户登录的信息
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
//如果用户未登录直接返回null
if (userInfoTo.getUserId() == null) {
return null;
} else {
//获取购物车项
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
//获取所有的
List<CartItemVo> cartItems = getCartItems(cartKey);
if (cartItems == null) {
throw new CartException();
}
//筛选出选中的
cartItemVoList = cartItems.stream()
.filter(items -> items.getCheck())
.map(item -> {
//更新为最新的价格(查询数据库)
BigDecimal price = productFeignService.getPrice(item.getSkuId());
item.setPrice(price);
return item;
})
.collect(Collectors.toList());
}
return cartItemVoList;
}
}
- 在添加购物车操作时涉及到异步编排(需要配置线程池)
参考异步编排篇