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

背景

  在传统的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. 【网络爬虫入门02】HTTP客户端库Requests的基本原理与基础应用

    [网络爬虫入门02]HTTP客户端库Requests的基本原理与基础应用 广东职业技术学院  欧浩源 1.引言 实现网络爬虫的第一步就是要建立网络连接并向服务器或网页等网络资源发起请求.urllib是 ...

  2. Python通过future处理并发

    future初识 通过下面脚本来对future进行一个初步了解:例子1:普通通过循环的方式 import os import time import sys import requests POP20 ...

  3. 完整Log4Net配置信息

    <?xml version="1.0" encoding="utf-8" ?> <configuration> <configSe ...

  4. win10 uwp 使用 Geometry resources 在 xaml

    经常会遇到在 xaml 使用矢量图,对于 svg 的矢量图,一般都可以拿出来写在 Path 的 Data ,所以可以写为资源,但是写出来的是字符串,如何绑定 Geometry 到字符串资源? 假如在资 ...

  5. Visual Studio中让一个JS文件智能提示另一个JS文件中的成员

    当一个Web页面引用了两个JS文件(假如分别叫common.js和JScript1.js),如果JScript1.js中需要调用大量的common.js中的方法,这时候在JScript1.js中智能提 ...

  6. Integrates Git with Sublime 3 to pull or push to Github by using Sublime plugin Git

    1. Git must be installed, Sublime plugin "Git" only connects Sublime with Git. Download UR ...

  7. LeetCode 455. Assign Cookies (分发曲奇饼干)

    Assume you are an awesome parent and want to give your children some cookies. But, you should give e ...

  8. OpenCV Image Watch 调试插件

    昨晚偶然发现vs2012的这个很神奇的插件,对于经常使用opencv的人来说,这个插件无疑是我们的调试神器.今天马上下载试用,感觉超级棒!~以后要想查看图像结果,不用再imshow了! Image W ...

  9. 数据结构--KMP算法总结

    数据结构—KMP KMP算法用于解决两个字符串匹配的问题,但更多的时候用到的是next数组的含义,用到next数组的时候,大多是题目跟前后缀有关的 . 首先介绍KMP算法:(假定next数组已经学会, ...

  10. 通过xinetd服务管理 rsync 实现开机自启动

    1.1 xinetd服务配置 1.1.1 检查xinetd服务是否安装 [root@backup ~]# rpm -qa xinetd [root@backup ~]# rpm -ql xinetd ...