在介绍Session共享之前,我们先来了解一下session-cookie机制,该机制出现的根源,主要是因为http连接是无状态的连接,浏览器的每一次请求,服务器会独立处理,不与之前或之后的请求产生关联,即同一浏览器向服务端发送多次请求,服务器无法识别,哪些请求是同一个浏览器发出的。
所以这就意味着,任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器请求;要限制浏览器请求,必须鉴别浏览器请求,响应合法请求,忽略非法请求;要鉴别浏览器请求,必须清楚浏览器请求状态。既然http协议无状态,那就需要服务器和浏览器共同维护一个状态,其实就是我们常说的会话机制。
浏览器第一次请求服务器,服务器创建一个会话,并将会话的id作为响应的一部分发送给浏览器,浏览器存储会话id,并在后续第二次和第三次请求中带上会话id,服务器取得请求中的会话id就知道是不是同一个用户了,那么服务器在内存中保存会话对象,浏览器怎么保存会话id呢?你可能会想到两种方式,如下:
当然上述两种方法其实都是不安全的,因为浏览器标识在网络上的传输,是明文的,可通过一些抓包工具获取其参数,如果想要提高其安全性,可改为https协议。
这里我们主要来看看Cookie机制的方式,Cookie是浏览器用来存储少量数据的一种机制,数据以”key/value“形式存储,浏览器发送http请求时自动附带cookie信息,其主要过程如下:
response.addCookie(Cookie cookie)
向Response Header中设置Cookie。Tomcat会话机制当然也实现了cookie机制,访问Tomcat服务器时,浏览器中可以看到一个名为“JSESSIONID”的cookie,这就是Tomcat会话机制维护的会话id,使用了cookie的请求响应具体过程如下:
上述通过Cookie与Session的交互,服务器就可以判断出来自同一浏览器的请求,但是如果在分布式环境下就会存在问题了,如果将系统分割为多个子系统,独立部署后,不可避免的会遇到会话管理的问题。
当单系统发展成多系统组成的应用群,面对如此众多的系统,用户难道要一个一个登录、然后一个一个注销吗?无论系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问系统的整个应用群与访问单个系统一样,登录/注销只要一次就够了。
那么这里我们怎么解决呢?这里我们可以通过Redis来解决,我们在第一次 request.getSession()
时,去Redis内拉取值,Response返回数据时,推送session到Redis中,这样我们就可以实现在分布式下,各个子系统的单点登陆了。
但是我们知道其 request.getSession()
方法我们是没法修改的,但是我们可以来继承它,来实现自己的业务逻辑,如下:
这里我们主要来看下项目的结构,这里分为三个模块,session_support
模块以及session_a
、session_b
模块,其中session_a和session_b模块都继承于session_support模块,session_support模块主要提供了一些公共的方法,用于对session的处理,如下:
首先User类中,就是一个简单的用户Model,用于前端登录请求的传值,其中backUrl字段用于保存原请求路径(如未登录状态下访问首页,被强制跳转至登录页面,用户登录后需要跳转至用户原来想要访问的界面)
public class User implements Serializable{
private static final long serialVersionUID = -3266830420902121735L;
private String username;
private String password;
private String backUrl;
//省略相关Getter、Setter方法
}
其中 MySession 类就是继承了 HttpSession 实现的自己的 Session 类,继承了该接口需要实现很多方法,我们这里就主要实现了下面两个方法,其余的暂不实现,如下:
public class MySession implements HttpSession, Serializable {
private static final long serialVersionUID = 243457112279446716L;
private String id;
private Map<String, Object> attrs;
public void setId(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
public Map<String, Object> getAttrs() {
return attrs;
}
public void setAttrs(Map<String, Object> attrs) {
this.attrs = attrs;
}
@Override
public Object getAttribute(String s) {
return this.attrs.get(s);
}
@Override
public void setAttribute(String s, Object o) {
this.attrs.put(s, o);
}
//省略其余继承接口需要实现的方法
//...
}
上述就实现了我们自己的 MySession 类,其中的id就是后续我们使用UUID生成的sessionId,而attrs里面会存储用户登录成功的用户信息,其是个Map结构,其中的key我们事先会定义好的,如user。(当然也可以存储其它你想要的的信息,这里我们存储用户信息,是以便其他系统可以从这获取当前登录用户的信息)
有了我们自己的MySession类,这样我们就可以每次有请求访问时,我们就通过Filter将其拦截,如果含有sessionId,我们会去Redis中查询,然后组成一个MySession返回,我们通过判断其中的attrs中 key=user 是否有值即可,该 key=user 值只有当用户登录成功后,我们才会将其设值,并存入Redis中。
(下图部分代码是用户登录验证代码,后续会介绍,这里未做验证处理,直接就算登录成功了,其中CookieUtil.SESSION_USER_INFO就是一个常量字符串user)
然后我们就看上述提到的 MyRequestWrapper 类,其实是我们继承了 HttpServletRequestWrapper 来的,并且上述介绍的部分逻辑代码,也在该类中实现,如判断是否登录,获取MySession等,具体如下:
/**
* Request包装类
*/
public class MyRequestWrapper extends HttpServletRequestWrapper {
private volatile boolean committed = false;
private MySession session;
private RedisTemplate redisTemplate;
public MyRequestWrapper(HttpServletRequest request, RedisTemplate redisTemplate) {
super(request);
this.redisTemplate = redisTemplate;
}
//是否已登陆
public boolean isLogin() {
Object user = getSession().getAttribute(CookieUtil.SESSION_USER_INFO);
return null != user;
}
//取session
public MySession getSession() {
if (null != session) {
return session;
}
return this.createSession();
}
//创建新session
private MySession createSession() {
String sessionId = CookieUtil.getRequestedSessionId(this);
Map<String, Object> attr;
if (null != sessionId) {
attr = redisTemplate.opsForHash().entries(sessionId);
} else {
sessionId = UUID.randomUUID().toString();
attr = new HashMap<>();
}
//session成员变量持有
session = new MySession();
session.setId(sessionId);
session.setAttrs(attr);
return session;
}
//提交session内值到Redis
public void commitSession() {
if (committed) {
return;
}
committed = true;
MySession session = this.getSession();
if (session != null && null != session.getAttrs()) {
redisTemplate.opsForHash().putAll(session.getId(), session.getAttrs());
}
}
}
我们在访问服务器时,服务器会拦截请求,去获得一个MySession,若无MySession会去创建一个,通过Cookie中是否携带了sessionId,无携带直接UUID生成一个,其余参数为空Map返回,若携带了sessionId,则通过该值去Redis中查询组成MySesion。
上述其实主要有两种情况:
至于为什么只要存在系统已登录,Cookie中就会携带sessionId,这里后续会在登录成功后,种Cookie的时候介绍到。(至于上述类中最有一个commitSession方法,提交session内值到Redis,这里其实会在过滤器中介绍的,在过滤器链最后就会进行提交)
然后我们来看看上述中提到的从Cookie获取请求携带的sessionId,如下第一个方法就是我们从Cookie中获取sessionId的方法,另一个方法是我们用来种Cookie的方法,是我们在用户登录成功后进行种Cookie
public class CookieUtil {
public static final String SESSION_NAME = "mysession";
public static final String SESSION_USER_INFO = "user";
public static String getRequestedSessionId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (!SESSION_NAME.equalsIgnoreCase(cookie.getName())) {
continue;
}
return cookie.getValue();
}
return null;
}
public static void onNewSession(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession();
String sessionId = session.getId();
Cookie cookie = new Cookie(SESSION_NAME, sessionId);
cookie.setHttpOnly(true);
cookie.setPath(request.getContextPath() + "/");
cookie.setDomain("bxs.com");
cookie.setMaxAge(7 * 24 * 60 * 60);
response.addCookie(cookie);
}
}
至于为什么未登录系统中请求时,Cookie中携带sessionId,因为我们在种Cookie的时候设置了.setDomain
,这样Cookie就可以实现跨域共享了,但是需要注意,这里必须位于上述设置的同一个顶级域名下才可以实现Cookie共享,后续会详细介绍。
最后我们提一下在该项目中所需要用的的依赖,主要如下:
<dependency>
<groupId>javax.servletgroupId>
<artifactId>javax.servlet-apiartifactId>
<version>3.1.0version>
<scope>providedscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
上述我们理清楚了共同模块session_support,然后我们再来看看session_a模块,该模块继承于上述模块,模拟了一个子系统,如下:
其中,我们集成了 thymeleaf 模板语言,这里其实主要用引入依赖,然后再 resources/templates
路径下放置展示的页面文件,其中共有两个页面,一个是用于登录的页面login.html,另一个则是登录后的页面 index,其中我们就简单显示了用户的用户名
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>session_atitle>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
head>
<body>
<div text-align="center">
<h1>请登陆h1>
<form action="#" th:action="@{/toLogin}" th:object="${user}" method="post">
<p>用户名: <input type="text" th:field="*{userName}" />p>
<p>密 码: <input type="text" th:field="*{passWord}" />p>
<input type="text" th:field="*{backUrl}" hidden="hidden" />
<p><input type="submit" value="submit"/>p>
form>
div>
body>
html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>session_atitle>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
head>
<body>
<div>
<h1>你好,<span th:text="${user.userName}"/>h1>
div>
body>
html>
然后我们可以看下如果我们直接访问 /index 页面,然后我们后台会设置了Filter过滤器,会将其拦截,判断是否已登录,若未登录则重定向至登录页面,如下:
public class SessionFilter implements Filter {
private RedisTemplate redisTemplate;
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//包装request对象
MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request, redisTemplate);
//如果已登陆,再访问登录页面,则转向跳转至首页
String requestUrl = request.getServletPath();
if("/login".equals(requestUrl) && myRequestWrapper.isLogin()){
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.sendRedirect("http://a.bxs.com:8080/index");
return;
}
//如果未登陆,则拒绝请求,转向登陆页面,并记录原请求路径backUrl
if (!"/login".equals(requestUrl) && !"/toLogin".equals(requestUrl) && !myRequestWrapper.isLogin()) {
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.sendRedirect("http://a.bxs.com:8080/login?backUrl="+request.getRequestURL().toString());
return ;
}
try {
filterChain.doFilter(myRequestWrapper, servletResponse);
} finally {
//提交session到Redis
myRequestWrapper.commitSession();
}
}
@Override
public void destroy() {
}
}
其中重定向登录页面 a.bxs.com:8080 网址,是我们在本机 host 文件中修改的,用于测试如下:
然后我们是如何将其Filter加入项目中的呢,这里是我们在Configuration配置类中,手动将使用@Bean注解注入的,如下:
@Configuration
public class SessionConfig {
@Bean
public SessionFilter sessionFilter(RedisTemplate redisTemplate){
SessionFilter sessionFilter = new SessionFilter();
sessionFilter.setRedisTemplate(redisTemplate);
return sessionFilter;
}
@Bean
public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(sessionFilter);
registration.addUrlPatterns("/*");
registration.setName("sessionFilter");
registration.setOrder(1); //值越小,Filter越靠前。
return registration;
}
}
这里主要注意的是,之前我们在web.xml
中配置 Filter 的时候,是有先后顺序的,在配置时需要注意先配什么后配什么,这里我们如果想要指定Filter的执行顺序,可以使用上述的 .setOrder()
方法。
至于上述Filter中判断用户是否登录,这点我们在之前也就详细介绍过了,这里我们直接来看其被拦截后,请求的Controller,在未登录的情况下,会将其拦截至 login()
方法中,让其去登录,会展示 login.html
,如下:
@Controller
public class IndexController {
@GetMapping("/login")
public String toLogin(Model model, MyRequestWrapper request) {
User user = new User();
user.setUserName("");
user.setPassWord("");
user.setBackUrl(request.getParameter("backUrl"));
model.addAttribute("user", user);
return "login";
}
@PostMapping("/toLogin")
public void login(@ModelAttribute User user, MyRequestWrapper request, HttpServletResponse response) throws IOException {
request.getSession().setAttribute(CookieUtil.SESSION_USER_INFO, user);
CookieUtil.onNewSession(request, response);
response.sendRedirect(user.getBackUrl());
}
@GetMapping("/index")
public ModelAndView index(MyRequestWrapper request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(CookieUtil.SESSION_USER_INFO));
return modelAndView;
}
}
然后看其第二个方法 /toLogin
方法,就是我们用户在前端输入的账号密码进行校验,然后通过后会进行种Cookie,并在Session中添加了登录的用户信息,并重定向至用户原请求页面。
那么登录完成后,我们如何将其设置到Redis中呢,这里我们就要回到上述的Filter中了,在Filter中最后我们在 finally 方法内进行了向Redis中保存,所以这个Filter我们将其优先级设置为1,最好保证其为第一个Filter。
然后最后第三个方法就是登录后可以访问的 index
页面,其中我们就是展示了从session中获取之前保存的用户信息。
接下来我们就剩下最后一个session_b模块了,其功能和session_a类似,模拟的另一个子系统,就是用于最后的演示,如下:
其中的类和session_a模块几乎一致,我们就不细看了,这里我们主要看下session_b的过滤器Filter,其内容其实还是类似的,只不过重定向的url是 b.bxs.com 而已,这些都很好理解。
我们主要看下其注入的项目中的方式变了,这里我们直接通过了 @WebFilter
注解来注入了,就不用使用 @Bean 的方式手工注入了,如下:
@WebFilter(filterName = "sessionFilter", urlPatterns = "/*")
public class SessionFilter implements Filter {
@Autowired
private RedisTemplate redisTemplate;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//包装request对象
MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request, redisTemplate);
//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/login".equals(requestUrl) && !"/toLogin".equals(requestUrl) && !myRequestWrapper.isLogin()) {
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.sendRedirect("http://b.bxs.com:8081/toLogin");
return;
}
try {
filterChain.doFilter(myRequestWrapper, servletResponse);
} finally {
//提交session到redis
myRequestWrapper.commitSession();
}
}
@Override
public void destroy() {
}
}
其代码内容还是一样,没什么太大区别,那么我们这种方式我们又该如何来指定其Filter的顺序呢,这里就比较尴尬了,这里是通过类的命名来分先后顺序的,所以起名很重要。
另外有介绍说可以在类上加上 @Order(1)
类似的注解来指定Filter顺序,不过好像并没有生效。
还有一点比较重要,就是使用 @WebFilter
注解的方式,我们需要在启动类上加上 @ServletComponentScan()
注解,如下:
(其实有关这两种使用方式,我们在基于Zookeeper实现服务注册与发现中使用 @WebListen
的时候也介绍过,和上述两个方式类似。)
最后我们来看下session_b模块的yml配置(session_a模块之前没有看,其实都是一样的,只不过端口号不同而已,session_a模块的端口号为8080)
server:
port: 8081
spring:
redis:
host: 127.0.0.1
port: 6379
最后我们就来进行测试,需要先启动Redis服务,然后再启动session_a项目及session_b项目,分别访问其项目index页面,即 a.bxs.com:8080/index 和 b.bxs.com:8081/index ,但是都被过滤器拦截,跳转至 login 方法,最后显示了 login.html 页面,如下:
然后注意了,我们在 a.bxs.com:8080/toLogin 页面输入BXS(密码随便输入,演示根本没校验),然后进行submit提交登录,登录成功后就自动跳转至 a.bxs.com:8080/index 页面了,如下
然后我们在另一个未登录页面,刷新一下页面就可以访问 /index 页面,无需再次登录,如下:
为什么呢?因为 b.bxs.com:8081 虽然没有进登录,但是我们在访问服务器时,是携带了 sessionId 的,服务器通过这个sessionId在Redis中找到了相关信息,并且从找到的session中取得了登录的用户信息。
那么为什么访问 b.bxs.com 也会携带Cookie中记录的sessionId呢?这个就需要看下登录成功后,种Cookie的过程了,其中我们设置了 .setDomain()
方法,如下:
这样我们就可以使Cookie进行跨域了,这样在我们设置的根域名下的所有子域名进行访问时,都会携带上我们种的Cookie了。
当然这样方式也是有缺点的,就是对于完全不同域名的系统,cookie是无法跨域名共享的,这里如果我们在本机的host文件将两个的顶级域名改为不一致的话,就无法做到Cookie共享了,如下:
(上述测试时,需要将Filter的重定向地址同步修改下,那么有关该问题如何解决,我们在后续会进行介绍)
最后提个小插曲,就是在上述的测试中,如果登录后向重试,可以将Redis中的记录直接全部清空掉,或者我们可以在浏览器中将服务器种的Cookie给清除掉,这里介绍个非常实用的小插件—— EditThisCookie