SpringBoot2 | 第二十九篇:Redis 实现购物车

Redis 是一个高性能的 key-value数据库。它有五种数据类型,分别为 string(字符串)、list(列表)、set(集合)、zset(有序集合)、hash(哈希表),这些数据类型的所有操作都是原子性的。由于 Redis 有多种数据类型,而且性能特别高,所以能用的场景也很多,包括购物车

注意:Redis是使用 ANSI C语言编写的,ANSI 是一种基于ASCII扩充的编码字符集。以下说 32 字节,在这里可以放 32 个字母。

[TOC]

为什么选择 Redis 做购物车?

​ 存储购物车当然得使用数据库,数据库分为关系型数据库(这里用 Mysql)和非关系型数据库(这里用 Redis),我们应该怎样选择?

​ 我们先从购物车Item的数据模型说起,百度了一下,网上的很多方案,大部分都有一个缺点,就是将Sku图片等等属于Sku的数据(以下称为“共有数据”;在他们的数据库设计中,这些字段都属于冗余字段)放在购物车Item的数据模型中,购物车每加入一个商品,都会有这些重复的数据;如果有一万个用户的购物车都有这种Sku,那么这些共有数据将会重复 9999 次。那如果还有一万Sku呢?那数据库岂不是存放了大量的垃圾数据!这只是缺点之一。如果你也这么做的话,那你应该会发现,购物车这个模块,你还不能很好的使用 cache(可以参考后面的方案对比)。他们设计这样的数据模型不仅浪费数据库空间,而且性能上也不怎么样(以查询 mysql 和查询 redis 做对比)。

​ 那么,既然是 共有数据,我们就应该考虑重复使用,购物车Item的话,数据模型可以包括五部分:用户IDSkuID店铺ID商品数量加入购物车时间,这些数据都是购物车的核心。其中,加入购物车时间是为了展示的时候按照时间排序,最新的排在前面。那些需要展示的共有数据,我们则是从 skucache 中取;查询购物车列表的时候,我们也可以约定每次下拉刷新 16 条记录,然后通过遍历的方式从缓存中拿到这 16 条需要展示的数据。这种方式是不是性能更好,可以大大地减少 mysql负担。当然,我们系统的 mysql 一般都存储了很多数据,忍辱负重,购物车当然可以存放在 mysql 中,但这样做如果用户太多,对 mysql多少还是有些压力 。那如果我们选择将购物车放在 Redis 中呢?从性能上讲,是不是达到了最优的策略?!😆

方案对比

接下来不讨论 Mysql 方案数据库设计的缺点(需要看上边)

对购物车的操作(可分为以下五种):
1)加入一个新的 Sku
2)原有的 Sku 数量加 n 减一(当然,在数量允许范围内)
3)指定的 Sku 从购物车移除(购物车减少一种 Sku
4)清空购物车
5)查看购物车

前四项操作与第五项操作的触发关系:
1)必定会触发5)。加入之后肯定会去看购物车然后下单,除非你加入的时候不想买(这种情况不讨论)。
2)前端可以做处理,向后端的请求只要返回成功,前端就保持 count 不变,不刷新购物车,这样就不会触发5)
3)同2)
4)一般不会触发5)

Mysql 方案

购物车(商品项列表)加层缓存。(缓存影响因素在用户)
缺点:

  1. 用户体验相对较差
  2. 一定概率会出现数据库崩溃(人为触发缓存清除)
  3. 支持的用户量级别取决于单体 Mysql 能支持的并发量(并不是指支持的用户量等于数据库支持的并发量)
  4. Mysql 集群也是强弩之末,支持的用户量级别只能增加 n 倍,n 表示集群的数目节点数

解析:

​ 执行前四项操作的任何一项,都会造成购物车数据改变,这时候购物车缓存需要被清除(换句话说,为购物车加的缓存,只对用户不改变购物车数据的时候有效),如果用户选择这时候再次刷新购物车,那么请求会直接到达数据库,响应相对缓存会慢很多,用户体验较差。如果多个用户同时处于这种状态(执行过前四项操作之一,并且都选择刷新购物车,以下的这种状态也是指这个意思),那么当同时处于这种状态的用户量达到一定程度时,会对数据库造成很大的负担严重时数据库直接挂了。其实这和缓存雪崩类似,区别是雪崩是数据库存在大量过期时间相同的缓存数据,缓存过期后会自动清除;而当前这种方案的话,或许缓存已经设置为永久不过期,但是一定会出现人为造成缓存清除(执行前四项操作之一)。可能有人觉得大量用户处于这种状态的概率特别小(其实,用户量比较大的时候,这个概率就特别大了),我则是认为我们不能因为概率小就忽视它,缓存雪崩的造成原因,即数据库存在大量过期时间相同的缓存数据出现的概率也是特别小的,但是往往有时候就是这种概率小的事件,给我们带来非常头疼的问题。

Redis 方案

指本文方案

购物车(商品项列表)需要展示的数据直接从 Sku 缓存拿,只要 Sku 不改变,Sku 缓存就一直都在。(缓存影响因素不在用户,而在于意外)
优点:

  1. 用户体验非常好(数据基本都放 Redis
  2. Mysql 方案同一级别的用户量,数据库崩溃的概率基本为 0
  3. 出现数据库崩溃的概率有多小健壮性好),支持的用户量级别就有多大
  4. Mysql 集群的环境下,根据用户量级别配置对应的节点数,数据库崩溃的概率基本为 0

​ 不存在了上述 Mysql 方案的缺点,执行前四项操作的任何一项,都不会动到缓存(Sku 缓存)。可能有人觉得,Sku 缓存也可能被清除,也有极小的概率造成数据库挂了。Sku 缓存大量被清除的情况一般是不存在的,通过后台管理系统对 Sku 修改之后一定会对该商品进行 Sku 列表的查询(查看是否操作成功嘛),所以 Sku 缓存会立即恢复;除非有人直接用 Redis 客户端 或者 命令 手动删除了缓存,这就另当别论了。其实就算真的出现 Sku 缓存全被清除了,大量用户同时选择刷新购物车,也没事,这里的条件还不足以触发某轮①出现上万次数据库访问②,而且Sku 在被刷新一次后它就被缓存起来,最多只会造成部分用户体验差点(到数据库查询 Sku 的那部分用户),没什么别的影响,健壮性在这里体现出来了。

①:某轮。看下图,比如购物车 1 的第 1 个 Sku,购物车 2 的第 3 个 Sku,购物车 3 的第 2 个 Sku,购物车 a 的第 b 个等等好多 Sku 构成一轮查询同时查询数据库。

②:触发条件很苛刻。大量查询购物车的请求同时来到查询方法,方法中会执行一条 Redis 命令,其时间复杂度为 O(1),又因为Redis 是单线程,所以这条 Redis 命令在这里起到了队列的作用。具体参考方法 com.fatal.service.impl.ShopCartServiceImpl#shopCartGrouping。所以这里除非 Redis 集群节点数很多而且用户量也是一个非常大的数字,不然不可能有上万并发到数据库。

1591416070225

​ 如果同时存在:有人把 Sku 缓存全部删了;大量用户同时选择刷新购物车(1);这些用户购物车的某轮①数据库查询达到上万次(2),并且前 n 轮都不存在相同的 Sku;其他用户刷新购物车(购物车含有该轮查询的 Sku)发生在(1)之后;其他用户执行前两项操作(购物车含有该轮查询的 Sku)发生在(1)之后(前两项操作做了校验,判断 Sku 是否存在);其他用户在逛商城时浏览商品(含有该轮查询的 Sku)发生在(1)之后(商品展示的规格数据会根据是否有库存判断该 Sku 是否可以点击);… 。数据库还真的有可能挂了。

抱歉,打搅了。

​ 真的会出现这种情况的话,只能从硬件上解决问题,因为软件已经达到极限了。某轮①数据库查询达到上万次,而且前 n 轮都不存在相同的 Sku,当这个概率很大的时候,那说明这个商城的规模已经特别特别大了,它肯定很有钱,Mysql 搞个集群什么的肯定没什么压力,可以通过这种方式来解决问题。Mysql 方案也可以搞集群,但是它和 Redis 方案两者支持的用户量完全不在一个数量级上。(两种方案数据库崩溃的概率对比,Mysql 方案得配多少个 Mysql 集群节点才能将支持的用户量提到 Redis 方案一个级别啊,而且就算支持的用户量上来了,那用户体验还是没改变。)

多规格的商品 Sku

SKU 百度文库

SKU = Stock Keeping Unit(库存量单位),sku 可以理解为具有具体规格的商品。

同一种商品存在不同的规格参数值,包含这些值的属性又称为 sku 属性,因为它决定了 SKU 的绝对数量。

比如40码白色帆布鞋,40白色都属于帆布鞋的规格参数值。

