代码改变世界

koa2基于stream(流)进行文件上传和下载

2019-05-07 23:13 龙恩0707 阅读(...) 评论(...) 编辑 收藏

阅读目录

一:上传文件(包括单个文件或多个文件上传)

在之前一篇文章,我们了解到nodejs中的流的概念,也了解到了使用流的优点,具体看我之前那一篇文章介绍的。
现在我们想使用流做一些事情,来实践下它的应用场景及用法。今天我给大家分享的是koa2基于流的方式实现文件上传和下载功能。

首先要实现文件上传或下载肯定是需要使用post请求,以前我们使用 koa-bodyparser这个插件来解析post请求的。但是今天给大家介绍另一个插件 koa-body, 
该插件即可以解析post请求,又支持文件上传功能,具体可以看这篇文章介绍(http://www.ptbird.cn/koa-body.html), 或看官网github(https://github.com/dlau/koa-body).

其次就是koa-body的版本问题,如果旧版本的koa-body通过ctx.request.body.files获取上传的文件。而新版本是通过ctx.request.files获取上传的文件的。否则的话,你会一直报错:ctx.request.files.file ---------->终端提示undefined问题. 如下图所示:

我这边的koa-body 是4版本以上的("koa-body": "^4.1.0",), 因此使用 ctx.request.files.file; 来获取文件了。

那么上传文件也有两种方式,第一种方式是使用form表单提交数据,第二种是使用ajax方式提交。那么二种方式的区别我想大家也应该了解,无非就是页面刷不刷新的问题了。下面我会使用这两种方式来上传文件演示下。

1. 上传单个文件

首先先来介绍下我项目的目录结构如下:

|----项目demo
|  |--- .babelrc       # 解决es6语法问题
|  |--- node_modules   # 所有依赖的包
|  |--- static
|  | |--- upload.html  # 上传html页面
|  | |--- load.html    # 下载html页面
|  | |--- upload       # 上传图片或文件都放在这个文件夹里面
|  |--- app.js         # 编写node相关的入口文件,比如上传,下载请求
|  |--- package.json   # 依赖的包文件

如上就是我目前项目的基本架构。如上我会把所有上传的文件或图片会放到 /static/upload 文件夹内了。也就是说把上传成功后的文件存储到我本地文件内。然后上传成功后,我会返回一个json数据。

在项目中,我用到了如下几个插件:koa, fs, path, koa-router, koa-body, koa-static. 如上几个插件我们并不陌生哦。下面我们分别引用进来,如下代码:

const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const router = require('koa-router')();
const koaBody = require('koa-body');
const static = require('koa-static');

const app = new Koa();

/* 
  koa-body 对应的API及使用 看这篇文章 http://www.ptbird.cn/koa-body.html
  或者看 github上的官网 https://github.com/dlau/koa-body
*/
app.use(koaBody({
  multipart: true, // 支持文件上传
  formidable: {
    maxFieldsSize: 2 * 1024 * 1024, // 最大文件为2兆
    multipart: true // 是否支持 multipart-formdate 的表单
  }
}));

app.use(static(path.join(__dirname)));

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3001, () => {
  console.log('server is listen in 3001');
});

如上代码就是我app.js 基本架构,使用koa来监听服务,端口号是3001,然后使用koa-router来做路由页面指向。使用koa-body插件来解析post请求,及支持上传文件的功能。使用 koa-static插件来解析静态目录资源。使用fs来使用流的功能,比如 fs.createWriteStream 写文件 或 fs.createReadStream 读文件功能。使用path插件来解析目录问题,比如 path.join(__dirname) 这样的。

我们希望当我们 当我们访问 http://localhost:3001/ 的时候,希望页面指向 我们的 upload.html页面,因此app.js请求代码可以写成如下:

router.get('/', (ctx) => {
  // 设置头类型, 如果不设置,会直接下载该页面
  ctx.type = 'html';
  // 读取文件
  const pathUrl = path.join(__dirname, '/static/upload.html');
  ctx.body = fs.createReadStream(pathUrl);
});

注意:如上 ctx.type = 'html', 一定要设置下,否则打开该页面直接会下载该页面的了。然后我们使用fs.createReadStream来读取我们的页面后,把该页面指向 ctx.body 了,因此当我们访问 http://localhost:3001/ 的时候 就指向了 我们项目中的 static/upload.html 了。

下面我们来看下我们项目下的 /static/upload.html 页面代码如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>文件上传</title>
</head>
<body>
  <form action="http://localhost:3001/upload" method="post" enctype="multipart/form-data">
    <div>
      <input type="file" name="file">
    </div>
    <div>
      <input type="submit" value="提交"/>
    </div>
  </form>
</body>
</html>

