在前面的文章曾讨论了HTTP消息头的三个和断点继传有关的字段。一个是请求消息的字段Range,另两个是响应消息字段Accept-Ranges和Content-Range.其中Accept-Ranges用来断定Web服务器是否支持断点继传功能。在这里为了演示如何实现断点继传功能,假设Web服务器支持这个功能;因此,我们只使用Range和Content-Range来完成一个断点继传工具的开发。
● 要实现一个什么样的断点续传工具?
这个断点续工具是一个单线程的下载工具。它通过参数传入一个文本文件。这个文件的格式如下:
http://www.ishare.cc/d/1174254-2/106.jpg d:\ok1.jpg 8192 http://www.ishare.cc/d/1174292-2/156.jpg d:\ok2.jpg 12345 http://www.ishare.cc/d/1174277-2/147.jpg d:\ok3.jpg 3456 |
这个文本文件的每一行是一个下载项,这个下载项分为三部分:
● 要下载的Web资源的URL.
● 要保存的本地文件名。
● 下载的缓冲区大小(单位是字节)。
使用至少一个空格来分隔这三部分。这个下载工具逐个下载这些文件,在这些文件全部下载完后程序退出。
● 断点续传的工作原理
“断点续传”顾名思义,就是一个文件下载了一部分后,由于服务器或客户端的原因,当前的网络连接中断了。在中断网络连接后,用户还可以再次建立网络连接来继续下载这个文件还没有下完的部分。
要想实现单线程断点续传,必须在客户断保存两个数据。
1. 已经下载的字节数。
2. 下载文件的URL.
一但重新建立网络连接后,就可以利用这两个数据接着未下载完的文件继续下载。在本下载工具中第一种数据就是文件已经下载的字节数,而第二个数据在上述的下载文件中保存。
在继续下载时检测已经下载的字节数,假设已经下载了3000个字节,那么HTTP请求消息头的Range字段被设为如下形式:
Range: bytes=3000- |
HTTP响应消息头的Content-Range字段被设为如下的形式:
Content-Range: bytes 3000-10000/10001 |
● 实现断点续传下载工具
一个断点续传下载程序可按如下几步实现:
1. 输入要下载文件的URL和要保存的本地文件名,并通过Socket类连接到这个URL
所指的服务器上。
2. 在客户端根据下载文件的URL和这个本地文件生成HTTP请求消息。在生成请求
消息时分为两种情况:
(1)第一次下载这个文件,按正常情况生成请求消息,也就是说生成不包含Range
字段的请求消息。
(2)以前下载过,这次是接着下载这个文件。这就进入了断点续传程序。在这种情况生成的HTTP请求消息中必须包含Range字段。由于是单线程下载,因此,这个已经下载了一部分的文件的大小就是Range的值。假设当前文件的大小是1234个字节,那么将Range设成如下的值:
Range:bytes=1234- |
3. 向服务器发送HTTP请求消息。
4. 接收服务器返回的HTTP响应消息。
5. 处理HTTP响应消息。在本程序中需要从响应消息中得到下载文件的总字节数。如
果是第一次下载,也就是说响应消息中不包含Content-Range字段时,这个总字节数也就是Content-Length字段的值。如果响应消息中不包含Content-Length字段,则这个总字节数无法确定。这就是为什么使用下载工具下载一些文件时没有文件大小和下载进度的原因。如果响应消息中包含Content-Range字段,总字节数就是Content-Range:bytes m-n/k中的k,如Content-Range的值为:
Content-Range:bytes 1000-5000/5001 |
则总字节数为5001.由于本程序使用的Range值类型是得到从某个字节开始往后的所有字节,因此,当前的响应消息中的Content-Range总是能返回还有多少个字节未下载。如上面的例子未下载的字节数为5000-1000+1=4001.
6. 开始下载文件,并计算下载进度(百分比形式)。如果网络连接断开时,文件仍未下载完,重新执行第一步。也果文件已经下载完,退出程序。
分析以上六个步骤得知,有四个主要的功能需要实现:
1. 生成HTTP请求消息,并将其发送到服务器。这个功能由generateHttpRequest方法来完成。
2. 分析HTTP响应消息头。这个功能由analyzeHttpHeader方法来完成。
3. 得到下载文件的实际大小。这个功能由getFileSize方法来完成。
4. 下载文件。这个功能由download方法来完成。
以上四个方法均被包含在这个断点续传工具的核心类HttpDownload.java中。在给出HttpDownload类的实现之前先给出一个接口DownloadEvent接口,从这个接口的名字就可以看出,它是用来处理下载过程中的事件的。下面是这个接口的实现代码:
package download; public interface DownloadEvent { void percent(long n); // 下载进度 void state(String s); // 连接过程中的状态切换 void viewHttpHeaders(String s); // 枚举每一个响应消息字段 } |
从上面的代码可以看出,DownloadEvent接口中有三个事件方法。在以后的主函数中将实现这个接口,来向控制台输出相应的信息。下面给出了HttpDownload类的主体框架代码:
001 package download; 002 003 import java.net.*; 004 import java.io.*; 005 import java.util.*; 006 007 public class HttpDownload 008 { 009 private HashMap httpHeaders = new HashMap(); 010 private String stateCode; 011 012 // generateHttpRequest方法 013 014 /* ananlyzeHttpHeader方法 015 * 016 * addHeaderToMap方法 017 * 018 * analyzeFirstLine方法 019 */ 020 021 // getFileSize方法 022 023 // download方法 024 025 /* getHeader方法 026 * 027 * getIntHeader方法 028 */ 029 } |
上面的代码只是HttpDownload类的框架代码,其中的方法并未直正实现。我们可以从中看出第012、014、021和023行就是上述的四个主要的方法。在016和018行的addHeaderToMap和analyzeFirstLine方法将在analyzeHttpHeader方法中用到。而025和027行的getHeader和getIntHeader方法在getFileSize和download方法都会用到。上述的八个方法的实现都会在后面给出。
001 private void generateHttpRequest(OutputStream out, String host, 002 String path, long startPos) throws IOException 003 { 004 OutputStreamWriter writer = new OutputStreamWriter(out); 005 writer.write("GET " + path + " HTTP/1.1\r\n"); 006 writer.write("Host: " + host + "\r\n"); 007 writer.write("Accept: */*\r\n"); 008 writer.write("User-Agent: My First Http Download\r\n"); 009 if (startPos > 0) // 如果是断点续传,加入Range字段 010 writer.write("Range: bytes=" + String.valueOf(startPos) + "-\r\n"); 011 writer.write("Connection: close\r\n\r\n"); 012 writer.flush(); 013 } |
这个方法有四个参数:
1. OutputStream out
使用Socket对象的getOutputStream方法得到的输出流。
2. String host
下载文件所在的服务器的域名或IP。
3. String path
下载文件在服务器上的路径,也就跟在GET方法后面的部分。
4. long startPos
从文件的startPos位置开始下载。如果startPos为0,则不生成Range字段。
001 private void analyzeHttpHeader(InputStream inputStream, DownloadEvent de) 002 throws Exception 003 { 004 String s = ""; 005 byte b = -1; 006 while (true) 007 { 008 b = (byte) inputStream.read(); 009 if (b == '\r') 010 { 011 b = (byte) inputStream.read(); 012 if (b == '\n') 013 { 014 if (s.equals("")) 015 break; 016 de.viewHttpHeaders(s); 017 addHeaderToMap(s); 018 s = ""; 019 } 020 } 021 else 022 s += (char) b; 023 } 024 } 025 026 private void analyzeFirstLine(String s) 027 { 028 String[] ss = s.split("[ ]+"); 029 if (ss.length > 1) 030 stateCode = ss[1]; 031 } 032 private void addHeaderToMap(String s) 033 { 034 int index = s.indexOf(":"); 035 if (index > 0) 036 httpHeaders.put(s.substring(0, index), s.substring(index + 1) .trim()); 037 else 038 analyzeFirstLine(s); 039 } |
第001 〜 024行:analyzeHttpHeader方法的实现。这个方法有两个参数。其中inputStream是用Socket对象的getInputStream方法得到的输入流。这个方法是直接使用字节流来分析的HTTP响应头(主要是因为下载的文件不一定是文本文件;因此,都统一使用字节流来分析和下载),每两个""r"n"之间的就是一个字段和字段值对。在016行调用了DownloadEvent接口的viewHttpHeaders事件方法来枚举每一个响应头字段。
第026 〜 031行:analyzeFirstLine方法的实现。这个方法的功能是分析响应消息头的第一行,并从中得到状态码后,将其保存在stateCode变量中。这个方法的参数s就是响应消息头的第一行。
第032 〜 039行:addHeaderToMap方法的实现。这个方法的功能是将每一个响应请求消息字段和字段值加到在HttpDownload类中定义的httpHeaders哈希映射中。在第034行查找每一行消息头是否包含":",如果包含":",这一行必是消息头的第一行。因此,在第038行调用了analyzeFirstLine方法从第一行得到响应状态码。
001 private String getHeader(String header) 002 { 003 return (String) httpHeaders.get(header); 004 } 005 private int getIntHeader(String header) 006 { 007 return Integer.parseInt(getHeader(header)); 008 } |
这两个方法将会在getFileSize和download中被调用。它们的功能是从响应消息中根据字段字得到相应的字段值。getHeader得到字符串形式的字段值,而getIntHeader得到整数型的字段值。
001 public long getFileSize() 002 { 003 long length = -1; 004 try 005 { 006 length = getIntHeader("Content-Length"); 007 String[] ss = getHeader("Content-Range").split("[/]"); 008 if (ss.length > 1) 009 length = Integer.parseInt(ss[1]); 010 else 011 length = -1; 012 } 013 catch (Exception e) 014 { 015 } 016 return length; 017 } |
getFileSize方法的功能是得到下载文件的实际大小。首先在006行通过Content-Length得到了当前响应消息的实体内容大小。然后在009行得到了Content-Range字段值所描述的文件的实际大小("""后面的值)。如果Content-Range字段不存在,则文件的实际大小就是Content-Length字段的值。如果Content-Length字段也不存在,则返回-1,表示文件实际大小无法确定。
001 public void download(DownloadEvent de, String url, String localFN, 002 int cacheSize) throws Exception 003 { 004 File file = new File(localFN); 005 long finishedSize = 0; 006 long fileSize = 0; // localFN所指的文件的实际大小 007 FileOutputStream fileOut = new FileOutputStream(localFN, true); 008 URL myUrl = new URL(url); 009 Socket socket = new Socket(); 010 byte[] buffer = new byte[cacheSize]; // 下载数据的缓冲 011 012 if (file.exists()) 013 finishedSize = file.length(); 014 015 // 得到要下载的Web资源的端口号,未提供,默认是80 016 int port = (myUrl.getPort() == -1) ? 80 : myUrl.getPort(); 017 de.state("正在连接" + myUrl.getHost() + ":" + String.valueOf(port)); 018 socket.connect(new InetSocketAddress(myUrl.getHost(), port), 20000); 019 de.state("连接成功!"); 020 021 // 产生HTTP请求消息 022 generateHttpRequest(socket.getOutputStream(), myUrl.getHost(), myUrl 023 .getPath(), finishedSize); 024 025 InputStream inputStream = socket.getInputStream(); 026 // 分析HTTP响应消息头 027 analyzeHttpHeader(inputStream, de); 028 fileSize = getFileSize(); // 得到下载文件的实际大小 029 if (finishedSize >= fileSize) 030 return; 031 else 032 { 033 if (finishedSize > 0 && stateCode.equals("200")) 034 return; 035 } 036 if (stateCode.charAt(0) != '2') 037 throw new Exception("不支持的响应码"); 038 int n = 0; 039 long m = finishedSize; 040 while ((n = inputStream.read(buffer)) != -1) 041 { 042 fileOut.write(buffer, 0, n); 043 m += n; 044 if (fileSize != -1) 045 { 046 de.percent(m * 100 / fileSize); 047 } 048 } 049 fileOut.close(); 050 socket.close(); 051 } |
download方法是断点续传工具的核心方法。它有四个参数:
1. DownloadEvent de
用于处理下载事件的接口。
2. String url
要下载文件的URL。
3. String localFN
要保存的本地文件名,可以用这个文件的大小来确定已经下载了多少个字节。
4. int cacheSize
下载数据的缓冲区。也就是一次从服务器下载多个字节。这个值不宜太小,因为,频繁地从服务器下载数据,会降低网络的利用率。一般可以将这个值设为8192(8K)。
为了分析下载文件的url,在008行使用了URL类,这个类在以后还会介绍,在这里只要知道使用这个类可以将使用各种协议的url(包括HTTP和FTP协议)的各个部分分解,以便单独使用其中的一部分。
第029行:根据文件的实际大小和已经下载的字节数(finishedSize)来判断是否文件是否已经下载完成。当文件的实际大小无法确定时,也就是fileSize返回-1时,不能下载。
第033行:如果文件已经下载了一部分,并且返回的状态码仍是200(应该是206),则表明服务器并不支持断点续传。当然,这可以根据另一个字段Accept-Ranges来判断。
第036行:由于本程序未考虑重定向(状态码是3xx)的情况,因此,在使用download时,不要下载返回3xx状态码的Web资源。
第040 〜 048行:开始下载文件。第046行调用DownloadEvent的percent方法来返回下载进度。
001 package download; 002 003 import java.io.*; 004 005 class NewProgress implements DownloadEvent 006 { 007 private long oldPercent = -1; 008 public void percent(long n) 009 { 010 if (n > oldPercent) 011 { 012 System.out.print("[" + String.valueOf(n) + "%]"); 013 oldPercent = n; 014 } 015 } 016 public void state(String s) 017 { 018 System.out.println(s); 019 } 020 public void viewHttpHeaders(String s) 021 { 022 System.out.println(s); 023 } 024 } 025 026 public class Main 027 { 028 public static void main(String[] args) throws Exception 029 { 030 031 DownloadEvent progress = new NewProgress(); 032 if (args.length < 1) 033 { 034 System.out.println("用法:java class 下载文件名"); 035 return; 036 } 037 FileInputStream fis = new FileInputStream(args[0]); 038 BufferedReader fileReader = new BufferedReader(new InputStreamReader( 039 fis)); 040 String s = ""; 041 String[] ss; 042 while ((s = fileReader.readLine()) != null) 043 { 044 try 045 { 046 ss = s.split("[ ]+"); 047 if (ss.length > 2) 048 { 049 System.out.println("\r\n---------------------------"); 050 System.out.println("正在下载:" + ss[0]); 051 System.out.println("文件保存位置:" + ss[1]); 052 System.out.println("下载缓冲区大小:" + ss[2]); 053 System.out.println("---------------------------"); 054 HttpDownload httpDownload = new HttpDownload(); 055 httpDownload.download(new NewProgress(), ss[0], ss[1], 056 Integer.parseInt(ss[2])); 057 } 058 } 059 catch (Exception e) 060 { 061 System.out.println(e.getMessage()); 062 } 063 } 064 fileReader.close(); 065 } 066 } |
第005 〜 024行:实现DownloadEvent接口的NewDownloadEvent类。用于在Main函数里接收相应事件传递的数据。
第026 〜 065 行:下载工具的Main方法。在这个Main方法里,打开下载资源列表文件,逐行下载相应的Web资源。
测试
假设download.txt在当前目录中,内容如下:
http://files.cnblogs.com/nokiaguy/HttpSimulator.rar HttpSimulator.rar 8192 http://files.cnblogs.com/nokiaguy/designpatterns.rar designpatterns.rar 4096 http://files.cnblogs.com/nokiaguy/download.rar download.rar 8192 |
这两个URL是在本机的Web服务器(如IIS)的虚拟目录中的两个文件,将它们下载在D盘根目录。
运行下面的命令:
java download.Main download.txt |
运行的结果如图1所示。
图1