而 38,39,40 这些值都属于码数,白色,黑色,蓝色属于颜色,这里的码数和颜色就属于帆布鞋的sku属性。38,39,40 与 白色,黑色,蓝色都是 sku 的属性选项(展示的时候叫规格参数)。

当商品比较简单的时候,后面笔记中的 sku 也可以用goods代替。

为什么选择 hash 来实现?

如果使用 Redis 做购物车,有多少种数据类型可以选呢?

下面一个 购物车skuDTO 对应一个对象 {}

第一:如果选择 string 呢?

  • key:userId;value:[{skuId: xxx, count: xxx, intoTime: xxx},...]

    选择 string 的话,每个用户的购物车对应一个 string 对象,value 存的是 购物车skuDTO 集合。string 底层编码有 intrawembstr,由于我们 value 存的是字符串值的长度一般都大于 32 字节,所以 string 会选择使用raw编码。

第二:如果选择 set 呢?

  • key:userId;value:{skuId: xxx, count: xxx, intoTime: xxx}

    选择 set 的话,每个用户的购物车对应一个 set 对象, value 存的是 单个购物车skuDTO 。set 底层编码有 intsethashtable

    当集合对象同时满足以下两个条件时,对象使用 intset 编码;不能满足则使用 hashtable 编码。

    • 集合对象保存的所有元素是整数值;
    • 集合对象保存的元素数量不超过 512 个。

    由于 value 存的不是整数值不满足第一个条件,所以 set 使用 hashtable 编码。

第三:如果选择 zset 呢?

  • key:userId;value:{skuId: xxx, count: xxx}

    选择 zset 的话,每个用户的购物车对应一个 zset 对象, value 存的是购物车skuDTO,score 存的是加入购物车的时间,让 zset 帮我们排序。zset 的底层编码是 ziplistskiplist

    当有序集合同时满足以下两个条件时,对象使用 ziplist 编码;不能满足则使用 skiplist 编码。

    • 有序集合保存的元素数量小于 128 个;
    • 有序集合保存的所有元素成员的长度小于 64 位。

    由于我们将 zset 的元素个数限制在120之内(这是淘宝购物车单种商品的上限数量,参考购买指南),满足第一条件,不过元素值长度会超过 64字节,所以zset 使用 skiplist 编码。

第四:如果选择 list 呢?

  • key: userId;value:{skuId: xxx, count: xxx}

    选择 list 的话,每个用户的购物车对应一个 list 对象,value 存的是 购物车skuDTO。list 的底层编码是 ziplistlinkedlist

    当列表对象同时满足以下两个条件时,对象就会使用 ziplist 编码;不满足则使用 linkedlist 编码。

    • 列表对象保存的所有字符串元素的长度小于 64 字节;
    • 列表对象保存的元素数量小于 512 个。

    从 3.2 版本开始,Redis 对列表对象的底层编码做了改造,使用 quicklist 替代之前两种,它是 ziplistlinkedlist 的混合体,它会安装插入顺序排序。

第五:如果选择 hash 呢?

  • key:userId;field:skuId;value:count

    选择 hash 的话,每个用户的购物车对应一个 hash 对象,field 存的是skuID,value 存的是购物车单种sku总量。hash 的底层编码是 ziplisthashtable

    当哈希对象同时满足以下两种条件时,对象使用 ziplist 编码;不能满足则使用 hashtable 编码。

    • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
    • 哈希对象保存的键值对数量小于 512 个。

    首先,field 的长度肯定不会超过 64 字节,value 存放的是购物车单种sku总量,这个数量我们系统会默认给一个上限(如:10000),第一个条件可以满足;其次,购物车 sku 的种类我们可以限制在 120 之内(这个数字你随意,在 512 之内即可),第二个条件也可以满足。两个条件都能同时满足,使用 hash 做购物车的时候,hash 对象(的 field)使用 ziplist 编码,它会按照插入顺序排序。这也是我们没有存 加入购物车时间的原因,它已经帮我们保留了顺序,所以这个时间就多余了。

相比这五种选择,除了hash,其它的数据类型,都需要 get 出来计算后 set 回去,有的还需保存加入购物车时间 来排序。用来做购物车还是可以的,就是有点小麻烦,也没有充分利用 Redis 的数据类型(充分利用的话性能有很大的优势);而 hash 就不一样了,我们满足它以 ziplist编码的条件,它就是有序的了 ,不需要额外计算,可以直接使用 Redis 命令来完成对购物车的维护,性能上无疑达到了最优,这简直就是 Redis为购物车专门定制的一套 完美的方案 嘛。:heart_eyes:

相关命令时间复杂度

Spring Data Redis Redis 命令 Redis 命令时间复杂度 最终时间复杂度
HV get(K key, Object hashKey) hget key field O(1) O(1)
void put(K key, HK hashKey, HV value) hset key field value O(1) O(1)
Long size(K key) hlen key O(1) O(1)
Long increment(K key, HK hashKey, long delta) hincrby key field increment O(1) O(1)
Map<HK, HV> entries(K key) hgetall key O(N) N是hash元素个数 购物车Sku种类限制最多120,所以最坏的情况下是O(120)。O(1~120)
Cursor<Entry<HK, HV>> scan(K key, ScanOptions options) hscan key cursor [MATCH pattern][COUNT count] O(1) O(1)
Long delete(K key, Object… hashKeys) hdel key [key …] O(N) N是被删除的field的数量 购物车Sku种类限制最多120,所以最坏的情况下是O(120)。O(1~120)
Boolean delete(K key) del key [key …] O(N) N是被删除的key的数量,key 可以是任何类型;比如hash,删除一个key的时间复杂度也是O(1) 清空购物车,删除一个hash,所以时间复杂度是O(1)
  • hscan 取代 hgetall 优化性能
  • hdel 删除 field 的个数一般不会很大。

Redis 底层编码 ziplist

ziplist 重点:

  • 压缩列表是一种为节约内存而开发的的顺序型数据结构。(这是重点
  • 压缩列表被用作列表键哈希键 的底层实现之一。
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
  • 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率不高

​ 因为连锁更新在最坏的情况下需要对压缩列表进行 N 次空间重分配操作,而每次空间重分配操作的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N²)

  • 首先,压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的。

Redis 的 hash 选择ziplist编码的第一个条件要求:所有的 fieldvalue 值的长度都小于64字节,64 远小于 250,所以我们可以不用担心 Redis 的 hash 会出现连锁更新

hash 实现购物车

​ 使用 Redishash 数据类型实现购物车,每个用户的购物车则对应一张哈希表。哈希表每个元素的 field 存储skuIDvalue 存储订购sku的数量。购物车中单个sku订购的数量需要有上限,系统默认对加入购物车的sku 统一设置了上限(例如:10000),卖家可以在这个上限的范围之内再设置一个小于系统默认值的上限(例如:200);购物车的sku的种类也需要有上限,系统默认对购物车的商品种类设置了上限,例如:120,这个数字是淘宝的默认值,当用户的购物车有120sku的时候,它不能再添加新的sku进购物车。

Redis 做购物车,这里选择了 hash 类型,每个用户的购物车对应一个 hash 对象。如下:

key field value
userId skuId count
用户ID skuID 单种sku总量

这是这次选择的方案,直接用 Redis 命令保存移除(部分)清空查询购物车,前三个不需要额外的计算。

由于购物车需要保留两种顺序,一种是添加的顺序,另一种是计算后展示的顺序(同一个店铺的商品,只要有一个是刚添加的,那么就算其他的是最早添加,这个店铺也是排在购物车的第一位),所以这里还需要设计一张哈希表。(记录 shopId 是为了后边分组)

key field value
userId skuId shopId
用户ID skuID 店铺ID

这个哈希表不是必要的。你也可以去掉,但是去掉之后,每次查询购物车要拿到和店铺的关系数据(就是这张哈希表)都得通过遍历拿出所有 Sku 数据,虽然每次拿使用的 get 命令的时间复杂度是 O(1),但是购物车Sku种类限制最多有120,所以最坏的情况下也是 O(120),和使用 hgetall 效果差不多;加了这个哈希表就不一样了,通过简单的维护,后边需要拿和店铺的关系数据的时候,直接使用时间复杂度为 O(1)hscan 命令即可,所以建议加上它。

环境/版本一览:

  • 开发工具:Intellij IDEA 2019.1.3
  • springboot: 2.1.7.RELEASE
  • jdk:1.8.0_171
  • maven:3.3.9
  • mysql-connector-java:5.1.47

1、pom.xml

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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-cache</artifactId>
</dependency>
<!-- 整合Lettuce Redis需要commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<!-- 版本用 5.x 的,与本地保持一致 -->
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

2、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
32
33
34
35
36
37
38
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
# 基本属性
url: jdbc:mysql://localhost:3306/chapter29?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=UTC&useSSL=false
username: root
password: 123456
jpa:
# 显示 sql
show-sql: true
# 数据库类型
database: mysql
# JPA 配置
hibernate:
ddl-auto: update
# 指定生成的表的引擎为InnoDB类型(默认是MyISAM,MyISAM不支持事务)
database-platform: org.hibernate.dialect.MySQL57InnoDBDialect

