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

背景

  在传统的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="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是透明的。

 

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管理器对于登陆管理器的应用

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弊端相当大。大家看看就好,不是我写的

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后端会话信息管理的更多相关文章

  1. 从Spring-Session源码看Session机制的实现细节

    Re:从零开始的Spring Session(一) Re:从零开始的Spring Session(二) Re:从零开始的Spring Session(三) 去年我曾经写过几篇和 Spring Sess ...

  2. app后端设计(12)--图片的处理

    app上线后,不断接受用户的反馈,于是,反馈非常差的情况下,都会有app的改版. 一旦app的改版,都会有比较大的UI改动,一改动UI,那么图片的尺寸也就必须要改变. 在app后端设计(1)—api( ...

  3. Session机制详解

    转自:http://justsee.iteye.com/blog/1570652 虽然session机制在web应用程序中被采用已经很长时间了,但是仍然有很多人不清楚session机制的本质,以至不能 ...

  4. app 后端技术

    app 后端技术 一直以来工作的方向是web server,对app server没有什么了解.虽然没有接触过移动app开发,但对app后端技术还是挺有探索欲望的,app应用和web应用在前端的用户习 ...

  5. node模拟http服务器session机制-我们到底能走多远系列(36)

    我们到底能走多远系列(36) 扯淡: 年关将至,总是会在一些时间节点上才感觉时光飞逝,在平时浑浑噩噩的岁月里都浪费掉了太多的宝贵.请珍惜! 主题:      我们在编写http请求处理和响应的代码的时 ...

  6. **app后端设计(10)--数据增量更新(省流量)

    在新浪微博的app中,从别的页面进入主页,在没有网络的情况下,首页中的已经收到的微博还是能显示的,这显然是把相关的数据存储在app本地. 使用数据的app本地存储,能减少网络的流量,同时极大提高了用户 ...

  7. 理解session机制

    理解session机制 session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息. 当程序需要为某个客户端的请求创建一个session的时候,服务器首 ...

  8. app后端设计(10)--数据增量更新

    在新浪微博的app中,从别的页面进入主页,在没有网络的情况下,首页中的已经收到的微博还是能显示的,这显然是把相关的数据存储在app本地. 使用数据的app本地存储,能减少网络的流量,同时极大提高了用户 ...

  9. app后端设计(9)-- 动态通知

    在app中,例如在通知界面,当新通知的时候,需要显示有多少条通知,用户点击"获取新通知"后,就能看到新的通知. 那么在app端,怎么才能知道有多少条新通知? 实现的技术有两种: 1 ...

随机推荐

  1. 索引节点inode详解

    Inode(index node),索引节点.Linux系统中,分区要进行格式化,创建文件系统.在每个Linux存储设备或存储设备的分区(可以是硬盘,软盘,U盘等)被格式化为ext3文件系统后,一般分 ...

  2. jQuery和AngularJS的区别

    这篇文章主要介绍了jQuery和AngularJS的区别浅析,本文着重讲解一个熟悉jQuery开的程序员如何应对AngularJS中的一些编程思想的转变,需要的朋友可以参考下   最近一直在研究ang ...

  3. wpf GifBitmapDecoder 解析 gif 格式

    在网上有很多图片都是gif,那么如何在 wpf 解析 gif? 本文告诉大家如何使用 GifBitmapDecoder 把gif分开为一张一张,获得他的信息. 如果需要把一个 gif 分开,使用的代码 ...

  4. 不使用数据结构反转栈 递归 CVTE实习 CVTE是一家什么公司

    本文因为垃圾csdn标题字限制,标题写不好.本文想说一个算法,和我在CVTE的实习,我看到CVTE是一家什么公司.如果想要喷我的,可以留言,我不会理.如果想喷公司,可以在博客评论或发到我邮件linde ...

  5. PyCharm 2017 免费 破解 注册 激活 教程(附 License Server 地址)(Python 编辑器 IDE 推荐)

    许多朋友都在问如何破解 PyCharm 2017 Professional 专业版,咪博士对此是坚决反对的! 不到万不得已,请不要这样做.破解之前,请拖到文章末尾,思考几个问题,想明白你确实需要这样做 ...

  6. django中使用sha1,md5加密

    # salt 盐 使用sha1加密算法,返回str加密后的字符串 # 提高字符串的复杂的 from hashlib import sha1 def get_hash(str, salt=None): ...

  7. 使用ftp软件上传下载php文件时换行丢失bug(全部变为一行)

    文章来源:http://www.piaoyi.org/computer/ftp-php-r-n-bug.html 正 文: 在使用ftp软件上传下载php源文件时,我们偶尔会发现在本地windows下 ...

  8. Linux.根据进程名关键字杀进程

    先看例子, 假设系统中有以下2个进程 USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND root     ...

  9. PHP设计模式一:工厂方法设计模式

    一.什么是工厂方法模式 作为一种创建型设计模式,工厂方法模式就是要创建“某种东西”.对于工厂方法,要创建的“东西”是一个产品,这个产品与创建它的类之间不存在绑定. 实际上,为了保持这种松耦合,客户会通 ...

  10. Linux系统EXT文件系统

    分区格式化(Linux创建文件系统):(假设需要格式化的分区为/dev/sdb1) 1. ext2文件系统和ext3文件系统的区别: ext2不支持日志文件系统,不会产生日志文件,ext3则会产生日志 ...