前言
記錄前后端分離的系統應用下應用場景————用戶信息傳遞
需求緣起
照例先看看web
系統的一張經典架構圖,這張圖參考自網絡:

在 Dubbo 自定義異常,你是怎么處理的? 中已經對該架構做了簡單說明,這里不再描述。
簡單描述下在該架構中用戶信息(如userId)的傳遞方式
:
現在絕大多數的項目都是前后端分離的開發模式,采用token
方式進行用戶鑒權:
- 客戶端(pc,移動端,平板等)首次登錄,服務端簽發
token
,在token
中放入用戶信息(如userId)
等返回給客戶端 - 客戶端訪問服務端接口,需要在頭部攜帶
token
,跟表單一並提交到服務端 - 服務端在
web
層統一解析token
鑒權,同時取出用戶信息(如userId)
並繼續向底層傳遞,傳到服務層操作業務邏輯 - 服務端在
service
層取到用戶信息(如userId)
后,執行相應的業務邏輯操作
問題:
為什么一定要把用戶信息(如userId)
藏在token
中,服務端再解析token
取出?直接登錄后向客戶端返回用戶信息(如userId)
不是更方便么?
跟用戶強相關的信息是相當敏感的,一般用戶信息(如userId)
不會直接明文暴露給客戶端,會帶來風險。
單體應用下`用戶信息(如userId)`的傳遞流程
什么是單體應用? 簡要描述就是web
層,service
層全部在一個jvm
進程中,更通俗的講就是只有一個項目
。
登錄簽發 token
看看下面的登錄接口偽代碼:
web
層接口:
1 @Loggable(descp = "用戶登錄", include = "loginParam")
2 @PostMapping("/login")
3 public BaseResult<LoginVo> accountLogin(LoginParam loginParam) {
4 return mAccountService.login(loginParam);
5 }
service
層接口偽代碼:
1public BaseResult<LoginVo> login(LoginParam param) throws BaseException {
2 //1.登錄邏輯判斷
3 LoginVo loginVo = handleLogin(param);
4 //2.簽發token
5 String subject = userId;
6 String jwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(), subject,
7 "token-server", BaseConstants.TOKEN_PERIOD_TIME, "", null, SignatureAlgorithm.HS512);
8 loginVo.setJwt(jwt);
9 return ResultUtil.success(loginVo);
10 }
注意到上述偽代碼中,簽發token
時把userId
放入客戶標識subject
中,簽發到token
中返回給客戶端。這里使用的是JJWT
生成的token
引入依賴:
1 <!--jjwt-->
2 <dependency>
3 <groupId>io.jsonwebtoken</groupId>
4 <artifactId>jjwt</artifactId>
5 <version>0.9.0</version>
6 </dependency>
7 <dependency>
8 <groupId>com.fasterxml.jackson.core</groupId>
9 <artifactId>jackson-databind</artifactId>
10 <version>2.8.9</version>
11 </dependency>
相關工具類JsonWebTokenUtil
:
1public class JsonWebTokenUtil {
2 //秘鑰
3 public static final String SECRET_KEY = BaseConstant.SECRET_KEY;
4 private static final ObjectMapper MAPPER = new ObjectMapper();
5 private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
6
7 //私有化構造
8 private JsonWebTokenUtil() {
9 }
10 /* *
11 * @Description json web token 簽發
12 * @param id 令牌ID
13 * @param subject 用戶標識
14 * @param issuer 簽發人
15 * @param period 有效時間(秒)
16 * @param roles 訪問主張-角色
17 * @param permissions 訪問主張-權限
18 * @param algorithm 加密算法
19 * @Return java.lang.String
20 */
21 public static String issueJWT(String id,String subject, String issuer, Long period,
22 String roles, String permissions, SignatureAlgorithm algorithm) {
23 // 當前時間戳
24 Long currentTimeMillis = System.currentTimeMillis();
25 // 秘鑰
26 byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
27 JwtBuilder jwtBuilder = Jwts.builder();
28 if (StringUtils.isNotBlank(id)) {
29 jwtBuilder.setId(id);
30 }
31 if (StringUtils.isNotBlank(subject)) {
32 jwtBuilder.setSubject(subject);
33 }
34 if (StringUtils.isNotBlank(issuer)) {
35 jwtBuilder.setIssuer(issuer);
36 }
37 // 設置簽發時間
38 jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
39 // 設置到期時間
40 if (null != period) {
41 jwtBuilder.setExpiration(new Date(currentTimeMillis + period*1000));
42 }
43 if (StringUtils.isNotBlank(roles)) {
44 jwtBuilder.claim("roles",roles);
45 }
46 if (StringUtils.isNotBlank(permissions)) {
47 jwtBuilder.claim("perms",permissions);
48 }
49 // 壓縮,可選GZIP
50 jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
51 // 加密設置
52 jwtBuilder.signWith(algorithm,secreKeyBytes);
53
54 return jwtBuilder.compact();
55 }
56
57 /**
58 * 解析JWT的Payload
59 */
60 public static String parseJwtPayload(String jwt){
61 Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
62 String base64UrlEncodedHeader = null;
63 String base64UrlEncodedPayload = null;
64 String base64UrlEncodedDigest = null;
65 int delimiterCount = 0;
66 StringBuilder sb = new StringBuilder(128);
67 for (char c : jwt.toCharArray()) {
68 if (c == '.') {
69 CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
70 String token = tokenSeq!=null?tokenSeq.toString():null;
71
72 if (delimiterCount == 0) {
73 base64UrlEncodedHeader = token;
74 } else if (delimiterCount == 1) {
75 base64UrlEncodedPayload = token;
76 }
77
78 delimiterCount++;
79 sb.setLength(0);
80 } else {
81 sb.append(c);
82 }
83 }
84 if (delimiterCount != 2) {
85 String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
86 throw new MalformedJwtException(msg);
87 }
88 if (sb.length() > 0) {
89 base64UrlEncodedDigest = sb.toString();
90 }
91 if (base64UrlEncodedPayload == null) {
92 throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
93 }
94 // =============== Header =================
95 Header header = null;
96 CompressionCodec compressionCodec = null;
97 if (base64UrlEncodedHeader != null) {
98 String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
99 Map<String, Object> m = readValue(origValue);
100 if (base64UrlEncodedDigest != null) {
101 header = new DefaultJwsHeader(m);
102 } else {
103 header = new DefaultHeader(m);
104 }
105 compressionCodec = codecResolver.resolveCompressionCodec(header);
106 }
107 // =============== Body =================
108 String payload;
109 if (compressionCodec != null) {
110 byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
111 payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
112 } else {
113 payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
114 }
115 return payload;
116 }
117
118 /**
119 * 驗簽JWT
120 *
121 * @param jwt json web token
122 */
123 public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException,
124 MalformedJwtException, SignatureException, IllegalArgumentException {
125 Claims claims = Jwts.parser()
126 .setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
127 .parseClaimsJws(jwt)
128 .getBody();
129 JwtAccount jwtAccount = new JwtAccount();
130 //令牌ID
131 jwtAccount.setTokenId(claims.getId());
132 //客戶標識
133 String subject = claims.getSubject();
134 jwtAccount.setSubject(subject);
135 //用戶id
136 jwtAccount.setUserId(subject);
137 //簽發者
138 jwtAccount.setIssuer(claims.getIssuer());
139 //簽發時間
140 jwtAccount.setIssuedAt(claims.getIssuedAt());
141 //接收方
142 jwtAccount.setAudience(claims.getAudience());
143 //訪問主張-角色
144 jwtAccount.setRoles(claims.get("roles", String.class));
145 //訪問主張-權限
146 jwtAccount.setPerms(claims.get("perms", String.class));
147 return jwtAccount;
148 }
149
150 public static Map<String, Object> readValue(String val) {
151 try {
152 return MAPPER.readValue(val, Map.class);
153 } catch (IOException e) {
154 throw new MalformedJwtException("Unable to userpager JSON value: " + val, e);
155 }
156 }
157}
JWT
相關實體JwtAccount
:
1@Data
2public class JwtAccount implements Serializable {
3
4 private static final long serialVersionUID = -895875540581785581L;
5
6 /**
7 * 令牌id
8 */
9 private String tokenId;
10
11 /**
12 * 客戶標識(用戶id)
13 */
14 private String subject;
15
16 /**
17 * 用戶id
18 */
19 private String userId;
20
21 /**
22 * 簽發者(JWT令牌此項有值)
23 */
24 private String issuer;
25
26 /**
27 * 簽發時間
28 */
29 private Date issuedAt;
30
31 /**
32 * 接收方(JWT令牌此項有值)
33 */
34 private String audience;
35
36 /**
37 * 訪問主張-角色(JWT令牌此項有值)
38 */
39 private String roles;
40
41 /**
42 * 訪問主張-資源(JWT令牌此項有值)
43 */
44 private String perms;
45
46 /**
47 * 客戶地址
48 */
49 private String host;
50
51 public JwtAccount() {
52
53 }
54}
`web`層統一鑒權,解析`token`
客戶端訪問服務端接口,需要在頭部攜帶token
,跟表單一並提交到服務端,服務端則在web
層新增MVC
攔截器統一做處理
新增MVC
攔截器如下:
1public class UpmsInterceptor extends HandlerInterceptorAdapter {
2
3 @Override
4 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
5 BaseResult result = null;
6 //獲取請求uri
7 String requestURI = request.getRequestURI();
8
9 ...省略部分邏輯
10
11 //獲取認證token
12 String jwt = request.getHeader(BaseConstant.AUTHORIZATION);
13 //不傳認證token,判斷為無效請求
14 if (StringUtils.isBlank(jwt)) {
15 result = ResultUtil.error(ResultEnum.ERROR_REQUEST);
16 RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
17 return false;
18 }
19 //其他請求均需驗證token有效性
20 JwtAccount jwtAccount = null;
21 String payload = null;
22 try {
23 // 解析Payload
24 payload = JsonWebTokenUtil.parseJwtPayload(jwt);
25 //取出payload中字段信息
26 if (payload.charAt(0) == '{'
27 && payload.charAt(payload.length() - 1) == '}') {
28 Map<String, Object> payloadMap = JsonWebTokenUtil.readValue(payload);
29 //客戶標識(userId)
30 String subject = (String) payloadMap.get("sub");
31
32 //查詢用戶簽發秘鑰
33
34 }
35 //驗簽token
36 jwtAccount = JsonWebTokenUtil.parseJwt(jwt, JsonWebTokenUtil.SECRET_KEY);
37 } catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
38 //令牌錯誤
39 result = ResultUtil.error(ResultEnum.ERROR_JWT);
40 RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
41 return false;
42 } catch (ExpiredJwtException e) {
43 //令牌過期
44 result = ResultUtil.error(ResultEnum.EXPIRED_JWT);
45 RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
46 return false;
47 } catch (Exception e) {
48 //解析異常
49 result = ResultUtil.error(ResultEnum.ERROR_JWT);
50 RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
51 return false;
52 }
53 if (null == jwtAccount) {
54 //令牌錯誤
55 result = ResultUtil.error(ResultEnum.ERROR_JWT);
56 RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
57 return false;
58 }
59
60 //將用戶信息放入threadLocal中,線程共享
61 ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
62 return true;
63 }
64
65 //...省略部分代碼
66}
整個token
解析過程已經在代碼注釋中說明,可以看到解析完token
后取出userId
,將用戶信息放入了threadLocal
中,關於threadLocal
的用法,本文暫不討論.
1 //將用戶信息放入threadLocal中,線程共享
2 ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
添加配置使攔截器生效:
1<?xml version="1.0" encoding="UTF-8"?>
2<beans xmlns="http://www.springframework.org/schema/beans"
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 ...省略部分代碼">
5
6 <!-- web攔截器 -->
7 <mvc:interceptors>
8 <mvc:interceptor>
9 <mvc:mapping path="/**"/>
10 <bean class="com.easywits.upms.client.interceptor.UpmsInterceptor"/>
11 </mvc:interceptor>
12 </mvc:interceptors>
13
14</beans>
15
相關工具代碼ThreadLocalUtil
:
1public class ThreadLocalUtil {
2
3 private ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
4
5 //new一個實例
6 private static final ThreadLocalUtil instance = new ThreadLocalUtil();
7
8 //私有化構造
9 private ThreadLocalUtil() {
10 }
11
12 //獲取單例
13 public static ThreadLocalUtil getInstance() {
14 return instance;
15 }
16
17 /**
18 * 將用戶對象綁定到當前線程中,鍵為userInfoThreadLocal對象,值為userInfo對象
19 *
20 * @param userInfo
21 */
22 public void bind(UserInfo userInfo) {
23 userInfoThreadLocal.set(userInfo);
24 }
25
26 /**
27 * 將用戶數據綁定到當前線程中,鍵為userInfoThreadLocal對象,值為userInfo對象
28 *
29 * @param companyId
30 * @param userId
31 */
32 public void bind(String userId) {
33 UserInfo userInfo = new UserInfo();
34 userInfo.setUserId(userId);
35 bind(userInfo);
36 }
37
38 /**
39 * 得到綁定的用戶對象
40 *
41 * @return
42 */
43 public UserInfo getUserInfo() {
44 UserInfo userInfo = userInfoThreadLocal.get();
45 remove();
46 return userInfo;
47 }
48
49 /**
50 * 移除綁定的用戶對象
51 */
52 public void remove() {
53 userInfoThreadLocal.remove();
54 }
55}
那么在web
層和service
都可以這樣拿到userId
:
1 @Loggable(descp = "用戶個人資料", include = "")
2 @GetMapping(value = "/info")
3 public BaseResult<UserInfoVo> userInfo() {
4 //拿到用戶信息
5 UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
6 return mUserService.userInfo();
7 }
service
層獲取userId
:
1public BaseResult<UserInfoVo> userInfo() throws BaseException {
2 //拿到用戶信息
3 UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
4 UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
5 return ResultUtil.success(userInfoVo);
6 }
分布式應用下(Dubbo)`用戶信息(如userId)`的傳遞流程
分布式應用與單體應用最大的區別就是從單個應用拆分成多個應用,service
層與web
層分為兩個獨立的應用,使用rpc
調用方式處理業務邏輯。而上述做法中我們將用戶信息放入了threadLocal
中,是相對單應用進程而言的,假如service
層接口在另外一個服務進程中,那么將獲取不到。
有什么辦法能解決跨進程傳遞用戶信息呢?翻看了下Dubbo
官方文檔,有隱式參數
功能:

文檔很清晰,只需要在web
層統一的攔截器中調用如下代碼,就能將用戶id
傳到service
層
1RpcContext.getContext().setAttachment("userId", xxx);
相應地調整web
層攔截器代碼:
1public class UpmsInterceptor extends HandlerInterceptorAdapter {
2
3 @Override
4 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
5 //...省略部分代碼
6
7 //將用戶信息放入threadLocal中,線程共享
8 ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
9
10 //將用戶信息隱式透傳到服務層
11 RpcContext.getContext().setAttachment("userId", jwtAccount.getUserId());
12 return true;
13 }
14
15 //...省略部分代碼
16}
那么服務層可以這樣獲取用戶id
了:
1public BaseResult<UserInfoVo> userInfo() throws BaseException {
2 //拿到用戶信息
3 String userId = RpcContext.getContext().getAttachment("userId");
4 UserInfoVo userInfoVo = getUserInfoVo(userId);
5 return ResultUtil.success(userInfoVo);
6 }
為了便於統一管理,我們可以在service
層攔截器中將獲取到的userId
再放入threadLocal
中,service
層攔截器可以看看這篇推文:Dubbo自定義日志攔截器
1public class DubboServiceFilter implements Filter {
2
3 private static final Logger LOGGER = LoggerFactory.getLogger(DubboServiceFilter.class);
4
5 @Override
6 public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
7
8 //...省略部分邏輯
9
10 //獲取web層透傳過來的用戶參數
11 String userId = RpcContext.getContext().getAttachment("userId");
12 //放入全局threadlocal 線程共享
13 if (StringUtils.isNotBlank(userId)) {
14 ThreadLocalUtil.getInstance().bind(userId);
15 }
16 //執行業務邏輯 返回結果
17 Result result = invoker.invoke(invocation);
18 //清除 防止內存泄露
19 ThreadLocalUtil.getInstance().remove();
20
21 //...省略部分邏輯
22 return result;
23 }
24}
這樣處理,service
層依然可以通過如下代碼獲取用戶信息了:
1public BaseResult<UserInfoVo> userInfo() throws BaseException {
2 //拿到用戶信息
3 UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
4 UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
5 return ResultUtil.success(userInfoVo);
6 }
參考文檔
關於jwt
:https://blog.leapoahead.com/2015/09/06/understanding-jwt/
關於dubbo
:http://dubbo.apache.org/zh-cn/docs/user/demos/attachment.html
最后
篇幅較長,總結一個較為實用的web
應用場景,后續會不定期更新原創文章,歡迎關注公眾號 「張少林同學」!
