Skip to content

NodeJS实现文件下载 #18

Open
Open
@jappp

Description

@jappp

最近遇见一个Node实现文件下载的需求,需要实现如下的feature:

  1. 返回实时的下载进度
  2. 能够准备的判断文件是否下载完成
  3. 下载出错时,能够实现断点继续下载
  4. 准确捕获下载时出现的错误

在现有场景下,因为不希望因为一个下载小功能就引入第三方库,并且也没有找到完全符合要求的库,所以自己实现了文件下载功能。

1. 获取一个可读流

实现下载功能的核心模块是 Stream

NodeJs中的 Stream 一共有四种类型,我们常用的是可读流 Readable 和可写流 Writable 。实现下载功能的第一步,就是获取到一个可读流(数据源)
既然是下载功能,可读流大部分情况都是通过网络请求来得到的。我们可以使用axios、request这样的三方的库,也可以使用Node自带的http(s)模块来发送请求

1.1 使用axios获取可读流

使用axios时记得指定responseType为stream

const { data } = await axios.get('https://abc.com/test.exe', { responseType: 'stream' });
console.log(data); // data就是一个可读流

1.2 使用http模块获取可读流

使用http(s)时,记得对 ClientRequest 的timeout和error事件做处理,因为请求时很可能出现错误

在回调函数中拿到的res实际上是一个 incommingMessage 的实例,它在可读流的基础上进行了一次扩展,我们可以把它当做可读流来使用,完全没问题

const req = http.get({
    hostname: 'abc.com',
    protocol: 'https:',
    path: 'test.exe',
}, (res) => {
  console.log(res); // res就是一个可读流
})
 
// 请求出错
req.on('error', (err) => {
  
});
 
// 请求超时
req.on('timeout', (err) => {
 
});

2. 将可读流存储到本地文件

现在,我们有了一个可读流,我们需要将它的数据写入到我们本地文件中,这时候可写流就要出场了。写入文件分为以下步骤:

  1. 设置可读流的编码 setEncoding ,默认会使用Buffer来传输数据。下载图片、安装包等二进制文件的时候,使用Buffer即可,如果下载json、txt这种文件,就需要自行设置编码
  2. 创建一个可写流,传入一个本地文件的路径,例如/my/test.exe。这里文件的后缀名一般来说,要和你下载的文件保持一致。将一个图片文件的数据写入到.txt文件里面,会出现问题
  3. 处理数据流动过程中的事件
// 将数据写入文件
const readStream = await request('balabala'); // 我是获取到的可读流
const writeStream = fs.createWriteStream('/abc/test.exe'); // 新建一个可读流,传入我们需要保存的文件
 
// 使用可读流的pipe方法,所有数据将流动到test.exe中
readStream.pipe(writeStream);

上述代码执行完之后,其实文件下载就已经开始了。不过只下载文件可不行,还有很多feature没有实现呢

2.1 断点继续下载

一般可读流的数据出问题之后,会进入pause状态,我们需要在可读流恢复正常之后继续下载,而不是重新下载整个文件。有两种实现方法:

  1. 手动调用 readable.resume() 方法,可以将暂停的流恢复。但是不适用当前的场景,因为我们不知道流恢复正常的时机
  2. 监听可读流的 readable事件 reabable event , 在可读流的数据准备好给外界读取的时候,该事件都会触发
reabable.on('readable', () => {
 readable.read(); // 数据可以被读取时,调用read方法读取数据
})

2.2 获取下载进度

代码中储存本地已经下载完毕的chunk大小 A ,通过header中的content-length来获取远程文件的大小 B,文件的下载进度即为 A / B

// 远程文件的大小
let { 'content-length': remoteFileSize } = headers;
remoteFileSize = parseInt(remoteFileSize, 10);
 
 
// let chunkSize = 0 ; // 本地已经下载完的chunk大小
 
 
readable.on('data', (chunk) => {
   chunkSize += chunk.length; // 每次数据的流出,都会触发data事件,将流出数据的大小累加在chunkSize上
   event.emit('progress', chunkSize / remoteFileSize); // 通过事件抛出文件下载的进度
});

2.3 文件下载完毕

一开始,我以为可读流的end事件触发的时候,文件就已经下载完毕了,但是却踩了坑,见下面的代码

readable.on('end', () => {
   // 我以为文件已经下载完毕
   shell.open(distPath); // 尝试打开这个文件
   // Error:file is busy
});

当我想打开这个文件的时候,却发现偶尔会出现 文件正在被另一个程序 占用的错误
翻了一下文档,发现可读流end事件触发时,只表明可读流的数据已经消耗完毕 ,并不能保证数据已经全部写入到了本地磁盘
那么回忆一下,我们是用什么来写入数据到本地文件的?没错,就是可写流

writeableStream.on('finish', () => {
   // 文件是真的已经写入到本地磁盘了
   shell.open(distPath); // 尝试打开这个文件
   // 成功打开,Yes!
});

总结

NodeJs中的Stream是一个非常强大的功能,下载文件功能是一个Stream的经典应用场景
最后附上所有的download模块源码

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions