1. 程式人生 > >模仿J2EE的session機制的App後端會話信息管理

模仿J2EE的session機制的App後端會話信息管理

序列化 相關 || redis 字節 param div 模仿 remove

此文章只將思想,不提供具體完整實現(博主太懶,懶得整理),有疑問或想了解的可以私信或評論

背景

  在傳統的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後端會話信息管理