SpringMVC底層資料轉送校正的方案(修改版),springmvc修改版
團隊的項目正常運行了很久,但近期偶爾會出現BUG。目前觀察到的有兩種情境:一是大批量提交業務請求,二是產生大量匯出檔案。出錯後,再執行一次就又正常了。
經過追蹤記錄檔,發現是在Server之間進行json格式大資料量傳輸時會丟失部分字元,造成接收方拿到完整字串後不能正確解析成json,因此報錯。
同其他團隊同事們溝通後發現,不僅僅是我們項目有這個問題,我們不是一個人在戰鬥。
1 問題現象
伺服器之間使用http+json的資料轉送方案,在傳輸過程中,一些json資料發生錯誤,導致資料接收方解析json報錯,系統功能因此失敗。
下面截取了一小段真實資料錯誤,在傳輸的json中,有一個資料項目是departmentIdList,其內容時一個長整型數組。
傳輸之前的資料為:
"departmentIdList" : [ 719, 721, 722, 723, 7367, 7369, 7371, 7373, 7375, 7377 ]
接收到的資料為:
"departmentIdlist" : [ 719, 721'373, 7375, 7377 ]
可以看到,這個錯誤導致了兩個問題:
1、 json解析失敗
2、 丟失了一些有效資料
詳細檢查系統日誌之後,這是偶發bug,並且只在傳輸資料較大時發生。
2 可選的解決方案2.1 請架構組協助解決
這是最直接的解決方案,因為我們項目使用架構組提供的環境,他們需要提供可靠的底層資料轉送機制。
2.2 壓縮傳輸資料
因為資料量大時容易發生,並且傳輸的都是普通文本,可以考慮對內容進行壓縮後傳輸。普通檔案壓縮率也很高,壓縮後內容長度能做到原資料10%以內,極大減少傳輸出錯的幾率。
2.3 對傳輸資料進行MD5校正
將傳輸資料作為一個完整資料區塊,傳輸之前先做一個md5摘要,並將原資料和摘要一併發送;接收方收到資料後,先進行資料校正工作,校正成功後再進行後續操作流程,如果不成功可以輔助重傳或直接報錯等機制。
3 方案設計
為了徹底解決這個問題,設計了一個底層方案
3.1 設計原則
1、 適用類型:Spring MVC項目,資料發送方使用RestTemplate工具類,使用fastjson作為json工具類。
2、 資料校正,使用MD5加密,當然也可以配合資料壓縮機制,減少傳輸資料量。
3、 提供底層解決方案,不需要對系統代碼做大規模調整。
3.2 核心設計
資料發送方,重載RestTemplate,在資料轉送之前對資料進行md5摘要,並將未經處理資料和 md5摘要一併傳輸。
資料接收方,重載AbstractHttpMessageConverter,接收到資料後,對資料進行MD5校正。
3.3 DigestRestTemplate關鍵代碼
對原json進行摘要,並同未經處理資料一起產生一個新的json對象。
private Object digestingJson(JSONObject json) throws Exception { String requestJsonMd5 = JsonDigestUtil.createMD5(json); JSONObject newJson = new JSONObject(); newJson.put("content", json); newJson.put("md5", requestJsonMd5); return newJson; } |
重載的postForEntity函數核心部分,如果傳入參數是 JSONObject,則調用方法對資料進行摘要操作,並用新產生的json進行傳輸。
Object newRequest = null; if (request instanceof JSONObject) { JSONObject json = (JSONObject) request; try { newRequest = digestingJson(json); } catch (Exception e) { } } if (newRequest == null) { newRequest = request; } return super.postForEntity(url, newRequest, responseType); |
3.4 DigestFastJsonHttpMessageConverter 核心代碼
首先會判斷是否是經過md5摘要的json,是有摘要的資料進行校正,否則直接返回對象。
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new RuntimeException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } return json; } |
原有的處理資料代碼增加調用該方法的代碼
@Override protected Object readInternal(Class<? extends Object> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { JSONObject json = null; InputStream in = inputMessage.getBody(); Charset jsonCharset = fastJsonConfig.getCharset(); Feature[] jsonFeatures = fastJsonConfig.getFeatures(); json = JSON.parseObject(in, jsonCharset, clazz, jsonFeatures); json = getDigestedJson(json); return json; } |
當前的代碼,如果資料校正失敗,簡單拋出異常。後續可以增加更多的機制,比如在RestTemplate處增加校正,如果發現校正失敗,則重傳。
3.5 資料發送方項目配置
以Spring Boot項目為例
在Main類中定義 restTemplate
@Bean(name = "restTemplate") public RestTemplate getRestTemplate() { RestTemplate restTemplate = new DigestRestTemplate(); return restTemplate; } |
需要調用RestTemplate的代碼,只需要依賴注入RestTemplate
@Autowired RestTemplate restTemplate; |
3.6 資料接收方項目設定
在SpringBootApplication類中定義
@Bean public HttpMessageConverters fastJsonHttpMessageConverters() { DigestFastJsonHttpMessageConverter fastConverter = new DigestFastJsonHttpMessageConverter(); FastJsonConfig fastJsonConfig = new FastJsonConfig(); fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat); fastConverter.setFastJsonConfig(fastJsonConfig); HttpMessageConverter<?> converter = fastConverter; return new HttpMessageConverters(converter); } |
4 出錯重傳機制
在資料接收端,當資料校正失敗時,會拋出一個RuntimeException異常(如果要做到產品,當然應該自訂一個高大上的Exception)。
4.1 伺服器端隨機類比傳輸失敗
為了類比測試,在接收方的代碼中,增加隨機失敗的情況。見下面代碼中黑體字部分,大約10%的機率會失敗。
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new RuntimeException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } if (random.nextInt(100) < 10) { logger.info("random throw exception"); throw new RuntimeException("content be modified"); } return json; } |
4.2 發送方Catch異常重傳
當接收端拋異常後,最終會發送一個500錯誤到資料發送方。
org.springframework.web.client.HttpServerErrorException: 500 Internal Server Error |
最簡單的處理方式,在發送方校正是否發生了 500 錯誤,如果發生了就重傳。這個方案的代碼如下:
ResponseEntity<T> responseEntity = null; int times = 0; while (times < 5) { try { responseEntity = super.postForEntity(url, newRequest, responseType, uriVariables); break; } catch (Exception e) { if (e instanceof HttpServerErrorException) { times++; logger.error("post for entity", e); logger.error("resend the {}'st times", times); } else { break; } } } |
當傳輸錯誤後,圖示代碼會最多嘗試發送五次。仍然失敗後考慮拋異常,由發送端上層代碼處理。
但這個代碼有一個很明顯的問題,接收端的任何錯誤如資料儲存失敗,都會導致發送端重傳資料。下面讀一下Spring的代碼,看看是如何處理異常的。
4.3 SpringMVC異常處理4.3.1 第一層處理
在類AbstractMessageConverterMethodArgumentResolver的readWithMessageConverters()方法中,會Catch IOException,相關代碼為
catch (IOException ex) { throw new HttpMessageNotReadableException( "Could not read document: " + ex.getMessage(), ex); } |
HttpMessageNotReadableException是繼承自RuntimeException的一個異常。
4.3.2 第二層處理
在類InvocableHandlerMethod的getMethodArgumentValues()方法,Catch Exception列印一下日誌,然後繼續throw。
try { args[i] = this.argumentResolvers.resolveArgument( parameter, mavContainer, request, this.dataBinderFactory); continue; } catch (Exception ex) { if (logger.isDebugEnabled()) { logger.debug(getArgumentResolutionErrorMessage("Failed to resolve", i) , ex); } throw ex; } |
4.3.3 第三層處理
在類org.springframework.web.servlet.DispatcherServlet.doDispatch()分別捕獲了兩種異常,代碼如下
catch (Exception ex) { dispatchException = ex; } catch (Throwable err) { dispatchException = new NestedServletException( "Handler dispatch failed", err); } processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException); |
可以看到,如果拋出的Exception異常,會將原異常直接處理,如果是Runtime Exception,會轉換成繼承自ServletException的異常NestedServletException。
4.3.4 處理異常
在 processDispatchResult() 方法中,異常處理核心代碼
if (exception instanceof ModelAndViewDefiningException) { logger.debug("ModelAndViewDefiningException encountered", exception); mv = ((ModelAndViewDefiningException) exception).getModelAndView(); } else { Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null); mv = processHandlerException(request, response, handler, exception); errorView = (mv != null); } |
我們拋出的異常,明顯不是 ModelAndViewDefiningException,所以會交由processHandlerException處理。看看它的代碼
ModelAndView exMv = null; for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) { exMv =resolver.resolveException(request, response, handler, ex); if (exMv != null) { break; } } …(如果exMv不為空白,會單獨處理) throw ex; |
可以看到,這部分代碼如果沒有處理,會繼續拋出異常,回到 processDispatchResult()
catch (Exception ex) { triggerAfterCompletion(processedRequest, response, mappedHandler, ex); } |
呃,太複雜,先不往下看了。因為我們需要區分是資料轉送錯誤還是其他錯誤,可以考慮資料出錯時拋異常,不拋普通的RuntimeException,而是HttpMessageNotReadableException,看看資料發送端會有什麼變化。
4.3.4 資料接收方拋新異常
修改了資料接收方代碼中拋出異常HttpMessageNotReadableException
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new HttpMessageNotReadableException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } // 調試用,後續刪掉 if (random.nextInt(15) < 10) { logger.info("random throw exception"); throw new HttpMessageNotReadableException("content be modified"); } return json; } |
4.3.5 資料發送端修改代碼
RestClientException transferException = null; ResponseEntity<T> responseEntity = null; int times = 0; while (times < 5) { try { responseEntity = super.postForEntity(url, newRequest, responseType, uriVariables); transferException = null; break; } catch (RestClientException e) { transferException = e; boolean transferError = false; if (e instanceof HttpClientErrorException) { HttpClientErrorException clientError = (HttpClientErrorException) e; transferError = clientError.getRawStatusCode() == 400; } if (transferError) { times++; logger.error("post for entity", e); logger.error("resend the {}'st times", times); } else { break; } } } if(transferException != null){ throw transferException; } return responseEntity; |
如果返回的是400錯誤,發送方會嘗試共發送5次;如果是其他異常或5次都不成功,則拋出異常。
5 後記
經過測試,這個方案是可行的。如果為了能夠適應更多的項目及更多的Java技術棧,需要對代碼進行進一步完善。
補充:第一版發布後,同學們很關心如何重傳的問題。對這個也做了一些測試,補充到文檔中。如果是資料轉送錯誤,會嘗試共傳輸5次;如果仍然不成功則拋出異常由上層代碼處理。