==========================2017/03/28========================
今天测试出了一个问题,日志:
java.lang.IllegalStateException: invalidate: Session already invalidated
这个是因为在我的 logout 里面执行了一次会话销毁:
// 登出
@RequestMapping(value="logout", method=RequestMethod.GET)
@ResponseBody
public BaseResponse logout(HttpServletRequest request){
BaseResponse resp = new BaseResponse(RESPONSE_STATUS.SUCCESS);
// 注销会话
request.getSession().invalidate();
resp.setMsg("成功退出系统");
return resp;
}
但是我在 web.xml 里面把下面这个 Listener 注释掉了。
<listener>
<listener-class>com.hebta.vinci.interceptor.VinciContextLoaderListener</listener-class>
</listener>
这就导致了 logout 里面的 invalidate() 完了后并没有执行 VinciSessionListener.sessionDestroyed 里面的清理工作。然后在 login 方法栈的 doSingleLogin 里,被销毁的会话再一次执行了 invalidate(),造成重复销毁一个会话的异常。
还有一个问题,就是 Session Timeout 后,sessionDestroyed() 方法没有被调用,这个很奇怪。由于没有从我们的 Map<String, Pair<String, HttpSession>> 里面删除对应记录,用户登录时, doSingleLogin() 里面的 old.invalidate(); 一调就报这个异常。由于一时搞不清问题所在,只能 try-catch 这个代码,然后清除记录:
for (String key : map.keySet()){
Pair<String, HttpSession> pair = map.get(key);
if (pair.getLeft().equalsIgnoreCase(loginName)){
logger.debug("曾经登录过,注销前面的会话");
HttpSession old = pair.getRight();
try {
old.invalidate();
} catch (Exception e) {
logger.debug("可能是会话超时,服务器已经删除过了");
map.remove(key);
}
break;
}
}
==========================2017/03/23========================
3月16日的方法有个问题,如果前端把浏览器关掉了,就没办法再登录了,因为服务端不知道这个事件,就不能销毁会话,用户只能傻等着会话超时。
所以这次微调下:使用 Map<String, Pair<String, HttpSession>>, Pair 关联用户的登录账号和会话,如果用户再次登录,那么前面那个会话被销毁,将新会话注册到应用上下文。
新的 login 逻辑代码如下:
public User login(User user){
// other work
if(isCorrectPassword){
doSingleLogin(user.getLoginName());
return dbUser;
}
return null;
}
private void doSingleLogin(String loginName) {
ServletContext ctx = SessionUtil.getServletContext();
Map<String, Pair<String, HttpSession>> map = (Map<String, Pair<String, HttpSession>>)ctx.getAttribute(VinciConstants.SINGLE_LOGIN);
for (String key : map.keySet()){
Pair<String, HttpSession> pair = map.get(key);
if (pair.getLeft().equalsIgnoreCase(loginName)){
logger.debug("如果曾经登录过,就让前面的会话注销");
HttpSession old = pair.getRight();
old.invalidate();
break;
}
}
logger.debug("注册新的会话到应用上下文");
HttpSession session = SessionUtil.getRequest().getSession();
map.put(session.getId(), Pair.of(loginName, session));
}
// 重新登录,或者主动登出,都会触发会话注销,所以这里集中从应用上下文里删除老的会话
@Override
public void sessionDestroyed(HttpSessionEvent se) {
String id = se.getSession().getId();
logger.debug("session destroyed :: " + id);
Object singleLoginAttr = SessionUtil.getServletContext().getAttribute(VinciConstants.SINGLE_LOGIN);
if (singleLoginAttr != null) {
Map<String, Pair<String, HttpSession>> map = (Map<String, Pair<String, HttpSession>>) singleLoginAttr;
map.remove(id);
} else {
logger.warn("没有找到会话里的属性SINGLE_LOGIN");
}
}
====================2017/03/16=========================
需求是:如果账号已经在被使用,就不可再用此账号登录系统,需要等待前面那个用户主动退出或会话超时才可以登录系统。
实现方案:在不考虑分布式的情况下,往ServletContext 放一个全局的变量,用来存储登录过的账号,每次用户登录,都去检查该账号是否在这个变量里,有就说明正在使用,需要等待。否则就成功登录并且把自己的SessionId 和账号用户名存放到这个全局变量里。
1. 首先在 Spring 启动的时候就初始化全局变量。这里自定义一个类,继承 Spring 的 ContextLoaderListener 监听器,然后重写 contextInitialized 方法。这个全局变量是一个 Map<String, String>, key 保存用户登录后的 SessionId, value 则是用户帐号的用户名。
public class VinciContextLoaderListener extends ContextLoaderListener {
private static final Logger logger = Logger.getLogger(VinciContextLoaderListener.class);
/**
* Initialize the root web application context.
*/
@Override
public void contextInitialized(ServletContextEvent event) {
logger.debug("添加一个全局的Map变量,用来防止一个用户账号多处登录");
ServletContext servletContext = event.getServletContext();
// 这里key放SessionId, value放用户名
Map<String, String> singleLogin = new HashMap<>();
servletContext.setAttribute(VinciConstants.SINGLE_LOGIN, singleLogin);
initWebApplicationContext(servletContext);
}
}
2. 在用户登录的处理逻辑里,如果账号登录过并且还在会话期内,就不可登录,否则注册会话号和用户名。
public User login(User user){
logger.debug("先检查账号是否被使用,如果被使用了就不可以重复登录");
Map<String, String> map = (Map<String, String>)SessionUtil.getServletContext().getAttribute(VinciConstants.SINGLE_LOGIN);
if (map.values().contains(user.getLoginName())){
throw new RuntimeException("该账号正在被使用");
}
// other work
if(isCorrectPassword){
// 将已登录的账号保存到全局变量里,注销则在会话失效时
map.put(SessionUtil.getRequest().getSession().getId(), user.getLoginName());
return dbUser;
}
return null;
}
public class VinciSessionListener implements HttpSessionListener {
private static final Logger logger = Logger.getLogger(VinciSessionListener.class);
@Override
public void sessionCreated(HttpSessionEvent se) {
logger.debug("session created :: " + se.getSession().getId());
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
logger.debug("session destroyed :: " + se.getSession().getId());
logger.debug("从全局变量里删除登录账号");
Object attr = se.getSession().getServletContext().getAttribute(VinciConstants.SINGLE_LOGIN);
Map<String, String> map = (Map<String, String>)attr;
map.remove(se.getSession().getId());
}
}
至此,单系统内一个账号在一个时间点只能由一个用户使用。