redis:
host: localhost
password: 123456
# Redis默认情况下有16个分片,这里配置具体使用的分片,默认是0
database: 2
lettuce:
pool:
# 当池耗尽时,在引发异常之前连接分配可以阻塞的最长时间(使用负值表示没有限制) 默认 -1
max-wait: -1ms
# 连接池最大连接数(使用负值表示没有限制) 默认 8
max-active: 8
# 连接池中的最大空闲连接 默认 8
max-idle: 8
# 连接池中的最小空闲连接 默认 0
min-idle: 0
# 连接超时时间
timeout: 10000ms
# 一般来说是不用配置的,Spring Cache 会根据依赖的包自行装配
cache:
type: redis

3、sql

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
/*
SQLyog Ultimate v12.09 (64 bit)
MySQL - 5.7.22 : Database - chapter29
*********************************************************************
*/


/*!40101 SET NAMES utf8 */;

/*!40101 SET SQL_MODE=''*/;

/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`chapter29` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */;

USE `chapter29`;

/*Table structure for table `sku` */

DROP TABLE IF EXISTS `sku`;

CREATE TABLE `sku` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'skuId',
`goods_id` bigint(20) NOT NULL COMMENT '商品ID',
`goods_name` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '商品名称',
`max` int(11) NOT NULL COMMENT '购物车单种sku允许最大个数',
`picture` varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT 'sku图片',
`price` bigint(20) NOT NULL COMMENT 'sku单价',
`properties` varchar(255) COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'sku规格',
`shop_id` bigint(20) NOT NULL COMMENT '店铺ID',
`shop_name` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '店铺名',
`status` int(11) NOT NULL COMMENT 'sku状态:-1 下架; 0 删除; 1 在架',
`stock` int(11) NOT NULL COMMENT 'sku库存',
`create_time` datetime NOT NULL,
`update_time` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

/*Data for the table `sku` */

insert into `sku`(`id`,`goods_id`,`goods_name`,`max`,`picture`,`price`,`properties`,`shop_id`,`shop_name`,`status`,`stock`,`create_time`,`update_time`) values (1,111111,'帆布鞋',300,'http://...pic...123.png',55000,'白色;40',123457,'MiCai Shop',1,1000,'2019-08-20 14:02:38','2019-08-20 14:02:38'),(2,222222,'太阳帽',300,'http://...pic...123.png',21000,'黑色',123457,'MiCai Shop',1,1000,'2019-08-20 14:07:26','2019-08-20 14:07:26'),(3,222222,'太阳帽',200,'http://...pic...123.png',21000,'蓝色',123457,'MiCai Shop',1,1000,'2019-08-20 14:08:03','2019-08-20 14:08:03'),(4,444444,'男士皮鞋',500,'http://...pic...123.png',221000,'40;黑色编织镂空款B1923913',123456,'Fatal Shop',1,1000,'2019-08-20 14:20:07','2019-08-20 14:20:07'),(5,444444,'男士皮鞋',500,'http://...pic...123.png',221000,'39;黑色编织镂空款B1923913',123456,'Fatal Shop',0,1000,'2019-08-20 14:21:01','2019-08-20 14:21:01'),(6,444444,'男士皮鞋',500,'http://...pic...123.png',221000,'43;黑色编织镂空款B1923913',123456,'Fatal Shop',1,1000,'2019-08-20 14:21:56','2019-08-20 14:21:56'),(7,333333,'限量款骑士领带',2,'http://...pic...123.png',521000,'棕色',123456,'Fatal Shop',1,1000,'2019-08-20 14:23:36','2019-08-20 14:23:36'),(8,431235,'女生西装',100,'http://...pic...123.png',1121000,'L;灰色',123458,'MiQI Shop',1,1000,'2019-08-21 16:46:19','2019-08-21 16:46:19'),(9,431312,'男士西装',100,'http://...pic...123.png',911000,'XL;灰色',123458,'MiQI Shop',1,1000,'2019-08-21 16:47:50','2019-08-21 16:47:50'),(10,221312,'男士中邦皮靴',200,'http://...pic...123.png',111000,'40;豹纹',123458,'MiQI Shop',1,1000,'2019-08-21 16:49:34','2019-08-21 16:49:34'),(11,151312,'胸针',10,'http://...pic...123.png',211000,'骑士银',123458,'MiQI Shop',1,1000,'2019-08-21 16:51:15','2019-08-21 16:51:15'),(12,151312,'胸针',10,'http://...pic...123.png',191000,'骑士蓝',123457,'MiCai Shop',1,1000,'2019-08-21 16:53:05','2019-08-21 16:53:05'),(13,151312,'胸针',10,'http://...pic...123.png',191000,'骑士红',123457,'MiCai Shop',-1,1000,'2019-08-23 16:13:27','2019-08-23 16:13:27');

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

4、config

配置两部分:( Json 序列化)

  • 自定义 RedisCacheConfiguration 的序列化方式
  • 自定义 RedisTemplate,以及 keyvaluehashKeyhashValue 的序列化方式。hashKeyhashValue 存储的是 Long 类型或者 Integer 类型,所以序列化方式可以选择 GenericJackson2JsonRedisSerializer
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
package com.fatal.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.io.Serializable;

/**
* Redis 配置组件
* @author Fatal
* @date 2019/8/14 0014 19:26
*/
@Configuration
public class RedisConfig {

/**
* 自定义 RedisCacheConfiguration
* @return
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
// 自定义 key 的序列化器
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
// 自定义 value 的序列化器
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer()))
// 禁止缓存 null 值
.disableCachingNullValues();
}

/**
* 自定义 RedisTemplate
* Key: StringRedisSerializer
* Value: genericJackson2JsonRedisSerializer
* HashKey: genericJackson2JsonRedisSerializer
* HashValue: genericJackson2JsonRedisSerializer
* @return
*/
@Bean
public RedisTemplate<String, Serializable> serializableRedisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(keySerializer());
redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(genericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(genericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}

@Bean
public RedisSerializer<String> keySerializer() {
return new StringRedisSerializer();
}

@Bean
public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}

}

5、common

5.1、constants

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
package com.fatal.common.constants;

/**
* 购物车常量
* @author Fatal
* @date 2019/8/15 0015 8:16
*/
public interface ShopCartConstant {

/**
* 购物车key前缀
*/
String SHOP_CART = "SHOP_CART:%s";

/**
* 用于分组的购物车(后续需要按店铺分组)
*/
String SHOP_CART_FOR_GROUPING = "SHOP_CART_FOR_GROUPING:%s";

/**
* 排序购物车
*/
String SORT_SHOP_CART = "SORT_SHOP_CART";

/**
* 购物车分页(根据排序购物车计算出的分页)
*/
String SHOP_CART_PAGE = "SHOP_CART_PAGE";

/**
* 系统统一数据:单种sku允许添加到购物车的数额
*/
Integer MAX = 10000;

/**
* 系统统一数据:允许添加到购物车sku种类的数额
*/
Integer TYPE_MAX = 120;

/**
* 购物车每页显示条数(以淘宝为例,每次下拉都会刷新 31 条记录)
*/
// Integer PAGE_SIZE = 16;
Integer PAGE_SIZE = 4;

/**
* 为购物车加上前缀
* @param userId
* @return
*/
static String getCartKey(Long userId) {
return String.format(ShopCartConstant.SHOP_CART, userId);
}

static String getGroupingKey(Long userId) {
return String.format(ShopCartConstant.SHOP_CART_FOR_GROUPING, userId);
}

}

5.2、enums

ResponseEnum.java

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
package com.fatal.common.enums;

import lombok.Getter;

/**
* @author Fatal
* @date 2019/8/18 0018 11:10
*/
@Getter
public enum ResponseEnum {

/**
* 购物车已满,不能添加新sku
*/
SHOP_CART_SKU_TYPE_COUNT_FULL(10000, "购物车已满,不能添加新sku"),

/**
* 数据库不存在此sku
*/
SKU_IS_NOT_EXISTS(10100, "数据库不存在此sku"),

/**
* sku已下架
*/
SKU_IS_OFF_THE_SHELVES(10101, "sku已下架"),

;

private Integer code;

private String message;

ResponseEnum(Integer code, String message) {
this.code = code;
this.message = message;
}

}

StatusEnums.java

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
package com.fatal.common.enums;

import lombok.Getter;

/**
* 状态枚举
* @author Fatal
* @date 2019/8/15 0015 17:49
*/
@Getter
public enum StatusEnums {

/**
* 正常
*/
NORMAL(1, "NORMAL"),

/**
* 已删除
*/
DELETED(0, "DELETED"),

/**
* 下架
*/
PROHIBIT(-1, "PROHIBIT");

private Integer code;

private String message;

StatusEnums(Integer code, String message) {
this.code = code;
this.message = message;
}

}

