背景
最近项目有一个需求,就是客户端通过http方式访问http服务器(tomcat)进行下载,然后tomcat通过ftp方式进行下载原文件,然后在tomcat中进行文件流的转发,实现客户端的断点续传功能
整体架构图如下
这里我们假设客户端想要下载的文件叫做:下载测试.zip
在这里主要分为下面这几个步骤:
1:客户端A 通过 http方式访问服务端(tomcat),请求下载文件 下载测试.zip;
2 : 服务端接收A的请求进行解析 ,获取到想要下载的文件分片 下载的文件名称
3: 服务器内部调用 ftp方式下载文件 ,通过B中接受到的 分片起始位置进行 ftp方式断点续传。
4 :服务端将通过ftp方式的下载的文件流分片转发到客户机,实现整体功能上的断点续传。
这里 总体需要实现两个断点续传:
1 客户端到服务端的HTTP方式的断点续传
2 服务端到FTP服务器的断点续传
整体构建:
首先我们把项目的框架搭起来:
1 客户端 普通的java方式 访问http
2 服务端 通过SpringBoot实现http服务器转发的效果
3 ftp服务器 通过windows自带的功能进行组建ftp服务器
功能代码与构思
ftp的断点续传主要是使用的
ftpclient使用的是org.apache.commons.net.ftp.FTPClient 进行连接ftp服务器
public class FTPConnect {
/**
* 方法仅仅传入一个文件的节点游标 然后再使用ftpClient进行 setRestartOffset(fileChipCursor); 从游标这里开始读取 ftpclient使用的是 org
* @return 上传的状态
* @throws IOException
*/
public InputStream getInputStream(long fileChipCursor) throws IOException {
//设置被动模式
ftpClient.enterLocalPassiveMode();
ftpClient.changeWorkingDirectory(new String(getRemotePath().getBytes("GBK"),"ISO-8859-1"));
ftpClient.setRestartOffset(fileChipCursor);
return ftpClient.retrieveFileStream(new String(getResource().getBytes("GBK"),"iso-8859-1"));
}
需要获取到远程的文件路径进行判断
public long getRemoteFileSize() throws IOException {
//检查远程文件是否存在 并设置编码格式 防止中文名称的 文件下载为空
FTPFile[] files = ftpClient.listFiles(new String(getRemotePath().getBytes("GBK"),"iso-8859-1"));
long lRemoteSize=0L;
if (files.length>0){
for (FTPFile ff:files){
String suffix = ff.getName().substring(ff.getName().lastIndexOf(".") + 1);
if (suffix.equals("zip")){
lRemoteSize=ff.getSize();
}
}
}
return lRemoteSize;
}
获取到远程的路径
private String getRemotePath(){
//这里是相对于ftp服务器的相对路径
String remotePath = "/"+Namepath**+"/"+path**+"/";
return remotePath;
}
}
ftp连接工具类
package ****;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPClientConfig;
import org.apache.commons.net.ftp.FTPReply;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.SocketException;
/**
* @Description ftp文件传输工具类
* @Auther zhangjun
* @Data 2020/4/27 10:38
**/
public class FtpClientUtil {
//账号
private static String username = "adminftp";
//密码
private static String password = "123456";
//默认地址
private static String ip = "172.16.15.250";
//默认端口号
private static String port = "21";
/**
* ftp链接
* @throws IOException
*/
public static FTPClient ftpConnection() throws IOException {
FTPClient ftpClient = new FTPClient();
ftpClient.setConnectTimeout(0);
try {
ftpClient.connect(ip, Integer.parseInt(port));
ftpClient.login(username, password);
int replyCode = ftpClient.getReplyCode(); //是否成功登录服务器
if(!FTPReply.isPositiveCompletion(replyCode)) {
ftpClient.disconnect();
System.exit(1);
}
//告诉对面服务器开一个端口
ftpClient.enterLocalPassiveMode();
ftpClient.setControlEncoding("GBK");
FTPClientConfig conf = new FTPClientConfig(FTPClientConfig.SYST_NT);
conf.setServerLanguageCode("zh");
ftpClient.configure(conf);
} catch (SocketException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return ftpClient;
}
/**
* 断开FTP连接
* @param ftpClient 初始化的对象
* @throws IOException
*/
public static void close(FTPClient ftpClient) throws IOException{
if(ftpClient!=null && ftpClient.isConnected()){
ftpClient.logout();
ftpClient.disconnect();
}
}
}
SpringBoot的http服务端代码
这里的服务端对应的就是springBoot的下载功能 因为可以端对服务端就是下载请求,只是服务端不像是以前读取本地文件发送给 客户端,这次是转发 ftp目录上面的文件流给客户端。
代码实现:
/**
* Project Name: ***
* File Name: httpController
* Package Name:****
* Date: 2020/5/12 14:16
* Copyright (c) 2020,All Rights Reserved.
*/
package ***;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Controller;
import org.json.JSONException;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* @Author: zhangjun
* @Description:
* @Date: Create in 14:16 2020/5/12
*/
@Controller
public class httpController {
@RequestMapping(value = "/download/{path1}/{path2}", method = RequestMethod.GET)
public void download(@PathVariable("path1") String path1,@PathVariable("path2") String path2, HttpServletRequest request, HttpServletResponse response) {
try{
//EasyConnect6301.zip FTPConnect 里面自己创建两个变量作为path1 与 path2 我这里使用的 productname与version
FTPConnect Ftp = new FTPConnect();
Ftp.setproduceName(path1);
Ftp.setProductversion(path2);
//从request中获取 range 获取客户端请求的断点信息
String headerInfo = request.getHeader("Range");
if (headerInfo != null) {
response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
}
// 表示下载范围的pojo
ResponseContentRange range = getRange(Ftp.getRemoteFileSize(), headerInfo);
String fileName = "EasyConnect6301.zip";
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
if (request.getHeader(HttpHeaders.USER_AGENT).contains("MSIE")) {
fileName = URLEncoder.encode(fileName, "UTF-8");
} else {
fileName = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
response.setContentType("application/octet-stream");
response.setContentLengthLong(Ftp.getRemoteFileSize());
response.addHeader("Content-Disposition", "attachment;filename=" + fileName);
response.setHeader("Content-Range", "bytes " + range.getStartIndex() + "-" + (range.getStartIndex()
+ range.getContentSize() - 1) + "/" + Ftp.getRemoteFileSize());
byte[] buffer = new byte[1024*1024];
int n;
int writeCount = 0;
OutputStream outputStream = response.getOutputStream();
//**Ftp.getInputStream((int) range.getStartIndex());**这段代码是这个服务端的核心
将ftp的文件流直接写入response中。做到中转。
InputStream in =**Ftp.getInputStream((int) range.getStartIndex());**
while ((n = in.read(buffer))!= -1 && writeCount < range.getContentSize()) {
outputStream.write(buffer, 0, n);
writeCount += n;
}
in.close();
Ftp.getFtpClient().completePendingCommand();
Ftp.getFtpClient().logout();
Ftp.getFtpClient().disconnect();
//使用完了一定要关闭ftp链接,不然ftp服务占用过高,后面下载特别卡顿
} catch (IOException ignore) {
}
}
/**
* 根据给定的rangeInfo,解析出回复的内容的范围
*
* @param maxSize 范围的最大值
* @param rangeInfo rangeInfo
* @return
*/
private ResponseContentRange getRange(long maxSize, String rangeInfo) {
long startIndex = 0L, contentLength = maxSize;
if (rangeInfo != null && rangeInfo.trim().length() > 0) {
String rangBytes = rangeInfo.replaceAll("bytes=", "");
if (rangBytes.endsWith("-")) {
startIndex = Long.parseLong(rangBytes.substring(0, rangBytes.indexOf("-")));
contentLength = maxSize - startIndex;
} else if (rangBytes.startsWith("-")) {
startIndex = Long.parseLong(rangBytes.substring(rangBytes.indexOf("-") + 1));
contentLength = maxSize - startIndex;
} else {
String[] indexs = rangBytes.split("-");
startIndex = Long.parseLong(indexs[0]);
contentLength = Long.parseLong(indexs[1]) - startIndex + 1;
}
}
return new ResponseContentRange(startIndex, contentLength);
}
class ResponseContentRange {
private long startIndex;
private long ContentSize;
public long getStartIndex() {
return startIndex;
}
ResponseContentRange(long startIndex,long contentLength){
this.ContentSize=contentLength;
this.startIndex=startIndex;
}
public void setStartIndex(long startIndex) {
this.startIndex = startIndex;
}
public long getContentSize() {
return ContentSize;
}
public void setContentSize(long contentLength) {
this.ContentSize = contentLength;
}
}
}
客户端代码构建
客户端就是使用一个简单的java.net自带的工具,实现url链接,注意,下载方法是使用的get方式 ,我本来尝试用post方式,但是实在是失败了。但是使用get方式 我的功能代码还是测试通过了,只是说传递url参数的时候需要对中文进行转码。
package com.supermap.digicty.sdm;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @Author: zhangjun
* @Description:
* @Date: Create in 16:14 2020/4/29
* 根据输入的url和设定的线程数,来完成断点续传功能。
* 每个线程支负责某一小段的数据下载;再通过RandomAccessFile完成数据的整合。
*/
public class MultiTheradDownLoad {
private String urlStr = null;
private String filename = null;
private String tmpfilename = null;
private int threadNum = 0;
private CountDownLatch latch = null;//设置一个计数器,代码内主要用来完成对缓存文件的删除
private long fileLength = 0l;
private long threadLength = 0l;
private long[] startPos;//保留每个线程下载数据的起始位置。
private long[] endPos;//保留每个线程下载数据的截止位置。
private boolean bool = false;
private URL url = null;
//有参构造函数,先构造需要的数据
public MultiTheradDownLoad(String urlStr, int threadNum) {
this.urlStr = urlStr;
this.threadNum = threadNum;
startPos = new long[this.threadNum];
endPos = new long[this.threadNum];
latch = new CountDownLatch(this.threadNum);
}
/*
* 组织断点续传功能的方法
*/
public void downloadPart() {
File file = null;
File tmpfile = null;
//设置HTTP网络访问代理
//从文件链接中获取文件名,此处没考虑文件名为空的情况,此种情况可能需使用UUID来生成一个唯一数来代表文件名。
//改成从request中获取文件名
try {
//创建url
url = new URL(urlStr);
//打开下载链接,并且得到一个HttpURLConnection的一个对象httpcon
HttpURLConnection httpcon = (HttpURLConnection) url.openConnection();
httpcon.setRequestMethod("GET");
//文件的实际名称与大小
filename = "E:\\a整体功能测试\\断点下载测试\\ASASA\\DM.zip";
tmpfilename = filename + "_tmp";
fileLength = httpcon.getContentLengthLong();
//下载文件和临时文件
file = new File(filename);//相对目录
tmpfile = new File(tmpfilename);
//每个线程需下载的资源大小;由于文件大小不确定,为避免数据丢失
threadLength = fileLength%threadNum == 0 ? fileLength/threadNum : fileLength/threadNum+1;
//打印下载信息
System.out.println("80 文件名称: " + filename + " ," + "文件长度= "
+ fileLength + " 线程的每个文件的大小= " + threadLength);
//各个线程在exec线程池中进行,起始位置--结束位置
if (file.exists()&& file.length() == fileLength) {
System.out.println("85 文件已存在!!");
return;
} else {
setBreakPoint(startPos, endPos, tmpfile);
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < threadNum; i++) {
exec.execute(new DownLoadThread(startPos[i], endPos[i],
this, i, tmpfile, latch));
}
latch.await();//当你的计数器减为0之前,会在此处一直阻塞。
exec.shutdown();
}
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
//下载完成后,判断文件是否完整,并删除临时文件
if (file.length() == fileLength) {
if (tmpfile.exists()) {
System.out.println("删除临时文件!!");
tmpfile.delete();
}
}
}
/*
* 断点设置方法,当有临时文件时,直接在临时文件中读取上次下载中断时的断点位置。没有临时文件,即第一次下载时,重新设置断点
* Rantmpfile.seek()跳转到一个位置的目的是为了让各个断点存储的位置尽量分开。
* 这是实现断点续传的重要基础。
*/
private void setBreakPoint(long[] startPos, long[] endPos, File tmpfile) {
RandomAccessFile rantmpfile = null;
try {
if (tmpfile.exists()) {
System.out.println("继续下载!!");
rantmpfile = new RandomAccessFile(tmpfile, "rw");
for (int i = 0; i < threadNum; i++) {
rantmpfile.seek(8 * i + 8);
startPos[i] = rantmpfile.readLong();
rantmpfile.seek(8 * (i + 1000) + 16);
endPos[i] = rantmpfile.readLong();
System.out.println("线程的每个分片的大小: ");
System.out.println("thre 线程 " + (i + 1) + " 开始节点:"
+ startPos[i] + ", 结束节点: " + endPos[i]);
}
} else {
System.out.println(" 149 没有临时文件 重新开始下载 ");
rantmpfile = new RandomAccessFile(tmpfile, "rw");
//最后一个线程的截止位置大小为请求资源的大小
for (int i = 0; i < threadNum; i++) {
startPos[i] = threadLength * i;
if (i == threadNum - 1) {
endPos[i] = fileLength;
} else {
endPos[i] = threadLength * (i + 1) - 1;
}
rantmpfile.seek(8 * i + 8);
rantmpfile.writeLong(startPos[i]);
rantmpfile.seek(8 * (i + 1000) + 16);
rantmpfile.writeLong(endPos[i]);
System.out.println("重新开始下载的文件分片:");
System.out.println(" 线程 " + (i + 1) + " 开始节点: "
+ startPos[i] + ", 结束节点 : " + endPos[i]);
}
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (rantmpfile != null) {
rantmpfile.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/*
* 实现下载功能的内部类,通过读取断点来设置向服务器请求的数据区间。
*/
class DownLoadThread implements Runnable {
private long startPos;
private long endPos;
private MultiTheradDownLoad task = null;
private RandomAccessFile downloadfile = null;
private int id;
private File tmpfile = null;
private RandomAccessFile rantmpfile = null;
private CountDownLatch latch = null;
public DownLoadThread(long startPos, long endPos,
MultiTheradDownLoad task, int id, File tmpfile,
CountDownLatch latch) {
this.startPos = startPos;
this.endPos = endPos;
this.task = task;
this.tmpfile = tmpfile;
try {
this.downloadfile = new RandomAccessFile(this.task.filename,"rw");
this.rantmpfile = new RandomAccessFile(this.tmpfile, "rw");
} catch (FileNotFoundException e) {
e.printStackTrace();
}
this.id = id;
this.latch = latch;
}
@Override
public void run() {
HttpURLConnection httpcon = null;
InputStream is = null;
int length = 0;
System.out.println("线程" + id + " 开始下载!!");
while (true) {
try {
httpcon = (HttpURLConnection) task.url.openConnection();
httpcon.setRequestMethod("GET");
//防止网络阻塞,设置指定的超时时间;单位都是ms。超过指定时间,就会抛出异常
httpcon.setReadTimeout(200000000);//读取数据的超时设置
httpcon.setConnectTimeout(200000000);//连接的超时设置
if (startPos < endPos) {
//向服务器请求指定区间段的数据,这是实现断点续传的根本。
httpcon.setRequestProperty("Range", "bytes=" + startPos+ "-" + endPos);
System.out.println("线程 " + id+ " 长度:---- "+ (endPos - startPos));
downloadfile.seek(startPos);
if (httpcon.getResponseCode() != HttpURLConnection.HTTP_OK
&& httpcon.getResponseCode() != HttpURLConnection.HTTP_PARTIAL) {
this.task.bool = true;
httpcon.disconnect();
downloadfile.close();
System.out.println("线程 ---" + id + " 下载完成!!");
latch.countDown();//计数器自减
break;
}
is = httpcon.getInputStream();//获取服务器返回的资源流
long count = 0L;
byte[] buf = new byte[1024*1024*2];
while (!this.task.bool && (length = is.read(buf)) != -1) {
count += length;
downloadfile.write(buf, 0, length);
//不断更新每个线程下载资源的起始位置,并写入临时文件;为断点续传做准备
startPos += length;
System.out.println("229 线程本次写入 "+id+"==="+length);
rantmpfile.seek(8 * id + 8);
rantmpfile.writeLong(startPos);
}
System.out.println("线程 " + id
+ " 总下载大小: " + count);
//关闭流
is.close();
httpcon.disconnect();
downloadfile.close();
rantmpfile.close();
}
latch.countDown();//计数器自减
System.out.println("线程 " + id + " 下载完成!!");
break;
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (is != null) {
is.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
public static void main(String[] args) throws UnsupportedEncodingException {
int threadNum = 1;
//因为使用简单URL方式,所以必须转码服务端才能识别
String AA= URLEncoder.encode("中文path1","UTF-8");
String filepath = "http://172.16.15.250:8080/ftp/downFile/"+AA+"/path2";
System.out.println(AA);
MultiTheradDownLoad load = new MultiTheradDownLoad(filepath ,threadNum);
load.downloadPart();
}
}
总结
1 经过测试,该功能可以实现 文件从ftp 到 http的转发功能 并且在断电断网环境下都可以进行续传下载。并且文件不会丢失,不会损坏。 并且下载速度基本与ftp直连的速度一样 ,下载3G文件只需要 2分钟 。