模仿J2EE的session机制的App后端会话信息管理实例

时间:2021-12-15 04:10:54

此文章只将思想,不提供具体完整实现(博主太懒,懒得整理),有疑问或想了解的可以私信或评论

背景

在传统的java web 中小型项目中,一般使用session暂存会话信息,比如登录者的身份信息等。此机制是借用http的cookie机制实现,但是对于app来说每次请求都保存并共享cookie信息比较麻烦,并且传统的session对集群并不友好,所以一般app后端服务都使用token来区分用户登录信息。

j2ee的session机制大家都很了解,使用非常方便,在传统java web应用中很好用,但是在互联网项目中或用得到集群的一些项目就有些问题,比如序列化问题,同步的延时问题等等,所以我们需要一个使用起来类似session的却能解决得了集群等问题的一个工具。

方案

我们使用cache机制来解决这个问题,比较流行的redis是个nosql内存数据库,而且带有cache的失效机制,很适合做会话数据的存储。而token字符串需要在第一次请求时服务器返回给客户端,客户端以后每次请求都使用这个token标识身份。为了对业务开发透明,我们把app的请求和响应做的报文封装,只需要对客户端的http请求工具类做点手脚,对服务端的mvc框架做点手脚就可以了,客户端的http工具类修改很简单,主要是服务端的协议封装。

实现思路

一、制定请求响应报文协议。

二、解析协议处理token字符串。

三、使用redis存储管理token以及对应的会话信息。

四、提供保存、获取会话信息的API。

我们逐步讲解下每一步的实现方案。

一、制定请求响应报文协议。

既然要封装报文协议,就需要考虑什么是公共字段,什么是业务字段,报文的数据结构等。

请求的公共字段一般有token、版本、平台、机型、imei、app来源等,其中token是我们这次的主角。

响应的公共字段一般有token、结果状态(success,fail)、结果码(code)、结果信息等。

报文数据结构,我们选用json,原因是json普遍、可视化好、字节占用低。

请求报文如下,body中存放业务信息,比如登录的用户名和密码等。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "token": "客户端token",
  /**客户端构建版本号*/
  "version": 11,
  /**客户端平台类型*/
  "platform": "IOS",
  /**客户端设备型号*/
  "machineModel": "Iphone 6s",
  "imei": "客户端串号(手机)",
  /**真正的消息体,应为map*/
  "body": {
    "key1": "value1",
    "key2": {
      "key21": "value21"
    },
    "key3": [
      1,
 
    ]
  }
}

响应的报文

?
1
2
3
4
5
6
7
8
9
10
11
12
13
{
    /**是否成功*/
    "success": false,
    /**每个请求都会返回token,客户端每次请求都应使用最新的token*/
    "token": "服务器为当前请求选择的token",
    /**失败码*/
    "failCode": 1,
    /**业务消息或者失败消息*/
    "msg": "未知原因",
    /**返回的真实业务数据,可为任意可序列化的对象*/
    "body": null
  }
}

二、解析协议处理token字符串。

服务端的mvc框架我们选用的是SpringMVC框架,SpringMVC也比较普遍,不做描述。

暂且不提token的处理,先解决制定报文后怎么做参数传递。

因为请求信息被做了封装,所以要让springmvc框架能正确注入我们在Controller需要的参数,就需要对报文做解析和转换。

要对请求信息做解析,我们需要自定义springmvc的参数转换器,通过实现HandlerMethodArgumentResolver接口可以定义一个参数转换器

RequestBodyResolver实现resolveArgument方法,对参数进行注入,以下代码为示例代码,切勿拿来直用。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
  public Object resolveArgument(MethodParameter parameter,
      ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) throws Exception {
    String requestBodyStr = webRequest.getParameter(requestBodyParamName);//获取请求报文,可以使用任意方式传递报文,只要在这获取到就可以
    if(StringUtils.isNotBlank(requestBodyStr)){
      String paramName = parameter.getParameterName();//获取Controller中参数名
      Class<?> paramClass = parameter.getParameterType();//获取Controller中参数类型
      /* 通过json工具类解析报文 */
      JsonNode jsonNode = objectMapper.readTree(requestBodyStr);
      if(paramClass.equals(ServiceRequest.class)){//ServiceRequest为请求报文对应的VO
        ServiceRequest serviceRequest = objectMapper.readValue(jsonNode.traverse(),ServiceRequest.class);
        return serviceRequest;//返回这个object就是注入到参数中了,一定要对应类型,否则异常不容易捕获
      }
      if(jsonNode!=null){//从报文中查找Controller中需要的参数
        JsonNode paramJsonNode = jsonNode.findValue(paramName);
        if(paramJsonNode!=null){
          return objectMapper.readValue(paramJsonNode.traverse(), paramClass);
        }
        
      }
    }
    return null;
  }