如上upload.html页面,就是一个form表单页面,然后一个上传文件按钮,上传后,我们点击提交,即可调用form表单中的action动作调用http://localhost:3001/upload这个接口,因此现在我们来看下app.js中 '/upload' 代码如下:

const uploadUrl = "http://localhost:3001/static/upload";
// 上传文件
router.post('/upload', (ctx) => {

  const file = ctx.request.files.file;
  // 读取文件流
  const fileReader = fs.createReadStream(file.path);

  const filePath = path.join(__dirname, '/static/upload/');
  // 组装成绝对路径
  const fileResource = filePath + `/${file.name}`;

  /*
   使用 createWriteStream 写入数据,然后使用管道流pipe拼接
  */
  const writeStream = fs.createWriteStream(fileResource);
  // 判断 /static/upload 文件夹是否存在,如果不在的话就创建一个
  if (!fs.existsSync(filePath)) {
    fs.mkdir(filePath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        fileReader.pipe(writeStream);
        ctx.body = {
          url: uploadUrl + `/${file.name}`,
          code: 0,
          message: '上传成功'
        };
      }
    });
  } else {
    fileReader.pipe(writeStream);
    ctx.body = {
      url: uploadUrl + `/${file.name}`,
      code: 0,
      message: '上传成功'
    };
  }
});

如上代码 '/post' 请求最主要做了以下几件事:
1. 获取上传文件,使用 const file = ctx.request.files.file; 我们来打印下该file,输出如下所示:

2. 我们使用 fs.createReadStream 来读取文件流;如代码:const fileReader = fs.createReadStream(file.path);  我们也可以打印下 fileReader 输出内容如下:

3. 对当前上传的文件保存到 /static/upload 目录下,因此定义变量:const filePath = path.join(__dirname, '/static/upload/');

4. 组装文件的绝对路径,代码:const fileResource = filePath + `/${file.name}`;

5. 使用 fs.createWriteStream 把该文件写进去,如代码:const writeStream = fs.createWriteStream(fileResource);

6. 下面这段代码就是判断是否有该目录,如果没有改目录,就创建一个 /static/upload 这个目录,如果有就直接使用管道流pipe拼接文件,如代码:fileReader.pipe(writeStream);

if (!fs.existsSync(filePath)) {
  fs.mkdir(filePath, (err) => {
    if (err) {
      throw new Error(err);
    } else {
      fileReader.pipe(writeStream);
      ctx.body = {
        url: uploadUrl + `/${file.name}`,
        code: 0,
        message: '上传成功'
      };
    }
  });
} else {
  fileReader.pipe(writeStream);
  ctx.body = {
    url: uploadUrl + `/${file.name}`,
    code: 0,
    message: '上传成功'
  };
}

最后我们使用 ctx.body 返回到页面来,因此如果我们上传成功了,就会在upload页面返回如下信息了;如下图所示:

因此所有的app.js 代码如下:

const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const router = require('koa-router')();
const koaBody = require('koa-body');
const static = require('koa-static');

const app = new Koa();

/* 
  koa-body 对应的API及使用 看这篇文章 http://www.ptbird.cn/koa-body.html
  或者看 github上的官网 https://github.com/dlau/koa-body
*/
app.use(koaBody({
  multipart: true, // 支持文件上传
  formidable: {
    maxFieldsSize: 2 * 1024 * 1024, // 最大文件为2兆
    multipart: true // 是否支持 multipart-formdate 的表单
  }
}));

const uploadUrl = "http://localhost:3001/static/upload";

router.get('/', (ctx) => {
  // 设置头类型, 如果不设置,会直接下载该页面
  ctx.type = 'html';
  // 读取文件
  const pathUrl = path.join(__dirname, '/static/upload.html');
  ctx.body = fs.createReadStream(pathUrl);
});

// 上传文件
router.post('/upload', (ctx) => {

  const file = ctx.request.files.file;
  console.log(file);
  // 读取文件流
  const fileReader = fs.createReadStream(file.path);
  console.log(fileReader);
  const filePath = path.join(__dirname, '/static/upload/');
  // 组装成绝对路径
  const fileResource = filePath + `/${file.name}`;

  /*
   使用 createWriteStream 写入数据,然后使用管道流pipe拼接
  */
  const writeStream = fs.createWriteStream(fileResource);
  // 判断 /static/upload 文件夹是否存在,如果不在的话就创建一个
  if (!fs.existsSync(filePath)) {
    fs.mkdir(filePath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        fileReader.pipe(writeStream);
        ctx.body = {
          url: uploadUrl + `/${file.name}`,
          code: 0,
          message: '上传成功'
        };
      }
    });
  } else {
    fileReader.pipe(writeStream);
    ctx.body = {
      url: uploadUrl + `/${file.name}`,
      code: 0,
      message: '上传成功'
    };
  }
});

app.use(static(path.join(__dirname)));

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3001, () => {
  console.log('server is listen in 3001');
});

如上是使用 form表单提交的,我们也可以使用 ajax来提交,那么需要改下 upload.html代码了。

2. 使用ajax方法提交。

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>文件上传</title>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
  <!-- 使用form表单提交
  <form action="http://localhost:3001/upload" method="post" enctype="multipart/form-data">
    <div>
      <input type="file" name="file">
    </div>
    <div>
      <input type="submit" value="提交"/>
    </div>
  </form>
  -->
  <div>
    <input type="file" name="file" id="file">
  </div>
  <script type="text/javascript">
    var file = document.getElementById('file');
    const instance = axios.create({
      withCredentials: true
    });
    file.onchange = function(e) {
      var f1 = e.target.files[0];
      var fdata = new FormData();
      fdata.append('file', f1);
      instance.post('http://localhost:3001/upload', fdata).then(res => {
        console.log(res);
      }).catch(err => {
        console.log(err);
      });
    }
  </script>
</body>
</html>

如上我们打印 console.log(res); 后,可以看到如下信息了;

3. 上传多个文件

为了支持多个文件上传,和单个文件上传,我们需要把代码改下,改成如下:

html代码如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>文件上传</title>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
  <!-- 使用form表单提交
  <form action="http://localhost:3001/upload" method="post" enctype="multipart/form-data">
    <div>
      <input type="file" name="file">
    </div>
    <div>
      <input type="submit" value="提交"/>
    </div>
  </form>
  -->
  <!--  上传单个文件
  <div>
    <input type="file" name="file" id="file">
  </div>
  <script type="text/javascript">
    var file = document.getElementById('file');
    const instance = axios.create({
      withCredentials: true
    });
    file.onchange = function(e) {
      var f1 = e.target.files[0];
      var fdata = new FormData();
      fdata.append('file', f1);
      instance.post('http://localhost:3001/upload', fdata).then(res => {
        console.log(res);
      }).catch(err => {
        console.log(err);
      });
    }
  </script>
  -->
  <div>
    <input type="file" name="file" id="file" multiple="multiple">
  </div>
  <script type="text/javascript">
    var file = document.getElementById('file');
    const instance = axios.create({
      withCredentials: true
    });
    file.onchange = function(e) {
      var files = e.target.files;
      var fdata = new FormData();
      if (files.length > 0) {
        for (let i = 0; i < files.length; i++) {
          const f1 = files[i];
          fdata.append('file', f1);
        }
      }
      instance.post('http://localhost:3001/upload', fdata).then(res => {
        console.log(res);
      }).catch(err => {
        console.log(err);
      });
    }
  </script>
</body>
</html>

如上是多个文件上传的html代码和js代码,就是把多个数据使用formdata一次性传递多个数据过去,现在我们需要把app.js 代码改成如下了,app.js 代码改的有点多,最主要是要判断 传过来的文件是单个的还是多个的逻辑,所有代码如下:

const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const router = require('koa-router')();
const koaBody = require('koa-body');
const static = require('koa-static');

const app = new Koa();

/* 
  koa-body 对应的API及使用 看这篇文章 http://www.ptbird.cn/koa-body.html
  或者看 github上的官网 https://github.com/dlau/koa-body
*/
app.use(koaBody({
  multipart: true, // 支持文件上传
  formidable: {
    maxFieldsSize: 2 * 1024 * 1024, // 最大文件为2兆
    multipart: true // 是否支持 multipart-formdate 的表单
  }
}));

const uploadUrl = "http://localhost:3001/static/upload";

router.get('/', (ctx) => {
  // 设置头类型, 如果不设置,会直接下载该页面
  ctx.type = 'html';
  // 读取文件
  const pathUrl = path.join(__dirname, '/static/upload.html');
  ctx.body = fs.createReadStream(pathUrl);
});
/*
 flag: 是否是多个文件上传
*/
const uploadFilePublic = function(ctx, files, flag) {
  const filePath = path.join(__dirname, '/static/upload/');
  let file,
    fileReader,
    fileResource,
    writeStream;

  const fileFunc = function(file) {
    // 读取文件流
    fileReader = fs.createReadStream(file.path);
    // 组装成绝对路径
    fileResource = filePath + `/${file.name}`;
    /*
     使用 createWriteStream 写入数据,然后使用管道流pipe拼接
    */
    writeStream = fs.createWriteStream(fileResource);
    fileReader.pipe(writeStream);
  };
  const returnFunc = function(flag) {
    console.log(flag);
    console.log(files);
    if (flag) {
      let url = '';
      for (let i = 0; i < files.length; i++) {
        url += uploadUrl + `/${files[i].name},`
      }
      url = url.replace(/,$/gi, "");
      ctx.body = {
        url: url,
        code: 0,
        message: '上传成功'
      };
    } else {
      ctx.body = {
        url: uploadUrl + `/${files.name}`,
        code: 0,
        message: '上传成功'
      };
    }
  };
  if (flag) {
    // 多个文件上传
    for (let i = 0; i < files.length; i++) {
      const f1 = files[i];
      fileFunc(f1);
    }
  } else {
    fileFunc(files);
  }
  
  // 判断 /static/upload 文件夹是否存在,如果不在的话就创建一个
  if (!fs.existsSync(filePath)) {
    fs.mkdir(filePath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        returnFunc(flag);
      }
    });
  } else {
    returnFunc(flag);
  }
}

// 上传单个或多个文件
router.post('/upload', (ctx) => {
  let files = ctx.request.files.file;
  const fileArrs = [];
  if (files.length === undefined) {
    // 上传单个文件,它不是数组,只是单个的对象
    uploadFilePublic(ctx, files, false);
  } else {
     uploadFilePublic(ctx, files, true);
  }
});

app.use(static(path.join(__dirname)));

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3001, () => {
  console.log('server is listen in 3001');
});

然后我现在来演示下,当我选择多个文件,比如现在选择两个文件,会返回如下数据:

当我现在只选择一个文件的时候,只会返回一个文件,如下图所示:

如上app.js改成之后的代码现在支持单个或多个文件上传了。

注意:这边只是演示下多个文件上传的demo,但是在项目开发中,我不建议大家这样使用,而是多张图片多个请求比较好,因为大小有限制的,比如a.png 和 b.png 这两张图片,如果a图片比较小,b图片很大很大,那么如果两张图片一起上传的话,接口肯定会上传失败,但是如果把请求分开发,那么a图片会上传成功的,b图片是上传失败的。这样比较好。

当然我们在上传之前我们还可以对文件进行压缩下或者对文件的上传进度实时显示下优化下都可以,但是目前我这边先不做了,下次再把所有的都弄下。这里只是演示下 fs.createReadStream 流的一些使用方式。

二:下载文件

文件下载需要使用到koa-send这个插件,该插件是一个静态文件服务的中间件,它可以用来实现文件下载的功能。

html代码如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>文件下载演示</title>
</head>
<body>
  
  <div>
    <button onclick="fileLoad()">文件下载</button>
    <iframe name="iframeId" style="display:none"></iframe>
  </div>
  <script type="text/javascript">
    function fileLoad() {
      window.open('/fileload/Q4汇总.xlsx', 'iframeId');
    }
  </script>
</body>
</html>

app.js 所有的代码改成如下:

const Koa = require('koa');
const fs = require('fs');
const path = require('path');
const router = require('koa-router')();
const koaBody = require('koa-body');
const static = require('koa-static');
const send = require('koa-send');
const app = new Koa();
app.use(koaBody());

router.get('/', (ctx) => {
  // 设置头类型, 如果不设置,会直接下载该页面
  ctx.type = 'html';
  // 读取文件
  const pathUrl = path.join(__dirname, '/static/load.html');
  ctx.body = fs.createReadStream(pathUrl);
});

router.get('/fileload/:name', async (ctx) => {
  const name = ctx.params.name;
  const path = `static/upload/${name}`;
  ctx.attachment(path);
  await send(ctx, path);
});

app.use(static(path.join(__dirname)));
app.use(router.routes());
app.use(router.allowedMethods());

app.listen(3001, () => {
  console.log('server is listen in 3001');
});

如上代码就可以了,当我页面访问 http://localhost:3001/ 这个的时候,会显示我项目下的 load.html页面,该页面有一个下载文件的按钮,当我点击该按钮的时候,就会下载我本地上某一个文件。比如上面的代码,我们使用了window.open. 跳转指定到了某个隐藏的iframe,如果我们使用window.open(url), 后面不指定任何参数的话,它会以 '_blank' 的方式打开,最后会导致页面会刷新下,然后下载,对于用户体验来说不好,隐藏我们就让他在iframe里面下载,因此页面看不到跳动的感觉了。
当然如果我们使用window.open(url, '_self') 也是可以的,但是貌似有小问题,比如可能会触发 beforeunload 等页面事件,如果你的页面监听了该事件做一些操作的话,那就会有影响的。 所以我们使用隐藏的iframe去做这件事。

注意:上面的window.open('/fileload/Q4汇总.xlsx'); 中的 Q4汇总.xlsx 是我本地项目中刚刚上传的文件。也就是说该文件在我本地上有这个的文件的就可以下载的。如果我本地项目中没有该文件就下载不了的。

注意:当然批量文件下载也是可以做的,这里就不折腾了。有空自己研究下,或者百度下都有类似的文章,自己折腾下即可。这篇文章最主要想使用 fs.createReadStream 的使用场景。

查看github上的源码