微服务下的登录实现及相关问题解决
最近由于工作需要,需要开发一个登录的微服务;由于前期在网上找session共享的实现方案遇到各种问题,所以现在回过头来记录下整个功能的实现和其中遇到的问题;总结一下主要有以下几点:
1、登录实现(整合redis以及用户信息的共享问题)
2、登录拦截器的实现及拦截后成功跳转(这里踩了一个大坑)
3、登录过期时间随用户的操作而跟新(即当用户操作时间大于设置的登录时间时不要让用户推出登录)
4、Springboot的自定义异常捕获(初衷是为了解决2的跳转问题,最后兜了一大圈)
下面对上面提到的几点进行详细记录;
一、登录实现(整合redis以及用户信息的共享问题)
这是整个功能的核心所在,由于我们有多个服务所以首要解决的就是session共享的问题,解决这个问题主要是通过redis来实现的,我把登录成功后对session的操作全部换为对redis的操作,以userId为key,然后将userId返回给前台,在前台需要写一个common.js来重写ajax请求,使得每次访问后台的请求都自动带上userId,这样再写一个拦截器登录整个登录功能差不多就实现了;
二、登录拦截器的实现及拦截后成功跳转
首先需要写一个拦截器的配置类,主要就是将我们自定义的拦截器注册到项目中以及白名单的添加;下面是注册拦截器的代码:
@Configuration
public class LoginInterceptorConfig extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {
/**
* LoginInterceptor是自定义的拦截器
* addPathPatterns参数/**是通配符表示拦截所有的请求
* excludePathPatterns方法的参数是可变参数,可以输n个字符串类型的参数,用来添加白名单
*/
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**").excludePathPatterns("/login/doLogin");
}
}
然后贴上我登录拦截器(LoginInterceptor)里的代码:
package com.huayun.base.interceptors; import com.huayun.base.entity.UserBean; import com.huayun.base.exception.BaseErrorCode; import com.huayun.base.exception.BaseException; import com.huayun.base.util.RedisUtil; import net.sf.json.JSONObject; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import redis.clients.jedis.Jedis; import javax.annotation.PostConstruct; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.PrintWriter; /** * 登陆拦截器 * 主要判断请求中有没有token以及token有没有过期 */ @Component public class LoginInterceptor extends HandlerInterceptorAdapter { // @Autowired // private RedisClusterUtil redisUtil; public static LoginInterceptor interceptor; @PostConstruct public void init(){ interceptor = this; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object token = request.getParameter("token"); String userId = request.getParameter("userId"); String url = request.getRequestURI(); if(isWhiteMenu(url)){ // /login/logout return true; } if(token == null || userId == null) { JSONObject json = new JSONObject(); json.put("code","10005"); json.put("msg","登陆验证失败,请重新登陆!"); response.setHeader("Access-Control-Allow-Origin","*"); response.setContentType("application/json;charset=utf-8"); response.setStatus(200); response.getWriter().write(json.toString()); return false; // throw new BaseException("登陆验证失败,请重新登陆", // BaseErrorCode.VALIDATOR_ERROR); } // UserBean user = null; try { // 获取key过期时间 Long expireTime = RedisUtil.getExpire(userId); if(expireTime < 0) { // key不存在则登录超时 JSONObject json = new JSONObject(); json.put("code","10005"); json.put("msg","登陆超时,请重新登陆!"); response.setHeader("Access-Control-Allow-Origin","*"); response.setContentType("application/json;charset=utf-8"); response.setStatus(200); response.getWriter().write(json.toString()); return false; } if(expireTime < 60*5) {// 如果过期时间小于5分钟秒则重置过期时间 RedisUtil.expire(userId,30*60); } // user = (UserBean)RedisUtil.get(userId);// redis单机 // user = (UserBean) RedisClusterUtil.getObject(userId.toString());//redis哨兵 }catch (Exception ex) { // return true;//ruturn true 是为了当redis连接出问题时程序能正常运行,但没有进行登陆过期的判断 ex.printStackTrace(); throw new BaseException("Redis连接异常", BaseErrorCode.VALIDATOR_ERROR); } // if(user == null) { // JSONObject json = new JSONObject(); // json.put("code","10005"); // json.put("msg","登陆超时,请重新登陆!"); // response.setHeader("Access-Control-Allow-Origin","*"); // response.setContentType("application/json;charset=utf-8"); // response.setStatus(200); // response.getWriter().write(json.toString()); // return false; //// throw new BaseException("登陆超时,请重新登陆", //// BaseErrorCode.VALIDATOR_ERROR); // } return true; } /** * 判断是否白名单 * @param url * @return */ private boolean isWhiteMenu(String url) { if(url.contains("removeLoginParam") || url.contains("setLoginParam") || url.contains("getYwzAndBdz") || url.contains("getYwzAndBdzInfo") || url.contains("searchStation") || url.contains("swagger-resources") || url.contains("configuration/ui") || url.contains("v2/api-docs")){ return true; }else{ return false; } } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
这其中拦截成功的跳转登录页我踩了一个大坑,起初很天真的想直接用跳转和转发,稍微想下也知道是不可能实现的,因为这完全是跨域的,百度说在response里添加Access-Control-Allow-Origin就可以了,但仅仅添加这个响应头也是不行的,因为我们是前后端分离的;这其中一个大神给的意见是让我自定义一个异常,拦截成功后直接抛异常然后进行捕获统一处理,他自己曾经就这样试过,然后我就屁颠屁颠的把他的代码拿来改了,最后的确是成功跳转了,但后来回看代码其实成功的关键并不在于异常的捕获然后捕获成功后的处理;也就是说我不捕获异常直接在拦截器里写也是可以成功跳转的;
关键是跳转的这段逻辑,由于跨域、前后端分离等原因从后台直接跳转实现不了,所以换个思路,不直接跳转,而是给拦截到的请求进行自定义响应,让ajax的success回调能捕获到我们返回的登录被拦截的信息最后从前端跳登录页;关键代码如下:
JSONObject json = new JSONObject(); json.put("code","10005"); json.put("msg","登陆超时,请重新登陆!"); response.setHeader("Access-Control-Allow-Origin","*"); response.setContentType("application/json;charset=utf-8"); response.setStatus(200); response.getWriter().write(json.toString());
标红的缺一不可,其实起初我也想到了这个实现方法,但是不论怎么写ajax的回调捕获到的响应信息都是空,最后发现我们可以自定义请求的http响应码response.setStatus(200);
至此,登录拦截器也基本实现了,接下来说说一些小细节
三、登录过期时间随用户的操作而跟新
这个bug是给领导演示的时候发现的,我默认redis的key过期时间为30分钟,那天演示的时候领导一直在操作页面结果三十分钟到了,果断跳到登录页了,这下领导不满意了,你这个怎么这样呀,不能实时获取用户的操作状态吗?这样用户体验太差了;我表面点头称是其实心里在想哪个用户像你这样一点点半个小时的呀,哈哈;话虽这么说bug还是要改掉的;想到的第一个解决方案就是写监听器监听session的状态,只要监听到用户在操作就跟新过期时间,先不说这样平白无故添加了n次redis的操作,就连监听器我都实现不了;因为我们是多个服务session没有实现真正意义上的共享.无法有效监听;所以后来想到了一个超级简单的方法,就是在拦截器里获取过期时间的同时添加一个判断,当有效时间小于五分钟时重新更新有效时间,这样就不会新增无畏的redis操作了。关键代码如下:
// 获取key过期时间 Long expireTime = RedisUtil.getExpire(userId); if(expireTime < 0) { // key不存在则登录超时 JSONObject json = new JSONObject(); json.put("code","10005"); json.put("msg","登陆超时,请重新登陆!"); response.setHeader("Access-Control-Allow-Origin","*"); response.setContentType("application/json;charset=utf-8"); response.setStatus(200); response.getWriter().write(json.toString()); return false; } if(expireTime < 60*5) {// 如果过期时间小于5分钟秒则重置过期时间 RedisUtil.expire(userId,30*60); }
四、Springboot的自定义异常捕获并返回自定义异常信息
本来单单是开发一个登录微服务是不大需要来进行全局异常的捕获的,但之前由于在登录拦截成功后始终无法正常跳转,为了解决这一问题可谓是想尽了办法,在一位大神的建议下尝试捕获全局异常,虽然这样最后也解决了,但成功解决的根本原因并不在全局异常捕获而在于给ajax请求的response中sethttp的状态码,但功能都实现了所以我也就记录下springboot环境下如何进行全局异常捕获
首页定义一个异常处理器,使用@ControllerAdvice、@ExceptionHandler注解拦截异常,具体代码如下:
import com.huayun.base.exception.BaseException; import com.google.common.collect.Maps; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.net.HttpURLConnection; import java.util.Map; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.serializer.SerializerFeature; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @ControllerAdvice public class BaseExpectionAdvice { protected final static String code_ok = "1"; protected final static String code_fail = "0"; /** * 异常页面控制 * BaseException是我们自定义的异常,此方法意为捕获我们抛出的Base异常并进行统一跳转处理
* 如果想捕获全局异常将BaseException.class改成Exception.class就可以了 * @param * @return */ @ExceptionHandler(BaseException.class) public String ExceptionHandler(Exception ex, HttpServletResponse resp) { // ex.printStackTrace(); if (ex.getMessage() != null) { System.out.println(ex.getMessage()); // logger.error(ex.toString() + "-" + ex.getMessage()); } if (ex instanceof BaseException) { BaseException be = (BaseException) ex; render(be.getCode(), be.getMessage(), null, resp, HttpURLConnection.HTTP_OK); } else { render(code_fail, "系统错误!" + (ex.getMessage() != null ? ex.getMessage() : ""), null, resp, HttpURLConnection.HTTP_BAD_REQUEST); } return null; } public void render(String code, String message, Map<String, Object> dataMap, HttpServletResponse resp, Integer httpStatus) { Map<String, Object> jsonmap = Maps.newHashMap(); jsonmap.put("code", code); jsonmap.put("msg", message); if (dataMap != null) { jsonmap.put("data", dataMap); } String jsonStr = JSON.toJSONString(jsonmap, SerializerFeature.WriteMapNullValue); jsonStr = jsonStr.replaceAll("null", "\"\""); PrintWriter writer = null; try { resp.setHeader("Access-Control-Allow-Origin","*"); resp.setContentType("application/json;charset=utf-8"); // resp.setContentType("text/html;charset=utf-8"); resp.setCharacterEncoding("UTF-8"); resp.setStatus(httpStatus == null ? HttpURLConnection.HTTP_OK: httpStatus); writer = resp.getWriter(); writer.write(jsonStr); } catch (IOException e) { e.printStackTrace(); // logger.error(e.getMessage()); } finally { writer.flush(); writer.close(); } } public void render(String code, String message, Map<String, Object> dataMap, HttpServletResponse resp) { render(code, message, dataMap, resp, null); } }
BaseException的代码
public class BaseException extends RuntimeException { private String code; private String message; public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public BaseException() { super(); } public BaseException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } public BaseException(String message, Throwable cause) { super(message, cause); } public BaseException(String message) { super(message); } public BaseException(Throwable cause) { super(cause); } public BaseException(String message, String code) { super(message); this.message = message; this.code = code; } }
异常捕获后抛异常的代码:
try{ ... }catch(Exception ex){ throw new BaseException("自定义异常信息","自定义异常状态码") }