详解Spring Session

1.背景

1.1 session

说起session还要从http协议说起,http协议是无状态协议。所谓无状态协议,就是http请求之间是相互独立的。比如,你用百度搜索“2020年NBA总冠军是谁”,百度服务器会给你一个响应,这一次的http请求就算完事儿了。当你再去搜索“谁会赢得2020年美国总统大选”,这个时候百度服务器并不会感知到你上一次搜索了啥,你的两次搜索是完全独立的。可以用下面的图简略表示下。

详解Spring Session_第1张图片

对于百度搜索这样的场景,这样的无状态协议问题不大。但是对于大多数场景,这种无状态协议是远远不够的。比如,你要查看自己淘宝的购物车,淘宝会返回一个登陆页面,这个时候你输入了自己的用户名和密码,发起了一次登陆的http请求,假设用户名和密码都正确,这个时候系统会自动跳转到登陆成功的页面;但是当你点击付款的时候,发起了一次付款的http请求,这个时候系统会校验当前的用户信息,以及用户是否登陆,由于http是无状态协议,服务器并不知道你已经通过上次请求了登陆,会再次返回登陆页面。这样每操作一次都需要登陆一次,用户体验极差。

为了解决类似的问题,session应运而生。以淘宝登陆为例,它的主要工作流程如下:

1. 用户在登陆页面输入用户名和密码,点击登陆,发送登录请求至淘宝服务器。

2. 服务器接收到请求后,校验请求头部中未携带sessionId,于是创建session对象,并将sessionId放入response的cookie对象中。

3. 服务器校验用户和密码成功后,将用户信息存放至session中。请求返回,浏览器将response中cookie缓存至本地。

4. 用户点击付款,发送付款请求,由于请求的是相同的域名,浏览器会自动在请求头部中添加sessionId。

5. 服务器接收到请求后,识别到请求头部中sessionId,直接根据sessionId去查找上一次创建的session对象,并将该对象放入request对象中。

6. 服务器从request的session对象中获取到用户信息,校验通过,付款成功。

      详解Spring Session_第2张图片

        

1.2 分布式应用存在的问题

1.2.1问题复现

session的出现很大程度上解决了http请求无状态的问题。但是,随着业务量增加和对服务高可用的诉求,分布式应用和微服务得到了越来越广泛的应用,这样使用传统的session就会存在问题。接下来我们就来复现一下这个问题。

1.使用springboot实现一个简单的登陆功能

访问http://localhost:8080/login,如果用户已经登陆,则直接返回主页面;如果用户未登陆,跳转至登陆页面。在登陆页面输入用户名和密码,点击登陆,跳转至登陆成功页面(此处不对用户名和密码进行校验)。

package com.example.mysession.collector;

import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Controller
public class LoginContoller {

    /**
     * 返回登陆页面
     */
    @GetMapping("/login")
    public String login() {
        HttpServletRequest request = getRequest();
        HttpSession session = request.getSession();
        // session中包含用户信息,直接返回主页
        String usernameInSession = (String) session.getAttribute("username");
        if (!StringUtils.isEmpty(usernameInSession)) {
            return "main";
        }

        // 重定向至登陆页面
        return "loginpage";
    }


    /**
     * 处理登陆请求
     */
    @PostMapping("/doLogin")
    public String doLogin(@RequestParam String username, @RequestParam String password) {
        HttpServletRequest request = getRequest();
        HttpSession session = request.getSession();
        // 将用户名放入session,并通知用户登陆成功(这里不对用户名和密码进行校验)
        session.setAttribute("username", username);
        return "main";
    }


    private HttpServletRequest getRequest() {
        ServletRequestAttributes servletRequestAttributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return servletRequestAttributes.getRequest();
    }
}

2.我们来分析一下这个简单的登陆流程

(1)springboot启动后,访问http://localhost:8084/login。回想1.1中的流程,由于我们是首次访问,请求头中没有带sessionId,因此tomcat会为我们创建一个session对象,并将sessionId返回,浏览器将sessionId缓存至本地。

请求前:

详解Spring Session_第3张图片

请求后:

详解Spring Session_第4张图片

详解Spring Session_第5张图片

(2)输入用户名和密码,点击登陆。这时请求会带上SessionId,tomcat会根据SessionId找到之前创建session,并将其放入request对象中。doLogin()方法在处理时会将用户信息存入session。

