GuliMall 高级篇
商品上架
创建传入ES 的映射结构
其中 skuTiltle作为全文检索标志
任何图片相关的不能作为索引(“index”: false),不能聚合(“doc_values”: false)
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"brandImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"catalogName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
通过TypeReference(fastjson)向方法传入泛型
新建TypeReference
TypeReference<List<SkuHasStockVo>> typeReference = new TypeReference<此处传入泛型对象>() {};
接着可以把json字符串转成typereference的泛型
T t = JSON.parseObject(s, typeReference);
商城业务
配置windows本机DNS映射
浏览器访问网址会先看本机是否含有内部域名映射规则
可以在路径 为
C:\Windows\System32\drivers\etc/hosts文件修改域名映射规则
现为本项目新增一个hosts方案,当前系统hosts方案为glmall
如需切换为原来的,可以通过switchhosts(管理员权限)软件切换
# glmall
192.168.149.100 glmall.com
通过nginx搭建域名访问环境
首先上方修改windows内部域名映射规则
后在nginx上游服务器中配置负载均衡
events块中配置upstream
upstream glmall{
server 192.168.92.1:88;
}
server块中配置负载均衡
listen 80;
server_name glmall.com;
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location / {
proxy_set_header Host $host;
proxy_pass http://glmall;
}
ps: nginx代理给网关的时候,会丢失请求的host信息,所以要在location信息中加入下列信息防止请求头丢失。
proxy_set_header Host $host;
配置gateway网关路由规则(根据host头部路由)
- id: glmall_host_route
uri: lb://glmall-product
predicates:
- Host=**.glmall.com,glmall.com
配置动静分离
新增规则 也就是路径带有/staic/的将根路径转变为/usr/share/nginx/htm
例
给出的路径http://glmall.com/static/index/js/header.js
,
实际访问的路径是/usr/share/nginx/html/static/index/js/header.js
。
location /static/ {
root /usr/share/nginx/html;
}
将product商城页的index目录加入到虚拟机/mydata/nginx/html/staic包下
压力测试
相关指标
- 响应时间(Response Time: RT)
响应时间指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响
应结束,整个过程所耗费的时间。 - HPS(Hits Per Second) :每秒点击次数,单位是次/秒。
- TPS(Transaction per Second):系统每秒处理交易数,单位是笔/秒。
- QPS(Query per Second):系统每秒处理查询次数,单位是次/秒。
对于互联网业务中,如果某些业务有且仅有一个请求连接,那么 TPS=QPS=HPS,一
般情况下用 TPS 来衡量整个业务流程,用 QPS 来衡量接口查询次数,用 HPS 来表
示对服务器单击请求。 - 最大响应时间(Max Response Time) 指用户发出请求或者指令到系统做出反应(响应)
的最大时间。 - 最少响应时间(Mininum ResponseTime) 指用户发出请求或者指令到系统做出反应(响
应)的最少时间。 - 90%响应时间(90% Response Time) 是指所有用户的响应时间进行排序,第 90%的响
应时间。
从外部看,性能测试主要关注如下三个指标
吞吐量:每秒钟系统能够处理的请求数、任务数。
响应时间:服务处理一个请求或一个任务的耗时。
错误率:一批请求中结果出错的请求所占比例。
由下图可看出,主要优化的方面是数据库
Redisson
引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
配置单节点的redisson
@Configuration
public class MyRedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException{
//创建配置
Config config = new Config();
//设置单节点redis 地址前需加redis://
config.useSingleServer().setAddress("redis://192.168.149.100:6379");
//创建redissonclient实例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
锁的名字一样 就代表是同1把锁,所以尽量控制锁的粒度,粒度越小越快
Redisson使用锁的基础实例
public String hello(){
//获取锁 并为锁取名(后续根据锁名进行可重入锁的判断)
RLock myLock = redisson.getLock("myLock");
//进行加锁 未获取锁的线程将进行阻塞式等待
myLock.lock();
try {
System.out.println("成功加锁,线程.."+ Thread.currentThread().getId());
} catch (Exception e) {
} finally {
System.out.println("成功加锁,线程.."+ Thread.currentThread().getId());
//释放锁
myLock.unlock();
}
return "hello";
}
关于死锁问题
默认加的锁的过期时间是30s,如果业务时间大于30s,Redisson的watchdog会对锁进行续期,也就是刷新过期时间。
如果获取锁的线程还没来得及释放锁突然宕机,则到了过期时间锁自然会释放。
加锁lock()方法 带参和不带参的区别
带参
传入锁的过期时间,实际上是执行一段lua脚本,脚本内规定了过期时间,并没有续期机制。
无参
传入锁的过期时间,实际上是执行一段lua脚本,脚本内规定了过期时间(internalLockLeaseTime),
但每隔过期时间(internalLockLeaseTime) / 3的时间后,也就是默认internalLockLeaseTime时间30s/3的10s后,会再次执行一段lua脚本,重新设置锁的过期时间。这样就形成了续期机制。
简单来说,每隔 过期时间(internalLockLeaseTime) / 3 的时间后,就会对锁发送一次lua脚本进行续期,续期时间为过期时间(internalLockLeaseTime)。
tryLock()方法
tryLock(p1,p2,p3)
p1是指最长等待时间,
p2是指获取锁后p2时间后自动解锁,
p3是指时间单位
tryLock默认没有续期机制
读写锁
对于读写锁,只有获取了读锁才能进行读操作,只有获取了写锁才能进行写操作
当一个线程持有读锁时,其他线程可以继续获取读锁,但不能获取写锁;当一个线程持有写锁时,其他线程无法获取读锁或写锁,从而实现了对共享资源的读写互斥控制。
获取方法
获取读写锁
RReadWriteLock rwLock = redisson.getReadWriteLock("rwLock");
//对读锁进行加锁
rwLock.readLock().lock();
//对写锁进行加锁
rwLock.writeLock().lock();
具体应用
可以设置一把共用读写锁(即锁名称相同),然后根据逻辑达到获取最新数据的效果。
闭锁(CountDownLatch)
通过 trySetCount() 设定一定计数count
其他调用该锁方可以使用 countDown() 方法让计数减一
await() 方法进行阻塞,直到计数count=0时才会停止阻塞
锁方
RCountDownLatch cdlLock = redisson.getCountDownLatch("cdl");
cdlLock.trySetCount(2L);
cdlLock.await();
使用方
RCountDownLatch cdlLock = redisson.getCountDownLatch("cdl");
cdlLock.countDown();
信号量(Semaphore)
RSemaphore rs = redisson.getSemaphore("name");
- acquire(): 获取一个信号量,如果信号量为0,就会 阻塞等待 ,直到信号量大于0,成功获取信号量为止。
- tryacquire():也是获取一个信号量,但如果信号量为0,不会 阻塞等待 ,而是往下执行。该方法有Boolean类型的返回值,代表是否成功获取信号量。
- release():释放一个信号量
- trySetPermits(int permits):尝试设置信号量
缓存数据一致性
方案1: 双写模式
上图线程1可能中途卡顿,导致最新缓存比旧缓存先写入redis,造成脏数据问题
方案2: 删缓存(失效模式)
同样问题,线程卡顿造成脏数据
以上两种方案问题
解决方法1:读写加锁,但性能损耗大
解决方法2:如果可以容忍一定时间的脏数据,可以使用缓存数据+过期时间方法
下一次更新缓存时,从数据库获取最新数据
解决方法3: 对于菜品,商品等基础数据,可以用canal订阅binlog的方式
canal是通过感知数据库变化获取最新结果,并向redis更新
SpringCache
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
配置文件
spring.cache.type=redis //指定缓存类型
@Cacheable 将数据保存到缓存
需指定缓存 value / CacheNames(二者概念相同),也就是缓存分区名
通常是按业务类型分。例: @Cacheable(value={“category”})。
SpringCahe会按缓存分区名将数据存入redis,下次执行该方法缓存有数据直接读取缓存。
sync 加锁操作
sync 属性如果设置为 true,表示在多线程环境下对相同的缓存 key 进行操作时会进行加锁(本地锁)操作,确保只有一个线程能够执行缓存的加载操作。
测试用例
原方法: 方法每次调用都会向数据库查数据
public List<CategoryEntity> getLevel1Categorys() {
return baseMapper.selectList(new QueryWrapper<CategoryEntity().eq("cat_level", 1));
}
使用@Cacheable 方法会将数据保存到缓存
@Cacheable(value={"category"})
public List<CategoryEntity> getLevel1Categorys() {
return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", 1));
}
SpringCache存入redis的格式
分区名作为层级名,key为自动生成的 simpleKey
序列化机制默认使用 jdk序列化机制
默认ttl为 -1, 数据永不过期
自定义存入redis的格式
@Cacheable注解中:
key()
表示存入的key值。例:@Cacheable(key={“‘key’”}), 注意,因为key接受spel表达式,所以这里的key在外面的 “” 内还要加 ‘’ 后才能生效, 也就是 “ ‘ key ’ ” (实际没有空格,便于观察)
例 @Cacheable(key={“#root.method.name”}) , 根据方法名指定key
keyGenerator() 指定key的生成器
condition() 存入缓存的条件,比如哪些数据存入缓存
unless() 哪种情况不存入缓存
配置过期时间ttl
spring.cache.redis.time-to-live= 1000
配置key前缀,前缀名就会覆盖前面的value缓存分级名,即前缀名是层级名
spring.cache.redis.key-prefix=CACHE_
开启key前缀,也就是开启了层级
spring.cache.redis.use-key-prefix=true
缓存空值,防止缓存穿透
spring.cache.redis.cache-null-value=true
自定义SpringCahe配置
可修改序列化机制
@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {
/**
* 配置文件中 TTL设置没用上
*
* 原来:
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
*
* 现在要让这个配置文件生效 : @EnableConfigurationProperties(CacheProperties.class)
*
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 设置kv的序列化机制
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.prefixKeysWith(redisproperties.getKeyPrefix());
}
if(!redisproperties.isCacheNullValues()){
config = config.disableCachingNullValues();
}
if(!redisproperties.isUseKeyPrefix()){
config = config.disableKeyPrefix();
}
return config;
}
}
@CacheEvict 如有更新操作将数据从缓存删除,也就是失效操作
ps: 一个方法只能加一个@CacheEvict 注解
一个业务要删除多个缓存
方法1: 在@CacheEvict 将 allEntries 设置为true ,即将该 value 缓存分区名下的所有数据都删除
方法2: 使用@Caching ,在Caching内可连用多个@CacheEvict 注解
例
@Caching(evict = {
@CacheEvict(),
@CacheEvict()...
})
@CachePut 更新数据时同时将数据保存到缓存 也就是双写
@Caching 组合多个操作
小总结
对于读多写少,即时性,一致性要求不高的数据,可以使用SpringCache的写模式,设置缓存过期时间。
对于特定要求数据,不使用springcache并额外设计代码
检索模块
面包屑导航
如上图,会带有选中的条件。
封装到返回结果中
private List<NavVo> navs = new ArrayList<>(); //存放导航信息
/**
* 被筛选的属性id
*/
private List<Long> attrIds = new ArrayList<>();
@Data
public static class NavVo {
private String name; //选择的/品牌/属性
private String navValue; ///品牌/属性值
private String link; // 上一步(选择)的路径
}
CompletableFuture
创建异步操作
创建异步操作的四种方法(前两种runAsync无返回值,后supplyAsync两种有返回值)
ps: CompletableFuture 的 get() 方法是阻塞式等待,直到获取到值后才停止阻塞
例:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service);
Integer integer = future.get();
System.out.println("integer = " + integer);
计算完成时回调方法 接受(结果,异常两个参数)
- whenComplete 和 whenCompleteAsync 的区别:
- whenComplete:是执行当前任务的线程执行继续执行 whenComplete 的任务。
- whenCompleteAsync:是执行把 whenCompleteAsync 这个任务继续提交给线程池
来进行执行。
方法不以 Async 结尾,意味着 Action 使用相同的线程执行,而 Async 可能会使用其他线程
执行(如果是使用相同的线程池,也可能会被同一个线程选中执行)
例:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service).whenComplete((res, throwable) -> {
System.out.println("异步任务执行完成了,结果是:" + res + ",异常情况:" + throwable);
});
处理异常情况方法 接受异常参数 可修改返回值
例
//自定义线程池service
public static ExecutorService service = Executors.newFixedThreadPool(10);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service).exceptionally((throwable -> {
System.out.println("异常情况 : " + throwable + "默认返回-1");
return -1;
}));
处理所有情况方法 接受结果,异常两个参数 可修改返回值
例
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service).handle((res, throwable) -> {
System.out.println("异步任务执行完成了,结果是:" + res + ",异常情况:" + throwable);
if (res != null)
return res;
if (throwable != null)
return -1;
return 0;
});
线程串行化方法
- thenApply 方法:当一个线程依赖另一个线程时,获取上一个任务返回的结果,并返回当前
任务的返回值。 - thenAccept 方法:消费处理结果。接收任务的处理结果,并消费处理,无返回结果。
- thenRun 方法:只要上面的任务执行完成,就开始执行 thenRun,只是处理完任务后,执行thenRun 的后续操作
带有 Async 默认是异步执行的。同之前。以上都要前置任务成功完成
thenAccept例
CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service).thenAcceptAsync(res->{
System.out.println("res = " + res);
},service);
thenApply例
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
int i = 10 / 2;
System.out.println("i = " + i);
return i;
}, service).thenApplyAsync(res -> {
System.out.println("res = " + res);
return "值为:" + res;
}, service);
//获取future返回值(thenApplyAsync return)
System.out.println("future.get() = " + future.get());
两任务组合 - 都要完成即调用
runAfterBoth方法 不能获取到另一个线程的值 无返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
例
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
return "im task1";
}, service);
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
return "im task2";
}, service);
task1.runAfterBothAsync(task2, () -> {
System.out.println("task1 and task2 runAfterBothAsync test successful~~~");
}, service);
thenAcceptBoth() 可以获取另一个线程的值 无返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
其中任务AB都要完成的方法接受两个形参,分别为任务AB的值
例
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
return "im task1";
}, service);
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
return "im task2";
}, service);
task1.thenAcceptBothAsync(task2, (v1, v2) -> {
System.out.println("task1.value=" + v1);
System.out.println("task2.value=" + v2);
System.out.println("task1 and task2 thenAcceptBothAsync test successful~~~");
}, service);
thenCombine() 可以获取另一个线程的值 有返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
其中任务AB都要完成的方法接受两个形参,分别为任务AB的值,且可以返回value
例
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
return "im task1";
}, service);
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
return "im task2";
}, service);
CompletableFuture<String> future = task1.thenCombineAsync(task2, (v1, v2) -> {
System.out.println("task1.value=" + v1);
System.out.println("task2.value=" + v2);
System.out.println("task1 and task2 thenAcceptBothAsync test successful~~~");
return v1 + "+" + v2;
}, service);
System.out.println("返回值 = " + future.get());
两任务组合 - 任意一个完成即调用 两任务如有返回值需一致
runAfterEither() 不能获取到另一个线程的值 无返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
acceptEither() 可以获取另一个线程的值 无返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
其中任务AB都要完成的方法接受两个形参,分别为任务AB的值
applyToEither() 一个线程的值 有返回值
假设任务A和任务B一起执行
调用方法为: 任务A.runAfterBothAsync(任务B,任务AB都要完成的方法,executor)
其中任务AB都要完成的方法接受两个形参,分别为任务AB的值,且可以返回value
多任务组合
allOf
allOf:等待所有任务完成
例
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
return "im task1";
}, service);
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
return "im task2";
}, service);
CompletableFuture<String> task3 = CompletableFuture.supplyAsync(() -> {
return "im task3";
}, service);
CompletableFuture<Void> allOf = CompletableFuture.allOf(task1, task2, task3);
//阻塞式等待
allOf.get();
System.out.println("所有任务完成"+task1.get()+task2.get()+task3.get());
anyOf
anyOf:只要有一个任务完成
例
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "im task1";
}, service);
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "im task2";
}, service);
CompletableFuture<String> task3 = CompletableFuture.supplyAsync(() -> {
return "im task3";
}, service);
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(task1, task2, task3);
System.out.println("三个任务中最先完成的任务 = " + anyOf.get());
System.out.println("全部完成" + task1.get() + task3.get() + task2.get());
商品详情
MyBatis mapper自定义结果封装resultMap
例:
调用mybatis返回结果类型
@Data
@ToString
public class SpuItemAttrGroupVo {
private String groupName;
private List<Attr> attrs;
}
其中的Attr
@Data
public class Attr {
private Long attrId;
private String attrName;
private String attrValue;
}
存在多个类,会使mybatis返回结果封装错误
mapper中自定义reusltMap
完整的resultMap
<resultMap id="SpuItemAttrGroupVo" type="com.gl.glmall.product.vo.SpuItemAttrGroupVo">
<result property="groupName" column="attr_group_name"></result>
<collection property="attrs" ofType="com.gl.glmall.product.vo.Attr">
<result column="attr_name" property="attrName"></result>
<result column="attr_value" property="attrValue"></result>
</collection>
</resultMap>
带有解释的resultMap
//指定reusltMap的id,和返回结果的类型type
<resultMap id="SpuItemAttrGroupVo" type="com.gl.glmall.product.vo.SpuItemAttrGroupVo">
//封装返回结果属性 groupName,colunmn为数据库查到的字段,也就是将column字段封装到SpuItemAttrGroupVo的GroupName中
<result property="groupName" column="attr_group_name"></result>
//SpuItemAttrGroupVo的attrs属性是List集合,需用collection标签包裹,ofType指定List集合的类型
<collection property="attrs" ofType="com.gl.glmall.product.vo.Attr">
//封装返回结果属性 attrName,colunmn为数据库查到的字段,也就是将column字段封装到attrs的attrName中
<result column="attr_name" property="attrName"></result>
<result column="attr_value" property="attrValue"></result>
</collection>
</resultMap>
查询语句指定resultMap返回结果
<select id="getAttrGroupWithAttrsBySpuId" resultMap="SpuItemAttrGroupVo">
SELECT pav.`spu_id`, ag.`attr_group_name`, ag.`attr_group_id`, aar.`attr_id`, attr.`attr_name`,pav.`attr_value`
FROM `pms_attr_group` ag
LEFT JOIN `pms_attr_attrgroup_relation` aar ON aar.`attr_group_id` = ag.`attr_group_id`
LEFT JOIN `pms_attr` attr ON attr.`attr_id` = aar.`attr_id`
LEFT JOIN `pms_product_attr_value` pav ON pav.`attr_id` = attr.`attr_id`
WHERE ag.catelog_id = #{catalogId} AND pav.`spu_id` = #{spuId}
</select>
@ConfigurationProperties(prefix = “”)
从prefix的配置文件寻找值
例
@ConfigurationProperties(prefix = "glmall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
application.properties
glmall.thread.core-size=20
glmall.thread.max-size=200
glmall.thread.keep-alive-time=10
springboot会从application.properties将对应的值赋给ThreadPoolConfigProperties
认证服务
路径(视图)映射 只能是GET请求
原写法
@GetMapping("/login.html")
public String login(){
return "login";
}
修改后的绑定配置文件的写法
public class glmallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
重定向和转发
转发
- 请求转发通过HttpServletRequest对象获取请求转发器实现
- 请求转发是服务器内部的行为,对客户端是屏蔽的
- 客户端只发送了一次请求,客户端地址栏不变
- 服务端只产生了一对请求和响应对象,这一对请求和响应对象会继续传递给下一个资源
- 因为全程只有一个HttpServletRequset对象,所以请求参数可以传递,请求域中的数据也可以传递
- 请求转发可以转发给其他Servlet动态资源,也可以转发给一些静态资源以实现页面跳转
- 请求转发可以转发给WEB-INF下受保护的资源
- 请求转发不能转发到本项目以外的外部资源
重定向
- 响应重定向通过HttpServletResponse对象的sendRedirect方法实现
- 响应重定向是服务端通过302响应码和路径,告诉客户端自己去找其他资源,是在服务端提示下的,客户端的行为
- 客户端至少发送了两次请求,客户端地址栏是要变化的
- 服务端产生了多对请求和响应对象,且请求和响应对象不会传递给下一个资源
- 因为全程产生了多个HttpServletRequset对象,所以请求参数不可以传递,请求域中的数据也不可以传递
- 重定向可以是其他Servlet动态资源,也可以是一些静态资源以实现页面跳转
- 重定向不可以到给WEB-INF下受保护的资源
- 重定向可以到本项目以外的外部资源
RedirectAttributes 重定向后携带数据
如果往model存数据,重定向后数据会丢失。
所以可以使用RedirectAttributes 重定向后携带数据
// addFlashAttribute 将数据放到session中,这个数据只取一次
redirectAttributes.addFlashAttribute("errors", errors);
BCryptPasswordEncoder 密码加密
因为MD5密码可以被破解,所以需要进行加盐处理(密码中加入随机串生成MD5码)。
BCryptPasswordEncoder则是简易的加盐算法,无需存入随机串值,每次生成的的加密串都不一样,但可以匹配到正确的密码
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//加密处理
String encode = passwordEncoder.encode("123456");
//匹配密码
boolean isMatch = passwordEncoder.matches("123456", encode);
社交登录 OAuth2.0
Gitee社交登录(免审核)
设置 - 数据管理 - 第三方应用
文档
https://gitee.com/api/v5/swagger#/deleteV5UserKeysId
Gitee登录流程
点击社交登录按钮 --》 跳转到对应平台的认证授权页面 --》 输入账户密码
跳转到所设置的回调地址,请求参数带有 code值
后台根据 code值 到对应平台获取 access_token (1天有效期)
有了access_token 即可获取用户在对应平台的信息
Session共享问题
session原理
保存cookie(下图中的jsessionid),服务器通过该cookie获取对应的session
存在的问题
-
不能跨不同域名共享session
-
存在多个微服务,多个微服务session不同步
相同域名下session同步问题
hash一致 将携带cookie的客户端固定到一台服务器中,从而只需要在那台服务器中存取session
统一存储到redis/DB
不同域名下session问题
思路:将不同域名服务的cookie作用域调为共享域名(auth.glmall.com search.glmall.com --> glmall.com)
解决: 自定义Cookie配置
@Configuration
public class glmallSessionConfig {
/**
* 设置cookie的名字,作用域等信息
* @return
*/
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("glmall.com");
cookieSerializer.setCookieName("glSESSION");
return cookieSerializer;
}
/**
* 将fastjson设为序列化器
* @return
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
SpringSession核心原理
包装request和response 再根据自定义逻辑(redis/db/mongodb。。。)获取session信息
单点登录
例: atguigu.com为认证中心, 旗下的gulixueyuan,gulimall,gulifunding其一只要在atguigu登录过,则登录其他网址无需再次登录
一种实现逻辑
-
客户端:client1,client2
-
认证中心:ssoserver
-
- 对于第一次登录,客户端登录时,先跳转到认证中心登录。认证中心登录后,会在浏览器认证中心域名下存入Cookie,该Cookie作为登录的凭证。
- 登录完成后,将登录对象存入redis,并且跳转回原页面。跳转地址上携带token,原页面的方法根据有无token判断是否登录。
- 对于第一次登录,客户端登录时,先跳转到认证中心登录。认证中心登录后,会在浏览器认证中心域名下存入Cookie,该Cookie作为登录的凭证。
-
- 对于其他客户端已经登录过的,因为登录过会在浏览器认证中心域名下存入Cookie,所以该客户端跳转到认证中心会先判断认证中心域名下有无对于cookie,有的话则携带token跳转回原页面,登录成功。
订单服务
Feign远程调用丢失请求头问题
在订单模块中,请求头有Cookie(GLSESSION)信息,该Cookie可从redis中获取用户登录信息。
confirmOrder()方法中需要Feign调用远程方法从购物车获取购物项,但Feign调用远程方法会丢失原有的请求头(Cookie),所以需要在新请求配置中手动加上这个Cookie,这样远程调用才能有Cookie数据,才能正确获取到购物车信息。
设置Feign拦截器,先获取请求(RequestContextHolder底层通过ThreadLocal获取),再获取请求的Cookie,再给Feign新请求设置Cookie
@Configuration
public class glFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate requestTemplate) {
//获取调用feign接口的请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
//获取请求中的Cookie
String cookie = request.getHeader("Cookie");
//新请求加上Cookie
requestTemplate.header("Cookie",cookie);
}
};
}
}
- Feign 远程调用设置拦截器源码
Feign异步丢失上下文问题
之前的订单模块,如果是同一线程获取地址,购物车信息,则没有问题。
但如果使用CompletableFuture异步编排的方式,不同线程的ThreadLocal不同,进入Feign拦截器的RequestContextHolder页不同,那么获取到的请求request也就不同,(RequestContextHolder底层通过ThreadLocal获取)就会有null空指针现象。
幂等性 本项目用令牌token机制(服务端与用户端根据token鉴定)
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因
为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结
果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结
果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口
的幂等性
类似幂等性情况
用户多次点击按钮,用户页面回退再次提交,微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制,其他业务情况
基于Token的方式保证幂等性
本项目使用token令牌的逻辑是: 用户下单在结算页生成一个token给用户,同时将这个token存入session(redis),
订单提交时,根据用户提交的token和redis中的token进行校验,一致则说明是第一次提交,此时将服务端的token删除
但可能同一时间多个线程同时进入,导致token失效, 所以要保证 对比token(用户提交和redis中) 和 删除redis中token 这两个操作的原子性, 也就是这两个操作同时删除
通过lua脚本实现原子性
//lua脚本内容
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
//redis执行lua脚本 获取返回值
Long result = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRsepVo.getId()), orderToken);
分布式事务
分布式事务存在的问题:
多个远程服务互相调用,某个服务出现问题,无法实现整体回滚,造成有的事务成功提交数据,有的事务回滚数据
造成分布式事务问题的部分原因:
- 远程服务成功了并且提交了数据,但由于网络故障(如openFeign的Read timeout)出现异常,导致有的事务成功提交数据,有的事务回滚数据
- 远程服务成功了并且提交了数据,但该服务出现和远程服务无关的问题(比如int i= 1/0),导致有的事务成功提交数据,有的事务回滚数据
类内方法互相调用 事务设置失效问题
==事务是通过代理对象来控制的。==同一个类的代理对象相同,如果方法A调用方法B,方法AB的事务都有各自的设置,因为在同一个类中,事务的设置会以该方法事务的设置为准,此时AB事务都会失效。
解决方法: 通过AopContext动态代理
//生成代理对象
Object proxy = (代理对象所处的类)AopContext.currentProxy();
//通过代理对象调用AB方法
proxy.A();
proxy.B();
分布式事务中的CAP
其中网络问题是不可避免的,所以必须支持分区容错性。
但一致性和可用性不能同时满足,比如AB两台机器,A将某个值改为8,但因为网络问题没有传给B,如果要保持一致性,那此时就不满足可用性,反之。
所以CAP不可能三者兼顾,只能出现CP or AP 两种情况。
可以通过不同的算法保持一致性,比如Raft算法。
BASE 最终一致
柔性事务-TCC 事务补偿型方案(Seata)
通过Seata的Undo_log日志回滚
柔性事务-最大努力通知型方(Mq)
通过给Mq的延时队列,定时检查消息
Seata控制分布式事务
Seata AT模式使用如下
创建undo_log 1.0.0seata版本
当出现异常,TC可以告诉RM,RM通过undo_log表回滚数据
CREATE TABLE IF NOT EXISTS `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
修改registry.conf 文件 配置nacos信息
type = "nacos"
nacos {
serverAddr = "localhost:8848"
namespace = "public"
cluster = "default"
}
配置seata包装原来的数据源
@Configuration
public class MySeataConfig {
@Autowired
DataSourceProperties dataSourceProperties;
@Bean
public DataSource dataSource(DataSourceProperties dataSourceProperties){
HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
if(StringUtils.hasText(dataSourceProperties.getName())){
dataSource.setPoolName(dataSourceProperties.getName());
}
return new DataSourceProxy(dataSource);
}
}
将file.conf registry.conf 引入对应的微服务ware,order
修改file.conf 中的vgroup_mapping.
vgroup_mapping.glmall-order-fescar-service-group = "default"
给分布式大事务入口标记@GlobalTransactional,远程调用的小事务标记@Transactional
本项目订单分布式事务
采用==柔性事务-最大努力通知型方(Mq)==方法
正常情况(上)
Mq订单和库存都使用延时队列方法,先将消息放入延时队列,消息到期再由死信交换机转发到release队列
订单解锁时间比库存解锁时间短,所以库存release队列收到消息后,订单release队列肯定收到了消息。也就是订单肯定先判断好了状态。订单数据库表中维护订单状态字段,库存表就根据这个状态字段来判断库存是否解锁。
也就体现了最终一致,可能消息不是实时更新,但最后会根据mq的消息实现最终一致
库存解锁判断逻辑
异常情况(下)
可能订单出现问题卡顿,库存以为订单状态是正常结果可能已经消费了商品,库存消息优先到期,但是这时候mq卡顿恢复,订单出现问题,但此时库存商品已被消费。
解决方法: 订单解锁逻辑最后再向库存发送消息,也就是订单release向库存release发送消息表明订单已经解锁,此时如果商品已消费则将商品状态更改
支付模块
内网穿透
他人电脑通过访问内网穿透服务器来获取我的电脑的信息
秒杀
cron 定时任务阻塞问题
- 使用异步编排的方法,将任务加入线程池
- 使用spring自带的异步方法,在类上加@EnableAsync, 在方法上加@Async,并在配置文件配置线程池信息
bug
p125 导入依赖问题
导入elasticsearch-rest-high-level-client依赖时
就算改了<elasticsearch.version>7.4.2</elasticsearch.version>,maven界面还是会显示
- org.elasticsearch.client:elasticsearch-rest-client: 6.4.3
- org.elasticsearch:elasticsearch: 6.4.3
解决方法: 分别额外引入依赖,并在elasticsearch-rest-high-level-client排除6.4.3旧依赖
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.4.2</version>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.4.2</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.4.2</version>
<exclusions>
<exclusion>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
</exclusion>
<exclusion>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
</exclusion>
</exclusions>
</dependency>
p138 静态资源js包下catalogLoader.js 中访问三级目录的路径
将nginx存放首页资源的 catalogLoader.js 中访问三级目录的路径 是 index/json/catalog.json
而indexcontroller获取三级目录的路径是 index/catalog.json
将catalogLoader.js 对应路径更改为 catalogLoader.js , 并用 ctrl + f5 清除浏览器页面缓存即可解决问题
p174 首页从三级分类无法跳转到搜索页
将nginx存放首页资源的 catalogLoader.js 的 gmall 改为 glmall
p178 mapping结构
{
"mappings": {
"properties": {
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
},
"brandId": {
"type": "long"
},
"brandImg": {
"type": "keyword"
},
"brandName": {
"type": "keyword"
},
"catalogId": {
"type": "long"
},
"catalogName": {
"type": "keyword"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"saleCount": {
"type": "long"
},
"skuId": {
"type": "long"
},
"skuImg": {
"type": "keyword"
},
"skuPrice": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"spuId": {
"type": "keyword"
}
}
}
}
商品详情页 jquery文件找不到 脚本绑定点击事件无法执行
以前同样的代码可以执行 某一天突然不能执行
以前的代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="stylesheet" type="text/css" href="/static/item/scss/shop.css"/>
<link rel="stylesheet" type="text/css" href="/static/item/scss/jd.css"/>
<link rel="stylesheet" href="/static/item/scss/header.css"/>
<link rel="stylesheet" type="text/css" href="/static/item/bootstrap/css/bootstrap.css"/>
</head>
<body></body>
<script src="/static/item/js/jquery1.9.js" type="text/javascript" charset="utf-8"></script>
<script src="/static/item/js/js.js" type="text/javascript" charset="utf-8"></script>
<!--<script type="text/javascript"></script>-->
<script>
$(".sku_attr_value").click(function () {
var skus = new Array();
// 1.获取所有加了checked的属性
// 1.1 点击的元素加上自定义属性
$(this).addClass("clicked");
var curr = $(this).attr("skus").split(",");
// 当前被点击的所有sku组合的数组放进去
skus.push(curr)
// 去掉同一行所有的checked
$(this).parent().parent().find(".sku_attr_value").removeClass("checked");
$("a[class='sku_attr_value checked']").each(function () {
skus.push($(this).attr("skus").split(","));
});
// 2.取出他们的交集 得到skuId 调用filter方法的一定是jQuery元素
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++) {
filterEle = $(filterEle).filter(skus[i]);
}
console.log(filterEle[0])
// 3.跳转
location.href = "http://item.glmall.com/" + filterEle[0] + ".html";
});
$(function () {
$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});
$("a[class='sku_attr_value checked']").parent().css({"border": "solid 1px red"});
})
$("#addToCartA").click(function () {
let num = $("#numInput").val();
// 获取当前按钮的自定义属性
let skuId = $(this).attr("skuId");
location.href = "http://cart.glmall.com/addToCart?skuId=" + skuId + "&num=" + num;
return false;
})
</script>
</html>
脚本内容
<script src="/static/item/js/jquery1.9.js" type="text/javascript" charset="utf-8"></script>
<script src="/static/item/js/js.js" type="text/javascript" charset="utf-8"></script>
<!--<script type="text/javascript"></script>-->
<script>
$(".sku_attr_value").click(function () {
var skus = new Array();
// 1.获取所有加了checked的属性
// 1.1 点击的元素加上自定义属性
$(this).addClass("clicked");
var curr = $(this).attr("skus").split(",");
// 当前被点击的所有sku组合的数组放进去
skus.push(curr)
// 去掉同一行所有的checked
$(this).parent().parent().find(".sku_attr_value").removeClass("checked");
$("a[class='sku_attr_value checked']").each(function () {
skus.push($(this).attr("skus").split(","));
});
// 2.取出他们的交集 得到skuId 调用filter方法的一定是jQuery元素
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++) {
filterEle = $(filterEle).filter(skus[i]);
}
console.log(filterEle[0])
// 3.跳转
location.href = "http://item.glmall.com/" + filterEle[0] + ".html";
});
$(function () {
$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});
$("a[class='sku_attr_value checked']").parent().css({"border": "solid 1px red"});
})
$("#addToCartA").click(function () {
let num = $("#numInput").val();
// 获取当前按钮的自定义属性
let skuId = $(this).attr("skuId");
location.href = "http://cart.glmall.com/addToCart?skuId=" + skuId + "&num=" + num;
return false;
})
</script>
原因:可能是由于页面结构或脚本依赖关系发生了变化。当页面结构或脚本依赖关系发生变化时,可能会影响到脚本的执行顺序或加载时机
解决:把脚本放到head标签中
因为将脚本放在<head>
标签中是为了确保在页面加载时先加载和执行脚本,以避免因为脚本未加载完成而导致的页面渲染问题
此时jquery可以找到了,但是脚本绑定的点击事件依然不能执行
原因:该脚本没有在DOM完全加载后执行
解决: 在DOM完全执行后执行对应的脚本内容
加入==$(document).ready(function() {}==; 可以确保脚本在DOM执行后执行
$(document).ready(function() {
alert("dom")
$(".sku_attr_value").click(function () {
var skus = new Array();
// 1.获取所有加了checked的属性
// 1.1 点击的元素加上自定义属性
$(this).addClass("clicked");
var curr = $(this).attr("skus").split(",");
// 当前被点击的所有sku组合的数组放进去
skus.push(curr)
// 去掉同一行所有的checked
$(this).parent().parent().find(".sku_attr_value").removeClass("checked");
$("a[class='sku_attr_value checked']").each(function () {
skus.push($(this).attr("skus").split(","));
});
// 2.取出他们的交集 得到skuId 调用filter方法的一定是jQuery元素
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++) {
filterEle = $(filterEle).filter(skus[i]);
}
console.log(filterEle[0])
// 3.跳转
location.href = "http://item.glmall.com/" + filterEle[0] + ".html";
});
$(function () {
$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});
$("a[class='sku_attr_value checked']").parent().css({"border": "solid 1px red"});
})
$("#addToCartA").click(function () {
let num = $("#numInput").val();
// 获取当前按钮的自定义属性
let skuId = $(this).attr("skuId");
location.href = "http://cart.glmall.com/addToCart?skuId=" + skuId + "&num=" + num;
return false;
})
$("#secKillA").click(function () {
var isLogin = [[${session.loginUser != null}]]
if(isLogin){
var killId = $(this).attr("sessionid") + "-" + $(this).attr("skuid");
var num = $("#numInput").val();
location.href = "http://seckill.glmall.com/kill?killId=" + killId + "&key=" + $(this).attr("code") + "&num=" + num;
}else{
layer.msg("请先登录!")
}
return false;
})
});
为什么以前能执行脚本现在不行了?
对于jquery文件以前能获取现在不能获取的问题来说
之前将脚本放在<body>标签后能够生效,而现在需要将脚本放在<head>标签中才能生效,可能是由于页面结构或脚本依赖关系发生了变化。当页面结构或脚本依赖关系发生变化时,可能会影响到脚本的执行顺序或加载时机,从而导致之前放在<body>标签后生效的脚本现在需要放在<head>标签中才能正确执行
对于脚本执行文件来说
之前将脚本放在body标签之后,可能因为DOM加载得足够快,脚本可以在没有问题的情况下执行。
但现在需要放在head标签内才能加载,脚本执行时DOM可能还没有准备好,导致事件绑定失败
修正后的代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="stylesheet" type="text/css" href="/static/item/scss/shop.css"/>
<!-- <link rel="stylesheet" type="text/css" href="/static/item/scss/jd.css"/>-->
<link rel="stylesheet" type="text/css" href="/static/item/scss/main.css"/>
<!-- <link rel="stylesheet" type="text/css" href="/static/item/scss/SHOUhou.css"/>-->
<link rel="stylesheet" href="/static/item/scss/header.css"/>
<link rel="stylesheet" type="text/css" href="/static/item/bootstrap/css/bootstrap.css"/>
<script src="/static/item/js/jquery1.9.js" type="text/javascript" charset="utf-8"></script>
<script src="/static/item/js/js.js" type="text/javascript" charset="utf-8"></script>
<script type="text/javascript">
$(document).ready(function() {
alert("dom")
$(".sku_attr_value").click(function () {
var skus = new Array();
// 1.获取所有加了checked的属性
// 1.1 点击的元素加上自定义属性
$(this).addClass("clicked");
var curr = $(this).attr("skus").split(",");
// 当前被点击的所有sku组合的数组放进去
skus.push(curr)
// 去掉同一行所有的checked
$(this).parent().parent().find(".sku_attr_value").removeClass("checked");
$("a[class='sku_attr_value checked']").each(function () {
skus.push($(this).attr("skus").split(","));
});
// 2.取出他们的交集 得到skuId 调用filter方法的一定是jQuery元素
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++) {
filterEle = $(filterEle).filter(skus[i]);
}
console.log(filterEle[0])
// 3.跳转
location.href = "http://item.glmall.com/" + filterEle[0] + ".html";
});
$(function () {
$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});
$("a[class='sku_attr_value checked']").parent().css({"border": "solid 1px red"});
})
$("#addToCartA").click(function () {
let num = $("#numInput").val();
// 获取当前按钮的自定义属性
let skuId = $(this).attr("skuId");
location.href = "http://cart.glmall.com/addToCart?skuId=" + skuId + "&num=" + num;
return false;
})
$("#secKillA").click(function () {
var isLogin = [[${session.loginUser != null}]]
if(isLogin){
var killId = $(this).attr("sessionid") + "-" + $(this).attr("skuid");
var num = $("#numInput").val();
location.href = "http://seckill.glmall.com/kill?killId=" + killId + "&key=" + $(this).attr("code") + "&num=" + num;
}else{
alert("请先登录")
}
return false;
})
});
</script>
</head>
<body></body>
</html>
sentinel版本与jdk版本不一致
本机jdk环境变量配的是jdk17,sentinel1.6.3支持jdk8
改环境变量