通過RestTemplate
上傳文件
1.上傳文件File
碰到一個需求,在代碼中通過HTTP方式做一個驗證的請求,請求的參數包含了文件類型。想想其實很簡單,直接使用定義好的MultiValueMap
,把文件參數傳入即可。
我們知道,restTemplate 默認定義了幾個通用的消息轉換器,見org.springframework.web.client.RestTemplate#RestTemplate()
,那么文件應該對應哪種資源呢?
看了上面這個方法之后,可以很快聯想到是ResourceHttpMessageConverter
,從類簽名也可以看出來:
Implementation of {@link HttpMessageConverter} that can read/write {@link Resource Resources}
and supports byte range requests.
這個轉換器主要是用來讀寫各種類型的字節請求的。
既然是Resource
,那么我們來看一下它的實現類有哪些:
以上是AbstractResource
的實現類,有各種各樣的實現類,從名稱上來說應該比較有用的應該是:InputStreamResource
和FileSystemResource
,還有ByteArrayResource
和 UrlResource
等。
1.1 使用FileSystemResource
上傳文件
這種方式使用起來比較簡單,直接把文件轉換成對應的形式即可。
MultiValueMap<String, Object> resultMap = new LinkedMultiValueMap<>();
Resource resource = new FileSystemResource(file);
param.put("file", resource);
網上使用RestTemplate
上傳文件大多數是這種方式,簡單,方便,不用做過多的轉換,直接傳遞參數即可。
但是為什么會寫這篇博客來記錄呢?因為,有一個不喜歡的地方就是,它需要傳遞一個文件。而我得到是文件源是一個流,我需要在本地創建一個臨時文件,然后把InputStream
寫入到文件中去。使用完之后,還需要把文件刪除。
那么既然這么麻煩,有沒有更好的方式呢?
1.2 使用InputStreamResource
上傳文件
這個類的構造函數可以直接傳入流文件。那么就直接試試吧!
MultiValueMap<String, Object> resultMap = new LinkedMultiValueMap<>();
Resource resource = new InputStreamResource(inputStream);
param.put("file", resource);
沒有想到,服務端報錯了,返回的是:沒有傳遞文件。這可就納悶了,明明已經有了啊。
網上使用這種方式上傳的方式不多,只找到這么一個文件,但已經夠了:RestTemplate通過InputStreamResource上傳文件.
博主的疑問和我一樣,不想去創建本地文件,然后就使用了這個流的方式。但是也碰到了問題。
文章寫得很清晰:使用InputStreamResource
上傳文件時,需要重寫該類的兩個方法,contentLength
和getFilename
。
果然按照這個文章的思路嘗試之后,就成功了。代碼如下:
public class CommonInputStreamResource extends InputStreamResource {
private int length;
public CommonInputStreamResource(InputStream inputStream) {
super(inputStream);
}
public CommonInputStreamResource(InputStream inputStream, int length) {
super(inputStream);
this.length = length;
}
/**
* 覆寫父類方法
* 如果不重寫這個方法,並且文件有一定大小,那么服務端會出現異常
* {@code The multi-part request contained parameter data (excluding uploaded files) that exceeded}
*
* @return
*/
@Override
public String getFilename() {
return "temp";
}
/**
* 覆寫父類 contentLength 方法
* 因為 {@link org.springframework.core.io.AbstractResource#contentLength()}方法會重新讀取一遍文件,
* 而上傳文件時,restTemplate 會通過這個方法獲取大小。然后當真正需要讀取內容的時候,發現已經讀完,會報如下錯誤。
* <code>
* java.lang.IllegalStateException: InputStream has already been read - do not use InputStreamResource if a stream needs to be read multiple times
* at org.springframework.core.io.InputStreamResource.getInputStream(InputStreamResource.java:96)
* </code>
* <p>
* ref:com.amazonaws.services.s3.model.S3ObjectInputStream#available()
*
* @return
*/
@Override
public long contentLength() {
int estimate = length;
return estimate == 0 ? 1 : estimate;
}
}
關於contentLength
文章里說的很清楚:上傳文件時resttemplate會通過這個方法得到inputstream的大小。
而InputStreamResource
的contentLength
方法是繼承AbstractResource
,它的實現如下:
InputStream is = getInputStream();
Assert.state(is != null, "Resource InputStream must not be null");
try {
long size = 0;
byte[] buf = new byte[255];
int read;
while ((read = is.read(buf)) != -1) {
size += read;
}
return size;
}
finally {
try {
is.close();
}
catch (IOException ex) {
}
}
已經讀完了流,導致會報錯,其實InputStreamResource
的類簽名是已經注明了:如果需要把流讀多次,不要使用它。
Do not use an {@code InputStreamResource} if you need to
keep the resource descriptor somewhere, or if you need to read from a stream
multiple times.
所以需要像我上面一樣改寫一下,然后就可以完成了。那么原理到底是不是這樣呢?繼續看。
2. RestTemplate
上傳文件時的處理
上面我們說到RestTemplate
初始化時,需要注冊幾個消息轉換器,那么其中有一個就是ResourceHTTPMessageConverter
,那么我們看看它完成了哪些功能呢:
方法很少,一下子就可以看完:關於文件大小(contentLength),文件類型(ContentType),讀(readInternal),寫(org.springframework.http.converter.ResourceHttpMessageConverter#writeInternal)等方法。
上面的第二點,我們說InputStreamResource
不做任何處理時,會導致文件多次讀取,那么是怎么做的呢,我們看看源碼:
2.1 第一次讀取
InputStreamResouce
中有兩個讀取流的方法,上面講過,一個是contentLength
,第二個是getInputStream
我們從讀取到了一下代碼:
public final void write(final T t, MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
final HttpHeaders headers = outputMessage.getHeaders();
addDefaultHeaders(headers, t, contentType); //1
if (outputMessage instanceof StreamingHttpOutputMessage) {
StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
@Override
public void writeTo(final OutputStream outputStream) throws IOException {
writeInternal(t, new HttpOutputMessage() {
@Override
public OutputStream getBody() throws IOException {
return outputStream;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
});
}
});
}
else {
writeInternal(t, outputMessage);//2
outputMessage.getBody().flush();
}
}
注釋中的兩個標記處,分別會調用contentLength
和getInputStream
方法,但是第一個方法會直接返回null,不會調用。但是第二個方法會調用一次。
這里說明上傳時,流會被讀第一次。
3. 服務端上傳文件時的處理
文件源
AbstractMultipartHttpServletRequest # multipartFiles
賦值
StandardMultipartHttpServletRequest # parseRequest
需要 disposition ("content-disposition")里有“filename=” 字段或者“filename*=”,從里面獲取 fileName
io.undertow.servlet.spec.HttpServletRequestImpl#loadParts 里對 getParts 賦值
MultiPartParserDefinition #io.undertow.servlet.spec.HttpServletRequestImpl#loadParts 解析 表單數據
- 其中獲取流 ServletInputStreamImpl
按照上面的流程排查下來,沒有發現有什么問題,唯一出問題的地方是請求中的“diposition”字段設置有問題,沒有把filename=
放入,導致解析不到文件。
3.1 重新回到請求體寫入FormHttpMessageConverter#writePart
從這個方法中,我們可以看到各個轉換器的遍歷調用。看看下面的代碼:
private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException {
Object partBody = partEntity.getBody();
Class<?> partType = partBody.getClass();
HttpHeaders partHeaders = partEntity.getHeaders();
MediaType partContentType = partHeaders.getContentType();
for (HttpMessageConverter<?> messageConverter : this.partConverters) {
if (messageConverter.canWrite(partType, partContentType)) {
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os);
multipartMessage.getHeaders().setContentDispositionFormData(name, getFilename(partBody)); // 1
if (!partHeaders.isEmpty()) {
multipartMessage.getHeaders().putAll(partHeaders);
}
((HttpMessageConverter<Object>) messageConverter).write(partBody, partContentType, multipartMessage);
return;
}
}
throw new HttpMessageNotWritableException("Could not write request: no suitable HttpMessageConverter " +
"found for request type [" + partType.getName() + "]");
}
從中我們可以看setContentDispositionFormData
這一行:getFileName
方法,這里會走到各個Resource
的getFileName
方法。
真相即將得到:InputStreamResource
的這個方法是繼承自org.springframework.core.io.AbstractResource#getFilename
,這個方法直接返回null。之后的就很簡單了:當fileName為null時,不會在setContentDispositionFormData
中把filename=
拼入。所以服務端不會解析到文件,導致報錯。
4. 結論
1、使用RestTemplate
上傳文件使用FileSystemResource
在直接是文件的情況下很簡單。
2、如果不想在本地新建臨時文件可以使用:InputStreamResource
,但是需要覆寫FileName
方法。
3、由於2的原因,2.2.1 中的contentLength
方法,不會對InputStreamResource
做特殊處理,而是直接去讀取流,導致流被讀取多次;按照類簽名,會報錯。所以也需要覆寫contentLength
方法。
4. 是由於2的原因,才需要3的存在,不過使用方式是對的:使用InputStreamResource需要覆寫兩個方法contentLength
和getFileName
。