详解Spring Session_第6张图片

(3)再访问http://localhost:8084/login。此次请求浏览器依然会将SessionId放入请求头部,login()方法根据SessionId找到session,进而在该session中找到用户信息,判断当前用户已经登陆,直接返回登陆成功页面。

详解Spring Session_第7张图片

3.使用nginx反向代理,实现简单的集群

(1)上述是单机环境,session完美解决了http协议无状态的问题,使得我们不需要频繁登陆。接下来,我们启动两个相同的服务(1中简单登陆服务),他们分别监听8084和8085端口。

详解Spring Session_第8张图片

(2)测试两个服务是否都正常可用

8084:

详解Spring Session_第9张图片

8085:

详解Spring Session_第10张图片

(3)使用ngnix进行反向代理

nginx.conf:


#user  nobody;
worker_processes  1;

events {
    # 并发连接数量
    worker_connections  1024;
}


http {
    # tomcat集群
    upstream tomcat_servers{
        server 127.0.0.1:8084;
        server 127.0.0.1:8085;
    }

    server {
        # 监听80端口
        listen       80;
        server_name  localhost;

        # 将请求交给tomcat集群处理
        location / {
		    proxy_set_header Host $host:$server_port;
            proxy_pass http://tomcat_servers;
        }

    }
}

4.出现问题

(1)访问http://localhost/login。

详解Spring Session_第11张图片

(2)输入用户名和密码,点击登陆。

详解Spring Session_第12张图片

(3) 再次访问http://localhost/login。按照之前单节点的处理逻辑,应该返回登陆成功的页面才对,但是现在好像有点不太对?

详解Spring Session_第13张图片

1.2.2 原因分析

上述的现象比较明显,就是服务器好像并没有记住我们已经登陆过了。要解释这个问题我们首先要看下session的本质,session本质是tomcat为我们创建的一个对象,它使用ConcurrentHashMap保存属性值。tomcat本质也是一个Java程序,一个tomcat容器(8084)创建的session另外一个tomcat(8085)自然是获取不到的。

详解Spring Session_第14张图片

好了,有了上面的知识,我们再来看看这个问题是怎么产生的。我们启动了两个tomcat服务器(分别监听8084和8085端口),使用ngnix进行反向代理,为了达到负载均衡的效果,我们在ngnix上置的策略是轮流访问8084和8085,也就是请求第一次访问8084节点,第二次访问8085节点,再是8084...以此类推。

当我们第一次访问http://localhost/login时,ngnix会为我们路由到8084节点,由于是第一次访问8084会为我们创建一个session,sessionId为32AC7EEFE4CD0486F88B23B84594A768,并返回至浏览器。

当我们输入用户名和密码,点击登陆,第二次访问时,ngnix会为我们路由到8085节点。这一次我们请求头部带了SessionId:32AC7EEFE4CD0486F88B23B84594A768,但是这个session是8084节点创建的,8085节点获取不到这个session,所以8085又会创建一个新的session,709F94B639FFD8DF34110E1F1760D84D,返回至浏览器。

至此我们已经登陆成功了。我们再次访问http://localhost/login,ngnix会为我们路由到8084节点,请求头部带上上一次请求返回的sessionId:709F94B639FFD8DF34110E1F1760D84D,而这个是8085节点创建的,8084节点获取不到这个session,所以认为我们没有登陆过。

...

如此循环,我们会发现每一次请求,tomcat都会为我们创建一个session对象,而一个tomcat容器创建的session对象,另一个tomcat容器获取不到,这就是问题的根源。可能有的人会说,这不简单,在ngnix上配置策略,让每次来自同一个IP的用户访问同一个tomcat容器,这样不就好了?但是这样主要有两个问题,第一,如果一个节点出问题挂掉了,那么之前一直访问这个节点的用户登陆信息就丢失了,需要重新登陆,这显然不合理第二,移动端应用越来越多,而移动端可能换一个基站IP就变了。

2.spring session

2.1 解决问题的原理

知道了上述问题的原因,我们来看看怎么解决这个问题。首先来回顾下tomcat处理请求的流程

详解Spring Session_第15张图片

