調試釘釘小程序請求的坑-@RequestBody處理form提交數據

問題背景

​ 接口項目本身約定數據都是json格式,使用@RequestBody接收

@RestController
@RequestMapping("/app")
@Transactional(rollbackFor=Exception.class)
public class AppAction {
   
	@PostMapping("/login")
	public LoginResp login(@RequestBody @Valid LoginReq req)throws Exception{
		throw new LogicException(Code.APP_VERSION_LOWER.getCode(),Code
				.APP_VERSION_LOWER.getMessage());
	}
}

​ 但是請求安全需要,請求和響應數據加密解密處理,也就是在http發送post請求的時候,服務端收到的本身是密文,需要解密就纔是 json

ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw/7XfSK2QAKIY0kWsGMlNG92RVQLf079/D7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=

​ 因此使用RequestBodyAdviceResponseBodyAdvice裏面做了解密 和 加密處理,

​ 最近在接釘釘小程序出現了問題,因爲釘釘在發送請求的是時候,會校驗content-type,因爲本身發的密文,不是json 格式,所以content-type=application/json;的時候,發送密文傳是失敗的,因此,只能按照content-type=application/x-www-form-urlencoded發送,

​ 報錯如下:

org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported

​ 服務端肯定失敗,因爲只會處理 json

解決思路:

​ 既然,發送的請求類型不對,那麼我在服務端處理之前包裝下requet,讓它變成json格式,也就是問題變成,如果包裝requst ,把content-type 變成application/json

  • 攔截器

  • Aop

  • 過濾器

    ​ 經過測試,最後選擇使用過濾器,用HttpServletRequestWrapper包裝request 後往後傳遞處理。

新建過濾器

@Component
@WebFilter(urlPatterns = "/app/**")
public class MyRequestFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        ServletRequest wrapperRequest = null;

        if (request instanceof HttpServletRequest) {
            wrapperRequest = new MyRequestWrapper((HttpServletRequest) request);
        }

        chain.doFilter(wrapperRequest, response);
    }

    class MyRequestWrapper extends HttpServletRequestWrapper {
        
        public MyRequestWrapper(HttpServletRequest request) {
            super(request);
        }

        @Override
        public Enumeration<String> getHeaders(String name) {
            if (null != name && name.equals("content-type")) {
                return new Enumeration<String>() {
                    private boolean hasGetted = false;

                    @Override
                    public String nextElement() {
                        if (hasGetted) {
                            throw new NoSuchElementException();
                        } else {
                            hasGetted = true;
                            return MediaType.APPLICATION_JSON_VALUE;
                       }
                    }

                    @Override
                    public boolean hasMoreElements() {
                        return !hasGetted;
                    }
                };

            }
            return super.getHeaders(name);
        }
    }

}

​ 調試發現,還是不行

調試分析

調用鏈

RequestMappingHandlerAdapter#invokeHandlerMethod

ServletInvocableHandlerMethod#invokeAndHandle

InvocableHandlerMethod#invokeForRequest

InvocableHandlerMethod#getMethodArgumentValues

HandlerMethodArgumentResolverComposite#resolveArgument

RequestResponseBodyMethodProcessor#resolveArgument

RequestResponseBodyMethodProcessor#readWithMessageConverters

AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters

調試分析

@SuppressWarnings("unchecked")
@Nullable
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, 				MethodParameter parameter,Type targetType) 
    throws IOException, HttpMediaTypeNotSupportedException,         								HttpMessageNotReadableException {

    MediaType contentType;
    boolean noContentType = false;
    try {
        // 這裏的確是從 header 裏面獲取的,我們包裝類在就是在這裏修改了content-type
        contentType = inputMessage.getHeaders().getContentType();
    }
    catch (InvalidMediaTypeException ex) {
        throw new HttpMediaTypeNotSupportedException(ex.getMessage());
    }
    if (contentType == null) {
        noContentType = true;
        // 沒有conent-type, 默認是 字節流處理  application/octet-stream
        contentType = MediaType.APPLICATION_OCTET_STREAM;
    }

    ......
    HttpMethod httpMethod = (inputMessage instanceof HttpRequest ? ((HttpRequest) inputMessage).getMethod() : null);
    Object body = NO_VALUE;

    EmptyBodyCheckingHttpInputMessage message;
    try {
        // 這裏是關鍵,待會分析
        message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
		// 下面就是 各種 HttpMessageConverter 調用,什麼json string 都是在這裏處理
        // json ---MappingJackson2HttpMessageConverter
        // 自定義的 faston ---- FastonHttpMessageConverter
        // string ----StringHttpMessageConverter
        // form ----FormHttpMessageConverter
        for (HttpMessageConverter<?> converter : this.messageConverters) {
            Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
            GenericHttpMessageConverter<?> genericConverter =
                (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
            if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
                (targetClass != null && converter.canRead(targetClass, contentType))) {
                // 處理各種  RequestBodyAdvice  
                if (message.hasBody()) {
                    // 前置處理  beforeBodyRead
                    HttpInputMessage msgToUse =
                        getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
                    body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
                            ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
                    // 後置處理  afterBodyRead
                    body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
                }
                else {
                    body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
                }
                break;
            }
        }
    }
    catch (IOException ex) {
        throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
    }

    // body == null 經常報錯  Request body  is missing 就是在這裏了
    if (body == NO_VALUE) {
        if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
            (noContentType && !message.hasBody())) {
            return null;
        }
        // 開始的那個報錯 Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported  就是這裏拋出的
        throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes);
    }

    // 以上步驟都對
    MediaType selectedContentType = contentType;
    Object theBody = body;
    // 日誌輸出 請求封裝結果
    LogFormatUtils.traceDebug(logger, traceOn -> {
        String formatted = LogFormatUtils.formatValue(theBody, !traceOn);
      //Read "application/json;charset=UTF-8" to [LoginReq{userName='dura-sale-1', password='96e79218965eb72c92a549dd5a330112', lon=null, lat=null, ci (truncated)...]
        return "Read \"" + selectedContentType + "\" to [" + formatted + "]";
    });

    return body;
}

​ 繼續分析EmptyBodyCheckingHttpInputMessage

// 把HttpInputMessage 包裝爲  EmptyBodyCheckingHttpInputMessage
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);

​ 所以,其實,各種請求的信息,還是從HttpInputMessage裏面獲取的

​ 查看構造函數

public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException {
    this.headers = inputMessage.getHeaders();
    // 這裏很關鍵,直接去獲取請求的 inputStream了
    InputStream inputStream = inputMessage.getBody();
    ......
}

​ 繼續查看getBody

//ServletServerHttpRequest#getBody
@Override
public InputStream getBody() throws IOException {
    // 判斷是不是 form 表單,
    //也就是content-type=application/x-www-form-urlencoded;charset=UTF-8
    if (isFormPost(this.servletRequest)) {
        // 是的話,獲取的 request.getParameterMaps()
        return getBodyFromServletRequestParameters(this.servletRequest);
    }
    else {
        // 不是的話,獲取 request.getInputStream();
        return this.servletRequest.getInputStream();
    }
}

​ 但是很坑的是 isFormPost的判斷

//ServletServerHttpRequest#isFormPost
private static boolean isFormPost(HttpServletRequest request) {
    // 直接獲取的是 request.getContentType(),而不是 request.getHeader("content-type")
    String contentType = request.getContentType();
    return (contentType != null && contentType.contains(FORM_CONTENT_TYPE) &&
            HttpMethod.POST.matches(request.getMethod()));
}

​ 這就很坑了啊,說明,我們在HttpServletRequestWrapper修改的header裏面的conent-type沒起到作用啊 ,所以肯定走到邏輯request.getInputStream();去處理了

​ 這裏的處理,後續也可以,但是仍然有個很坑的問題,參數處理中直接把特殊字符轉義了,導致解密邏輯失敗

​ 比如密文是:

ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw/7XfSK2QAKIY0kWsGMlNG92RVQLf079/D7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=
//被轉義的密文
ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw%2F7XfSK2QAKIY0kWsGMlNG92RVQLf079%2FD7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=

​ 這還是隻是/被轉義了,其他沒有用到的,還不知道多少坑呢,所以換個角度處理

​ 上面分析,既然判斷邏輯是request.getContentType(),所以,覆蓋一下嘛,修改MyRequestWrapper,添加如下:

public String getContentType() {
    return MediaType.APPLICATION_JSON_UTF8_VALUE;
}

​ 繼續調試,報錯如下:

org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing:

​ 爲什麼呢,因爲剛纔判斷isFormPost=false,是從inputStream裏面獲取輸入流,所以,也要覆寫一下,繼續修改,添加如下:

@Override
public ServletInputStream getInputStream() throws IOException {
    return getRequest().getInputStream();
}

​ 但是,還是提示 Required request body is missing,繼續調試分析

​ 奇怪,getBody()返回的是inputStream,但是上面已經添加了getInputStream(),到底幾個意思啊,也就是說,我們getRequest().getInputStream();返回的輸入流不對?

public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException {
    this.headers = inputMessage.getHeaders();
    InputStream inputStream = inputMessage.getBody();
    // 輸入流,是否支持?
    if (inputStream.markSupported()) {
        inputStream.mark(1);
        this.body = (inputStream.read() != -1 ? inputStream : null);
        inputStream.reset();
    }
    else {
        // 走到這個邏輯了
        PushbackInputStream pushbackInputStream = new PushbackInputStream(inputStream);
        int b = pushbackInputStream.read();
        if (b == -1) {
            // 所以 報錯 Required request body is missing
            this.body = null;
        }
        else {
            this.body = pushbackInputStream;
            pushbackInputStream.unread(b);
        }
    }
}

​ 最開始的的cotent-type=application/x-www-form-urlencoded;charset=UTF-8,屬於表單提交數據,所以是用request.getParamMaps()去獲取參數處理,所以request.getInputStream.markSupported()=false,而我們只是強制修改了content-type=application-json;,可以去獲取request.getInputStream(),但是確是不支持的,也就是不可read

​ 思考,在包裝類MyRequestWrapper裏面,自己獲取一下內容,然後轉換爲inputStream返回,繼續修改,完整代碼如下:

class MyRequestWrapper extends HttpServletRequestWrapper {

    private String body;

    public MyRequestWrapper(HttpServletRequest request) {
        super(request);
        try {
            String encrypt = IOUtils.toString(request.getInputStream());
            log.debug("請求密文:" + encrypt);
            this.body = encrypt;
        } catch (IOException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public Enumeration<String> getHeaders(String name) {
        if (null != name && name.equals("content-type")) {
            return new Enumeration<String>() {
                private boolean hasGetted = false;

                @Override
                public String nextElement() {
                    if (hasGetted) {
                        throw new NoSuchElementException();
                    } else {
                        hasGetted = true;
                        return MediaType.APPLICATION_JSON_VALUE;
                    }
                }
                @Override
                public boolean hasMoreElements() {
                    return !hasGetted;
                }
            };

        }
        return super.getHeaders(name);
    }

    public String getContentType() {
        return MediaType.APPLICATION_JSON_UTF8_VALUE;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        InputStream inputStream = IOUtils.toInputStream(this.getBody());
        ServletInputStream servletInputStream = new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public int read() throws IOException {
                return inputStream.read();
            }
        };
        return servletInputStream;

    }

    public String getBody() {
        return this.body;
    }

}

​ 再次調試,果然可以了,

​ 另外,雖然判斷isFormPost的邏輯是使用request.getContentType來判斷,但是判斷支持的content-ype類型,卻的確是從header裏面獲取判斷的,略坑,所以我們最初包裝類裏面設置header的代碼還必須保留,不然報錯

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章