分布式系统Session同步问题
在搭建完集群环境后,不得不考虑的一个问题就是用户访问产生的session如何处理。如果不做任何处理的话,用户将出现频繁登录的现象,比如集群中存在A、B两台服务器,用户在第一次访问网站时,Nginx通过其负载均衡机制将用户请求转发到A服务器,这时A服务器就会给用户创建一个Session(session用于保存用户信息)。当用户第二次发送请求时,Nginx将其负载均衡到B服务器,而这时候B服务器并不存在Session,所以就会将用户踢到登录页面。这将大大降低用户体验度,导致用户的流失,这种情况是项目绝不应该出现的。
实现步骤:
- 在用户登录的时候,通过UUidUtils工具类,生成随机UUid,以此作为token(令牌)
- 通过redis存储用户信息,通过hset命令创建map数据格式,map的名称(自定义常量类,用于代表session信息),map的key(uuid生成的token),map的value(登录用户user对象).设置有效期
- 创建Cookie,设置Cookie的key和value值,key(自定义常量,用于代表是用户信息),value(uuid生成的token)
- 设置cookie的有效期(同创建redis有效期),设置cookie的路径path("/")
- 延长redis中session有效期,在查询redis中session信息同时,重新把用户信息保存到redis中,并设置给cookie返回。
注: - 用户信息保存在redis中设置map数据名称的自定义常量,例:redisToken
- cookie设置key的自定义常量,例如:cookieToken
方便在搜索的时候快速查询同时区分其它redis和cookie数据.
随机生成的uuid作为保存用户信息的唯一标识(可以用AtomicInteger代替)保存在redis中持久化到硬盘数据,服务端通过获取用户的cookie值,就可以获取到保存于redis中的用户信息数据
扩展WebMvcConfigurer实现抽取获取session信息代码
WebMvcConfigurerAdapter是SpringMVC的自动配置适配器接口,内部有很多springmvc扩展方法当spring版本为5.x以上,或者springboot版本为2.x以上,WebMvcConfigurerAdapter被废弃了,使用WebMvcConfigurer替代
WebMvcConfigurerAdapter 是一个实现了WebMvcConfigurer 接口的抽象类,并提供了全部方法的空实现,我们可以在其子类中覆盖这些方法,以实现我们自己的配置,如视图解析器,拦截器和跨域支持等…,由于Java的版本更新,在Java 8中,可以使用default关键词为接口添加默认的方法,Spring在升级的过程中也同步支持了Java 8中这一新特性,所以WebMvcConfigurer 接口可以使用default关键词为接口添加默认的方法,从而摒弃了WebMvcConfigurerAdapter 。
WebMvcConfigurerAdapter 是抽象类实现WebMvcConfigurer 接口,通过继承WebMvcConfigurerAdapter 并重写需要的方法实现自定义WebMvc配置(典型的接口适配模式)
WebMvcConfigurerAdapter部分源码展示:
/**
* An implementation of {@link WebMvcConfigurer} with empty methods allowing
* subclasses to override only the methods they're interested in.
*
* @author Rossen Stoyanchev
* @since 3.1
* @deprecated as of 5.0 {@link WebMvcConfigurer} has default methods (made
* possible by a Java 8 baseline) and can be implemented directly without the
* need for this adapter
*/
@Deprecated
public abstract class WebMvcConfigurerAdapter implements WebMvcConfigurer {
/**
* {@inheritDoc}
* <p>This implementation is empty.
*/
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
}
/**
* {@inheritDoc}
* <p>This implementation is empty.
*/
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
}
...
}
在JDK1.8之后,可以使用default关键词为接口添加默认的方法,通过直接实现WebMvcCinfigurer接口,实现指定方法来自定义WebMvc配置
public interface WebMvcConfigurer {
default void configurePathMatch(PathMatchConfigurer configurer) {
}
/**
* Configure content negotiation options.
*/
default void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
}
/**
* Configure asynchronous request handling options.
*/
default void configureAsyncSupport(AsyncSupportConfigurer configurer) {
}
...
}
根据spring/springboot版本编写一个配置类继承/实现WebMvcConfigurationAdaper/WebMvcConfigurer,重写addArgumentResolvers(自定义参数解析器)方法,需要添加自定义的ArgumentResolver
以下WebMvcConfigurerAdapter 比较常用的重写接口
/** 解决跨域问题 **/
public void addCorsMappings(CorsRegistry registry) ;
/** 添加拦截器 **/
void addInterceptors(InterceptorRegistry registry);
/** 这里配置视图解析器 **/
void configureViewResolvers(ViewResolverRegistry registry);
/** 配置内容裁决的一些选项 **/
void configureContentNegotiation(ContentNegotiationConfigurer configurer);
/** 视图跳转控制器 **/
void addViewControllers(ViewControllerRegistry registry);
/** 静态资源处理 **/
void addResourceHandlers(ResourceHandlerRegistry registry);
/** 默认静态资源处理器 **/
void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer);
自定义类实现WebMvcConfigurer接口扩展,重写所需要自定义的方法
package com.supplier.config;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
//声明这是一个配置类
@Configuration
public class MyConfiguration implements WebMvcConfigurer {
// EL读取配置文件中指定值
@Value("${file.staticAccessPath}")
private String staticAccessPath;
@Value("${file.uploadFolder}")
private String uploadFolder;
/**
* addResourceHandlers 静态资源处理 处理图片资源映射
* 将访问的图片资源路径由staticAccessPath替换成uploadFolder
**/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler(staticAccessPath).addResourceLocations("file:" + uploadFolder);
}
/**
* UserArgumentResolver实现HandlerMethodArgumentResolver-自定义参数解析器
*
* @return 声明一个自定义bean
*/
@Bean
public UserArgumentResolver argumentResolver() {
UserArgumentResolver userArgumentResolver = new UserArgumentResolver();
return userArgumentResolver;
}
/**
* addArgumentResolvers 添加自定义参数解析器
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolver) {
argumentResolver.add(argumentResolver()); // 添加自定义的参数解析器
}
}
自定义参数解析器对象实现HandlerMethodArgumentResolver接口
重写
- supportsParameter:用于判断是否需要处理该参数,返回true为需要,并会去调用下面的方法resolveArgument,其中MethodParameter方法参数对象
通过它可以获取该方法参数上的一些信息 - resolvveArgument方法:真正用于处理参数解析的方法
package com.supplier.config;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import com.supplier.entity.Police;
import com.supplier.utils.CookieUtil;
import com.supplier.utils.RedisUtil;
/**
* HandlerMethodArgumentResolver 自定义参数解析器
*
* @author 张江丰
*
*/
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
private CookieUtil cookieUtil;
@Autowired
private RedisUtil redisUtil;
/**
* 用于判断是否需要处理参数,返回true为需要,并会去调用下面的方法resolveArgument
* 这里直接返回true,无论如何都会执行参数解析方法resolveArgument
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
System.out.println(parameterType == Police.class ? true : false);
return true;
}
/**
* **resolvveArgument方法:真正用于处理参数解析的方法**
*/
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// 转换成我们需要的request和response
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpServletResponse response = (HttpServletResponse) webRequest.getNativeResponse();
//通过cookie工具类获取指定名称的cookie
Cookie cookie = CookieUtil.get(request, CookieUtil.token);
// cookie不为空,通过cookie的value获取到redis不为空
if (cookie != null && redisUtil.hget(RedisUtil.getUser, cookie.getValue()) != null) {
//redis中保存有用户对象,从而实现分布式session,处理逻辑...
// request.getSession().setAttribute("user",
// redisUtil.hget(RedisUtil.getUser, cookie.getValue()));
return null;
} else {
//未获取到,redis中没有用户,处理逻辑...
return null;
}
}
}
逻辑注释:
- supportsParament方法的参数MethodParament对象,获取所有请求参数对象,判断是否有指定参数对象返回true,如果是登录方法在resolveArgument放行,如果是其它方法在resolveArgument拦截
- resolveArgument方法参数,获取request和response对象,获取请求参数cookie数据,通过cookie保存的的token,查询redis数据得到登录用户user
问题:
是否可以用AOP切面,在访问controller之前判断redis中用户状态?
使用WebMvcConfigurer扩展接口HandlerMethodArgumentResolver 自定义参数解析器,可以在supportsParament方法中通过MethodParament对象更加细化的判断请求参数对象,根据业务逻辑需求定制化处理用户信息状态,而使用AOP切面来实现则无法控制.
用户登录Controller方法(集成shiro权限控制)
@RequestMapping("/tologin")
@ResponseBody
public CommonResponse login(HttpServletRequest request, String tokens, HttpServletResponse response,
Map<String, Object> paramMap) {
Manager man = null;
Police po = null;
String username = null;
String password = null;
String flags = null;
// 直接从redis中获取用户
Cookie cookie = CookieUtil.get(request, CookieUtil.token);
if (cookie != null) {
Object obj = redisUtil.hget(RedisUtil.getUser, cookie.getValue());
System.out.println("redis中保存的对象,从而实现分布式共享" + obj);
}
username = request.getParameter("userName");
password = request.getParameter("password");
flags = request.getParameter("flag");
boolean rememberMe = request.getParameter("rememberMe") != null;
UsernamePasswordToken token = new UsernamePasswordToken(username, password, flags);
// RememberMe这个参数设置为true后,在登陆的时候就会在客户端设置remenberme的相应cookie。
// 下次访问带上这个cookie,访问链接为user链接器的,就不需要进行登录验证,直接进入权限验证。
if (rememberMe) {
token.setRememberMe(true);
}
Subject subject = SecurityUtils.getSubject();
String error = null;
try {
subject.login(token);
// 这里的catch到的异常会被继承的父类BaseController处理
} catch (UnknownAccountException | IncorrectCredentialsException e) {
error = "用户名或密码错误";
} catch (ExcessiveAttemptsException e) {
error = "登录错误次数超过五次,请十分钟后登录!";
} catch (AuthenticationException e) {
error = "其它错误:" + e.getMessage();
}
logger.error("错误信息:" + error);
// 获取shiro保存的用户
if (flags.equals("0")) {
man = (Manager) subject.getPrincipal();
} else {
po = (Police) subject.getPrincipal();
}
if (error != null) {
paramMap.put("error", error);
return CommonResponseUtil.success(paramMap);
} else {
if (flags.equals("0")) {
paramMap.put("manager", man);
paramMap.put("flag", flags);
// 获取唯一uuid
UUID randomUUID = UUID.randomUUID();
System.out.println("当前登录用户的uuid:" + randomUUID.toString() + ",redis的key");
//第一次登录--设置登录用户的cookie,保存用户信息到redis
CookieUtil.set(response, CookieUtil.token, randomUUID.toString(), 36000);
redisUtil.hset(redisUtil.getUser, randomUUID.toString(), man);
} else {
paramMap.put("po", po);
paramMap.put("flag", flags);
UUID randomUUID = UUID.randomUUID();
System.out.println("当前登录用户的uuid:" + randomUUID.toString() + ",redis的key");
//第一次登录--设置登录用户的cookie,保存用户信息到redis
CookieUtil.set(response, CookieUtil.token, randomUUID.toString(), 36000);
redisUtil.hset(redisUtil.getUser, randomUUID.toString(), po);
}
return CommonResponseUtil.success(paramMap);
}
}