从上面的分析以及工作流程我们可以知道,解决该问题的关键就是如何让多个tomcat共享同一个session。我们自然而然就会想到,把session存储在一个公共的地方,这样每个tomcat就都会获取到了,这个公共的地方就是数据库(本文以Redis为例)。spring session的实现原理,就是在tomcat中加入了一个优先级很高的filter,来一个偷天换日,将request中的session置换为spring session,而这个spring session就存储在数据库中(Redis),这样一来,不同的tomca就可以共享同一个session啦。具体的细节我们在源码解析中再详细介绍,先看看spring session如何使用。

详解Spring Session_第16张图片

2.2 spring session的使用

可以参考spring官网https://docs.spring.io/spring-session/docs/1.3.0.RELEASE/reference/html5/guides/httpsession.html

1.添加依赖



    4.0.0
    
        org.springframework.boot
        spring-boot-starter-parent
        2.3.5.RELEASE
         
    
    com.example
    my-session
    0.0.1-SNAPSHOT
    my-session
    Demo project for Spring Boot

    
        1.8
    

    
        
            org.springframework.boot
            spring-boot-starter-web
        

        
            org.springframework.boot
            spring-boot-starter-thymeleaf
        

        
        
            org.springframework.session
            spring-session-data-redis
        
        
            org.springframework.boot
            spring-boot-starter-data-redis
        
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    


 

2.添加一个配置文件

package com.example.mysession.config;

import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

@EnableRedisHttpSession
public class SpringSessionConfig {

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
    
}

3.向spring中注入一个Initializer

package com.example.mysession.initializer;

import jdk.nashorn.internal.runtime.regexp.joni.Config;
import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer;
import org.springframework.stereotype.Component;

@Component
public class Initializer extends AbstractHttpSessionApplicationInitializer {

    public Initializer() {
        super(Config.class);
    }
}

4.测试

(1)访问http://localhost/login。

详解Spring Session_第17张图片

(2)输入用户名和密码,点击登陆。

详解Spring Session_第18张图片

(3)再次请求http://localhost/login。直接跳转至登陆成功页面。

详解Spring Session_第19张图片

这里我们可以看到,第二次请求和第三次请求携带的都是第一次请求返回的sessionId。

2.3 spring session源码解析

详解Spring Session_第20张图片

详解Spring Session_第21张图片

spring的官网写的非常清晰,我们使用@EnableRedisHttpSession注解创建了一个springSessionRepositoryFilter。而这个filter就是用来将tomcat创建的session对象替换为spring session。

实际处理的Filter时SessionRepositoryFilter,而SessionRepositoryFilter又继承OncePerRequestFilter,所以dofilter()方法在OncePerRequestFilter类中实现。

详解Spring Session_第22张图片

OncePerRequestFilter中的dofilter()方法

	/**
	 * This {@code doFilter} implementation stores a request attribute for "already
	 * filtered", proceeding without filtering again if the attribute is already there.
	 * @param request the request
	 * @param response the response
	 * @param filterChain the filter chain
	 * @throws ServletException if request is not HTTP request
	 * @throws IOException in case of I/O operation exception
	 */
	@Override
	public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
			throw new ServletException("OncePerRequestFilter just supports HTTP requests");
		}
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		HttpServletResponse httpResponse = (HttpServletResponse) response;
		String alreadyFilteredAttributeName = this.alreadyFilteredAttributeName;
		boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;

        // 已经过滤过了
		if (hasAlreadyFilteredAttribute) {
			if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
				doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
				return;
			}
			// Proceed without invoking this filter...
			filterChain.doFilter(request, response);
		}
        // 还未过滤
		else {
			// Do invoke this filter...
			request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
			try {
				doFilterInternal(httpRequest, httpResponse, filterChain);
			}
			finally {
				// Remove the "already filtered" request attribute for this request.
				request.removeAttribute(alreadyFilteredAttributeName);
			}
		}
	}

我们来看这个doFilterInternal方法。(SessionRepositoryFilter类中的方法)这里采用了装饰器模式,将HttpServletRequest和HttpServletResponse封装成SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper。在这两个Wrapper中对tomcat的session进行了替换。

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);

		SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response);
		SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest,
				response);

		try {
			filterChain.doFilter(wrappedRequest, wrappedResponse);
		}
		finally {
			wrappedRequest.commitSession();
		}
	}

 

 

 

 

 

 

 

你可能感兴趣的:(spring,java)