分布式Session解决方案

2022-05-29 10:53:09 浏览数 (1)

考虑一个场景,用户在进行下单操作之前后台需要校验该用户是否登录,若未登录则不允许提交订单,这在传统的单体应用中非常容易实现,只需在提交订单之前判断Session中的用户信息是否登录即可,但在分布式应用中,这显然是一个待解决的问题。

分布式应用下Session存在的问题

在分布式架构中,一个应用往往被划分为多个子模块,比如:登录注册模块和订单模块,当应用被拆分后,随之而来的便是数据的共享问题:

一般我们都在登录注册模块中将用户的登录状态保存到Session中,然而当用户进行下单操作时,由于订单模块是独立的,它无法获取到登录注册模块中保存的Session,所以订单模块是无法判断用户是否登录的。

而为了保证系统的高可用,一个模块往往被部署多份形成集群,这些模块之间的数据共享也是一个问题:

用户在一个模块中登录成功后,很可能在下次访问时请求被负载均衡到其它的集群模块中,这样会导致无法读取到Session,使得用户又得重新登录一次系统。

Session共享问题的案例演示

下面编写一个案例进行演示,首先创建一个SpringBoot应用,实现登录模块:

代码语言:javascript复制
@RestController
public class LoginController {

    @Autowired
    private ServiceOrderClient serviceOrderClient;

    @GetMapping("/login")
    public Result login(User user, HttpSession session) {
        String username = user.getUsername();
        String password = user.getPassword();
        Result result = new Result();
        if ("admin".equals(username) && "admin".equals(password)) {
            result.setCode(200);
            result.setMessage("登录成功");
            session.setAttribute("user", user);
        } else {
            result.setCode(-1);
            result.setMessage("登录失败");
        }
    }
}

再创建一个SpringBoot应用,实现订单模块:

代码语言:javascript复制
@RestController
public class OrderController {

    @GetMapping("/order/test")
    public String order(@CookieValue("JSESSIONID") String jSessionId) {
        return "success";
    }
}

代码都非常简单,我们主要是观察Session的问题,在登录模块中编写远程调用接口:

代码语言:javascript复制
@FeignClient("service-order")
public interface ServiceOrderClient {

    @GetMapping("/order/test")
    String order();
}

将这两个应用都注册到Nacos中,其它代码我就不贴出来了,都比较简单。

分别启动这两个项目,并访问 http://localhost:8080/test ,会发现访问是不成功的:

控制台输出的结果:

代码语言:javascript复制
2021-09-21 16:51:43.155  WARN 20908 --- [nio-9000-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingRequestCookieException: Missing cookie 'JSESSIONID' for method parameter of type String]

找不到名为 JSESSIONID 的Cookie,我们知道,服务端是通过JSESSIONID来找到该用户对应的Session信息的,既然JSESSIONID都获取不到,就更不用说用户信息了,这就是Session不共享的问题。

Redis解决Session共享问题

对于分布式应用中的Session问题,其实也非常简单,无非就是不能共享到Session,所以,我们可以类比缓存的思想,将Session放入缓存中,其它服务想要获取Session也从缓存中拿,这样就实现了Session的共享。

改进一下登录模块:

代码语言:javascript复制
@GetMapping("/login")
public Result login(User user, HttpSession session) {
    String username = user.getUsername();
    String password = user.getPassword();
    Result result = new Result();
    if ("admin".equals(username) && "admin".equals(password)) {
        result.setCode(200);
        result.setMessage("登录成功");
        String json = JSONObject.toJSONString(user);
        redisTemplate.opsForValue().set("session", json);
    } else {
        result.setCode(-1);
        result.setMessage("登录失败");
    }
    return result;
}

当我们访问登录接口 http://localhost:8080/login?username=admin&password=admin 时,就会向Redis保存一份Session的值:

此时若是其它服务需要Session,只要从Redis中读取即可,修改一下订单模块:

代码语言:javascript复制
@RestController
public class OrderController {

    @GetMapping("/order/test")
    public String order() {
        return "success";
    }
}

在订单模块中添加一个登录的拦截器:

代码语言:javascript复制
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 手动获取StringRedisTemplate对象
        StringRedisTemplate redisTemplate = SpringBeanOperator.getBean(StringRedisTemplate.class);
        String json = redisTemplate.opsForValue().get("session");
        User user = JSONObject.parseObject(json, User.class);
        System.out.println(user);
        if (user == null) {
            System.out.println("用户未登录......");
            return false;
        } else {
            System.out.println("用户已登录......");
            return true;
        }
    }
}

将拦截器注册一下:

代码语言:javascript复制
@Configuration
public class MyWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**");
    }
}

重启项目,访问 http://localhost:8080/test ,输出结果:

代码语言:javascript复制
User(username=admin, password=admin)
用户已登录......

SpringSession解决Session共享问题

