分佈式系統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);
}
}