将自己定义的参数转换器配置到SrpingMVC的配置文件中<mvc:argument-resolvers>

?
1
2
3
4
5
6
7
8
<mvc:argument-resolvers>
  <!-- 统一的请求信息处理,从ServiceRequest中取数据 -->
     <bean id="requestBodyResolver" class="com.niuxz.resolver.RequestBodyResolver">
       <property name="objectMapper"><bean class="com.shoujinwang.utils.json.ObjectMapper"></bean></property>
       <!-- 配置请求中ServiceRequest对应的字段名,默认为requestBody -->
       <property name="requestBodyParamName"><value>requestBody</value></property>
     </bean>
</mvc:argument-resolvers>

这样就可以使报文中的参数能被springmvc正确识别了。

接下来我们要对token做处理了,我们需要添加一个SrpingMVC拦截器将每次请求都拦截下来,这属于常用功能,不做细节描述

?
1
2
3
4
5
6
Matcher m1 =Pattern.compile("\"token\":\"(.*?)\"").matcher(requestBodyStr);
  
if(m1.find()){
  token = m1.group(1);
}
tokenMapPool.verifyToken(token);//对token做公共处理,验证

这样就简单的获取到了token了,可以做公共处理了。

三、使用redis存储管理token以及对应的会话信息。

其实就是写一个redis的操作工具类,因为使用了spring作为项目主框架,而且我们用到redis的功能并不多,所以直接使用spring提供的CacheManager功能

配置org.springframework.data.redis.cache.RedisCacheManager

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 缓存管理器 全局变量等可以用它存取-->
<bean id="cacheManager" class="org.springframework.data.redis.cache.RedisCacheManager">
  <constructor-arg>
    <ref bean="redisTemplate"/>
  </constructor-arg>
  <property name="usePrefix" value="true" />
  <property name="cachePrefix">
    <bean class="org.springframework.data.redis.cache.DefaultRedisCachePrefix">
      <constructor-arg name="delimiter" value=":@WebServiceInterface"/>
    </bean>
  </property>
  <property name="expires"><!-- 缓存有效期 -->
    <map>
      <entry>
        <key><value>tokenPoolCache</value></key><!-- tokenPool缓存名 -->
        <value>2592000</value><!-- 有效时间 -->
      </entry>
    </map>
  </property>
</bean>

四、提供保存、获取会话信息的API。

通过以上前戏我们已经把token处理的差不多了,接下来我们要实现token管理工作了

我们需要让业务开发方便的保存获取会话信息,还要使token是透明的。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import java.util.HashMap;
import java.util.Map;
 
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.Cache.ValueWrapper;
import org.springframework.cache.CacheManager;
 
/**
 *
 * 类      名:  TokenMapPoolBean
 * 描      述:  token以及相关信息调用处理类
 * 修 改 记 录: 
 * @version  V1.0
 * @date  2016年4月22日
 * @author  NiuXZ
 *
 */
public class TokenMapPoolBean {
  
  
  private static final Log log = LogFactory.getLog(TokenMapPoolBean.class);
  
  /** 当前请求对应的token*/
  private ThreadLocal<String> currentToken;
  
  private CacheManager cacheManager;
  
  private String cacheName;
  
  private TokenGenerator tokenGenerator;
  
  public TokenMapPoolBean(CacheManager cacheManager, String cacheName, TokenGenerator tokenGenerator) {
    this.cacheManager = cacheManager;
    this.cacheName = cacheName;
    this.tokenGenerator = tokenGenerator;
    currentToken = new ThreadLocal<String>();
  }
  
  /**
   * 如果token合法就返回token,不合法就创建一个新的token并返回,
   * 将token放入ThreadLocal中 并初始化一个tokenMap
   * @param token
   * @return token
   */
  public String verifyToken(String token) {
    //    log.info("校验Token:\""+token+"\"");
    String verifyedToken = null;
    if (tokenGenerator.checkTokenFormat(token)) {
      //      log.info("校验Token成功:\""+token+"\"");
      verifyedToken = token;
    }
    else {
      verifyedToken = newToken();
    }
    currentToken.set(verifyedToken);
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      throw new RuntimeException("获取不到存放token的缓存池,chacheName:" + cacheName);
    }
    ValueWrapper value = cache.get(verifyedToken);
    //token对应的值为空,就创建一个新的tokenMap放入缓存中
    if (value == null || value.get() == null) {
      verifyedToken = newToken();
      currentToken.set(verifyedToken);
      Map<String, Object> tokenMap = new HashMap<String, Object>();
      cache.put(verifyedToken, tokenMap);
    }
    return verifyedToken;
  }
  
  /**
   * 生成新的token
   * @return token
   */
  private String newToken() {
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      throw new RuntimeException("获取不到存放token的缓存池,chacheName:" + cacheName);
    }
    String newToken = null;
    int count = 0;
    do {
      count++;
      newToken = tokenGenerator.generatorToken();
    }
    while (cache.get(newToken) != null);
    //    log.info("创建Token成功:\""+newToken+"\" 尝试生成:"+count+"次");
    return newToken;
  }
  
  /**
   * 获取当前请求的tokenMap中对应key的对象
   * @param key
   * @return 当前请求的tokenMap中对应key的属性,模拟session
   */
  public Object getAttribute(String key) {
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      throw new RuntimeException("获取不到存放token的缓存池,chacheName:" + cacheName);
    }
    ValueWrapper tokenMapWrapper = cache.get(currentToken.get());
    Map<String, Object> tokenMap = null;
    if (tokenMapWrapper != null) {
      tokenMap = (Map<String, Object>) tokenMapWrapper.get();
    }
    if (tokenMap == null) {
      verifyToken(currentToken.get());
      tokenMapWrapper = cache.get(currentToken.get());
      tokenMap = (Map<String, Object>) tokenMapWrapper.get();
    }
    return tokenMap.get(key);
  }
  
  /**
   * 设置到当前请求的tokenMap中,模拟session<br>
   * TODO:此种方式设置attribute有问题:<br>
   * 1、可能在同一token并发的情况下执行cache.put(currentToken.get(),tokenMap);时,<br>
   *   tokenMap可能不是最新,会导致丢失数据。<br>
   * 2、每次都put整个tokenMap,数据量太大,需要优化<br>
   * @param key value
   */
  public void setAttribute(String key, Object value) {
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      throw new RuntimeException("获取不到存放token的缓存池,chacheName:" + cacheName);
    }
    ValueWrapper tokenMapWrapper = cache.get(currentToken.get());
    Map<String, Object> tokenMap = null;
    if (tokenMapWrapper != null) {
      tokenMap = (Map<String, Object>) tokenMapWrapper.get();
    }
    if (tokenMap == null) {
      verifyToken(currentToken.get());
      tokenMapWrapper = cache.get(currentToken.get());
      tokenMap = (Map<String, Object>) tokenMapWrapper.get();
    }
    log.info("TokenMap.put(key=" + key + ",value=" + value + ")");
    tokenMap.put(key, value);
    cache.put(currentToken.get(), tokenMap);
  }
  
  /**
   * 获取当前线程绑定的用户token
   * @return token
   */
  public String getToken() {
    if (currentToken.get() == null) {
      //初始化一次token
      verifyToken(null);
    }
    return currentToken.get();
  }
  
  /**
   * 删除token以及tokenMap
   * @param token
   */
  public void removeTokenMap(String token) {
    if (token == null) {
      return;
    }
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      throw new RuntimeException("获取不到存放token的缓存池,chacheName:" + cacheName);
    }
    log.info("删除Token:token=" + token);
    cache.evict(token);
  }
  
  public CacheManager getCacheManager() {
    return cacheManager;
  }
  
  public void setCacheManager(CacheManager cacheManager) {
    this.cacheManager = cacheManager;
  }
  
  public String getCacheName() {
    return cacheName;
  }
  
  public void setCacheName(String cacheName) {
    this.cacheName = cacheName;
  }
  
  public TokenGenerator getTokenGenerator() {
    return tokenGenerator;
  }
  
  public void setTokenGenerator(TokenGenerator tokenGenerator) {
    this.tokenGenerator = tokenGenerator;
  }
  
  public void clear() {
    currentToken.remove();
  }
  
}

