软工大作业——多线程下载(一)

2022-03-11

这篇文章主要介绍了关于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
    • 具体参见
  • 随机访问文件的方法
    • 服务器方面,我们通过分段的方式对服务器的文件进行请求,本地方面,我们也需要分段写入,比如请求到的文件第100-200字节,也要写入本地文件的100-200字节才行
    • 这里用到Java中的RandomAccessFile
    • 相关资料
    • 相关资料
  • 线程池和守护线程的使用
    • 线程池是用来管理线程的,使线程的创建更加规范,同时将细节交给线程池管理,解放程序员的同时提高了运行效率
    • 在《阿里巴巴java开发手册》中指出了线程资源必须通过线程池提供
    • java线程池类ThreadPoolExecutor
    • 参考资料
    • 参考资料
    • 参考资料
  • 对于并发量的控制
    • 在多线程的程序中不可避免的就是线程的同步和互斥
    • 比如开启了8个线程正在处理前8块block,那么后面block的处理就要被阻塞,直到有线程空闲再进行分配
    • 这里我们采用操作系统中学过的信号量进行控制
    • Java有封装好的Semaphore类,使用十分方便
  • 参考前人工作

三、内容实现

1、核心思路

用到的主要类的类图如下所示,其中一部分类实际使用时为java自带或者第三方开源库提供,可能与设计的类有所区别

leitu2.drawio(1)(1)(1)

整个代码的核心逻辑用活动图表示,如下所示

swimming1.drawio

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等等问题

image-20220116202827227

经过数次优化后已经解决了所有的警告

image-20220117122911982

五、性能分析与改进(这部分写给老师看的,实际上没啥改进,可以跳过)

使用JProfiler进行性能分析,并记录过程中快照如下

image-20220119105005851

image-20220119105055149

分析后的改进

  1. 之前写文件是把bytes一口气读进内存再写入文件,发现使用了很大的内存,现在使用一个定长bytes数组,循环读入,减少内存占
  2. 按块给线程分配下载任务,之前是固定块大小,比如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();
    }
}

image-20220119141220357

分支覆盖率如下所示

image-20220119141202913

七、收获

​ 学会了很多东西,包括线程池的使用,java写http的方法等等,还有因为异常的写在面对idea疯狂挠头的时候哈哈哈哈哈哈,我认为都是宝贵的经历,对自我的提升很大。不过u1s1,爬虫还是python方便

​ 另外,我深刻的意识到了,在你啥都不会的时候做的需求分析和具体设计,大概率是会被现实啪啪打脸的