此文章只將思想,不提供具體完整實現(博主太懶,懶得整理),有疑問或想了解的可以私信或評論
背景
在傳統的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