这里用到了ThreadLocal变量是因为servlet容器一个请求对应一个线程,在一个请求的生命周期内都是处于同一个线程中,而同时又有多个线程共享token管理器,所以需要这个线程本地变量来保存token字符串。

注意事项:

1、verifyToken方法的调用,一定要在每次请求最开始调用。并且在请求结束后调用clear做清除,以免下次有未知异常导致verifyToken未被执行,却在返回时从ThreadLocal里取出token返回。(这个bug困扰我好几天,公司n个开发检查代码也没找到,最后我经过测试发现是在发生404的时候没有进入拦截器,所以就没有调用verifyToken方法,导致返回的异常信息中的token为上一次请求的token,导致诡异的串号问题。嗯,记我一大锅)。

2、客户端一定要在封装http工具的时候把每次token保存下来,并用于下一次请求。公司ios开发请的外包,但是外包没按要求做,在未登录时,不保存token,每次传递的都是null,导致每次请求都会创建一个token,服务器创建了大量的无用token。

使用

使用方式也很简单,以下是封装的登录管理器,可以参考一下token管理器对于登陆管理器的应用

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.Cache.ValueWrapper;
import org.springframework.cache.CacheManager;
 
import com.niuxz.base.Constants;
 
/**
 *
 * 类      名:  LoginManager
 * 描      述:  登录管理器
 * 修 改 记 录: 
 * @version  V1.0
 * @date  2016年7月19日
 * @author  NiuXZ
 *
 */
public class LoginManager {
  
  
  private static final Log log = LogFactory.getLog(LoginManager.class);
  
  private CacheManager cacheManager;
  
  private String cacheName;
  
  private TokenMapPoolBean tokenMapPool;
  
  public LoginManager(CacheManager cacheManager, String cacheName, TokenMapPoolBean tokenMapPool) {
    this.cacheManager = cacheManager;
    this.cacheName = cacheName;
    this.tokenMapPool = tokenMapPool;
  }
  public void login(String userId) {
    log.info("用户登录:userId=" + userId);
    Cache cache = cacheManager.getCache(cacheName);
    ValueWrapper valueWrapper = cache.get(userId);
    String token = (String) (valueWrapper == null ? null : valueWrapper.get());
    tokenMapPool.removeTokenMap(token);//退出之前登录记录
    tokenMapPool.setAttribute(Constants.LOGGED_USER_ID, userId);
    cache.put(userId, tokenMapPool.getToken());
  }
  
  public void logoutCurrent(String phoneTel) {
    String curUserId = getCurrentUserId();
    log.info("用户退出:userId=" + curUserId);
    tokenMapPool.removeTokenMap(tokenMapPool.getToken());//退出登录
    if (curUserId != null) {
      Cache cache = cacheManager.getCache(cacheName);
      cache.evict(curUserId);
      cache.evict(phoneTel);
    }
  }
  
  /**
   * 获取当前用户的id
   * @return
   */
  public String getCurrentUserId() {
    return (String) tokenMapPool.getAttribute(Constants.LOGGED_USER_ID);
  }
  
  public CacheManager getCacheManager() {
    return cacheManager;
  }
  
  public String getCacheName() {
    return cacheName;
  }
  
  public TokenMapPoolBean getTokenMapPool() {
    return tokenMapPool;
  }
  
  public void setCacheManager(CacheManager cacheManager) {
    this.cacheManager = cacheManager;
  }
  
  public void setCacheName(String cacheName) {
    this.cacheName = cacheName;
  }
  
  public void setTokenMapPool(TokenMapPoolBean tokenMapPool) {
    this.tokenMapPool = tokenMapPool;
  }
  
}

