谷粒商城 - 项目笔记
一、 环境搭建
1. 虚拟机安装
1.1 安装 Vmware,并在其基础上安装 CentOS7
2. 安装Docker
2.1 安装Docker参考我的博文 Docker搭建大数据集群
2.2 docker 容器开机自启 (可选 always、no)
docker update container-name --restart=always
容器的安装过程都不给出了,因为过个半个月可能就跑不了,只说下原理
3. 安装MySQL
3.1 TIMESTAMP和DATETIME的不同点
对于TIMESTAMP,它把客户端插入的时间从当前时区转化为UTC(世界标准时间)进行存储。查询时,将其又转化为客户端当前时区进行返回。
而对于DATETIME,不做任何改变,基本上是原样输入和输出。
4. 安装Redis
5. 使用人人开源脚手架
5.1 项目模块对应
- renren-fast 一个快速后台开发脚手架
- renren-vue 对应的前端
- renren-generator 对应的代码生成器
6. 安装Nacos
直接使用docker进行安装
Nacos是整个项目的微服务注册中心和配置中心。
7. 网关 GateWay
7.1 网关原理
客户端发送请求给网关,HandlerMapping 判断是否请求满足某个路由,满足就发给网关的 WebHandler。这个 WebHandler 将请求交给一个过滤器链,请求到达目标服务之前,会执行所有过滤器的 pre 方法。请求到达目标服务处理之后再依次执行所有过滤器的 post 方法。
7.2 跨域问题
解决方法:
-
Nginx将服务部署为同一个域名
-
在服务器配置运行跨域
-
将session存放在cookie中,并且设置相对应的domain
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
//设置
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
8. 前端
8.1 使用VUE
//安装模块
npm install
//运行项目
npm run dev
ElementUI
9. OSS - 图片上传
9.1 OSS原理
将图片文件上传到云服务器,本地保存对应的连接
原理:Minio,桶存储对象
优点:
- 减轻本地服务器压力
- 存储在云端有更好的容灾能力
- 服务提供商有相对应的CDN加速,能够一定程度上提升访问速度
过程:
- 创建Bucket
- 上传文件
服务端签名直传:
过程:
- 客户端向服务器请求上传Policy
- 服务器返回对应policy,并存储相对应的连接
- 客户端直接上传到OSS服务器
好处:不需要将文件过一遍本地服务器.
10. 校验功能 JSR-303
10.1 使用注解校验
步骤1
//在相对应的类的属性上使用注解,例如@NotNull
@Data
@TableName("pms_brand")
public class BrandEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 品牌id
*/
@NotNull(message = "修改必须指定品牌ID", groups = {UpdateGroup.class})
@Null(message = "新增商品不能指定ID", groups = {AddGroup.class})
@TableId
private Long brandId;
/**
* 品牌名
*/
@NotBlank(message = "品牌名必须提交", groups = {AddGroup.class, UpdateGroup.class})
private String name;
/**
* 品牌logo地址
*/
@NotBlank(message = "品牌LOGO不能为空", groups = {AddGroup.class})
@URL(message = "品牌LOGO必须是一个合法的URL地址", groups = {AddGroup.class, UpdateGroup.class})
private String logo;
/**
* 介绍
*/
private String descript;
/**
* 显示状态[0-不显示;1-显示]
*/
@NotNull(message = "状态不能为空", groups = {AddGroup.class, UpdateGroup.class})
@ListValue(vals = {0, 1}, groups = {AddGroup.class, UpdateStatusGroup.class})
private Integer showStatus;
/**
* 检索首字母
*/
@NotEmpty(message = "检索首字母不能为空", groups = {AddGroup.class})
private String firstLetter;
/**
* 排序
*/
@NotNull(groups = {AddGroup.class})
@Min(value = 0, message = "排序必须大于等于0", groups = {AddGroup.class, UpdateGroup.class})
private Integer sort;
}
步骤2
在对应的Controller接收方法前添加注解
/**
* 保存
*/
@RequestMapping("/save")
public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand){
brandService.save(brand);
return R.ok();
}
可选
在对应方法后面添加参数BindResult可以获取检验结果
@RequestMapping("/save")
public R save(@Valid @RequestBody BrandEntity brand, BindingResult result){
if( result.hasErrors()){
Map<String,String> map=new HashMap<>();
//1.获取错误的校验结果
result.getFieldErrors().forEach((item)->{
//获取发生错误时的message
String message = item.getDefaultMessage();
//获取发生错误的字段
String field = item.getField();
map.put(field,message);
});
return R.error(400,"提交的数据不合法").put("data",map);
}else {
}
brandService.save(brand);
return R.ok();
}
但是这样为每一个类编写对应代码显然不合适,所以应该使用全局异常处理
//全局异常处理
@Log4j2
//全局异常类注解 @RestControllerAdvice
@RestControllerAdvice(basePackages = "com.atguigu.gulimall.product.controller")
public class GulimallExceptionControllerAdvice {
//配置相对应的 @ExceptionHandler
@ExceptionHandler(value=Exception.class)
public R handlerValidException(Exception e){
log.error("出现问题{},异常类型:{}", e.getMessage(), e.getClass());
return R.error();
}
}
在编写业务的时候,应该编写一套严格的错误状态码,可以定义枚举类来存储错误码,并且在编写代码的时候使用,能够直观看出对应错误码的意思
public enum BizCodeEnum {
UNKNOW_EXCEPTION(10000, "系统未知异常"),
VALID_EXCEPTION(10001, "参数格式校验失败"),
TOO_MANY_REQUEST(10002, "请求流量过大,请稍后再试"),
SMS_CODE_EXCEPTION(10002, "验证码请求频率过高,请稍后再试"),
PRODUCT_UP_EXCEPTION(11000, "商品上架异常"),
USER_EXIST_EXCEPTION(15001, "存在相同的用户"),
PHONE_EXIST_EXCEPTION(21000, "存在相同的手机号"),
NO_STOCK_EXCEPTION(21000,"商品库存不足"),
LOGINACCT_PASSWORD_EXCEPTION(15003, "账号或密码错误");
private int code;
private String msg;
BizCodeEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
分组校验功能
用处:多场景的复杂校验
创建对应的分组interface,主要起标识作用
public interface AddGroup {}
对应属性添加group
@NotNull(message = "状态不能为空", groups = {AddGroup.class, UpdateGroup.class})
@ListValue(vals = {0, 1}, groups = {AddGroup.class, UpdateStatusGroup.class})
private Integer showStatus;
在controller指定相对应的Group
@RequestMapping("/save")
public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand){
brandService.save(brand);
return R.ok();
}
按分组来生效
11. VO (Value Object)
11.1 VO的由来
当有新增字段时,我们往往会在entity实体类中新建一个字段,并标注数据库中不存在该字段,然而这种方式并不规范
@TableField(exist = false)
private Long attrGroupId;
应该新建对应的VO
11.2 Object分类
1.PO (persistant object) 持久对象
PO 就是对应数据库中某个表中的一条记录,多个记录可以用 PO 的集合。 PO 中应该不包
含任何对数据库的操作。
2.DO(Domain Object)领域对象
就是从现实世界中抽象出来的有形或无形的业务实体。
3.TO (Transfer Object) ,数据传输对象
不同的应用程序之间传输的对象
4.DTO(Data Transfer Object)数据传输对象
这个概念来源于 J2EE 的设计模式,原来的目的是为了 EJB 的分布式应用提供粗粒度的
数据实体,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载,但在这
里,泛指用于展示层与服务层之间的数据传输对象。
5.VO (value object) 值对象
通常用于业务层之间的数据传递,和 PO 一样也是仅仅包含数据而已。但应是抽象出
的业务对象 , 可以和表对应 , 也可以不 , 这根据业务的需要 。用 new 关键字创建,由
GC 回收的。
View object:视图对象;
接受页面传递来的数据,封装对象
将业务处理完成的对象,封装成页面要用的数据
6.BO(business object) 业务对象
从业务模型的角度看 , 见 UML 元件领域模型中的领域对象。封装业务逻辑的 java 对
象 , 通过调用 DAO 方法 , 结合 PO,VO 进行业务操作。business object: 业务对象 主要作
用是把业务逻辑封装为一个对象。这个对象可以包括一个或多个其它的对象。 比如一个简
历,有教育经历、工作经历、社会关系等等。 我们可以把教育经历对应一个 PO ,工作经
历对应一个 PO ,社会关系对应一个 PO 。 建立一个对应简历的 BO 对象处理简历,每
个 BO 包含这些 PO 。 这样处理业务逻辑时,我们就可以针对 BO 去处理。
7.POJO(plain ordinary java object) 简单无规则 java 对象
传统意义的 java 对象。就是说在一些 Object/Relation Mapping 工具中,能够做到维护
数据库表记录的 persisent object 完全是一个符合 Java Bean 规范的纯 Java 对象,没有增
加别的属性和方法。我的理解就是最基本的 java Bean ,只有属性字段及 setter 和 getter
方法!。
POJO 是 DO/DTO/BO/VO 的统称。
8.DAO(data access object) 数据访问对象
是一个 sun 的一个标准 j2ee 设计模式, 这个模式中有个接口就是 DAO ,它负持久
层的操作。为业务层提供接口。此对象用于访问数据库。通常和 PO 结合使用, DAO 中包
含了各种数据库的操作方法。通过它的方法 , 结合 PO 对数据库进行相关的操作。夹在业
务逻辑与数据库资源中间。配合 VO, 提供数据库的 CRUD 操作.
12. 事务
12.1 一个方法中拥有多个数据库操作的需要添加事务
@Transactional
12.2 编程式事务与声明式事务
编程式事务:通过相对应的方法,在编程时植入事务逻辑
声明式事务:在方法中使用注解,@Transactional
区别:便捷性、灵活性
12.3 Spring事务的隔离级别
public enum Isolation {
//默认,与数据库对应
DEFAULT(-1),
//读未提交
READ_UNCOMMITTED(1),
//读已提交
READ_COMMITTED(2),
//可重复读
REPEATABLE_READ(4),
//串行化
SERIALIZABLE(8);
}
12.4 Spring事务的传播行为
public enum Propagation {
REQUIRED(0),
SUPPORTS(1),
MANDATORY(2),
REQUIRES_NEW(3),
NOT_SUPPORTED(4),
NEVER(5),
NESTED(6);
}
REQUIRED :如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
SUPPORTS :如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
MANDATORY :如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
REQUIRES_NEW :创建一个新的事务,如果当前存在事务,则把当前事务挂起。
NOT_SUPPORTED :以非事务方式运行,如果当前存在事务,则把当前事务挂起。
NEVER :以非事务方式运行,如果当前存在事务,则抛出异常。
NESTED :如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于 REQUIRED 。
指定方法:通过使用 propagation 属性设置
例如:@Transactional(propagation = Propagation.REQUIRED)
13. 安装ELK
直接docker安装
可以使用RestFul方法进行操作
ElasticSearch本身不支持中文分词,需要安装中文分词器 IK分词器,自行github搜索安装
二、 项目模块
1. product 产品模块
1.1 分类菜单
通过parent_id来标识层级关系
- 先查找所有的目录
- 找第一层
- 然后遍历第一层,生成第二层
- 在生成第二层时遍历第三层
public Map<String, List<Catalog2Vo>> getCatalogJsonFromDB() {
//先查找所有的分类,在程序中查找父id,进行分类
List<CategoryEntity> entities = this.baseMapper.selectList(null);
//查出素有的1级分类
List<CategoryEntity> level1Categorys = getChildren(entities, 0L);
//封装数据
return level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//查找二级分类
List<CategoryEntity> categoryEntities = getChildren(entities, v.getCatId());
List<Catalog2Vo> catalog2Vos = null;
if (categoryEntities != null) {
catalog2Vos = categoryEntities.stream().map(item -> {
//查找三级分类
List<CategoryEntity> level3Catalog = getChildren(entities, item.getCatId());
List<Catalog2Vo.Catalog3Vo> catalog3Vos = null;
if (level3Catalog != null){
catalog3Vos = level3Catalog.stream().map(l3 -> new Catalog2Vo.Catalog3Vo(l3.getParentCid().toString(), l3.getCatId().toString(), l3.getName())).collect(Collectors.toList());
}
return new Catalog2Vo(v.getParentCid().toString(), catalog3Vos, item.getCatId().toString(), item.getName());
}).collect(Collectors.toList());
}
return catalog2Vos;
}));
}
public List<CategoryEntity> getChildren(List<CategoryEntity> entities, Long parentCid){
return entities.stream().filter(item -> Objects.equals(item.getParentCid(), parentCid)).collect(Collectors.toList());
}
本质是拥有子类属性
1.2 SPU和SKU
SPU (标准化产品单元)
该产品共有的信息,例如:cpu、摄像头
SKU (库存量单元)
产品特有的信息,例如:颜色、内存
1.3 响应式编程
响应式编程是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。
1.4 Feign的使用
1.4.1 主程序加上@EnableFeignClients
注释
@EnableFeignClients//这个注释
@EnableDiscoveryClient
@SpringBootApplication
@EnableRedisHttpSession
@MapperScan("com.atguigu.gulimall.product.dao")
public class GulimallProductApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallProductApplication.class, args);
}
}
1.4.2 创建远程调用方法相对应的接口,需要有相对应的API
@FeignClient("gulimall-search")
public interface SearchFeignService {
@PostMapping("/search/save/product")
R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
1.5 SpringCache的使用
1.5.1 引入对应的依赖,编写配置类
@EnableCaching
@Configuration
public class MyCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
1.5.2 yaml配置
spring:
cache:
type: redis
redis:
# 生存周期
time-to-live: 3600000
# 使用前缀
use-key-prefix: true
# 允许空值,防止缓存穿透
cache-null-values: true
1.5.3 在对应的地方使用注解
@Override
@Cacheable(value = {"category"}, key = "#root.method.name", sync = true)
public List<CategoryEntity> getLevel1Categorys() {
return this.baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
}
@CacheEvict是删除缓存,在更新数据时使用此注解。
1.6 线程池
1.6.1 创建线程池的配置类,并将其注册到bean
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(){
return new ThreadPoolExecutor(
20,
200,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
1.6.2 使用CompletableFuture进行异步编排,使用线程池
@Override
public SkuItemVo item(Long skuId) {
SkuItemVo skuItemVo = new SkuItemVo();
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
//sku基本信息获取
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync(res -> {
//sku销售属性获取
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync(res -> {
//spu介绍获取
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVo.setDesc(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync(res -> {
//spu规格参数信息
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
//这个可以让当前线程执行
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
//sku图片信息获取
List<SkuImagesEntity> images = skuImagesService.getImagesbySkuId(skuId);
skuItemVo.setImages(images);
}, executor);
try {
CompletableFuture.allOf(saleAttrFuture, descFuture, baseAttrFuture, imageFuture).get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
return skuItemVo;
}
好处:
-
降低资源的消耗 ,通过重复利用已经创建好的线程降低线程的创建和销毁带来的损耗
-
提高响应速度 ,因为线程池中的线程数没有超过线程池的最大上限时,有的线程处于等待分配任务的状态,当任务来时无需创建新的线程就能执行
-
提高线程的可管理性 ,线程池会根据当前系统特点对池内的线程进行优化处理,减少创建和销毁线程带来的系统开销。无限的创建和销毁线程不仅消耗系统资源,还降低系统的稳定性,使用线程池进行统一分配
四种常见的线程池
常见线程池
- newFixedThreadPool:最大线程和核心线程一致,用的是LinkedBlockingQueue,无限容量。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
- newSingleThreadExecutor:最大线程和核心线程一致,用的是LinkedBlockingQueue,无限容量。
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()));
}
- newCachedThreadPool:没有核心线程,直接向 SynchronousQueue 中提交任务,如果有空闲线程,就去取出任务执行。如果没有空闲线程,就新建一个。执行完任务的线 程有 60 秒生存时间,如果在这个时间内可以接到新任务,才可以存活下去。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
}
- newScheduledThreadPool:核心线程和最大线程都有,采用DelayedWorkQueue 队列。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, new DelayedWorkQueue());
}
private static final long DEFAULT_KEEPALIVE_MILLIS = 10L;
1.7 Redisson分布式锁的使用
1.7.1 创建配置类
@Configuration
public class MyRedissonConfig {
@Bean
public RedissonClient redisson() throws IOException{
//创建配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.5.94:6397");
//根据config创建出RedissonClient实例
return Redisson.create(config);
}
}
1.7.2 直接使用
redissonClient.getLock("lock"+s)
//应该使用try-catch结构解锁,并设置超时时间
1.8 分布式锁
一个Redis中的重要理念SETNX
Set If Not Exist
,如果不存在就设置
原理:
- 拿一个Token去执行
Set If Not Exist
命令,如果设置成功就获取锁,设置的时候应该设置失效时间 - 使用完后拿着Token解锁,如果Token一致,那么删除,否则只能等锁失效
加锁
stringRedisTemplate.opsForValue().setIfAbsent("lock", "uuid", 30, TimeUnit.MINUTES);
删除锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class) , Arrays.asList("lock"), uuid);
但是这样是有问题的
客户端长时间阻塞导致锁失效问题
可以设置锁的失效时间来尽量避免,但是业务时长难以准确预料
redis服务器时钟漂移问题
如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。
单点实例安全问题
如果redis是单master模式的,当这台机宕机的时候,那么所有的客户端都获取不到锁了,为了提高可用性,可能就会给这个master加一个slave,但是因为redis的主从同步是异步进行的,可能会出现客户端1设置完锁后,master挂掉,slave提升为master,因为异步复制的特性,客户端1设置的锁丢失了,这时候客户端2设置锁也能够成功,导致客户端1和客户端2同时拥有锁。
为了解决Redis单点问题,redis的作者提出了RedLock算法。
RedLock算法
该算法的实现前提在于Redis必须是多节点部署的,可以有效防止单点故障,具体的实现思路是这样的:
1、获取当前时间戳(ms)
2、先设定key的有效时长(TTL),超出这个时间就会自动释放,然后client(客户端)尝试使用相同的key和value对所有redis实例进行设置,每次链接redis实例时设置一个比TTL短很多的超时时间,这是为了不要过长时间等待已经关闭的redis服务。并且试着获取下一个redis实例
比如:TTL(也就是过期时间)为5s,那获取锁的超时时间就可以设置成50ms,所以如果50ms内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁
3、client通过获取所有能获取的锁后的时间减去第一步的时间,还有redis服务器的时钟漂移误差,然后这个时间差要小于TTL时间并且成功设置锁的实例数 >= N/2 + 1(N为Redis实例的数量),那么加锁成功
比如TTL是5s,连接redis获取所有锁用了2s,然后再减去时钟漂移(假设误差是1s左右),那么锁的真正有效时长就只有2s了;
4、如果客户端由于某些原因获取锁失败,便会开始解锁所有redis实例。
首先第一点,我们可以看到,在RedLock算法中,锁的有效时间会减去连接Redis实例的时长,如果这个过程因为网络问题导致耗时太长的话,那么最终留给锁的有效时长就会大大减少,客户端访问共享资源的时间很短,很可能程序处理的过程中锁就到期了。而且,锁的有效时间还需要减去服务器的时钟漂移,但是应该减多少合适呢,要是这个值设置不好,很容易出现问题。
然后第二点,这样的算法虽然考虑到用多节点来防止Redis单点故障的问题,但但如果有节点发生崩溃重启的话,还是有可能出现多个客户端同时获取锁的情况。
假设一共有5个Redis节点:A、B、C、D、E,客户端1和2分别加锁
- 客户端1成功锁住了A,B,C,获取锁成功(但D和E没有锁住)。
- 节点C的master挂了,然后锁还没同步到slave,slave升级为master后丢失了客户端1加的锁。
- 客户端2这个时候获取锁,锁住了C,D,E,获取锁成功。
这样,客户端1和客户端2就同时拿到了锁,程序安全的隐患依然存在。除此之外,如果这些节点里面某个节点发生了时间漂移的话,也有可能导致锁的安全问题。
所以说,虽然通过多实例的部署提高了可用性和可靠性,但RedLock并没有完全解决Redis单点故障存在的隐患,也没有解决时钟漂移以及客户端长时间阻塞而导致的锁超时失效存在的问题,锁的安全性隐患依然存在。
1.9 看门狗机制
如果负责储存某些分布式锁的某些Redis节点宕机以后,而且这些锁正好处于锁住的状态时,这些锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
注意:看门狗延长的是锁实例的存在时间,而不是延长锁的使用时间
2. 权限模块
2.1 视图映射
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// 这样就可以不用写空方法直接跳转
// registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
2.2 OAuth2.0登录
2.3 OAuth2.0
主要步骤
- 首先在能够第三方登录的网站设置相对应的回调链接和APP设置。
- 跳转到第三方登录页面。跳转时带上回调链接。
- 第三方网站认证成功后会通过跳转到回调链接,然后带上相对应的code。
- 通过code获得对应用户的Access Token。(获取过程需要带上服务器的
client_id
和client_secret
,否则无法获取成功) - 然后服务端就可以通过Access Token获取第三方网站允许访问的内容,例如:用户名、性别、年龄等。
- 将获取的内容和客户端对比,登录或者注册,执行业务逻辑。
2.4 单点登录(SSO)(Single Sign On)
主要内容和OAuth2.0 类似。
主要是将第三方登录页面换成自己的单点登录页面。
但是有一些小的细节。
例如:
- 有的单点登录是通过在cookie共享session,然后获取code,进而验证登录的。但是用户有可能禁用cookie,导致功能失效。解决方法是:在地址栏返回相对应的code。
- 拿到Access Token之后需要验证登录,否则不能保证用户信息正确。
2.5 SpringSession
原理是拦截器,重写了session中的操作方法,将用户session存放到redis中
查询的时候先查询Redis中的session
- 使用了SpringSession保存用户session状态,将其存放在Redis和cookie中
- cookie的域名是根域名,所以在多个网站直接可以直接使用HttpRequest获取用户session,实现了客户的登录状态保存
- cookie中会存放session_id,用来识别用户的session
- 序列化采用json,需要设置配置类
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
配置application.yml
spring:
redis:
host: 192.168.5.94
port: 6379
session:
store-type: redis
3. common模块
3.1 该模块是公用模块,用于依赖引入和存放公用类(例如枚举、TO、异常和工具类等)
公用依赖
<dependencies>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<!-- httpcore -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.15</version>
</dependency>
<!-- commons-lang -->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.25</version>
</dependency>
<!-- servlet-api -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
<!-- nacos服务注册/发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- nacos配置中心、配置管理 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- validation-api -->
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<!-- hibernate-validator -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.2.Final</version>
</dependency>
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.6.3</version>
</dependency>
<!-- devtools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<version>2.6.3</version>
<optional>true</optional>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.3</version>
</dependency>
<!-- redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
<!-- cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.6.3</version>
</dependency>
<!-- spring-boot-configuration-processor 配置注解提示 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>2.6.3</version>
<optional>true</optional>
</dependency>
<!-- httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5</version>
</dependency>
</dependencies>
4. coupon 优惠券模块
主要作用是保存和获取优惠信息,均为OpenFeign远程调用
5. gateway 网关模块
5.1 跨域问题解决
- 应用程序配置,编写配置类
@Configuration
public class GulimallCorsConfiguration {
@Bean
public CorsWebFilter corsWebFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
//配置跨域
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**", corsConfiguration);
return new CorsWebFilter(source);
}
}
- application.yml中配置路由
spring:
cloud:
nacos:
discovery:
server-addr: 192.168.5.94:8848
config:
server-addr: 192.168.5.94:8848
namespace: a503fc33-c76b-41f4-aadd-0523955b5cb0
gateway:
routes:
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**
filters:
- RewritePath=/api/(?<segment>/?.*), /$\{segment}
- id: third_party_route
uri: lb://gulimall-third-party
predicates:
- Path=/api/thirdparty/**
filters:
- RewritePath=/api/thirdparty(?<segment>/?.*), /$\{segment}
- id: member_route
uri: lb://gulimall-member
predicates:
- Path=/api/member/**
filters:
- RewritePath=/api/(?<segment>/?.*), /$\{segment}
- id: ware_route
uri: lb://gulimall-ware
predicates:
- Path=/api/ware/**
filters:
- RewritePath=/api/(?<segment>/?.*), /$\{segment}
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>/?.*), /renren-fast/$\{segment}
- id: gulimall_host_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com,item.gulimall.com
- id: gulimall_search_route
uri: lb://gulimall-search
predicates:
- Host=search.gulimall.com
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
- id: gulimall_order_route
uri: lb://gulimall-order
predicates:
- Host=order.gulimall.com
application:
name: gulimall-gateway
server:
port: 88
负载均衡是Ribbon的工作,但是openfeign和nacos都有集成
6. member 会员模块
会员模块目前开发的功能,只有登录、注册和获取收货地址这三个主要功能。
6.1 密码加密
密码采用BCryptPasswordEncoder进行加密
//密码进行BCryptPasswordEncoder加密
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
//使用encode方法进行加密
bCryptPasswordEncoder.encode(vo.getPassword());
//使用match方法进行密码匹配,返回boolean值
passwordEncoder.matches(vo.getPassword(), memberEntity.getPassword());
原理:生成密文的时候会先随机生成盐值,然后用盐值生成Hash信息。密文中包括盐值和Hash信息。
所以每次生成密文的时候,密文都不一样。匹配的时候只需要使用密文中的盐值即可。
6.2 社交登录
社交登录验证应该使用另一套方案,权限认证交由第三方系统。
@Override
public MemberEntity oauthLogin(SocialUser socialUser) {
//1、判断当前社交用户是否已经登录过系统
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", socialUser.getId()));
if (memberEntity != null) {
return memberEntity;
} else {
MemberEntity register = new MemberEntity();
//查询成功
register.setUsername(socialUser.getName());
register.setNickname(socialUser.getName());
register.setEmail(socialUser.getEmail());
register.setHeader(socialUser.getAvatarUrl());
register.setCreateTime(new Date());
register.setSocialUid(socialUser.getId());
//把用户信息插入到数据库中
this.baseMapper.insert(register);
return register;
}
}
7. 第三方模块
7.1 OSS (Object Storage Service)对象存储服务
一种将对象存储在云上的服务
使用第三方签名直传模式
对应的Controller类
@RestController
public class OSSController {
// 创建OSSClient实例。
@Autowired
private OSS ossClient;
@Value("${spring.cloud.alicloud.oss.endpoint}")
private String endpoint;
@Value("${spring.cloud.alicloud.oss.bucket}")
private String bucket;
@Value("${spring.cloud.alicloud.access-key}")
private String accessId;
@RequestMapping("/oss/policy")
public R policy(){
String host = "https://" + bucket + "." + endpoint;
String format = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
String dir = format + "/"; // 用户上传文件时指定的前缀。
Map<String, String> respMap = new LinkedHashMap<>();
try {
long expireTime = 30;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
// PostObject请求最大可支持的文件大小为5GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes(StandardCharsets.UTF_8);
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
respMap.put("accessid", accessId);
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("expire", String.valueOf(expireEndTime / 1000));
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
ossClient.shutdown();
}
return R.ok().put("data", respMap);
}
}
原理:
- 先由浏览器向服务器发送对应的请求信息,服务器发送信息
信息包含accessid(登录id)、policy(加密后的密钥)、signature(签名)、dir(文件路径)、host(存储域名地址)、expire(信息过期时间)。
好处:可以直接由用户浏览器上传,无需经过服务器。
7.2 @Value注解的使用
@Value("${spring.cloud.alicloud.oss.endpoint}")
这个注解可以获取application.yml中的值
8. ware 仓储模块
主要业务
- 获取采购单-->采购相应货物,并添加到仓库
- 采购单的合并,同样货物的采购单可以合并
- 获取运费,一般来说应该有一个运费表,有从A-->B地区相对应的运费,但是这里为了方便开发,直接使用10元运费
- 仓库的CRUD
- 仓库库存的CRUD
- 锁定库存,订单创建的相关业务
- 查询库存,查询是否有库存,查询库存数量,订单创建的相关业务
8.1 死信队列 解锁库存、取消订单
该类主要作用是:收到信息后解锁库存,解锁成功后删除信息(发送ACK确认信息),如果失败则拒绝该信息,将其返回到队列,交由其他消费者消费
@Log4j2
@RabbitListener(queues = "stock.release.stock.queue")
@Service
public class StockReleaseListener {
@Autowired
private WareSkuService wareSkuService;
/**
* 1、库存自动解锁
* 下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
*
* 2、订单失败
* 库存锁定失败
*
* 只要解锁库存的消息失败,一定要告诉服务解锁失败
*/
@RabbitHandler
public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
log.info("******收到解锁库存的信息******");
try {
//当前消息是否被第二次及以后(重新)派发过来了
// Boolean redelivered = message.getMessageProperties().getRedelivered();
//解锁库存
wareSkuService.unlockStock(to);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
@RabbitHandler
public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
log.info("******收到订单关闭,准备解锁库存的信息******");
try {
wareSkuService.unlockStock(orderTo);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
Order模块的内容去Order模块看
8.2 库存加锁
基本原理:找仓库-->锁库存-->返回成功信息
/**
* 为某个订单锁定库存
* @param vo
* @return
*/
@Transactional(rollbackFor = Exception.class)
@Override
public boolean orderLockStock(WareSkuLockVo vo) {
/*
保存库存工作单详情信息
追溯
*/
WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
wareOrderTaskEntity.setCreateTime(new Date());
wareOrderTaskService.save(wareOrderTaskEntity);
//1、按照下单的收货地址,找到一个就近仓库,锁定库存
//2、找到每个商品在哪个仓库都有库存
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map((item) -> {
SkuWareHasStock stock = new SkuWareHasStock();
Long skuId = item.getSkuId();
stock.setSkuId(skuId);
stock.setNum(item.getCount());
//查询这个商品在哪个仓库有库存
List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIdList);
return stock;
}).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
boolean skuStocked = false;
Long skuId = hasStock.getSkuId();
List<Long> wareIds = hasStock.getWareId();
if (org.springframework.util.StringUtils.isEmpty(wareIds)) {
//没有任何仓库有这个商品的库存
throw new NoStockException(skuId);
}
//1、如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
//2、锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁
for (Long wareId : wareIds) {
//锁定成功就返回1,失败就返回0
Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1) {
skuStocked = true;
WareOrderTaskDetailEntity taskDetailEntity = WareOrderTaskDetailEntity.builder()
.skuId(skuId)
.skuName("")
.skuNum(hasStock.getNum())
.taskId(wareOrderTaskEntity.getId())
.wareId(wareId)
.lockStatus(1)
.build();
wareOrderTaskDetailService.save(taskDetailEntity);
//TODO 告诉MQ库存锁定成功
StockLockedTo lockedTo = new StockLockedTo();
lockedTo.setId(wareOrderTaskEntity.getId());
StockDetailTo detailTo = new StockDetailTo();
BeanUtils.copyProperties(taskDetailEntity,detailTo);
lockedTo.setDetailTo(detailTo);
rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",lockedTo);
break;
} else {
//当前仓库锁失败,重试下一个仓库
}
}
if (!skuStocked) {
//当前商品所有仓库都没有锁住
throw new NoStockException(skuId);
}
}
//3、肯定全部都是锁定成功的
return true;
}
8.3 解锁库存
基本原理:没有订单和订单取消的要解锁库存(需要查询一下订单状态)-->按照是否解锁返回信息
解锁时需要查询订单状态
@Override
public void unlockStock(StockLockedTo to) {
//库存工作单的id
StockDetailTo detail = to.getDetailTo();
Long detailId = detail.getId();
/**
* 解锁
* 1、查询数据库关于这个订单锁定库存信息
* 有:证明库存锁定成功了
* 解锁:订单状况
* 1、没有这个订单,必须解锁库存
* 2、有这个订单,不一定解锁库存
* 订单状态:已取消:解锁库存
* 已支付:不能解锁库存
*/
WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);
if (taskDetailInfo != null) {
//查出wms_ware_order_task工作单的信息
Long id = to.getId();
WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);
//获取订单号查询订单状态
String orderSn = orderTaskInfo.getOrderSn();
//远程查询订单信息
R orderData = orderFeignService.getOrderStatus(orderSn);
if (orderData.getCode() == 0) {
//订单数据返回成功
OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {});
//判断订单状态是否已取消或者支付或者订单不存在
if (orderInfo == null || orderInfo.getStatus() == 4) {
//订单已被取消,才能解锁库存
if (taskDetailInfo.getLockStatus() == 1) {
//当前库存工作单详情状态1,已锁定,但是未解锁才可以解锁
unLockStock(detail.getSkuId(),detail.getWareId(),detail.getSkuNum(),detailId);
}
}
} else {
//消息拒绝以后重新放在队列里面,让别人继续消费解锁
//远程调用服务失败
throw new RuntimeException("远程调用服务失败");
}
} else {
//无需解锁
}
}
/**
* 防止订单服务卡顿,导致订单状态消息一直改不了,库存优先到期,查订单状态新建,什么都不处理
* 导致卡顿的订单,永远都不能解锁库存
* @param orderTo
*/
@Transactional(rollbackFor = Exception.class)
@Override
public void unlockStock(OrderTo orderTo) {
String orderSn = orderTo.getOrderSn();
//查一下最新的库存解锁状态,防止重复解锁库存
WareOrderTaskEntity orderTaskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);
//按照工作单的id找到所有 没有解锁的库存,进行解锁
Long id = orderTaskEntity.getId();
List<WareOrderTaskDetailEntity> list = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>()
.eq("task_id", id).eq("lock_status", 1));
for (WareOrderTaskDetailEntity taskDetailEntity : list) {
unLockStock(taskDetailEntity.getSkuId(),
taskDetailEntity.getWareId(),
taskDetailEntity.getSkuNum(),
taskDetailEntity.getId());
}
}
/**
* 解锁库存的方法
* @param skuId
* @param wareId
* @param num
* @param taskDetailId
*/
public void unLockStock(Long skuId,Long wareId,Integer num,Long taskDetailId) {
//库存解锁
wareSkuDao.unLockStock(skuId,wareId,num);
//更新工作单的状态
WareOrderTaskDetailEntity taskDetailEntity = new WareOrderTaskDetailEntity();
taskDetailEntity.setId(taskDetailId);
//变为已解锁
taskDetailEntity.setLockStatus(2);
wareOrderTaskDetailService.updateById(taskDetailEntity);
}
9. search 搜索模块
搜索模块主要是elasticsearch的一个应用模块
新版本换API了,所以这些内容可能没有用
配置类,单例模式生成client
@Configuration
public class GulimallElasticSearchConfig {
public static final RequestOptions COMMON_OPTIONS;
static{
RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
COMMON_OPTIONS = builder.build();
}
@Bean
public RestHighLevelClient esRestClient(){
return new RestHighLevelClient(RestClient.builder(new HttpHost("192.168.5.94", 9200, "http")));
}
}
主要难点:
- mapping的构造
- DSL语句的构造
- result的解析
PUT product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword"
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword"
},
"brandImg": {
"type": "keyword"
},
"catalogName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
9.1 ElasticSearch原理
倒排索引,索引组织结构与一般不同,通过记录数据出现的位置来组织索引
9.2 IK分词器
项目依托中文数据,但是ElasticSearch本身并不支持中文分词,需要引入IK分词器
10. cart 购物车模块
10.1 拦截器
先实现HandlerInterceptor
,然后实现WebMvcConfigurer
创建组件类,实现HandlerInterceptor
,拿到session,判断是否登录,登录成功会有loginUser
这个属性在session里
@Component
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>();
/**
* 判断是否登录,如果未登录就分配一个临时id
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
MemberResponseVo member = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (member != null){
userInfoTo.setUserId(member.getId());
}
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0){
for (Cookie cookie : cookies) {
String name = cookie.getName();
if (name.equalsIgnoreCase(CartConstant.TEMP_USER_COOKIE_NAME)){
userInfoTo.setUserKey(cookie.getValue());
userInfoTo.setTempUser(true);
}
}
}
if (StringUtils.isEmpty(userInfoTo.getUserKey())){
String uuid = UUID.randomUUID().toString();
userInfoTo.setUserKey(uuid);
}
toThreadLocal.set(userInfoTo);
return true;
}
/**
* 浏览器保存
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTo userInfoTo = toThreadLocal.get();
if (!userInfoTo.getTempUser()){
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
cookie.setDomain("gulimall.com");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_EXPIRY);
response.addCookie(cookie);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
配置类注册拦截器
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
}
}
10.2 Redis存储购物车
购物车的redis前缀gulimall:cart:
获取购物车
@Override
public CartVo getCart() throws ExecutionException, InterruptedException {
CartVo cartVo = new CartVo();
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
if (userInfoTo.getUserId() != null) {
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
String tempCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
List<CartItemVo> tempCartItems = getCartItems(tempCartKey);
if (tempCartItems != null) {
for (CartItemVo tempCartItem : tempCartItems) {
addToCart(tempCartItem.getSkuId(), tempCartItem.getCount());
}
clearCartInfo(tempCartKey);
}
List<CartItemVo> cartItems = getCartItems(cartKey);
cartVo.setItems(cartItems);
} else {
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
List<CartItemVo> cartItems = getCartItems(cartKey);
cartVo.setItems(cartItems);
}
return cartVo;
}
如果登录,那么就将临时购物车放进登陆后的购物车
清空购物车,只需删除Redis中的key即可
@Override
public void clearCartInfo(String cartKey) {
stringRedisTemplate.delete(cartKey);
}
购物车添加item
@Override
public CartItemVo addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
BoundHashOperations<String, Object, Object> carOps = getCarOps();
String productRedisValue = (String) carOps.get(skuId.toString());
if (StringUtils.isEmpty(productRedisValue)) {
CartItemVo cartItemVo = new CartItemVo();
CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> {
//远程调用
R info = productFeignService.info(skuId);
String json = JSON.toJSONString(info.get("skuInfo"));
SkuInfoVo skuInfoVo = JSON.parseObject(json, SkuInfoVo.class);
cartItemVo.setSkuId(skuInfoVo.getSkuId());
cartItemVo.setTitle(skuInfoVo.getSkuTitle());
cartItemVo.setImage(skuInfoVo.getSkuDefaultImg());
cartItemVo.setPrice(skuInfoVo.getPrice());
cartItemVo.setCount(num);
}, executor);
CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> {
List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);
cartItemVo.setSkuAttrValues(skuSaleAttrValues);
}, executor);
CompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get();
String cartItemJson = JSON.toJSONString(cartItemVo);
carOps.put(skuId.toString(), cartItemJson);
return cartItemVo;
} else {
CartItemVo cartItemVo = JSON.parseObject(productRedisValue, CartItemVo.class);
cartItemVo.setCount(cartItemVo.getCount() + num);
String cartItemJson = JSON.toJSONString(cartItemVo);
carOps.put(skuId.toString(), cartItemJson);
return cartItemVo;
}
}
商品item
属性通过远程调用获取,其中使用了线程池,不同属性使用不同线程进行获取
获取购物车商品item
,直接get,然后反序列化json即可
@Override
public CartItemVo getCartItem(Long skuId) {
BoundHashOperations<String, Object, Object> carOps = getCarOps();
String redisValue = (String) carOps.get(skuId.toString());
return JSON.parseObject(redisValue, CartItemVo.class);
}
获取购物车所有item
,逐个反序列化
private List<CartItemVo> getCartItems(String cartKey) {
BoundHashOperations<String, Object, Object> operations = stringRedisTemplate.boundHashOps(cartKey);
List<Object> values = operations.values();
if (values != null && values.size() > 0) {
return values.stream().map(obj -> {
String str = (String) obj;
return JSON.parseObject(str, CartItemVo.class);
}).collect(Collectors.toList());
}
return null;
}
获取用户购物车
public List<CartItemVo> getUserCartItems() {
List<CartItemVo> cartItemVos;
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
if (userInfoTo.getUserId() == null) {
return null;
} else {
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
List<CartItemVo> cartItems = getCartItems(cartKey);
if (cartItems == null) {
throw new CartExceptionHandler();
}
cartItemVos = cartItems.stream().filter(CartItemVo::getCheck).peek(item -> {
BigDecimal price = productFeignService.getPrice(item.getSkuId());
item.setPrice(price);
}).collect(Collectors.toList());
}
return cartItemVos;
}
勾选商品,商品需要勾选才会结算
@Override
public void checkItem(Long skuId, Integer check) {
CartItemVo cartItem = getCartItem(skuId);
cartItem.setCheck(check == 1);
String redisValue = JSON.toJSONString(cartItem);
BoundHashOperations<String, Object, Object> carOps = getCarOps();
carOps.put(skuId.toString(), redisValue);
}
其他CRUD不作分析
10.3 ThreadLocal 识别身份
购物车拦截器中有一个ThreadLocal,客户访问就设置userInfoTo
属性
ThreadLocal应用场景
为线程拥有专属变量
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程 都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使用 get()和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。 ThreadLocal 最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在ThreadLocal上, ThreadLocal 可以理解为只是ThreadLocalMap 的封装,传递了变量ThreadLocalMap 值。我们可以把ThrealLocal理解为ThreadLocal 类实现的定制化的 HashMap 。类中可以通过Thread.currentThread() 获取到当前线程对象后,直接通过getMap(Thread t) 可以访问到该线程的ThreadLocalMap 对象。每个 Thread 中都具备一个 ThreadLocalMap ,而 ThreadLocalMap 可以存储以ThreadLocal 为 key ,Object对象为 value 的键值对。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {}
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话,会使用 Thread 内部都是使用仅有那个ThreadLocalMap 存放数据的, ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是ThreadLocal 对象调用set 方法设置的值。
ThreadLocal的缺陷
ThreadLocalMap如何解决冲突?
采用线性探测的方式 。
public class Thread implements Runnable {
......
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
......
}
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来, ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后 最好手动调用remove() 方法。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。但是Entry中key只能是ThreadLocal对象,这点被Entry的构造方法已经限定死了。Entry继承自WeakReference( 弱引用,生命周期只能存活到下次GC前 ),但只有Key是弱引用类型的, Value并非弱引用。由于ThreadLocalMap的key是弱引用,而Value是强引用。这就导致了 一个问题,ThreadLocal在没有外部对象强引用时,发生GC时弱引用Key会被回收,而Value不会回收。当线程没有结束,但是ThreadLocal已经被回收,则可能导致线程中存在ThreadLocalMap<null, Object>的键值对,造成内存泄露。( ThreadLocal被回收,ThreadLocal关联的线程共享变量还存在 )。
为了防止此类情况的出现,我们有两种手段:
1、使用完线程共享变量后,显示调用ThreadLocalMap.remove()方法清除线程共享变量;
既然Key是弱引用,那么我们要做的事,就是在调用ThreadLocal的get()、set()方法时完成后再调用remove方法,将Entry节点和Map的引用关系移除,这样整个Entry对象在GC Roots 分析后就变成不可达了,下次GC的时候就可以被回收。
2、JDK建议ThreadLocal定义为private static,这样ThreadLocal的弱引用问题则不存在了。
11. order 订单模块
下订单-->验令牌-->验价格(优惠券)-->锁定库存(删除购物车中的商品)-->等待支付
11.1 OpenFeign远程调用请求头丢失解决
创建Feign的配置类,将浏览器的Cookie同步到Feign中
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
RequestInterceptor requestInterceptor = new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
//1、使用RequestContextHolder拿到刚进来的请求数据
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
//老请求
HttpServletRequest request = requestAttributes.getRequest();
//2、同步请求头的数据(主要是cookie)
//把老请求的cookie值放到新请求上来,进行一个同步
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
};
return requestInterceptor;
}
}
11.2 令牌校验
幂等性
除了Token机制,还有其他的机制保证幂等性,例如锁机制,唯一约束
锁:获取锁的时候判断数据是否被使用
唯一约束:生成全局唯一ID,然后通过唯一约束来保证幂等性
原理:
- 进入订单结算页的时候分配一个Token(令牌,令牌是结算页唯一的,不是用户唯一的),并且在Redis中存储该Token
- 提交订单的时候带上该Token,并删除Redis中的Token
- 后续如果有相同Token的请求(查不到Redis中的Token),则直接拒绝
难点:
- LUA脚本解锁,使得删除Token步骤是原子的,因为Redis的操作是单线程的、排它的。
//LUA脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//令牌执行
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);
- rabbitMQ保证最终一致性
CAP理论
该系统采用AP架构,所以需要保证最终一致性,订单最终状态在所有系统都是一致的
-
订单应该存储在数据库,每个订单的创建、取消都应该做好记录
-
订单数据应该根据情况真实构建,操作订单时记得更改数据库的订单状态码
11.3 订单取消(延时队列)
死信队列的处理逻辑,超过三十分钟,队列中的订单未被消费就会被转发到死信队列
@Service
@RabbitListener(queues = "order.release.order.queue")
public class OrderCloseListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {
System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());
try {
orderService.closeOrder(orderEntity);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
11.4 队列的配置
这里主要是RabbitMQ的一些配置(将RabbitMQ的rabbitTemplate加载到Bean中、开启手动ACK)
@Configuration
public class MyRabbitConfig {
private RabbitTemplate rabbitTemplate;
@Primary
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
this.rabbitTemplate = rabbitTemplate;
rabbitTemplate.setMessageConverter(messageConverter());
initRabbitTemplate();
return rabbitTemplate;
}
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
/**
* 定制RabbitTemplate
* 1、服务收到消息就会回调
* 1、spring.rabbitmq.publisher-confirms: true
* 2、设置确认回调
* 2、消息正确抵达队列就会进行回调
* 1、spring.rabbitmq.publisher-returns: true
* spring.rabbitmq.template.mandatory: true
* 2、设置确认回调ReturnCallback
*
* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
*
*/
// @PostConstruct //MyRabbitConfig对象创建完成以后,执行这个方法
public void initRabbitTemplate() {
/**
* 1、只要消息抵达Broker就ack=true
* correlationData:当前消息的唯一关联数据(这个是消息的唯一id)
* ack:消息是否成功收到
* cause:失败的原因
*/
//设置确认回调
rabbitTemplate.setConfirmCallback((correlationData,ack,cause) -> {
System.out.println("confirm...correlationData["+correlationData+"]==>ack:["+ack+"]==>cause:["+cause+"]");
});
/**
* 只要消息没有投递给指定的队列,就触发这个失败回调
* message:投递失败的消息详细信息
* replyCode:回复的状态码
* replyText:回复的文本内容
* exchange:当时这个消息发给哪个交换机
* routingKey:当时这个消息用哪个路邮键
*/
rabbitTemplate.setReturnCallback((message,replyCode,replyText,exchange,routingKey) -> {
System.out.println("Fail Message["+message+"]==>replyCode["+replyCode+"]" +
"==>replyText["+replyText+"]==>exchange["+exchange+"]==>routingKey["+routingKey+"]");
});
}
}
11.5 手动ACK
保证消息的可靠性
RabbitMQ中的订单是重要的业务场景,不应该交由程序确认消费,所以需要开启手动ACK,自己确保订单真的被消费
//确认消费,手动ACK
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
//不确认消费,返回队列,交由其他人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
11.6 RabbitMQ 队列的创建
直接new Queue,然后Binding绑定到路由中
@Configuration
public class MyRabbitMQConfig {
/* 容器中的Queue、Exchange、Binding 会自动创建(在RabbitMQ)不存在的情况下 */
/**
* 死信队列
*
* @return
*/@Bean
public Queue orderDelayQueue() {
/*
Queue(String name, 队列名字
boolean durable, 是否持久化
boolean exclusive, 是否排他
boolean autoDelete, 是否自动删除
Map<String, Object> arguments) 属性
*/
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "order-event-exchange");
arguments.put("x-dead-letter-routing-key", "order.release.order");
arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
Queue queue = new Queue("order.delay.queue", true, false, false, arguments);
return queue;
}
/**
* 普通队列
*
* @return
*/
@Bean
public Queue orderReleaseQueue() {
Queue queue = new Queue("order.release.order.queue", true, false, false);
return queue;
}
/**
* TopicExchange
*
* @return
*/
@Bean
public Exchange orderEventExchange() {
/*
* String name,
* boolean durable,
* boolean autoDelete,
* Map<String, Object> arguments
* */
return new TopicExchange("order-event-exchange", true, false);
}
@Bean
public Binding orderCreateBinding() {
/*
* String destination, 目的地(队列名或者交换机名字)
* DestinationType destinationType, 目的地类型(Queue、Exhcange)
* String exchange,
* String routingKey,
* Map<String, Object> arguments
* */
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 订单释放直接和库存释放进行绑定
* @return
*/
@Bean
public Binding orderReleaseOtherBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
/**
* 商品秒杀队列
* @return
*/
@Bean
public Queue orderSecKillOrrderQueue() {
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderSecKillOrrderQueueBinding() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
Binding binding = new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
return binding;
}
}
注意:
- 引的包应该是
import org.springframework.amqp.core
- 需要设置订单的TTL(Time To Live 存活时间),然后转发到指定的路由,再由路由转发到队列