为什么使用session

HTTP协议是无状态的,每次客户端想要与服务端通信,都必须与服务端连接,这意味着:请求一次,客户端与服务端就连接一次,下一次的请求与上一次的请求没有关系。这种无状态的方式存在一个问题:怎么判断两次请求是同一个人?因此,需要通过session、cookie或token方式,进行用户判断、鉴权。

session引入方案

session保存在服务器端,如果同一时间太多用户访问服务器,则会导致服务器端的内存压力过大。 因此,我们考虑使用redis,redis用于缓存用户的session,后期redis也可以通过主从模式进行拓展。 此外,我们采用sessionId,前后端的请求通过该字段来识别用户的身份。

具体地,我们会在filter中截取用户的请求数据,在interceptor中判断用户的身份,并保存对应数据在request中,方便后续的后端操作。 其中,filter中截取用户数据可以参考https://blog.nowcoder.net/n/8dc8fb4c78994d30a6439125d1c78001。

拦截器改造

在interceptor中,我们会使用2个拦截器,其中一个拦截器是获取数据,解析数据成JSONObject对象,另一个拦截器则会判定用户的登录情况。

引入JsonInterceptor

具体地,在JsonInterceptor中,也就是第一个拦截器中,我们通过fastJson对前端请求的数据进行解析,再存入到request中。具体代码如下:

public class JsonInterceptor implements HandlerInterceptor {

    /**
     * 读取请求
     * @param request current HTTP request
     * @param response current HTTP response
     * @param handler chosen handler to execute, for type and/or instance evaluation
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String body = ((RequestWrapper) request).getBody();
        JSONObject jsonObject;
        try {
            // 前后端交互要求为json字符串
            jsonObject = JSONObject.parseObject(body);
        }catch (Exception e){
            throw new AppTransException(ErrorEnum.REQUEST_JSON_ERROR);
        }
        // 获取header
        // 获取交互数据的userSessionId字段,并在request中进行设置,若无,则为""
        JSONObject headerParams = jsonObject.getJSONObject(RequestConstant.HEADER);
        headerParams = Optional.ofNullable(headerParams).orElse(new JSONObject());
        Object userSessionId = headerParams.getOrDefault(RequestConstant.USER_SESSION_ID, "");
        // 设置的userSessionId属性在LoginInterceptor中使用
        request.setAttribute(RequestConstant.USER_SESSION_ID, userSessionId);
        // 获取body,body的数据用于dto的赋值使用
        JSONObject bodyParams = jsonObject.getJSONObject(RequestConstant.BODY);
        bodyParams = Optional.ofNullable(bodyParams).orElse(new JSONObject());
        request.setAttribute(RequestConstant.BODY, bodyParams);
        return true;
    }
}

引入LoginInterceptor

在第二个拦截器中,我们会对请求数据的userSessionId进行解析,判断这属于无状态的请求、有状态的请求,再执行相关的代码逻辑。

其中,无状态的请求(不需要用户登录也可完成)则会直接返回true,有状态的请求包含两种,(1)登录请求;(2)非登录请求。对于登录请求,我们会新生成一个userSessionId,而非登录请求,我们会判断session是否仍在有效期内,若不在有效期内,则会抛用户登录已过期的相关错误。 具体的代码如下:

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

    /**
     * 存储NO_SESSION的URL,这些并不需要通过校验
     */
    private static final Set<String> NO_SESSION_URI = new ConcurrentSkipListSet<>();

    static {
        NO_SESSION_URI.add("/upload/img");
        NO_SESSION_URI.add("/upload/file");
    }

    /**
     * 对未登录的请求进行阻断,
     * 非session请求可以通过
     * 登录请求会设置userSessionId
     * 非登录请求会刷新userSessionId
     * @param request current HTTP request
     * @param response current HTTP response
     * @param handler chosen handler to execute, for type and/or instance evaluation
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        // 判断是否为NO_SESSION的请求路径,是的话直接返回true
        if(isNoSessionRequest(requestURI)){
            return true;
        }
        // 判断是否为NEW_SESSION的请求路径,如果是,则需要赋值新的userSessionId,以及设置新的sessionCtx,然后返回true
        if(isLoginRequest(requestURI)){
            String userSessionId = RequestUtils.genNewUserSessionId(RequestConstant.SESSION_ID_LENGTH, RequestConstant.USER_SESSION_ID + "_");
            // 设置context,该context用于储存数据
            SessionCtx ctx = new SessionCtx();
            request.setAttribute(RequestConstant.USER_SESSION_ID, userSessionId);
            request.setAttribute(RequestConstant.SESSION_CONTEXT, ctx);
            RedisUtils.setValueByKeyExpired(userSessionId, RedisPrefixEnum.SESSION_PREFIX, RequestConstant.SESSION_TIMEOUT, ctx);
            return true;
        }
        // 判断当前的登录时间是否过期,未过期则重新赋值登录时间,然后返回true,否则直接返回false
        String userSessionId = request.getAttribute(RequestConstant.USER_SESSION_ID).toString();
        // 如果没有userSessionId,则抛session不存在异常
        if(StringUtil.isEmpty(userSessionId)){
            throw new AppTransException(ErrorEnum.SESSION_TIMEOUT, "用户未登录,请重试");
        }
        SessionCtx ctx = RedisUtils.getValueByKey(userSessionId, RedisPrefixEnum.SESSION_PREFIX, SessionCtx.class);
        // ctx不存在,则表明登录已过期
        if(null == ctx){
            throw new AppTransException(ErrorEnum.SESSION_TIMEOUT);
        }
        request.setAttribute(RequestConstant.SESSION_CONTEXT, ctx);
        // 发起请求后,重新设置过期时间
        RedisUtils.setValueByKeyExpired(userSessionId, RedisPrefixEnum.SESSION_PREFIX, RequestConstant.SESSION_TIMEOUT, ctx);
        return true;
    }

    /**
     * 判断是否为登录请求
     * @param requestURI
     * @return
     */
    private boolean isLoginRequest(String requestURI){
        return RequestUrlEnum.LOGIN_REQUEST.getValue().equals(requestURI);
    }

    /**
     * 判断是否为noSession请求,这些请求并不需要userSessionId
     * @param requestURI
     * @return
     */
    private boolean isNoSessionRequest(String requestURI){
        return requestURI != null && NO_SESSION_URI.contains(requestURI);
    }
}

在这里,我们使用sessionCtx来保存每一个用户在服务器端存储的数据,并存储到redis中,由redis进行管理。

具体的效果: alt

在登录后,我们会将userSessionId发送给前端。前端在获取userSessionId后,之后的请求则会夹带这个userSessionId,来表明是由该用户发起的请求。

总结

我们使用session充当用户的交互数据。此外,我们引用redis,将session数据存储在redis中,方便后期的交互、使用。

参考网址

Cookie、Session、Token究竟区别在哪?如何进行身份认证,保持用户登录状态?