2.6 购物车服务
2.6.1 环境搭建
①域名配置
②创建 微服务
暂时需要的插件
- 此外,导入 公共包的依赖
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
- application.properties配置
server.port=40000
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
- 主启动类加上nacos服务发现注册注解,以及后面需要用到的远程服务注解,并且因为导入了common包,需要暂时排除数据库自动配置
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
③动静分离
- 静态资源放到 Nginx中
- 动态页面放到 templates下。
- 页面前缀替换:
- 网关配置
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
⑦测试,为了方便,将success页面重命名为index页面
出现问题,替换:将 th:替换成空字符串
前端页面跳转
点击 图标等能返回首页。
2.6.2 数据模型分析
1、购物车需求
1)、需求描述:
- 用户可以在登录状态下将商品添加到购物车【用户购物车/在线购物车】
- 放入数据库
- mongodb
- 放入redis(采用)
登录以后,会将临时购物车的数据全部合并过来,并清空临时购物车;
- 用户可以在未登录状态下将商品添加到购物车【游客购物车/离线购物车/临时购物车】
- 放入localstorage(客户端存储,后台不存)
- cookie
- WebSQL
- 放入redis(采用)
浏览器即使关闭,下次进入,临时购物车数据都在
- 用户可以使用购物车一起结算下单
- 给购物车添加商品
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 选中不选中商品
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化
2)、数据结构
因此每一个购物项信息,都是一个对象,基本字段包括:
{
skuId: 2131241,
check: true,
title: "Apple iphone.....",
defaultImage: "...",
price: 4999,
count: 1,
totalPrice: 4999,
skuSaleVO: {
...}
}
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
[
{
...},{
...},{
...}
]
Redis 有5 种不同数据结构,这里选择哪一种比较合适呢?Map<String, List>
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为key 来存储,Value 是
用户的所有购物车信息。这样看来基本的k-v
结构就可以了。 - 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品id 进行判断,
为了方便后期处理,我们的购物车也应该是k-v
结构,key 是商品id,value 才是这个商品的
购物车信息。
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
- 第一层Map,Key 是用户id
- 第二层Map,Key 是购物车中商品id,值是购物项数据
将购物车中的购物项存为list类型的话,修改起来太麻烦要从头到尾遍历。可以使用hash来存储购物车中的购物项
2.6.3 vo编写
购物项的Vo编写
/**购物项内容
*/
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;
}
}
编写购车Vo
/**整个购物车
* 需要计算的属性,必须重写它的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) {
BigDecimal totalPrice = item.getTotalPrice();
amount = amount.add(totalPrice);
}
}
//2、减去优惠总价
BigDecimal subtract = amount.subtract(getReduce());
return subtract;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public BigDecimal getReduce() {
return reduce;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
2.6.4 ThreadLocal用户身份鉴别
1.将购物车数据存储至Redis中,因此,需要导入Spring整合Redis的依赖以及Redis的配置。项目上线之后,应该有一个专门的Redis负责存储购物车的数据不应该使用缓存的Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置redis地址
spring.redis.host=192.168.56.10
2.编写服务层
@Slf4j
@Service
public class CartServiceImpl implements CartService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
3.判断用户是否登录则通过判断Session中是否有用户的数据,因此,导入SpringSession的依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
配置Session
/**自定义SpringSession完成子域session共享
* @author wystart
* @create 2022-12-06 21:48
*/
@EnableRedisHttpSession
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");// 扩大session作用域,也就是cookie的有效域
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
// 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
4. cookie中的user-key说明
第一次访问京东,会给你的cookie中设置user-key标识你的身份,有效期为一个月,浏览器会保存你的user-key,以后访问都会带上
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
5.编写To与常量
购物车服务下
@ToString
@Data
public class UserInfoTo {
private Long userId;
private String userKey;//一定要封装
private boolean tempUser = false;//标识位
}
公共服务下:新建 CartConstant
public class CartConstant {
public static final String TEMP_USER_COOKIE_NAME = "user-key";
//过期时间为1一个月
public static final int TEMP_USER_COOKIE_TIMEOUT = 60*60*24*30;//临时用户的过期时间
}
6.编写拦截器
拦截器逻辑:业务执行之前,判断是否登录,若登录则封装用户信息,将标识位设置为true,postHandler就不再设置作用域和有效时间,否则为其创建一个user-key
注意细节:整合SpringSession之后,Session获取数据都是从Redis中获取的
使用ThreadLocal,解决线程共享数据问题,方便同一线程共享UserInfoTo
编写拦截器实现类
/**在执行目标方法之前,判断用户的登录状态,并封装传递给controller目标请求
* @author wystart
* @create 2022-12-08 20:58
*/
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> threadLocal = 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();
MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (member != null){
//用户登录
userInfoTo.setUserId(member.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());
}
}
}
//如果没有临时用户一定分配一个临时用户
if (StringUtils.isEmpty(userInfoTo.getUserKey())){
String uuid = UUID.randomUUID().toString();
userInfoTo.setUserKey(uuid);
}
//目标方法执行之前
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("gulimall.com");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
}
}
}
配置拦截器,否则拦截器不生效
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
//配置CartInterceptor拦截器拦截所有请求
registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
}
}
@Controller
public class CartController {
/**
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
* @return
*/
@GetMapping("/cart.html")
public String cartListPage(){
//1、快速得到用户信息,id,user-key
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
System.out.println(userInfoTo);
return "cartList";
}
}
Debug测试UserInfoTo中是否有数据
user-key也有
2.6.5 页面环境搭建
- 首页点击购物车去购物车页面
index.html
- 检索页面点击 我的购物车去购物车页面
list.html
- 商品详情页修改
item.html
CartController
/**
* 添加商品到购物车
* @return
*/
@GetMapping("/addToCart")
public String addToCart(){
return "success";
}
- 加入商品成功后,跳转到购物车列表页面
success.html
这里“查看商品详情暂时写死了”!!
- 购物车详情页面
cartList.html
2.6.6 添加购物车
编写添加商品进入购物车的请求方法,需要知道商品的SkuId和数量
为加入购物车绑定单击事件,url改为#避免跳转并且设置id;为超链接自定义属性,用于存储skuId
<a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}">
加入购物车
</a>
为文本框设置id
编写单击事件 ,$(this)指当前实例,return false : 禁止默认行为
$("#addToCartA").click(function (){
var num = $("#numInput").val();//获取数量
var skuId = $(this).attr("skuId");//获取商品的skuId
location.href = "http://cart.gulimall.com/addToCart?skuId="+skuId+"&num="+num;//拼接路径
return false;//取消默认行为
})
修改加入购物车的成功页面的显示
①默认图片的显示、商品详情页跳转以及标题显示、商品数量显示
购物车前缀
boundHashOps()方法:所有的增删改查操作只针对这个key
将其抽取成方法
选中->右击->Refactor->Extract Method
/**
* 获取到我们要操作的购物车
* @return
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
String cartKey = "";
if (userInfoTo.getUserId() != null){
//gulimall:cart:1 ----登录用户
cartKey = CART_PREFIX + userInfoTo.getUserId();
}else{
//gulimall:cart:xxxxx ----临时用户
cartKey = CART_PREFIX + userInfoTo.getUserKey();
}
//redisTemplate.boundHashOps(cartKey):以后关于这个cartKey就都绑定到redis中操作了
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
return operations;
}
远程调用product查询sku详情
记得主启动类要加上@EnableFeignClients:开启远程调用注解
远程调用product服务查询销售属性
①编写product服务中的查询销售属性AsString的接口
SkuSaleAttrValueController
@GetMapping("/stringlist/{skuId}")
public List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId){
return skuSaleAttrValueService.getSkuSaleAttrValuesAsStringList(skuId);
}
SkuSaleAttrValueServiceImpl
@Override
public List<String> getSkuSaleAttrValuesAsStringList(Long skuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
return dao.getSkuSaleAttrValuesAsStringList(skuId);
}
数据库查询SQL语句
select concat(attr_name,":",attr_value)
from `pms_sku_sale_attr_value`
where sku_id = 21
SkuSaleAttrValueDao.xml
<select id="getSkuSaleAttrValuesAsStringList" resultType="java.lang.String">
select concat(attr_name,":",attr_value)
from `pms_sku_sale_attr_value`
where sku_id = #{skuId}
</select>
购物车服务远程调用接口
@FeignClient("gulimall-product")
public interface ProductFeignService {
@RequestMapping("/product/skuinfo/info/{skuId}")
//@RequiresPermissions("product:skuinfo:info")
R getSkuInfo(@PathVariable("skuId") Long skuId);
@GetMapping("/product/skusaleattrvalue/stringlist/{skuId}")
List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId);
}
配置线程池提高查询效率:因为多次调用远程服务
MyThreadConfig(直接复制之前商品服务的)
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){
return new ThreadPoolExecutor(pool.getCoreSize(),
pool.getMaxSize(),pool.getKeepAliveTime(),
TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
ThreadPoolConfigProperties
@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
application.properties配置
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
使用异步编排
①编写vo,属性从SkuInfoEntity中copy
@Data
public class SkuInfoVo {
private Long skuId;
/**
* spuId
*/
private Long spuId;
/**
* sku名称
*/
private String skuName;
/**
* sku介绍描述
*/
private String skuDesc;
/**
* 所属分类id
*/
private Long catalogId;
/**
* 品牌id
*/
private Long brandId;
/**
* 默认图片
*/
private String skuDefaultImg;
/**
* 标题
*/
private String skuTitle;
/**
* 副标题
*/
private String skuSubtitle;
/**
* 价格
*/
private BigDecimal price;
/**
* 销量
*/
private Long saleCount;
}
②异步编排
@Autowired
ThreadPoolExecutor executor;
@Override
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
CartItem cartItem = new CartItem();
//异步编排
CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
//1、远程调用商品服务查询商品详情
R skuInfo = productFeignService.getSkuInfo(skuId);
SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
//2、商品添加 到购物车
cartItem.setCheck(true);
cartItem.setCount(num);
cartItem.setImage(data.getSkuDefaultImg());
cartItem.setTitle(data.getSkuTitle());
cartItem.setSkuId(skuId);
cartItem.setPrice(data.getPrice());
}, executor);
//3、远程查询sku的组合信息
//同时调用多个远程服务,为了不影响最终的查询速度,我们可以使用多线程的方式,使用自定义的线程池提高效率
CompletableFuture<Void> getSkuoSaleAttrValues = CompletableFuture.runAsync(() -> {
List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
cartItem.setSkuAttr(values);
}, executor);
CompletableFuture.allOf(getSkuInfoTask,getSkuoSaleAttrValues).get();
String s = JSON.toJSONString(cartItem);
cartOps.put(skuId.toString(),s);
return cartItem;
}
测试:
出现问题:
不能使用th:else,改为 th:if
item.html
list.html
未登录状态
已登录状态
redis中存储:cart:3代表已登录状态;cart:xxxxx代表临时用户,没有登录状态。
完善购物车添加细节①:之前是都认为购物车中没有要添加的此商品存在,现在要判断购物车中是否有我们要添加的此商品。也即是:上面的操作是针对添加新商品进购物车,若购物车里已存在此商品则是一个数量的叠加
@Override
public CartItem addToCart(Long skuId