Nest.js檔案上傳 EP01: Express.js搭配Multer實作檔案上傳功能


前言

筆者最近接觸Nest.js,其使用Adaptor設計模式,可採用Express或Fastify作為實現。目前社群上Express的文件比較齊全,所以就以Express為主學習。本篇探討如何使用Express實作檔案上傳功能,下一篇再使用Nest.js實現。

建立專案

  • 初始化專案

    • Bash commands

      mkdir express-example-multer
      cd express-example-multer
      npm init -y
    • tsconfig.json

      {
          "compilerOptions": {
              "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
              "module": "commonjs",                                /* Specify what module code is generated. */
              "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
              "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
              "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
              "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
          }
      }

安裝依賴

npm install cors express multer
npm install -D @types/cors @types/express @types/multer @ts-node @tsc @typescript

此時package.json應該會像這樣(main和script start/build需要再做修改)

{
  "name": "express-example-multer",
  "version": "1.0.0",
  "description": "",
  "main": "./dist/index.js",
  "scripts": {
    "start": "ts-node index.ts",
    "build": "tsc"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.18.3",
    "multer": "1.4.5-lts.1"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/multer": "^1.4.11",
    "ts-node": "^10.9.2",
    "tsc": "^2.0.4",
    "typescript": "^5.3.3"
  }
}

建立Express後端

  • index.ts
import { NextFunction, Request, Response } from 'express'
import express from 'express'
import multer, { MulterError } from 'multer'
import cors from 'cors'
import fs from 'fs'
import path from 'path'

const app = express()
app.use(cors())

app.listen(3000);

建立測試用網頁

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Express Multer file upload example</title>
    <script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
</head>
<body>
    <script>
    </script>
</body>
</html>

範例說明

本文有多個Use case分別對應不同的使用場景:

  • 單個檔案上傳
  • 多個檔案上傳
  • 多檔案上傳(指定欄位名稱)
  • 多檔案上傳(任意欄位名稱)

以下就一一介紹如何實作功能

單個檔案上傳

首先我們先從後端(index.ts)下手

import { NextFunction, Request, Response } from 'express'
import express from 'express'
import multer, { MulterError } from 'multer'
import cors from 'cors'
import fs from 'fs'
import path from 'path'

const app = express()
app.use(cors())

const upload = multer({ dest: 'uploads/' }); // 初始化multer實例

app.post('/upload', upload.single('file'), (req: Request, res: Response, next: NextFunction) => {
    console.log(`Reqest file: ${JSON.stringify(req?.file, null, 2)}`);
    console.log(`Reqest body: ${JSON.stringify(req?.body, null, 2)}`);
});

app.listen(3000);

這裡用multer(處理html multipart的middleware)來處理上傳檔案的處理,首先先初始化multer實例(dest為檔案上傳時存放的目錄),然後我們宣告一個post路由來提供上傳Endpoint(這裡是/upload)。使用初始化後的 upload.single方法來接收post表單,然後定義Callback function來作後續處理。(這邊印出檔案資訊和post body)。

再來寫個簡單的前端頁面作測試(也可以用postman等工具來測試)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Express Multer file upload example</title>
    <script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
</head>
<body>
    <div>
        <span>Single file</span>
        <input id="fileInput" type="file"/>
    </div>

    <script>
        const fileInput = document.querySelector('#fileInput');
        async function formData() {
            const data = new FormData();
            data.set('Foo','Bar');
            data.set('serial', 114514);
            data.set('file', fileInput.files[0]);

            try {
                const res = await axios.post('http://localhost:3000/upload', data);
                console.log(res);
            } catch (err) {
                console.log(`Error when uploading single file: ${err}`);
            }
        }
        fileInput.onchange = formData;
    </script>
</body>
</html>

這邊簡單用一個 input元素來對應要上傳的檔案,然後註冊一個事件,當檔案發生變化時自動建立表單後上傳

(輸出結果: TBD)

多個檔案上傳

多個檔案和單個檔案的程式碼很像,只是我們要改用 multer.array來處理檔案

app.post('/upload-multiple', upload.array('files', maxFileCount), (req: Request, res: Response, next: NextFunction) => {
    console.log(`Reqest files: ${JSON.stringify(req?.files, null, 2)}`);
    console.log(`Reqest body: ${JSON.stringify(req?.body, null, 2)}`);
}, (err: Error, req: Request, res: Response, next: NextFunction) => {
    console.log(`Error: ${JSON.stringify(err, null, 2)}`);
    if (err instanceof MulterError && err.code === 'LIMIT_UNEXPECTED_FILE') {
        res.status(400).send('Too many files!');   
    }
});

multer.array有一個參數可以設定最多接收幾個檔案,當超過檔案限制時會拋出異常

