Http的范围请求与断点下载的原理

前言: 我最近在看《HTTP权威指南》,学习到了范围请求的概念。它也解开了我一直以来的疑惑,当初使用学习《疯狂Java讲义》的时候,曾经实现了书上的那个多线程下载,但是当时其实也是不太理解,只是知道对于一个网络上的文件,可以跳过前面的某一部分,然后读取另一部分。不过当时也没有学习计算机网络,确实对于这方面没有什么清晰的概念。

参考博文:
多线程断点复制
Java多线程和IO流的应用

这个对于输入流调用这个skip方法,跳过前面不需要读取的字节数,其实是很高级的用法了,至少隐藏了一些HTTP的实现细节。
在这里插入图片描述
其实,如果我们自己编写一个简单的TCP服务器和客户端用来传递文件的话,你想过没有,如果客户端这样向服务器说:你把文件的某个(连续的)部分发给我。你觉得服务器可以进行正确的响应吗? 显然是可以的,但是必须约定一个这样的传输方式或者更进一步——需要一个协议进行控制。单纯的TCP只是具有传输数据简单的功能,无法实现更加丰富的操作。这里说了这些,其实是想引出:多线程断点下载在底层是依赖于HTTP协议的某些功能的。

范围请求相关的首部

下面我要介绍与分块传输相关的三个HTTP首部:
Range
Accept-Ranges
Content-Range

想来了解一下这几个首部的概念

范围请求允许我们一次只请求文件的某个连续字节部分,这样可以带来许多有意思的事情,例如:
断点下载,当你下载的时候网络暂停了或者断开了,你可以等网络恢复以后继续开始下载,而不必从头开始下载整个文件。
多线程下载,你可以启动多个线程下载同一个资源,每个线程下载文件的一个部分即可。或者使用多台机器一起下载,然后对文件进行合并操作。这样可以大大缩减下载一个文件所需要的时间。

范围请求相关的三个首部

Range 首部是客户端发送的,它的目的是进行范围请求。
Accept-Ranges 首部是服务器向客户端响应的,即当前服务器支持范围请求。并不是所有的服务器都是可以支持它的,对于某些古老或者简单的服务器它是没有这种功能的。所以,进行范围请求前,最好要确定服务器是具有这个响应首部的。不然,即使进行范围请求也会被忽略。
Content-Range 首部是进行客户端范围请求,然后服务器支持这种请求,响应的报文即包含改首部,报文的数据体部分是范围请求的内容。

这里举一个简单的例子:
下图表示一个抽象的文件,它被分为A、B、C、D、E五个部分。所谓范围请求的意思就是客户端可以单独请求A部分、B部分、C部分、D部分或者是E部分。这里的部分是不具体的,它可以是任意一段连续的部分,我这里将文件看出是逻辑上连续的一串字节流。 服务器将会响应客户端相应的请求部分(但是必须注意:服务器需要支持范围请求这个首部,某些服务器可能不支持它)。
在这里插入图片描述

实验代码

上面是理论的部分,但是我们都知道学习软件相关的知识,必须要亲自动手实践才会有一个比较好的理解。这里我准备了两个实验代码,希望借助它可以让你了解并掌握范围请求的概念和实际应用。

从底层进行模拟实验

这里我先使用Socket来对这个范围请求进行验证实验。因为最近在看Java中关于网络的流的知识,所以我对这个简单的使用很顺手。

实验思路:
使用Socket发送HTTP请求报文请求一张网络图片,第一次不使用范围请求,但是当文件下载大小超过一半时,主动抛出一个异常,使得下载中断。然后,再次发起HTTP请求,但是这次我只请求我已经下载好的那部分数据之外的部分。对于文件的输出流,采用追加的形式,即第二次请求的数据直接追加到第一次文件的尾部,这样两次请求之后,一个完整的文件就下载完毕了。然后进行一个验证,这里单纯的采用字节数大小和图片的显示其实是不严谨的,所以这里对于文件我使用文件的md5进行验证。

SocketDownload 类

发起Http请求,但是这里我只考虑了http协议,对于https协议就不进行考虑了。主要是两点原因:
1.https的请求发送起来比较复杂,而且这里的关注点也不是它。
2.我对于https还是不太熟悉,目前只知道需要SSLSocket和密码才行。
但是,如果你感兴趣的话,可以参考这个博文:
简单的Socket爬虫

