自Spring Framework 5.3发布以来,已经有一段时间了。 该版本中的一个功能是对我们的Reactive Multipart支持进行了大修。 在这篇博文中,我们分享了在开发该功能时学到的一些知识。 特别是,我们专注于在字节缓冲区的流中寻找一个标记物。
多部分表单数据
每当你上传一个文件,你的浏览器就会把它--以及表单中的其他字段--作为一个multipart/form-data 消息发送给服务器。这些消息的确切格式在RFC 7578中有所描述。如果你提交一个简单的表单,其中有一个名为foo 的单一文本字段和一个名为file 的文件选择器,multipart/form-data 消息看起来像这样。
POST / HTTP/1.1
Host: example.com
Content-Type: multipart/form-data;boundary="boundary" (1)
--boundary (2)
Content-Disposition: form-data; name="foo" (3)
bar
--boundary (4)
Content-Disposition: form-data; name="file"; filename="lorum.txt" (5)
Content-Type: text/plain
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam.
--boundary-- (6)
-
消息的
Content-Type头部包含boundary参数。 -
该边界用于开始第一部分。它的前面是
--。 -
第一部分包含文本字段的值,
foo,从部分标题中可以看出。该字段的值是bar。 -
边界是用来分隔第一和第二部分的。同样,它的前面是
--。 -
第二部分包含提交的文件的内容,名为
lorum.txt。 -
信息的结束由边界表示。它的前面和后面都是
--。
寻找边界
multipart/form-data 信息中的边界是相当重要的。它被指定为Content-Type 头部的一个参数。当前面有两个连字符(-- )时,边界表示新部分的开始。当后面也有-- ,边界表示信息的结束。
在解析多部分信息时,在传入的字节缓冲区流中找到边界是关键。 这样做似乎很简单。
private int indexOf(DataBuffer source, byte[] target) {
int max = source.readableByteCount() - target.length + 1;
for (int i = 0; i < max; i++) {
boolean found = true;
for (int j = 0; j < target.length; j++) {
if (source.getByte(i + j) != target[j]) {
found = false;
break;
}
}
if (found) {
return i;
}
}
return -1;
}
然而,有一个复杂的问题:边界可能被分割成两个缓冲区,在Reactive环境中,这两个缓冲区可能不是同时到达的。 例如,鉴于前面显示的多部分消息样本,第一个缓冲区可能包含以下内容。
POST / HTTP/1.1
Host: example.com
Content-Type: multipart/form-data;boundary="boundary"
--boundary
Content-Disposition: form-data; name="foo"
bar
--bou
而下一个缓冲区包含其余部分。
ndary
Content-Disposition: form-data; name="file"; filename="lorum.txt"
Content-Type: text/plain
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam.
--boundary--
如果我们一次只检查一个缓冲区,我们就无法找到像这样的分割边界。 相反,我们需要找到多个缓冲区的边界。
解决这个问题的一个方法是等到所有的缓冲区都收到后,把它们连接起来,然后再定位边界。 下面的例子就是这样做的,使用一个样本流和前面定义的indexOf 方法。
Flux<DataBuffer> stream = Flux.just("foo", "bar", "--boun", "dary", "baz")
.map(s -> factory.wrap(s.getBytes(UTF_8)));
byte[] boundary = "--boundary".getBytes(UTF_8);
Mono<Integer> result = DataBufferUtils.join(stream)
.map(joined -> indexOf(joined, boundary));
StepVerifier.create(result)
.expectNext(6)
.verifyComplete();
使用Reactor的StepVerifier ,我们看到边界开始于索引6。
这种方法有一个主要的缺点:将多个缓冲区连接成一个缓冲区,实际上是将整个多部分信息存储在内存中。 多部分信息主要用于上传(大)文件,所以这不是一个可行的选择。 相反,我们需要一个更聪明的方法来定位边界。
Knuth来拯救!
幸运的是,这样的方法以Knuth-Morris-Pratt算法的形式存在。 该算法的主要思想是,如果我们已经匹配了边界的几个字节,但下一个字节不匹配,我们不需要从头开始重新启动。 为此,该算法保持状态,其形式是在一个预先计算的表中的一个位置,其中包含不匹配后可跳过的字节数。
在Spring Framework中,我们已经将Knuth-Morris-Pratt算法在 Matcher接口中实现了Knuth-Morris-Pratt算法,你可以通过该接口获得一个实例。 DataBufferUtils::matcher你也可以查看源代码。
在这里,我们使用Matcher ,给我们提供boundary 在stream 中的结束索引,使用与前面相同的样本输入。
Flux<DataBuffer> stream = Flux.just("foo", "bar", "--boun", "dary", "baz")
.map(s -> factory.wrap(s.getBytes(UTF_8)));
byte[] boundary = "--boundary".getBytes(UTF_8);
DataBufferUtils.Matcher matcher = DataBufferUtils.matcher(boundary);
Flux<Integer> result = stream.map(matcher::match);
StepVerifier.create(result)
.expectNext(-1)
.expectNext(-1)
.expectNext(-1)
.expectNext(3)
.expectNext(-1)
.verifyComplete();
请注意,Knuth-Morris-Pratt算法给出的是边界的结束索引,这就解释了测试结果:边界直到倒数第二个缓冲区的索引3才结束。
可以预料,Spring Framework的MultipartParser ,大量使用了Matcher ,因为
-
通过寻找以
--为前缀的边界来找到第一个边界。 -
通过寻找以
CRLF(上一部分结束)和--为前缀的边界来寻找后续边界。 -
找到
CRLFCRLF部分标题和部分正文之间的分隔符。
如果你需要在字节缓冲流中找到一系列的字节,可以试试Matcher!