分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战

时间:2022-12-29 18:53:44

1、分布式session解决方案

在上一篇文章 深入底层,spring mvc父子容器初始化过程解析 中,介绍了Java Web的基础知识,以及Spring MVC父子容器初始化过程,有兴趣的读者可以阅读一下,一是作为本文的铺垫,二是本文所用到的项目也可以从上一篇文章获取到。

本文由上一篇文章引申出来,我们知道Java Web有个Session的概念,是存在于服务端的一块内存,但如今服务都是集群部署,如何解决集群多个节点间session不共享的问题呢?现有如下几种方案:

  • session粘滞:通常采用IP哈希负载策略将来自相同客户端的请求转发至相同的服务器上进行处理;缺陷:会存在负载不均衡现象
  • session复制:在集群节点间同步session数据;缺陷:占用大量资源
  • session共享:将session从服务器内存抽离出来,集中存储到独立的数据容器

session共享这种方案实用得多,也是现在最常用的方案。

2、引入Spring Session框架

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-core</artifactId>
    <version>2.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    <version>2.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>5.2.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.21</version>
</dependency>

spring-session-core是spring session框架核心依赖,spring-session-data-redis依赖则是将session数据持久化到redis中,也可以通过JDBC持久化到关系型数据库中,而lettuce-core则是spring session操作redis服务的客户端依赖,fastjson用于session数据存储到redis时进行序列化。

3、集成Spring MVC(HttpSession)

HttpSession是servlet规范的一个接口,而Spring Session与HttpSession集成之后,会通过过滤器springSessionRepositoryFilter将HttpSession转化为自己的Session,从而可以将session数据写到redis中。

可以通过继承AbstractHttpSessionApplicationInitializer配置springSessionRepositoryFilter过滤器。

import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer;
/**
 * 用于初始化springSessionRepositoryFilter
 */
public class SpringSessionInitializer extends AbstractHttpSessionApplicationInitializer {
    public SpringSessionInitializer() {
        super();
    }
}

Spring Session配置:

import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import com.alibaba.fastjson2.support.config.FastJsonConfig;
import com.alibaba.fastjson2.support.spring.data.redis.FastJsonRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.Session;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.session.events.SessionCreatedEvent;
import org.springframework.session.events.SessionDeletedEvent;
import org.springframework.session.events.SessionExpiredEvent;

@EnableRedisHttpSession
public class SpringSessionConfig {
    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
    @EventListener
    public void onCreated(SessionCreatedEvent event) {
        String sessionId = event.getSessionId();
        // spring-session提供的session
        Session session = event.getSession();
        System.out.println("创建:" + sessionId);
    }
    @EventListener
    public void onDeleted(SessionDeletedEvent event) {
        String sessionId = event.getSessionId();
        // spring-session提供的session
        Session session = event.getSession();
        System.out.println("删除:" + sessionId);
    }
    @EventListener
    public void onExpired(SessionExpiredEvent event) {
        String sessionId = event.getSessionId();
        // spring-session提供的session
        Session session = event.getSession();
        System.out.println("过期:" + sessionId);
    }
    @Bean(name="springSessionDefaultRedisSerializer")
    public RedisSerializer<Object> redisSerializer() {
        FastJsonRedisSerializer<Object> serializer = new FastJsonRedisSerializer<>(Object.class);
        FastJsonConfig config = new FastJsonConfig();
        config.setReaderFeatures(JSONReader.Feature.SupportAutoType);
        config.setWriterFeatures(JSONWriter.Feature.WriteClassName);
        serializer.setFastJsonConfig(config);
        return serializer;
    }
}

@EnableRedisHttpSession用于开启以redis为存储媒介的spring session。由于spring session的过滤器会将HttpSession转为自己的session,因此HttpSessionListener也会失效,但spring session会触发自己的session事件,如SessionCreatedEvent,我们可以监听它们。

4、session计数功能

最后,我们实现一个利用spring session统计访问次数的功能。

1、服务端代码

ChatController:

import com.bobo.springmvc.service.ChatService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Controller
@Slf4j
public class ChatController {
    @Autowired
    private ChatService chatService;

