1. 购物车功能分析 1.1. 功能需求 程序源码论坛-1024,网址 www.cx1314.cn 仅分享最流行最优质的IT资源!
不同于其他论坛平台,这里只有精品、稀有资源,已泛滥、已过时、垃圾资源不录入!
Java,前端,python,人工智能,大数据,云计算...持续更新资源-最新完整且均不加密、、、
活动线报,宅男福利,最新大片…
程序员的新大陆-更新最快的IT资源社区!开发者必备平台!
欢迎访问:www.cx1314.cn 百度搜索-> 程序源码论坛
需求描述:
用户可以在登录状态下将商品添加到购物车
用户可以在未登录状态下将商品添加到购物车
用户可以使用购物车一起结算下单
用户可以查询自己的购物车
用户可以在购物车中修改购买商品的数量。
用户可以在购物车中删除商品。
在购物车中展示商品优惠信息
提示购物车商品价格变化
提示购物车商品价格变化,数据结构,首先分析一下购物车的数据结构
1.2. 数据结构 首先分析一下购物车的数据结构
因此每一个购物车信息,都是一个对象,基本字段包括:
1 2 3 4 5 6 7 8 9 10 11 12 13 { id : 1 , userId : '2' , skuId : 2131241 , check : true , title : "Apple iphone....." , image : "..." , price : 4999 , count : 1 , store : true , saleAttrs : [{..},{..}], sales : [{..},{..}] }
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
1.3. 怎么保存 由于购物车是一个读多写多的场景,为了应对高并发场景,所有购物车采用的存储方案也和其他功能,有所差别。
主流的购物车数据存储方案 :
redis(登录/未登录):性能高,代价高,不利于数据分析
mysql(登录/未登录):性能低,成本低,利于数据分析
cookie(未登录):未登录时,不需要和服务器交互,性能提高。其他请求会占用带宽
localStorage/IndexedDB/WebSQL(未登录):不需要和服务器交互,不占用带宽
一般情况下,企业级购物车通常采用组合方案 :
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,值是购物车数据
1.4. 流程分析 参照jd:
user-key是游客id,不管有没有登录都会有这个cookie信息。
两个功能:新增商品到购物车、查询购物车。
新增商品:判断是否登录
是:则添加商品到后台Redis+mysql中,把user的唯一标识符作为key。
否:则添加商品到后台Redis+mysql中,使用随机生成的user-key作为key。
查询购物车列表:判断是否登录
否:直接根据user-key查询redis中数据并展示
是:已登录,则需要先根据user-key查询redis是否有数据。
有:需要先合并数据(redis + mysql),而后查询。
否:直接去后台查询redis,而后返回。
2. 搭建购物车服务 2.1. 表设计 创建guli_cart数据库,创建下表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 CREATE TABLE `cart_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` varchar(30) NOT NULL COMMENT '用户id或者userKey', `sku_id` bigint(20) NOT NULL COMMENT 'skuId', `check` tinyint(4) NOT NULL COMMENT '选中状态', `title` varchar(255) NOT NULL COMMENT '标题', `default_image` varchar(255) DEFAULT NULL COMMENT '默认图片', `price` decimal(18,2) NOT NULL COMMENT '加入购物车时价格', `count` int(11) NOT NULL COMMENT '数量', `store` tinyint(4) NOT NULL COMMENT '是否有货', `sale_attrs` varchar(100) DEFAULT NULL COMMENT '销售属性(json格式)', `sales` varchar(255) DEFAULT NULL COMMENT '营销信息(json格式)', PRIMARY KEY (`id`), KEY `idx_uid_sid` (`user_id`,`sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2.2. 创建工程 购物车系统也应该搭建两个工程,购物车服务的提供方(CRUD操作)及服务消费方(为页面提供数据)。
这里我们只搭建一个工程,推荐你们尝试搭建两个工程
pom依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 <?xml version="1.0" encoding="UTF-8" ?> <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > com.atguigu</groupId > <artifactId > gmall-1010</artifactId > <version > 0.0.1-SNAPSHOT</version > </parent > <groupId > com.atguigu</groupId > <artifactId > gmall-cart</artifactId > <version > 0.0.1-SNAPSHOT</version > <name > gmall-cart</name > <description > 谷粒商城购物车系统</description > <properties > <java.version > 1.8</java.version > </properties > <dependencies > <dependency > <groupId > com.atguigu</groupId > <artifactId > gmall-common</artifactId > <version > 0.0.1-SNAPSHOT</version > </dependency > <dependency > <groupId > com.atguigu</groupId > <artifactId > gmall-pms-interface</artifactId > <version > 0.0.1-SNAPSHOT</version > </dependency > <dependency > <groupId > com.atguigu</groupId > <artifactId > gmall-sms-interface</artifactId > <version > 0.0.1-SNAPSHOT</version > </dependency > <dependency > <groupId > com.atguigu</groupId > <artifactId > gmall-wms-interface</artifactId > <version > 0.0.1-SNAPSHOT</version > </dependency > <dependency > <groupId > com.atguigu</groupId > <artifactId > gmall-cart-interface</artifactId > <version > 0.0.1-SNAPSHOT</version > </dependency > <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > com.alibaba.cloud</groupId > <artifactId > spring-cloud-starter-alibaba-nacos-config</artifactId > </dependency > <dependency > <groupId > com.alibaba.cloud</groupId > <artifactId > spring-cloud-starter-alibaba-nacos-discovery</artifactId > </dependency > <dependency > <groupId > com.alibaba.cloud</groupId > <artifactId > spring-cloud-starter-alibaba-sentinel</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-openfeign</artifactId > </dependency > <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-zipkin</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > <exclusions > <exclusion > <groupId > org.junit.vintage</groupId > <artifactId > junit-vintage-engine</artifactId > </exclusion > </exclusions > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
bootstrap.yml:
1 2 3 4 5 6 7 spring: application: name: cart-service cloud: nacos: config: server-addr: 127.0 .0 .1 :8848
application.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 server: port: 18090 spring: cloud: nacos: discovery: server-addr: localhost:8848 sentinel: transport: dashboard: localhost:8080 port: 8719 zipkin: base-url: http://localhost:9411/ sender: type: web discovery-client-enabled: false sleuth: sampler: probability: 1 redis: host: 172.16 .116 .100 datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://172.16.116.100:3306/guli_cart username: root password: root thymeleaf: cache: false feign: sentinel: enabled: true
启动类:
1 2 3 4 5 6 7 8 9 10 @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients @MapperScan("com.atguigu.gmall.cart.mapper") public class GmallCartApplication { public static void main (String[] args) { SpringApplication.run(GmallCartApplication.class, args); } }
网关配置:
nginx配置中追加域名映射:重新加载nginx
并在hosts文件中追加域名映射:
1 172.16.116.100 api.gmall.com manager.gmall.com www.gmall.com gmall.com static.gmall.com search.gmall.com item.gmall.com sso.gmall.com cart.gmall.com order.gmall.com
2.3. 添加登录校验 购物车系统根据用户的登录状态,购物车的增删改处理方式不同,因此需要添加登录校验。而登录状态的校验如果在每个方法中进行校验,会造成代码的冗余,不利于维护。所以这里使用拦截器统一处理。
springboot自定义拦截器:
编写自定义拦截器类实现HandlerInterceptor接口(前置方法 后置方法 完成方法)
编写配置类(添加@Configuration注解)实现WebMvcConfigurer接口(重写addInterceptors方法)
2.3.1. 编写拦截器 1 2 3 4 5 6 7 8 9 10 @Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true ; } }
2.3.2. 配置拦截器 配置SpringMVC,使过滤器生效:
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor).addPathPatterns("/**" ); } }
2.3.3. 编写Controller测试拦截器 1 2 3 4 5 6 7 8 9 @Controller public class CartController { @GetMapping("test") @ResponseBody public String test () { return "hello cart!" ; } }
debug启动后,访问:http://cart.gmall.com/test进入拦截器
说明拦截器已经生效
2.3.4. 传递登录信息 拦截器定义好了,将来怎么把拦截器中获取的用户信息传递给后续的每个业务逻辑:
public类型的公共变量。线程不安全
request对象。不够优雅
ThreadLocal线程变量。推荐
实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 @Component public class LoginInterceptor implements HandlerInterceptor { private static final ThreadLocal<UserInfo> THREAD_LOCAL = new ThreadLocal <>(); @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { UserInfo userInfo = new UserInfo (); userInfo.setUserId(1l ); userInfo.setUserKey(UUID.randomUUID().toString()); THREAD_LOCAL.set(userInfo); return true ; } public static UserInfo getUserInfo () { return THREAD_LOCAL.get(); } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { THREAD_LOCAL.remove(); } }
声明ThreadLocal中的载荷对象UserInfo
内容如下:
1 2 3 4 5 6 @Data public class UserInfo { private Long userId; private String userKey; }
在controller中尝试获取登录信息:
1 2 3 4 5 6 7 8 9 10 11 @Controller public class CartController { @GetMapping("test") @ResponseBody public String test () { UserInfo userInfo = LoginInterceptor.getUserInfo(); System.out.println(userInfo); return "hello cart!" ; } }
debug启动访问:http://cart.gmall.com/test
效果如下:可以获取到userInfo载荷信息
2.3.5. 拦截器代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 @Component @EnableConfigurationProperties({JwtProperties.class}) public class LoginInterceptor implements HandlerInterceptor { private static final ThreadLocal<UserInfo> THREAD_LOCAL = new ThreadLocal <>(); @Autowired private JwtProperties jwtProperties; @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userKey = CookieUtil.getCookieValue(request, jwtProperties.getUserKey()); if (StringUtils.isBlank(userKey)){ userKey = UUID.randomUUID().toString(); CookieUtil.setCookie(request, response, jwtProperties.getUserKey(), userKey, jwtProperties.getExpireTime()); } UserInfo userInfo = new UserInfo (); userInfo.setUserKey(userKey); String token = CookieUtil.getCookieValue(request, jwtProperties.getCookieName()); if (StringUtils.isNotBlank(token)){ try { Map<String, Object> map = JwtUtil.getInfoFromToken(token, jwtProperties.getPublicKey()); Long userId = Long.valueOf(map.get("userId" ).toString()); userInfo.setUserId(userId); } catch (Exception e) { e.printStackTrace(); } } THREAD_LOCAL.set(userInfo); return true ; } public static UserInfo getUserInfo () { return THREAD_LOCAL.get(); } @Override public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { THREAD_LOCAL.remove(); } }
JwtProperties读取配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Data @Slf4j @ConfigurationProperties(prefix = "auth.jwt") public class JwtProperties { private String pubKeyPath; private String cookieName; private String userKey; private Integer expireTime; private PublicKey publicKey; @PostConstruct public void init () { try { this .publicKey = RsaUtil.getPublicKey(pubKeyPath); } catch (Exception e) { log.error("生成公钥和私钥出错" ); e.printStackTrace(); } } }
对应配置如下:
1 2 3 4 5 6 auth: jwt: pubKeyPath: D:\\project-1010\\rsa\\rsa.pub cookieName: GMALL-TOKEN userKey: userKey expireTime: 15552000
重启后测试,效果如下:可以获取到userId及userKey信息
2.4. 实体类及feign接口 添加实体类、mapper接口及feign接口:
购物车实体类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Data @TableName("cart_info") public class Cart { @TableId private Long id; @TableField("user_id") private String userId; @TableField("sku_id") private Long skuId; @TableField("`check`") private Boolean check; private String image; private String title; @TableField("sale_attrs") private String saleAttrs; private BigDecimal price; private BigDecimal count; private Boolean store = false ; private String sales; }
mapper接口:
1 2 public interface CartMapper extends BaseMapper <Cart> {}
Feign接口:
1 2 3 @FeignClient("pms-service") public interface GmallPmsClient extends GmallPmsApi {}
1 2 3 @FeignClient("sms-service") public interface GmallSmsClient extends GmallSmsApi {}
1 2 3 @FeignClient("wms-service") public interface GmallWmsClient extends GmallWmsApi {}
在gmall-pms工程的SkuSaleAttrValueController中新增根据skuId查询销售属性及值:
1 2 3 4 5 6 7 @ApiOperation("查询sku的所有销售属性") @GetMapping("all/{skuId}") public ResponseVo<List<SkuAttrValueEntity>> querySkuAttrValuesBySkuId (@PathVariable("skuId") Long skuId) { List<SkuAttrValueEntity> skuAttrValueEntities = this .skuAttrValueService.list(new QueryWrapper <SkuAttrValueEntity>().eq("sku_id" , skuId)); return ResponseVo.ok(skuAttrValueEntities); }
给gmall-pms-interface工程的GmallPmsApi添加接口方法:
1 2 @GetMapping("pms/skuattrvalue/all/{skuId}") public ResponseVo<List<SkuAttrValueEntity>> querySkuSaleAttrValueBySkuId (@PathVariable("skuId") Long skuId) ;
3. 新增购物车 参照京东,在商品详情页,鼠标放在加入购物车按钮上,如下:
可以看到请求地址:https://cart.jd.com/gate.atction?pid=100011336082&pcount=1&ptype=1
pid:skuId
pcount:商品数量
请求方式肯定是a标签的href属性发送请求(GET请求),否则这里看不到地址。
新增成功后,会跳转到如下页面:
页面的地址变成了:https://cart.jd.com/addToCart.html?rcd=1&pid=100005138103&pc=1&eb=1&rid=1590586387170&em=
可以发现添加购物车成功的页面地址和加入购物车时的链接地址不一样了。说明添加购物车成功后,做了重定向。F12查看控制台,发现确实做了重定向(gate.action请求的状态码是302)
3.1. CartController 我们模仿京东:
加入购物车
请求方式:Get
请求路径:无
请求参数:?skuId=40&count=2
添加成功,重定向
请求方式:Get
请求路径:addCart.html
请求参数:?skuId=40
具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package com.atguigu.gmall.cart.controller;import com.atguigu.gmall.cart.bean.Cart;import com.atguigu.gmall.cart.bean.UserInfo;import com.atguigu.gmall.cart.interceptor.LoginInterceptor;import com.atguigu.gmall.cart.service.CartService;import com.atguigu.gmall.common.bean.ResponseVo;import org.hibernate.validator.constraints.CodePointLength;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.*;import javax.servlet.http.HttpServletRequest;import javax.xml.ws.Response;import java.util.List;@Controller public class CartController { @Autowired private CartService cartService; @GetMapping public String addCart (Cart cart) { if (cart == null || cart.getSkuId() == null ){ throw new RuntimeException ("没有选择添加到购物车的商品信息!" ); } this .cartService.addCart(cart); return "redirect:http://cart.gmall.com/addCart.html?skuId=" + cart.getSkuId(); } @GetMapping("addCart.html") public String addCart (@RequestParam("skuId") Long skuId, Model model) { Cart cart = this .cartService.queryCartBySkuId(skuId); model.addAttribute("cart" , cart); return "addCart" ; } @GetMapping("test") @ResponseBody public String test () { UserInfo userInfo = LoginInterceptor.getUserInfo(); System.out.println(userInfo); return "hello cart!" ; } }
3.2. CartService 基本思路:
先查询之前的购物车数据
判断要添加的商品是否存在
存在:则直接修改数量后写回Redis及mysql
不存在:新建一条数据,然后写入Redis及mysql
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 @Service public class CartService { @Autowired private GmallPmsClient pmsClient; @Autowired private GmallSmsClient smsClient; @Autowired private GmallWmsClient wmsClient; @Autowired private StringRedisTemplate redisTemplate; @Autowired private CartMapper cartMapper; private static final String KEY_PREFIX = "cart:info:" ; public void addCart (Cart cart) { String userId = getUserId(); String key = KEY_PREFIX + userId; BoundHashOperations<String, Object, Object> hashOps = this .redisTemplate.boundHashOps(key); String skuId = cart.getSkuId().toString(); BigDecimal count = cart.getCount(); if (hashOps.hasKey(skuId)) { String cartJson = hashOps.get(skuId).toString(); cart = JSON.parseObject(cartJson, Cart.class); cart.setCount(cart.getCount().add(count)); this .cartMapper.update(cart, new UpdateWrapper <Cart>().eq("user_id" , cart.getUserId()).eq("sku_id" , cart.getSkuId())); } else { cart.setUserId(userId); ResponseVo<SkuEntity> skuEntityResponseVo = this .pmsClient.querySkuById(cart.getSkuId()); SkuEntity skuEntity = skuEntityResponseVo.getData(); if (skuEntity != null ) { cart.setTitle(skuEntity.getTitle()); cart.setPrice(skuEntity.getPrice()); cart.setImage(skuEntity.getDefaultImage()); } ResponseVo<List<SkuAttrValueEntity>> skuattrValueResponseVo = this .pmsClient.querySkuAttrValuesBySkuId(cart.getSkuId()); List<SkuAttrValueEntity> skuAttrValueEntities = skuattrValueResponseVo.getData(); cart.setSaleAttrs(JSON.toJSONString(skuAttrValueEntities)); ResponseVo<List<ItemSaleVo>> itemSaleVoResposneVo = this .smsClient.querySalesBySkuId(cart.getSkuId()); List<ItemSaleVo> itemSaleVos = itemSaleVoResposneVo.getData(); cart.setSales(JSON.toJSONString(itemSaleVos)); ResponseVo<List<WareSkuEntity>> listResponseVo = this .wmsClient.queryWareSkusBySkuId(cart.getSkuId()); List<WareSkuEntity> wareSkuEntities = listResponseVo.getData(); if (!CollectionUtils.isEmpty(wareSkuEntities)) { cart.setStore(wareSkuEntities.stream().anyMatch(wareSkuEntity -> wareSkuEntity.getStock() - wareSkuEntity.getStockLocked() > 0 )); } cart.setCheck(true ); this .cartMapper.insert(cart); } hashOps.put(skuId, JSON.toJSONString(cart)); } public Cart queryCartBySkuId (Long skuId) { String userId = getUserId(); String key = KEY_PREFIX + userId; BoundHashOperations<String, Object, Object> hashOps = this .redisTemplate.boundHashOps(key); if (hashOps.hasKey(skuId.toString())){ String cartJson = hashOps.get(skuId.toString()).toString(); return JSON.parseObject(cartJson, Cart.class); } throw new RuntimeException ("您的购物车中没有该商品记录!" ); } private String getUserId () { UserInfo userInfo = LoginInterceptor.getUserInfo(); if (userInfo.getUserId() != null ) { return userInfo.getUserId().toString(); } return userInfo.getUserKey(); } }
3.3. 结果 测试未登录状态的购物车:
响应:
redis的数据:
再次发送相同参数的请求,购物车数量会累计;换个skuId的商品,该游客会有两条购物车记录
测试已登录状态的购物车:略。。。
4. 异步优化新增购物车 目前添加购物车我们使用的是同步操作redis与mysql,这样效率比较低,并发量不高,如何优化呢?我们可以采取同步操作reids,异步更新mysql的方式,如何实现呢?
在日常开发中,我们的逻辑都是同步调用 ,顺序执行。在一些场景下,我们会希望异步调用,将和主线程关联度低的逻辑异步调用 ,以实现让主线程更快的执行完成,提升性能。例如说:记录用户访问日志到数据库,记录管理员操作日志到数据库中。
考虑到异步调用的可靠性 ,我们一般会考虑引入分布式消息队列,例如说 RabbitMQ、RocketMQ、Kafka 等等。但是在一些时候,我们并不需要这么高的可靠性,可以使用进程内 的队列或者线程池。
这里说进程内 的队列或者线程池,相对不可靠 的原因是,队列和线程池中的任务仅仅存储在内存中,如果 JVM 进程被异常关闭,将会导致丢失,未被执行。
而分布式消息队列,异步调用会以一个消息的形式,存储在消息队列的服务器上,所以即使 JVM 进程被异常关闭,消息依然在消息队列的服务器上。
所以,使用进程内 的队列或者线程池来实现异步调用的话,一定要尽可能的保证 JVM 进程的优雅关闭,保证它们在关闭前被执行完成。
4.1. 编写异步demo 在CartController中改造test方法:
1 2 3 4 5 6 7 8 9 10 11 12 @GetMapping("test") @ResponseBody public String test () { System.out.println("controller.test方法开始执行!" ); this .cartService.executor1(); this .cartService.executor2(); System.out.println("controller.test方法结束执行!!!" ); return "hello cart!" ; }
在CartService中添加两个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public String executor1 () { try { System.out.println("executor1方法开始执行" ); TimeUnit.SECONDS.sleep(4 ); System.out.println("executor1方法结束执行。。。" ); } catch (InterruptedException e) { e.printStackTrace(); } return "executor1" ; } public String executor2 () { try { System.out.println("executor2方法开始执行" ); TimeUnit.SECONDS.sleep(5 ); System.out.println("executor2方法结束执行。。。" ); } catch (InterruptedException e) { e.printStackTrace(); } return "executor2" ; }
浏览器访问:http://cart.gmall.com/test
控制台打印效果如下:
1 2 3 4 5 6 controller.test方法开始执行! executor1方法开始执行 executor1方法结束执行。。。 executor2方法开始执行 executor2方法结束执行。。。 controller.test方法结束执行!!!9001
浏览器需要等待9s才能响应
4.2. 简单入门 因为 Spring Task 是 Spring Framework 的模块,所以在我们引入 spring-boot-web 依赖后,无需特别引入它。
4.2.1. @EnableAsync开启异步功能 在springboot工程的启动类上添加@EnableAsync开启spring-task的异步功能:
4.2.2. @Async标记异步调用方法
4.3.3. 重启测试结果 在浏览器访问:http://cart.gmall.com/test
控制台打印结果如下:
1 2 3 4 5 6 controller.test方法开始执行! controller.test方法结束执行!!!11 executor1方法开始执行 executor2方法开始执行 executor1方法结束执行。。。 executor2方法结束执行。。。
可以看到浏览器只需11ms就能响应。
4.3. 获取异步执行结果 上一节虽然实现了异步调用,但是无法获取异步任务的返回值。
我们知道通过Callable + FutureTask实现多线程程序,可以获取异步任务的执行结果(阻塞子线程)。springTask一样可以获取子任务的返回结果。
4.3.1. 改造service方法返回异步结果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Async public Future<String> executor1 () { try { System.out.println("executor1方法开始执行" ); TimeUnit.SECONDS.sleep(4 ); System.out.println("executor1方法结束执行。。。" ); } catch (InterruptedException e) { e.printStackTrace(); } return AsyncResult.forValue("executor1" ) ; } @Async public Future<String> executor2 () { try { System.out.println("executor2方法开始执行" ); TimeUnit.SECONDS.sleep(5 ); System.out.println("executor2方法结束执行。。。" ); } catch (InterruptedException e) { e.printStackTrace(); } return AsyncResult.forValue("executor2" ); }
4.3.2. 改造controller方法获取异步结果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @GetMapping("test") @ResponseBody public String test () throws ExecutionException, InterruptedException { long now = System.currentTimeMillis(); System.out.println("controller.test方法开始执行!" ); Future<String> future1 = this .cartService.executor1(); Future<String> future2 = this .cartService.executor2(); System.out.println("future1的执行结果:" + future1.get()); System.out.println("future2的执行结果:" + future2.get()); System.out.println("controller.test方法结束执行!!!" + (System.currentTimeMillis() - now)); return "hello cart!" ; }
4.3.3. 重启测试结果 在浏览器继续访问:http://cart.gmall.com/test
控制台打印如下
1 2 3 4 5 6 7 8 controller.test方法开始执行! executor1方法开始执行 executor2方法开始执行 executor1方法结束执行。。。 future1的执行结果:executor1 executor2方法结束执行。。。 future2的执行结果:executor2 controller.test方法结束执行!!!5009
浏览器等待大概5s可以响应成功
结论:
这两个异步调用的逻辑,可以并行 执行。当同时有多个异步调用,并阻塞等待执行结果,消耗时长由最慢的异步调用的逻辑所决定。
分别调用两个 Future 对象的 get() 方法,阻塞等待结果。
4.4. 异步回调 这个类似于ajax的异步回调:
1 2 3 4 5 6 7 8 9 10 $.ajax ({ url : 'http://xxx.com/xx/xx' , dataType : 'json' , success (result ) { ...... }, error (err ) { ...... } })
springTask允许使用异步回调的方式,根据不同的响应结果做出不同的处理。springTask提供了ListenableFuture对象来实现自定义回调 。
4.4.1. ListenableFuture改造Service方法 把方法的返回值:Future —> ListenableFuture。并添加异常情况下的返回值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @Async public ListenableFuture<String> executor1 () { try { System.out.println("executor1方法开始执行" ); TimeUnit.SECONDS.sleep(4 ); System.out.println("executor1方法结束执行。。。" ); return AsyncResult.forValue("executor1" ); } catch (InterruptedException e) { e.printStackTrace(); return AsyncResult.forExecutionException(e); } } @Async public ListenableFuture<String> executor2 () { try { System.out.println("executor2方法开始执行" ); TimeUnit.SECONDS.sleep(5 ); System.out.println("executor2方法结束执行。。。" ); int i = 1 / 0 ; return AsyncResult.forValue("executor2" ); } catch (InterruptedException e) { e.printStackTrace(); return AsyncResult.forExecutionException(e); } }
4.4.2. 在controller方法中添加回调 如果是正常的结果,调用 SuccessCallback 的回调。
如果是异常的结果,调用 FailureCallback 的回调。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @GetMapping("test") @ResponseBody public String test () throws ExecutionException, InterruptedException { long now = System.currentTimeMillis(); System.out.println("controller.test方法开始执行!" ); this .cartService.executor1().addCallback(new SuccessCallback <String>() { @Override public void onSuccess (String result) { System.out.println("future1的正常执行结果:" + result); } }, new FailureCallback () { @Override public void onFailure (Throwable ex) { System.out.println("future1执行出错:" + ex.getMessage()); } }); this .cartService.executor2().addCallback(new SuccessCallback <String>() { @Override public void onSuccess (String result) { System.out.println("future2的正常执行结果:" + result); } }, new FailureCallback () { @Override public void onFailure (Throwable ex) { System.out.println("future2执行出错:" + ex.getMessage()); } }); System.out.println("controller.test方法结束执行!!!" + (System.currentTimeMillis() - now)); return "hello cart!" ; }
4.4.3. 重启测试结果 在浏览器继续访问:http://cart.gmall.com/test
控制台打印如下:
1 2 3 4 5 6 7 8 controller.test方法开始执行! executor1方法开始执行 executor2方法开始执行 controller.test方法结束执行!!!22 executor1方法结束执行。。。 future1的正常执行结果:executor1 executor2方法结束执行。。。 future2执行出错:/ by zero
浏览器等待22ms
4.5. 异步执行异常处理 返回值为ListenableFuture的异步方法可以使用异步回调处理异常结果,那么返回值为普通类型的异步方法出现异常该如何处理呢?
springTask提供了AsyncUncaughtExceptionHandler 接口,达到对异步调用的异常的统一处理。
注意:AsyncUncaughtExceptionHandler 只能拦截返回类型非 Future 的异步调用方法。
返回类型为 Future 的异步调用方法,请使用异步回调来处理。
实现步骤:
自定义异常处理实现类实现AsyncUncaughtExceptionHandler 接口
添加配置类(@Configuration)实现AsyncConfigurer异步配置接口
4.5.1. 实现AsyncUncaughtExceptionHandler 自定义异常处理实现类AsyncExceptionHandler实现AsyncUncaughtExceptionHandler 接口
1 2 3 4 5 6 7 8 9 @Component @Slf4j public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException (Throwable throwable, Method method, Object... objects) { log.error("异步调用发生异常,方法:{},参数:{}。异常信息:{}" , method, objects, throwable.getMessage()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Configuration public class AsyncConfig implements AsyncConfigurer { @Autowired private AsyncExceptionHandler asyncExceptionHandler; @Override public Executor getAsyncExecutor () { return null ; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler () { return asyncExceptionHandler; } }
4.5.3. 改造代码测试效果
改造CartService的executor2方法
1 2 3 4 5 6 7 8 9 10 11 12 13 @Async public String executor2 () { try { System.out.println("executor2方法开始执行" ); TimeUnit.SECONDS.sleep(5 ); System.out.println("executor2方法结束执行。。。" ); int i = 1 / 0 ; return "executor2" ; } catch (InterruptedException e) { e.printStackTrace(); } return null ; }
改造CartController的test方法:
1 2 3 4 5 6 7 8 9 10 @GetMapping("test") @ResponseBody public String test () throws ExecutionException, InterruptedException { long now = System.currentTimeMillis(); System.out.println("controller.test方法开始执行!" ); this .cartService.executor2(); System.out.println("controller.test方法结束执行!!!" + (System.currentTimeMillis() - now)); return "hello cart!" ; }
重启测试
1 2 3 4 5 controller.test方法开始执行! controller.test方法结束执行!!!3 executor2方法开始执行 executor2方法结束执行。。。 2020-05-28 17:35:24.700 ERROR [cart-service,d86be1dd3d5b5ac5,016327888243bb3b,true] 31080 --- [ cart-thread-3] c.a.g.cart.config.AsyncExceptionHandler : 异步调用发生异常,方法:public java.lang.String com.atguigu.gmall.cart.service.CartService.executor2(),参数:[]。异常信息:/ by zero
浏览器等待了3ms,并且打印了异常信息
4.6. 线程池配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 spring: task: execution: thread-name-prefix: task- pool: core-size: 8 max-size: 20 keep-alive: 60s queue-capacity: 200 allow-core-thread-timeout: true shutdown: await-termination: true await-termination-period: 60
4.7. 最后寄语及扩展 使用 Spring Task 的异步任务,一定要注意三个点:
配置线程池控制线程及阻塞队列的大小。
JVM 应用的正常优雅关闭,保证异步任务都被执行完成。
编写异步异常处理器(实现AsyncUncaughtExceptionHandler接口),记录异常日志,进行监控告警。
springTask还为定时任务设计了一套注解:
@EnableSchedule :在启动类上开启定时任务功能
@Scheduled:在普通方法上声明一个方法是定时任务方法
请大家自行查询资料学习。
4.8. 使用SpringTask改造新增购物车 为了方便扩展维护,新增一个异步service专门完成mysql的异步操作。
4.8.1. 新增CartAsyncService
内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Service public class CartAsyncService { @Autowired private CartMapper cartMapper; @Async public void updateCartByUserIdAndSkuId (Cart cart) { this .cartMapper.update(cart, new UpdateWrapper <Cart>().eq("user_id" , cart.getUserId()).eq("sku_id" , cart.getSkuId())); } @Async public void saveCart (Cart cart) { this .cartMapper.insert(cart); } }
4.8.2. 改造CartService 注入:CartMapper –> CartAsyncService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Service public class CartService { ...... @Autowired private CartAsyncService cartAsyncService; public void addCart (Cart cart) { ...... if (hashOps.hasKey(skuId)) { ...... this .cartAsyncService.updateCartById(cart); } else { ...... this .cartAsyncService.saveCart(cart); } hashOps.put(skuId, JSON.toJSONString(cart)); } ...... }
5. 查询修改删除 5.1. 查询购物车
先根据userKey查询购物车中记录(redis)
判断是否登录,未登录直接返回
已登录,合并购物车中的记录并删除未登录状态的购物车(redis + mysql)
查询购物车记录(redis)
5.1.1. CartController
请求方式:GET
请求路径:/cart.html
请求参数:无
响应页面:cart.html列表页
1 2 3 4 5 6 7 8 @ResponseBody @GetMapping("cart.html") public List<Cart> queryCarts (Model model) { List<Cart> carts = this .cartService.queryCarts(); return carts; }
5.1.2. CartService 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 public List<Cart> queryCarts () { UserInfo userInfo = LoginInterceptor.getUserInfo(); String unloginKey = KEY_PREFIX + userInfo.getUserKey(); BoundHashOperations<String, Object, Object> unLoginHashOps = this .redisTemplate.boundHashOps(unloginKey); List<Object> unloginCartJsons = unLoginHashOps.values(); List<Cart> unloginCarts = null ; if (!CollectionUtils.isEmpty(unloginCartJsons)) { unloginCarts = unloginCartJsons.stream().map(cartJson -> { Cart cart = JSON.parseObject(cartJson.toString(), Cart.class); return cart; }).collect(Collectors.toList()); } if (userInfo.getUserId() == null ) { return unloginCarts; } String loginKey = KEY_PREFIX + userInfo.getUserId(); BoundHashOperations<String, Object, Object> loginHashOps = this .redisTemplate.boundHashOps(loginKey); if (!CollectionUtils.isEmpty(unloginCarts)) { unloginCarts.forEach(cart -> { String skuId = cart.getSkuId().toString(); if (loginHashOps.hasKey(skuId)) { String cartJson = loginHashOps.get(skuId).toString(); BigDecimal count = cart.getCount(); cart = JSON.parseObject(cartJson, Cart.class); cart.setCount(cart.getCount().add(count)); this .cartAsyncService.updateCartByUserIdAndSkuId(cart); } else { cart.setUserId(userInfo.getUserId().toString()); this .cartAsyncService.saveCart(cart); } loginHashOps.put(skuId, JSON.toJSONString(cart)); }); this .cartAsyncService.deleteCartsByUserId(userInfo.getUserKey()); this .redisTemplate.delete(unloginKey); } List<Object> loginCartJsons = loginHashOps.values(); if (CollectionUtils.isEmpty(loginCartJsons)) { return null ; } return loginCartJsons.stream().map(cartJson -> { Cart cart = JSON.parseObject(cartJson.toString(), Cart.class); return cart; }).collect(Collectors.toList()); }
5.1.3. CartAsyncService 1 2 3 4 @Async public void deleteCartsByUserId (String userKey) { this .cartMapper.delete(new UpdateWrapper <Cart>().eq("user_id" , userKey)); }
5.1.4. 测试 未登录时,在浏览器连续访问:
http://cart.gmall.com?skuId=30&count=2
http://cart.gmall.com?skuId=31&count=3
redis中未登录购物车信息:
mysql中未登录购物车信息:
登录状态时,在浏览器中连续访问:
http://cart.gmall.com?skuId=31&count=2
http://cart.gmall.com?skuId=32&count=2
redis中已登录购物车信息:
mysql中已登录购物车信息:
在浏览器中访问:http://cart.gmall.com/cart.html
redis中的数据已合并,并把未登录状态的购物车删除
查看redis:
查看mysql:
5.1.5. 加入页面联调 改造CartController中的queryCarts方法:
1 2 3 4 5 6 7 @GetMapping("cart.html") public String queryCarts (Model model) { List<Cart> carts = this .cartService.queryCarts(); model.addAttribute("carts" , carts); return "cart" ; }
接下来看cart.html页面:
对应的vuejs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 new Vue ({ el : '#app' , data : { carts : [[${carts}]], discount : 0 }, mounted ( ){ this .carts .forEach (cart => cart.saleAttrs = JSON .parse (cart.saleAttrs )); }, computed : { totalCount ( ){ return this .carts .reduce ((a, b ) => a + b.count , 0 ) }, totalMoney ( ){ return this .carts .reduce ((a, b ) => a + b.count * b.price , 0 ) } } })
渲染效果:
5.2. 修改商品数量 修改数量非常简单
CartController
1 2 3 4 5 6 7 @PostMapping("updateNum") @ResponseBody public ResponseVo<Object> updateNum (@RequestBody Cart cart) { this .cartService.updateNum(cart); return ResponseVo.ok(); }
CartService
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void updateNum (Cart cart) { String userId = this .getUserId(); String key = KEY_PREFIX + userId; BoundHashOperations<String, Object, Object> hashOps = this .redisTemplate.boundHashOps(key); if (hashOps.hasKey(cart.getSkuId().toString())) { String cartJson = hashOps.get(cart.getSkuId().toString()).toString(); BigDecimal count = cart.getCount(); cart = JSON.parseObject(cartJson, Cart.class); cart.setCount(count); this .cartAsyncService.updateCartByUserIdAndSkuId(cart); hashOps.put(cart.getSkuId().toString(), JSON.toJSONString(cart)); } }
cart.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 methods: { incr(cart){ let count = cart.count + 1 ; axios.post('http://cart.gmall.com/updateNum' , {skuId: cart.skuId, count: count}).then(({data})=>{ if (data.code === 0 ) { cart.count++; } }) }, decr(cart){ let count = cart.count - 1 ; axios.post('http://cart.gmall.com/updateNum' , {skuId: cart.skuId, count: count}).then(({data})=>{ if (data.code === 0 ) { cart.count--; } }) }, changeNum(cart){ axios.post('http://cart.gmall.com/updateNum' , {skuId: cart.skuId, count: cart.count}) } }
5.3. 删除购物车 模仿京东,当删除购物车时:
页面没有刷新,说明是异步操作。
请求方式:Post
请求路径:/deleteCart?skuId=30
请求参数:skuId
返回结果:无
CartController
1 2 3 4 5 6 7 @PostMapping("deleteCart") @ResponseBody public ResponseVo<Object> deleteCart (@RequestParam("skuId") Long skuId) { this .cartService.deleteCart(skuId); return ResponseVo.ok(); }
CartService
1 2 3 4 5 6 7 8 9 10 11 12 public void deleteCart (Long skuId) { String userId = this .getUserId(); String key = KEY_PREFIX + userId; BoundHashOperations<String, Object, Object> hashOps = this .redisTemplate.boundHashOps(key); if (hashOps.hasKey(skuId.toString())) { this .cartAsyncService.deleteByUserIdAndSkuId(userId, skuId); hashOps.delete(skuId.toString()); } }
CartAsyncService
1 2 3 4 @Async public void deleteByUserIdAndSkuId (String userKey, Long skuId) { this .cartMapper.delete(new UpdateWrapper <Cart>().eq("user_id" , userKey).eq("sku_id" , skuId)); }
6. 购物车价格同步 商品加入购物车之后,商品的价格可能会被修改,会导致redis中购物车记录的价格和数据库中的价格不一致。需要进行同步,甚至是比价:
解决方案:
每次查询购物车从数据库查询当前价格(需要远程调用,影响系统并发能力)
商品修改后发送消息给购物车同步价格(推荐)
pms-service微服务价格修改后,发送消息给购物车,购物车获取消息后,怎么进行价格的同步?
获取所有人的所有购物车记录,更新对应skuId购物车记录的价格(数据量庞大,效率低下)
redis中单独维护一个商品的价格,数据结构:{skuId: price}
如果使用第二种方案,redis中应该保存两份数据,一份购物车记录数据,一份sku最新价格数据
价格同步的流程如下:
那么查询购物车时,需要从redis中查询最新价格。
6.1. 改造新增购物车 给Cart追加一个字段:currentPrice
在CartService声明前缀:
改造新增购物车方法:
6.2. 改造查询购物车
6.3. 修改时的价格同步 略。。。。