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  =>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. 修改时的价格同步 略。。。。