分布式会话

1. 分布式会话

1.1 什么是会话?

会话 session 代表的是客户端与服务器的一次交互过程,这个过程可以是连续的也可以是时断时续的.曾经的 servlet 时代(jsp),一旦用户与服务端交互,tomcat 服务器就会为用户创建一个 session,同时前端会有一个 jsessionid,每次交互都会携带。如此一来, 服务器只要在接收到用户请求的时候,就可以拿到 jsessionid,并根据这个 id 在内存中找到对应的会话 session,当拿到 session 会话后,那么我们就可以操作会话了。会话存活期间,我们就能认为用户一直处于正在使用着网站的状态,一旦 session 超期过时,那么就可以认为用户已经离开网站,停止交互了。用户的身份信息,我们也是通过 session 来判断的,在 session 中可以保存不同用户的信息。

session 的使用方式如下:

1
2
3
4
5
6
7
8
@GetMapping("/setSession")
public Object setSession(HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("userInfo", "new user");
session.setMaxInactiveInterval(3600);
session.getAttribute("userInfo");
return "ok";
}

1.2 无状态会话

  • HTTP 请求是无状态的,用户向服务端发起多次请求,服务端并不会知道这么多次请求都来自同一个用户,这个就是无状态的。

  • cookie 的出现就是为了有状态的记录用户。

  • 常见的,ios 与服务端交互,安卓与服务端交互,前后端分离,小程序端与服务端交互,它们都是通过发起 http 请求来调用接口数据的,每次交互服务端都不会拿到客户端的状态,但我们可以通过手段去处理,比如用户每次发起请求的时候携带一个 userid 或 user-token,如此一来,就能让服务端根据 userid 或 token 来获得相应的数据。每个用户的下一次请求都能被服务器识别为同一个用户。

1.3 有状态会话

Tomcat 中的会话,就是有状态的,一旦用户和服务端交互,就有会话,会话保存了用户的信息,这样用户就有“状态”了,服务端会和每个客户端都保持着这样的一层关系,这个由容器来管理(也就是 tomcat),这个 session 会话是保存到内存空间里的,如此依赖,当不同的用户访问服务端,那么就能通过会话知道谁是谁了。如果用户不再和服务端交互,那么会话则消失,结束了它的生命周期。如此一来,每个用户其实都会有一个会话被维护,这就是有状态会话。

场景:在传统项目或者 jsp 项目中使用的最多的 session 都是有状态的,session 的存在就是为了弥补 http 的无状态。

提示:tomcat 会话可以通过手段实现多系统之间的状态同步,但是会损耗一定的时间,一旦发生同步那么用户请求就会等待,这种做法不可取。

1.4 为何使用无状态会话

  • 有状态的会话都是放在服务器的内存中的,一旦用户会话量多,那么内存就会出现瓶颈。而无状态会话可以采用介质,前端可以使用 cookie(app 可以使用缓存)保存用户 id 或 token,后端比如 redis,相应的用户会话都能放入 redis 中进行管理,如此,对应用部署的服务器就不会造成内存压力。用户在前端发起 http 请求,携带 id 或 token,这样服务器就能根据前端提供的 id 或 token 来识别用户了,可伸缩性就更强了。

1.5 单 tomcat 会话

  1. 先来看下单 tomcat 会话,这个是有状态的,用户首次访问服务端,这时候会话产生,并且会设置 jsessionid 放入 cookie 中,后续每次请求都会携带 jsessionid 以保持用户状态

    image-20230207211635086

1.6 动静分离会话

  1. 动静分离会话

    用户请求服务端,由于动静分离,前端发起 http 请求,不会携带任何状态,当用户第一次请求以后,我们手动设置一个 token,作为用户会话,放入 redis 中,如此作为 redis-session,并且这个 token 设置后放入前端 cookie 中(app 或 小程序可以放入本地缓存),如此后续交互中,前端只需传递 token 给后端,后端就能识别这个用户来自谁了

1.7 集群分布式系统会话

  1. 集群分布式系统会话

    集群或分布式系统本质都是多个系统,假设这里有两个服务器节点,分别是AB系统,它们可以是集群,也可以是分布式系统,一开始用户和 A 系统交互,那么这个时候的用户状态,我们可以保存到 redis 中,作为 A 系统的会话信息,随后用户的请求进入到了 B 系统,那么 B 系统中的会话也同样和 redis 关联,如此 AB 系统的 session 就统一了。当然 cookie 是会随着用户的访问携带过来的。那么这个其实就是分布式会话,通过 redis 来保存用户的状态。

    image-20230207212814129

