Maven工程聚合
pom: 表示该工程是一个聚合工程
jar 和war 可不写
1. 实体类的设计
所有的实体类都不需要设置id属性,将id,创建时间,修改时间和逻辑删除抽离出来,所有实体类都继承它
@Data
public class BaseEntity implements Serializable {
@ApiModelProperty(value = "id")
@TableId(type = IdType.AUTO)
private Long id;
@ApiModelProperty(value = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("create_time")
private Date createTime;
@ApiModelProperty(value = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("update_time")
private Date updateTime;
@ApiModelProperty(value = "逻辑删除(1:已删除,0:未删除)")
@JsonIgnore
@TableLogic
@TableField("is_deleted")
private Integer isDeleted;
}
商品分类
spu为荣耀70 , 根据销售属性来确定 sku 也就是具体的某一款 (比如需要什么颜色,版本,内存)
1.4 基本信息—spu与 sku
SKU=Stock Keeping Unit(库存量单位)。即库存进出计量的基本单元,可以是以件,盒,托盘等为单位。SKU这是对于大型连锁超市DC(配送中心)物流管理的一个必要的方法。现在已经被引申为产品统一编号的简称,每种产品均对应有唯一的SKU号**。
SPU(Standard Product Unit):标准化产品单元。是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。
首先通过检索搜索出来的商品列表中,每个商品都是一个sku。每个sku都有自己独立的库存数。也就是说每一个商品详情展示都是一个sku。
如上图,一般的电商系统你点击进去以后,都能看到这个商品关联了其他好几个类似的商品,而且这些商品很多的信息都是共用的,比如商品图片,海报、销售属性等。
那么系统是靠什么把这些sku识别为一组的呢,那是这些sku都有一个公用的spu信息。而它们公共的信息,都放在spu信息下。
所以,sku与spu的结构如下:
图中有两个图片信息表,其中spu_image表示整个spu相关下的所有图片信息,而sku_image表示这个spu下的某个sku使用的图片。sku_image中的图片是从spu_image中选取的。
每组sku的图片都是在spu下面,单击某个sku就会从spu中获取该sku图片并且展示
但是由于一个spu下的所有sku的海报都是一样,所以只存一份spu_poster就可以了。
根据spu制作sku
spu:表示一组商品的最小聚合信息
sku:表示库存量单位
2.构建springboot项目
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4LYDQDt6-1663918453440)(C:\Users\caituxinchu\AppData\Roaming\Typora\typora-user-images\image-20220907152240086.png)]
类加载扫描
@Configuration @Component 注解都需要被扫描 使用@ComponentScan({“com.tanyang.common”}) 来扫描路径
@Configuration 包含@Component
@Configuration类中可以使用@MapperScan(“扫描路径”) 可以扫描mapper
@EnableDiscoveryClient 将服务注册并且将注册了的服务全部拉取下来
只是想从注册中心拉取服务,我们只需要引导类上的注解改成
@EnableDiscoveryClient(autoRegister = false)。
@GetMapping("/getCategory2/{category1Id}")
public Result getCategory2(@PathVariable(value = "category1Id") Long id){ //变量和路径变量不一致可以这样写
}
swagger
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency>
import com.google.common.base.Predicates;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.util.ArrayList;
import java.util.List;
/**
* Swagger2配置信息
*/
@Configuration
@EnableSwagger2
public class Swagger2Config {
@Bean
public Docket webApiConfig(){
//添加head参数start
List<Parameter> pars = new ArrayList<>();
ParameterBuilder tokenPar = new ParameterBuilder();
tokenPar.name("userId")
.description("用户ID")
.defaultValue("1")
.modelRef(new ModelRef("string"))
.parameterType("header")
.required(false)
.build();
pars.add(tokenPar.build());
ParameterBuilder tmpPar = new ParameterBuilder();
tmpPar.name("userTempId")
.description("临时用户ID")
.defaultValue("1")
.modelRef(new ModelRef("string"))
.parameterType("header")
.required(false)
.build();
pars.add(tmpPar.build());
//添加head参数end
return new Docket(DocumentationType.SWAGGER_2)
.groupName("webApi谭阳")
.apiInfo(webApiInfo())
.select()
//过滤掉admin路径下的所有页面
.paths(Predicates.and(PathSelectors.regex("/api/.*")))
//过滤掉所有error或error.*页面
//.paths(Predicates.not(PathSelectors.regex("/error.*")))
.build()
.globalOperationParameters(pars);
}
@Bean
public Docket adminApiConfig(){
return new Docket(DocumentationType.SWAGGER_2)
.groupName("adminApi")
.apiInfo(adminApiInfo())
.select()
//只显示admin路径下的页面
.paths(Predicates.and(PathSelectors.regex("/admin/.*")))
.build();
}
private ApiInfo webApiInfo(){
return new ApiInfoBuilder()
.title("网站-API文档")
.description("本文档描述了网站微服务接口定义")
.version("1.0")
.contact(new Contact("Helen", "https://blog.csdn.net/qq_50629351?spm=1000.2115.3001.5343", "2115653270@qq.com"))
.build();
}
private ApiInfo adminApiInfo(){
return new ApiInfoBuilder()
.title("后台管理系统-API文档")
.description("本文档描述了后台管理系统微服务接口定义")
.version("1.0")
.contact(new Contact("Helen", "https://blog.csdn.net/qq_50629351?spm=1000.2115.3001.5343", "2115653270@qq.com"))
.build();
}
}
3.Mapper配置与使用
如果只有一个参数 可以直接写@Param ,但是有多个参数则必须指定value = “” ,这个就是xml文件的变量
List<BaseAttrInfo> selectAttrInfoList(@Param(value = "category1Id") Long category1Id,
@Param(value = "category2Id") Long category2Id,
@Param(value = "category3Id") Long category3Id);
mybatis-plus:
mapper-locations: classpath:mapper/*Mapper.xml 指定到类路径下取名为*Mapper 比如userMapper
mapper.xml的使用
在<where>标签中 能够自动判断 当第一个语句成立并且前面有or会自动去除
比如: 这个第一个<if>中的 or会去除
<where>
<if test="category1Id !=null and category1Id !=0">
or (bai.category_level=1 and bai.category_id=#{category1Id})
</if>
<if test="category2Id !=null and category2Id !=0">
or (bai.category_level=1 and bai.category_id=#{category2Id})
</if>
<if test="category3Id !=null and category3Id !=0">
or (bai.category_level=1 and bai.category_id=#{category3Id})
</if>
</where>
结果集映射:
column: 返回数据字段名 也就是数据库字段,但是当给sql字段取别名的时候返回的就是别名
property: 实体属性名
autoMapper = "true" 自动映射 如果column和property相同的字段则无需指明 会自动映射
<!-- 结果集处理-->
<resultMap id="baseAttrInfoMap" type="com.atguigu.gmall.model.product.BaseAttrInfo" autoMapping="true">
<id column="id" property="id"></id>
<!-- property 实体类属性名
ofType 实体类属性对应的类型
-->
<collection property="attrValueList" ofType="com.atguigu.gmall.model.product.BaseAttrValue" autoMapping="true">
<id column="attr_value_id" property="id"></id>
</collection>
</resultMap>
异常
一般分两类,运行时异常和检查时异常。
checked 异常就是经常遇到的IO异常,以及SQL异常等等,对于这种异常,编译器强制要求我们去try/catch。
而对于runtime exception,我们可以不处理,比如:我们从来没有人去处理过NullPointerException异常,它就是运行时异常,这还是个最常见的异常之一。
@Transactional:
默认的配置方式:只能对运行时异常回滚。
对于IOException 和SQLExceoption 这种检查时异常无法回滚
所以我们需要指定回滚异常为Exception
@Transactional(rollbackFor = Exception.class)
添加修改整合为一个接口
/**
* 新增和修改品台属性 具体逻辑根据attr_id判断 存在就是修改 不存在就是新增
* 在Mp中新增 主键自增会自动封装在实体类中
* @param baseAttrInfo
*/
@Override
public void saveAttrInfo(BaseAttrInfo baseAttrInfo) {
// 判断当前操作是保存还是修改
if (baseAttrInfo.getId() != null){
// 修改平台属性
baseAttrInfoMapper.updateById(baseAttrInfo);
//修改:通过先删除{baseAttrValue},再新增的方式!
//删除条件: baseAttrValue.attrId = baseAttrInfo.id
QueryWrapper<BaseAttrValue> baseAttrValueQueryWrapper = new QueryWrapper<>();
baseAttrValueQueryWrapper.eq("attr_id",baseAttrInfo.getId());
baseAttrValueMapper.delete(baseAttrValueQueryWrapper);
}else{
// 保存平台属性
baseAttrInfoMapper.insert(baseAttrInfo);
}
//操作平台属性
List<BaseAttrValue> attrValueList = baseAttrInfo.getAttrValueList();
if (!CollectionUtils.isEmpty(attrValueList)){
for (BaseAttrValue baseAttrValue : attrValueList){
// 设置平台属性id
baseAttrValue.setAttrId(baseAttrInfo.getId());
baseAttrValueMapper.insert(baseAttrValue);
}
}
}
mysql数据库编码
#当使用 show variables like '%char%';查看数据库编码的时候:
可以发现 character_set_server 为 latin1
这时候我们就需要在虚拟机的mysq
进入 mysql进行修改配置文件
echo "character-set-server=utf8" >> /etc/mysql/mysql.conf.d/mysqld.cnf
[root@localhost ~]# docker exec -it mysql /bin/bash
root@5804a01caf31:/# ls
bin dev entrypoint.sh home lib64 mnt proc run srv tmp var
boot docker-entrypoint-initdb.d etc lib media opt root sbin sys usr
root@5804a01caf31:/#
root@5804a01caf31:/# echo "character-set-server=utf8" >> /etc/mysql/mysql.conf.d/mysqld.cnf
root@5804a01caf31:/# exit
exit
[root@localhost ~]# docker restart mysql
mysql
跨域
2.4.4.2 解决跨域问题的方案
目前比较常用的跨域解决方案有3种:
- Jsonp
最早的解决方案,利用script标签可以跨域的原理实现。
https://www.w3cschool.cn/json/json-jsonp.html
限制:
- 需要服务的支持
- 只能发起GET请求
- nginx反向代理
思路是:利用nginx把跨域反向代理为不跨域,支持各种请求方式
缺点:需要在nginx进行额外配置,语义不清晰
- CORS
规范化的跨域请求解决方案,安全可靠。
优势:
- 在服务端进行控制是否允许跨域,可自定义规则
- 支持各种请求方式
缺点:
- 会产生额外的请求
在gateway网关模块配置全局跨域的配置:
配置该配置之后其他Controller层不需要再写@CrossOrigin
package com.tanyang.gmall.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 设置允许跨域的网络地址
corsConfiguration.addAllowedOrigin("*");
// 设置跨域是否允许携带cookie
corsConfiguration.setAllowCredentials(true);
// 跨域的请求方式设置为任意
corsConfiguration.addAllowedMethod("*");
// 允许携带的头信息也为任意
corsConfiguration.addAllowedHeader("*");
//创建配置对象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
//设置配置 {过滤哪些请求}
configurationSource.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(configurationSource);
}
}
配置文件迁移到nacos
当微服务模块过多配置文件散落在各个模块不好管理,可以迁移到Nacos进行管理
-
删除原有的nacos
-
docker stop nacos
-
docker rm nacos
-
docker run -d \ -e MODE=standalone \ -e PREFER_HOST_MODE=hostname \ -e SPRING_DATASOURCE_PLATFORM=mysql \ -e MYSQL_SERVICE_HOST=192.168.176.100 \ -e MYSQL_SERVICE_PORT=3306 \ -e MYSQL_SERVICE_USER=root \ -e MYSQL_SERVICE_PASSWORD=root \ -e MYSQL_SERVICE_DB_NAME=nacos \ -p 8848:8848 \ --name nacos \ --restart=always \ nacos/nacos-server:1.4.1
-
在数据库新建配置的数据库 可见上面代码 为nacos, 导入sql脚本
-
重新访问nacos
-
查看nacos 日期 docker log nacos 如果有端口号8848说明启动成功
-
访问 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wMyZnQZj-1663918453444)(C:\Users\caituxinchu\AppData\Roaming\Typora\typora-user-images\image-20220909155129557.png)]
-
千万不要写行尾注释
spring.application.name=server-gateway
spring.profiles.active=dev
# #注册中心的地址 注释不能写在后面 会被识别为 整体的字符串
spring.cloud.nacos.discovery.server-addr=ip地址:8848
# 配置中心的地址
spring.cloud.nacos.config.server-addr=ip地址:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
配置文件的优先级
父容器 bootstrap.yml/properties
子容器 application.yml/properties
bootstrap.yml/properties 父容器的作用:
1. 配置一些引导地址: 例如 配置中心的地址
Spu相关业务
content-type
无需自己设置content-type: 浏览器会根据数据类型使用正确的content-type
Headers:
参数名称 | 参数值 | 是否必须 | 示例 | 备注 |
---|---|---|---|---|
Content-Type | application/json | 是 |
问题描述
有这么一个接口,postman(或其他API测试工具)能够跑通,但是从浏览器的前端代码里发出去就错了,服务器报500,那么很有可能遇到了这个问题:content-type的设置和服务器期望的不一样.
现在后端通常会希望获取到json格式的数据,所以通常请求的headers里要设置content-type:application/json
headers: {
“Content-Type”: “application/json”,
}一般来说这么做是没有问题的,但是就怕忘了设置header,又多此一举的改变了body中数据的类型:
fetch(‘url’, {
method: ‘POST’,
body: JSON.stringify({
xxx:xxx,
}),
})就是这么多此一举,
后端希望得到json, 于是我自己将post数据的body变成了一个json字符串,同时我又忘记设置了header的content-type,会出现什么呢?从开发者工具看到请求的header里的content-type变成了text/plain,所以服务器拿不到数据json,和期望的数据不同,自然就很有可能跑挂了,报500的可能性就很大了.
其实浏览器是很智能的.
如果没有设置header的conten-type, 在body中传递了对象fetch(‘url’, {
method: ‘POST’,
body: {
xxx:xxx,
},
})浏览器会自动将content-type改成application/json,
如果是用了formdata
const body = new FormData()
body.append(‘xxx’, ‘xxx’)
fetch(‘url’, {
method: ‘POST’,
body,
})浏览器也可以很好的识别出请求的content-type,
所以如果可以的话, 尽量不要自己设置这个字段了,用正确的数据类型去告诉浏览器要用什么content-type就可以了
Mp的底层使用
可以把baseMapper当任意实体Mapper使用,因为他们是同一个类型
分类品牌中间表的业务
- 根据三级分类查询品牌
-
删除 只需要删除分配和品牌的中间表即可
-
添加品牌
service层的this.saveBatch()
//这里不是使用baseCategoryTradeMarkMapper 而是是用BaseCateogryTradeMarkServiceImpl {因为继承了mp中的saceBatch方法}
this.saveBatch(baseCategoryTrademarkList);
商品关系数据表
四、spu的保存功能中的图片上传
4.1 MinIo介绍
MinIO 是一个基于Apache License v2.0开源协议的对象存储服务。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。
MinIO是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。
官方文档:http://docs.minio.org.cn/docs 旧一点
https://docs.min.io/ 新
文件上传
@RestController
@RequestMapping("admin/product")
public class FileUploadController {
// 获取文件上传对应的地址
@Value("${minio.endpointUrl}")
public String endpointUrl;
@Value("${minio.accessKey}")
public String accessKey;
@Value("${minio.secreKey}")
public String secreKey;
@Value("${minio.bucketName}")
public String bucketName;
/***
* 文件上传
* /fileUpload
* @param file
*/
@PostMapping(value = "/fileUpload")
public Result fileUpload(MultipartFile file){
// 文件上传的路径
String url = "";
try {
// 使用MinIO服务的URL,端口,Access key和Secret key创建一个MinioClient对象
MinioClient minioClient = MinioClient.builder().endpoint(endpointUrl).credentials(accessKey,secreKey).build();
// 检查存储桶是否已经存在
boolean isExist = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if(isExist) {
System.out.println("Bucket gmall already exists.");
} else {
// 创建一个名为asiatrip的存储桶,用于存储照片的zip文件。
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
// 定义文件上传的名称 被上传文件的名称 名称不能重复!
String fileName = System.currentTimeMillis()+ UUID.randomUUID().toString();
// 使用putObject上传一个文件到存储桶中。
minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(fileName).stream(file.getInputStream(),
file.getSize(),-1).contentType(file.getContentType()).build());
// 文件上传后的路径 为Ip :端口号 /gmall/***
url = endpointUrl + "/"+ bucketName + "/"+ fileName;
System.out.println(url + " is successfully uploaded to" + endpointUrl+bucketName);
} catch(Exception e) {
e.printStackTrace();
}
return Result.ok(url);
}
}
保存spuInfo
/*
spuInfo;
spuImage;
spuSaleAttr;
spuSaleAttrValue;
spuPoster
*/
Sku数据表结构
保存SkuInfo
由图可见,关联着四张表
商品上下架
git
- 命令行 ssh-keygen 回车回车 重置公钥 并且会在C盘生成一个公钥文件
- 进入gitee设置中 创建一个新的公钥
Thymeleaf
有哪些对象可以将数据带到视图
-
Model model
-
HttpServletRequest request
-
HttpSession session
request.setAttribute("name","柳岩");
拼接的规则
引号嵌套: 双引号嵌套单引号
单引号嵌套双引号
引入了Mysql 如果不添加响应配置文件则会无法启动程序 因为springboot会根据依赖自动配置数据源 ,那么我们可以在启动类手动排除数据源
只有排除mysql
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
三、商品详情业务需求分析
3.1 详情渲染功能介绍
商品详情所需构建的数据如下:
1,Sku基本信息
2,Sku图片信息
3,Sku分类信息
4,Sku销售属性相关信息
5,Sku价格信息(平台可以单独修改价格,sku后续会放入缓存,为了回显最新价格,所以单独获取)
6,展示商品的海报
7,获取skuId 对应的商品规格参数
…
3.2 详情模块规划
模块规划思路:
1,service-item微服务模块封装详情页面所需数据接口;
2,service-item通过feign client调用其他微服务数据接口进行数据汇总;
3,pc端前台页面通过web-all调用service-item数据接口渲染页面;
4,service-item可以为pc端、H5、安卓与ios等前端应用提供数据接口,web-all为pc端页面渲染形式
5,service-item获取商品信息需要调用service-product服务sku信息等;
6,由于service各微服务可能会相互调用,调用方式都是通过feign client调用,所以我们把feign client api接口单独封装出来,需要时直接引用feign client api模块接口即可,即需创建service-client父模块,管理各service微服务feign client api接口。
- 根据三级分类Id查询一级 二级 三级的 id 和name
####创建视图
create view base_category_view2 as
select
c3.id as id,
c1.id as category1_id , c1.name as category1_name,
c2.id as category1_id, c2.name as category2_name,
c3.id as category3_id , c3.name as category3_name
from
base_category1 as c1
INNER JOIN base_category2 as c2 on c1.id = c2.category1_id
INNER JOIN base_category3 as c3 on c2.id = c3.category2_id
####查询视图
select * from base_category_view where id = 1
- 查询spu销售属性 和 选中的sku属性
根据销售属性 销售属性值 sku销售属性值表 查找选中属性的组 和未选中的组
### 将关联查询到的销售属性与sku_sale_attr_value进行查询的结果 与
## 将第一步的查询结果与 sku_sale_attr_value查询 依据是skuId 和spu_sale_attr_value关联
select ssa.id,
ssa.spu_id,
ssa.base_sale_attr_id,
ssa.sale_attr_name,
ssav.id sale_attr_value_id,
ssav.sale_attr_value_name,
if( skav.sku_id is null,0 ,1) is_checked
from
spu_sale_attr ssa INNER JOIN spu_sale_attr_value ssav
on
ssa. spu_id = ssav.spu_id and ssa.base_sale_attr_id = ssav.base_sale_attr_id
left join sku_sale_attr_value skav
on skav.sale_attr_value_id = ssav.id and skav.sku_id = 24
where ssa.spu_id = 11
order by ssa.base_sale_attr_id , ssav.id
根据spuid获取销售属性id和skuid的对应关系
sql解析:
我们查询sku销售属性表中的sku_id = 26的也就是 获取sku为26的这套sku属性,那么有两个sale_attr_value_id对应这个sku_id = 26的记录 ,而sale_attr_value_id 则关联表 spu_sale_attr_value这张表 ,而spu_sale_attr_value这张表记录id 为3735 3736 里面的销售属性值有基于base_sale_attr的表 有颜色和版本 并且有销售属性值字段 里面有具体颜色【幻夜星河】 或者 具体版本【6G+64G】,而skuId则在skuInfo表中,skuId为26的某个具体的skuInfo信息
##### 在分组的前提下 使用对每一组指定的数据进行拼接
##### GROUP_CONCAT:将gourp by 产生的同一个分组中的值拼接起来,返回一个字符串结果
##### group_concat(【distinct】 要连接的字段,【order by】要排序的字段,【separator】分隔符)
### 因为需要颜色在上面 所以拼接的时候需要根据base_sale_attr id进行升序排序 颜色在上 版本在下
select
group_concat(skav.sale_attr_value_id order by ssav.base_sale_attr_id asc SEPARATOR '|' ) as value_ids, sku_id
from
sku_sale_attr_value as skav
INNER JOIN
spu_sale_attr_value as ssav
on skav.sale_attr_value_id = ssav.id
where skav.spu_id = 11
group by
skav.sku_id
<!-- 将map的属性值进行自动映射-->
<resultMap id="getSkuValueIdsMap" type="map" autoMapping="true">
</resultMap>
<select id="selectgetSkuValueIdsMap" resultMap="getSkuValueIdsMap">
select
group_concat(skav.sale_attr_value_id order by ssav.base_sale_attr_id asc SEPARATOR '|' ) as value_ids, sku_id
from
sku_sale_attr_value as skav
INNER JOIN
spu_sale_attr_value as ssav
on skav.sale_attr_value_id = ssav.id
where skav.spu_id = #{spuId}
group by
skav.sku_id
</select>
OpenFeign的使用
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dq9pcoQH-1663918453456)(C:\Users\caituxinchu\AppData\Roaming\Typora\typora-user-images\image-20220915164835916.png)]
注:同一个@FeignClient里,fallback 和 fallbackFactory 不能同时使用。
负载均衡器SpringCloudLoadBalancer
由于 Netflix 对于 Ribbon 的维护已经暂停,所以 Spring Cloud 对于负载均衡建议使用由其自己定义的 Spring Cloud LoadBalancer。对于Spring Cloud LoadBalancer 的使用非常简单。
1、关闭Ribbon的负载均衡器
spring:
application:
name: consumer01-depart
cloud:
loadbalancer:
# 关闭Ribbon的负载均衡器
ribbon:
enabled: false
2、pom中添加LoadBalancer依赖
<!--spring cloud loadbalancer 依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
<version>2.2.3.RELEASE</version>
</dependency>
Feign 源码解析
FeignClient的配置
默认的配置类为FeignClientsConfiguration,这个类在spring-cloud-netflix-core的jar包下,打开这个类,可以发现它是一个配置类,注入了很多的相关配置的bean,包括feignRetryer、FeignLoggerFactory、FormattingConversionService等,其中还包括了Decoder、Encoder、Contract,如果这三个bean在没有注入的情况下,会自动注入默认的配置。
Decoder feignDecoder: ResponseEntityDecoder(这是对SpringDecoder的封装)
Encoder feignEncoder: SpringEncoder
Logger feignLogger: Slf4jLogger
Contract feignContract: SpringMvcContract
Feign.Builder feignBuilder: HystrixFeign.Builder
代码如下:
@Configuration
public class FeignClientsConfiguration {
...//省略代码
@Bean
@ConditionalOnMissingBean
public Decoder feignDecoder() {
return new ResponseEntityDecoder(new SpringDecoder(this.messageConverters));
}
@Bean
@ConditionalOnMissingBean
public Encoder feignEncoder() {
return new SpringEncoder(this.messageConverters);
}
@Bean
@ConditionalOnMissingBean
public Contract feignContract(ConversionService feignConversionService) {
return new SpringMvcContract(this.parameterProcessors, feignConversionService);
}
...//省略代码
}
重写配置:
你可以重写FeignClientsConfiguration中的bean,从而达到自定义配置的目的,比如FeignClientsConfiguration的默认重试次数为Retryer.NEVER_RETRY,即不重试,那么希望做到重写,写个配置文件,注入feignRetryer的bean,代码如下:
@Configuration
public class FeignConfig {
@Bean
public Retryer feignRetryer() {
return new Retryer.Default(100, SECONDS.toMillis(1), 5);
}
}
在上述代码更改了该FeignClient的重试次数,重试间隔为100ms,最大重试时间为1s,重试次数为5次。
Feign的工作原理
feign是一个伪客户端,即它不做任何的请求处理。Feign通过处理注解生成request,从而实现简化HTTP API开发的目的,即开发人员可以使用注解的方式定制request api模板,在发送http request请求之前,feign通过处理注解的方式替换掉request模板中的参数,这种实现方式显得更为直接、可理解。
通过包扫描注入FeignClient的bean,该源码在FeignClientsRegistrar类:
首先在启动配置上检查是否有@EnableFeignClients注解,如果有该注解,则开启包扫描,扫描被@FeignClient注解接口。代码如下:
private void registerDefaultConfiguration(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
Map<String, Object> defaultAttrs = metadata
.getAnnotationAttributes(EnableFeignClients.class.getName(), true);
if (defaultAttrs != null && defaultAttrs.containsKey("defaultConfiguration")) {
String name;
if (metadata.hasEnclosingClass()) {
name = "default." + metadata.getEnclosingClassName();
}
else {
name = "default." + metadata.getClassName();
}
registerClientConfiguration(registry, name,
defaultAttrs.get("defaultConfiguration"));
}
}
<!-- 插件 打可执行jar包的插件-->
<build>
<finalName>web-all</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
将实体类解析为JSON字符串
后端将pojo对象解析为json字符串 因为需要传递一个字符串类型
// 将这个map 转换为页面需要的Json 对象
String valueJson = JSON.toJSONString(skuValueIdsMap);
result.put("valuesSkuJson", valueJson);
前端 将字符串解析为Pojo对象
// JSON.parse() : Json 字符串转换为对象! {"115|117":"44","114|117":"45"}
var valuesSkuJson = JSON.parse(this.valuesSkuJson);
Redis
Redis配置
作为公共模块引入
-
首先引入坐标依赖
<!-- redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- spring2.X集成redis所需common-pool2--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.6.0</version> </dependency>
-
设置redisTemplate的配置类
/** * Redis配置类 */ @Configuration @EnableCaching public class RedisConfig { // 声明模板 /* ref = 表示引用 value = 具体的值 <bean class="org.springframework.data.redis.core.RedisTemplate" > <property name="defaultSerializer" ref = ""> </bean> */ @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>(); // 设置redis的连接池工厂。 redisTemplate.setConnectionFactory(redisConnectionFactory); // 设置序列化的。 Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(objectMapper); // 将Redis 中 string ,hash 数据类型,自动序列化! redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // 设置数据类型是Hash 的 序列化! redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } }
-
Redis开发的相关规则
开始开发先说明redis key的命名规范,由于Redis不像数据库表那样有结构,其所有的数据全靠key进行索引,所以redis数据的可读性,全依靠key。
企业中最常用的方式就是:object:id:field
比如:sku:1314:info
user:1092:info
:表示根据windows的 /一个意思
1.4 缓存常见问题
缓存最常见的3个问题: 面试
\1. 缓存穿透
\2. 缓存雪崩
\3. 缓存击穿
缓存穿透: 是指查询一个不存在的数据,由于缓存无法命中,将去查询数据库,但是数据库也无此记录,并且出于容错考虑,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
解决:空结果也进行缓存,但它的过期时间会很短,最长不超过五分钟。
比如请求一个4 我们 存储null值 4 null 30s
但是恶意攻击的时候:使用随机数随机ID访问导致宕机,我们可以使用布隆过滤器
【布隆过滤器】:将所有的id存储在布隆过滤器中 ,当请求的id不在布隆过滤器中直接返回 结束请求
缓存雪崩:是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
解决:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
缓存击穿: 是指对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个key在大量请求同时进来之前正好失效,那么所有对这个key的数据查询都落到db,我们称为缓存击穿。
与缓存雪崩的区别:
\1. 击穿是一个热点key失效
\2. 雪崩是很多key集体失效
解决:锁
本地锁的局限
下图清晰的说明了本地锁的局限性,会造成线程安全问题。
本地锁只能锁住同一个工程的资源 在分布式系统里面有很大的局限性。 单纯的Java Api 并不提供分布式锁的能力,为了解决这个问题就需要一种跨jvm的互斥机制来控制共享资源的访问。
2.2 分布式锁实现的解决方案
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
分布式锁主流的实现方案:
-
基于数据库实现分布式锁
-
基于缓存(Redis等)
-
基于Zookeeper (强一致性,单个结点不能用其他的结点也无法使用,可用性就低了)
每一种分布式锁解决方案都有各自的优缺点:
-
性能:redis最高
-
可靠性:zookeeper最高
这里,我们就基于redis实现分布式锁。
2.3 使用redis实现分布式锁
\1. 多个客户端同时获取锁(setnx)
\2. 获取成功,才能够执行操作,执行业务逻辑{从db获取数据,放入缓存},执行完成释放锁(del)
\3. 其他客户端等待重试 ,把锁放开,让其他用户进行争抢锁。
代码模拟:
@Override
public void testLock() {
// 1. 获取锁
//2. 操作
// 3. 释放锁
// 设置的锁如果不存在
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(0));
return;
}
Integer num = Integer.valueOf(value);
num = num +1;
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(num));
// 操作完释放锁
stringRedisTemplate.delete("lock");
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
出现问题1:
当获取锁lock后 出现异常,可能会导致锁无法释放。
一开始的解决思路: 可以获取锁后给锁设置一个过期时间,这样即便出现异常也能自动释放。
代码如下:
@Override
public void testLock() {
// 1. 获取锁
//2. 操作
// 3. 释放锁
// 设置的锁如果不存在
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock){
//在这里给获取到的锁设置一个生命周期
stringRedisTemplate.expire("lock",7, TimeUnit.SECONDS);
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(0));
return;
}
Integer num = Integer.valueOf(value);
num = num +1;
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(num));
// 操作完释放锁
stringRedisTemplate.delete("lock");
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
出现问题2:
当前先获取锁,再设置过期时间不是原子操作,当获取锁后发生异常,同样会无法设置过期时间。
保证原子性操作代码如下:
@Override
public void testLock() {
// 1. 获取锁
//2. 操作
// 3. 释放锁
// 设置的锁如果不存在
// Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
//在获取锁的时候设置生命周期,这样保证了原则子操作
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111",7,TimeUnit.SECONDS);
if (lock){
//在这里给获取到的锁设置一个生命周期
stringRedisTemplate.expire("lock",7, TimeUnit.SECONDS);
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(0));
return;
}
Integer num = Integer.valueOf(value);
num = num +1;
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(num));
// 操作完释放锁
stringRedisTemplate.delete("lock");
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
出现问题3
当设置的锁的生存时间如果比业务逻辑执行的时间少,在业务还没执行结束就过期了,那么其他的线程就能够获取该锁,进行业务操作,这样也会导致线程不安全。还有可能使得某个锁释放的是其他线程的锁,比如第二个线程还没执行完业务,锁的声明周期过期了,释放了本锁,这时候第三个线程获取该锁,第二个线程执行完业务也会释放锁,此时释放的锁就是第三个线程的了,那么就会造成锁的混乱释放。
解决问题3的思路:因为过期导致的释放锁混乱的情况,我们可以使用UUID防止误删的方法
代码如下:
@Override
public void testLock() {
// 1. 获取锁
//2. 操作
// 3. 释放锁
// 设置的锁如果不存在
// Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
//在获取锁的时候设置生命周期,这样保证了原则子操作
String uuid = UUID.randomUUID().toString().replaceAll("-","");
//使用uuid作为唯一标识进行上锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,7,TimeUnit.SECONDS);
if (lock){
//在这里给获取到的锁设置一个生命周期
stringRedisTemplate.expire("lock",7, TimeUnit.SECONDS);
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(0));
return;
}
Integer num = Integer.valueOf(value);
num = num +1;
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(num));
if (uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){
// 使用uuid校验是否误删
stringRedisTemplate.delete("lock");
}
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
出现问题4
当在线程A业务执行完后,进入了uuid判断并且判断为当前锁,这时候lock锁过期了,那么当前lock锁就被释放了,这时候另外一个线程B能够获取到当前的锁,并且进行执行业务lock锁不释放,然后回到A的判断中,这时候A将释放锁,并且已经早已判断过uuid是当前线程的,但是释放的锁却是B线程的lock锁,这依然也会造成误删除的问题。
比如:
问题:删除操作缺乏原子性。
场景:
\1. index1执行删除时,查询到的lock值确实和uuid相等
\2. index1执行删除前,lock刚好过期时间已到,被redis自动释放
在redis中没有了锁。
\3. index2获取了lock,index2线程获取到了cpu的资源,开始执行方法
\4. index1执行删除,此时会把index2的lock删除
index1 因为已经在方法中了,所以不需要重新上锁。index1有执行的权限。index1已经比较完成了,这个时候,开始执行
删除的index2的锁!
问题4的解决方案
引入lua脚本: 链接SET — Redis 命令参考 (redisfans.com)
可以通过以下修改,让这个锁实现更健壮:
- 不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。
- 不使用 DEL 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
这两个改动可以防止持有过期锁的客户端误删现有锁的情况出现。
以下是一个简单的解锁脚本示例:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
@Override
public void testLock() {
// 1. 获取锁
//2. 操作
// 3. 释放锁
//在获取锁的时候设置生命周期,这样保证了原则子操作
String uuid = UUID.randomUUID().toString().replaceAll("-","");
//使用uuid作为唯一标识进行上锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,7,TimeUnit.SECONDS);
if (lock){
//在这里给获取到的锁设置一个生命周期
stringRedisTemplate.expire("lock",7, TimeUnit.SECONDS);
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(0));
return;
}
Integer num = Integer.valueOf(value);
num = num +1;
this.stringRedisTemplate.opsForValue().set("num",String.valueOf(num));
// 2. 释放锁 del 每个结点用户端输入的参数
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 设置lua脚本返回的数据类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置lua脚本返回类型为Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
//执行
redisTemplate.execute(redisScript, Arrays.asList("lock"),uuid);
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
- 加锁和解锁必须具有原子性
redis集群状态下的问题:
\1. 客户端A从master获取到锁
\2. 在master将锁同步到slave之前,master宕掉了。
\3. slave节点被晋级为master节点
\4. 客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。
安全失效!
如图: A还未同步到slave之前宕机了,此时B又能够向集群获取该锁,造成安全问题。
解决方案:了解即可!
Redisson
Redisson提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这在Redisson中称之为 Watch Dog 机制。
如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,假如一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制。
Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。
默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期
同时 redisson 还有公平锁、读写锁的实现。
使用样例如下,附有方法的详细机制释义
private void redissonDoc() throws InterruptedException {
//1. 普通的可重入锁
RLock lock = redissonClient.getLock("generalLock");
// 拿锁失败时会不停的重试
// 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
lock.lock();
// 尝试拿锁10s后停止重试,返回false
// 具有Watch Dog 自动延期机制 默认续30s
boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);
// 拿锁失败时会不停的重试
// 没有Watch Dog ,10s后自动释放
lock.lock(10, TimeUnit.SECONDS);
// 尝试拿锁100s后停止重试,返回false
// 没有Watch Dog ,10s后自动释放
boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
//2. 公平锁 保证 Redisson 客户端线程将以其请求的顺序获得锁
RLock fairLock = redissonClient.getFairLock("fairLock");
//3. 读写锁 没错与JDK中ReentrantLock的读写锁效果一样
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");
readWriteLock.readLock().lock();
readWriteLock.writeLock().lock();
}
使用场景代码
@Override
public void testLock() {
// 创建锁:
String skuId = "20";
String lockKey = "lock:" + skuId;
// 锁的是每个商品 普通的可重入锁
RLock lock = redissonClient.getLock(lockKey);
// 开始加锁 拿锁失败会一直尝试拿锁 具有Watch Dog 自动延迟机制 默认续30s, 每隔30/3=10s 后会自动续约到30s
// lock.lock();
try {
//尝试拿锁20s后会停止重试 但有Watch Dog 机制 默认续约30s 会自动自旋
boolean lockRes = lock.tryLock(20, TimeUnit.SECONDS);
if (lockRes){
// 业务逻辑代码
// 获取数据
String value = stringRedisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)){
return;
}
// 对redis值++
int num = Integer.valueOf(value);
redisTemplate.opsForValue().set("num",String.valueOf(++num));
// 执行完业务可能还没到默认的30s 这时候需要释放锁
lock.unlock();
}else{
//如果没有拿到锁 就自己进入自旋
Thread.sleep(30);
this.testLock();
}
} catch (InterruptedException e) {
// 如果释放锁因为异常没有执行,就需要捕获异常来释放锁
lock.unlock();
e.printStackTrace();
}
}
启动Redisson的看门机制
如果你想让Redisson启动看门狗机制,你就不能自己在获取锁的时候,定义超时释放锁的时间,无论,你是通过lock() (void lock(long leaseTime, TimeUnit unit);)
还是通过tryLock获取锁,只要在参数中,不传入releastime或者传入的releastime为-1,就会开启看门狗机制,
就是这两个方法不要用:boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
和 void lock(long leaseTime, TimeUnit unit);,因为它俩都传release
但是,你传的leaseTime是-1,也是会开启看门狗机制的
默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。
设置获取锁超时时间的原因
当一个线程A在获取redis分布式锁的时候,没有设置超时时间,如果在释放锁的时候,出现了异常,那么锁就会常驻redis服务中,当另外一个线程B获取锁的时候,无论你是通过自定义的redis分布式锁setnx,还是通过Redisson实现的分布式锁的方式
**if (redis.call(‘exists’, KEYS[1]) == 0) **,在获取锁之前,其实都有一个逻辑判断:如果该锁已经存在,就是key已经存在,就不往redis中写了,也就是获取锁失败
那么线程B就永远不会获取到锁,自然就一直阻塞在获取锁的代码处,发生死锁
如果有了超时时间,异常发生了,超时的话,redis服务器自己就把key删除了,也就是锁释放了
这也就避免了并发下的死锁问题
注意:
- watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
- watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
- 如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
如果释放锁操作本身异常了,watch dog 还会不停的续期吗?不会,因为无论释放锁操作是否成功,EXPIRATION_RENEWAL_MAP中的目标 ExpirationEntry 对象已经被移除了,watch dog 通过判断后就不会继续给锁续期了。
因为无论在释放锁的时候,是否出现异常,都会执行释放锁的回调函数,把看门狗停了
总结:
- watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
- 如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
- 要使 watchLog机制生效 。只要不传leaseTime即可
- watchdog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
- watchdog 会每 lockWatchdogTimeout/3时间,去延时。
- watchdog 通过 类似netty的 Future功能来实现异步延时
- watchdog 最终还是通过 lua脚本来进行延时
2.4.3 读写锁(ReadWriteLock)
基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
有四种情况:
- 读 读
- 读 写
- 写 读
- 写 写
当有写事务都会锁住 其他线程无法获取读锁 或者写锁
测试代码:
@Override
public void writeLock() {
// 获取读写锁 需要对同一个锁进行操作
RReadWriteLock readwriteLock = redissonClient.getReadWriteLock("readwriteLock");
// 获取写锁
RLock rLock = readwriteLock.writeLock();
// 锁10s 不会续期
rLock.lock(10,TimeUnit.SECONDS);
// 写入 msg数据
this.redisTemplate.opsForValue().set("msg",UUID.randomUUID().toString());
}
@Override
public void readLock() {
// 获取读写锁 需要对同一个锁进行操作
RReadWriteLock readwriteLock = redissonClient.getReadWriteLock("readwriteLock");
// 获取读锁
RLock rLock = readwriteLock.readLock();
// 加锁 锁10s 不会续期
rLock.lock(10,TimeUnit.SECONDS);
// 写入 msg数据
this.redisTemplate.opsForValue().get("msg");
}
使用redssion实现分布式锁
private SkuInfo getSkuInfoByRedisson(Long skuId) {
SkuInfo skuInfo = null;
try {
String skuKey = RedisConst.SKUKEY_PREFIX+skuId + RedisConst.SKUKEY_SUFFIX;
// 获取缓存数据
skuInfo = (SkuInfo)redisTemplate.opsForValue().get(skuKey);
//如果skuInfo为空 则表示缓存中没有此数据
if (skuInfo == null){
// 缓存中没有数据 就从数据库中获取数据 再将数据缓存在redis中
//操作数据库 使用redisson 添加锁的方式
//定义锁的key
String lockKey = RedisConst.SKUKEY_PREFIX + skuId + RedisConst.SKULOCK_SUFFIX;
// 请求获取锁
RLock lock = redissonClient.getLock(lockKey);
// 加锁 看门狗续期30s 这里使用waitTime 和 realseTime 都为1s
boolean tryLock = lock.tryLock(RedisConst.SKULOCK_EXPIRE_PX1,RedisConst.SKULOCK_EXPIRE_PX2,TimeUnit.SECONDS);
if (tryLock){
try {
//如果获取锁了 查询数据库
skuInfo = getSkuInfoDB(skuId);
// 如果数据库中也没有
if (skuInfo == null){
// 为了避免缓存穿透 应该将查询的数据放入缓存中
SkuInfo skuInfoNullValue = new SkuInfo();
// 将skuInfo为空的存入redis 存入一个短期的(不重要数据) 存储知识以防缓存穿透
redisTemplate.opsForValue().set(skuKey,skuInfoNullValue,RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
return skuInfoNullValue;
}
// 否则在数据库中查询到了skuInfo 现在将它续期
redisTemplate.opsForValue().set(skuKey,skuInfo,RedisConst.SKUKEY_TIMEOUT,TimeUnit.SECONDS);
return skuInfo;
} catch (Exception e) {
e.printStackTrace();
}finally {
// 加锁后的逻辑操作产生异常需要手动解锁
lock.unlock();
}
}else{
//否者枷锁失败 进入自选 它能够自选 但是最好自己手动自选
Thread.sleep(1000);
return this.getSkuInfoByRedisson(skuId);
}
}else{
// 如果缓存中有数据就直接返回
return skuInfo;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
// 防止缓存宕机 ,如果缓存宕机这里就查询数据库来兜底
return this.getSkuInfoDB(skuId);
}
AOP
代理:只在原有的代码上面做增强
静态代理 (简单的注入组合) 动态代理 (jdk cglib)
jdk :实现接口 需要实现被代理类的接口 有接口才能被代理
cglib: 可以使用继承类的方式来增强
自定义注解
/**
* @Target 表示注解使用的位置 类 ,方法 ,属性 构造方法 参数
* @Retention: 表示注解的生命周期
* SOURCE:只存在类文件源码中 ,如果编译后变成class文件后不存在了
* CLASS: 会存在到类文件(字节码文件)中
* RUNTIME: 也会存在运行时
*@Inherited: 继承 表示被GmallCache修饰的类的子类会继承GmallCache这个注解
*
* @Documented 可以被生成javaDoc文档
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface GmallCache {
//缓存的前缀
String prefix() default "";
//缓存的后缀
String suffix() default "";
}
切面
@Component
@Aspect //定义切面
/**
* 例如在 public SkuInfo getSkuInfo(Long skuId) 上面添加注解@GmallCache(prefix = "sku:")
* 那么我们可以根据joinPoint连接点 获取
* 1. 可以获取添加@GmallCache的方法 getSkuInfo
* 2. 可以获取注解
* 3. 可以获取注解的属性 prefix suffix等
* 4. 可以获取方法的参数 Long skuId
*/
public class GmallCacheAspect {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(com.tanyang.gmall.common.cache.GmallCache)")
public Object CacheGmallAspect(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 创建返回的对象
Object object = new Object();
// 2. 获取添加了注解的方法
// MethodSignatrue接口里面有 Class getReturnType(); 获取返回值 Method getMethod();获取方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 获取注解
GmallCache gmallCache = signature.getMethod().getAnnotation(GmallCache.class);
// 获取缓存的前缀
String prefix = gmallCache.prefix();
//获取缓存的后缀
String suffix = gmallCache.suffix();
//根据连接点获取方法传入的参数
Object[] args = joinPoint.getArgs();
//组合获取数据的key
String key = prefix + Arrays.asList(args).toString() + suffix;
//从缓存中获取数据
object = cacheHit(key,signature);
try {
if (object == null){
//缓存中没有数据 需要从数据库查询
// 1.定义锁的key
String lockKey = prefix + ":lock";
//准备上锁 使用Redisson
RLock lock = redissonClient.getLock(lockKey);
// 上锁锁
boolean tryLock = lock.tryLock(RedisConst.SKULOCK_EXPIRE_PX1, RedisConst.SKULOCK_EXPIRE_PX2, TimeUnit.SECONDS);
//判断是否获取成功
if (tryLock){
//成功获取锁
// 执行切入方法的方法体 比如SkuInfoDB 就是查询数据库
// 使用连接点执行方法体 并且传入被切入方法的参数来执行该方法体
try {
object = joinPoint.proceed(args);
//判断是否从Mysql中查询到了数据
if (object == null){
//执行了方法体查询数据库 表示数据库中也没有数据
//创建空值
// object = new Object();
Class returnType = signature.getReturnType();
//创建对象 使用多台 向上转型
object = returnType.newInstance();
// 将空值也存储redis中 避免缓存穿透 存储以字符串的形式进行存储
redisTemplate.opsForValue().set(key,JSON.toJSONString(object),RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
//返回object
return object;
}else{
//从数据库查询的数据不为空 就进行续约
redisTemplate.opsForValue().set(key,JSON.toJSONString(object),RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
//返回object
return object;
}
} catch (Throwable throwable) {
throwable.printStackTrace();
} finally {
// 如果业务出现异常了 那么就需要手动解锁
lock.unlock();
}
}else{
//睡眠 重新自旋
Thread.sleep(1000);
this.CacheGmallAspect(joinPoint);
}
}else{
//从缓存中获取到了数据
return object;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
// 查询缓存出现异常 使用查询数据库来兜底
return joinPoint.proceed(args);
}
/**
* 从缓存中获取数据
* @param key
* @param signature
* @return
*/
private Object cacheHit(String key, MethodSignature signature) {
// 获取数据 存储的时候 转换成Json字符串
String strJson =(String) redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(strJson)){
//获取当前对象的返回值类型
Class returnType = signature.getReturnType();
//将字符串转成方法指定的类型
return JSON.parseObject(strJson,returnType);
}
return strJson;
}
}
注意
最好使用 父类 父类 = new 子类(); 这样向上转型了 避免向下转型时候报转换异常
Class returnType = signature.getReturnType();
//创建对象 使用多台 向上转型
object = returnType.newInstance();
// 将空值也存储redis中 避免缓存穿透 存储以字符串的形式进行存储 JSON.toJSONString(object)
redisTemplate.opsForValue().set(key,JSON.toJSONString(object),RedisConst.SKUKEY_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
//将字符串转成方法指定的类型
return JSON.parseObject(strJson,returnType);
布隆过滤器
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。存储的数据不是0就是1,默认是0。主要用于判断一个元素是否在一个集合中,0代表不存在某个数据,1代表存在某个数据。
通过K个哈希函数计算该数据,返回K个计算出的hash值
这些K个hash值映射到对应的K个二进制的数组下标
将K个下标对应的二进制数据改成1。
例如,第一个哈希函数返回x,第二个第三个哈希函数返回y与z,那么: X、Y、Z对应的二进制改成1。
查询过程
布隆过滤器主要作用就是查询一个数据,在不在这个二进制的集合中,查询过程如下:
1、通过K个哈希函数计算该数据,对应计算出的K个hash值
2、通过hash值找到对应的二进制的数组下标
3、判断:如果存在一处位置的二进制数据是0,那么该数据不存在。如果都是1,该数据存在集合中。
5.1.4 布隆过滤器的优缺点
优点
\1. 由于存储的是二进制数据,所以占用的空间很小
\2. 它的插入和查询速度是非常快的,时间复杂度是O(K),空间复杂度:O (M)。
K: 是哈希函数的个数
M: 是二进制位的个数
\3. 保密性很好,因为本身不存储任何原始数据,只有二进制数据
缺点
\1. 添加数据是通过计算数据的hash值,那么很有可能存在这种情况:两个不同的数据计算得到相同的hash值。
例如图中的“张三”和“张三丰”,假如最终算出hash值相同,那么他们会将同一个下标的二进制数据改为1。
这个时候,你就不知道下标为1的二进制,到底是代表“张三”还是“张三丰”。
由此得出如下缺点:
一、存在误判
假如上面的图没有存 “张三”,只存了 “张三丰”,那么用"张三"来查询的时候,会判断"张三"存在集合中。
因为“张三”和“张三丰”的hash值是相同的,通过相同的hash值,找到的二进制数据也是一样的,都是1。
误判率:
受三个因素影响: 二进制位的个数m, 哈希函数的个数k, 数据规模n (添加到布隆过滤器中的函数)
已知误判率p, 数据规模n, 求二进制的个数m,哈希函数的个数k {m,k 程序会自动计算 ,你只需要告诉我数据规模,误判率就可以了}
ln: 自然对数是以常数e为底数的对数,记作lnN(N>0)。在物理学,生物学等自然科学中有重要的意义,一般表示方法为lnx。数学中也常见以logx表示自然对数。
二、删除困难
还是用上面的举例,因为“张三”和“张三丰”的hash值相同,对应的数组下标也是一样的。
如果你想去删除“张三”,将下标为1里的二进制数据,由1改成了0。
那么你是不是连“张三丰”都一起删了呀。
初始化
-
数据的数量
-
误判率
-
在启动类实现CommandLineRunner 并且实现方法来添加布隆过滤器数据的数量和误判率
@Slf4j
public class ServiceProductApplication implements CommandLineRunner {
/**
* 初始化布隆过滤器
* @param args
* @throws Exception
*/
@Override
public void run(String... args) throws Exception {
// 获取布隆过滤器
RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter(RedisConst.SKU_BLOOM_FILTER);
// 初始化布隆过滤器 计算元素的数量 比如预计有多少个sku
bloomFilter.tryInit(10001,0.001);
}
}
- 在插入数据后将插入记录的主键添加到布隆过滤器中
//告诉布隆过滤器 存储是否存在的标记到布隆过滤器
RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter(RedisConst.SKU_BLOOM_FILTER);
// 将存储skuInfo的主键存储到布隆过滤器
bloomFilter.add(skuInfo.getId());
-
在查询数据的时候 在布隆过滤器中过滤
//在布隆过滤器中判断数据id是否存在 RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter(RedisConst.SKU_BLOOM_FILTER); if (!bloomFilter.contains(skuId)){ //如果 布隆过滤器中没有存储该值 就直接返回 return result; }
CompletableFuture异步编排
可通过回调的方式处理计算结果
1.2 创建异步对象
CompletableFuture 提供了四个静态方法来创建一个异步操作。
没有指定Executor的方法会使用ForkJoinPool.commonPool() 作为它的线程池执行异步代码。如果指定线程池,则 。
- runAsync方法不支持返回值。
- supplyAsync可以支持返回值。
1.3 计算完成时回调方法
当CompletableFuture的计算结果完成,或者抛出异常的时候,可以执行特定的Action。主要是下面的方法:
whenComplete可以处理正常或异常的计算结果,exceptionally处理异常情况。BiConsumer<? super T,? super Throwable>可以定义处理业务
whenComplete 和 whenCompleteAsync 的区别:
whenComplete:是执行当前任务的线程执行继续执行 whenComplete 的任务。
whenCompleteAsync:是执行把 whenCompleteAsync 这个任务继续提交给线程池来进行执行。
方法不以Async结尾,意味着Action使用相同的线程执行,而Async可能会使用其他线程执行(如果是使用相同的线程池,也可能会被同一个线程选中执行)
// 创建对象
CompletableFuture<String> integerCompletableFuture = CompletableFuture.supplyAsync(new Supplier<String>() {
@Override
public String get() {
int a = 1/0;
return "404";
}
}).whenComplete(new BiConsumer<String, Throwable>() {
/**
*
* @param s 异步对象执行后的返回结果
* @param throwable 异常对象
*/
@Override
public void accept(String s, Throwable throwable) {
System.out.println("whenComplete:"+ s);
System.out.println("whenComplete:" + throwable);
}
}).exceptionally(new Function<Throwable, String>() {
/**
* 只处理异常的结果
* @param throwable
* @return
*/
@Override
public String apply(Throwable throwable) {
return null;
}
}).whenCompleteAsync(new BiConsumer<String, Throwable>() {
/**
* 和异步对象可能不使用同一个线程 可能由线程池重新分配其他线程
* @param s 返回值
* @param throwable 异常对象
*/
@Override
public void accept(String s, Throwable throwable) {
}
});
String s = integerCompletableFuture.get();
}
1.4 线程串行化与并行化方法
thenApply 方法:当一个线程依赖另一个线程时,获取上一个任务返回的结果,并返回当前任务的返回值。
thenAccept方法:消费处理结果。接收任务的处理结果,并消费处理,无返回结果。
thenRun方法:只要上面的任务执行完成,就开始执行thenRun,只是处理完任务后,执行 thenRun的后续操作
带有Async默认是异步执行的。这里所谓的异步指的是不在当前线程内执行。
Function<? super T,? extends U>
T:上一个任务返回结果的类型
U:当前任务的返回值类型
首页
首页流程分析
1. 封装数据的步骤
JSONObject
JSONObject底层使用了一个map对象作为属性 ,所以可以直接将JSONObject作为一个map使用
使用JSONObject的前提是导入alibaba的fastjson