Old Sunset Days

Node.jsでExpress上で画像アップロード

日付 タグ node.js カテゴリ node.js

目次

Express上でexpress-fileupload使って画像アップロード

先日Passportを使ってログイン認証をする記事を書いたが、ユーザーのアイコン画像とか、何かしら画像登録を登録したい場合はどうするのだろう?

ちょっと調べたところ、express-fileuploadというモジュールを使うと、どうも簡単にできそうだということでサンプルを作ってみた。

https://www.npmjs.com/package/express-fileupload - express-fileupload

さらにアップロードした画像をアイコン目的に使う場合、ある一定サイズ以下に縮小するようなことができないかということで、処理が軽いと言われている画像リサイズが簡単に行えるsharpも使ってみることにする。

https://github.com/lovell/sharp - sharp

なお、今回作ったサンプルのソースコードは参照用としてGithubの以下に置いてある。 https://github.com/hugodeblog/node-fileupload

Passportと必要なモジュールのインストール

いつもの通り、サンプルディレクトリを作って、Expressフレームワークを入れ、その上で必要なモジュールのインストール。

$ mkdir node-fileupload
$ cd node-fileupload
$ npx express-generator@4.x --view=hbs
$ npm install
$ npm install express-fileupload --save
$ npm install sharp --save

あと画像アップロード先としてディレクトリも作っておく。

$ mkdir -p public/img/upload_icon

インストールされたモジュールはpackage.json を見ると、こんな感じである。


package.json

{
  "name": "node-fileupload",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "debug": "~2.6.9",
    "express": "~4.16.1",
    "express-fileupload": "^1.2.1",
    "hbs": "~4.0.4",
    "http-errors": "~1.6.3",
    "morgan": "~1.9.1",
    "sharp": "^0.27.0"
  }
}

今回のプログラムで主なところを抜き出すと、

./
├── app.mjs
├── approotdir.mjs
├── appsupport.mjs
├── package.json
├── package-lock.json
├── routes
│   └── index.mjs
└── views
|   ├── error.hbs
|   ├── index.hbs
|   ├── layout.hbs
|   └── upload.hbs
|

もちろん、他にもファイルがあるのだが、主に実装していくなどで扱う部分は上記である。


approotdir.mjs

import * as path from 'path';
import * as url from 'url';

const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const approotdir = __dirname;


appsupport.mjs

import { server, port } from './app.mjs';
import * as util from 'util';

/**
 * Normalize a port into a number, string, or false.
 */
export function normalizePort(val) {
    const port = parseInt(val, 10);

    if (isNaN(port)) {
        // named pipe
        return val;
    }

    if (port >= 0) {
        // port number
        return port;
    }

    return false;
}

/**
 * Event listener for HTTP server "error" event.
 */
export function onError(error) {
    if (error.syscall !== 'listen') {
        throw error;
    }

    const bind = typeof port === 'string'
        ? 'Pipe ' + port
        : 'Port ' + port;

    // handle specific listen errors with friendly messages
    switch (error.code) {
        case 'EACCES':
            console.error(`${bind} requires elevated privileges`);
            process.exit(1);
            break;
        case 'EADDRINUSE':
            console.error(`${bind} is already in use`);
            process.exit(1);
            break;
        default:
            throw error;
    }
}

/**
 * Event listener for HTTP server "listening" event.
 */
export function onListening() {
    const addr = server.address();
    const bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
    console.log(`Listening on ${bind}`);
}


export function handle404(req, res, next) {
    const err = new Error('Not Found');
    err.status = 404;
    next(err);
}

export function basicErrorHandler(err, req, res, next) {
    // http://expressjs.com/en/guide/error-handling.html
    if (res.headersSent) {
        return next(err)
    }
    // set locals, only providing error in development
    res.locals.message = err.message;
    res.locals.error = req.app.get('env') === 'development' ? err : {};

    // render the error page
    res.status(err.status || 500);
    res.render('error');
}

async function catchProcessDeath() {
    console.log('shutdown ...');
    await server.close();
    process.exit(0);
}

process.on('uncaughtException', function(err) {
    console.error("Crashed - "+ (err.stack || err));
});

process.on('unhandledRejection', (reason, p) => {
    console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});

process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);

process.on('exit', () => { console.log('exiting...'); })

このあたりは、以前書いた記事等と同じ部分が多いので説明は割愛させていただく。


app.mjs

import { default as express } from 'express';
import { default as fileUpload } from 'express-fileupload';
import { default as hbs } from'hbs';
import * as path from'path';
import { default as logger } from'morgan';
import { default as cookieParser } from'cookie-parser';
import { default as bodyParser } from'body-parser';
import * as http from 'http';
import { approotdir } from './approotdir.mjs';
const __dirname = approotdir;
import {
    normalizePort, onError, onListening, handle404, basicErrorHandler
} from './appsupport.mjs';

