從SpringMVC獲取使用者資訊談起
- Github地址:https://github.com/andyslin/spring-ext
- 編譯、執行環境:JDK 8 + Maven 3 + IDEA + Lombok
- spring-boot:2.1.0.RELEASE(Spring:5.1.2.RELEASE)
- 如要本地執行github上的專案,需要安裝lombok外掛
上週末拜讀了一位牛人的公眾號文章<<Token認證,如何快速方便獲取使用者資訊>>,語言風趣,引人入勝,為了表示濤濤敬仰之情,已經轉載到自己的公眾號了。
回顧一下文章內容,為了在Controller
的方法中獲取已經認證過的使用者資訊(比如通過JWT-JSON Web Token傳輸的Token
- 方式一(很挫)直接在
Controller
方法中獲取Token
頭,然後解析; - 方式二(優雅)在過濾器
Filter
中驗證JWT
後,直接使用HttpServletRequestWrapper
偷樑換柱,覆蓋getHeader
方法,然後在Controller
方法中呼叫getHeader
,這樣就不需要再次解析了; - 方式三(很優雅)同樣在過濾器
Filter
中使用HttpServletRequestWrapper
,只是覆蓋getParameterNames
、getParameterValues
(針對表單提交)和getInputStream
(針對JSON提交),然後就可以和客戶端引數相同的方式獲取了。
方式一需要重複解析JWT
,而且控制器和Servlet API
繫結,不方便測試,但是勝在簡單直接。方式二和方式三雖然是一個很好的練習HttpServletRequestWrapper
的示例,但是可能還算不上是優雅的獲取使用者資訊的方式。
不妨思考一下:
- 除了獲取
userId
外,如果還想獲取JWT中PAYLOAD
的其它資訊,能不能做到只修改Controller
?還是需要再次修改驗證JWT
的過濾器Filter
呢? HttpServletRequest
的getInpustStream()
方法,Web容器實現基本都是隻能呼叫一次的,因而方式三在擴充套件getInpustStream()
的時候,先將其轉換為byte[]
byte[]
反序列化為map
,新增使用者資訊之後又序列化為byte[]
,反覆多次,這種方式效能怎麼樣?如果是檔案上傳,這種方式能否行得通?- 方式三中
HttpServletRequestWrapper
會無形中啟到遮蔽loginUserId
引數的作用,但如果客戶端的的確確傳入了一個loginUserId
的引數(當然,這種情況還是需要儘量避免),在Controller
中怎麼又獲取到客戶端的這個引數?
有沒有什麼其它的方式呢?
SpringMVC
中關於引數繫結有很多介面,其中很關鍵的一個是HandlerMethodArgumentResolver
,可以通過新增新實現類來實現獲取使用者資訊嗎?當然可以,對應該介面的兩個方法,首先要能夠識別什麼情況下需要繫結使用者資訊,一般來說,可以根據引數的特殊型別,也可以根據引數的特殊註解;其次要能夠獲取到使用者資訊,類似於原文中做的那樣。雖然這樣做也可以實現功能,但是卻很繁瑣。
不如拋開怎麼獲取使用者資訊不談,先來看看SpringMVC
在控制器的處理方法HandlerMethod
中繫結引數是怎麼做的?
熟悉SpringMVC
處理流程的朋友,自然知道,主控制器是DispatcherServlet
,在doDispatch()
方法中根據HandlerMapping
找到處理器,然後找到可以呼叫該處理器的HandlerAdapter
,其中最常用也最核心的莫過於RequestMappingHandlerMapping
、HandlerMethod
、RequestMappingHandlerAdapter
組合了。檢視RequestMappingHandlerAdapter
的原始碼,找到呼叫HandlerMethod
的方法:
@Override
protected ModelAndView handleInternal(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
ModelAndView mav;
checkRequest(request);
// Execute invokeHandlerMethod in synchronized block if required.
if (this.synchronizeOnSession) {
HttpSession session = request.getSession(false);
if (session != null) {
Object mutex = WebUtils.getSessionMutex(session);
synchronized (mutex) {
mav = invokeHandlerMethod(request, response, handlerMethod);
}
}
else {
// No HttpSession available -> no mutex necessary
mav = invokeHandlerMethod(request, response, handlerMethod);
}
}
else {
// No synchronization on session demanded at all...
mav = invokeHandlerMethod(request, response, handlerMethod);
}
if (!response.containsHeader(HEADER_CACHE_CONTROL)) {
if (getSessionAttributesHandler(handlerMethod).hasSessionAttributes()) {
applyCacheSeconds(response, this.cacheSecondsForSessionAttributeHandlers);
}
else {
prepareResponse(response);
}
}
return mav;
}
可以看到,真正的呼叫是委託給invokeHandlerMethod()
方法了:
@Nullable
protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
ServletWebRequest webRequest = new ServletWebRequest(request, response);
try {
// 建立資料繫結工廠
WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
ModelFactory modelFactory = getModelFactory(handlerMethod, binderFactory);
// 建立可呼叫的方法
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
if (this.argumentResolvers != null) {
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.setDataBinderFactory(binderFactory);
invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
ModelAndViewContainer mavContainer = new ModelAndViewContainer();
mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));
modelFactory.initModel(webRequest, mavContainer, invocableMethod);
mavContainer.setIgnoreDefaultModelOnRedirect(this.ignoreDefaultModelOnRedirect);
// 省略非同步處理相關程式碼
// 這裡才是真正的方法呼叫
invocableMethod.invokeAndHandle(webRequest, mavContainer);
// 處理返回結果
return getModelAndView(mavContainer, modelFactory, webRequest);
}
finally {
webRequest.requestCompleted();
}
}
這個方法很關鍵,如果需要研讀SpringMVC
,可以從這個方法著手。不過由於這篇文章關注的是引數繫結,所以這裡只關心WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
這句程式碼,接著看getDataBinderFactory()
方法:
private WebDataBinderFactory getDataBinderFactory(HandlerMethod handlerMethod) throws Exception {
Class<?> handlerType = handlerMethod.getBeanType();
Set<Method> methods = this.initBinderCache.get(handlerType);
if (methods == null) {
methods = MethodIntrospector.selectMethods(handlerType, INIT_BINDER_METHODS);
this.initBinderCache.put(handlerType, methods);
}
List<InvocableHandlerMethod> initBinderMethods = new ArrayList<>();
// Global methods first
this.initBinderAdviceCache.forEach((clazz, methodSet) -> {
if (clazz.isApplicableToBeanType(handlerType)) {
Object bean = clazz.resolveBean();
for (Method method : methodSet) {
initBinderMethods.add(createInitBinderMethod(bean, method));
}
}
});
for (Method method : methods) {
Object bean = handlerMethod.getBean();
initBinderMethods.add(createInitBinderMethod(bean, method));
}
return createDataBinderFactory(initBinderMethods);
}
這個方法前面的程式碼都是一些準備工作,比如呼叫ControllerAdvice
,最終還是呼叫createDataBinderFactory()
方法:
protected InitBinderDataBinderFactory createDataBinderFactory(List<InvocableHandlerMethod> binderMethods)
throws Exception {
return new ServletRequestDataBinderFactory(binderMethods, getWebBindingInitializer());
}
終於看到資料繫結工廠例項的建立了,方法體非常簡單,只有一個new
,而且非常幸運,這個方法是protected
的,這說明,SpringMVC
的設計者原本就預留了擴充套件點給我們,如果需要擴充套件資料繫結相關的功能,這裡應該是一個不錯的入口,具體做法是:
- 實現新的
WebDataBinderFactory
,當然,最好是繼承這裡的ServletRequestDataBinderFactory
; - 繼承
RequestMappingHandlerAdapter
,覆蓋createDataBinderFactory()
方法,返回新實現的WebDataBinderFactory
例項; - 在
SpringMVC
容器中使用新的RequestMappingHandlerAdapter
。
我們從後往前看:
有多種方式實現第3步,在SpringBoot
應用中,比較簡單的是通過向容器註冊一個WebMvcRegistrations
的實現類,這個介面定義如下:
public interface WebMvcRegistrations {
default RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return null;
}
default RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() {
return null;
}
default ExceptionHandlerExceptionResolver getExceptionHandlerExceptionResolver() {
return null;
}
}
實現第二個方法就可以。
第2步更簡單,上面已經說明,這裡就不贅述了。
再看第1步,檢視ServletRequestDataBinderFactory
的原始碼:
public class ServletRequestDataBinderFactory extends InitBinderDataBinderFactory {
public ServletRequestDataBinderFactory(@Nullable List<InvocableHandlerMethod> binderMethods,
@Nullable WebBindingInitializer initializer) {
super(binderMethods, initializer);
}
@Override
protected ServletRequestDataBinder createBinderInstance(
@Nullable Object target, String objectName, NativeWebRequest request) throws Exception {
return new ExtendedServletRequestDataBinder(target, objectName);
}
}
除了建構函式,只定義了一個createBinderInstance()
方法(一個工廠類建立一種例項,很熟悉的味道吧?),返回ExtendedServletRequestDataBinder
的例項,真正的繫結邏輯在這個類裡面,還需要擴充套件這個類:
public class ExtendedServletRequestDataBinder extends ServletRequestDataBinder {
public ExtendedServletRequestDataBinder(@Nullable Object target) {
super(target);
}
public ExtendedServletRequestDataBinder(@Nullable Object target, String objectName) {
super(target, objectName);
}
@Override
@SuppressWarnings("unchecked")
protected void addBindValues(MutablePropertyValues mpvs, ServletRequest request) {
String attr = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
Map<String, String> uriVars = (Map<String, String>) request.getAttribute(attr);
if (uriVars != null) {
uriVars.forEach((name, value) -> {
if (mpvs.contains(name)) {
if (logger.isWarnEnabled()) {
logger.warn("Skipping URI variable '" + name +
"' because request contains bind value with same name.");
}
}
else {
mpvs.addPropertyValue(name, value);
}
});
}
}
}
要擴充套件一個類,首先還是找一下有哪些protected
方法,可以看到有一個addBindValues()
方法,然後再看這個方法被誰呼叫了,發現在父類ServletRequestDataBinder
中有:
public void bind(ServletRequest request) {
MutablePropertyValues mpvs = new ServletRequestParameterPropertyValues(request);
MultipartRequest multipartRequest = WebUtils.getNativeRequest(request, MultipartRequest.class);
if (multipartRequest != null) {
bindMultipart(multipartRequest.getMultiFileMap(), mpvs);
}
// 繫結前新增繫結引數
addBindValues(mpvs, request);
// 執行引數繫結,包括引數格式化、引數校驗等
doBind(mpvs);
// 可以新增一些繫結之後的處理
}
至此,已經找到擴充套件接入點了,為了更好的對擴充套件開放,引入一個新的介面PropertyValuesProvider
:
/**
* 屬性值提供器介面
*/
public interface PropertyValuesProvider {
/**
* 繫結前新增繫結屬性,仍然需要經過引數校驗
*/
default void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
}
/**
* 繫結後修改目標物件,修改後的引數不需要經過引數校驗
*
*/
default void afterBindValues(PropertyAccessor accessor, ServletRequest request, Object target, String name) {
}
}
然後實現新的DataBinder
,整個程式碼如下:
class ArgsBindRequestMappingHandlerAdapter extends RequestMappingHandlerAdapter {
private final List<PropertyValuesProvider> providers;
public ArgsBindRequestMappingHandlerAdapter(List<PropertyValuesProvider> providers) {
this.providers = providers;
}
@Override
protected InitBinderDataBinderFactory createDataBinderFactory(List<InvocableHandlerMethod> binderMethods) throws Exception {
return new ArgsBindServletRequestDataBinderFactory(binderMethods, getWebBindingInitializer());
}
private class ArgsBindServletRequestDataBinderFactory extends ServletRequestDataBinderFactory {
public ArgsBindServletRequestDataBinderFactory(List<InvocableHandlerMethod> binderMethods, WebBindingInitializer initializer) {
super(binderMethods, initializer);
}
@Override
protected ServletRequestDataBinder createBinderInstance(Object target, String objectName, NativeWebRequest request) {
return new ArgsBindServletRequestDataBinder(target, objectName);
}
}
private class ArgsBindServletRequestDataBinder extends ExtendedServletRequestDataBinder {
public ArgsBindServletRequestDataBinder(Object target, String objectName) {
super(target, objectName);
}
/**
* 屬性繫結前
*/
@Override
protected void addBindValues(MutablePropertyValues mpvs, ServletRequest request) {
super.addBindValues(mpvs, request);
if (null != providers) {
Object target = getTarget();
String name = getObjectName();
providers.forEach(provider -> provider.addBindValues(mpvs, request, target, name));
}
}
/**
* 屬性繫結後
*/
@Override
public void bind(ServletRequest request) {
super.bind(request);
if (null != providers) {
ConfigurablePropertyAccessor mpvs = getPropertyAccessor();
Object target = getTarget();
String name = getObjectName();
providers.forEach(provider -> provider.afterBindValues(mpvs, request, target, name));
}
}
}
}
最後,加上SpringBoot
自動配置類:
@Configuration
public class ArgsBindAutoConfiguration {
@Bean
@ConditionalOnBean(PropertyValuesProvider.class)
@ConditionalOnMissingBean(ArgsBindWebMvcRegistrations.class)
public ArgsBindWebMvcRegistrations argsBindWebMvcRegistrations(List<PropertyValuesProvider> providers) {
return new ArgsBindWebMvcRegistrations(providers);
}
static class ArgsBindWebMvcRegistrations implements WebMvcRegistrations {
private final List<PropertyValuesProvider> providers;
public ArgsBindWebMvcRegistrations(List<PropertyValuesProvider> providers) {
this.providers = providers;
}
@Override
public RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() {
return new ArgsBindRequestMappingHandlerAdapter(providers);
}
}
}
好了,有了新的介面,要實現文章開始的獲取使用者資訊的問題,也就是新增一個新介面PropertyValuesProvider
的實現類,並注入到SpringMVC
的容器中即可,如果需要獲取PAYLOAD
中的其它資訊,或者有其它的自定義引數繫結邏輯,可以再加幾個實現類。
在我的Github上有一個簡單的測試示例,有興趣的朋友不妨一試