5.3、exception

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.fatal.common.exception;

import com.fatal.common.enums.ResponseEnum;

import javax.validation.ConstraintViolationException;

/**
* 自定义校验异常,可以被本类异常处理器catch;优先本类的处理器,如果没有本类的,往上一层。
* @author Fatal
* @date 2019/8/18 0018 11:00
*/
public class ValidateException extends ConstraintViolationException {

public ValidateException(ResponseEnum responseEnum) {
super(responseEnum.getMessage(), null);
}

}

5.4、handler

自定义全局异常处理器。

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
package com.fatal.common.handler;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.validation.ConstraintViolationException;

/**
* @author Fatal
* @date 2019/8/16 0016 14:11
*/
@ControllerAdvice
public class GlobalExceptionHandler {

/**
* 校验异常处理器
* @param e
* @return
*/
@ExceptionHandler(value = ConstraintViolationException.class)
public ResponseEntity<String> constraintViolationException(ConstraintViolationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}

}

5.5、utils

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
package com.fatal.common.utils;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

/**
* @author Fatal
* @date 2019/8/9 0009 22:47
*/
public class JsonUtil {

private static Gson gson;

static {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.setPrettyPrinting();
gson = gsonBuilder.create();
}

private JsonUtil() {}

/**
* 控制台输出Json格式的对象
* @param object
* @return
*/
public static String toJson(Object object) {
return gson.toJson(object);
}

public static <T> T fromJson(String json, Class<T> classOfT) {
return gson.fromJson(json, classOfT);
}

}

6、entity

记得实现 Serializable,后面自定义的RedisTemplate保存的 value 只要实现 Serializable 接口即可。(Spring Cache 缓存的对象不需要实现 Serializable 接口)

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
package com.fatal.entity;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import lombok.experimental.Accessors;

