前言
大家好!我是sum墨,一個一線的底層碼農(nóng),平時喜歡研究和思考一些技術(shù)相關(guān)的問題并整理成文,限于本人水平,如果文章和代碼有表述不當(dāng)之處,還請不吝賜教。
作為一名從業(yè)已達(dá)六年的老碼農(nóng),我的工作主要是開發(fā)后端Java業(yè)務(wù)系統(tǒng),包括各種管理后臺和小程序等。在這些項(xiàng)目中,我設(shè)計過單/多租戶體系系統(tǒng),對接過許多開放平臺,也搞過消息中心這類較為復(fù)雜的應(yīng)用,但幸運(yùn)的是,我至今還沒有遇到過線上系統(tǒng)由于代碼崩潰導(dǎo)致資損的情況。這其中的原因有三點(diǎn):一是業(yè)務(wù)系統(tǒng)本身并不復(fù)雜;二是我一直遵循某大廠代碼規(guī)約,在開發(fā)過程中盡可能按規(guī)約編寫代碼;三是經(jīng)過多年的開發(fā)經(jīng)驗(yàn)積累,我成為了一名熟練工,掌握了一些實(shí)用的技巧。
考慮到文字太過寡淡,我先上一張圖

在Spring Boot中,默認(rèn)情況下,每個請求到達(dá)時都會分配一個單獨(dú)的線程來處理,而且請求的發(fā)起人也不一定都是同一個人,所以一個請求對應(yīng)一個用戶上下文,并且要求線程隔離,即不同線程的用戶上下文互不影響,最后用戶上下文還需要隨著線程的結(jié)束而刪除。
本文我會從用戶上下文如何構(gòu)建、如何使用、如何刪除這三個方面解釋接口用戶上下文的設(shè)計與實(shí)現(xiàn)。
本文參考項(xiàng)目源碼地址:summo-springboot-interface-demo
由于文章經(jīng)常被抄襲,開源的代碼甚至被當(dāng)成收費(fèi)項(xiàng),所以源碼里面不是全部代碼,有需要的同學(xué)可以留個郵箱,我給你單獨(dú)發(fā)!
一、接口用戶上下文的構(gòu)建、使用、清除
1. 利用Filter攔截到每一個請求
由于接口散落在各個Controller中,且絕大部分接口都是需要這個用戶上下文的(注:也不排除不需要用戶上下文的接口存在),所以這里需要統(tǒng)一入口進(jìn)行創(chuàng)建、銷毀??雌饋砜梢允褂肁OP的方式來實(shí)現(xiàn),
不過這里有一個更合適的方案,利用SpringBoot自帶的Filter【javax.servlet.Filter】來實(shí)現(xiàn)。
實(shí)現(xiàn)起來非常簡單,我這邊自定義了一個WebFilter,代碼如下:
WebFilter.java
package com.summo.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.summo.context.GlobalUserContext;
import com.summo.context.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class WebFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
try {
//獲取本次接口的唯一碼
String token = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
MDC.put("requestId", token);
//獲取請求頭
HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
HttpServletResponse httpServletResponse = (HttpServletResponse)servletResponse;
log.info("當(dāng)前請求鏈接為:[{}]", httpServletRequest.getRequestURL());
//設(shè)置用戶上下文
UserContext userContext = new UserContext();
userContext.setUserId(1L);
GlobalUserContext.setUserContext(userContext);
//執(zhí)行doFilter,這行一定要加,否則程序會中斷掉
filterChain.doFilter(httpServletRequest, httpServletResponse);
} catch (Exception e) {
log.error("do doFilter exception", e);
} finally {
GlobalUserContext.clear();
MDC.remove("requestId");
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
這段代碼的核心方法是:
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
我們可以在這個方法里面獲取到ServletRequest和ServletResponse,這兩個類能獲取到代表著我們可以操作整個請求過程,這里如何確定當(dāng)前請求的用戶?下面有一張流程圖供大家參考:
image.png
還有一種做法是使用JWT來當(dāng)做用戶token,因?yàn)镴WT本身就可以存儲一些信息,所以我們就不需要去緩存用戶信息了,直接解析JWT即可,這種做法在分布式應(yīng)用中很常見。
2. 獲取當(dāng)前請求的線程
上面已經(jīng)獲取到用戶信息了,現(xiàn)在需要將用戶信息放入用戶上下文中,但由于請求的發(fā)起人不一定都是同一個人,所以一個請求對應(yīng)著一個用戶上下文,也即一個線程設(shè)置一個上下文。那么這里就需要獲取到當(dāng)前線程才能設(shè)置上下文。
獲取當(dāng)前線程有很多辦法,這里推薦使用阿里巴巴開源的TTL框架(TransmittableThreadLocal)來實(shí)現(xiàn),功能強(qiáng)大且用法簡單。
引入方法如下:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.11.1</version>
</dependency>
使用方法如下:
private static final TransmittableThreadLocal<UserContext> USER_HOLDER = new TransmittableThreadLocal<>();
直接new一個對象就行,而且支持泛型。
3. 用戶上下文生命周期管理
對于用戶上下文的生命周期管理需要定義3個方法:
- 設(shè)置上下文用戶信息;
- 獲取上下文用戶信息
- 清除上下文用戶信息
以上方法均為靜態(tài)方法。
下面是一個簡單的例子:
GlobalUserContext.java
package com.summo.context;
import com.alibaba.ttl.TransmittableThreadLocal;
public class GlobalUserContext {
private static final TransmittableThreadLocal<UserContext> USER_HOLDER = new TransmittableThreadLocal<>();
/**
* 設(shè)置上下文用戶信息
*
* @param user 用戶信息
*/
public static void setUserContext(UserContext user) {
USER_HOLDER.set(user);
}
/**
* 獲取上下文用戶信息
*/
public static UserContext getUserContext() {
return USER_HOLDER.get();
}
/**
* 清除上下文用戶信息
*/
public static void clear() {
USER_HOLDER.remove();
}
}
UserContext.java
package com.summo.context;
import lombok.Data;
@Data
public class UserContext {
/**
* 用戶ID
*/
private Long userId;
}
調(diào)用方式如下:
設(shè)置上下文用戶信息:GlobalUserContext.setUserContext(userContext);
獲取上下文用戶信息:GlobalUserContext.getUserContext();
清除上下文用戶信息:GlobalUserContext.clear();
4. 用戶上下文的使用
獲取用戶上下文很方便,調(diào)用GlobalUserContext.getUserContext();就行了,這里我主要講一下用戶上下文的使用場景。
a. 身份認(rèn)證
可以將用戶的身份認(rèn)證信息(如用戶名、密碼、權(quán)限等)保存在用戶上下文中,在需要進(jìn)行鑒權(quán)的地方進(jìn)行驗(yàn)證。
b. 用戶日志記錄
正如《優(yōu)化接口設(shè)計的思路》系列:第三篇—在用戶使用系統(tǒng)過程中留下痕跡 的方法三.
c. 防止接口數(shù)據(jù)越權(quán)
舉個例子,比如有些業(yè)務(wù)需要獲取當(dāng)前登錄用戶的信息、當(dāng)前登錄用戶的收藏、當(dāng)前登錄用戶的瀏覽記錄,這樣的接口總不能在接口上傳一個userId吧?真要這樣干了,非得給安全罵死。。。
利用用戶上下文的話,接口就可以不用傳遞任何參數(shù)獲取到當(dāng)前用戶的userId,實(shí)現(xiàn)你的需求啦。
d. 跨服務(wù)調(diào)用
在分布式系統(tǒng)中,可以將用戶上下文信息傳遞給其他服務(wù),以保持用戶的一致性和連貫性。
e. 監(jiān)控和統(tǒng)計
可以將用戶上下文中的信息用于系統(tǒng)的監(jiān)控和統(tǒng)計,如請求的處理時間、請求的次數(shù)等。
5. 用戶上下文的刪除
刪除很簡單,調(diào)用GlobalUserContext.clear();即可,詳情可見WebFilter.java內(nèi)容。
二. 用戶登錄&認(rèn)證
上面主要是說怎么獲取到接口請求的用戶以及怎么設(shè)置用戶上下文,但沒說用戶身份是什么時候確認(rèn)的以及怎么確認(rèn)的,這里說一下常見做法。
想要確認(rèn)用戶信息就不得不提到用戶登錄&認(rèn)證這套東西了,登錄的方式非常多,簡單的有賬號密碼登錄、手機(jī)驗(yàn)證碼登錄,復(fù)雜的就是單點(diǎn)登錄、三方授權(quán)登錄如微信掃碼、支付寶掃碼等。雖然方式多,但是結(jié)果都一樣的:確認(rèn)當(dāng)前用戶身份。
當(dāng)前用戶身份確認(rèn)好之后,系統(tǒng)一般會根據(jù)當(dāng)前用戶信息生成一個唯一的并帶有時效性的token,放入下一次請求的cookie中。等到下一次請求來的時候,我們就可以從cookie中獲取這個token,利用這個token獲取這個用戶的信息。
由于用戶認(rèn)證情況太多,這里我就不貼代碼了,上面是賬號密碼登錄用戶認(rèn)證的的時序圖,供大家參考。