2. SpringSession 实现用户会话

  1. 引入依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  2. 新增配置信息

    1
    2
    3
    spring:
    session:
    store-type: redis
  3. 启动类开启 HttpSession

    1
    2
    3
    4
    5
    6
    7
    8
    @EnableRedisHttpSession // 开启使用 redis 作为 SpringSession
    public class Application {

    public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
    }

    }
  4. 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @GetMapping("/setSession")
    public Object setSession(HttpServletRequest request) {
    HttpSession session = request.getSession();
    session.setAttribute("userInfo", "new user");
    session.setMaxInactiveInterval(3600);
    session.getAttribute("userInfo");
    // session.removeAttribute("userInfo");
    return "ok";
    }

    image-20230209151107074

3.分布式会话拦截器

基于分布式会话的权限拦截

  1. 编写拦截器代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95


    public class UserTokenInterceptor implements HandlerInterceptor {

    public static final String REDIS_USER_TOKEN = "redis_user_token";

    @Autowired
    private RedisOperator redisOperator;

    /**
    * controller调用之前
    *
    * @param request
    * @param response
    * @param handler
    * @return
    * @throws Exception
    */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    System.out.println("进入到拦截器,被拦截... ...");
    String headerUserId = request.getHeader("headerUserId");
    String headerUserToken = request.getHeader("headerUserToken");

    if (StringUtils.isNotBlank(headerUserId) && StringUtils.isNotBlank(headerUserToken)) {
    String uniqueUserToken = this.redisOperator.get(REDIS_USER_TOKEN + ":" + headerUserId);
    if (StringUtils.isBlank(uniqueUserToken)) {
    returnErrorResponse(response,JSONResult.errorMsg("请登录... ...."));
    return false;
    } else {
    if (!Objects.equals(headerUserToken, uniqueUserToken)) {
    returnErrorResponse(response,JSONResult.errorMsg("账号在异地登录,请重新登录... ...."));
    return false;
    }
    }
    } else {
    returnErrorResponse(response,JSONResult.errorMsg("请登录... ...."));
    return false;
    }

    return true;
    }

    public void returnErrorResponse(HttpServletResponse response, JSONResult result) {

    OutputStream out = null;
    response.setCharacterEncoding("utf-8");
    response.setContentType("text/json");
    try {
    out = response.getOutputStream();
    out.write(JsonUtils.objectToJson(result).getBytes(StandardCharsets.UTF_8));
    out.flush();
    } catch (IOException e) {
    e.printStackTrace();
    } finally {
    try {
    if (out != null) {

    out.close();
    }
    } catch (IOException e) {
    e.printStackTrace();
    }
    }

    }

    /**
    * controller 之后,渲染视图之前
    *
    * @param request
    * @param response
    * @param handler
    * @param modelAndView
    * @throws Exception
    */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    /**
    * controller 之后,渲染视图之后
    *
    * @param request
    * @param response
    * @param handler
    * @param ex
    * @throws Exception
    */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }
    }

  2. 添加拦截器并指定拦截路径

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {

    // 实现静态资源的映射
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/**")
    .addResourceLocations("classpath:/META-INF/resources/") // 映射swagger2
    .addResourceLocations("file:/workspaces/images/"); // 映射本地静态资源
    }

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
    return builder.build();
    }

    @Bean
    public UserTokenInterceptor userTokenInterceptor() {
    return new UserTokenInterceptor();
    }


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(userTokenInterceptor())
    .addPathPatterns("/hello");
    WebMvcConfigurer.super.addInterceptors(registry);
    }
    }

4. CAS 单点登录

4.1 相同顶级域名的单点登录 SSO

  1. 引子

    单点登录又称之为 Single Sign On,简称 SSO,单点登录可以通过基于用户会话的共享,分为两种:

    • 第一种:基于分布式会话实现

      比如现在有个一级域名为www.fengjian.tech,是教育类网站,这个网站还有其它产品线,可以通过构建二级域名提供服务给用户访问,比如math.fengjian.techenglish.fengjian.tech等等,分别为数学、英语等,用户只需要在其中一个站点登录,那么其它站点也会随之登录。

  2. Cookie + Redis 实现 SSO

    基于 redis 的分布式会话可以流窜在后端的各个系统,都可以获取到 redis 中的用户信息。前端通过使用 cookie (可以保证在同域名下的一级、二级获取)保存用户的 userid 和 token,访问后端时进行携带,用户在任意系统登录后,cookie 和 redis 中都会有用户的信息,只要用户不退出,那么就可以随意登录任意站点了。

    • 顶级域名www.fengjian.tech*.fengjian.tech的 cookie 是可以共享的,都可以携带到后端
    • 二级域名自己独立的 cookie 是不能共享的,math.fengjian.techenglish.fengjian.tech的 cookie 无法实现共享,两者互不影响
  3. Cookie 共享测试

    打开前端项目:设置域名,必须和 SwitchHosts 中设置的一致

    1
    cookieDomain: ".fengjian.tech"
    1
    2
    127.0.0.1 math.fengjian.tech
    127.0.0.1 english.fengjian.tech

4.2 不同顶级域名的单点登录SSO