分布式session
- 前言
- Session分布式问题的解决
- Session原理
- SpringSession 整合Redis
- 1.准备工作
- 2.解决Session序列化问题、作用域问题
- 3. Session的保存
- 拦截器获得用户信息
- 使用实例
- RequestContextHolder
- rpc丢失用户信息
- 线程异步丢失上下文问题
- 总结
前言
现在大多数登录技术都使用的是JWT技术,去Redis当中进行验证。
而谷粒商城项目依旧使用的是Session验证,这里记录一下Session登录及一些常见问题
Session分布式问题的解决
Session原理
session存储在服务端,jsessionId存在客户端。jsessionid就是用来判断当前用户对应于哪个session。
事实上 jsessionid ==request.getSession().getId()
但是这就产生了一个大问题!!
如果我有多个服务器,那这次请求发A服务器(订单服务),保存了Session,下次发B服务器(库存服务),还要保存一遍??
显然不合适,于是我们想到可以使用Redis去统一存储Session,不就只要存一次就好了?
SpringSession 整合Redis
1.准备工作
引入依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
修改配置
spring.session.store-type=redis #session存储类型
server.servlet.session.timeout=30m #过期事件
spring.redis.host=192.168.56.10 #redis地址
添加注解
@EnableRedisHttpSession //创建了一个springSessionRepositoryFilter ,负责将原生HttpSession 替换为Spring Session的实现
public class GulimallAuthServerApplication {
2.解决Session序列化问题、作用域问题
Session默认的是JDK序列化方式
每个 serializable 对象的类都被编码,编码内容包括类名和类签名、对象的字段值和数组值,以及从初始对象中引用的其他所有对象的闭包。
通过导入RedisSerializer修改为json序列化
@Bean // redis的json序列化
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
Session作用域
cookie对象的domain属性设置了cookie的作用域。domain本身以及domain的子域名可以访问到相关cookie。
在对cookie的domain进行设置时,不能讲domain指定为除当前域名或者其父域名之外的其他域名,即cookie无法跨域设置
一个有效的cookie的作用域为: domain本身以及domain下的所有子域名。
@Bean // cookie
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSIONID"); // cookie的键
serializer.setDomainName("gulimall.com"); // 扩大session作用域,也就是cookie的有效域
return serializer;
}
整体设置为一个组件
@Configuration
public class GulimallSessionConfig {
@Bean // redis的json序列化
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
@Bean // cookie
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("GULISESSIONID"); // cookie的键
serializer.setDomainName("gulimall.com"); // 扩大session作用域,也就是cookie的有效域
return serializer;
}
}
把这个配置放到每个微服务下
3. Session的保存
@GetMapping({"/login.html","/","/index","/index.html"}) // auth
public String loginPage(HttpSession session){
// 从会话从获取loginUser
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);// "loginUser";
System.out.println("attribute:"+attribute);
if(attribute == null){
return "login";
}
System.out.println("已登陆过,重定向到首页");
return "redirect:http://gulimall.com";
}
@PostMapping("/login") // auth
public String login(UserLoginVo userLoginVo,
RedirectAttributes redirectAttributes,
HttpSession session){
// 远程登录
R r = memberFeignService.login(userLoginVo);
if(r.getCode() == 0){
// 登录成功
MemberRespVo respVo = r.getData("data", new TypeReference<MemberRespVo>() {});
// 放入session // key为loginUser
session.setAttribute(AuthServerConstant.LOGIN_USER, respVo);//loginUser
log.info("\n欢迎 [" + respVo.getUsername() + "] 登录");
// 登录成功重定向到首页
return "redirect:http://gulimall.com";
}else {
HashMap<String, String> error = new HashMap<>();
// 获取错误信息
error.put("msg", r.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors", error);
return "redirect:http://auth.gulimall.com/login.html";
}
}
拦截器获得用户信息
订单系统为例,必须需要用户登录,我们可以使用拦截器去判断用户是否登陆了
先注入拦截器HandlerInterceptor组件
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
// 加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
// 这个请求直接放行
boolean match = new AntPathMatcher().match("/order/order/status/**", uri);
if(match){
return true;
}
// 获取session
HttpSession session = request.getSession();
// 获取登录用户
MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if(memberRespVo != null){
threadLocal.set(memberRespVo);
return true;
}else{
// 没登陆就去登录
session.setAttribute("msg", AuthServerConstant.NOT_LOGIN);
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
在config类中实现WebMvcConfigurer接口.addInterceptor()方法添加拦截器
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**"); //所有路径的请求
}
}
加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
//组件的拦截器中代码
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
loginUser.set(attribute);
//serviceImpl中的实现通过线程获取用户信息的代码
MemberRespVo memberRespVo = LoginUserIntercepter.loginUser.get(); //通过过滤器拿到用户Id
使用实例
@Autowired
MemberFeignService memberFeignService;
@Autowired
CartFeignService cartFeignService;
@Autowired
ThreadPoolExecutor executor; //异步线程池
@Autowired
WmsFeignService wmsFeignService;
@Autowired
StringRedisTemplate redisTemplate;
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserIntercepter.loginUser.get(); //通过过滤器拿到用户Id
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); // 拿到原请求的 RequestAttributes
// 第一个异步任务
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes); // 每一个请求都共享原请求的 RequestAttributes
//远程查询收货地址
List<MemberReceiveAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
//第二个异步任务
CompletableFuture<Void> getCartFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes); // 每一个请求都共享原请求的 RequestAttributes
//获得购物车中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setOrderItems(items);
}, executor).thenRunAsync(()->{
// 判断库存
List<OrderItemVo> items = confirmVo.getOrderItems();
List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
R hasStock = wmsFeignService.getSkusHasStock(collect);
List<SkuStockVo> data = (List<SkuStockVo>) hasStock.getData(new TypeReference<List<SkuStockVo>>() {
});
if (data!=null){
Map<Long, Boolean> map = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(map);
}
},executor);
//查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//其他数据自动计算
CompletableFuture.allOf(getAddressFuture,getCartFuture).get(); //等待异步任务全部完成!
//防重令牌
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberRespVo.getId(),token,30, TimeUnit.MINUTES);//前缀 , 令牌 ,
confirmVo.setOrderToken(token);
return confirmVo;
}
看的很懵对不对,这里有三个重要点
RequestContextHolder
RequestContextHolder顾名思义,持有上下文的Request容器。
我们先看使用场景(GuliFeignConfig(为Feign请求保持Request的配置类)当中)
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes!=null){ // 防止未登录状态下报空指针异常
HttpServletRequest request = attributes.getRequest(); // 拿到上文的请求
if (request != null){ //防止空指针异常
//同步请求数据 cookie
String cookie = request.getHeader("cookie");
//给新请求(调用Feign服务的请求一个cookie)
requestTemplate.header("Cookie",cookie);
}
}
- 正常来说在service层是没有request和response的,然而直接从controlller传过来的话解决方法太粗暴。解决方法是SpringMVC提供的
RequestContextHolder
- 用线程池执行任务时非主线程是没有请求数据的,可以通过该方法设置线程中的request数据,原理还是用的threadlocal(此时就要注意new thread就获取不到请求了)
在spring mvc中,为了随时都能取到当前请求的request对象,可以通过RequestContextHolder
的静态方法getRequestAttributes()
获取Request相关的变量,如request,response等
//两个方法在没有使用JSF的项目中是没有区别的
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
// RequestContextHolder.getRequestAttributes();
//从session里面获取对应的值
String str = (String) requestAttributes.getAttribute("name",RequestAttributes.SCOPE_SESSION);
// 拿到请求
HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
// 响应
HttpServletResponse response = ((ServletRequestAttributes)requestAttributes).getResponse();
rpc丢失用户信息
众所周知,
feign
远程调用的请求头中没有含有JSESSIONID
的cookie
,所以也就不能得到服务端的session
数据,也就没有用户数据
怎么解决呢?
在feign
的调用过程中,会使用容器中的RequestInterceptor
对RequestTemplate
进行处理,因此我们可以通过向容器中导入定制的RequestInterceptor
为请求加上cookie
。
public class GuliFeignConfig {
@Bean
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
//1. 使用RequestContextHolder拿到老请求的请求数据
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
HttpServletRequest request = requestAttributes.getRequest();
if (request != null) {
//2. 将老请求得到cookie信息放到feign请求上
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
}
};
}
}
//注意:上面在封装cookie的时候要拿到原来请求的`cookie`,设置到新的请求中
线程异步丢失上下文问题
在讨论第一个问题的时候,有说明: RequestContextHolder
为SpingMVC中共享request
数据的上下文,底层由ThreadLocal
实现,也就是说该请求只对当前访问线程有效
因此上面的实例引用了多线程,肯定无法共享cookie了
解决: 我们需要在开启异步时候就将老请求的RequestContextHolder
的数据设置进去
// 从主线程获取用户数据 放到局部变量中
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
// 把旧RequestAttributes放到新线程的RequestContextHolder中
RequestContextHolder.setRequestAttributes(attributes);
// 远程查询所有的收获地址列表
List<MemberAddressVo> address;
try {
address = memberFeignService.getAddress(MemberRespVo.getId());
总结
这些就是基本的使用session进行数据共享中知识点
苦厄难夺凌云志 不死终有出头日