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


通過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
以上是AbstractResource的實現類,有各種各樣的實現類,從名稱上來說應該比較有用的應該是:InputStreamResourceFileSystemResource,還有ByteArrayResourceUrlResource等。

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

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. 結論

1、使用RestTemplate上傳文件使用FileSystemResource在直接是文件的情況下很簡單。
2、如果不想在本地新建臨時文件可以使用:InputStreamResource,但是需要覆寫FileName方法。
3、由於2的原因,2.2.1 中的contentLength方法,不會對InputStreamResource做特殊處理,而是直接去讀取流,導致流被讀取多次;按照類簽名,會報錯。所以也需要覆寫contentLength方法。
4. 是由於2的原因,才需要3的存在,不過使用方式是對的:使用InputStreamResource需要覆寫兩個方法contentLengthgetFileName


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM