这篇文章主要介绍了关于http多线程下载的方法,使用的是Apache的第三方库
关于ftp以及torrent的介绍在后面的文章中
主要讲解了用到的http操作以及java线程池多线程的相关知识
一、根据psp表格做出预估
表格中实际耗时由需求完成后进行的统计
PSP 2.1表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 30 |
· Estimate | · 估计这个任务需要多少时间 | 20 | 30 |
Development | 开发 | 1630 | 2490 |
· Analysis | · 需求分析 (包括学习新技术) | 600 | 720 |
· Design Spec | · 生成设计文档 | 180 | 360 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 40 | 40 |
· Design | · 具体设计 | 120 | 260 |
· Coding | · 具体编码 | 360 | 750 |
· Code Review | · 代码复审 | 60 | 60 |
· Test | · 测试(自我测试,修改代码,提交修改) | 240 | 270 |
Reporting | 报告 | 140 | 260 |
· Test Report | · 测试报告 | 80 | 200 |
· Size Measurement | · 计算工作量 | 20 | 20 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 40 | 40 |
合计 | 1890 | 2780 |
二、思考与学习
本次项目允许使用的语言为c++、python和java,由于需求尚未完全发布,因此排除掉在大型项目中维护困难的python语言,最终选择使用java完成本项目。
对于本项目存在两个重点,一个是多线程,一个是下载,只要这两个问题解决,问题就迎刃而解了
具体实现时需要考虑到以下问题并搜寻相关资料:
- http协议的基本知识,这里不加赘述
- 通过http协议下载的方法
- 判断是否可以并发下载的方法
- 在请求报文中增加
Range
标签,即分段请求资源,只请求文件在的Range范围内的字节 - 实际上并发下载就是通过这种方式,每个线程求求文件不同的range进行下载,在本地再合成一个文件
- 若允许分段请求,则返回206,否则返回200
- 具体参见
- 在请求报文中增加
- 随机访问文件的方法
- 线程池和守护线程的使用
- 对于并发量的控制
- 在多线程的程序中不可避免的就是线程的同步和互斥
- 比如开启了8个线程正在处理前8块block,那么后面block的处理就要被阻塞,直到有线程空闲再进行分配
- 这里我们采用操作系统中学过的信号量进行控制
- Java有封装好的
Semaphore
类,使用十分方便
- 参考前人工作
三、内容实现
1、核心思路
用到的主要类的类图如下所示,其中一部分类实际使用时为java自带或者第三方开源库提供,可能与设计的类有所区别
整个代码的核心逻辑用活动图表示,如下所示
2、代码关键步骤
Http操作
使用的是Apache的httpclient
包
这里放上分段请求的代码,包括获取文件长度,检查是否支持分段请求都是差不多的写法
// 创建httpclient对象
CloseableHttpClient httpclient;httpclient = HttpClients.createDefault();
// 创建get访问方法的对象
HttpGet httpget = new HttpGet(urL);
// 在请求头中添加Range属性
httpget.addHeader("Range", "bytes=" + block.getBlockstart() + "-" + block.getBlockend());
// 执行get获取响应
HttpResponse response = httpclient.execute(httpget);
// 检查响应码
int status = response.getStatusLine().getStatusCode();
if (!(status >= 200 && status < 300)) {
throw new ClientProtocolException("Unexpected response status: " + status);
}
// 获取响应实体
HttpEntity entity = response.getEntity();
// 获取报文内容,这时服务器传过来的内容就可以通过输入流得到了
InputStream in = entity.getContent();
if(entity.getContentLength() <= 0) {
putQueue("No file to download");
throw new ClientProtocolException("entity.getContentLength() = 0");
}
根据文件大小将文件切块
/**
* 根据文件程度更新块大小
* 将文件切割成块放入list以便分配给各线程
*/
private void initBlock() {
// 更新块大小
blocksize = fileLength / (threadNum * 3L);
blockList = new CopyOnWriteArrayList<>();
long now = fileLength;
long blockstart = 0, blockend;
// 切割文件成块,放入list
while(now > 0) {
long len = Math.min(blocksize - 1, now);
blockend = blockstart + len;
blockend = Math.min(fileLength, blockend);
blockList.add(new Block(blockstart, blockend));
blockstart = blockend + 1;
now -= blocksize;
}
}
线程下载区域分配
所有的putQueue
相当于打印,使用了守护线程和list,保证打印的顺序
/**
* 线程控制
* 通过将不同的block分配给不同的task,实现线程的任务分配
*/
private void threadControl() {
// 倒计时计数器记下当前任务数
countDownLatch = new CountDownLatch(blockList.size());
for(Block b : blockList) {
executor.submit(new DownloadTask(b));
}
// 主线程等待待线程同步
try {
countDownLatch.await();
} catch (InterruptedException e) {
putQueue(e.getMessage());
}
// block没有完全remove说明有的block下载失败了,那就再来一遍
if(!blockList.isEmpty()) {
threadControl();
}
}
通过实现Runnable为线程设定任务
/**
* 设置内部类实现Runnable完成线程任务
*/
private final class DownloadTask implements Runnable{
private final Block block;
private InputStream in = null;
private DownloadTask(Block b) {
block = b;
}
@Override
public void run() {
// 使用RandomAccessFile进行随机读写
// 并且使用 try-with-resource 实现自动关闭
try(RandomAccessFile rfile = new RandomAccessFile(filename, "rwd")) {
// 使用信号量控制线程协同
semaphore.acquire();
rfile.seek(block.getBlockstart());
/*
** http操作与文件写入
*/
blockList.remove(block);
} catch (Exception e) {
putQueue(String.format("%s-%s failed, has been added into the download list again.",
block.getBlockstart(), block.getBlockend()));
putQueue(e.getMessage());
} finally {
countDownLatch.countDown();
semaphore.release();
try {
if(in != null) {
in.close();
}
} catch (IOException e) {
putQueue(e.getMessage());
}
}
}
}
完整的代码在https://github.com/Jump-Wang-111/MutiThreadDownload
四、代码质量检测
代码质量检测属于静态检测,代码质量检查与代码风格无关,它的作用是静态检测代码语法是否有可预见性的错误
我在这里使用QAPlug进行代码质量检测。
IDEA中可以方便的集成,在插件中搜索安装即可,这里附上我安装时参考的博客,作者写的很详细,我这里就不再赘述了
ps:插件安装失败实在不行就手动吧,手动快得很
初次检查共检查出54个问题,其中包括可设置成局部变量、使用日志打印代替System.out.println等等问题
经过数次优化后已经解决了所有的警告
五、性能分析与改进(这部分写给老师看的,实际上没啥改进,可以跳过)
使用JProfiler进行性能分析,并记录过程中快照如下
分析后的改进
- 之前写文件是把bytes一口气读进内存再写入文件,发现使用了很大的内存,现在使用一个定长bytes数组,循环读入,减少内存占
- 按块给线程分配下载任务,之前是固定块大小,比如1M,10M等,但是面对下载的文件大小不同的情况,如果块分的过小会导致很多次的http申请,使下载速度受到影响。下载1.8G文件,设置1M块,速度为6M/s;设置100M块,速度为11M/s。改进后的块大小是动态的,改为固定块数,假设线程数为n,设置块数固定为3n+1,这时可以适应不同大小的文件,速度不受影响
六、软件测试
单个文件的多线程下载
单元测试使用JUnit4,分支覆盖率使用idea自带测试方法
测试编号 | 输入信息 | 预期结果 |
---|---|---|
101(小文件,1M) | {“url”:”https://soft.xitongxz.net/202106/FZXGJ_V10.6_XiTongZhiJia.zip”, “ThreadNum”: “8”, “fileName”: “.\\download0.zip”} | Success |
102(一般文件,100M) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “8”, “fileName”: “.\\download1.exe”} | Success |
103(大文件,2G) | {“url”:”https://count.iuuu9.com/d.php?id=501616&urlos=android”, “ThreadNum”: “8”, “fileName”: “.\\download2.apk”} | Success |
104(设置4线程) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “4”, “fileName”: “.\\download3.exe”} | Success |
105(设置-1线程) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “-1”, “fileName”: “.\\download4.exe”} | Fail |
106(设置100线程) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “100”, “fileName”: “.\\download5.exe”} | Success |
107(非链接) | {“url”:”111222”, “ThreadNum”: “8”, “fileName”: “.\\download6.zip”} | Fail |
108(非下载链接) | {“url”:”https://www.coder.work/article/4681889”, “ThreadNum”: “8”, “fileName”: “.\\download7.zip”} | Fail |
109(已存在的文件名) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “8”, “fileName”: “.\\download1.exe”} | Fail |
110(不存在的目录下的文件名) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “8”, “fileName”: “.\\new\\download9.exe”} | Success |
111(使用单线程下载) | {“url”:”https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default”, “ThreadNum”: “8”, “fileName”: “.\\download10.exe”}(更改代码使用单线程) | Success |
测试代码
代码中不包含对单线程的测试,现在不支持并发下载的服务器太少了,笔者找了半天也没有找到,所以是改了源代码进行的测试,这里就不贴出来了
public class DownloadTest {
@Test
public void start0() {
Download d0 = new Download("https://soft.xitongxz.net/202106/FZXGJ_V10.6_XiTongZhiJia.zip",
8, ".\\download0.zip");
d0.start();
}
@Test
public void start1() {
Download d1 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
8, ".\\download1.exe");
d1.start();
}
@Test
public void start2() {
Download d2 = new Download("https://count.iuuu9.com/d.php?id=501616&urlos=android",
8, ".\\download2.apk");
d2.start();
}
@Test
public void start3() {
Download d3 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
4, ".\\download3.exe");
d3.start();
}
@Test
public void start4() {
Download d4 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
-1, ".\\download4.exe");
d4.start();
}
@Test
public void start5() {
Download d5 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
100, ".\\download5.exe");
d5.start();
}
@Test
public void start6() {
Download d6 = new Download("111222",
8, ".\\download6.exe");
d6.start();
}
@Test
public void start7() {
Download d7 = new Download("https://www.coder.work/article/4681889",
8, ".\\download7.exe");
d7.start();
}
@Test
public void start8() {
Download d8 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
8, ".\\download1.exe");
d8.start();
}
@Test
public void start9() {
Download d9 = new Download("https://ys-api.mihoyo.com/event/download_porter/link/ys_cn/official/pc_default",
8, ".\\new\\download9.exe");
d9.start();
}
}
分支覆盖率如下所示
七、收获
学会了很多东西,包括线程池的使用,java写http的方法等等,还有因为异常的写在面对idea疯狂挠头的时候哈哈哈哈哈哈,我认为都是宝贵的经历,对自我的提升很大。不过u1s1,爬虫还是python方便
另外,我深刻的意识到了,在你啥都不会的时候做的需求分析和具体设计,大概率是会被现实啪啪打脸的