背景
最近在做一个技术项目的迁移,将老的springMVC项目迁移到SpringWebFlux项目中,在流量迁移过程中发现有一个业务方传过来的参数新项目拿不到,究其原因是老版本的spring解析器和新版本的解析器对multipart/form-data
类型的contentType解析方式不一致。
复现请求
发送请求
@Autowired
private RestTemplate restTemplate;
@Test
public void testMutiplepart() {
String url = "https://localhost:8080/api/test?category_id=115348&app_id=1000912×tamp=1673339039&sig=041b5b6e4e6eae430208f9fbc45dc3a8";
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>(5);
map.add("category_id", "115348");
//注意,这里是int类型
map.add("app_id", 1000912);
//注意,这里是String类型
map.add("app_food_code", "1253");
map.add("timestamp", 1673339039);
map.add("sig", "041b5b6e4e6eae430208f9fbc45dc3a8");
String result = restTemplate.postForObject(url, map, String.class);
System.out.println(result);
}
请求原始body
--xe3PJOFEjosCT8h0phpVSBCgSQK7kLtcY0JEj
Content-Disposition: form-data; name="category_id"
Content-Type: text/plain;charset=UTF-8
Content-Length: 6
115348
--xe3PJOFEjosCT8h0phpVSBCgSQK7kLtcY0JEj
Content-Disposition: form-data; name="app_id"
Content-Type: application/json
1000912
--xe3PJOFEjosCT8h0phpVSBCgSQK7kLtcY0JEj
Content-Disposition: form-data; name="app_food_code"
Content-Type: text/plain;charset=UTF-8
Content-Length: 4
1253
--xe3PJOFEjosCT8h0phpVSBCgSQK7kLtcY0JEj
Content-Disposition: form-data; name="timestamp"
Content-Type: application/json
1673339039
--xe3PJOFEjosCT8h0phpVSBCgSQK7kLtcY0JEj
Content-Disposition: form-data; name="sig"
Content-Type: text/plain;charset=UTF-8
Content-Length: 32
041b5b6e4e6eae430208f9fbc45dc3a8
webFlux解析
在解析每一个part的时候,会根据header
org.springframework.http.codec.multipart.PartGenerator#newPart
private void newPart(State currentState, HttpHeaders headers) {
//如果是formField
if (isFormField(headers)) {
changeStateInternal(new FormFieldState(headers));
requestToken();
}
else if (!this.streaming) {
changeStateInternal(new InMemoryState(headers));
requestToken();
}
else {
Flux<DataBuffer> streamingContent = Flux.create(contentSink -> {
State newState = new StreamingState(contentSink);
if (changeState(currentState, newState)) {
contentSink.onRequest(l -> requestToken());
requestToken();
}
});
emitPart(DefaultParts.part(headers, streamingContent));
}
}
判断是否是formFiled的条件
private static boolean isFormField(HttpHeaders headers) {
MediaType contentType = headers.getContentType();
//判断条件是MediType必须为Text/Plain的子类型
return (contentType == null || MediaType.TEXT_PLAIN.equalsTypeAndSubtype(contentType))
&& headers.getContentDisposition().getFilename() == null;
}
CommonsMultipartResolver解析
org.apache.commons.fileupload.FileUploadBase.FileItemIteratorImpl#findNextItem
注意看,老版本的解析是判断文件名是否为空来决定它是不是一个formField。
String fieldName = getFieldName(headers);
if (fieldName != null) {
String subContentType = headers.getHeader(CONTENT_TYPE);
if (subContentType != null
&& subContentType.toLowerCase(Locale.ENGLISH)
.startsWith(MULTIPART_MIXED)) {
currentFieldName = fieldName;
// Multiple files associated with this field name
byte[] subBoundary = getBoundary(subContentType);
multi.setBoundary(subBoundary);
skipPreamble = true;
continue;
}
String fileName = getFileName(headers);
currentItem = new FileItemStreamImpl(fileName,
fieldName, headers.getHeader(CONTENT_TYPE),
//如果文件名为空,则是表单类型
fileName == null, getContentLength(headers));
currentItem.setHeaders(headers);
notifier.noteItem();
itemValid = true;
return true;
}
如果子类型为application/json,也会被直接解析成formField。
CommonsMultipartResolver解析Multiparts
protected MultipartParsingResult parseFileItems(List<FileItem> fileItems, String encoding) {
MultiValueMap<String, MultipartFile> multipartFiles = new LinkedMultiValueMap<String, MultipartFile>();
Map<String, String[]> multipartParameters = new HashMap<String, String[]>();
Map<String, String> multipartParameterContentTypes = new HashMap<String, String>();
for (FileItem fileItem : fileItems) {
//这里判断是否是表单类型的
if (fileItem.isFormField()) {
String value;
String partEncoding = determineEncoding(fileItem.getContentType(), encoding);
if (partEncoding != null) {
try {
value = fileItem.getString(partEncoding);
}
catch (UnsupportedEncodingException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Could not decode multipart item '" + fileItem.getFieldName() +
"' with encoding '" + partEncoding + "': using platform default");
}
value = fileItem.getString();
}
}
else {
value = fileItem.getString();
}
String[] curParam = multipartParameters.get(fileItem.getFieldName());
if (curParam == null) {
// simple form field
multipartParameters.put(fileItem.getFieldName(), new String[] {value});
}
else {
// array of simple form fields
String[] newParam = StringUtils.addStringToArray(curParam, value);
multipartParameters.put(fileItem.getFieldName(), newParam);
}
multipartParameterContentTypes.put(fileItem.getFieldName(), fileItem.getContentType());
}
else {
// multipart file field
CommonsMultipartFile file = new CommonsMultipartFile(fileItem);
multipartFiles.add(file.getName(), file);
if (logger.isDebugEnabled()) {
logger.debug("Found multipart file [" + file.getName() + "] of size " + file.getSize() +
" bytes with original filename [" + file.getOriginalFilename() + "], stored " +
file.getStorageDescription());
}
}
}
return new MultipartParsingResult(multipartFiles, multipartParameters, multipartParameterContentTypes);
}
解决方案
1.将MultiValueMap里面的value都转为String类型。
@Autowired
private RestTemplate restTemplate;
@Test
public void testMutiplepart() {
String url = "https://localhost:8080/api/test?category_id=115348&app_id=1000912×tamp=1673339039&sig=041b5b6e4e6eae430208f9fbc45dc3a8";
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>(5);
map.add("category_id", "115348");
map.add("app_id", "1000912");
//注意,这里是String类型
map.add("app_food_code", "1253");
map.add("timestamp", "1673339039");
map.add("sig", "041b5b6e4e6eae430208f9fbc45dc3a8");
String result = restTemplate.postForObject(url, map, String.class);
System.out.println(result);
}
2.指定contentType
public void testMutiplepart() {
String url = "http://localhost:8081/multipartPart?category_id=115348&app_id=1000912×tamp=1673339039&sig=041b5b6e4e6eae430208f9fbc45dc3a8";
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>(5);
map.add("category_id", "115348");
map.add("app_id", 1000912);
map.add("app_food_code", "1253");
map.add("timestamp", 1673339039);
map.add("sig", "041b5b6e4e6eae430208f9fbc45dc3a8");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(map, headers);
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
System.out.println(exchange.getBody());
}
为什么contentType会变成application/json
如果value值不是string,则是multipart
private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
if (contentType != null) {
return contentType.getType().equalsIgnoreCase("multipart");
}
for (List<?> values : map.values()) {
for (Object value : values) {
//如果value值不是string,则是multipart
if (value != null && !(value instanceof String)) {
return true;
}
}
}
return false;
}
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
if (headers.getContentType() == null) {
MediaType contentTypeToUse = contentType;
if (contentType == null || !contentType.isConcrete()) {
contentTypeToUse = getDefaultContentType(t);
}
else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
MediaType mediaType = getDefaultContentType(t);
contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
}
if (contentTypeToUse != null) {
if (contentTypeToUse.getCharset() == null) {
Charset defaultCharset = getDefaultCharset();
if (defaultCharset != null) {
contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
}
}
//在此处设置了application/json类型的contentType
headers.setContentType(contentTypeToUse);
}
}
if (headers.getContentLength() < 0 && !headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) {
Long contentLength = getContentLength(t, headers.getContentType());
if (contentLength != null) {
headers.setContentLength(contentLength);
}
}
}