掌握@ControllerAdvice配合RequestBodyAdvice/ResponseBodyAdvice使用,讓你的選擇不僅僅只有攔截器【享學Spring MVC】

  • 2019 年 10 月 10 日
  • 筆記

版權聲明:本文為部落客原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。

本文鏈接:https://blog.csdn.net/f641385712/article/details/101396307

前言

要麼出眾,要麼出局(stand out or get out)。

前言

我們在實際的項目開發中,肯定會有這樣的需求:請求時記錄請求日誌,返回時記錄返回日誌;對所有的入參解密,對所有的返回值加密…。這些都是與業務沒關係的花邊但又不可缺少的功能,若你全都寫在Controller的方法內部,那將造成大量的程式碼重複且嚴重干擾了業務程式碼的可讀性。 怎麼破?可能你第一反應想到的是使用Spring MVCHandlerInterceptor攔截器來做,沒毛病,相信大部分公司的同學也都是這麼來乾的。那麼本文就介紹一種更為優雅、更為簡便的實現方案:使用@ControllerAdvice + RequestBodyAdvice/ResponseBodyAdvice不僅僅只有攔截器一種。

@ControllerAdvice / @RestControllerAdvice

對於這個註解你可能即熟悉,卻又覺得陌生。熟悉是因為你看到很多項目都使用了@ControllerAdvice + @ExceptionHandler來實現全局異常捕獲;陌生在於你除了copy程式碼時看到過外,自己似乎從來沒有真正使用過它。 在前面關於@ModelAttribute@InitBinder 的相關文章中其實和這個註解是打過照面的:在此註解標註的類上使用@InitBinder等註解可以使得它對"全局"生效實現統一的控制。本文將把@ControllerAdvice此註解作為重點進一步的去了解它的使用以及工作機制。

此類的命名是很有資訊量的:ControllerAdvice通知。關於Advice的含義,熟悉AOP相關概念的同學就不會陌生了,因此可以看到它整體上還是個AOP的設計思想,只是實現方式不太一樣而已。

@ControllerAdvice使用AOP思想可以這麼理解:此註解對目標Controller的通知是個環繞通知,織入的方式是註解方式,增強器是註解標註的方法。如此就很好理解@ControllerAdvice搭配@InitBinder/@ModelAttribute/@ExceptionHandler起到的效果嘍~

使用示例

最簡單的示例前文有過,這裡摘抄出一小段:

@RestControllerAdvice  public class MyControllerAdvice {        @InitBinder      public void initBinder(WebDataBinder binder) {          //binder.setDisallowedFields("name");          binder.registerCustomEditor(String.class, new StringTrimmerEditor());      }  }

這樣我們的@InitBinder標註的方法對所有的Controller都是生效的。(@InitBinder寫在Controller內部只對當前處理器生效)

原理分析

接下來就看看這個註解到底是怎麼work的,做到知其然,知其所以然。

// @since 3.2  @Target(ElementType.TYPE) // 只能標註在類上  @Retention(RetentionPolicy.RUNTIME)  @Documented  @Component // 派生有組件註解的功能  public @interface ControllerAdvice {    	@AliasFor("basePackages")  	String[] value() default {};  	@AliasFor("value")  	String[] basePackages() default {};    	Class<?>[] basePackageClasses() default {};  	Class<?>[] assignableTypes() default {};  	Class<? extends Annotation>[] annotations() default {};  }

官方doc說它可以和如上我指出的三個註解的一起使用。關於它的使用我總結有如下注意事項:

  1. @ControllerAdvice只需要標註上即可,Spring MVC會在容器里自動探測到它(請確保能被掃描到,否則無效哦~)
  2. 若有多個@ControllerAdvice可以使用@Order或者Ordered介面來控制順序
  3. basePackageClasses屬性最終也是轉換為了basePackages拿去匹配的,相關程式碼如下:
HandlerTypePredicate:  		// 這是packages屬性本文:有一個判空的過濾器  		public Builder basePackage(String... packages) {  			Arrays.stream(packages).filter(StringUtils::hasText).forEach(this::addBasePackage);  			return this;  		}    		// packageClasses最終都是轉換為了addBasePackage  		// 只是它的pachage值是:ClassUtils.getPackageName(clazz)  		// 說明:ClassUtils.getPackageName(String.class) --> java.lang  		public Builder basePackageClass(Class<?>... packageClasses) {  			Arrays.stream(packageClasses).forEach(clazz -> addBasePackage(ClassUtils.getPackageName(clazz)));  			return this;  		}  		private void addBasePackage(String basePackage) {  			this.basePackages.add(basePackage.endsWith(".") ? basePackage : basePackage + ".");  		}
  1. 它的basePackages掃包不支援佔位符Ant形式的匹配。對於其他幾個屬性的匹配可參照下面這段匹配程式碼(我配上了文字說明):
HandlerTypePredicate:    	@Override  	public boolean test(Class<?> controllerType) {  		// 1、若所有屬性一個都沒有指定,那就是default情況-->作用於所有的Controller  		if (!hasSelectors()) {  			return true;  		} else if (controllerType != null) {  			// 2、注意此處的basePackage只是簡單的startsWith前綴匹配而已~~~  			// 說明:basePackageClasses屬性最終都是轉為它來匹配的,  			// 如果寫了一個Controller類匹配上了,那它所在的包下所有的都是匹配的(因為同包嘛)  			for (String basePackage : this.basePackages) {  				if (controllerType.getName().startsWith(basePackage)) {  					return true;  				}  			}  			// 3、指定具體的Class類型,只會匹配數組裡面的這些類型,精確匹配。  			for (Class<?> clazz : this.assignableTypes) {  				if (ClassUtils.isAssignable(clazz, controllerType)) {  					return true;  				}  			}  			// 4、根據類上的註解類型來匹配(若你想個性化靈活配置,可以使用這種方式)  			for (Class<? extends Annotation> annotationClass : this.annotations) {  				if (AnnotationUtils.findAnnotation(controllerType, annotationClass) != null) {  					return true;  				}  			}  		}  		return false;  	}

說明一點:若註解的多個屬性都給值,它們是取並集的關係。

針對於@RestControllerAdvice,它就類似於@RestController和@Controller之間的區別,在@ControllerAdvice的基礎上帶有@ResponseBody的效果。

@ControllerAdvice在容器初始化的時候被解析,偽程式碼如下:

所有的被標註有此註解的Bean最終都變成一個org.springframework.web.method.ControllerAdviceBean,它內部持有Bean本身,以及判斷邏輯器(HandlerTypePredicate)的引用

RequestMappingHandlerAdapter:  	@Override  	public void afterPropertiesSet() {  		// Do this first, it may add ResponseBody advice beans  		initControllerAdviceCache();  		...  	}    	private void initControllerAdviceCache() {  		// 因為它需要通過它去容器內找到所有標註有@ControllerAdvice註解的Bean們  		if (getApplicationContext() == null) {  			return;  		}  		// 關鍵就是在findAnnotatedBeans方法里:傳入了容器上下文  		List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());  		// 注意此處是有個排序的~~~~  		AnnotationAwareOrderComparator.sort(adviceBeans);  		...    		// 注意:找到這些標註有@ControllerAdvice後並不需要保存下來。  		// 而是一個一個的找它們裡面的@InitBinder/@ModelAttribute 以及 RequestBodyAdvice和ResponseBodyAdvice  		// 說明:異常註解不在這裡解析,而是在`ExceptionHandlerMethodResolver`里~~~  		for (ControllerAdviceBean adviceBean : adviceBeans) {  			...  		}  	}    ControllerAdviceBean:  	// 找到容器內(包括父容器)所有的標註有@ControllerAdvice的Bean們~~~  	public static List<ControllerAdviceBean> findAnnotatedBeans(ApplicationContext context) {  		return Arrays.stream(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(context, Object.class))  				.filter(name -> context.findAnnotationOnBean(name, ControllerAdvice.class) != null)  				.map(name -> new ControllerAdviceBean(name, context))  				.collect(Collectors.toList());  	}

這就是@ControllerAdvice被解析、初始化的原理。它提供一個書寫Advice增強器的平台,在初始化的時候根據此類完成解析各種註解作用於各個功能上,從而在運行期直接運行即可。



RequestBodyAdvice/ResponseBodyAdvice

顧名思義,它們和@RequestBody@ResponseBody有關,ResponseBodyAdviceSpring4.1推出的,另外一個是4.2後才有。它哥倆和@ControllerAdvice一起使用會有很好的化學反應

說明:這哥倆是介面不是註解,實現類需要自己提供實現

RequestBodyAdvice

官方解釋為:允許body體轉換為對象之前進行自定義訂製;也允許該對象作為實參傳入方法之前對其處理

public interface RequestBodyAdvice {    	// 第一個調用的。判斷當前的攔截器(advice是否支援)  	// 注意它的入參有:方法參數、目標類型、所使用的消息轉換器等等  	boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);    	// 如果body體木有內容就執行這個方法(後面的就不會再執行嘍)  	Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);    	// 重點:它在body被read讀/轉換**之前**進行調用的  	HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException;    	// 它在body體已經轉換為Object後執行。so此時都不拋出IOException了嘛~  	Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);    }

它的內置實現有這些:

RequestResponseBodyAdviceChain比較特殊,放在後面重點說明。RequestBodyAdviceAdapter沒啥說的,因此主要看看JsonViewRequestBodyAdvice這個實現。

JsonViewRequestBodyAdvice

Spring MVC的內置實現,它支援的是Jackson的com.fasterxml.jackson.annotation.@JsonView這個註解,@JsonView一般用於標註在HttpEntity/@RequestBody上,來決定處理入參的哪些key。 該註解指定的反序列視圖將傳遞給MappingJackson2HttpMessageConverter,然後用它來反序列化請求體(從而做對應的過濾)。

// @since 4.2  public class JsonViewRequestBodyAdvice extends RequestBodyAdviceAdapter {    	// 處理使用的消息轉換器是AbstractJackson2HttpMessageConverter類型  	// 並且入參上標註有@JsonView註解的  	@Override  	public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {  		return (AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType) &&  				methodParameter.getParameterAnnotation(JsonView.class) != null);  	}    	// 顯然這裡實現的beforeBodyRead這個方法:  	// 它把body最終交給了MappingJacksonInputMessage來反序列處理消息體  	// 注意:@JsonView能處理這個註解。也就是說能指定把消息體轉換成指定的類型,還是比較實用的  	// 可以看到當標註有@jsonView註解後 targetType就沒啥卵用了  	@Override  	public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> selectedConverterType) throws IOException {  		JsonView ann = methodParameter.getParameterAnnotation(JsonView.class);  		Assert.state(ann != null, "No JsonView annotation");    		Class<?>[] classes = ann.value();  		// 必須指定class類型,並且有且只能指定一個類型  		if (classes.length != 1) {  			throw new IllegalArgumentException("@JsonView only supported for request body advice with exactly 1 class argument: " + methodParameter);  		}  		// 它是一個InputMessage的實現  		return new MappingJacksonInputMessage(inputMessage.getBody(), inputMessage.getHeaders(), classes[0]);  	}    }

說明:這個類只要你導入了jackson的jar,默認就會被添加進去,so註解@JsonView屬於天生就支援的。偽程式碼如下:

WebMvcConfigurationSupport:  	@Bean  	public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {  		...  		if (jackson2Present) {  			adapter.setRequestBodyAdvice(Collections.singletonList(new JsonViewRequestBodyAdvice()));  			adapter.setResponseBodyAdvice(Collections.singletonList(new JsonViewResponseBodyAdvice()));  		}  		...  	}
使用示例
@Getter  @Setter  @ToString  public static class User {      @JsonView({Simple.class, Complex.class})      private Long id;      @JsonView({Simple.class, Complex.class})      private String name;      @JsonView({Complex.class})      private Integer age;  }    // 準備兩個view類型(使用介面、類均可)  interface Simple {}  interface Complex {}

至於我為何這麼準備示例,有興趣的同學可以了解下@JsonView註解的用法和使用場景,你便會有所收穫。

繼續準備一個控制器,使用@JsonView來指定視圖類型:

@ResponseBody  @PostMapping("/test/requestbody")  public String testRequestBodyAdvice(@JsonView(Simple.class) @RequestBody User user) {      System.out.println(user);      return "hello world";  }

這時候請求(發送的body里有age這個key哦):

控制台輸出:

HelloController.User(id=1, name=fsx, age=null)

可以看到即使body體里有age這個key,服務端也是不會給與接收的(age仍然為null),就因為我要的是Simple類型的JsonView。這個時候若換成@JsonView(Complex.class)那最終的結果就為:

HelloController.User(id=1, name=fsx, age=18)

使用時需要注意如下幾點:

  1. 若不標註@JsonView註解,默認是接收所有(這是我們絕大部分的使用場景)
  2. @JsonView的value有且只能寫一個類型(必須寫)
  3. @JsonView指定的類型,在POJO的所有屬性(或者set方法)里都沒有@JsonView對應的指定,那最終一個值都不會接收(因為一個都匹配不上)。
@JsonView執行原理簡述

簡單說說@JsonView在生效的原理。它主要是在AbstractJackson2HttpMessageConverter的這個方法里(這就是為何JsonViewRequestBodyAdvice只會處理這種消息轉轉器的原因):

AbstractJackson2HttpMessageConverter(實際為MappingJackson2HttpMessageConverter):    	@Override  	public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {  		JavaType javaType = getJavaType(type, contextClass);  		// 把body內的東西轉換為java對象  		return readJavaType(javaType, inputMessage);  	}    	private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {  		if (inputMessage instanceof MappingJacksonInputMessage) {  			Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();  			if (deserializationView != null) {  				return this.objectMapper.readerWithView(deserializationView).forType(javaType).readValue(inputMessage.getBody());  			}  		}  		return this.objectMapper.readValue(inputMessage.getBody(), javaType);  	}

因為標註了@JsonView註解就使用的是它MappingJacksonInputMessage。so可見最底層的原理就是readerWithViewreadValue的區別。

ResponseBodyAdvice

它允許在@ResponseBody/ResponseEntity標註的處理方法上在用HttpMessageConverter在寫數據之前做些什麼。

// @since 4.1 泛型T:body類型  public interface ResponseBodyAdvice<T> {  	boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);  	@Nullable  	T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);  }

它的內置實現類們:

AbstractMappingJacksonResponseBodyAdvice

它做出了限定:body使用的消息轉換器必須是AbstractJackson2HttpMessageConverter才會生效。

public abstract class AbstractMappingJacksonResponseBodyAdvice implements ResponseBodyAdvice<Object> {  	@Override  	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {  		return AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType);  	}    	// 最終使用MappingJacksonValue來序列化body體  	@Override  	@Nullable  	public final Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType contentType, Class<? extends HttpMessageConverter<?>> converterType, ServerHttpRequest request, ServerHttpResponse response) {  		if (body == null) {  			return null;  		}  		MappingJacksonValue container = getOrCreateContainer(body);  		beforeBodyWriteInternal(container, contentType, returnType, request, response);  		return container;  	}  }
JsonViewResponseBodyAdvice

繼承自父類,用法幾乎同上面的@JsonView,只是它是標註在方法返回值上的。

它的源碼此處忽略,沒什麼特別的需要說明的
使用示例

準備一個控制器如下(其它的同上):

@ResponseBody  @GetMapping("/test/responsebody")  @JsonView(Simple.class)  public User testResponseBodyAdvice() {      User user = new User();      user.setId(1L);      user.setName("fsx");      user.setAge(18);      return user;  }

請求結果如下:

它的使用注意事項同上,基本原理同上(writerWithView/writer的區別)。


RequestResponseBodyAdviceChain

它是代理模式的實現,用於執行指定的RequestBodyAdvice/ResponseBodyAdvice們,實現方式基本同前面講過多次的xxxComposite模式。

需要注意的是,兩個advice的support()方法都只只只在這裡被調用。所以很容易相想到Spring調用advice增強時最終調用的都是它,它就是一個門面。

// @since 4.2  請注意:它的訪問許可權是default哦  class RequestResponseBodyAdviceChain implements RequestBodyAdvice, ResponseBodyAdvice<Object> {  	//它持有所有的,記住是所有的advice們  	private final List<Object> requestBodyAdvice = new ArrayList<>(4);  	private final List<Object> responseBodyAdvice = new ArrayList<>(4);    	// 可以看到這是個通用的方法。內來進行區分存儲的   getAdviceByType這個區分方法可以看一下  	// 兼容到了ControllerAdviceBean以及beanType本身  	public RequestResponseBodyAdviceChain(@Nullable List<Object> requestResponseBodyAdvice) {  		this.requestBodyAdvice.addAll(getAdviceByType(requestResponseBodyAdvice, RequestBodyAdvice.class));  		this.responseBodyAdvice.addAll(getAdviceByType(requestResponseBodyAdvice, ResponseBodyAdvice.class));  	}    	@Override  	public boolean supports(MethodParameter param, Type type, Class<? extends HttpMessageConverter<?>> converterType) {  		throw new UnsupportedOperationException("Not implemented");  	}  	@Override  	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {  		throw new UnsupportedOperationException("Not implemented");  	}    	// 可以看到最終都是委託給具體的Advice去執行的(supports方法)  	// 特點:符合條件的所有的`Advice`都會順序的、依次的執行  	@Override  	public HttpInputMessage beforeBodyRead(HttpInputMessage request, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {  		for (RequestBodyAdvice advice : getMatchingAdvice(parameter, RequestBodyAdvice.class)) {  			if (advice.supports(parameter, targetType, converterType)) {  				request = advice.beforeBodyRead(request, parameter, targetType, converterType);  			}  		}  		return request;  	}  	... // 其餘方法略。處理邏輯同上順序執行。  	// 最重要的是如下這個getMatchingAdvice()匹配方法      	private <A> List<A> getMatchingAdvice(MethodParameter parameter, Class<? extends A> adviceType) {  		// 簡單的說你想要的是Request的還是Response的List呢?  		List<Object> availableAdvice = getAdvice(adviceType);  		if (CollectionUtils.isEmpty(availableAdvice)) {  			return Collections.emptyList();  		}  		List<A> result = new ArrayList<>(availableAdvice.size());  		for (Object advice : availableAdvice) {  			if (advice instanceof ControllerAdviceBean) {  				ControllerAdviceBean adviceBean = (ControllerAdviceBean) advice;      				// 這裡面會調用beanTypePredicate.test(beanType)方法  				// 也就是根據basePackages等等判斷此advice是否是否要作用在本類上  				if (!adviceBean.isApplicableToBeanType(parameter.getContainingClass())) {  					continue;  				}  				advice = adviceBean.resolveBean();  			}  			// 當前的advice若是滿足類型要求的,那就添加進去  最終執行切面操作  			if (adviceType.isAssignableFrom(advice.getClass())) {  				result.add((A) advice);  			}  		}  		return result;  	}  }

這是批量代理模式的典型實現,Spring框架中不乏這種實現方式,對使用者非常友好,也很容易控制為鏈式執行或者短路執行。

初始化解析流程分析

我們知道所有的xxxBodyAdvice最終都是通過暴露的RequestResponseBodyAdviceChain來使用的,它內部持有容器內所有的Advice的引用。由於RequestResponseBodyAdviceChain的訪問許可權是default,所以這套機制完全由Spring內部控制。 他唯一設值處是AbstractMessageConverterMethodArgumentResolver

AbstractMessageConverterMethodArgumentResolver(一般實際為RequestResponseBodyMethodProcessor):  	// 唯一構造函數,指定所有的advices  	public AbstractMessageConverterMethodArgumentResolver(List<HttpMessageConverter<?>> converters, @Nullable List<Object> requestResponseBodyAdvice) {  		Assert.notEmpty(converters, "'messageConverters' must not be empty");  		this.messageConverters = converters;  		this.allSupportedMediaTypes = getAllSupportedMediaTypes(converters);  		this.advice = new RequestResponseBodyAdviceChain(requestResponseBodyAdvice);  	}

此構造函數在new RequestResponseBodyMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice)時候調用,傳進來的requestResponseBodyAdvice就剛好是在初始化RequestMappingHandlerAdapter的時候全局掃描進來的所有的增強器們。

使用場景

本文介紹了@ControllerAdvice的使用以及它的解析原理,最重要的是結合RequestBodyAdvice/ResponseBodyAdvice來實現類似攔截器的效果。在現在前後端分離的開發模式下,大部分的情況下的請求是json格式,因此此種方式會有很大的用武之地,我舉例幾個經典使用場景供以參考:

  1. 列印請求、響應日誌
  2. 對參數解密、對響應加密
  3. 對請求傳入的非法字元做過濾/檢測

總結

本文旨在介紹@ControllerAdviceRequestBodyAdvice/ResponseBodyAdvice的作用,為你解決在解決一些攔截問題時提供一個新的思路,希望能夠對你的眼界、程式碼結構上的把控能有所幫助。 同時也著重介紹了@JsonView的使用:它可以放入參時接收指定的欄位;也可以讓返回值中敏感欄位(如密碼、鹽值等)不予返回,可做到非常靈活的配置和管理,實現一套程式碼多處使用的目的,提高集成程度。

咀咒,需要注意的是:xxxBodyAdvice雖然使用方便,但是它的普適性還是沒有HandlerInterceptor那麼強的,下面我列出使用它的幾點局限/限制:

  1. xxxAdvice必須被@ControllerAdvice註解標註了才會生效,起到攔截的效果
  2. 它只能作用於基於消息轉換器的請求/響應(參考註解@RequestBody/@ResponseBody
  3. 當然,只能作用於@RequestMapping模式下的處理器模型上