刚才我们自己使用Redis尝试着解决了一下Session的共享问题,然而这种方式是有很多缺陷的,首先,我们保存的只是一个User对象,并不是Session,所以我们无法标识该用户,这样会导致用户访问到了其它用户的信息,使得系统混乱。我们当然可以使用JSESSIONID来标识不同的用户,但其实,Spring已经为我们提供了一个组件来解决这一问题,那就是SpringSession。

在两个模块中都引入SpringSession的依赖:

代码语言:javascript复制
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

在application.yml中配置一下Session的保存方式为Redis:

代码语言:javascript复制
spring:  session:
    store-type: redis

最后在启动类上添加 @EnableRedisHttpSession 注解,这样SpringSession的整合就完成了。

我们修改登录模块的代码:

代码语言:javascript复制
@GetMapping("/login")
public Result login(User user, HttpSession session) {
    String username = user.getUsername();
    String password = user.getPassword();
    Result result = new Result();
    if ("admin".equals(username) && "admin".equals(password)) {
        result.setCode(200);
        result.setMessage("登录成功");
        session.setAttribute("user",user);
    } else {
        result.setCode(-1);
        result.setMessage("登录失败");
    }
    return result;
}

按照正常流程将User对象存入Session,重启项目并访问登录接口,来看看Redis中有什么变化:

此时Redis中已经保存了用户信息,并且还有创建时间、存活时间等配置,其它模块要想获取到Session中的用户信息,也只需要按正常流程编写代码即可:

代码语言:javascript复制
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        User user = (User) session.getAttribute("user");
        System.out.println(user);
        if (user == null) {
            System.out.println("用户未登录......");
            return false;
        } else {
            System.out.println("用户已登录......");
            return true;
        }
    }
}

需要注意的是登录模块存入的User对象需要和其它模块读出的User对象包名一致,所以最好将User类抽取到公共模块中,提供给所有模块使用。

到这里SpringSession就解决了Session共享的问题,你可以运行项目测试一下,访问 http://localhost:8080/test :

结果出乎意料,控制台的结果是:

代码语言:javascript复制
null
用户未登录......

这就奇怪了,难道是SpringSession没起作用?我们写一个测试方法测试一下:

代码语言:javascript复制
@GetMapping("/test")
public String test(HttpSession session) {
    User user = (User) session.getAttribute("user");
    System.out.println(user);
    return "test";
}

访问 http://localhost:9000/test ,得到结果:

代码语言:javascript复制
User(username=admin, password=admin)

显然SpringSession是没有任何问题的,那么问题出在哪里了呢?

OpenFeign远程调用的坑

刚才我们进行了测试,发现在订单模块中直接访问Session可以获取User对象,然而通过远程调用,User就获取不到了,我们可以猜测这是OpenFeign出现了问题,Debug调试一下项目,这是远程调用的代码:

我们跟进去看看:

代码语言:javascript复制
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if ("equals".equals(method.getName())) {
        try {
            Object otherHandler =
                args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null;
            return equals(otherHandler);
        } catch (IllegalArgumentException e) {
            return false;
        }
    } else if ("hashCode".equals(method.getName())) {
        return hashCode();
    } else if ("toString".equals(method.getName())) {
        return toString();
    }

    return dispatch.get(method).invoke(args);
}

该方法中进行了一些判断,最终会调用dispatch.get()方法:

代码语言:javascript复制
@Override
public Object invoke(Object[] argv) throws Throwable {
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    Options options = findOptions(argv);
    Retryer retryer = this.retryer.clone();
    while (true) {
        try {
            return executeAndDecode(template, options);
        } catch (RetryableException e) {
            try {
                retryer.continueOrPropagate(e);
            } catch (RetryableException th) {
                Throwable cause = th.getCause();
                if (propagationPolicy == UNWRAP && cause != null) {
                    throw cause;
                } else {
                    throw th;
                }
            }
            if (logLevel != Logger.Level.NONE) {
                logger.logRetry(metadata.configKey(), logLevel);
            }
            continue;
        }
    }
}

该方法又会调用executeAndDecode():

该方法会封装一个请求模板作为目标请求进行远程调用,然而我们观察到该请求模板中并没有任何的参数和请求头,而我们知道,Session是依靠JSESSIONID进行识别的,在SpringSession中,Session是依靠SESSIONID识别的:

由此我们得到结论,因为OpenFeign远程调用丢失了请求头,导致SESSIONID丢失,最终导致订单模块无法获取到User对象。得知了问题后,解决就非常简单了,我们可以创建一个请求过滤器,它将在请求模板生成前对请求进行处理:

代码语言:javascript复制
@Configuration
public class MyFeignConfig {

    @Bean
    public RequestInterceptor requestInterceptor() {
        return requestTemplate -> {
            System.out.println("远程调用前调用该方法-->requestInterceptor......");
            ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = requestAttributes.getRequest();
            String cookie = request.getHeader("Cookie");
            requestTemplate.header("Cookie", cookie);
        };
    }
}

将原Request对象中的Cookie请求头信息设置给请求模板,这样OpenFeign创建的请求就具有了Cookie内容,重新启动项目测试,问题迎刃而解。

0 人点赞