前言
記錄前後端分離的系統應用下應用場景————使用者資訊傳遞
需求緣起
照例先看看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
層介面:
@Loggable(descp = "使用者登入", include = "loginParam")
@PostMapping("/login")
public BaseResult<LoginVo> accountLogin(LoginParam loginParam) {
return mAccountService.login(loginParam);
}
複製程式碼
service
層介面虛擬碼:
public BaseResult<LoginVo> login(LoginParam param) throws BaseException {
//1.登入邏輯判斷
LoginVo loginVo = handleLogin(param);
//2.簽發token
String subject = userId;
String jwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(), subject,
"token-server", BaseConstants.TOKEN_PERIOD_TIME, "", null, SignatureAlgorithm.HS512);
loginVo.setJwt(jwt);
return ResultUtil.success(loginVo);
}
複製程式碼
注意到上述虛擬碼中,簽發token
時把userId
放入客戶標識subject
中,簽發到token
中返回給客戶端。這裡使用的是JJWT
生成的token
引入依賴:
<!--jjwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.9</version>
</dependency>
複製程式碼
相關工具類JsonWebTokenUtil
:
public class JsonWebTokenUtil {
//祕鑰
public static final String SECRET_KEY = BaseConstant.SECRET_KEY;
private static final ObjectMapper MAPPER = new ObjectMapper();
private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
//私有化構造
private JsonWebTokenUtil() {
}
/* *
* @Description json web token 簽發
* @param id 令牌ID
* @param subject 使用者標識
* @param issuer 簽發人
* @param period 有效時間(秒)
* @param roles 訪問主張-角色
* @param permissions 訪問主張-許可權
* @param algorithm 加密演算法
* @Return java.lang.String
*/
public static String issueJWT(String id,String subject, String issuer, Long period,
String roles, String permissions, SignatureAlgorithm algorithm) {
// 當前時間戳
Long currentTimeMillis = System.currentTimeMillis();
// 祕鑰
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
JwtBuilder jwtBuilder = Jwts.builder();
if (StringUtils.isNotBlank(id)) {
jwtBuilder.setId(id);
}
if (StringUtils.isNotBlank(subject)) {
jwtBuilder.setSubject(subject);
}
if (StringUtils.isNotBlank(issuer)) {
jwtBuilder.setIssuer(issuer);
}
// 設定簽發時間
jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
// 設定到期時間
if (null != period) {
jwtBuilder.setExpiration(new Date(currentTimeMillis + period*1000));
}
if (StringUtils.isNotBlank(roles)) {
jwtBuilder.claim("roles",roles);
}
if (StringUtils.isNotBlank(permissions)) {
jwtBuilder.claim("perms",permissions);
}
// 壓縮,可選GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密設定
jwtBuilder.signWith(algorithm,secreKeyBytes);
return jwtBuilder.compact();
}
/**
* 解析JWT的Payload
*/
public static String parseJwtPayload(String jwt){
Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
String base64UrlEncodedDigest = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
for (char c : jwt.toCharArray()) {
if (c == '.') {
CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
String token = tokenSeq!=null?tokenSeq.toString():null;
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
delimiterCount++;
sb.setLength(0);
} else {
sb.append(c);
}
}
if (delimiterCount != 2) {
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
}
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
if (base64UrlEncodedPayload == null) {
throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
}
// =============== Header =================
Header header = null;
CompressionCodec compressionCodec = null;
if (base64UrlEncodedHeader != null) {
String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
Map<String, Object> m = readValue(origValue);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = codecResolver.resolveCompressionCodec(header);
}
// =============== Body =================
String payload;
if (compressionCodec != null) {
byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
} else {
payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
}
return payload;
}
/**
* 驗籤JWT
*
* @param jwt json web token
*/
public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException,
MalformedJwtException, SignatureException, IllegalArgumentException {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
JwtAccount jwtAccount = new JwtAccount();
//令牌ID
jwtAccount.setTokenId(claims.getId());
//客戶標識
String subject = claims.getSubject();
jwtAccount.setSubject(subject);
//使用者id
jwtAccount.setUserId(subject);
//簽發者
jwtAccount.setIssuer(claims.getIssuer());
//簽發時間
jwtAccount.setIssuedAt(claims.getIssuedAt());
//接收方
jwtAccount.setAudience(claims.getAudience());
//訪問主張-角色
jwtAccount.setRoles(claims.get("roles", String.class));
//訪問主張-許可權
jwtAccount.setPerms(claims.get("perms", String.class));
return jwtAccount;
}
public static Map<String, Object> readValue(String val) {
try {
return MAPPER.readValue(val, Map.class);
} catch (IOException e) {
throw new MalformedJwtException("Unable to userpager JSON value: " + val, e);
}
}
}
複製程式碼
JWT
相關實體JwtAccount
:
@Data
public class JwtAccount implements Serializable {
private static final long serialVersionUID = -895875540581785581L;
/**
* 令牌id
*/
private String tokenId;
/**
* 客戶標識(使用者id)
*/
private String subject;
/**
* 使用者id
*/
private String userId;
/**
* 簽發者(JWT令牌此項有值)
*/
private String issuer;
/**
* 簽發時間
*/
private Date issuedAt;
/**
* 接收方(JWT令牌此項有值)
*/
private String audience;
/**
* 訪問主張-角色(JWT令牌此項有值)
*/
private String roles;
/**
* 訪問主張-資源(JWT令牌此項有值)
*/
private String perms;
/**
* 客戶地址
*/
private String host;
public JwtAccount() {
}
}
複製程式碼
web
層統一鑑權,解析token
客戶端訪問服務端介面,需要在頭部攜帶token
,跟表單一併提交到服務端,服務端則在web
層新增MVC
攔截器統一做處理
新增MVC
攔截器如下:
public class UpmsInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
BaseResult result = null;
//獲取請求uri
String requestURI = request.getRequestURI();
...省略部分邏輯
//獲取認證token
String jwt = request.getHeader(BaseConstant.AUTHORIZATION);
//不傳認證token,判斷為無效請求
if (StringUtils.isBlank(jwt)) {
result = ResultUtil.error(ResultEnum.ERROR_REQUEST);
RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
return false;
}
//其他請求均需驗證token有效性
JwtAccount jwtAccount = null;
String payload = null;
try {
// 解析Payload
payload = JsonWebTokenUtil.parseJwtPayload(jwt);
//取出payload中欄位資訊
if (payload.charAt(0) == '{'
&& payload.charAt(payload.length() - 1) == '}') {
Map<String, Object> payloadMap = JsonWebTokenUtil.readValue(payload);
//客戶標識(userId)
String subject = (String) payloadMap.get("sub");
//查詢使用者簽發祕鑰
}
//驗籤token
jwtAccount = JsonWebTokenUtil.parseJwt(jwt, JsonWebTokenUtil.SECRET_KEY);
} catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
//令牌錯誤
result = ResultUtil.error(ResultEnum.ERROR_JWT);
RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
return false;
} catch (ExpiredJwtException e) {
//令牌過期
result = ResultUtil.error(ResultEnum.EXPIRED_JWT);
RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
return false;
} catch (Exception e) {
//解析異常
result = ResultUtil.error(ResultEnum.ERROR_JWT);
RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
return false;
}
if (null == jwtAccount) {
//令牌錯誤
result = ResultUtil.error(ResultEnum.ERROR_JWT);
RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
return false;
}
//將使用者資訊放入threadLocal中,執行緒共享
ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
return true;
}
//...省略部分程式碼
}
複製程式碼
整個token
解析過程已經在程式碼註釋中說明,可以看到解析完token
後取出userId
,將使用者資訊放入了threadLocal
中,關於threadLocal
的用法,本文暫不討論.
//將使用者資訊放入threadLocal中,執行緒共享
ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
複製程式碼
新增配置使攔截器生效:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
...省略部分程式碼">
<!-- web攔截器 -->
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<bean class="com.easywits.upms.client.interceptor.UpmsInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
</beans>
複製程式碼
相關工具程式碼ThreadLocalUtil
:
public class ThreadLocalUtil {
private ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
//new一個例項
private static final ThreadLocalUtil instance = new ThreadLocalUtil();
//私有化構造
private ThreadLocalUtil() {
}
//獲取單例
public static ThreadLocalUtil getInstance() {
return instance;
}
/**
* 將使用者物件繫結到當前執行緒中,鍵為userInfoThreadLocal物件,值為userInfo物件
*
* @param userInfo
*/
public void bind(UserInfo userInfo) {
userInfoThreadLocal.set(userInfo);
}
/**
* 將使用者資料繫結到當前執行緒中,鍵為userInfoThreadLocal物件,值為userInfo物件
*
* @param companyId
* @param userId
*/
public void bind(String userId) {
UserInfo userInfo = new UserInfo();
userInfo.setUserId(userId);
bind(userInfo);
}
/**
* 得到繫結的使用者物件
*
* @return
*/
public UserInfo getUserInfo() {
UserInfo userInfo = userInfoThreadLocal.get();
remove();
return userInfo;
}
/**
* 移除繫結的使用者物件
*/
public void remove() {
userInfoThreadLocal.remove();
}
}
複製程式碼
那麼在web
層和service
都可以這樣拿到userId
:
@Loggable(descp = "使用者個人資料", include = "")
@GetMapping(value = "/info")
public BaseResult<UserInfoVo> userInfo() {
//拿到使用者資訊
UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
return mUserService.userInfo();
}
複製程式碼
service
層獲取userId
:
public BaseResult<UserInfoVo> userInfo() throws BaseException {
//拿到使用者資訊
UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
return ResultUtil.success(userInfoVo);
}
複製程式碼
分散式應用下(Dubbo)使用者資訊(如userId)
的傳遞流程
分散式應用與單體應用最大的區別就是從單個應用拆分成多個應用,service
層與web
層分為兩個獨立的應用,使用rpc
呼叫方式處理業務邏輯。而上述做法中我們將使用者資訊放入了threadLocal
中,是相對單應用程式而言的,假如service
層介面在另外一個服務程式中,那麼將獲取不到。
有什麼辦法能解決跨程式傳遞使用者資訊呢?翻看了下Dubbo
官方文件,有隱式引數
功能:
文件很清晰,只需要在web
層統一的攔截器中呼叫如下程式碼,就能將使用者id
傳到service
層
RpcContext.getContext().setAttachment("userId", xxx);
複製程式碼
相應地調整web
層攔截器程式碼:
public class UpmsInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//...省略部分程式碼
//將使用者資訊放入threadLocal中,執行緒共享
ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
//將使用者資訊隱式透傳到服務層
RpcContext.getContext().setAttachment("userId", jwtAccount.getUserId());
return true;
}
//...省略部分程式碼
}
複製程式碼
那麼服務層可以這樣獲取使用者id
了:
public BaseResult<UserInfoVo> userInfo() throws BaseException {
//拿到使用者資訊
String userId = RpcContext.getContext().getAttachment("userId");
UserInfoVo userInfoVo = getUserInfoVo(userId);
return ResultUtil.success(userInfoVo);
}
複製程式碼
為了便於統一管理,我們可以在service
層攔截器中將獲取到的userId
再放入threadLocal
中,service
層攔截器可以看看這篇推文:Dubbo自定義日誌攔截器
public class DubboServiceFilter implements Filter {
private static final Logger LOGGER = LoggerFactory.getLogger(DubboServiceFilter.class);
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
//...省略部分邏輯
//獲取web層透傳過來的使用者引數
String userId = RpcContext.getContext().getAttachment("userId");
//放入全域性threadlocal 執行緒共享
if (StringUtils.isNotBlank(userId)) {
ThreadLocalUtil.getInstance().bind(userId);
}
//執行業務邏輯 返回結果
Result result = invoker.invoke(invocation);
//清除 防止記憶體洩露
ThreadLocalUtil.getInstance().remove();
//...省略部分邏輯
return result;
}
}
複製程式碼
這樣處理,service
層依然可以通過如下程式碼獲取使用者資訊了:
public BaseResult<UserInfoVo> userInfo() throws BaseException {
//拿到使用者資訊
UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
return ResultUtil.success(userInfoVo);
}
複製程式碼
參考文件
關於jwt
:https://blog.leapoahead.com/2015/09/06/understanding-jwt/
關於dubbo
:http://dubbo.apache.org/zh-cn/docs/user/demos/attachment.html
最後
篇幅較長,總結一個較為實用的web
應用場景,後續會不定期更新原創文章,歡迎關注公眾號 「張少林同學」!