vlambda博客
学习文章列表

tomcat如何处理文件上传的


上周同事有个需求,有个web页面支持升级的逻辑的,但升级包的大小比较大(接近2G)。由此引申出来上传前需要判断磁盘是否足够的问题。此时就产生了如下几个问题

  • tomcat如何处理文件上传的是否使用磁盘文件,猜测必然使用磁盘文件,不然文件大时内存不足的。

  • 那么磁盘文件是如何创建,如何删除,系统异常重启后是否会删除?

文件上传协议

协议基础

查看rfc文档(http://www.faqs.org/rfcs/rfc1867.html),该文档描述了带文件的form表单提交的要求。该提案对HTML进行了两项更改:

  • 为INPUT的TYPE属性添加FILE选项。

  • 允许INPUT标签使用ACCEPT属性,该属性是输入所允许的媒体类型或类型模式

此外,它定义了新的MIME媒体类型multipart / form-data,并指定了HTML用户代理在使用ENCTYPE =“ multipart / form-data”和/或<INPUT type =“ file”>解释表单时的行为标签。

协议格式

该文档侧重于tomcat如何处理文件上传请求的,对协议的详细信息不做过多介绍,有想了解的参考rfc文档。更多侧重于协议content内容的格式介绍

简介http数据格式

http数据包中包含3个部分:

  • 请求行

    • 格式为:method url version \r\n (GET /manager/images/asf-logo.svg HTTP/1.1\r\n)

    • method:http请求类型,常见的有GET, POST, PUT,DELETE, OPTION 等

    • url:指定要访问的网络资源,如 /manager/images/asf-logo.svg

    • version:http协议的版本

  • 请求头

    • 格式为:Key: value \r\n (Content-Type: text/plain\r\n)

    • 请求头中包含很多有关客户端的环境和请求正文的有用信息,例如常见的Content-Type,Content-Length等

    • 请求头中包含多行请求信息

  • 请求体

    代表请求中包含的信息,如form表单提交的时候携带的信息,本文描述的文件上传的文件信息等

根据上面的介绍我们看个请求的样例:

Content-Length为45,由于请求体为:deployPath=123&deployConfig=123&deployWar=123 长度就是45。

Content-Type为 application/x-www-form-urlencoded 这个请求是一个普通的form表单的提交。

form-data的content格式

上面介绍了通用的http请求的数据格式, 我们看看form-data的数据请求格式。从rfc文档中可以看到,form-data的请求可以归结为如下几点:

  • 请求头中Content-Type格式为 Content-type: multipart/form-data, boundary=AaB03x

  • boundary要求需要在任何一部分的数据内容中都不能包含

  • 请求体中可以包含多部分,每部分的格式如下:

    1
    2
    3
    4
    boundary (http请求头中的boundary)
    header (当前请求部分的头信息,与http头格式一致)
    \r\n
    data (实际的数据信息)

从上述介绍引申出如下几个问题:

  • tomcat是如何解析,请求的content信息的

  • 如果每一部分的请求数据中包含了boundary的请求下会出现什么问题

tomcat解析流程

核心类简介

  • MultipartConfigElement, 上传文件的配置信息。通常如果是处理文件上传的servlet中我们会配置一些参数,最终会解析为一个MultipartConfigElement对象。配置如下:

    1
    2
    3
    4
    5
    6
    <multipart-config>
    <location></location>
    <max-file-size>10737418240</max-file-size>
    <max-request-size>10737418240</max-request-size>
    <file-size-threshold>1048576</file-size-threshold>
    </multipart-config>
  • FileItem, 由于一个请求中可以包含多份数据,在tomcat中就将每一部分数据定义个一个FileItem对象

  • DiskFileItemFactory, 顾名思义,该类为了生成一份份FileItem对象

  • ServletFileUpload, 该类为解析content内容的入口,尤其基类FileUploadBase进行实际的业务处理

  • FileItemIterator,使用迭代器的方式,将content中的每一部分组装为一个迭代器,依次遍历下来

查看tomcat源码

1、tomcat源码中,全局搜索multipart/form-data,可以在Request中搜索到Request类中有个方法parseParameters,顾名思义是解析参数的。中间有一段判断,如果contentType为multipart/form-data的情况下会调用parseParts方法,解析请求中的每一部分。

1
2
3
4
5
if ("multipart/form-data".equals(contentType)) {
parseParts(false);
success = true;
return;
}

2、接着我们看看parseParts方法中实现,主要看核心的部分,总结来说该方法的主要步骤包括如下几步

  • 从请求上下文中获取的MultipartConfigElement信息

  • 创建DiskFileItemFactory对象

  • 创建ServletFileUpload对象

  • 调用ServletFileUpload的parseRequest对请求进行解析,获取到请求内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取配置信息
Context context = getContext();
MultipartConfigElement mce = getWrapper().getMultipartConfigElement();
// 创建FileItemFactory对象
DiskFileItemFactory factory = new DiskFileItemFactory();
try {
factory.setRepository(location.getCanonicalFile());
} catch (IOException ioe) {
// ignore
}
factory.setSizeThreshold(mce.getFileSizeThreshold());
//创建content解析
ServletFileUpload upload = new ServletFileUpload();
upload.setFileItemFactory(factory);
upload.setFileSizeMax(mce.getMaxFileSize());
upload.setSizeMax(mce.getMaxRequestSize());
// 调用解析方法进行解析
List<FileItem> items = upload.parseRequest(new ServletRequestContext(this));

3、关注ServletFileUpload的parseRequest方法内的实现,依旧关注核心代码的实现,总结来说该方法的实现包括如下几步。

  • 通过request信息获取到FileItemIterator的一个迭代器的对象

  • 遍历迭代器,将迭代器的header信息组装为一个个FileItem对象

  • 将每一部分的数据copy到fileItem的数据中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//创建FileItemIterator对象
FileItemIterator iter = getItemIterator(ctx);
FileItemFactory fac = getFileItemFactory();
if (fac == null) {
throw new NullPointerException("No FileItemFactory has been set.");
}
while (iter.hasNext()) {
//遍历迭代器,创建一个个FileItem对象
final FileItemStream item = iter.next();
final String fileName = ((FileItemIteratorImpl.FileItemStreamImpl) item).name;
FileItem fileItem = fac.createItem(item.getFieldName(), item.getContentType(),
item.isFormField(), fileName);
items.add(fileItem);
try {
Streams.copy(item.openStream(), fileItem.getOutputStream(), true);
} catch (Exception e) {
// ignore
}
final FileItemHeaders fih = item.getHeaders();
fileItem.setHeaders(fih);
}

3、关注迭代器的实现, 一般来说迭代器的会包含hasNext以及next方法,我们关注下迭代器的创建以及hasNext和next的方法,首先看看迭代器的创建的实现, 为了简化代码省略了部分异常的处理。可以把其整理为如下几步

  • 检查请求体的长度是否符合要求,此处的sizeMax是最初从MultipartConfigElement中取得的

  • 将请求上下文的inputstream转化为MultipartStream对象

  • 调用findNextItem方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
FileItemIteratorImpl(RequestContext ctx) throws FileUploadException, IOException {
String contentType = ctx.getContentType();
final long requestSize = ((UploadContext) ctx).contentLength();
if (sizeMax >= 0) {
if (requestSize != -1 && requestSize > sizeMax) {
//检查请求体的长度是否符合要求
throw Exception("max request");
}
input = new LimitedInputStream(ctx.getInputStream(), sizeMax) {
@Override
protected void raiseError(long pSizeMax, long pCount) throws IOException {
throw new FileUploadIOException(ex);
}
};
} else {
input = ctx.getInputStream();
}
String charEncoding = headerEncoding;
if (charEncoding == null) {
charEncoding = ctx.getCharacterEncoding();
}
boundary = getBoundary(contentType);
notifier = new MultipartStream.ProgressNotifier(listener, requestSize);
multi = new MultipartStream(input, boundary, notifier);
multi.setHeaderEncoding(charEncoding);
skipPreamble = true;
findNextItem();
}

4、创建迭代器的关键就到了findNextItem的实现逻辑了, 简化该方法的实现,其逻辑包括如下几个流程。

  • 读取Boundary,即将输入流中的boundary读取指针调整到header中

  • 解析当前部分的header信息内容

  • 将header信息组装为一个FileItemStreamImpl对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//读取Boundary
if (skipPreamble) {
nextPart = multi.skipPreamble();
} else {
nextPart = multi.readBoundary();
}
//读取headers
FileItemHeaders headers = getParsedHeaders(multi.readHeaders());
String fieldName = getFieldName(headers);
String subContentType = headers.getHeader(CONTENT_TYPE);
String fileName = getFileName(headers);
// 创建FileItemStreamImpl对象
currentItem = new FileItemStreamImpl(fileName,fieldName, headers.getHeader(CONTENT_TYPE),
fileName == null, getContentLength(headers));
currentItem.setHeaders(headers);

5、接着我们需要考虑下读取Boundary的逻辑,skipPreamble和readBoundary的差别不大,差别主要在于是否需要先处理掉Boundary之前的数据。正常来说,调用到此处之前已经把boundary之前的数据消费了。skipPreamble中同样调用的有readBoundary数据, 我们就侧重于关注下skipPreamble的实现,核心分为函数computeBoundaryTable, discardBodyData, 和 readBoundary。按照我们协议介绍的,不同的数据段之间是使用boundary进行分割的,那么实现逻辑自然就是从数据流中查找与boundary相匹配的字符串段,字符串匹配的算法有很多,tomcat选择的是KMP算法,具体的逻辑可以简述为:

  • 生成kmp算法的next数组

  • 读取到boundary的位置

  • 读取boundary以及换行符

1
2
3
computeBoundaryTable()
discardBodyData();
return readBoundary();

6、从上述介绍中我们可以大致了解到数据包中的每一部分的解析逻辑,简单介绍为按照boundary的值对数据流进行分割,分割为一段一段的数据处理, 引题的问题,就涉及到了我们对每一部分数据的处理逻辑。那么这片的逻辑如何实现的, 从第3点我们看到的其中逻辑最后一步是将每一部分的数据copy到fileItem的数据中, 那么我们就需要看fileItem中是如何处理数据流的, copy到数据流中,我们就需要看每一部分的read的函数实现以及FileItem的数据流的write的方法的实现:

  • read的实现,相当于将socket中的数据读取到数组中,是否读取完成是以数据是否读取到boundary为止

  • write的实现,先check数据是否达到了Threshold(逻辑),如果达到了上限那么将output由内存转换为FileOutputstream

  • Threshold 实际读取源码可以看到DeferredFileOutputStream的sizeThreshold,最终还是通过MultipartConfigElement中获取的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// read的实现
public int read(byte[] b, int off, int len) throws IOException {
// 检测是否有可用的数据
int res = available();
if (res == 0) {
// 读取数据到缓存数据中,此处如何处理的,从上述解释中我们看到数据是以boundary进行解析的,此处的逻辑简单描述为从socket中读取数据,一致读取到boundary,默认每次读取4096个数据,如果没有分割符不会继续往下读的,等下次调用read的方法后继续读取
res = makeAvailable();
if (res == 0) {
return -1;
}
}
res = Math.min(res, len);
//将读取到的数据更新到buffer中
System.arraycopy(buffer, head, b, off, res);
head += res;
total += res;
return res;
}

//write的实现
public OutputStream getOutputStream()
throws IOException {
if (dfos == null) {
File outputFile = getTempFile();
dfos = new DeferredFileOutputStream(sizeThreshold, outputFile);
}
return dfos;
}
public void write(byte b[], int off, int len) throws IOException{
// 检测是否达到上限,切换内存数据到磁盘数据
checkThreshold(len);
getStream().write(b, off, len);
written += len;
}

7、总结下实现流程

  • 解析到http协议的数据内容时,将content解析为一个个数据部分

  • 每个部分的分割符为header中的boundary信息(数据报文中可能会调整boundary),boundary的匹配是通过KMP算法进行查询的

  • 读取数据的时候优先存储在内存中,如果数据超过MultipartConfigElement的sizeThreshold配置时在读取后将数据存储在磁盘中。

引题问题

1、是否使用磁盘文件

答案:会使用磁盘文件,磁盘文件根据配置的location,如果未配置取默认配置,默认在tomcat工作目录下的work/Catalina下对应host下的,项目目录中。如E:\java\catalina-home\work\Catalina\localhost\manager。其中E:\java\catalina-home是tomcat的启动目录中

2、如何创建,如何删除,系统异常重启后是否会删除

答案: 创建在生成FIleItem的时候会同步生成文件,文件名的是格式为String.format(“upload_%s_%s.tmp”, UID, getUniqueId())。

删除时机:整个service处理完成后会进行删除,Request类中有个recycle的方法,负责资源的释放。

异常重启:此种逻辑tomcat原生的逻辑确实没有进行处理,那么就需要我们根据实际的情况,如果需要处理此种情况的话,需要我们额外的处理进行相应的额外

3、tomcat是如何解析,请求的content信息的

答案:tomcat原生的解析逻辑就是以boundary做为分割符进行分割的数据段的,使用的方法是KMP算法。

4、如果每一部分的请求数据中包含了boundary的请求下会出现什么问题

答案:从源码层面来看,如果数据中包含boundary的情况下确实会影响的数据的解析的,因此说在rfc文档中对于这种情况也有描述,尽可能选择不会出现在文件中的boundary

5、默认实现的思考

答案:上传数据时会遍历全部的数据信息,以此进行相应的算法匹配,效率确实一般。如果时每一部分中包含数据包的长度的情况下会不会更好点,解析的时候就不需要对每一部分的数据进行遍历匹配?