在 HTTP 模块章节中的静态文件服务器中,我们已经看过了两个可写流的例子,即服务器可以向 response 对象中写入数据和向 request 返回的请求对象中写入数据。
可写流是在 node 接口中广泛使用的概念,所有可写流都有一个 write 方法,用来传递字符串或者 buffer 对象; 还有一个 end 方法用于关闭流,如果给定一个参数,end 会在流关闭前输出指定的一段数据。这两个方法都可以接受一个回调函数作为参数。

但是在我们搭建的静态文件服务器中有一个问题,就是文件夹中的文件我们是直接用 readfire 方法读取的,无论文件的大小,对于体积小一点的文件来说还好说,但是如果是文件是一个几个 G 大小的电影的话,占用的内存空间就需要几个 G 的大小,而且在这个文件完全写入网络另一端之前这些内存是无法释放掉的,这对我们的电脑来说是无法承受的

事实上,对于文件来说我们也没有必要非得一次性的将文件传输完成,我们完全可以读一点写一点,一点一点地完成文件的传输。当然也不能是读一点马上就写一点,传输速度可能不同,比如文件读取的快、写入的慢,电脑完全可以读出了一定量的数据在内存里用来写入后去运行其他任务,这就需要有一个缓冲区来提高运行的效率。

流的目的:

  • 可以控制内存占用(控制内存占用不要超过一个水位线)
  • 可以将大的文件分解为小的片段,再一点一点的传输,以减轻内存的压力
  • 协调不同阶段处理速度之间的差异

流的分类:

  • 可读流
  • 可写流
  • 双工流
  • 转换流

例如:我们想传输一个电影

在以往的方式,可能我们会这么写:

const fs = require('fs')var file = 'movie.mp4'fs.readFile(file, (err, data) => {  if (err) {    console.log(err)  } else {    fs.writeFile('movie-1', data , () => {      console.log('done')    })  }})

写法十分简单,就是读取然后写入,这个过程是一次性完成的,也就意味着如果电影的大小是1 G 的话,内存的占用也至少为一个 G

如果用流的方式写呢:

const fs = require('fs')var file = 'D:/Users/movie.mp4'var rs = fs.createReadStream(file)var ws = fs.createWriteStream('D:/Users/movie-1.mp4', {highWaterMark: 65536})//这里的 highWaterMark 可以指定缓冲内存的大小,默认大小是65536个字节,即 64krs.on('data', data => {  if(ws.write(data) === false) { //这里的write函数会返回一个布尔值,false 表示缓冲内存已满,不可继续写入了    rs.pause() //如果内存已满,rs 暂停  }})ws.on('drain', () => { // ws 内存耗尽  rs.resume(  )  // rs 回复执行})rs.on('end', () => {  ws.end()})

如果读取的速度快于写入的速度缓冲内存满了之后,rs 就会在中途暂停读取,等缓冲内存里的数据写入完了之后再继续读取,这就是一个简单的流
注意,虽然 write 函数返回 false 时就告诉内存不可写入了,但是如果依旧写入的话,内存还是会接收数据的,而不会扔掉,当然这会导致内存占用过高

现在有更简单,更常用的书写方式,就是 pipe

const fs = require('fs')var file = 'D:/Users/movie.mp4'var rs = fs.c reateReadStream(file)var ws = fs.createWriteStream('D:/Users/movie-1.mp4', {highWaterMark: 65536})rs.pipe(ws) // pipe 以一种管道式的方式书写,并依次运行,如果有有的话完全可以写成:// rs.pipe(gzip).pipe(ws).pipe(conn)

当然了,pipe 的内部实现是要比上面的代码复杂的多的,但是主要想做的事情就是刚才我们做的,当每个环节流速不同时,通过调节流速来控制内存占用

练习

用一个可读流读取某一路径的文件

const { Readable } = require('stream')const fs = require('fs')exports.createReadStream = function createReadStream(path) {  var fd = fs.openSync(path,'r') //打开 path 路径文件  var fileStat = fs.statSync(path)  var fileSize = fileStat.size  var position = 0  return new Readable({    read(size) {      var buf = Buffer.alloc(1024) // buf 上有1024个字节的空间可用      if (position >= fileSize) {        this.push(null)        fs.close(fd, (err) => {          console.log(err)        })      } else {        fs.read(fd, buf, 0, 1024, position, (err, bytesRead) => {          // 从 fd 文件的 position 位置,读取1024个字节放在buf的第0位          if (err) {            console.log(err)          }          if (bytesRead < 1024) {            this.push(buf.slice(0, bytesRead))          } else {            this.push(buf)          }        })        position += 1024      }    }  })}

在 node 中运行代码

$ node> cfrs = require('./file-read-stream.js')> rs = cfrs('./http-server.js')> rs.on('data', d => console.log(d.toString()))  

这样就能读取出上节 HTTP 模块案例的代码了,如果我们在添加一个 write 的函数,就可以实现一个复制文件的功能了

exports.createWriteStream = function createWriteStream(path) {  var fd = fs.openSync(path, 'a+')  var position = 0  return new Writable({    write(chunk, encoding, done) {      fs.write(fd, chunk, 0, chunk.length, position, () => {        done()      })      position += chunk.length    }  })}$ node> const {createReadStream, createWriteStream} = require('./file-read-stream.js')> createReadStream('./http-server.js').pipe(createWriteStream('./http-server222.js'))

运行这段代码,就会有一个 http-server 的复制文件 http-server222 出现在文件夹中

回到昨天的静态文件服务器,我们需要将 readFile 改成流的方式

const http = require('http')const fs = require('fs')const fsp = fs.promisesconst path = require('path')const mime = require('mime')const port = 8090const baseDir = __dirnameconst server = http.createServer(async (req, res) => {  var targetPath = decodeURIComponent(path.join(baseDir, req.url))  console.log(req.method, req.url, baseDir, targetPath)  try {    var stat = await fsp.stat(targetPath)    if (stat.isFile()) {      try {        var type = mime.getType(targetPath)        if (type) {// 如果文件类型在 mimeMap 对象中,就使用相应的解码方式          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})        } else { //如果不在,就以流的方式解码          res.writeHead(200, {'Content-Type': `application/octet-stream`})        }        fs.createReadStream(targetPath).pipe(res)      } catch(e) {        res.writeHead(502)        res.end('502 Internal Server Error')      }    } else if (stat.isDirectory()) {      var indexPath = path.join(targetPath, 'index.html')      try {        await fsp.stat(indexPath)        var type = mime.getType(indexPath)        if (type) {          res.writeHead(200, {'Content-Type': `${type}; charset=UTF-8`})        } else {          res.writeHead(200, {'Content-Type': `application/octet-stream`})        }        fs.createReadStream(indexPath).pipe(res)      } catch(e) {        if (!req.url.endsWith('/')) {           res.writeHead(301, {            'Location': req.url + '/'          })          res.end()          return        }        var entries = await fsp.readdir(targetPath, {withFileTypes: true})        res.writeHead(200, {          'Content-Type': 'text/html; charset=UTF-8'        })        res.end(`          ${            entries.map(entry => {              var slash = entry.isDirectory() ? '/' : ''                return `                  <div>                    <a href='${entry.name}${slash}'>${entry.name}${slash}</a>                  </div>                `            }).join('')           }        `)      }    }  } catch(e) {      res.writeHead(404)      res.end('404 Not Found')  }})server.listen(port, () => {  console.log(port)})