SpringMVC-嵌套對象傳參及原理解析


引子

在涉及前后端交互的 Java 應用中,SpringMVC 可以說是很流行的一種框架。那么在 SpringMVC 中,如何將較復雜的嵌套對象從前端傳給后端呢?可以使用注解 @RequestBody 。 @RequestBody 的實現原理是:根據指定的前端傳參類型及 Media Type 來選擇適當的 HttpMessageConverter 來進行參數轉換。

傳參

后端接收:

@RequestMapping(value = "/save")
@ResponseBody
public BaseResult save(@RequestBody BookInfo bookInfo) {
    Assert.notNull(bookInfo, "商品對象不能為空");
    Assert.notNull(bookInfo.getGoods().getGoodsId(), "商品ID不能為空");

    complete(bookInfo);
    boolean isSaved = goodsSnapshotService.save(bookInfo);
    BookSaveResponse bookSaveResponse = new BookSaveResponse(bookInfo.getOrder().getOrderNo(), bookInfo.getGoods().getGoodsId());
    return isSaved ? BaseResult.succ(bookSaveResponse): BaseResult.failed(Errors.BookError);
}

public class BookInfo {

    /** 下單的商品信息 */
    private GoodsInfo goods;

    /** 下單的訂單信息 */
    private Order order;
}

public class GoodsInfo {

    /** 商品ID */
    private Long goodsId;

    /** 店鋪ID */
    private Long shopId;

    /** 商品標題 */
    private String title;

    /** 商品描述 */
    private String desc;

    /** 商品服務 keys */
    private String serviceKeys;

    /** 商品規格選擇 */
    private String choice;

    /** 商品關聯的訂單號 */
    private String orderNo;
}

public class Order {

    /** 訂單號 */
    private String orderNo;

    /** 店鋪ID */
    private Long shopId;

    /** 下單時間 */
    private Long bookTime;

    /** 下單人ID */
    private Long userId;

    /** 是否貨到付款 */
    private Boolean isCodPay;

    /** 是否擔保交易 */
    private Boolean isSecuredOrder;

    /** 是否有線下門店 */
    private Boolean hasRetailShop;

    /** 配送方式 0 快遞 1 自提 2 同城送 */
    private DeliveryType deliveryType;

    /** 訂單金額, 分為單位 */
    private Long price;

    /** 快遞配送金額 */
    private Long expressFee;

    /** 同城配送起送金額 */
    private Long localDeliveryBasePrice;

    /** 同城配送金額 */
    private Long localDeliveryPrice;

    /** 訂單的服務 keys */
    private List<String> keys;
}

前端傳參:


    var bookInfo = {
       'goods': {
            'shopId': shopId,
            'goodsId': goodsId,
            'price': priceNum,
            'title': title,
            'desc': desc,
            'serviceKeys' : serviceKeys,
            'choice': choice
        },
        'order': {
            'shopId': shopId,
            'userId': userId,
            'deliveryType': deliveryType,
            'price': priceNum,
            'isCodPay' : isCodPay,
        }
    };

    var jqXHR = jQuery.ajax({
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        url: 'http://localhost:8080/api/goodsnapshot/save',
        data: JSON.stringify(bookInfo),
        timeout: 90000,
        type: 'POST'
    });


原理

@RequestBody 的作用就是做參數校驗和參數轉換,將前端的 JSON 字符串轉換成后端給定的 Java 對象。那么,它的參數轉換功能是如何實現的呢? 要回答這個問題,就要找到這個注解的實現類。

找到入口方法

可以在 IDEA 搜索類 RequestBody,得到與之相關的幾個類如下圖所示:

在可能實現類的 public 方法入口打上斷點,然后單步調試。可以找到入口方法是 RequestResponseBodyMethodProcessor.resolveArgument 。這個方法進一步調用了方法 AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters , 該方法會拿到若干個 HttpMessageConverter 的實現類(如下圖所示),判斷其中是否有可以處理請求對象 InputMessage 的類。只要任一能處理即可。

AbstractMessageConverterMethodArgumentResolver.java


        Object inputMessage;
        try {
            inputMessage = new AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage(inputMessage);
            Iterator var10 = this.messageConverters.iterator();

            while(var10.hasNext()) {
                HttpMessageConverter<?> converter = (HttpMessageConverter)var10.next();
                Class<HttpMessageConverter<?>> converterType = converter.getClass();
                if (converter instanceof GenericHttpMessageConverter) {
                    GenericHttpMessageConverter<?> genericConverter = (GenericHttpMessageConverter)converter;
                    if (genericConverter.canRead(targetType, contextClass, contentType)) {
                        if (this.logger.isDebugEnabled()) {
                            this.logger.debug("Read [" + targetType + "] as \"" + contentType + "\" with [" + converter + "]");
                        }

                        if (((HttpInputMessage)inputMessage).getBody() != null) {     // HttpMessageConverter 進行參數轉換時有切面
                            inputMessage = this.getAdvice().beforeBodyRead((HttpInputMessage)inputMessage, parameter, targetType, converterType);
                            body = genericConverter.read(targetType, contextClass, (HttpInputMessage)inputMessage);
                            body = this.getAdvice().afterBodyRead(body, (HttpInputMessage)inputMessage, parameter, targetType, converterType);
                        } else {
                            body = this.getAdvice().handleEmptyBody((Object)null, (HttpInputMessage)inputMessage, parameter, targetType, converterType);
                        }
                        break;
                    }
                } else if (targetClass != null && converter.canRead(targetClass, contentType)) {
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("Read [" + targetType + "] as \"" + contentType + "\" with [" + converter + "]");
                    }

                    if (((HttpInputMessage)inputMessage).getBody() != null) {   // HttpMessageConverter 進行參數轉換時有切面
                        inputMessage = this.getAdvice().beforeBodyRead((HttpInputMessage)inputMessage, parameter, targetType, converterType);  
                        body = converter.read(targetClass, (HttpInputMessage)inputMessage);
                        body = this.getAdvice().afterBodyRead(body, (HttpInputMessage)inputMessage, parameter, targetType, converterType);
                    } else {
                        body = this.getAdvice().handleEmptyBody((Object)null, (HttpInputMessage)inputMessage, parameter, targetType, converterType);
                    }
                    break;
                }
            }


找到參數轉換類

如何判斷 HttpMessageConverter 是否能夠處理 InputMessage 呢?要滿足兩個基本條件:

  • 能夠處理請求參數類,比如,這里是 BookInfo ;
  • 要能處理給定的 Content-type ,比如,這里是 JSON , UTF-8。

AbstractHttpMessageConverter.java


    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        return this.supports(clazz) && this.canRead(mediaType);
    }

    protected boolean canRead(MediaType mediaType) {
        if (mediaType == null) {
            return true;
        } else {
            Iterator var2 = this.getSupportedMediaTypes().iterator();

            MediaType supportedMediaType;
            do {
                if (!var2.hasNext()) {
                    return false;
                }

                supportedMediaType = (MediaType)var2.next();
            } while(!supportedMediaType.includes(mediaType));

            return true;
        }
    }

可以逐一查看上述截圖中的 HttpMessageConverter 實現類(支持的 Media Type 如截圖所示):

  • ByteArrayHttpMessageConverter: 支持字節數組的處理;
  • StringHttpMessageConverter: 支持字符串的處理;
  • ResourceHttpMessageConverter: 支持 Resource 類型的處理;
  • SourceHttpMessageConverter: 支持 DOMSource, SAXSource, StAXSource, StreamSource, Source 類型;
  • AllEncompassingFormHttpMessageConverter: 支持表單的處理;
  • MappingJackson2HttpMessageConverter: 支持 JSON 字符串的處理;
  • Jaxb2RootElementHttpMessageConverter:參數類帶有 XmlRootElement 或 XmlType 。

最終發現,能夠處理 JSON 字符串及 JSON media 的是類 MappingJackson2HttpMessageConverter 。 注意到,在這個類處理 InputMessage 的時候,還有一個切面。這個切面的實現類是 RequestResponseBodyAdviceChain ,里面應用了責任鏈設計模式來處理請求 InputMessage。對應於 @RequestBody 的切面類是 JsonViewRequestBodyAdvice, 僅在參數上有 @JsonView 注解時才處理。

向前追溯

為什么入口類是 RequestResponseBodyMethodProcessor 呢?可以看看方法 RequestResponseBodyMethodProcessor.resolveArgument 的調用者。進一步追溯可知:

  • Spring 應用里的參數類(比如 BookInfo)對應於 Spring 框架里的 MethodParam ;

  • 在類 HandlerMethodArgumentResolverComposite (應用了組合設計模式)里面含有一個參數解析映射緩存 Map[MethodParam, HandlerMethodArgumentResolver] argumentResolverCache,可以根據指定的 MethodParam 拿到對應的 HandlerMethodArgumentResolver 實現類,即 RequestResponseBodyMethodProcessor。

argumentResolverCache 的內容是獲取參數解析類的時候動態添加進去的,它依賴於一個實例成員 List[HandlerMethodArgumentResolver] argumentResolvers ,而 argumentResolvers 是在方法 RequestMappingHandlerAdapter.getDefaultArgumentResolvers 里添加的,該方法在類 RequestMappingHandlerAdapter 初始化完成后調用。

HandlerMethodArgumentResolverComposite.java


         private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
		HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
		if (result == null) {
			for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
				if (logger.isTraceEnabled()) {
					logger.trace("Testing if argument resolver [" + methodArgumentResolver + "] supports [" +
							parameter.getGenericParameterType() + "]");
				}
				if (methodArgumentResolver.supportsParameter(parameter)) {
					result = methodArgumentResolver;
					this.argumentResolverCache.put(parameter, result);
					break;
				}
			}
		}
		return result;
       	}

看來,參數類解析相關的初始化的奧秘在類 RequestMappingHandlerAdapter 里。

RequestMappingHandlerAdapter.java


	public void afterPropertiesSet() {
		// Do this first, it may add ResponseBody advice beans
		initControllerAdviceCache();

		if (this.argumentResolvers == null) {
			List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
			this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
		}
		if (this.initBinderArgumentResolvers == null) {
			List<HandlerMethodArgumentResolver> resolvers = getDefaultInitBinderArgumentResolvers();
			this.initBinderArgumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
		}
		if (this.returnValueHandlers == null) {
			List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();
			this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);
		}
	}


請求的處理

類 RequestMappingHandlerAdapter 既然負責初始化,那很可能也負責請求處理。可以在 RequestMappingHandlerAdapter 的 public 方法打斷點,然后發請求。果然,經過 RequestMappingHandlerAdapter.invokeHandlerMethod 方法。再查看方法 invokeHandlerMethod 的調用,一直向前追溯調用鏈,最終,可以找到源頭處,即: DispatcherServlet.doService 方法。 好了,可以在這里打個斷點,一步步跟蹤,看看一路經過了哪些類,數據是怎樣的,這樣,就可以理解 MVC 的整個請求處理流程。


引申

不同的參數注解,就對應不同的參數解析類。 比如:

  • @RequestParam => RequestParamMethodArgumentResolver & RequestParamMapMethodArgumentResolver
  • @PathVariable => PathVariableMethodArgumentResolver

這些參數解析類,有兩個主要方法:

  • supportsParameter : 支持解析怎樣的參數形式;
  • resolveName : 具體地解析參數的實現。

RequestParamMethodArgumentResolver

RequestParamMethodArgumentResolver 適用的情況可以看它的 supportsParameter 方法:

  • @RequestPart MultipartFile
  • @RequestPart Part
  • @RequestParam + simple type

后台代碼如下:


@RequestMapping(value = "/searchForSelect")
@ResponseBody
public Map<String, Object> searchForSelect(
    @RequestParam(value = "k", required = false) String title,
    @RequestParam(value = "page", defaultValue = "1") Integer page,
    @RequestParam(value = "rows", defaultValue = "10") Integer pageSize) {
    CreativeQuery query = buildCreativeQuery(title, page, pageSize);
    return searchForSelect2(query,
                            (q) -> creativeService.search(q),
                            (q) -> creativeService.count(q));
  }

發送請求: http://localhost:8080/api/creatives//searchForSelect?page=2&rows=20 ,就會使用類 RequestParamMethodArgumentResolver 來解析參數,得到 page = 2, pageSize = 20。這個值是怎么拿到的呢 ? 是從 request.coyoteRequest.parameters.paramHashValues 里面取到的。


RequestParamMapMethodArgumentResolver

RequestParamMapMethodArgumentResolver 的實現方式與 RequestParamMethodArgumentResolver 相同,也是從 request.coyoteRequest.parameters.paramHashValues 里面取到的。所不同的是,它修飾的參數類型是 Map 。 如下代碼所示:


@RequestMapping(value = "/updateByMap")
@ResponseBody
public BaseResult updateByMap(@RequestParam  Map creative) {
    Assert.notNull(creative, "對象不能為空");
    Assert.notNull(creative.get("creativeId"), "創意ID不能為空");
    CreativeDO creativeObj = JSONObject.parseObject(JSONObject.toJSONString(creative), CreativeDO.class);
    creativeService.update(creativeObj);
    return BaseResult.succ("創意更新成功");
}

發送請求: http://localhost:8080/api/creatives//updateByMap?creativeId=2&content=newContent22&title=newtitle222 ,就會使用類 RequestParamMapMethodArgumentResolver 來解析參數,將 creative 設置為 Map["creativeId"=>2, "content"=>"newContent22", "title" => newtitle222] 。

PathVariableMethodArgumentResolver

適用於請求路徑中含有可變參數的情形。比如 /get/{creativeId} 。 后台代碼如下:


@RequestMapping(value = "/get/{creativeId}")
public String get(@PathVariable Long creativeId, ModelMap model) {
    Assert.notNull(creativeId, "請選擇要查詢的創意id");
    CreativeDO creativeObj = creativeService.get(creativeId);
    model.put("creative", creativeObj);
    return "/creative/detail";
}

發送請求:http://localhost:8080/api/creatives/get/1 ,使用類 PathVariableMethodArgumentResolver 來解析路徑參數,得到請求參數 creativeId = 1 。這個 1 是怎么得到的呢 ? 是方法 PathVariableMethodArgumentResolver.resolveName 從 request.attributes 取出 key = “org.springframework.web.servlet.HandlerMapping.uriTemplateVariables” 的值拿到的。

小結

一切都在代碼里。只要找到了入口,知道了套路,學會單步調試,類似問題解決起來就相對容易了。



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM