經過`RestTemplate`上傳文件(InputStreamResource詳解)

經過RestTemplate上傳文件

1.上傳文件File

碰到一個需求,在代碼中經過HTTP方式作一個驗證的請求,請求的參數包含了文件類型。想一想其實很簡單,直接使用定義好的MultiValueMap,把文件參數傳入便可。html

咱們知道,restTemplate 默認定義了幾個通用的消息轉換器,見org.springframework.web.client.RestTemplate#RestTemplate(),那麼文件應該對應哪一種資源呢?java

看了上面這個方法以後,能夠很快聯想到是ResourceHttpMessageConverter,從類簽名也能夠看出來:web

Implementation of {@link HttpMessageConverter} that can read/write {@link Resource Resources}
and supports byte range requests.

這個轉換器主要是用來讀寫各類類型的字節請求的。spring

既然是Resource,那麼咱們來看一下它的實現類有哪些: AbstractResource 以上是AbstractResource的實現類,有各類各樣的實現類,從名稱上來講應該比較有用的應該是:InputStreamResourceFileSystemResource,還有ByteArrayResourceUrlResource等。ide

1.1 使用FileSystemResource上傳文件

這種方式使用起來比較簡單,直接把文件轉換成對應的形式便可。函數

MultiValueMap<String, Object> resultMap = new LinkedMultiValueMap<>();
	Resource resource = new FileSystemResource(file);
	param.put("file", resource);

網上使用RestTemplate上傳文件大多數是這種方式,簡單,方便,不用作過多的轉換,直接傳遞參數便可。ui

可是爲何會寫這篇博客來記錄呢?由於,有一個不喜歡的地方就是,它須要傳遞一個文件。而我獲得是文件源是一個流,我須要在本地建立一個臨時文件,而後把InputStream寫入到文件中去。使用完以後,還須要把文件刪除。this

那麼既然這麼麻煩,有沒有更好的方式呢?url

1.2 使用InputStreamResource上傳文件

這個類的構造函數能夠直接傳入流文件。那麼就直接試試吧!spa

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的大小。

InputStreamResourcecontentLength方法是繼承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();
		}
	}

註釋中的兩個標記處,分別會調用contentLengthgetInputStream方法,可是第一個方法會直接返回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方法,這裏會走到各個ResourcegetFileName方法。

真相即將獲得:InputStreamResource 的這個方法是繼承自org.springframework.core.io.AbstractResource#getFilename,這個方法直接返回null。以後的就很簡單了:當fileName爲null時,不會在setContentDispositionFormData中把filename=拼入。因此服務端不會解析到文件,致使報錯。

4. 結論

一、使用RestTemplate上傳文件使用FileSystemResource在直接是文件的狀況下很簡單。 二、若是不想在本地新建臨時文件可使用:InputStreamResource,可是須要覆寫FileName方法。 三、因爲2的緣由,2.2.1 中的contentLength方法,不會對InputStreamResource作特殊處理,而是直接去讀取流,致使流被讀取屢次;按照類簽名,會報錯。因此也須要覆寫contentLength方法。 4. 是因爲2的緣由,才須要3的存在,不過使用方式是對的:使用InputStreamResource須要覆寫兩個方法contentLengthgetFileName

相關文章
相關標籤/搜索