koa2基於stream(流)進行檔案上傳和下載

龍恩0707發表於2019-05-07

閱讀目錄

一:上傳檔案(包括單個檔案或多個檔案上傳)

在之前一篇文章,我們瞭解到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上的原始碼

相關文章