import { router as indexRouter } from './routes/index.mjs';

export const app = express();

// Viewセットアップ
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));

app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use(fileUpload());

// ルーティング
app.use('/', indexRouter);

app.use(handle404);
app.use(basicErrorHandler);

export const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

export const server = http.createServer(app);

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
import { default as fileUpload } from 'express-fileupload';
...
...
app.use(fileUpload());

express-fileuploadをimportして利用を宣言している。

次に画像ファイルアプロードのためのビュー処理を先に見ておこう。


upload.mjs

<h1>画像をアップロードテスト</h1>
<p>Welcome to 画像をアップロードテスト</p>
<form action="/upload" method="post" encType="multipart/form-data">
  <div>
    <input type="file" name="uploadFile" />
  </div>
  <div>
    <input type='submit' value='Upload' />
  </div>
</form>
<hr>
<a href="/">トップページの戻る</a>

multipart/form-data/upload にPOSTされたデータの中で、name="uploadFile" として渡されるのが今回のアップロードされる画像である。

そのPOSTされたファイルを受け取るルーティング処理は以下の1つのファイルで処理を全部記述している。


index.mjs

import * as util from 'util';
import { default as express } from 'express';
export const router = express.Router();
import { approotdir } from '../approotdir.mjs';
const __dirname = approotdir;
import * as path from'path';

import { default as sharp } from 'sharp';

// ホームページ
router.get('/', (req, res, next) => {
  try {
    // 付加パラメータがあるかどうか
    console.log('resizeURL=' + req.query.resizeURL);
    res.render('index', {image_url: req.query.resizeURL ? req.query.resizeURL : undefined});
  } catch (err) {error(err); next(err);}
});


// ファイルアップロードフォーム
router.get('/upload', (req, res, next) => {
  try {
    // 全メモリストを取得して表示
    res.render('upload');
  } catch (err) {error(err); next(err);}
});

// ファイルアップロードを受け付ける
router.post('/upload', async (req, res, next) => {
  try {

    if (!req.files || Object.keys(req.files).length === 0) {
      return res.status(400).send('No files were uploaded');
    }

    let uploadFile = req.files.uploadFile;

    // アップロードファイルは20MB以内
    if (uploadFile.size > 20*1024*1024) {
      return res.status(400).send('File size is too big');
    }

    // 対応しているのはpng, jpg, gif, jpegのファイルとする
    let uploadFileExt = path.extname(uploadFile.name);

    if(uploadFileExt !== '.png' && uploadFileExt !== '.jpg' && uploadFileExt !== '.gif' && uploadFileExt !== '.jpeg') {
      return res.status(400).send('Only png, jpg, gif and jpeg are available');
    }

    // 保存するファイル名は同じファイル名が生じるケースを考えてDate.now()をつけたす
    let saveFilename = `${path.basename(uploadFile.name, uploadFileExt)}-${Date.now()}${uploadFileExt}`;

    // サーバー上の保存位置
    let uploadPath = path.join(__dirname, `public/img/upload_icon/${saveFilename}`);

    console.log(`ファイル名: ${uploadFile.name}`);
    console.log(`保存パス: ${uploadPath}`);

    // メモリ上にあるファイルをサーバーパスへ移動させる
    uploadFile.mv(uploadPath, (err) => {

      if(err)
        return res.status(500).send(err);

      // sharpをt使ってリサイズする時のファイル名
      let resizeURL = `img/upload_icon/${path.basename(saveFilename, path.extname(saveFilename))}-resize.jpg`;

      sharp(uploadPath)
      .resize(200, 200, {
        fit: 'inside'})
      .toFile(path.join(__dirname, `public/${resizeURL}`), (err, info)=>{
        if(err){ throw err }
        console.log(info);
      });

      res.redirect(`/?resizeURL=${resizeURL}`);
    });
  } catch (err) {console.log(err); next(err);}
});

ファイルアップロードのPOST処理部分は以下。