import javax.persistence.*;
import java.io.`Serializable;
import java.time.LocalDateTime;

/**
* 全名 Stock Keeping Unit(库存量最小单位),可以理解为有具体规格的商品。例如:40码白色帆布鞋。
* @author Fatal
* @date 2019/8/14 0014 17:15
*/
@Data
@Entity
@Accessors(chain = true)
public class Sku implements Serializable {

/**
* skuID
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 这里为了方便,用自增
private Long id;

/**
* 商品ID
*/
private Long goodsId;

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称(冗余字段)
*/
private String shopName;

/**
* 商品名称(冗余字段)
*/
private String goodsName;

/**
* sku单价(单位:分)
*/
private Long price;

/**
* sku库存
*/
private Integer stock;

/**
* sku图片
*/
private String picture;

/**
* 规格(规格本来应该是张表的,这里为了方便,就直接 String)
*/
private String properties;

/**
* 单种sku允许添加到购物车的最大数额
*/
private Integer max;

/**
* 创建时间
*/
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;

/**
* 更新时间
*/
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;

/**
* sku状态:-1 下架; 0 删除; 1 在架
*/
private Integer status;

}

7、dto

位于 controllerservice 两层,不包括 RedisTestDTO,它只是测试用的。

RedisTestDTO.java

测试数据类型的底层数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.fatal.dto;

import lombok.Data;
import lombok.experimental.Accessors;

import java.io.Serializable;

/**
* 用于测试
* @author Fatal
* @date 2019/8/19 0019 10:25
*/
@Data
@Accessors(chain = true)
public class RedisTestDTO implements Serializable {

private Long skuId;

private Integer count;

}

ShopCartDTO.java

对应 ShopCartVO,属性完全一样的。

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
package com.fatal.dto;

import lombok.Data;
import lombok.experimental.Accessors;

import java.util.List;

/**
* 店铺DTO,包含了`店铺`信息和`购物车sku`信息
* @author Fatal
* @date 2019/8/15 0015 19:17
*/
@Data
@Accessors(chain = true)
public class ShopCartDTO {

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称
*/
private String shopName;

/**
* 购物车sku集合
*/
private List<ShopCartItemDTO> items;

public static ShopCartDTO of(ShopCartSkuDTO shopCartSkuDTO) {
return new ShopCartDTO()
.setShopId(shopCartSkuDTO.getShopId())
.setShopName(shopCartSkuDTO.getShopName());
}
}

ShopCartSkuDTO.java

ShopCartItemDTO 的区别就是多了 shopIdshopName,这两个属性用于后边分组展示;少了 count 属性,这个属性不在 ISkuService 对外 DTO 的范畴中。

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
package com.fatal.dto;

import com.fatal.entity.Sku;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.beans.BeanUtils;

/**
* 购物车skuDTO,包含了购物车需要显示的所有数据
* @desc @EqualsAndHashCode 这里只选择了两个属性,是为了后面去重;先去重后map,可以减少大部分操作。
* 注:这个属于 ISkuService 对外交互的DTO,是必不可少的。
* @author Fatal
* @date 2019/8/15 0015 17:45
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(of = {"shopId", "shopName"})
public class ShopCartSkuDTO {

/**
* skuID
*/
private Long id;

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称
*/
private String shopName;

/**
* 商品名称
*/
private String goodsName;

/**
* sku单价(单位:分)
*/
private Long price;

/**
* sku图片
*/
private String picture;

/**
* 规格(规格本来应该是张表的,这里为了方便,就直接 String)
*/
private String properties;

/**
* 单种sku允许添加到购物车的最大数额
*/
private Integer max;

/**
* sku状态:-1 下架; 0 删除; 1 在架
*/
private Integer status;

public static ShopCartSkuDTO of(Sku sku) {
ShopCartSkuDTO dto = new ShopCartSkuDTO();
BeanUtils.copyProperties(sku, dto);
return dto;
}

}

ShopCartItemDTO.java

对应 ShopCartItemVO,属性完全一样的。

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
package com.fatal.dto;

import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.beans.BeanUtils;

import java.util.List;
import java.util.stream.Collectors;

/**
* 购物车skuDTO,去掉了重复的属性
* @author Fatal
* @date 2019/8/15 0015 17:45
*/
@Data
@Accessors(chain = true)
public class ShopCartItemDTO {

/**
* skuID
*/
private Long id;

/**
* 商品名称
*/
private String goodsName;

/**
* sku单价(单位:分)
*/
private Long price;

/**
* sku图片
*/
private String picture;

/**
* 规格(规格本来应该是张表的,这里为了方便,就直接 String)
*/
private String properties;

/**
* 单种sku允许添加到购物车的最大数额
*/
private Integer max;

/**
* sku个数
*/
private Integer count;

/**
* sku状态:-1 下架; 0 删除; 1 在架
*/
private Integer status;

private static ShopCartItemDTO of(ShopCartSkuDTO shopCartSkuDTO) {
ShopCartItemDTO shopCartItemDTO = new ShopCartItemDTO();
BeanUtils.copyProperties(shopCartSkuDTO, shopCartItemDTO);
return shopCartItemDTO;
}

public static List<ShopCartItemDTO> of(List<ShopCartSkuDTO> shopCartSkuDTOs) {
return shopCartSkuDTOs.stream()
.map(ShopCartItemDTO::of)
.collect(Collectors.toList());
}

}

8、vo

位于 controller 层,用在视图展示,可以将一个或多个DTO改造成需要展示的数据放在VO中。

ShopCartVO.java

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
package com.fatal.vo;

import com.fatal.dto.ShopCartDTO;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
* 店铺VO,包含了`店铺`信息和`购物车sku`信息
* @author Fatal
* @date 2019/8/15 0015 19:17
*/
@Data
@Accessors(chain = true)
public class ShopCartVO {

/**
* 店铺ID
*/
private Long shopId;

/**
* 店铺名称
*/
private String shopName;

/**
* 购物车sku集合
*/
private List<ShopCartItemVO> items;

private static ShopCartVO of(ShopCartDTO shopCartDTO) {
return new ShopCartVO()
.setShopId(shopCartDTO.getShopId())
.setShopName(shopCartDTO.getShopName())
.setItems(ShopCartItemVO.of(shopCartDTO.getItems()));
}

public static List<ShopCartVO> of(List<ShopCartDTO> shopCartDTOs) {
return CollectionUtils.isEmpty(shopCartDTOs) ?
new ArrayList<>() :
shopCartDTOs.stream()
.map(ShopCartVO::of)
.collect(Collectors.toList());
}
}

ShopCartItemVO.java

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
package com.fatal.vo;

import com.fatal.dto.ShopCartItemDTO;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.beans.BeanUtils;

import java.util.List;
import java.util.stream.Collectors;

/**
* 购物车skuVO,去掉了重复的属性
* @author Fatal
* @date 2019/8/15 0015 17:45
*/
@Data
@Accessors(chain = true)
public class ShopCartItemVO {

/**
* skuID
*/
private Long id;

/**
* 商品名称
*/
private String goodsName;

/**
* sku单价(单位:分)
*/
private Long price;

/**
* sku图片
*/
private String picture;

/**
* 规格(规格本来应该是张表的,这里为了方便,就直接 String)
*/
private String properties;

/**
* 单种sku允许添加到购物车的最大数额
* @desc 前端用于判断,不给用户随便输入
*/
private Integer max;

/**
* sku个数
*/
private Integer count;

/**
* sku状态:-1 下架; 0 删除; 1 在架
* @desc 前端用于判断,下架的sku不允许移出购物车(count减1),只给用户移除整个sku。
*/
private Integer status;

private static ShopCartItemVO of(ShopCartItemDTO shopCartItemDTO) {
ShopCartItemVO shopCartItemVO = new ShopCartItemVO();
BeanUtils.copyProperties(shopCartItemDTO, shopCartItemVO);
return shopCartItemVO;
}

public static List<ShopCartItemVO> of(List<ShopCartItemDTO> shopCartItemDTOs) {
return shopCartItemDTOs.stream()
.map(ShopCartItemVO::of)
.collect(Collectors.toList());
}

}

9、repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.fatal.repository;

import com.fatal.entity.Sku;
import org.springframework.data.jpa.repository.JpaRepository;

/**
* @author Fatal
* @date 2019/8/14 0014 17:33
*/
public interface SkuRepository extends JpaRepository<Sku, Long> {

/**
* 查找未删除的Sku
* @param id
* @param status
* @return
*/
Sku findByIdAndStatusIsNot(Long id, Integer status);

}

10、service

ISkuService.java

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
package com.fatal.service;

import com.fatal.dto.ShopCartSkuDTO;
import com.fatal.entity.Sku;

/**
* @author Fatal
* @date 2019/8/14 0014 18:21
*/
public interface ISkuService {

/**
* 根据skuID查找sku
* @param id
* @return
*/
Sku getById(Long id);

/**
* 根据skuID查询购物车skuDTO
* @param id
* @return
*/
ShopCartSkuDTO getShopCartSkuById(Long id);

}

IShopCartService.java

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
package com.fatal.service;

import com.fatal.dto.ShopCartDTO;

import java.util.List;

/**
* 购物车服务
* @author Fatal
* @date 2019/8/14 0014 23:20
*/
public interface IShopCartService {

/**
* 改变指定 sku 的 count
* @param userId 用户ID
* @param skuId skuID
* @param finalValue count 的最终值
*/
void put(Long userId, Long skuId, Long finalValue);

/**
* 购物车列表分页
* @param userId 用户ID
* @param currentPage
* @return
*/
List<ShopCartDTO> shopCarts(Long userId, Integer currentPage);

/**
* 购物车列表
* @param userId 用户ID
* @param skuIds
* @return
*/
List<ShopCartDTO> shopCarts(Long userId, List<Long> skuIds);

/**
* 移除购物车指定sku
* @param userId 用户ID
* @param skuIds skuID数组(该参数必须是可变参数或者数组,后面需要转为 byte[][] 类型,如果
* 这里用集合的话,后面序列化的 byte[][] 结果是错的)
*/
void remove(Long userId, Long... skuIds);

/**
* 清空购物车
* @param userId 用户ID
*/
void clear(Long userId);

/**
* 进入购物车
* @param userId 用户ID
* @return 购物车 TotalPage
*/
Integer into(Long userId);

/**
* 获得当前页的skuIds
* @param userId 用户ID
* @param currentPage 当前页码
* @return
*/
List<Long> currentPageSkuIds(Long userId, Integer currentPage);

/**
* 获得分组摊平后的购物车
* @param userId 用户ID
* @return
*/
List<Long> shopCartGrouping(Long userId);

}

SkuServiceImpl.java

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
package com.fatal.service.impl;

import com.fatal.common.enums.ResponseEnum;
import com.fatal.common.enums.StatusEnums;
import com.fatal.common.exception.ValidateException;
import com.fatal.dto.ShopCartSkuDTO;
import com.fatal.entity.Sku;
import com.fatal.repository.SkuRepository;
import com.fatal.service.ISkuService;
import org.springframework.aop.framework.AopContext;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.Optional;

/**
* @author Fatal
* @date 2019/8/14 0014 18:22
*/
@Service
public class SkuServiceImpl implements ISkuService {

private SkuRepository skuRepository;

public SkuServiceImpl(SkuRepository skuRepository) {
this.skuRepository = skuRepository;
}

/**
* sku详情
* @desc 这里的缓存在更新或者删除该sku的时候清理。
* 为什么这里查找的是上架和下架两个状态的sku。因为下架的sku也需要展示
* @param id
* @return
*/
@Override
@Cacheable(cacheNames = "entity:sku", key = "#id")
public Sku getById(Long id) {
return Optional.ofNullable(skuRepository.findByIdAndStatusIsNot(id, StatusEnums.DELETED.getCode()))
.orElseThrow(() -> new ValidateException(ResponseEnum.SKU_IS_NOT_EXISTS));
}

/**
* 用于展示的数据。如果还涉及到其他表的数据,可在这里封装。
* @desc 这里的缓存在更新或者删除该sku的时候清理
* @param id
* @return
*/
@Override
public ShopCartSkuDTO getShopCartSkuById(Long id) {
Sku sku = proxy().getById(id);
return ShopCartSkuDTO.of(sku);
}

private ISkuService proxy() {
return (SkuServiceImpl) AopContext.currentProxy();
}

}

ShopCartServiceImpl.java

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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
package com.fatal.service.impl;

import com.fatal.common.constants.ShopCartConstant;
import com.fatal.common.enums.ResponseEnum;
import com.fatal.common.enums.StatusEnums;
import com.fatal.common.exception.ValidateException;
import com.fatal.dto.ShopCartDTO;
import com.fatal.dto.ShopCartItemDTO;
import com.fatal.dto.ShopCartSkuDTO;
import com.fatal.service.IShopCartService;
import com.fatal.service.ISkuService;
import org.springframework.aop.framework.AopContext;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

import java.io.IOException;
import java.io.Serializable;
import java.util.*;
import java.util.stream.Collectors;

/**
* 购物车服务实现
* @author Fatal
* @date 2019/8/15 0015 8:13
*/
@Service
public class ShopCartServiceImpl implements IShopCartService {

private ISkuService skuService;

private RedisTemplate<String, Serializable> redisTemplate;

private HashOperations<String, Object, Object> hashOperations;

public ShopCartServiceImpl(RedisTemplate<String, Serializable> redisTemplate,
ISkuService skuService) {
this.redisTemplate = redisTemplate;
this.hashOperations = redisTemplate.opsForHash();
this.skuService = skuService;
}

/**
* 操作:1. 新增一个sku
* 2. 购物车sku项点击 “-”,`购物车sku总数count`减一,前端做判断,等于1的时候不能执行该操作
* 3. 购物车sku项点击 “+”,`购物车sku总数count`加一
* 4. 购物车sku项手动填`购物车sku总数count`
* 其中2和3,都要要求前端根据sku的max限制count(finalValue = count > max ? max : count)
* 以上的操作,都调用该方法,将`购物车sku总数count`作为第三参数
* @desc 该方法会对购物车单种sku总个数以及购物车sku种类数量进行控制,并且维护后面用于分组的购物车信息
* @param userId 用户ID
* @param skuId skuID
* @param finalValue count 的最终值
*/
@Override
public void put(Long userId, Long skuId, Long finalValue) {
ShopCartSkuDTO shopCartSkuDTO = checkSkuIfExists(skuId);
Integer value = (Integer) hashOperations.get(ShopCartConstant.getCartKey(userId), skuId);
if (ObjectUtils.isEmpty(value)) {
if (hashOperations.size(ShopCartConstant.getCartKey(userId)) >= ShopCartConstant.TYPE_MAX) {
throw new ValidateException(ResponseEnum.SHOP_CART_SKU_TYPE_COUNT_FULL);
}
hashOperations.put(ShopCartConstant.getGroupingKey(userId), skuId, shopCartSkuDTO.getShopId());
}
Integer max = ShopCartConstant.MAX > shopCartSkuDTO.getMax() ? shopCartSkuDTO.getMax() : ShopCartConstant.MAX;
hashOperations.put(ShopCartConstant.getCartKey(userId), skuId, finalValue > max ? max : finalValue);
}

/**
* 获取当前的 skuIds 然后封装数据
* @param userId 用户ID
* @param currentPage 当前页数
* @return
*/
@Override
public List<ShopCartDTO> shopCarts(Long userId, Integer currentPage) {
List<Long> skuIds = proxy().currentPageSkuIds(userId, currentPage);
return CollectionUtils.isEmpty(skuIds) ? new ArrayList<>() : shopCarts(userId, skuIds);
}

/**
* 购物车(skuId)分页缓存
* @Cacheable unless排除对空集和null的缓存
* @desc 使用 Stream#skip + Stream#limit 在流中实现分页
* @param userId 用户ID
* @param currentPage 当前页码
* @return
*/
@Override
@Cacheable(unless = "#result == null || #result.size() == 0", cacheNames = ShopCartConstant.SHOP_CART_PAGE,
key = "#userId + ':' + #currentPage")
public List<Long> currentPageSkuIds(Long userId, Integer currentPage) {
return proxy().shopCartGrouping(userId).stream()
.skip((currentPage - 1) * ShopCartConstant.PAGE_SIZE)
.limit(ShopCartConstant.PAGE_SIZE)
.collect(Collectors.toList());
}

/**
* 获得分组摊平后的购物车(未分页的列表)
* [7,6,4,11,10,8,9,1,12,3,2]
* 对应三个店铺:[7,6,4],[11,10,8,9],[1,12,3,2]
* @param userId 用户ID
* @return
*/
@Override
@Cacheable(unless = "#result == null", cacheNames = ShopCartConstant.SORT_SHOP_CART, key = "#userId")
public List<Long> shopCartGrouping(Long userId) {
Map<Object, Object> map = scan(ShopCartConstant.getGroupingKey(userId));
List<Map.Entry<Object, Object>> entryList = new ArrayList<>(map.entrySet());
// 最新加入购物的sku在最后,所以这里得先反序
Collections.reverse(entryList);
Map<Object, List<Object>> collect = entryList.stream()
.collect(
Collectors.groupingBy(
Map.Entry::getValue,
// 得到的key保留原来(作为Entry的value时)的顺序(保存put的顺序)
LinkedHashMap::new,
// 得到的list元素也要保留顺序 Collectors.toList() -> ArrayList(有序)
Collectors.mapping(Map.Entry::getKey, Collectors.toList())
)
);
return collect.values().stream()
.flatMap(Collection::stream)
.map(this::toLong)
.collect(Collectors.toList());
}

/**
* @step
* 1. 根据 skuIds 获得 shopCartSkuDTOs(封装购物车中sku数量 -> count)
* 2. 根据 店铺ID 对 shopCartSkuDTOs 进行分组
* 3. 根据 shopCartSkuDTOs 获得店铺集合(已去重),遍历填充数据
* 1)去重:这里使用 Stream#distinct 的方式去重,以 "shopId", "shopName" 为标识去除标识重复的数据。在 ShopCartSkuDTO 类上
* 加上 @EqualsAndHashCode(of = {"shopId", "shopName"}) 可以实现,后边调用 Stream#distinct 会在底层使用
* equals() 和 hashCode() 方法进行去重。
* 2)封装:这里使用 Stream#peek 的方式封装,其实可以使用 Stream#map,但是返回类型如果没有改变的话,使用 peek 更直观。
* @desc 这个方法的作用就是封装购物车需要展示的数据,参数为带顺序的购物车列表,店铺的顺序由旗下最新添加的商品顺序决定。
* 展示给前端后,临界值看看是否操作,不需要的话他可以直接拿去展示。
* @param userId 用户ID
* @param skuIds skuIds
* @return
*/
@Override
public List<ShopCartDTO> shopCarts(Long userId, List<Long> skuIds) {
List<ShopCartSkuDTO> shopCartSkuDTOs = skuIds.stream()
.map(skuService::getShopCartSkuById)
.collect(Collectors.toList());
// Map<Long, List<ShopCartSkuDTO>> -> Map<shopId, List<ShopCartSkuDTO>>
Map<Long, List<ShopCartSkuDTO>> shopMap = shopCartSkuDTOs.stream()
.collect(Collectors.groupingBy(ShopCartSkuDTO::getShopId));
return shopCartSkuDTOs.stream()
.distinct()
.map(ShopCartDTO::of)
.peek(shopCartDTO -> {
List<ShopCartSkuDTO> subShopCartSkuDTOs = shopMap.get(shopCartDTO.getShopId());
List<ShopCartItemDTO> shopCartItemDTOs = ShopCartItemDTO.of(subShopCartSkuDTOs).stream()
.peek(shopCartItemDTO -> {
Integer count = (Integer) hashOperations.get(ShopCartConstant.getCartKey(userId), shopCartItemDTO.getId());
shopCartItemDTO.setCount(count);
}).collect(Collectors.toList());
shopCartDTO.setItems(shopCartItemDTOs);
})
.collect(Collectors.toList());
}

/**
* 将指定的 sku 从购物车移除。SORT_SHOP_CART 和 SHOP_CART_PAGE 一定会变的,所以需要清除缓存。
* @param userId 用户ID
* @param skuIds skuID数组(该参数必须是可变参数或者数组,后面需要转为 byte[][] 类型,如果
*/
@Override
@Caching(evict = {
@CacheEvict(cacheNames = ShopCartConstant.SORT_SHOP_CART, key = "#userId", beforeInvocation = true),
@CacheEvict(cacheNames = ShopCartConstant.SHOP_CART_PAGE, key = "#userId", beforeInvocation = true)
})
public void remove(Long userId, Long... skuIds) {
hashOperations.delete(ShopCartConstant.getCartKey(userId), skuIds);
hashOperations.delete(ShopCartConstant.getGroupingKey(userId), skuIds);
}

/**
* 清空购物车。
* 将属于该用户的购物车数据全部删除。
* @param userId 用户ID
*/
@Override
@Caching(evict = {
@CacheEvict(cacheNames = ShopCartConstant.SORT_SHOP_CART, key = "#userId", beforeInvocation = true),
@CacheEvict(cacheNames = ShopCartConstant.SHOP_CART_PAGE, key = "#userId", beforeInvocation = true)
})
public void clear(Long userId) {
redisTemplate.delete(ShopCartConstant.getCartKey(userId));
redisTemplate.delete(ShopCartConstant.getGroupingKey(userId));
}

@Override
public Integer into(Long userId) {
Long size = hashOperations.size(ShopCartConstant.getGroupingKey(userId));
return getTotalPage(size.intValue());
}

/**
* 获取购物车总页数
* @param totalSize
* @return
*/
private Integer getTotalPage(Integer totalSize) {
return totalSize % ShopCartConstant.PAGE_SIZE == 0 ?
totalSize / ShopCartConstant.PAGE_SIZE :
totalSize / ShopCartConstant.PAGE_SIZE + 1;
}

/**
* `加入购物车`和`移出购物车`都检验sku是否`上架`
* @param skuId
*/
private ShopCartSkuDTO checkSkuIfExists(Long skuId) {
// 校验 skuId 是否存在(保证数据库存在该sku,且该sku的状态为正常)
ShopCartSkuDTO shopCartSkuDTO = skuService.getShopCartSkuById(skuId);
if (!StatusEnums.NORMAL.getCode().equals(shopCartSkuDTO.getStatus())) {
throw new ValidateException(ResponseEnum.SKU_IS_OFF_THE_SHELVES);
}
return shopCartSkuDTO;
}

/**
* Redis 命令 `hgetall` 的时间复杂度为 O(n),最坏的情况下为 O(120),所以频繁使用会造成线上服务阻塞
* Redis 命令 `scan` 的时间复杂度为 O(1),可以无阻塞的匹配出列表,缺点是可能出现重复数据,这里用 Map 接收
* 刚好可以解决这个问题,因为要求匹配的数据必须带顺序,所以在本方法中直接用 LinkedHashMap 来实现。
* Spring Data Redis 中的 scan 方法都帮我们维护了 Cursor 游标值了。
* @param key
* @return
*/
private Map<Object, Object> scan(String key) {
Map<Object, Object> linkedHashMap = new LinkedHashMap<>();
try {
// 设置 count 选项来指定每次迭代返回元素的最大值,设置 match 指定 field 需要匹配的 pattern
Cursor<Map.Entry<Object, Object>> cursor = hashOperations.scan(key,
ScanOptions.scanOptions().count(ShopCartConstant.TYPE_MAX).match("*").build());
// 带顺序的 HashMap,同一个元素就算被返回多次也不影响。
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
linkedHashMap.put(entry.getKey(), entry.getValue());
}
// 关闭游标,释放资源
cursor.close();
} catch (IOException e) {
e.printStackTrace();
}
return linkedHashMap;
}

private IShopCartService proxy() {
return (ShopCartServiceImpl) AopContext.currentProxy();
}

private Long toLong(Object id) {
return id instanceof Long?
(Long) id :
Long.valueOf(((Integer) id).longValue());
}

}

11、controller

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
package com.fatal.controller;

import com.fatal.dto.ShopCartDTO;
import com.fatal.service.IShopCartService;
import com.fatal.vo.ShopCartVO;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;

/**
* @author Fatal
* @date 2019/8/16 0016 12:54
*/
@Validated
@RestController
@RequestMapping("/shopCart")
public class ShopCartController {

private IShopCartService shopCartService;

public ShopCartController(IShopCartService shopCartService) {
this.shopCartService = shopCartService;
}

@PutMapping("/put")
public ResponseEntity<Void> put(@NotNull(message = "userId不能为空") Long userId,
@NotNull(message = "skuId不能为空") Long skuId,
@NotNull(message = "finalValue不能为空")
@Min(value = 1, message = "finalValue不能小于{value}") Long finalValue) {
shopCartService.put(userId, skuId, finalValue);
return ResponseEntity.ok().build();
}

@GetMapping("/shopCarts")
public ResponseEntity<List<ShopCartVO>> shopCarts(@NotNull(message = "userId不能为空") Long userId,
@NotNull(message = "currentPage不能为空")
@Min(value = 1, message = "currentPage不能小于{value}") Integer currentPage) {
List<ShopCartDTO> shopCartDTOs = shopCartService.shopCarts(userId, currentPage);
return ResponseEntity.ok(ShopCartVO.of(shopCartDTOs));
}

@DeleteMapping("/remove")
public ResponseEntity<Void> remove(@NotNull(message = "userId不能为空") Long userId,
@NotEmpty(message = "skuIds不能为空") Long... skuIds) {
shopCartService.remove(userId, skuIds);
return ResponseEntity.ok().build();
}

@DeleteMapping("/clear")
public ResponseEntity<Void> clear(@NotNull(message = "userId不能为空") Long userId) {
shopCartService.clear(userId);
return ResponseEntity.ok().build();
}

@GetMapping
public ResponseEntity<Integer> into(@NotNull(message = "userId不能为空") Long userId) {
return ResponseEntity.ok(shopCartService.into(userId));
}

}

12、Application

加两个配置:

  • 启用缓存
  • 暴露代理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.fatal;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@EnableCaching // 启用缓存
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理
public class Chapter29Application {

public static void main(String[] args) {
SpringApplication.run(Chapter29Application.class, args);
}

}

13、Test

ShopCartServiceImplTest.java

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
package com.fatal.service.impl;

import com.fatal.Chapter29ApplicationTests;
import com.fatal.common.utils.JsonUtil;
import com.fatal.dto.ShopCartDTO;
import com.fatal.service.IShopCartService;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

/**
* @author Fatal
* @date 2019/8/15 0015 8:54
*/
public class ShopCartServiceImplTest extends Chapter29ApplicationTests {

private Long userId = 999999L;

@Autowired
private IShopCartService shopCartService;

@Test
public void incrementOne() {
shopCartService.put(userId, 1L, 101L);
}

@Test
public void init() {
shopCartService.put(userId,2L, 100L);
shopCartService.put(userId,4L, 100L);
shopCartService.put(userId,9L, 100L);
shopCartService.put(userId,3L, 100L);
shopCartService.put(userId,8L, 100L);
shopCartService.put(userId,12L, 100L);
shopCartService.put(userId,1L, 100L);
shopCartService.put(userId,6L, 100L);
shopCartService.put(userId,10L, 100L);
shopCartService.put(userId,11L, 100L);
shopCartService.put(userId,7L, 100L);
}

@Test
public void shopCarts() {
List<ShopCartDTO> shopCartDTOs = shopCartService.shopCarts(userId, 3);
String json = JsonUtil.toJson(shopCartDTOs);
System.out.println(json);
}

@Test
public void delete() {
Long[] skuIds = new Long[]{7L, 10L, 2L};
shopCartService.remove(userId, skuIds);
}

@Test
public void clear() {
shopCartService.clear(userId);
}

@Test
public void into() {
Integer totalPage = shopCartService.into(userId);
System.out.println(totalPage);
}

@Test
public void shopCartGrouping() {
List<Long> list = shopCartService.shopCartGrouping(userId);
System.out.println(list);
}

}

RedisTests

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
package com.fatal.redis;

import com.fatal.Chapter29ApplicationTests;
import com.fatal.common.constants.ShopCartConstant;
import com.fatal.dto.RedisTestDTO;
import com.fatal.entity.Sku;
import com.fatal.common.enums.StatusEnums;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.*;

import java.io.IOException;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.*;

/**
* 执行完放可以输入 redis 命令 > object encoding [key] 来查看底层数据结构
* @author Fatal
* @date 2019/8/16 0016 9:44
*/
public class RedisTests extends Chapter29ApplicationTests {

@Autowired
private RedisTemplate<String, Serializable> redisTemplate;

private Sku sku;

private RedisTestDTO redisTestDTO;

@Before
public void before() {
sku = new Sku()
.setGoodsId(111111L)
.setShopId(123457L)
.setShopName("MiCai Shop")
.setGoodsName("帆布鞋")
.setPrice(15000L)
.setStock(100)
.setPicture("http://...pic...123.png")
.setProperties("白色;40")
.setMax(300)
.setStatus(StatusEnums.NORMAL.getCode())
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now());

redisTestDTO = new RedisTestDTO()
.setSkuId(Long.MAX_VALUE)
.setCount(ShopCartConstant.MAX);
}

@Test
public void zSetTest() {
String key = "ZSET_TEST";
ZSetOperations<String, Serializable> zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add(key, redisTestDTO, 1); // skiplist
Set<Serializable> range = zSetOperations.range(key, 0, -1);
assert range != null;
range.forEach(System.out::println);
}

@Test
public void hashTest() {
HashOperations<String, Object, Object> hashOperations = redisTemplate.opsForHash();
hashOperations.put("Cart", "用户id", sku);
Sku result = (Sku) hashOperations.get("Cart", "用户id");
System.out.println(result);
}

@Test
public void stringTest() {
ValueOperations<String, Serializable> valueOperations = redisTemplate.opsForValue();
valueOperations.set("SKU", sku);
Serializable sku = valueOperations.get("SKU");
System.out.println(sku);
}

@Test
public void listTest() {
String key = "LIST_TEST";
ListOperations<String, Serializable> listOperations = redisTemplate.opsForList();
listOperations.leftPush(key, redisTestDTO);
List<Serializable> range = listOperations.range(key, 0, -1);
assert range != null;
range.forEach(System.out::println);
}

}

14、测试

由于数据不多,下面的测试设置了 pageSize4 方便看。

说明

  • SHOP_CART:购物车,放SkuId以及它对应的数量
  • SHOP_CART_FOR_GROUPING:用于分组的购物车,放SkuId及对应的店铺ID(后续需要按店铺分组)
  • SORT_SHOP_CART:排序购物车,经过分组和摊平操作之后的 SkuIds(缓存)
  • SHOP_CART_PAGE:购物车分页所包含的SkuIds(根据排序购物车计算得出)(缓存)
  • entity:sku:实体(缓存)

初始化数据

运行 ShopCartServiceImplTest.init()

1590934870261

保存购物车

不管是四项操作中的哪一项,只要把 count 最终值作为第三参数传给后台即可

访问 localhost:8080/shopCart/put,参数自己填充

1591516587177

Redis 视图界面:

1590935210388

进入购物车

访问 localhost:8080/shopCart?userId=999999

1566196424378

响应:

1
3 // 总页数

购物车列表

从上个接口可以得出现在有3页

首先看看第一页

访问 localhost:8080/shopCart/shopCarts?userId=999999&currentPage=1

1566196407427

先看看 Redis 视图化界面:

SORT_SHOP_CARTskuId 的顺序:

1590935412948

1
[7,6,4,11,10,8,9,1,12,3,2]

再来看看SHOP_CART_PAGE第一页的skuId

1590935518869

1
[7,6,4,11]

再来看看响应的数据是否一致:

从这里可以看出,skuId 为 7 的 sku最新添加的,所以,它所属的店铺信息排在最前边

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
[
{
"shopId": 123456,
"shopName": "Fatal Shop",
"items": [
{
"id": 7,
"goodsName": "限量款骑士领带",
"price": 521000,
"picture": "http://...pic...123.png",
"properties": "棕色",
"max": 2,
"count": 2,
"status": 1
},
{
"id": 6,
"goodsName": "男士皮鞋",
"price": 221000,
"picture": "http://...pic...123.png",
"properties": "43;黑色编织镂空款B1923913",
"max": 500,
"count": 100,
"status": 1
},
{
"id": 4,
"goodsName": "男士皮鞋",
"price": 221000,
"picture": "http://...pic...123.png",
"properties": "40;黑色编织镂空款B1923913",
"max": 500,
"count": 100,
"status": 1
}
]
},
{
"shopId": 123458,
"shopName": "MiQI Shop",
"items": [
{
"id": 11,
"goodsName": "胸针",
"price": 211000,
"picture": "http://...pic...123.png",
"properties": "骑士银",
"max": 10,
"count": 10,
"status": 1
}
]
}
]

debug 了 ShopCartServiceImpl#shopCarts 的方法,下图是该方法的第三步的流程图。

1590922792410

其实,按照 stream 的方式在 map 之后 items 是没有数据的,不过 idea 它这里显示的是现在时,也就是当前状态,所以 peek 之后的数据在这里也展示了。

接着来看看第二页

访问 localhost:8080/shopCart/shopCarts?userId=999999&currentPage=2

1566402745570

还是先看 Redis 视图化界面:

你会发现,SHOP_CART_PAGE第二页的skuId也正常

1590935676206

1
[10,,8,9,1]

再来看看响应的数据是否一致,发现数据也正常。

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
[
{
"shopId": 123458,
"shopName": "MiQI Shop",
"items": [
{
"id": 10,
"goodsName": "男士中邦皮靴",
"price": 111000,
"picture": "http://...pic...123.png",
"properties": "40;豹纹",
"max": 200,
"count": 102,
"status": 1
},
{
"id": 8,
"goodsName": "女生西装",
"price": 1121000,
"picture": "http://...pic...123.png",
"properties": "L;灰色",
"max": 100,
"count": 100,
"status": 1
},
{
"id": 9,
"goodsName": "男士西装",
"price": 911000,
"picture": "http://...pic...123.png",
"properties": "XL;灰色",
"max": 100,
"count": 100,
"status": 1
}
]
},
{
"shopId": 123457,
"shopName": "MiCai Shop",
"items": [
{
"id": 1,
"goodsName": "帆布鞋",
"price": 55000,
"picture": "http://...pic...123.png",
"properties": "白色;40",
"max": 300,
"count": 100,
"status": 1
}
]
}
]

看看最后一页

访问 localhost:8080/shopCart/shopCarts?userId=999999&currentPage=3

1566402980408

刷新下 Redis 视图化界面,SHOP_CART_PAGE第三页的 skuId也正常:

1590935762300

1
[12,3,2]

再来看看响应的数据,发现都正常…

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
[
{
"shopId": 123457,
"shopName": "MiCai Shop",
"items": [
{
"id": 12,
"goodsName": "胸针",
"price": 191000,
"picture": "http://...pic...123.png",
"properties": "骑士蓝",
"max": 10,
"count": 10,
"status": 1
},
{
"id": 3,
"goodsName": "太阳帽",
"price": 21000,
"picture": "http://...pic...123.png",
"properties": "蓝色",
"max": 200,
"count": 100,
"status": 1
},
{
"id": 2,
"goodsName": "太阳帽",
"price": 21000,
"picture": "http://...pic...123.png",
"properties": "黑色",
"max": 300,
"count": 100,
"status": 1
}
]
}
]

从购物车移除(部分种类商品)

访问 localhost:8080/shopCart/remove,参数自己填充(7,10,3)

1566197174800

Redis 视图界面:

你会发现 SORT_SHOP_CARTSHOP_CART_PAGE这两组缓存数据被清理了

1590935982070

并且 SHOP_CARTSHOP_CART_FOR_GROUPING的数据也更新了

1590936038160

1590936092745

再测试下查询,看看数据是否正常

先访问localhost:8080/shopCart?userId=999999,得到的总页数为:2

接着分别访问localhost:8080/shopCart/shopCarts?userId=999999&currentPage=1currentPage为 2的。

Redis 视图化界面:

SORT_SHOP_CART

1590936365922

SHOP_CART_PAGE的第一页:

1590936436068

1
[11,8,9,6]

SHOP_CART_PAGE的第二页:

1590936489208

1
[4,1,12,2]

再看看响应

第一页响应:

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
[
{
"shopId": 123458,
"shopName": "MiQI Shop",
"items": [
{
"id": 11,
"goodsName": "胸针",
"price": 211000,
"picture": "http://...pic...123.png",
"properties": "骑士银",
"max": 10,
"count": 10,
"status": 1
},
{
"id": 8,
"goodsName": "女生西装",
"price": 1121000,
"picture": "http://...pic...123.png",
"properties": "L;灰色",
"max": 100,
"count": 100,
"status": 1
},
{
"id": 9,
"goodsName": "男士西装",
"price": 911000,
"picture": "http://...pic...123.png",
"properties": "XL;灰色",
"max": 100,
"count": 100,
"status": 1
}
]
},
{
"shopId": 123456,
"shopName": "Fatal Shop",
"items": [
{
"id": 6,
"goodsName": "男士皮鞋",
"price": 221000,
"picture": "http://...pic...123.png",
"properties": "43;黑色编织镂空款B1923913",
"max": 500,
"count": 100,
"status": 1
}
]
}
]

第二页响应:

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
[
{
"shopId": 123456,
"shopName": "Fatal Shop",
"items": [
{
"id": 4,
"goodsName": "男士皮鞋",
"price": 221000,
"picture": "http://...pic...123.png",
"properties": "40;黑色编织镂空款B1923913",
"max": 500,
"count": 100,
"status": 1
}
]
},
{
"shopId": 123457,
"shopName": "MiCai Shop",
"items": [
{
"id": 1,
"goodsName": "帆布鞋",
"price": 55000,
"picture": "http://...pic...123.png",
"properties": "白色;40",
"max": 300,
"count": 100,
"status": 1
},
{
"id": 12,
"goodsName": "胸针",
"price": 191000,
"picture": "http://...pic...123.png",
"properties": "骑士蓝",
"max": 10,
"count": 10,
"status": 1
},
{
"id": 2,
"goodsName": "太阳帽",
"price": 21000,
"picture": "http://...pic...123.png",
"properties": "黑色",
"max": 300,
"count": 99,
"status": 1
}
]
}
]

响应数据跟缓存数据一致。

清空购物车

访问 localhost:8080/shopCart/clear,参数自己填充

1566197287007

Redis 视图界面:

1590936664436

只剩下实体的缓存

展示

首先,前后端约定每次下拉最多刷新多少sku,这篇笔记以 16 为例子。

前端拿 购物车列表 直接展示即可。

临界值

第一种情况:假设现在用户购物车有 30 个 sku。用户第一次查出了16条记录中,最后一条记录属于店铺 Fatal Shop。当用户向下拉的时候,前端将会拿接下来的14skuId,问题来了,第二次这 14sku 的前两个 sku也属于店铺 Fatal Shop,这是出现以下的数据:

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
// 这是进入购物车后首次展示的16个sku.
[
{
...
},...
{
"shopId": 123456,
"shopName": "Fatal Shop",
"items": [
{
"id": 12,...
}
]
}
]
// 这是第二次刷新需要展示的14个sku.
[
{
"shopId": 123456,
"shopName": "Fatal Shop",
"items": [
{
"id": 4,...
},
{
"id": 6,...
}
]
},...
]

那么,这时候需要合并。首先,刷新之前先拿到上一页最后一个店铺id。刷新之后,与下页的第一个店铺id进行比较,如果相等,则将下页第一个店铺的items数据追加在上一页最后一家店铺的items中,然后下页其它店铺的数据正常显示;如果不相等,直接渲染即可。

查看 Redis 底层实现

只举例 zset ,其它根据需要自己测。

执行 RedisTests.zSetTest() 方法

使用 redis-cli 输入命令

1
redis> object encoding "ZSET_TEST"

显示如下图

1591508385302

笔记

问题:使用Json方式序列化和反序列化,LocalDateTime 没有无参构造怎么处理?

解决方式:

在该属性上面添加两个注解,分别制定序列化器反序列化器

1
2
3
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime createTime;

查看 Redis 底层实现的命令

1
2
redis> object encoding [key] 
"ziplist"

Redis 的增量式迭代

SCAN 命令及其相关的 SSCAN, HSCANZSCAN 命令都用于增量迭代一个集合元素。

  • SCAN 命令用于迭代当前数据库中的key集合。
  • SSCAN 命令用于迭代SET集合中的元素。
  • HSCAN 命令用于迭代Hash类型中的键值对。
  • ZSCAN 命令用于迭代SortSet集合中的元素和元素对应的分值

增量式迭代:可以在迭代的过程中对集合进行元素操作,包括增加、修改、删除

概念具体可以参考(从 scan 命令 摘取)

  • 这类增量式迭代命令来说,有可能在增量迭代过程中,集合元素被修改,对返回值无法提供完全准确的保证。
  • 如果一个元素是在迭代过程中被添加到数据集的, 又或者是在迭代过程中从数据集中被删除的, 那么这个元素可能会被返回, 也可能不会。

更多关于 Redis scan 命令的信息请查看 scan 命令

提交订单后 Sku 何去何从

在购物车选中 sku 提交订单之后,这些 sku 将从购物车移除。

参考资料

Redis(十一):重要版本一览

Redis 设计与实现(第二版)

Redis 中文官方网站

scan 命令

总结

SpringBoot的知识已经有前辈在我们之前探索了。比较喜欢的博主有:唐亚峰 | Battcn方志朋的专栏程序猿DD纯洁的微笑。对这门技术感兴趣的可以去他们的博客逛逛。谢谢他们的分享~~

以上文章是我用来学习的Demo,都是基于 SpringBoot2.x 版本。

源码地址: https://github.com/ynfatal/springboot2-learning/tree/master/chapter29