注意: 这里使用Socket其实是一种很原始的方式,因为你需要自己构造请求报文、解析响应报文。对我来说构造一个请求报文,其实还是可以接受的,因为只要按照协议的格式拼接即可。但是解析响应报文,明显就是复杂的多了。这个代码下面是我的一种处理方式——它是可以正确解析这种简单的请求报文的,至于效率嘛?这里就不考虑了。 从这里较为底层的方式学习,我觉得可以更好的加深对于网络的认识和网络编程的理解。因为错了一点,都可能导致整个程序的崩溃,这样才能提高你对于它的理解。

package com.dragon;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * @author Alfred
 * @version 1.0
 * */
public class SocketDownload {
	
	private static final int TIMEOUT = 10*1000; // 超时时间
	private static final String BLANK = " ";    // 空格
	private static final String CRLF = "\r\n";  // 回车换行符
	private static final String RESOURCE = "./resource";  // 下载文件存放位置
	private static String nowFileName;
	
	/**
	 * 本来想传入一个url字符串,自己来解析的,但是发现似乎不是那么简单的。
	 * 索性就直接使用Java已经提供的功能了,比较这里我的关注点不是它。
	 * 虽然是有些偏底层,但也不是所有的东西都要自己搞,那样反而是自缚手脚了。
	 * 
	 * @throws MalformedURLException 
	 * @throws NotSupportProtocolException 
	 * */
	public void download(String urlstr, String suffix, boolean breakPoint) throws MalformedURLException, NotSupportProtocolException {
		URL url = new URL(urlstr);
		String protocol = url.getProtocol();   // 协议
		String host = url.getHost();           // 主机
		String path = url.getPath();           // 路径
		int port = url.getPort();              // 端口,注意这里默认端口的话,它的值是 -1,所以需要自己指定端口号。
	
		if (protocol.equals("http")) {
			this.httpDownload(host, port != -1 ? port : 80, path, suffix, breakPoint);
		} else {
			// 这里抛出一个异常,不支持这个协议
			throw new NotSupportProtocolException("只支持http协议!");
		}
	}
	
	/**
	 * 下载方法
	 * 
	 * @param host 主机
	 * @param port 端口号
	 * @param path 路径
	 * @param suffix 下载资源的后缀名 
	 * @param breakPoint true 开启断点,false 从断点处开始
	 * 
	 * 使用网络图片来进行测试,测试图片地址为:它的服务器可以接收范围请求,即ranges
	 * http://img.netbian.com/file/2017/0311/3b1a1528449a0d46ca7b6c6f8d1d7f41.jpg
	 * 
	 * http://xiaohua.zol.com.cn/detail60/59420.html
	 * */
	private void httpDownload(String host, int port, String path, String suffix, boolean breakPoint){
		try (Socket socket = new Socket(host, port)) {
			socket.setSoTimeout(TIMEOUT);   // 设置超时时间
			// 获取输出流和输入流
			OutputStream output = new BufferedOutputStream(socket.getOutputStream());
			InputStream input = new BufferedInputStream(socket.getInputStream());
			// 使用输出流发送请求数据
			
			// 构造简单的请求报文,这里报文非常简单
			StringBuilder msgBuilder = new StringBuilder();
			msgBuilder.append("GET").append(BLANK)
			.append(path).append(BLANK).append("HTTP/1.1").append(CRLF)
			.append("Host").append(":").append(BLANK).append(host).append(CRLF)
		//	.append("Referer: http://img.netbian.com/file/2017/0311/3b1a1528449a0d46ca7b6c6f8d1d7f41.jpg").append(CRLF)
			.append("User-Agent").append(":").append(BLANK).append("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
					+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36").append(CRLF)
			.append(CRLF);
			
			if (breakPoint) {
				// 开启断点下载,这里是简化处理。
				File dir = new File(RESOURCE);
				// 这里要保证,文件夹内只有当前的断点文件!!!
				File file = dir.listFiles()[0];
				long length = file.length();
				// 所要请求的内容是当前已下载长度之后的部分,不包括当前长度,如果它包括当前的话,你需要+1,
				// 但是程序会 read time out,因为已经没有数据了,你还在请求数据,最终会导致程序无法超时异常
				msgBuilder.insert(msgBuilder.length()-2, "Range: bytes="+length+"-"+CRLF);
				// 范围传输的文件的 Content-Length 就是请求的范围那么大的字节数
				nowFileName = file.getName();
			}
			
			// 请求报文转成字符串
			String msg = msgBuilder.toString();
			// 查看请求报文格式
			System.out.println(msg);
			
			// 请求报文字符串转成字节数组,网络上的数据最终都是以字节数据发送的,
			// 我们这是在TCP的层面看的,如果你往下一层,也可以说是比特流了。
			byte[] request = msg.getBytes(StandardCharsets.UTF_8);
			// 发送请求报文,这里其实可以一步到位的,但是为了表达清晰,还是分步来写
			output.write(request);			
			output.flush();  // 刷新输出流,不然未发送请求,导致无法接收到响应

			// 使用输入流接收请求数据,但是此处我无法直接读取请求流,不然就是将整个报文的内容读取下来了
			// 注意整个报文指的是整个响应报文,包含报文头和报文体。虽然我也可以通过打开整个报文,
			// 删除报文头,然后修改文件名,它就是一个图片了,但是这样感觉不是很规范。
			char ch;                           
			Map<String, String> headMap = new HashMap<>();  // 定义一个map结构,用于存储报文头部
			StringBuilder statusLine = new StringBuilder(); // 响应报文的首行
 			StringBuilder key = new StringBuilder();        // 存储键
			StringBuilder value = new StringBuilder();      // 存储键
			
			boolean flag = true;   // 定义一个标志标量,用于读取首行
			/**
			 * 读取不到数据,都是 -1,但是没有退出循环,将 -1 转成 char,导致了变成 65535 了!
			 * 
			 * */
			// 读取响应报文头部数据
			dragon:	while (true) {
				// 响应报文的首行需要单独处理
				if (flag) {
					while (true) {
						ch = (char) input.read();
						if (ch == '\n') {
							flag = false;  // 标志位置为假
							break;
						}
						statusLine.append(ch);
					}
				}
				// 读取一个 key
				while (true) {
					ch = (char) input.read();
					if (ch == ':') {
						break;
					}
					key.append(ch);
					// 如果 ch 是\n字符,那么就认为读取到了头部和数据部分的分隔符 \r\n,
					// 即头部已经读取完毕了,直接退出循环即可。
					if (ch == '\n') {  
						break dragon;
					}
				}
				// 读取一个键
				while (true) {
					ch = (char) input.read();
					if (ch == '\n') {
						break;
					}
					value.append(ch);
				}
				// 存储读取的key和value,并去除左右空格
				headMap.put(key.toString().trim(), value.toString().trim());
				// 清空 key 和 value,为下一次读取做准备。
				key.delete(0, key.length());   
				value.delete(0, value.length());
			}
			
			System.out.println("Accept-Ranges: " + headMap.get("Accept-Ranges"));  // 这个头说明它支持 范围传输
			System.out.println("Content-Range: " + headMap.get("Content-Range"));  // 这个头说明它正在 在范围传输
			String fileName = UUID.randomUUID().toString() + suffix;
			
			String content_Length = headMap.get("Content-Length");
			System.out.println("网络资源大小:" + content_Length);
			int length = Integer.parseInt(content_Length);
			// 读取响应报文数据部,即图片本身的二进制数据       //  这里需要采用追加的形式,并且不能是从头开始写入了,必须跳过已经下载的部分!
			try (OutputStream outputFile = new BufferedOutputStream(new FileOutputStream(
					new File(RESOURCE, nowFileName == null ? fileName : nowFileName), true))) {
				int hasRead = 0;
            	int len = 0;
            	byte[] b = new byte[1024];
            	int breakPosition = length / 2;  // 设置一个中断点,当下载超过一半时,中断下载
            	
            	while ((hasRead != length) && (len = input.read(b)) != -1) {   // 这个如果是 || 非的话,永远也结束不了了!哈哈
            		if (!breakPoint && hasRead >= breakPosition) {
            			System.out.println("已经下载的字节数为:" + hasRead);
            			throw new IOException("模拟网络异常,导致的下载中断!");
            		}
        			outputFile.write(b, 0, len);
        			hasRead += len;
            	}
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	public class NotSupportProtocolException extends Exception {
		private static final long serialVersionUID = 1L;

		public NotSupportProtocolException(String msg) {
			super(msg);
		}
	}
}

注意:有些网站的图片是有防盗链措施的,所以有时候需要加入referrer首部(但是,它也只是一种初级的反防盗链措施,不保证一定可以使用!)所以,如果希望运行改代码的话,最好就是使用我使用的用例url,这样可以保证不会引入其它问题。

BreakPointDownloader类

说明:
首先注释掉第二行,然后运行程序,程序会因为一个异常而停止(这是我安排的,不用担心!),此时在项目的resource文件夹下面会有一张猫的图片,但是它只是显示了一半,因为我也只是下载了一半!;然后注释掉低一行,解除第二行的注释,再次运行程序,此时resource文件夹下面的图片已经是完整的一张猫的图片了。

注意: 执行完程序,最好刷新(refresh)一下,再点击图片。或者直接去该项目所以文件夹下面查看,我推荐直接去该文件夹所在目录查看图片。

package com.dragon;

import java.net.MalformedURLException;

import com.dragon.SocketDownload.NotSupportProtocolException;

/**
 * 断点下载原理
 * */
public class BreakPointDownloader {
	
	public static void main(String[] args) {
		try {
				breakPointDownload();
			//	continueDownload();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
	/**
	 * 下载文件到一个指定字节数时,产生一个断点,暂停下载。
	 * @throws NotSupportProtocolException 
	 * @throws MalformedURLException 
	 * 
	 * */
	public static void breakPointDownload() throws MalformedURLException, NotSupportProtocolException {
		SocketDownload downloader = new SocketDownload();
		downloader.download("http://img.netbian.com/file/2017/0311/3b1a1528449a0d46ca7b6c6f8d1d7f41.jpg", ".jpg", false);
	}
	
	/**
	 * 从指定位置继续开始下载文件
	 * @throws NotSupportProtocolException 
	 * @throws MalformedURLException 
	 * */
	public static void continueDownload() throws MalformedURLException, NotSupportProtocolException {
		SocketDownload downloader = new SocketDownload();
		downloader.download("http://img.netbian.com/file/2017/0311/3b1a1528449a0d46ca7b6c6f8d1d7f41.jpg", ".jpg", true);
	}
}

测试

1.第一次执行程序,通过产生一个异常,达到只下载图片的一半的目的。

注意: 最上面是构造的请求报文,这是正常的一个请求,会获取该图片的完整数据,然后下面会依次打印响应报文 Accept-Ranges 和 Content-Range 首部。第一个首部的 bytes,表示该服务器支持范围请求,第二个首部表示服务器正在进行范围请求。但是因为我这里没有进行范围请求,所以响应报文没有该首部,因此值为null。
注意该网络资源的总大小,并且当前已经下载了的大小,下一次进行请求是从当前大小+1进行请求的。例如:总共10个字节,我已经下载了5个字节,那么我直接从第6个开始即可。

注意:每次运行程序,已经下载的字节数基本上是不会相同。因为网络并不是很稳定的,它涉及很多其它的东西,每次读取1024字节不会正好读取到的,但是差异很小。
在这里插入图片描述

1.1 文件夹下面的图片
注意:明显看出图片只有一半,因为另一半是异常的,是不是很有趣。只有为什么会是这样的显示,想必是和图像的格式有关,这个超出了我的能力范围了,先不管它了。(以我目前知道的来说,应该是图像格式的控制信息在前面,例如图片的长宽等,所以它可以显示前面,但是后面的数据缺失了,因此无法显示!)。并且在图片打开后,进行拉伸,图片下方会产生奇怪的现象,这里是我进行的截图。

在这里插入图片描述
1.3 图片的大小
在这里插入图片描述

2.第二次执行程序
注意需要注释第一行代码,去掉第二行代码的注释!

public static void main(String[] args) {
	try {
		// breakPointDownload();
		 continueDownload();
	} catch (Exception e) {
		e.printStackTrace();
	}
}

2.1 第二次执行程序的控制台输出
注意这里我加了 Range: bytes=430098- 首部。
表示请求第430098之后的所有字节(不包含),它是可以带一个范围的,但是这里也可以忽略,因为我知道自己只要后面的所有部分。

注意这里的网络资源大小,它其实就是Content-Length的值:428749。
并且 428749+430738 = 859487,这样经过两次请求我就获取到了图片的全部数据了。
在这里插入图片描述

2.2 文件夹下面的图片
在这里插入图片描述
在这里插入图片描述
2.3 图片的总字节数
这个字节数需要和第一次请求得到的Content-Length进行比较。
在这里插入图片描述

验证

上面的代码基本上已经没有问题了,但是它可靠吗?仅靠字节数和图片的外观,其实是不太可靠的。这个可靠主要是我的代码的问题了,因为基本的原理以及解释清楚了。这里我需要再做一些验证,来证明我的代码也是可靠的,即网络图片和我下载的图片也是完全一样的复制品。所以,我还需要借助一个工具来验证图片的一致性。这里我选择文件的md5值,它是很可靠的。

获取范围请求文件的md5值工具代码
package com.dragon;

import java.io.File;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * 验证文件的md5值是否一致
 * */
public class Md5Check {
	public static void main(String[] args) {
		try {
			MessageDigest md5 = MessageDigest.getInstance("MD5");
			// 或者直接简洁一点: File dir = new File("./resource")
			// 注意字符串连接路径时,别忘记这个 斜杠了!!!
			File dir = new File(System.getProperty("user.dir") + "/resource");
			// 获取resource目录下的第一个文件,所以需要保证resource目录只有一个文件!
			File file = dir.listFiles()[0];
			byte[] msg = md5.digest(Files.readAllBytes(file.toPath()));
			BigInteger bi = new BigInteger(1, msg);
			System.out.println("图片的md值为:" + bi.toString(16));
		} catch (NoSuchAlgorithmException | IOException e) {
			e.printStackTrace();
		}
	}
}

范围请求文件的md值

在这里插入图片描述

原图

直接从网络上完整下载的图片!
在这里插入图片描述
那我们再次获取一下原图的md5值进行比较即可了,但是我发现windows系统是自带了一个工具的,可以用来获取文件的md5值,所以对于原图(直接从网络完整下载的图片),我采用Windows系统自带的工具来获取它的md5值。
在这里插入图片描述
反正md5值转成16进制以后也不是太长,直接肉眼比较一下,可以看出md值是相等的。所以这里我的代码部分也是没有问题的!之所以增加这个环节,是因为我中间调试的时候,对于范围请求我少请求了一个字节,但是图片基本上没有区别。我多请求一个字节,会导致程序超时异常,因为数据以及传输完成了,你继续请求只能是没有任何响应的。但是图片是正常的,哈哈!因为数据以及获取完成了。

说明

上面的代码还有许多可以改进的地方,你可以直接获取整个响应报文,或者打印出所有的请求头,我都获取了存储在map结构里面,但是我并没有使用它们。再回到最开始的介绍部分,还记得那个 skip 方法吗?它应该就是利用了范围请求,不过一个skip隐藏了这些细节部分。不过,skip() 方法的具体实现细节,我没找到,都是面向接口和抽象类编程,有些类也找不到,需要单独导入。(我这里指的是使用URLConnection获取到的网络流时的 skip() 方法的细节。)

我这篇博客是一个很简单的http服务器,它就是忽略了所有的请求头,当然也就支持范围请求这个功能,所以范围请求对它来说也是没有任何用处的。
基于Java的简易Http服务器–你瞅啥

PS:项目的目录结构
在这里插入图片描述

更加简洁的方法

使用更高级的API,整个代码会非常简单。但是你失去了很多东西,如果你已经对此很了解,确实应该使用更高级的方式,但是学习的话,还是推荐使用上一种方式。

package com.dragon;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

public class RangeTest {
	public static void main(String[] args) throws IOException {
		URL url = new URL("http://img.netbian.com/file/2017/0311/3b1a1528449a0d46ca7b6c6f8d1d7f41.jpg");
		URLConnection connection = url.openConnection();
		connection.setRequestProperty("Range", "bytes=0-430738");
		try (InputStream input = new BufferedInputStream(connection.getInputStream())) {
			Files.copy(input, Paths.get("D:", "DBC", "socketPic", "cat.jpg"), StandardCopyOption.REPLACE_EXISTING);
		}
		System.out.println("范围请求完成!");
	}
}

在这里插入图片描述

请求我的你愁啥服务器,得到是却是完整的图片,因为你尽管可以请求,但是服务器却可以不理会你的请求,因为它本身也就是没有这个功能!
在这里插入图片描述

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值