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

从来好事天生俭,自古瓜儿苦后甜。这篇文章主要讲述模仿J2EE的session机制的App后端会话信息管理相关的知识,希望能为你提供帮助。
此文章只将思想,不提供具体完整实现(博主太懒,懒得整理),有疑问或想了解的可以私信或评论
背景
在传统的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中存放业务信息,比如登录的用户名和密码等。

{ "token": "客户端token", /**客户端构建版本号*/ "version": 11, /**客户端平台类型*/ "platform": "ios", /**客户端设备型号*/ "machineModel": "Iphone 6s", "imei": "客户端串号(手机)", /**真正的消息体,应为map*/ "body": { "key1": "value1", "key2": { "key21": "value21" }, "key3": [ 1, 2 ] } }

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

二、解析协议处理token字符串。
服务端的mvc框架我们选用的是SpringMVC框架,SpringMVC也比较普遍,不做描述。

暂且不提token的处理,先解决制定报文后怎么做参数传递。
因为请求信息被做了封装,所以要让springmvc框架能正确注入我们在Controller需要的参数,就需要对报文做解析和转换。
要对请求信息做解析,我们需要自定义springmvc的参数转换器,通过实现HandlerMethodArgumentResolver接口可以定义一个参数转换器
RequestBodyResolver实现resolveArgument方法,对参数进行注入,以下代码为示例代码,切勿拿来直用。
 
@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>
< 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拦截器将每次请求都拦截下来,这属于常用功能,不做细节描述
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
 
< !-- 缓存管理器全局变量等可以用它存取--> < bean id="cacheManager" class="org.springframework.data.redis.cache.RedisCacheManager"> < constructor-arg> < ref bean="redisTemplate"/> < /constructor-arg> < property name="usePrefix" value="https://www.songbingjia.com/android/true" /> < property name="cachePrefix"> < bean class="org.springframework.data.redis.cache.DefaultRedisCachePrefix"> < constructor-arg name="delimiter" value="https://www.songbingjia.com/android/:@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是透明的。

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以及相关信息调用处理类 *修 改 记 录: *@versionV1.0 *@date2016年4月22日 *@authorNiuXZ * */ 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 = https://www.songbingjia.com/android/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="https://www.songbingjia.com/android/+ 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管理器对于登陆管理器的应用
 
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 *描述:登录管理器 *修 改 记 录: *@versionV1.0 *@date2016年7月19日 *@authorNiuXZ * */ 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弊端相当大。大家看看就好,不是我写的
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); }


处理响应
有的同学会问了 那么响应的报文封装呢?
 
@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。

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后端会话信息管理】 






    推荐阅读