session共享问题
一、 问题概述
session共享问题出现于集群或分布式环境中。
在最简单的一主一备、负载均衡的集群下,比如两台tomcat服务器和一台nginx负载均衡服务器。当用户访问时,nginx分配给tomcat1服务器处理登陆业务,用户登陆成功,在tomcat1记录了其登陆信息,当页面刷新时,nginx将用户请求分配给tomcat2服务器,在tomcat2服务器上没有用户登陆session,这样就需要用户再次登陆,如果足够巧合,刚好再次登陆的请求转到tomcat1服务器,显示用户登陆,再次刷新刚好又分配给tomcat2服务器,又没有登陆,甚至形成既登陆又没有登陆的矛盾局面。这就造成了不好的体验。
一般的解决办法是,tomcat服务器之间开启session共享广播,当tomcat1服务器记录了session数据后,就广播给其他tomcat服务器。但是,tomcat的session共享的节点数是有上限的。当集群中配置的tomcat节点机到达一定数量后(一般是5个),节点内部通信的流量可能被session广播占满,导致无法顺畅的处理其他业务,特别是难以适应高并发的场景。
避免session广播形成节点上限的解决办法是,配置单点登录的session服务器,适应redis缓存模拟session保存登陆信息。
分布式环境中,本身就是根据业务拆分成的不同的系统,比如商品管理、搜索功能、首页展示、商品详情等,登陆功能也是一个独立的系统。使用单点登录服务器解决session共享问题。
二、 搭建sso工程
single sign on,单点登陆。
使用maven搭建sso服务层系统和表现层系统。
在登陆业务中,如果登陆通过验证,生成一个唯一性质的token,模拟sessionId。将token作为redis缓存中的key,以用户信息作为value,设置缓存的有效期模拟session的过期时间。返回登陆成功,将token保存到cookie,模拟sessionId保存到cookie。
判断登陆状态,比如当用户添加商品到购物车或者访问订单页面时,从cookie中取token,然后调用sso服务根据token查询用户信息。在sso系统中,接收token,根据token查询redis。判断token是否有值,如果没有,返回用户登陆界面;如果有,重置过期时间,返回用户登陆已登陆,并显示请求页面结果。
服务层用户登陆的业务。
@Service
publicclass LoginServiceImpl implements LoginService {
@Autowired
private TbUserMapper userMapper;
@Autowired
private JedisClient jedisClient;
@Value("${SESSION_EXPIRE_TIME}")
private Integer SESSION_EXPIRE_TIME;
@Value("${SEESION_PRE}")
private String SEESION_PRE;
/**
* 用户注册
*/
@Override
public ServiceResultuserLogin(String username, String password) {
// 非空判断
if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
return ServiceResult.build(400,"用户名或密码不能为空");
}
// 密码md5加密
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 设置查询条件
TbUserExample example = new TbUserExample();
Criteria criteria = example.createCriteria();
criteria.andUsernameEqualTo(username);
criteria.andPasswordEqualTo(password);
// 查询是否用户是否存在
List<TbUser> list = userMapper.selectByExample(example);
if (list != null && list.size() > 0) {
TbUser user = list.get(0);
// 生成token
String token = UUID.randomUUID().toString().replaceAll("-", "");
// 将token和用户信息保存到缓存
user.setPassword(null);
StringuserInfo = JsonUtils.objectToJson(user);
jedisClient.set(SEESION_PRE + ":" + token, userInfo);
// 设置session的过期时间
jedisClient.expire(SEESION_PRE + ":" + token, SESSION_EXPIRE_TIME);
// 返回带有token的服务业务结果对象
return ServiceResult.ok(token);
}
return ServiceResult.build(400,"用户名或密码有误");
}
}
表现层用户登陆的业务。
public ServiceResultlogin(String username, String password, HttpServletRequest request,
HttpServletResponseresponse) {
// 调用登陆服务
ServiceResultresult = loginService.userLogin(username, password);
// 判断是否登陆成功
if(result.getStatus() == 200) {
// 成功,将token写入cookie
Stringtoken = result.getData().toString();
CookieUtils.setCookie(request,response, TOKEN_KEY, token);
}
// 跳转到网站首页
return result;
}
登录跳转到网站页面时,需要显示用户登陆成功的信息,就是需要从cookie中获取token,再根据token从sso系统中获取用户信息。如果在表现层中调用sso中的方法获取用户信息,在分布式系统中就需要每个相关系统的表现层都调用sso系统,实现起来比较重复。第二种方案是在页面加载完成后使用js的ajax取token中的用户信息,这就涉及 到js跨域ajax请求。但是js不可以跨域请求,跨域是域名不同;或者域名相同端口不同。解决js的跨域问题可以使用jsonp。
首先在sso系统中提供根据token获取用户信息的接口和类。
服务层
public ServiceResultgetUserByToken(String token) {
// 从缓存中获取token建对应的值
if (StringUtils.isBlank(token)){
returnServiceResult.build(201, "未登录");
}
String result = jedisClient.get(SEESION_PRE+ ":" + token);
// 如果没有,返回提示未登录的信息
if (StringUtils.isBlank(result)){
returnServiceResult.build(201, "登录过期");
}
// 并重置token的有效期为初始值
jedisClient.expire(SEESION_PRE+ ":" + token, SESSION_EXPIRE_TIME);
// 如果有,将值封装到服务结果pojo,返回服务结果pojo
returnServiceResult.ok(JsonUtils.jsonToPojo(result, TbUser.class));
}
支持jsonp的表现层
@RequestMapping("/user/token/{token}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public StringgetUserByToken(@PathVariable String token, String callback) {
ServiceResult result= tokenService.getUserByToken(token);
// 判断是否为jsonp请求
if (StringUtils.isNoneBlank(callback)){
return callback+ "(" + JsonUtils.objectToJson(result) + ");";
}
returnJsonUtils.objectToJson(result);
}
spring4.1后的支持的表现层的方法
@RequestMapping(value = "/user/token/{token}")
@ResponseBody
public ObjectgetUserByToken(@PathVariable String token, String callback) {
ServiceResult result= tokenService.getUserByToken(token);
// 判断是否为jsonp请求
if (StringUtils.isNoneBlank(callback)){
MappingJacksonValuevalue = new MappingJacksonValue(result);
value.setJsonpFunction(callback);
return value;
}
return result;
}
三、 使用jsonp实现ajax跨域请求
jsonp的原理,虽然js不能跨域请求,但是js可以跨域请求js文件。jsonp根据这一特点。编写一个jsonp跨域请求的js文件,以便可以在需要的系统中直接引用。
var SHOP = {
checkLogin : function(){
var _ticket =$.cookie("shop-token");
if(!_ticket){
return;
}
$.ajax({
url : "http://localhost:8099/user/token/"+ _ticket,
dataType: "jsonp",
type : "GET",
success: function(data){
if(data.status== 200){
varusername = data.data.username;
varhtml = username + ",欢迎来到网!<a href=\"http://www.exx.com/user/logout.html\"class=\"link-logout\">[退出]</a>";
$("#loginbar").html(html);
}
}
});
}
}
$(function(){
// 查看是否已经登录,如果已经登录查询登录信息
SHOP.checkLogin();
});