通过RestTemplate上传文件

通过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上传文件时,需要重写该类的两个方法,contentLengthgetFilename

果然按照这个文章的思路尝试之后,就成功了。代码如下:

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方法。

  1. 是由于2的原因,才需要3的存在,不过使用方式是对的:使用InputStreamResource需要覆写两个方法contentLengthgetFileName
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,992评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,212评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,535评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,197评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,310评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,383评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,409评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,191评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,621评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,910评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,084评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,763评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,403评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,083评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,318评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,946评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,967评论 2 351

推荐阅读更多精彩内容