本文为《Http实战》系列最后一篇,本文主要探讨在使用HTTP协议进行大文件传输时我们经常会使用到的三个特性
- 内容编码
- 传输编码
- 范围请求
内容编码
https://datatracker.ietf.org/doc/html/rfc2616#section-3.5
在介绍这部分内容之前有必要对一个概念进行说明:实体。如果把 HTTP报文想象成因特网货运系统中的箱子,那么 HTTP实体就是报文中实际的货物。下图展示了一个简单的实体,封装在 HTTP 响应报文中。
实体首部指出这是一个纯文本文档(Content-Type:text/plain),它只有18个字节长(Content-Length:18)。一个空白行(CRLF)把首部同主体的开始部分分隔开来。
实体首部的作用在于对实体主体进行描述,例如上图中的Content-length用于说明主体数据的长度
HTTP/1.1 版定义了以下 10 个基本实体首部字段。
- Content-Type 实体中所承载对象的类型。
- Content-Length 所传送实体主体的长度或大小。
- Content-Language 与所传送对象最相配的人类语言。
- Content-Encoding 内容编码,接下来我们会详细讨论
- Content-Location 一个备用位置,请求时可通过它获得对象。
- Content-Range 如果这是部分实体,这个首部说明它是整体的哪个部分。
- Content-MD5 实体主体内容的校验和。
- Last-Modified 所传输内容在服务器上创建或最后修改的日期时间。
- Expires 实体数据将要失效的日期时间。
- Allow
- 该资源所允许的各种请求方法,例如,GET 和 HEAD。
- ETag
- 这份文档特定实例的唯一标识码。ETag 首部没有正式定义为实体首部,但它对许多涉及实体的操作来说,都是一个重要的首部。
- Cache-Control
- 指出应该如何缓存该文档。和 ETag 首部类似,Cache-Control首部也没有正式定义为实体首部。
其中的**【Content-Encoding】**字段就是本文想要介绍的内容编码。HTTP定义了一些标准的内容编码类型,并允许用扩展的形式添加更多的编码
编码类型 |
备注 |
|
gzip |
表明对主体数据采用GNU zip编码 |
|
compress |
表明采用Unix的文件压缩程序对主体数据进行压缩 |
|
deflate |
表明主体数据是用zlib的格式压缩的 |
|
identity | 表明没有对实体进行编码。当没有Content-Encoding header时,就默认为这种情况 |
gzip,compress, 以及deflate编码都是无损压缩算法,用于减少传输报文的大小,不会导致信息损失。 其中gzip通常效率最高, 使用最为广泛。
过程
内容编码的过程如下:
客户端在对主体数据进行编码后,需要在实体首部中添加**Content-Encoding: gzip**,表示使用gzip作为本次内容编码的编码类型,同时如果要携带Content-length这个请求头的话,那么Content-length的值要是编码后的数据长度。
服务端在接收到客户端编码过的数据后需要先对数据进行解码,得到原始数据再进行后续业务操作!
代码示例
客户端:
public class ContentEncodingHttpClient { static final CloseableHttpClient HTTP_CLIENT = HttpClientBuilder.create().build(); public static void main(String[] args) throws Exception { final HttpPost post = new HttpPost("http://127.0.0.1:8080"); post.setEntity(new GzipCompressingEntity(new StringEntity("Hi! I'm a message!"))); final CloseableHttpResponse execute = HTTP_CLIENT.execute(post); System.out.println(EntityUtils.toString(execute.getEntity())); post.releaseConnection(); } }
我们直接使用apache的httpclient实现一个简单的客户端。httpclient提供了一个GzipCompressingEntity,代码如下:
这个类中有一个需要注意的方法:
@Override public boolean isChunked() { // force content chunking return true; }
isChunked代表是否要使用分块传输,使用GzipCompressingEntity这个类时默认就会使用分块传输,这也很好理解:GzipCompressingEntity的主要作用在于进行数据压缩,只有数据较大时才有必要进行压缩,而大数据在进行传输时使用分块传输是一个很好的选择,在后文我们会详细介绍分块传输。
服务端:
public class NettyHttpServer { public static void main(String[] args) throws Exception { // Configure the server. EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.option(ChannelOption.SO_BACKLOG, 1024); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO)) .childHandler(new HttpHelloWorldServerInitializer()); Channel ch = b.bind(8080).sync().channel(); ch.closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } } /** 跟之前文章中搭建的服务端唯一的区别在于:添加了一个用于解码器,用于解码客户端传入的数据 **/ public class ContentEncodingServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof FullHttpRequest) { final FullHttpRequest fullHttpRequest = (FullHttpRequest) msg; final ByteBuf content = fullHttpRequest.content(); byte[] bytes = new byte[content.readableBytes()]; content.readBytes(bytes); // 对传入的gzip数据进行解码并输出到控制台 final GzipDecompressingEntity gzipDecompressingEntity = new GzipDecompressingEntity(new ByteArrayEntity(bytes)); System.out.println("编码后数据长度为"+fullHttpRequest.headers().get("content-length")); System.out.print("解码后数据为:"); gzipDecompressingEntity.writeTo(System.out); } super.channelRead(ctx,msg); } } public class HttpHelloWorldServerInitializer extends ChannelInitializer<SocketChannel> { @Override public void initChannel(SocketChannel ch) { ChannelPipeline p = ch.pipeline(); p.addLast(new HttpServerCodec()); p.addLast(new HttpServerKeepAliveHandler()); p.addLast(new HttpObjectAggregator(65535)); p.addLast(new ContentEncodingServerHandler()); p.addLast(new HttpHelloWorldServerHandler()); } }
服务端的代码很简单,我们只是添加了一个handler用于解码客户端通过gizp的方式压缩的数据并输出到控制台,这里就不再做多余的说明。
传输编码
我们首先要明白传输编码跟内容编码的区别。内容编码关注的是将传输的实体数据转换成什么样的格式更加合适,一般是跟内容格式相关的,例如,你可能会用 gzip 压缩文本文件,但不是 JPEG 文 件,因为 JPEG 这类东西用 gzip 压缩的不够好,而传输编码关注的是以什么样的方式将数据传输出去,并不在意内容本身的格式。
分块编码
传输编码中最常见也是最重要的一个编码方式就是:分块编码,这也是本文的重点。
如果需要使用分块传输编码的响应格式,我们需要在HTTP中设置头部字段:Transfer-Encoding: chunked。以客户端向服务端发送数据为例,下面是一个请求报文:
POST / HTTP/1.1 Transfer-Encoding: chunked Content-Type: text/plain; charset=ISO-8859-1 Host: 127.0.0.1:8080 Connection: Keep-Alive User-Agent: Apache-HttpClient/4.5.13 (Java/1.8.0_311) Accept-Encoding: gzip,deflate 12 Hi! I'm a message! 0
跟正常的报文相比,使用分块编码的特殊在于:正如我们前面提到的,必须要有一个头部字段:Transfer-Encoding: chunked。另外,我们关注传输的实体数据,12实际是一个16进制数,它代表分块传输的第一块数据的长度为18(16进制数12转换为10进制的话就是18),Hi! I'm a message!是实际传输的数据,最后一行的0代表到了数据传输结束。
关于分块编码本文想要说明的一点是:**分块编码不仅仅是用于服务端向客户端响应数据的时候,客户端主动向服务器发送数据时也能使用分块的方式对数据进行编码。**实际上在上面的我举的就是一个客户端向服务端发送数据的例子,接下来为了证明这一点,我们一起看看apache httpclient怎么使用分块传输发送数据,示例代码如下:
public class ContentEncodingHttpClient { static final CloseableHttpClient HTTP_CLIENT = HttpClientBuilder.create().build(); public static void main(String[] args) throws Exception { final HttpPost post = new HttpPost("http://127.0.0.1:8080"); final StringEntity entity = new StringEntity("Hi! I'm a message!"); // 使用分块编码发送数据 entity.setChunked(true); post.setEntity(entity); final CloseableHttpResponse execute = HTTP_CLIENT.execute(post); System.out.println(EntityUtils.toString(execute.getEntity())); post.releaseConnection(); } }
实际上使用这段代码发送请求时通过wireshark抓包得到的数据就是上面示例中的请求报文
在这里我也带大家看看httpclient实现分块编码的代码:
1.org.apache.http.impl.DefaultBHttpClientConnection#sendRequestEntity,这个方法会向服务端写入请求体中的数据
2.org.apache.http.impl.BHttpConnectionBase#createOutputStream,创建一个用于分块传输的outputStream。
3.org.apache.http.impl.io.ChunkedOutputStream#write(byte[], int, int),写入分块数据
这里首先会判断当前buffer中的剩余容量是否足够,如果容量不足的话将当前buffer中的数据以及本次要发送的数据一次作为一个大的chunk一次写入到服务端。如果容量足够的话,直接将本次要发送的数据copy到buffer中,之后调用flush方法将数据刷到服务端。
4.org.apache.http.impl.io.ChunkedOutputStream#flushCache,写入数据到服务端,在这里可以看到分块编码的规则
5.org.apache.http.impl.io.ChunkedOutputStream#writeClosingChunk,写入分块编码结束标志:0
服务端使用分块编码向客户端写入数据的原理也大致一致,这里就不再赘述。
范围请求
范围请求主要是针对较大的文件的请求或者上传,可以仅操作它的某一段。
一个比较常见的场景,就是断点续传/下载,在网络情况不好的时候,可以在断开连接以后,仅继续获取部分内容。例如在网上下载软件,已经下载了 95% 了,此时网络断了,如果不支持范围请求,那就只有被迫重头开始下载。但是如果有范围请求的加持,就只需要下载最后 5% 的资源,避免重新下载。另一个场景就是多线程下载,对大型文件,开启多个线程,每个线程下载其中的某一段,最后下载完成之后,在本地拼接成一个完整的文件,可以更有效的利用资源。
范围请求不是 Web 服务器必备的功能,可以实现也可以不实现,所以服务器必须在响应头里使用字段“Accept-Ranges: bytes”明确告知客户端:“我是支持范围请求的”。
如果不支持的话该怎么办呢?服务器可以发送“Accept-Ranges: none”,或者干脆不发送“Accept-Ranges”字段,这样客户端就认为服务器没有实现范围请求功能,只能老老实实地收发整块文件了。
请求头 Range 是 HTTP 范围请求的专用字段,格式是“bytes=x-y”,其中的 x 和 y 是以字节为单位的数据范围。
要注意 x、y 表示的是“偏移量”,范围是从 0 计数,例如前 10 个字节表示为“0-9”,第二个 10 字节表示为“10-19”,而“0-10”实际上是前 11 个字节。
Range 的格式也很灵活,起点 x 和终点 y 可以省略,能够很方便地表示正数或者倒数的范围。假设文件是 100 个字节,那么:
- “0-”表示从文档起点到文档终点,相当于“0-99”,即整个文件;
- “10-”是从第 10 个字节开始到文档末尾,相当于“10-99”;
- “-1”是文档的最后一个字节,相当于“99-99”;
- “-10”是从文档末尾倒数 10 个字节,相当于“90-99”。
一次范围请求的过程如下:
第一,它必须检查范围是否合法,比如文件只有 100 个字节,但请求“200-300”,这就是范围越界了。服务器就会返回状态码 416,意思是“你的范围请求有误,我无法处理,请再检查一下”。
**第二,**如果范围正确,服务器就可以根据 Range 头计算偏移量,读取文件的片段了,返回状态码“206 Partial Content”,和 200 的意思差不多,但表示 body 只是原数据的一部分。
第三,服务器要添加一个响应头字段 Content-Range,告诉片段的实际偏移量和资源的总大小,格式是“bytes x-y/length”,与 Range 头区别在没有“=”,范围后多了总长度。例如,对于“0-10”的范围请求,值就是“bytes 0-10/100”。
最后剩下的就是发送数据了,直接把片段用 TCP 发给客户端,一个范围请求就算是处理完了。
有了范围请求之后,HTTP 处理大文件就更加轻松了,看视频时可以根据时间点计算出文件的 Range,不用下载整个文件,直接精确获取片段所在的数据内容。
不仅看视频的拖拽进度需要范围请求,常用的下载工具里的多段下载、断点续传也是基于它实现的,要点是:
- 先发个 HEAD,看服务器是否支持范围请求,同时获取文件的大小;
- 开 N 个线程,每个线程使用 Range 字段划分出各自负责下载的片段,发请求传输数据;
- 下载意外中断也不怕,不必重头再来一遍,只要根据上次的下载记录,用 Range 请求剩下的那一部分就可以了。
Http的优势跟缺点
到这里关于Http协议中比较重要的内容我们就学习完了!
对协议有了一定的了解后我们会发现,http协议其实存在很多问题。
首先来说,http协议使用的是明文传输,不安全!
其次,http协议时无状态的,无状态协议的主要缺点在于,单个请求需要的所有信息都必须要包含在请求中一次发送到服务端,这导致单个消息的结构需要比较复杂,必须能够支持大量元数据,因此HTTP消息的解析要比其他许多协议都要复杂得多。同时,这也导致了相同的数据在多个请求上往往需要反复传输,例如同一个连接上的每个请求都需要传输Host、Authentication、Cookies、Server等往往是完全重复的元数据,一定程度上降低了协议的效率。