// ファイルアップロードを受け付ける
router.post('/upload', async (req, res, next) => {
  try {

    if (!req.files || Object.keys(req.files).length === 0) {
      return res.status(400).send('No files were uploaded');
    }

    let uploadFile = req.files.uploadFile;

    // アップロードファイルは20MB以内
    if (uploadFile.size > 20*1024*1024) {
      return res.status(400).send('File size is too big');
    }

express-fileuploadではマルチファイルのアップロードにも対応しているが、今回はフォームから1つのファイルのみアップロード選択できるようにしている。

そのため実際にはreq.filesにはファイルが1つ(req.files.uploadFile )しか入ってこない。

もしも、複数ファイルアップロードの場合は、例えば、

<input type="file" name="my_pic" />
<input type="file" name="my_pet" />

のようなフォームからのPOSTだと、

req.files.my_pic
req.files.my_pet

のような形でデータが渡ってくる。

// アップロードファイルは20MB以内
if (uploadFile.size > 20*1024*1024) {
  return res.status(400).send('File size is too big');
}

// 対応しているのはpng, jpg, gif, jpegのファイルとする
let uploadFileExt = path.extname(uploadFile.name);

if(uploadFileExt !== '.png' && uploadFileExt !== '.jpg' && uploadFileExt !== '.gif' && uploadFileExt !== '.jpeg') {
  return res.status(400).send('Only png, jpg, gif and jpeg are available');
}

ファイルサイズが20Mを超えるものはエラーを返し、またファイルの拡張子が(png, jpg, gif, jpeg)以外のものについてもエラーを返すようにしている。

ファイルデータそのものはexpression-fileuploadの挙動としてはデフォルトではサーバーメモリ上に最初は展開され、それを必要に応じてサーバー上のファイルに保存するのだが、

// 保存するファイル名は同じファイル名が生じるケースを考えてDate.now()をつけたす
let saveFilename = `${path.basename(uploadFile.name, uploadFileExt)}-${Date.now()}${uploadFileExt}`;

// サーバー上の保存位置
let uploadPath = path.join(__dirname, `public/img/upload_icon/${saveFilename}`);

同名ファイル名のファイルがアプロードされる場合を考えて日付(Unixtime)をつけてファイルは保存している。

[ファイルの拡張子を除いたもの]-日付(Unixtime).[ファイルの拡張子]という形になるので、

例えば、ocean.jpgならocean-1610797123709.jpgといった名前で保存する。

保存するサーバー上のディレクトリとしてはpublic/img/upload_icon以下。

// メモリ上にあるファイルをサーバーパスへ移動させる
uploadFile.mv(uploadPath, (err) => {

  if(err)
    return res.status(500).send(err);

メモリ上にある画像ファイルデータをサーバー上のファイルに保存。ファイルが保存されたら次にsharpを利用して画像のリサイズする。

縦横200px以内に収まるようにアスペクト比を維持してリサイズ。リサイズしたファイルはocean-1610797123709.jpgのファイルならocean-1610797123709-reseize.jpgとして保存。

  // sharpをt使ってリサイズする時のファイル名
  let resizeURL = `img/upload_icon/${path.basename(saveFilename, path.extname(saveFilename))}-resize.jpg`;

  sharp(uploadPath)
  .resize(200, 200, {
    fit: 'inside'})
  .toFile(path.join(__dirname, `public/${resizeURL}`), (err, info)=>{
    if(err){ throw err }
    console.log(info);
  });

ファイルのリサイズまで問題なければ、

res.redirect(`/?resizeURL=${resizeURL}`);

このリサイズした画像のURLをパラメータとしてトップページへリダイレクト。

そして、トップページのルーティング処理。

// ホームページ
router.get('/', (req, res, next) => {
  try {
    // 付加パラメータがあるかどうか
    console.log('resizeURL=' + req.query.resizeURL);
    res.render('index', {image_url: req.query.resizeURL ? req.query.resizeURL : undefined});
  } catch (err) {error(err); next(err);}
});

トップページのビューは、
index.mjs

<h1>画像をアップロードテスト</h1>
<p>Welcome to 画像をアップロードテスト</p>
{{#if image_url}}
<img src={{ image_url }}>
{{else}}
<p>トップページ用の画像が設定されていません</p>
{{/if}}
<hr>
<h2>画像をアップロードする</h2>
<a href="/upload">画像アップロード</a>

これにより、トップページに戻った時に画像が表示される。

他で触れていなかったビュー関係のファイルは以下だが、今までの記事と同じで特に特筆すべき点はない。


error.hbs

<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>


layout.hbs

<!DOCTYPE html>
<html>
  <head>
    <title>{{title}}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    {{{body}}}
  </body>
</html>

画像ファイルアップロードの動作検証

$ npm run start

> node-fileupload@0.0.0 start
> node ./app.mjs

Listening on port 3000

サーバーを起動させてhttp://localhost:3000にアクセスして動作チェックする。

挙動としては以下のようになる。

なお、トップページに戻った時に画像のURLは特に保持していないので、再度トップページをリロードすると画像が表示されない状態に戻る。

これはあくまでもテストサンプルなので、そうしているのだが、何かしら画像URLが一度設定されたら保持しておく仕組みが実用上は必要であろう。

以上、express-fileuploadを使用して画像ファイルのアップロードとsharpを利用して画像のリサイズを扱ってきた。

今回は画像ファイルがメモリ上に展開されているのをファイルに保存したが、express-fileuploadのオプションで一時ファイルに保存するのも可能である。

それらについてはexpress-fileuploadやsharpのURLリンク先から詳細をチェックしてみて欲しい。