前端測試程式碼

<body>
    <div>
        <span>Multiple files</span>
        <input id="fileInputMulti" type="file" multiple />
    </div>

    <script>
    const fileInputMulti = document.querySelector('#fileInputMulti');
        async function formDataMulti() {
            const data = new FormData();
            data.set('Foo','Bar');
            data.set('serial', 114514);
            Array.from(fileInputMulti.files).forEach(file => {
                data.append('files', file);
            });

            try {
                const res = await axios.post('http://localhost:3000/upload-multiple', data);
                console.log(res);
            } catch (err) {
                console.log(`Error when uploading multiple files: ${err}`);
            }
        }
        fileInputMulti.onchange = formDataMulti;
    </script>
</body>

這邊將多個檔案上傳時組成一個陣列放入 files欄位,且 input元素需指定 multiple來告訴瀏覽器允許選擇多個檔案上傳

(輸出結果: TBD)

多檔案上傳(指定欄位名稱)

後端跟前端商量好使用指定的欄位名稱來代表特定的檔案,方便開發及測試

app.post('/upload-multiple-with-specified-fields', upload.fields([
    { name: 'a-type-files', maxCount: 2 }, 
    { name: 'b-type-files', maxCount: 3 }
]), (req: Request, res: Response, next: NextFunction) => {
    console.log(`Reqest files: ${JSON.stringify(req?.files, null, 2)}`);
    console.log(`Reqest body: ${JSON.stringify(req?.body, null, 2)}`);
}, (err: Error, req: Request, res: Response, next: NextFunction) => {
    console.log(`Error: ${JSON.stringify(err, null, 2)}`);
    if (err instanceof MulterError && err.code === 'LIMIT_UNEXPECTED_FILE') {
        res.status(400).send('Too many files!');   
    }
});

這裡定義了兩個欄位分別代表不同類型檔案。舉個實際例子,公告試題與解答的後台需要提供上傳兩種類型的PDF,一種是純考題,另一種是考題帶解答的版本。

前端測試程式碼

<body>
    <div>
        <span>Multiple files with specified Fields</span>
        <input id="fileInputMultiWithSpecifiedFields" type="file" multiple />
    </div>

    <script>
        const fileInputMultiWithSpecifiedFields = document.querySelector('#fileInputMultiWithSpecifiedFields');
        async function formDataMultiWithSpecifiedFields() {
            const data = new FormData();
            data.set('Foo','Bar');
            data.set('serial', 114514);
            Array.from(fileInputMultiWithSpecifiedFields.files).forEach((file, idx) => {
                if (idx < 2) {
                    data.append('a-type-files', file);
                } else {
                    data.append('b-type-files', file);
                }
            });

            try {
                const res = await axios.post('http://localhost:3000/upload-multiple-with-specified-fields', data);
                console.log(res);
            } catch (err) {
                console.log(`Error when uploading multiple files with specified fields: ${err}`);
            }
        }
        fileInputMultiWithSpecifiedFields.onchange = formDataMultiWithSpecifiedFields;
    </script>
</body>

(輸出結果: TBD)

多檔案上傳(任意欄位名稱)

有時候我們功能要做的比較靈活,這時我們可能就不會限定使用者使用那些欄位名稱來上傳檔案,這時候就會用到 multer.any來處理

app.post('/upload-multiple-with-fields', upload.any(), (req: Request, res: Response, next: NextFunction) => {
    console.log(`Reqest files: ${JSON.stringify(req?.files, null, 2)}`);
    console.log(`Reqest body: ${JSON.stringify(req?.body, null, 2)}`);
});

前端測試範例

<script>
    const fileInputMultiWithFields = document.querySelector('#fileInputMultiWithFields');
    async function formDataMultiWithFields() {
        const data = new FormData();
        data.set('Foo','Bar');
        data.set('serial', 114514);
        Array.from(fileInputMultiWithFields.files).forEach((file, idx) => {
            data.append(generateUUID(), file);
        });

        try {
            const res = await axios.post('http://localhost:3000/upload-multiple-with-fields', data);
            console.log(res);
        } catch (err) {
            console.log(`Error when uploading multiple files with fields: ${err}`);
        }
    }
    fileInputMultiWithFields.onchange = formDataMultiWithFields;
</script>

這裡假設一個情境是雲端儲存空間提供使用者上傳任意個檔案,上傳時用UUID來代表紀錄上傳的檔案。

(輸出結果: TBD)

總結

本篇介紹了如何使用Express.js + Multer middleware來實作檔案上傳功能,有助於Nest.js實作同功能的理解(因為Nest.js底層就是封裝這兩個組件),下一篇就是Nest.js的實作,敬請期待!