    @RequestMapping(method = RequestMethod.GET,value = "/doChat",produces = "text/plain;charset=utf-8")
    @ResponseBody
    public String doChat(HttpServletRequest request){
        HttpSession session = request.getSession();
        if(null == session.getAttribute("viewCount")){
            session.setAttribute("viewCount",1);
        }else{
            session.setAttribute("viewCount",((int)(session.getAttribute("viewCount")))+1);
        }
        chatService.doChat(request);
        return request.getLocalAddr()+":"+request.getLocalPort()+" 访问次数:"+session.getAttribute("viewCount");
    }
}

另外为了测试对象序列化,ChatController还注入了ChatService,并调用了doChat方法。

ChatService:

import com.alibaba.fastjson2.JSONObject;
import com.bobo.springmvc.entity.Chat;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;

@Service
@Slf4j
public class ChatService implements ApplicationContextAware {
    /**
     * 测试spring session redis json序列化
     */
    public void doChat(HttpServletRequest request){
        log.info("开始聊天");
        Object attr = request.getSession().getAttribute("nbaChat");
        if(null == attr){
            Chat chat = new Chat();
            chat.setId(1);
            chat.setFrom("杜兰特");
            chat.setTo("维斯布鲁克");
            chat.setMessage("你很棒棒哦");
            request.getSession().setAttribute("nbaChat",chat);
        }else{
            Chat nbaChat = (Chat)attr;
            nbaChat.setId(nbaChat.getId()+1);
            request.getSession().setAttribute("nbaChat",nbaChat);
        }
        log.info("聊天内容:{}", JSONObject.toJSONString(request.getSession().getAttribute("nbaChat")));
    }
}

2、前端页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="jquery-3.4.1.js"></script>
    <script>
        $(function () {
            $.ajax({
                url: "http://localhost:8070/doChat",
                method : "get",
                xhrFields : {withCredentials : true},
                success:function (result) {
                    $("#content").text(result);
                }
            });
        });
    </script>
</head>
<body>
<h1 id="content"></h1>
</body>
</html>

3、项目架构与部署架构

项目架构:
分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战
本文用到的几个文件已经圈出来了。

部署架构:
分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战
因为要测分布式session,所以这里用到了nginx负载均衡,另外将前端静态资源放到一个单独的nginx服务器,实现前后端分离。

这里涉及到跨域问题以及跨域cookie问题,所以需要在负载均衡nginx中添加跨域配置,且前端页面在发起ajax请求时要设置withCredentials为true。

4、Nginx:静态资源服务器配置

将静态资源文件拷贝到nginx目录下的html/SpringMvc目录下,如下图所示。
分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战
然后配置nginx.conf:

# server模块下;root为静态资源存放目录
server {
        listen       80;
        location / {
            root   html/SpringMvc;
            index  index.html index.htm;
        }
}

5、Nginx:负载均衡配置

# http模块下
http {
	upstream webservers{
		server  localhost:8081;
		server  localhost:8082;
	}
    server {
        listen       8070;
		add_header 'Access-Control-Allow-Origin' 'http://localhost';
		add_header 'Access-Control-Allow-Headers' '*';
		add_header 'Access-Control-Allow-Methods' '*';
		add_header 'Access-Control-Allow-Credentials' 'true';
		if ($request_method = 'OPTIONS') {
			return 204;
		}
        location / {
			proxy_pass http://webservers;
        }
    }
}

这里配置了跨域的CORS响应头,另外设置Access-Control-Allow-Credentials为true是为了发送跨域cookie,如果跨域cookie无法发送那么session计数功能也就实现不了。另外当Access-Control-Allow-Credentials为true时Access-Control-Allow-Origin不允许为*。

6、启动服务

按以下顺序依次启动各个服务:

  1. redis服务,监听localhost:6379
  2. 2个服务节点,分别监听localhost:8081、localhost:8082
  3. nginx负载均衡服务器,监听localhost:8070
  4. nginx静态资源服务器,监听localhost:80

windows杀死所有nginx进程命令:taskkill /f /t /im nginx.exe

redis GUI软件推荐:RedisFront

各服务启动成功之后,浏览器访问http://localhost,并不断刷新页面,可以看到每次访问的服务节点可能不同,访问次数递增,如下图所示。
分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战
观察redis中spring session的数据,如下图所示。
分布式session解决方案 Spring Session与Spring MVC(HttpSession)集成实战