下面是一段常见的发送短信验证码接口,有的应用也是用session存储验证码,我不建议用这种方式,存session弊端相当大。大家看看就好,不是我写的

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void sendValiCodeByPhoneNum(String phoneNum, String hintMsg, String logSuffix) {
    validatePhoneTimeSpace();
    // 获取6位随机数
    String code = CodeUtil.getValidateCode();
    log.info(code + "------->" + phoneNum);
    // 调用短信验证码下发接口
    RetStatus retStatus = msgSendUtils.sendSms(code + hintMsg, phoneNum);
    if (!retStatus.getIsOk()) {
      log.info(retStatus.toString());
      throw new ThrowsToDataException(ServiceResponseCode.FAIL_INVALID_PARAMS, "手机验证码获取失败,请稍后再试");
    }
    // 重置session
    tokenMapPool.setAttribute(Constants.VALIDATE_PHONE, phoneNum);
    tokenMapPool.setAttribute(Constants.VALIDATE_PHONE_CODE, code.toString());
    tokenMapPool.setAttribute(Constants.SEND_CODE_WRONGNU, 0);
    tokenMapPool.setAttribute(Constants.SEND_CODE_TIME, new Date().getTime());
    log.info(logSuffix + phoneNum + "短信验证码:" + code);
  }

处理响应

有的同学会问了 那么响应的报文封装呢?

?
1
2
3
4
5
6
7
@RequestMapping("record")
@ResponseBody
public ServiceResponse record(String message){
  String userId = loginManager.getCurrentUserId();
  messageBoardService.recordMessage(userId, message);
  return ServiceResponseBuilder.buildSuccess(null);
}

其中ServiceResponse是封装的响应报文VO,我们直接使用springmvc的@ResponseBody注解就好了。关键在于这个builder。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import org.apache.commons.lang3.StringUtils;
 
import com.niuxz.base.pojo.ServiceResponse;
import com.niuxz.utils.spring.SpringContextUtil;
import com.niuxz.web.server.token.TokenMapPoolBean;
 
/**
 *
 * 类 名: ServiceResponseBuilder
 *
 * @version V1.0
 * @date 2016年4月25日
 * @author NiuXZ
 *
 */
public class ServiceResponseBuilder {
 
  /**
   * 构建一个成功的响应信息
   *
   * @param body
   * @return 一个操作成功的 ServiceResponse
   */
  public static ServiceResponse buildSuccess(Object body) {
    return new ServiceResponse(
        ((TokenMapPoolBean) SpringContextUtil.getBean("tokenMapPool"))
            .getToken(),
        "操作成功", body);
  }
 
  /**
   * 构建一个成功的响应信息
   *
   * @param body
   * @return 一个操作成功的 ServiceResponse
   */
  public static ServiceResponse buildSuccess(String token, Object body) {
    return new ServiceResponse(token, "操作成功", body);
  }
 
  /**
   * 构建一个失败的响应信息
   *
   * @param failCode
   *      msg
   * @return 一个操作失败的 ServiceResponse
   */
  public static ServiceResponse buildFail(int failCode, String msg) {
    return buildFail(failCode, msg, null);
  }
 
  /**
   * 构建一个失败的响应信息
   *
   * @param failCode
   *      msg body
   * @return 一个操作失败的 ServiceResponse
   */
  public static ServiceResponse buildFail(int failCode, String msg,
      Object body) {
    return new ServiceResponse(
        ((TokenMapPoolBean) SpringContextUtil.getBean("tokenMapPool"))
            .getToken(),
        failCode, StringUtils.isNotBlank(msg) ? msg : "操作失败", body);
  }
}

由于使用的是静态工具类的形式,不能通过spring注入tokenMapPool(token管理器)对象,则通过spring提供的api获取。然后构建响应信息的时候直接调用tokenMapPool的getToken()方法,此方法会返回当前线程绑定的token字符串。再次强调在请求结束后一定要手动调用clear(我通过全局拦截器调用)。

以上这篇模仿J2EE的session机制的App后端会话信息管理实例就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持服务器之家。

原文链接:http://www.cnblogs.com/niuxiaozu/archive/2017/11/23/7886600.html