2021-09-30 商城分布式高级篇技术总结
一、Nginx1、Nginx进行反向代理需求:利用Nginx对网关进行反向代理,网关负载均衡到微服务1、 首先,编写windows的hosts文件,模拟域名映射2、修改Nginx配置文件docker中的nginx对于配置文件中的server块进行了分离存储,进入当前路径,修改服务名和路由如上,对于server_name 可以根据域名监听proxy_set_header Host $host作用:在
目录
3)、实现 Callable 接口 + FutureTask (可以拿到返回结果,可以处理异常)
2、利用重定向接口收到的code码得到access_token
6、自定义SpringSession完成子域session共享
一、Nginx
1、Nginx进行反向代理
需求:利用Nginx对网关进行反向代理,网关负载均衡到微服务
1、 首先,编写windows的hosts文件,模拟域名映射
2、修改Nginx配置文件
docker中的nginx对于配置文件中的server块进行了分离存储,进入当前路径,修改服务名和路由如上,对于
server_name 可以根据域名监听
proxy_set_header Host $host
作用:在Nginx将请求发送给网关时,网关将会根据host方式进行请求路由,而nginx会丢弃Host请求头,因此,需要手动配置让Nginx携带Host请求头
请求young.com,请求头如下:
proxy_pass http://gulimail
作用:路由到上游服务器gulimail
上游服务配置如下(配置位置在server块外):
服务代理的地址是本机的88端口,即网关所在的ip和端口
流程分析:
当访问本机的80端口,即配置的young.com域名,即是访问监听80端口的nginx,nginx会将请求反向代理到本机的88端口(携带Host请求头的内容),即gateway监听的端口,网关集群时可以配置多个server,网关会按照请求头内容的方式以负载均衡的形式将请求分发给指定的微服务。
网关如下配置:
- id: gulimail-web-route
uri: lb://gulimail-product
predicates:
- Host=**.young.com,young.com
配置文件分块:
测试:
2、nginx动静分离
需求:请求的静态资源交给nginx返回,动态资源由后台返回
首先,将静态资源放到nginx的html文件夹下的static文件夹中
此时还无法访问
需要修改nginx配置文件
docker镜像中nginx配置文件在conf.d文件夹下,修改gulimail.conf如下
增加一个路由,请求路径带有static的路由到根目录如上的路径下
然后修改idea中文件的请求路径:
将原先所有静态资源的请求加上/static前缀
测试:
二、Redis处理分布式缓存
1、本地缓存:
传统单服务器,用map做本地缓存
在分布式情况下,每访问一次不同服务器,如果被访问服务器没有本地缓存,需要查询数据库,并且,当其中一台服务器数据修改以后,其他有缓存的服务器不会自动更新数据,会导致数据不一致的问题。
2、分布式缓存
分布式缓存可以选用redis做中间件,将多个微服务的缓存集中处理,一台服务器修改,总的缓存也会修改,其他服务器拿到的缓存就是最新的缓存,并且Redis可以集群以实现高可用。
3、使用Redis缓存数据
首先引入redis启动器
<!--redis做缓存中间件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
SpringBoot集成了两种操作redis的客户端:
lettuce与jedis ,默认使用lettuce
同时,SpringBoot封装了了两种实例用于操作redis
StringRedisTemplate与RedisTemplate
这里使用StringRedisTemplate,原因如下:
因为我们的数据封装为了map,最终要以json的形式传递,而json是一个跨平台跨语言的格式,如php也可以识别json。
具体实现:
先将数据库中查数据封装为普通方法
加入缓存的查询方法接口如下:
利用StringRedisTemplate进行对Redis的操作
@Override
public Map<String, List<Catalog2Vo>> getCatalogJson() {
//给缓存中放json字符串,拿出json字符串,还需你转成可用的对象类型【序列化与反序列化】
//1、加入缓存
String catalogJson = redisTemplate.opsForValue().get("catalogJson");
if (StringUtils.isEmpty(catalogJson)){
//1、如果缓存为空,从数据库中获取
Map<String, List<Catalog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
//2、存入redis之前先转为Json
String jsonString = JSON.toJSONString(catalogJsonFromDb);
//3、存入redis
redisTemplate.opsForValue().set("catalogJson",jsonString);
}
//转为指定类型对象
Map<String, List<Catalog2Vo>> finalResult = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catalog2Vo>>>() {
});
return finalResult;
}
注意:
在将json转换为指定类型时,如果使用alibaba提供的fastJson,需要在转换方法parseObject()的参数中,传入要转换的json字符串,还要新建一个TypeReference类,利用泛型指定要反序列化出的对象类型。
测试:
刷新页面后,在RDM中可以查到缓存已经存在
额外问题:
在使用低版本lettuce作为客户端时,如5.2.x,会产生如下异常
不管内存指定为多少,总会出现该异常,无非时间问题,lettuce至少在如下版本是没有异常的
解决方法:
*指定SpringBoot使用jedis作为客户端
*升级lettuc
4、分布式缓存应用问题
缓存击穿、缓存穿透、缓存雪崩,详细见2021/07/30 Redis配置及使用-02_Young的博客-CSDN博客
简要描述:
1、缓存穿透
解决示例:
空值设0,并且设置短暂的过期时间
2、缓存雪崩
解决示例:
设置方法类似上面的缓存穿透
3、缓存击穿(★)
5、用本地锁解决缓存击穿:
并发测试时线程都会涌入上面的方法中,将上面查询方法改为同步代码块方式:
新增红框内逻辑,因为线程进入同步代码块之前查询到的缓存都为空,但是第一次查询之后,查询的线程会将结果存入缓存,如果不加判空,后面的线程仍然认为自身需要查询一遍。
测试:
如图只查询了一次数据库
注意:
单机模式可以生效,但是在分布式中使用本地锁,会造成如下现象
多台服务器都加本地锁,还是会同时放出服务器数量个数的线程同时竞争资源,每个微服务仍会进行数据库的查询,仍然会导致并发问题,因此对于分布式需要使用分布式锁。
三、Redis分布式锁解决缓存问题
1、简单实现分布式锁
仍然对于查询菜单功能实现分布式锁
加锁方法:
//占分布式锁,去redis占坑,设置存活时间为30秒
String uuid = UUID.randomUUID().toString();
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
加锁过程一定是原子性,要么都成功要么都失败,用uuid作为锁的value用于标识唯一的一把锁,设置过期时间防止加锁时出现意外,如宕机,导致锁无法释放的问题,过期时间应该适当,可以避免线程A执行时间长于锁的有效时间,导致线程B提前进入,最终删锁时本该删除A的锁,最后却把B的锁也删了。
执行过程:
if (lock != null && lock) {
System.out.println("获取分布式锁成功...");
//加锁成功
Map<String, List<Catalog2Vo>> dataFromDb;
try {
dataFromDb = getDataFromDb();
} finally {
//查询后解锁
// redisTemplate.delete("lock");
//为了防止删除时出现死锁,用uuid标识锁
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));
System.out.println("分布式锁已经释放...");
}
return dataFromDb;
}
如果拿到了锁,查询数据库,并且查询过程用try-finally处理,如果发生意外,最终都会执行释放锁的lua脚本,该脚本来自于reids官方提供。
//执行删除锁脚本
redisTemplate.execute(new DefaultRedisScript<Long>(script,Long.class), Arrays.asList("lock", uuid));execute方法中的参数为
第一个:
RedisScript接口的实现类为DefaultRedisScript,该实现类的构造器参数为,第一个是string类型的脚本,第二个为脚本执行后的返回值类型,即redis中删除key的返回值类型long
第二个:
对应脚本占位元素KEYS[1]与ARGV[1] ,执行删除的key与value相同才会执行,即删的锁要与加的锁是同一把锁才会执行删除
未获取锁的情况:
else {
//加锁不成功,重试
System.out.println("获取分布式锁失败,正在重试...");
//需要进行睡眠,否则调用过快会导致栈溢出异常
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatalogJsonFromDbWithRedisLock();
}
如果线程获取不到锁,那么睡眠1s后会继续尝试获取锁,类似于一个自旋锁。
注意:
如果不执行sleep,会导致栈溢出异常。
2、Redisson介绍
操作redis的java客户端
先引入依赖:
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.3</version>
</dependency>
可以使用读取yml方式和程序化配置,选择程序化配置
@Configuration
public class MyRedissonConfig {
/**
* 所有操作redis的操作都通过RedissonClient实例实现
* @return RedissonClient实例
* @throws IOException io异常
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
Config config = new Config();
//单节点模式,rediss为启用安全连接
config.useSingleServer().setAddress("redis://192.168.131.11:6379");
return Redisson.create(config);
}
}
1、可重入锁(Reentrant Lock)
@ResponseBody
@GetMapping("/hello")
public String hello(){
//1、获取一把锁,只要锁名一样,就是同一把锁
RLock lock = redisson.getLock("myLock");
//2、加锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "--->加锁成功,执行业务...");
Thread.sleep(30000);
} catch (Exception e){
e.printStackTrace();
} finally {
//3、解锁
lock.unlock();
System.out.println(Thread.currentThread().getName()+"--->释放锁...");
}
return "hello";
}
lock. lock();//阻塞式等待。默认加的锁有效时间是30s时间。
1)、锁的自动续期,如果业务超长,Redisson内部提供的一个看门狗会在运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉。当然,如果业务宕机,看门狗续期失效,过期时间的限制依然会将锁释放出来。
2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。
关于可重入锁的看门狗机制:
上锁时,无参构造器会触发看门狗,而有参构造器(手动设置过期时间)不会触发看门狗。
分析看门狗:
Redisson提供锁的接口RLock继承了JUC中的Lock类,因此,juc中的方法在Redisson的锁中同样适用,如果使用默认构造器,赋值如下
过期时间leaseTime为-1 ,而指定过期时间如下:
最终不论是否指定过期时间,都会进入如下方法
如果过期时间为-1,即未指定过期时间,进入else,它为我们指定了一个时间internalLockLeaseTime,这个变量的值如下 :
进入getLockWatchdogTime()方法,它默认设置了过期时间为30s
继续执行
设置过期时间后进入if中的toMillis方法,即设置internalLockLeaseTime时间为指定时间
未设置过期时间进入scheduleExpirationRenewal(threadId);方法,它会调用一个重置方法
此方法中会创建一个定时器任务
定时器任务设置看门狗重置检查频率为10秒一次,分析完成。
总结:
如果设置过期时间,不会触发看门狗,可以将时间调大避免任务时间过长导致锁提前删除,
而未设置过期事件,初始化过期时间为30s,看门狗会10s检查一次并将时间重置为30s。
2、读写锁(ReadWriteLock)
代码:
/**
* 读锁
* @return 读到的结果
*/
@ResponseBody
@GetMapping("/read")
public String read(){
RReadWriteLock lock = redisson.getReadWriteLock("wrLock");
//获取读锁
RLock rLock = lock.readLock();
String writeValue = "";
//加读锁
rLock.lock();
try {
writeValue = (String) redisTemplate.opsForValue().get("writeValue");
}catch (Exception e){
e.printStackTrace();
} finally {
//释放读锁
rLock.unlock();
}
return "读出-->"+writeValue;
}
/**
* 写锁
* @return 写入的值
*/
@ResponseBody
@GetMapping("/write")
public String write(){
RReadWriteLock lock = redisson.getReadWriteLock("wrLock");
//获取写锁
RLock wLock = lock.writeLock();
//上锁
wLock.lock();
String value = null;
try {
Thread.sleep(10000);
value = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set("writeValue",value);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//解锁
wLock.unlock();
}
return "写入-->"+value;
}
效果:当写操作进行时,读操作等待,等写操作执行完,读操作可以执行。
此锁也使用看门狗机制
其保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁)。读锁是一个共享锁,写锁没释放读就必须等待
读+写:有读锁。写也需要等待。/只要有写的存在,都必须等待
写+写:阻塞方式
读+读:相当于无锁,并发读,只会在redis中记录好,所有当前的读锁。他们都会同时加锁成功
写+读:等待写锁释放
3、信号量(Semaphore)
@GetMapping("park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore lock = redisson.getSemaphore("count");
//请求型号量
lock.acquire();
return "parking";
}
@GetMapping("move")
@ResponseBody
public String move(){
RSemaphore lock = redisson.getSemaphore("count");
//释放型号量
lock.release();
return "moving";
}
利用停车模拟信号量机制,在redis设置初始键count数量为3,每次进行park请求都会减1,当count数量为0,再请求park,那么将会阻塞,会一直等待,只有当调用move方法,使信号量值为1时,park才可以继续消耗信号量
也可以尝试请求,区别是尝试情况下如果信号量为0,那么直接返回false而不会阻塞
4、闭锁(CountDownLatch)
/**
* 闭锁
* @return 字符串
* @throws InterruptedException s
*/
@GetMapping("closeDoor")
@ResponseBody
public String closeDoor() throws InterruptedException {
//获取闭锁
RCountDownLatch lock = redisson.getCountDownLatch("closeDoor");
//设置量为3
lock.trySetCount(3);
//等待
lock.await();
return "放假了...";
}
@GetMapping("/leave/{classId}")
@ResponseBody
public String leave(@PathVariable Integer classId){
//获取闭锁
RCountDownLatch lock = redisson.getCountDownLatch("closeDoor");
//计数减一
lock.countDown();
return classId+"班走了...";
}
模拟一个学生放假的情况,要求三个班学生都走了才能放假,如果计数没有清0,不会执行放假了...方法,而是等待在await()
3、缓存的双写一致性
1、双写模式
问题原因:
这个问题的原因就是第一次修改后A线程向缓存写入数据,而同时第二次修改紧随其后提交,并且第二次修改的服务速度快于第一次,最终第一次修改结果覆盖了第二次修改结果。
2、失效模式
从上到下三个线程为1、2、3号
1号线程更新数据库,更新完删除,然后二号线程又来更新数据库,在更新的同时,三号线程进入要访问缓存,此时缓存中还没有数据,于是三号线程访问数据库,恰好二号线程写入操作仍未执行完,三号线程就执行完毕,如果三号线程更新缓存操作在二号线程删除缓存之前,那么还可以接受,如果更新缓存在删除缓存之后,又导致三号线程读出了老数据。
缓存一致性问题解决方案:
四、SpringCache解决分布式缓存
缓存抽象
1、 先引入依赖
<!--redis做缓存中间件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring-cache配合redis处理缓存-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
2、 开启缓存,并且配置缓存类型为redis
@EnableCaching注解放在启动类上
配置reids类型缓存如下:
spring.cache.type=redis
同样,缓存过期时间也可以在配置文件中设置
spring.cache.redis.time-to-live=60000单位ms
3、在需要进行缓存的类上加注解
1、常用注解
Spring提供的注解如下
对于缓存声明,Spring 的缓存抽象提供了一组 JavaComments:
@Cacheable
:触发缓存填充。
@CacheEvict
:触发缓存逐出。
@CachePut
:在不影响方法执行的情况下更新缓存。
@Caching
:重新组合要在一个方法上应用的多个缓存操作。
@CacheConfig
:在类级别共享一些与缓存有关的常见设置。
1、@Cacheable
参数:value,key
value为缓存名称,key为缓存密钥
使用@Cacheable
注解可以指定如何通过其key
属性生成密钥。可以使用SpEL来选择
示例:
如下对方法的结果启用缓存,名称为catagory
//抽取前台首页查找二级三级分类的方法
@Cacheable(value = {"catagory"},key = "'getParentCid'")
public List<CategoryEntity> getParentCid(List<CategoryEntity> selectList, Long parentCid) {
// return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid() == parentCid
).collect(Collectors.toList());
System.out.println("查询....");
return collect;
}
显示的字符为jdk序列化后的字符
2、@CacheEvict
/**
* 级联更新所有关联数据
* @CacheEvict 清除缓存
* @param category 分类对象
*/
@CacheEvict(value = {"catagory"},key = "'getLevelOne'")
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
relationService.updateCascade(category.getCatId(), category.getName());
}
在后台系统更新商品分类菜单方法上加入逐出缓存注解,指定key和value定位到@Cacheable相同名称缓存,打开后台系统测试编辑菜单,编辑完成后会清除指定key-value的缓存。
3、@Caching
组合多个操作,演示删除,现在要在修改后台商品菜单时令前台的一级菜单和所有菜单的两个缓存失效,但是又无法标注两个@CacheEvict注解,因此可以用@Caching注解进行组合
进入该注解可以看到它支持三种组合,缓存,写入,失效
@Caching(evict = {
@CacheEvict(value = {"category"}, key = "'getLevelOne'"),
@CacheEvict(value = {"category"},key = "'getCatalogJson'")
})
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
relationService.updateCascade(category.getCatId(), category.getName());
}
如上我们对于级联更新菜单方法配置失效同一分区下两个key的策略。注意不要开启指定前缀,尽量用默认前缀--》即分区名作为前缀,便于我们的管理,也不会导致误删
总结:
默认行为:
- 如果缓存中有,不调用查询方法
- key默认自动生成,名称为SimpleKey[]
- 缓存的value值默认使用jdk序列化机制,将序列化后的数据存储在redis中
- 默认ttl时间-1
自定义
- 使用spEL形式指定key的值
- 配置文件中可以修改缓存存活时间
- 将数据保存为JSON格式
2、自定义缓存配置
上面可以看到,默认的jdk序列化处理缓存的value和key会导致乱码,需要自己配置解决乱码
原理:
配置如下
package com.young.gulimail.product.config;
import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@EnableCaching
@Configuration
//开启属性配置绑定功能
@EnableConfigurationProperties(CacheProperties.class)
public class MyCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
//得到所有redis的配置
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
//改变key-value的序列化机制
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
//令redis配置生效
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;
}
}
如果不进行上面的if判断,我们在配置文件中设置的缓存过期时间和前缀等参数会失效,因为spring使用的是我们自己的配置文件,
测试如下:
3、SpringCache的不足
总结:
常规数据(读多写少,即时性一致性要求不高的数据),完全可以使用SpringCache
特殊数据特殊设计
原理:
CacheManager(RedisCacheManager)-》Cache(RedisCache)-》Cache负责缓存读写
五、异步&线程池
1、初始化线程的 4 种方式
1)、继承 Thread
public static class Thread01 extends Thread{
@Override
public void run() {
System.out.println("线程 "+Thread.currentThread().getName() + " 开始启动..");
for (int i = 0; i < 3; i++) {
System.out.println("线程 "+Thread.currentThread().getName() + "运行中.." + i);
}
System.out.println("线程 "+Thread.currentThread().getName() + " 启动结束..");
}
}
直接调用start()方法即可启动
2)、实现 Runnable 接口
public static class Thread02 implements Runnable{
@Override
public void run() {
System.out.println("线程 "+Thread.currentThread().getName() + " 开始启动..");
for (int i = 0; i < 3; i++) {
System.out.println("线程 "+Thread.currentThread().getName() + "运行中.." + i);
}
System.out.println("线程 "+Thread.currentThread().getName() + " 启动结束..");
}
}
main方法:
// 2.实现Runnable接口
Thread02 thread02 = new Thread02();
new Thread(thread02).start();
3)、实现 Callable 接口 + FutureTask (可以拿到返回结果,可以处理异常)
public static class Thread03 implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("线程 " + Thread.currentThread().getName() + " 开始启动..");
int i = 10 / 2;
Thread.sleep(5000);
System.out.println("线程 " + Thread.currentThread().getName() + " 启动结束..");
return i;
}
}
main方法:
// 3.实现Callable接口
FutureTask<Integer> task = new FutureTask<>(new Thread03());
new Thread(task).start();
System.out.println(task.get());
get()方法是一个阻塞等待方法,结果是得到线程执行的返回值。
应该将所有的多线程异步任务交给线程池执行
4)、线程池★
// 4.线程池
public static ExecutorService service = Executors.newFixedThreadPool(10);
主方法:
// 4.线程池
service.execute(new Thread02());
service.submit(new Thread02());
execute直接执行,而submit有返回值
参数解析:
常见的 4 种线程池
newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若 无可回收,则新建线程。
newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。 newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。
newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务 按照指定顺序(FIFO, LIFO, 优先级)执行。
开发中为什么使用线程池
降低资源的消耗
通过重复利用已经创建好的线程降低线程的创建和销毁带来的损耗
提高响应速度
因为线程池中的线程数没有超过线程池的最大上限时,有的线程处于等待分配任务 的状态,当任务来时无需创建新的线程就能执行
提高线程的可管理性
线程池会根据当前系统特点对池内的线程进行优化处理,减少创建和销毁线程带来 的系统开销。无限的创建和销毁线程不仅消耗系统资源,还降低系统的稳定性,使 用线程池进行统一分配
2、CompletableFuture 异步编排
1、创建异步对象
bleFuture 提供了四个静态方法来创建一个异步操作。
1、runXxxx 都是没有返回结果的,supplyXxx 都是可以获取返回结果的
2、可以传入自定义的线程池,否则就用默认的线程池;
3、带有Async的方法会再开一个线程进行执行,而不共用当前执行的线程
System.out.println("main.....start.....");
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
}, executor);
System.out.println("main.....end.....");
System.out.println("main.....start.....");
CompletableFuture<Integer> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor);
Integer res = futureRes.get();
System.out.println("main.....end....."+res);
关于supplyAsync
这是一个函数式接口,调用返回值的get返回可以得到返回值
2、计算完成时回调方法
whenComplete()
System.out.println("main.....start.....");
CompletableFuture<Integer> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).whenComplete((res,exception) -> {
System.out.println("异步任务完成...结果是" + res + "异常是" + exception);
});
Integer res = futureRes.get();
System.out.println("main.....end....."+res);
也可以不抛出异常,而是收集到异常后返回默认值
exceptionally()
System.out.println("main.....start.....");
CompletableFuture<Integer> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).whenComplete((res,exception) -> {
System.out.println("异步任务完成...结果是" + res + "异常是" + exception);
}).exceptionally(throwable -> {
//可以感知异常,并返回默认值
return 10;
});
Integer res = futureRes.get();
System.out.println("main.....end....."+res);
3、 handle(),方法执行完成后的处理
System.out.println("main.....start.....");
CompletableFuture<Integer> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 0;
System.out.println("运行结果:" + i);
return i;
}, executor).handle((res,thr) -> {
if (res != null){
return res*2;
}
if (thr != null){
return 0;
}
return -1;
});
Integer res = futureRes.get();
System.out.println("main.....end....."+res);
4、线程串行化方法
thenApply 方法:
当一个线程依赖另一个线程时,获取上一个任务返回的结果,并返回当前 任务的返回值。
System.out.println("main.....start.....");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).thenApplyAsync((res) -> {
System.out.println("任务2启动了" + res);
// R apply(T t);
return res * 2;
}, executor);
Integer result = future.get();
System.out.println("main.....end....."+result);
thenAccept 方法:
消费处理结果。接收任务的处理结果,并消费处理,无返回结果。
System.out.println("main.....start.....");
CompletableFuture<Void> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).thenAcceptAsync((res) -> {
System.out.println("任务2启动了"+res);
},executor);
System.out.println("main.....end.....");
thenRun 方法:
只要上面的任务执行完成,就开始执行 thenRun,只是处理完任务后,执行 thenRun 的后续操作 带有 Async 默认是异步执行的。同之前。 以上都要前置任务成功完成。
Function T:上一个任务返回结果的类型
U:当前任务的返回值类型
public static void main(String[] args) {
System.out.println("main.....start.....");
CompletableFuture<Void> futureRes = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).thenRunAsync(() -> {
System.out.println("任务2启动了");
},executor);
System.out.println("main.....end.....");
}
总结:
* 线程串行化
* 1.thenRunAsync:不能获取上一个任务的执行结果,无返回值
* thenRunAsync(() -> {
* System.out.println("线程2启动中...");
* },poolExecutor);
* 2.thenAcceptAsync:可以获取上一个任务的执行结果,无返回值
* 3.thenApplyAsync:可以获取上一个任务的执行结果,并且有返回值
5、两任务组合 - 都要完成
结果没有返回值
/** * 两任务组合 - 都要完成 */ CompletableFuture<Integer> future01 = CompletableFuture.supplyAsync(() -> { System.out.println("任务1开始..."); int res = 10 / 2; System.out.println("任务1结束..."); return res; },executor); CompletableFuture<Integer> future02 = CompletableFuture.supplyAsync(() -> { System.out.println("任务2开始..."); int res = 10 / 2; System.out.println("任务2结束..."); return res; },executor); future01.runAfterBoth(future02,() -> { System.out.println("任务3开始..."); });
当任务一出错,结果如下, 任务三不会开始
该函数可以接收并处理两任务组合的两个结果
/** * 两任务组合 - 都要完成 */ CompletableFuture<Integer> future01 = CompletableFuture.supplyAsync(() -> { System.out.println("任务1开始..."); int res = 10 / 2; System.out.println("任务1结束..."); return res; },executor); CompletableFuture<Integer> future02 = CompletableFuture.supplyAsync(() -> { System.out.println("任务2开始..."); int res = 10 / 2; System.out.println("任务2结束..."); return res; },executor); future01.thenAcceptBothAsync(future02,(res1,res2)->{ System.out.println("任务1和任务2的结果是"+res1+"and"+res2); },executor);
/** * 两任务组合 - 都要完成 */ CompletableFuture<Integer> future01 = CompletableFuture.supplyAsync(() -> { System.out.println("任务1开始..."); int res = 10 / 2; System.out.println("任务1结束..."); return res; },executor); CompletableFuture<Integer> future02 = CompletableFuture.supplyAsync(() -> { System.out.println("任务2开始..."); int res = 10 / 2; System.out.println("任务2结束..."); return res; },executor); CompletableFuture<Integer> future = future01.thenCombineAsync(future02, (res1, res2) -> res1 + res2, executor); System.out.println(future.get());
该方法调用后有返回值
6、两任务组合 - 一个完成
接收参数,并且执行任务后有返回值,参数为一个任务,以及一个返回值,返回值必须相同类型,返回值为调用该方法的任务的返回值
接收参数,执行完成没有返回值,参数为一个任务,以及一个返回值,返回值必须相同类型,返回值为调用该方法的任务的返回值
直接执行
总结:
当两个任务中,任意一个 future 任务完成的时候,执行任务。
applyToEither:
两个任务有一个执行完成,获取它的返回值,处理任务并有新的返回值。
acceptEither:
两个任务有一个执行完成,获取它的返回值,处理任务,没有新的返回值。
runAfterEither:
两个任务有一个执行完成,不需要获取 future 的结果,处理任务,也没有返 回值。
7、多任务组合
allOf:
等待所有任务完成
/**
* 多任务组合
*/
CompletableFuture<String> futureImg = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的图片信息");
return "hello.jpg";
}, executor);
CompletableFuture<String> futureAttr = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("查询商品的属性信息");
return "魅影+256G";
}, executor);
CompletableFuture<String> futureDesc = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的介绍信息");
return "魅族";
}, executor);
CompletableFuture<Void> allOf = CompletableFuture.allOf(futureImg, futureAttr, futureDesc);
// System.out.println("---" + futureImg.get() + "---" + futureAttr.get() + "---" + futureDesc.get());
allOf.get();//等待所有任务完成
System.out.println("start.....end....");
如果不使用allOf()结果如下
使用之后:
当然,在代码中调用get方法一样可以达到这种效果,但是我们不可能总是调用全部get方法
anyOf:
只要有一个任务完成
/**
* 多任务组合
*/
CompletableFuture<String> futureImg = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的图片信息");
return "hello.jpg";
}, executor);
CompletableFuture<String> futureAttr = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("查询商品的属性信息");
return "魅影+256G";
}, executor);
CompletableFuture<String> futureDesc = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的介绍信息");
return "魅族";
}, executor);
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(futureImg, futureAttr, futureDesc);
anyOf.get();
System.out.println("start.....end...."+anyOf.get());
只要有一个任务成功,即可返回它的值,利用get()方法得到
六、注册用户存储密码利用MD5盐值加密
MD5
Message Digest algorithm 5,信息摘要算法
• 压缩性:任意长度的数据,算出的MD5值长度都是固定的。
• 容易计算:从原数据计算出MD5值很容易。
• 抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。
• 强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。
• 不可逆
阿帕奇提供的DigestUtils工具类可以进行MD5的加密
@Test
void MD5Test() {
String hanan = DigestUtils.md5Hex("hanan");
System.out.println(hanan);
}
但是MD5不能直接进行密码的存储,因为对于简单的密码,可以利用彩虹表进行暴力破解,因此需要盐值加密
加盐
• 通过生成随机数与MD5生成字符串进行组合
• 数据库同时存储MD5值与salt值。验证正确性时使用salt进行MD5即可
方式一:
利用该方法(阿帕奇),可以指定一个8位盐值,注意,前缀后面的盐值必须是8位
它的默认前缀是$1$
验证密码流程:
需要在数据库中创建一个字段存储所用的盐值,在验证用户密码时,需要对密码重新进行加密,然后与数据库中的值比较,因为相同盐值加密得到的结果是相同的
方式二(推荐):
利用Spring的BCryptPasswordEncoder 进行盐值加密
@Test
void MD5Test3() {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String pwd = encoder.encode("123456");
System.out.println(pwd);
}
结果如下,可以看出,该类自带盐值 ,并且每次生成的密文都不相同
验证密码流程:
调用matchs()方法,传入两个参数:密码原文与任意一个密文,则可得到一个布尔类型的值,验证是否为正确密码
boolean flag = encoder.matches("123456", "$2a$10$cXgLDfRBwpJq9cNu.ddEX.y1znF.pOhCsefhXSjCDaqaRyGqQgJq2"); System.out.println(flag);
正确
七、OAuth2
OAuth:
OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储 在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们 数据的所有内容。 OAuth2.0:
对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分 享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向 用户征求授权。
官方版流程:
描述
分析
1、使用gitee做第三方OAuth2认证
Gitee的OAuth2认证流程图:
2、OAuth2 获取 AccessToken 认证步骤
1、发送请求获得code码
登录时点击gitee跳转到A步骤指定的链接
参数:
client_id:gitee提供
redirect_uri:我们自己需要完成的接口,用于用户授权后返回的code码处理
代码如下
@GetMapping("/socialLogin")
public String socialLogin(@RequestParam(required = false) String code) throws Exception {
String url = "https://gitee.com/oauth/authorize?client_id=" + clientId
+ "&redirect_uri=" + redirectUri + "&response_type=" + responseType;
//登录成功跳回首页
return "redirect:" + url;
}
2、利用重定向接口收到的code码得到access_token
@GetMapping("/oauth2Gitee/socialLogin")
public String oauth2Login(@RequestParam("code") String code) throws Exception {
if (!StringUtils.isEmpty(code)) {
//成功返回code,说明授权成功
String baseUrl = "https://gitee.com/oauth/token?grant_type=authorization_code&code="
+ code
+ "&client_id=" + clientId
+ "&redirect_uri=" + redirectUri
+ "&client_secret=" + clientSecret;
//发送post请求获取access_token
String res = HttpClientUtils.post(baseUrl, null, null, null, null, null);
System.out.println("发送post请求获取access_token-->" + res);
//将返回的json字符串转换为map类型
Map<String, String> map = JSON.parseObject(res, Map.class);
//取出access_token
String accessToken = map.get("access_token");
System.out.println("accessToken---->" + accessToken);
//远程调用通过token获取用户信息的服务
R result = memberFeignService.getUserInfoByAccToken(accessToken);
String data = result.getData(new TypeReference<String>() {
});
//得到的data是一个json字符串
Map<String, String> userInfo = JSON.parseObject(data, Map.class);
System.out.println("用户信息----->" + userInfo);
OAuthUserVo oAuthUserVo = new OAuthUserVo();
String email = userInfo.get("email");
String name = userInfo.get("name");
if (!StringUtils.isEmpty(email)) {
oAuthUserVo.setEmail(email);
}
oAuthUserVo.setUserName(name);
//对社交登录用户进行注册/登录
//TODO 对此,应该判断用户是否已经存在与数据库,但是gitee只能返回用户名和邮箱,无法返回手机号
memberFeignService.oAuthregist(oAuthUserVo);
return "redirect:http://young.com";
} else {
//授权失败,重定向回登录页
return "redirect:http://auth.young.com/login.html";
}
}
//远程调用通过token获取用户信息的服务 R result = memberFeignService.getUserInfoByAccToken(accessToken);通过access_token请求gitee发送用户信息
/** * 处理gitee社交登录 * 通过accessToken获取用户信息 * @return */ @GetMapping("/getUserInfoByAccToken") public R getUserInfoByAccToken(@RequestParam("accessToken") String accessToken) throws Exception { String baseUrl = "https://gitee.com/api/v5/user?" + "access_token=" + accessToken; String userInfo = HttpClientUtils.get(baseUrl); //查询到了用户信息 return R.ok().setData(userInfo); }
//对社交登录用户进行注册/登录(对已经存在的用户,进行登录操作,对不存在的进行注册) memberFeignService.oAuthregist(oAuthUserVo);public R registMemberOAuth(MemberRegistVo vo) { MemberEntity memberEntity = new MemberEntity(); String email = vo.getEmail(); String userName = vo.getUserName(); QueryWrapper<MemberEntity> wrapper = new QueryWrapper<>(); if (!StringUtils.isEmpty(email)){ //如果用户名不为空 int countName = this.count(wrapper.eq("email", email)); if (countName> 0){ //用户已经存在,直接进行登录 return R.ok(); } memberEntity.setEmail(email); } memberEntity.setUsername(userName); this.baseMapper.insert(memberEntity); return R.ok(); }
八、分布式Session不共享不同步问题
我们执行一次使用gitee登录的流程,并在重定向方法中将登录用户的信息放入session
回显
但是首页并没有显示我们的回显信息,首页的session也并没有我们的session,反而在登录页中发现了我们的session
1、session共享问题
单一服务器原理
问题描述:
由于处理社交登录的是auth.young.com服务,而首页是young.com服务,session在不同的服务中无法共享,同样,在相同服务中,session也有不同步的问题
2、同服务不同服务器Session共享问题解决
1、session复制
修改tomcat配置,每台服务器都会互相传输自己的session供其他服务存储,但是分布式情况下该方法
1、占用网络带宽
2、服务器多的情况下会占用大量内存,不利于水平扩展
3、有网络延迟问题
因此分布式一般不采用此方案
2、客户端存储
3、hash一致性
该方法使用较多
4、统一存储
本项目采用后台统一存储的方式
九、SpringSession整合
1、引入依赖
在auth服务引入SpringSession的依赖
<!-- SpringSession --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>2、配置properties配置文件
配置文件中配置session存储的数据库类型为Redis,过期时间为60分钟
#session的存储方式 spring.session.store-type=redis #session的过期时间(如果未指定持续时间后缀,则使用秒。) server.servlet.session.timeout=60m3、@EnableRedisHttpSession
使用@EnableRedisHttpSession注解标记在auth服务启动类,开启SpringSession使用Redis存储session功能,同时在product服务中也标记该注解并配置properties文件4、实现
在使用到的注册用户实体类上也要实现序列化接口,否则session序列化进redis中时会发生序列化错误。
themlyf取出session中的数据:
执行gitee登录操作,然后手动修改auth服务的session 作用域domain
首页成功显示了用户名
5、问题
//TODO 默认发的令牌,作用域不能跨子域,需要解决 //TODO 使用JSON的序列化方式将对象数据存储到redis中
6、自定义SpringSession完成子域session共享
1、 修改作用域
在使用@EnableRedisHttpSession时,暴露cookie序列化器,创建bean来配置
@Configuration public class GulimailSessionConfig { @Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); //设置cookie名 serializer.setCookieName("GULISESSION"); //设置作用域名 serializer.setDomainName(".young.com"); return serializer; } }
2、修改Redis序列化格式
您可以通过创建一个名为springSessionDefaultRedisSerializer的bean来定制序列化,该bean实现了RedisSerializer。
@Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { //使用fastJson的序列化器 return new GenericFastJsonRedisSerializer(); }
在同一配置文件中使用FastJson的Redis序列化器
详情参考SpringSession配置Redis序列化器源码
3、总结
测试成功,我们自定义的session的domain域与session名称生效
并且Redis中存储的session不是jdk的序列化,而是fastJson序列化后的json字符串
7、SpringSession原理
基于2.3.1版本的SpringSession
1、@EnableRedisHttpSession
1.1、导入RedisHttpSessionConfiguration配置类
给容器中添加了一个组件RedisIndexedSessionRepository,其中封装了一个RedisTemplate,相当于用Redis操作session的增删改查
同时,导入的这个RedisHttpSessionConfiguration配置类还继承了SpringHttpSessionConfiguration,默认会为我们初始化一个cookie序列化器
1.2、SpringHttpSessionConfiguration也创建了一个session存储过滤器
该过滤器内部就相当于一个http的Filter
因为他继承的类,最终实现了Filter接口
核心:
SessionRepositoryFilter类中有个doFilterInternal方法,封装了原生的request和response请求
sessionRepository对session进行操作
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //添加一个sessionRepository对session进行操作 request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository); //封装原生的请求和响应对象 SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response); SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response); try { //包装后的对象应用到整个执行链 filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { wrappedRequest.commitSession(); } }
在OAuth2Controller类中,我们利用HttpSession进行获取session对用户信息存储
而它的原理是
因此,当调用getSession方法,即调用封装过的wrappedRequest 的getSession方法
该类(SessionRepositoryFilter)中重写了getSession()方法
如果session为空,调用getRequestedSession()方法,最终仍然从sessionRepository中获取
总结
十、单点登录
概念图
描述:
在分布式系统中,类似于auth.young.com/young.com这种字符域名,可以用SpringSession将session存储到数据库、rediis等中间件中,但是当域名不是子父域名时,显然我们不能将session作用域设置为.com,因此需要考虑单点登录,一处登录,处处登录
1、项目演示
1、测试
gitee搜索xxl,拉取sso单点登录项目框架进行测试
1、修改xxl-sso->xxl-sso-server中的application.properties文件中的redis地址
修改xxl-sso->xxl-sso-samples->xxl-sso-web-sample-springboot配置文件中redis地址
2、在hosts文件中配置本地域名映射
然后在xxl-sso文件中开启命令行执行maven打包命令
mvn clean package -Dmaven.skip.test=true
3、启动项目
java -jar [jar包名]
启动xxl-sso-server和xxl-sso-web-sample-springboot文件夹下target文件夹中的jar包,对于
xxl-sso-web-sample-springboot文件夹下的jar包,启动命令后加上--server.port=[端口号]
指定两个端口8081,8082启动
4、测试
登录client1,输入:client1.com:8080/xxl-sso-web-sample-springboot ,登录后如下
然后刷新server的页面
此时server也自动登入
当然,某一方注销,对应其他两个服务也一起注销
2、总结
2、测试单点登录
1、 创建三个服务:
gulimail-sso-test-server 登录处理服务 端口号8080
gulimail-sso-test-client 请求受保护服务的客户端1 端口号8081
gulimail-sso-test-client2 请求受保护服务的客户端1 端口号8082
下面是受保护的资源/employees,首次登录并没有token和session中的数据,因此会重定向到ssoServerUrl请求,并且要携带该受保护请求的请求地址,以方便登陆成功后返回该页面
@GetMapping(value = "/employees")
public String employees(Model model, HttpSession session, @RequestParam(value = "token", required = false) String token) {
if (!StringUtils.isEmpty(token)){
//token存在,说明用户已经登录,放入session
//TODO 去ssoserver获取token用户信息
session.setAttribute("loginUser",token);
}
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null) {
//没登录,重定向到服务端的登录页
//另外,我们还需要携带该请求地址,为了登录页登录后跳转回来
//http://ssoserver.com:8080/login.html
return "redirect:" + ssoServerUrl + "?redirect_url=http://client1.com:8081/employees";
} else {
//登录了
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps", emps);
return "employees";
}
}
登录页代码:
@GetMapping("/login.html")
public String loginPage(@RequestParam(value = "redirect_url") String url,
Model model,
@CookieValue(value = "sso_token", required = false) String sso_token) {
if (!StringUtils.isEmpty(sso_token)) {
//不为空说明之前有人登录
//该url为受保护请求地址
return "redirect:" + url + "?token=" + sso_token;
}
model.addAttribute("url", url);
return "login";
}
首次登录也没有sso_token,因此将参数url放入request域
login.html页面:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<form action="/doLogin" method="post">
用户名:<input type="text" name="username" /><br />
密码:<input type="password" name="password" /><br />
<input type="hidden" name="redirect_url" th:value="${url}" />
<input type="submit" value="登录">
</form>
</body>
</html>
提交表单请求doLogin方法,如下
@PostMapping(value = "/doLogin")
public String doLogin(@RequestParam(value = "username") String username,
@RequestParam(value = "password") String password,
@RequestParam(value = "redirect_url") String url,
HttpServletResponse response) {
//登录成功跳转,跳回到登录页
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
//生成唯一的一个令牌做标记
String uuid = UUID.randomUUID().toString().replace("-", "");
//将用户信息存入redis,方便我们识别到底是哪个用户登录
redisTemplate.opsForValue().set(uuid, username);
//ssoserver重定向之前,将这个令牌放入cookie携带给
//需要请求的受保护资源,让其知道已经登录
Cookie sso_token = new Cookie("sso_token", uuid);
response.addCookie(sso_token);
return "redirect:" + url+ "?token=" + uuid ;
}
return "login";
}
假设登陆成功,请求重定向时会携带我们设置的cookie
doLogin方法做两件事,如果提交用户名密码通过,则携带着隐藏域中的重定向url与生成的sso_token作为令牌,放入cookie,重定向到受保护资源,并且重定向过程中会携带标记已经登录的sso_token,同时,sso_token的值,也会存入redis作为键,用户信息作为值。
重定向回受保护资源时,我们的token已经不为空,那么会拿着token,访问ssoserver的userinfo请求,从redis中查出用户的信息。
@ResponseBody
@GetMapping("/userinfo")
public String userinfo(@RequestParam(value = "token") String token) {
String s = redisTemplate.opsForValue().get(token);
return s;
}
如下为我们输入的用户名 ,键为uuid,即我们设置的token
而真正实现不同域名单点登录的方法如下:
当client1登录后,生成了sso_token,所以client2登录时首先访问受保护资源重定向到登录页,然后取到client1的sso_token,
又携带着sso_token回到受保护页面,从而查出用户信息,进入已经登录状态,关键在于sso_token与token值相同。
@GetMapping("/login.html")
public String loginPage(@RequestParam(value = "redirect_url") String url,
Model model,
@CookieValue(value = "sso_token", required = false) String sso_token) {
if (!StringUtils.isEmpty(sso_token)) {
//不为空说明之前有人登录
//该url为受保护请求地址
return "redirect:" + url + "?token=" + sso_token;
}
model.addAttribute("url", url);
return "login";
}
更多推荐
所有评论(0)