序章

Nodeアプリケーションを構築する際に、アプリでユーザーのプロフィール写真として使用する写真(通常はフォームから)をアップロードする必要がありました。 さらに、通常、簡単にアクセスできるように、写真をローカルファイルシステム(開発中)またはクラウドに保存する必要があります。 これは非常に一般的なタスクであるため、プロセスの個々の部分を処理するために活用できるツールがたくさんあります。

このチュートリアルでは、写真をアップロードして、ストレージに書き込む前に操作(サイズ変更、切り抜き、グレースケールなど)する方法を説明します。 簡単にするために、ローカルファイルシステムにファイルを保存することに限定します。

前提条件

次のパッケージを使用してアプリケーションをビルドします。

  • express :非常に人気のあるノードサーバー。
  • lodash :配列、文字列、オブジェクト、関数型プログラミングを操作するための多くのユーティリティ関数を備えた非常に人気のあるJavaScriptライブラリ。
  • multer multipart/form-dataリクエストからファイルを抽出するためのパッケージ。
  • jimp :画像操作パッケージ。
  • dotenv .env変数をprocess.envに追加するためのパッケージ。
  • mkdirp :ネストされたディレクトリ構造を作成するためのパッケージ。
  • concat-stream :ストリームからのすべてのデータを連結し、結果を使用してコールバックを呼び出す書き込み可能なストリームを作成するためのパッケージ。
  • streamifier :バッファ/文字列を読み取り可能なストリームに変換するパッケージ。

プロジェクトの目標

Multer からアップロードされたファイルストリームを引き継ぎ、ストリームバッファー( image )を操作しますが、イメージをストレージに書き込む前に、Jimpを使用します。 (ローカルファイルシステム)。 これには、Multerで使用するカスタムストレージエンジンを作成する必要があります。これは、このチュートリアルで行います。

このチュートリアルで構築する内容の最終結果は次のとおりです。

ステップ1—はじめに

まず、Expressジェネレーターを使用して新しいExpressアプリを作成します。 Expressジェネレーターをまだお持ちでない場合は、コマンドラインターミナルで次のコマンドを実行して、最初にExpressジェネレーターをインストールする必要があります。

  1. npm install express-generator -g

Expressジェネレーターを入手したら、次のコマンドを実行して、新しいExpressアプリを作成し、Expressの依存関係をインストールできます。 ビューエンジンとしてejsを使用します。

  1. express --view=ejs photo-uploader-app
  2. cd photo-uploader-app
  3. npm install

次に、プロジェクトに必要な残りの依存関係をインストールします。

  1. npm install --save lodash multer jimp dotenv concat-stream streamifier mkdirp

ステップ2—基本を構成する

続行する前に、アプリでフォームを構成する必要があります。 プロジェクトのルートディレクトリに.envファイルを作成し、いくつかの環境変数を追加します。 .envファイルは次のスニペットのようになります。

AVATAR_FIELD=avatar
AVATAR_BASE_URL=/uploads/avatars
AVATAR_STORAGE=uploads/avatars

次に、 dotenv を使用して環境変数をprocess.envにロードし、アプリでそれらにアクセスできるようにします。 これを行うには、app.jsファイルに次の行を追加します。 依存関係をロードするポイントにこの行を追加してください。 すべてのルートをインポートする前、およびExpressアプリインスタンスを作成する前に行う必要があります。

app.js
var dotenv = require('dotenv').config();

これで、process.envを使用して環境変数にアクセスできます。 例:process.env.AVATAR_STORAGEには値uploads/avatarsが含まれている必要があります。 インデックスルートファイルroutes/index.jsを編集して、ビューに必要なローカル変数を追加します。 2つのローカル変数を追加します。

  • title :インデックスページのタイトル:Upload Avatar
  • avatar_field :アバター写真の入力フィールドの名前。 これはprocess.env.AVATAR_FIELDから取得します

GET /ルートを次のように変更します。

ルート/index.js
router.get('/', function(req, res, next) {
res.render('index', { title: 'Upload Avatar', avatar_field: process.env.AVATAR_FIELD });
});

ステップ3—ビューの準備

views/index.ejsファイルを変更して、写真アップロードフォームの基本的なマークアップを作成することから始めましょう。 わかりやすくするために、ビューにスタイルを直接追加して、少し見栄えを良くします。 このページのマークアップについては、次のコードを参照してください。

views / index.ejs
<html class="no-js">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %></title>
<style type="text/css">
* {
font: 600 16px system-ui, sans-serif;
}
form {
width: 320px;
margin: 50px auto;
text-align: center;
}
form > legend {
font-size: 36px;
color: #3c5b6d;
padding: 150px 0 20px;
}
form > input[type=file], form > input[type=file]:before {
display: block;
width: 240px;
height: 50px;
margin: 0 auto;
line-height: 50px;
text-align: center;
cursor: pointer;
}
form > input[type=file] {
position: relative;
}
form > input[type=file]:before {
content: 'Choose a Photo';
position: absolute;
top: -2px;
left: -2px;
color: #3c5b6d;
font-size: 18px;
background: #fff;
border-radius: 3px;
border: 2px solid #3c5b6d;
}
form > button[type=submit] {
border-radius: 3px;
font-size: 18px;
display: block;
border: none;
color: #fff;
cursor: pointer;
background: #2a76cd;
width: 240px;
margin: 20px auto;
padding: 15px 20px;
}
</style>
</head>
<body>
<form action="/upload" method="POST" enctype="multipart/form-data">
<legend>Upload Avatar</legend>
<input type="file" name="<%= avatar_field %>">
<button type="submit" class="btn btn-primary">Upload</button>
</form>
</body>
</html>

ビューでローカル変数を使用して、アバター入力フィールドのタイトルと名前を設定したことに注目してください。 ファイルをアップロードするため、フォームでenctype="multipart/form-data"を使用していることがわかります。 また、送信時にPOSTリクエストを/uploadルート(後で実装します)に送信するようにフォームを設定したこともわかります。

それでは、npm startを使用して初めてアプリを起動しましょう。

  1. npm start

正しくフォローしていれば、すべてがエラーなしで実行されるはずです。 ブラウザでlocalhost:3000にアクセスするだけです。 ページは次のスクリーンショットのようになります。

Upload Page

ステップ4— MulterStorageEngineを作成する

これまでのところ、アップロードリクエストのハンドラーを作成していないため、フォームから写真をアップロードしようとするとエラーが発生します。 /uploadルートを実装して実際にアップロードを処理し、そのためにMulterパッケージを使用します。 Multerパッケージにまだ慣れていない場合は、GithubMulterパッケージを確認できます。

Multerで使用するカスタムストレージエンジンを作成する必要があります。 プロジェクトルートにhelpersという名前の新しいフォルダーを作成し、その中にカスタムストレージエンジン用の新しいファイルAvatarStorage.jsを作成しましょう。 ファイルには、次のブループリントコードスニペットが含まれている必要があります。

helpers / AvatarStorage.js
// Load dependencies
var _ = require('lodash');
var fs = require('fs');
var path = require('path');
var Jimp = require('jimp');
var crypto = require('crypto');
var mkdirp = require('mkdirp');
var concat = require('concat-stream');
var streamifier = require('streamifier');

// Configure UPLOAD_PATH
// process.env.AVATAR_STORAGE contains uploads/avatars
var UPLOAD_PATH = path.resolve(__dirname, '..', process.env.AVATAR_STORAGE);

// create a multer storage engine
var AvatarStorage = function(options) {

// this serves as a constructor
function AvatarStorage(opts) {}

// this generates a random cryptographic filename
AvatarStorage.prototype._generateRandomFilename = function() {}

// this creates a Writable stream for a filepath
AvatarStorage.prototype._createOutputStream = function(filepath, cb) {}

// this processes the Jimp image buffer
AvatarStorage.prototype._processImage = function(image, cb) {}

// multer requires this for handling the uploaded file
AvatarStorage.prototype._handleFile = function(req, file, cb) {}

// multer requires this for destroying file
AvatarStorage.prototype._removeFile = function(req, file, cb) {}

// create a new instance with the passed options and return it
return new AvatarStorage(options);

};

// export the storage engine
module.exports = AvatarStorage;

リストされた関数の実装をストレージエンジンに追加し始めましょう。 コンストラクター関数から始めます。


// this serves as a constructor
function AvatarStorage(opts) {

var baseUrl = process.env.AVATAR_BASE_URL;

var allowedStorageSystems = ['local'];
var allowedOutputFormats = ['jpg', 'png'];

// fallback for the options
var defaultOptions = {
storage: 'local',
output: 'png',
greyscale: false,
quality: 70,
square: true,
threshold: 500,
responsive: false,
};

// extend default options with passed options
var options = (opts && _.isObject(opts)) ? _.pick(opts, _.keys(defaultOptions)) : {};
options = _.extend(defaultOptions, options);

// check the options for correct values and use fallback value where necessary
this.options = _.forIn(options, function(value, key, object) {

switch (key) {

case 'square':
case 'greyscale':
case 'responsive':
object[key] = _.isBoolean(value) ? value : defaultOptions[key];
break;

case 'storage':
value = String(value).toLowerCase();
object[key] = _.includes(allowedStorageSystems, value) ? value : defaultOptions[key];
break;

case 'output':
value = String(value).toLowerCase();
object[key] = _.includes(allowedOutputFormats, value) ? value : defaultOptions[key];
break;

case 'quality':
value = _.isFinite(value) ? value : Number(value);
object[key] = (value && value >= 0 && value <= 100) ? value : defaultOptions[key];
break;

case 'threshold':
value = _.isFinite(value) ? value : Number(value);
object[key] = (value && value >= 0) ? value : defaultOptions[key];
break;

}

});

// set the upload path
this.uploadPath = this.options.responsive ? path.join(UPLOAD_PATH, 'responsive') : UPLOAD_PATH;

// set the upload base url
this.uploadBaseUrl = this.options.responsive ? path.join(baseUrl, 'responsive') : baseUrl;

if (this.options.storage == 'local') {
// if upload path does not exist, create the upload path structure
!fs.existsSync(this.uploadPath) && mkdirp.sync(this.uploadPath);
}

}

ここでは、いくつかのオプションを受け入れるようにコンストラクター関数を定義しました。 また、これらのオプションが提供されていない場合や無効な場合に備えて、これらのオプションにいくつかのデフォルト(フォールバック)値を追加しました。 これを微調整して、必要に応じてより多くのオプションを含めることができますが、このチュートリアルでは、ストレージエンジンの次のオプションを使用します。

  • storage :ストレージファイルシステム。 ローカルファイルシステムで許可される値は'local'のみです。 デフォルトは'local'です。 必要に応じて、他のストレージファイルシステム(Amazon S3など)を実装できます。
  • output :画像出力形式。 'jpg'または'png'にすることができます。 デフォルトは'png'です。
  • greyscale trueに設定すると、出力画像はグレースケールになります。 デフォルトはfalseです。
  • quality :出力画像の品質を決定する0〜100の数値。 デフォルトは70です。
  • square trueに設定すると、画像は正方形にトリミングされます。 デフォルトはfalseです。
  • threshold :出力画像の最小サイズ(px内)を制限する数値。 デフォルト値は500です。 画像の最小サイズがこの数値を超えると、最小サイズがしきい値と等しくなるように画像のサイズが変更されます。
  • レスポンシブtrueに設定すると、サイズの異なる3つの出力画像(lgmdsm)が作成され、それぞれのフォルダに保存されます。 デフォルトはfalseです。

ランダムなファイル名を作成するためのメソッドと、ファイルに書き込むための出力ストリームを実装しましょう。


// this generates a random cryptographic filename
AvatarStorage.prototype._generateRandomFilename = function() {
// create pseudo random bytes
var bytes = crypto.pseudoRandomBytes(32);

// create the md5 hash of the random bytes
var checksum = crypto.createHash('MD5').update(bytes).digest('hex');

// return as filename the hash with the output extension
return checksum + '.' + this.options.output;
};

// this creates a Writable stream for a filepath
AvatarStorage.prototype._createOutputStream = function(filepath, cb) {

// create a reference for this to use in local functions
var that = this;

// create a writable stream from the filepath
var output = fs.createWriteStream(filepath);

// set callback fn as handler for the error event
output.on('error', cb);

// set handler for the finish event
output.on('finish', function() {
cb(null, {
destination: that.uploadPath,
baseUrl: that.uploadBaseUrl,
filename: path.basename(filepath),
storage: that.options.storage
});
});

// return the output stream
return output;
};

ここでは、 crypto を使用してランダムなmd5ハッシュを作成し、ファイル名として使用し、オプションからの出力をファイル拡張子として追加します。 また、指定されたファイルパスから書き込み可能なストリームを作成してからストリームを返すヘルパーメソッドを定義しました。 ストリームイベントハンドラーで使用しているため、コールバック関数が必要であることに注意してください。

次に、実際の画像処理を行う_processImage()メソッドを実装します。 実装は次のとおりです。


// this processes the Jimp image buffer
AvatarStorage.prototype._processImage = function(image, cb) {

// create a reference for this to use in local functions
var that = this;

var batch = [];

// the responsive sizes
var sizes = ['lg', 'md', 'sm'];

var filename = this._generateRandomFilename();

var mime = Jimp.MIME_PNG;

// create a clone of the Jimp image
var clone = image.clone();

// fetch the Jimp image dimensions
var width = clone.bitmap.width;
var height = clone.bitmap.height;
var square = Math.min(width, height);
var threshold = this.options.threshold;

// resolve the Jimp output mime type
switch (this.options.output) {
case 'jpg':
mime = Jimp.MIME_JPEG;
break;
case 'png':
default:
mime = Jimp.MIME_PNG;
break;
}

// auto scale the image dimensions to fit the threshold requirement
if (threshold && square > threshold) {
clone = (square == width) ? clone.resize(threshold, Jimp.AUTO) : clone.resize(Jimp.AUTO, threshold);
}

// crop the image to a square if enabled
if (this.options.square) {

if (threshold) {
square = Math.min(square, threshold);
}

// fetch the new image dimensions and crop
clone = clone.crop((clone.bitmap.width: square) / 2, (clone.bitmap.height: square) / 2, square, square);
}

// convert the image to greyscale if enabled
if (this.options.greyscale) {
clone = clone.greyscale();
}

// set the image output quality
clone = clone.quality(this.options.quality);

if (this.options.responsive) {

// map through the responsive sizes and push them to the batch
batch = _.map(sizes, function(size) {

var outputStream;

var image = null;
var filepath = filename.split('.');

// create the complete filepath and create a writable stream for it
filepath = filepath[0] + '_' + size + '.' + filepath[1];
filepath = path.join(that.uploadPath, filepath);
outputStream = that._createOutputStream(filepath, cb);

// scale the image based on the size
switch (size) {
case 'sm':
image = clone.clone().scale(0.3);
break;
case 'md':
image = clone.clone().scale(0.7);
break;
case 'lg':
image = clone.clone();
break;
}

// return an object of the stream and the Jimp image
return {
stream: outputStream,
image: image
};
});

} else {

// push an object of the writable stream and Jimp image to the batch
batch.push({
stream: that._createOutputStream(path.join(that.uploadPath, filename), cb),
image: clone
});

}

// process the batch sequence
_.each(batch, function(current) {
// get the buffer of the Jimp image using the output mime type
current.image.getBuffer(mime, function(err, buffer) {
if (that.options.storage == 'local') {
// create a read stream from the buffer and pipe it to the output stream
streamifier.createReadStream(buffer).pipe(current.stream);
}
});
});

};

この方法では多くのことが行われていますが、これが行っていることの要約です。

  • ランダムなファイル名を生成し、Jimp出力画像のmimeタイプを解決して、画像のサイズを取得します。
  • 必要に応じて、しきい値の要件に基づいて画像のサイズを変更し、最小のサイズがしきい値を超えないようにします。
  • オプションで有効になっている場合は、画像を正方形にトリミングします。
  • オプションで有効になっている場合は、画像をグレースケールに変換します。
  • オプションから画像出力品質を設定します。
  • レスポンシブが有効になっている場合、画像はレスポンシブサイズ(lgmdsm)ごとに複製およびスケーリングされ、_createOutputStream()それぞれのサイズの各画像ファイルのメソッド。 各サイズのファイル名は、[random_filename_hash]_[size].[output_extension]の形式を取ります。 次に、イメージクローンとストリームがバッチに入れられて処理されます。
  • レスポンシブが無効になっている場合は、現在の画像とその出力ストリームのみがバッチに入れられて処理されます。
  • 最後に、バッチ内の各アイテムは、 streamifier を使用してJimpイメージバッファーを読み取り可能なストリームに変換し、読み取り可能なストリームを出力ストリームにパイプすることによって処理されます。

次に、残りのメソッドを実装し、ストレージエンジンを使用します。


// multer requires this for handling the uploaded file
AvatarStorage.prototype._handleFile = function(req, file, cb) {

// create a reference for this to use in local functions
var that = this;

// create a writable stream using concat-stream that will
// concatenate all the buffers written to it and pass the
// complete buffer to a callback fn
var fileManipulate = concat(function(imageData) {

// read the image buffer with Jimp
// it returns a promise
Jimp.read(imageData)
.then(function(image) {
// process the Jimp image buffer
that._processImage(image, cb);
})
.catch(cb);
});

// write the uploaded file buffer to the fileManipulate stream
file.stream.pipe(fileManipulate);

};

// multer requires this for destroying file
AvatarStorage.prototype._removeFile = function(req, file, cb) {

var matches, pathsplit;
var filename = file.filename;
var _path = path.join(this.uploadPath, filename);
var paths = [];

// delete the file properties
delete file.filename;
delete file.destination;
delete file.baseUrl;
delete file.storage;

// create paths for responsive images
if (this.options.responsive) {
pathsplit = _path.split('/');
matches = pathsplit.pop().match(/^(.+?)_.+?\.(.+)$/i);

if (matches) {
paths = _.map(['lg', 'md', 'sm'], function(size) {
return pathsplit.join('/') + '/' + (matches[1] + '_' + size + '.' + matches[2]);
});
}
} else {
paths = [_path];
}

// delete the files from the filesystem
_.each(paths, function(_path) {
fs.unlink(_path, cb);
});

};

これで、ストレージエンジンをMulterで使用できるようになりました。

ステップ5—POST /uploadルートを実装する

ルートを定義する前に、ルートで使用するためにMulterをセットアップする必要があります。 routes/index.jsファイルを編集して、以下を追加しましょう。

ルート/index.js

var express = require('express');
var router = express.Router();

/**
 * CODE ADDITION
 * 
 * The following code is added to import additional dependencies
 * and setup Multer for use with the /upload route.
 */

// import multer and the AvatarStorage engine
var _ = require('lodash');
var path = require('path');
var multer = require('multer');
var AvatarStorage = require('../helpers/AvatarStorage');

// setup a new instance of the AvatarStorage engine 
var storage = AvatarStorage({
square: true,
responsive: true,
greyscale: true,
quality: 90
});

var limits = {
files: 1, // allow only 1 file per request
fileSize: 1024 * 1024, // 1 MB (max file size)
};

var fileFilter = function(req, file, cb) {
// supported image file mimetypes
var allowedMimes = ['image/jpeg', 'image/pjpeg', 'image/png', 'image/gif'];

if (_.includes(allowedMimes, file.mimetype)) {
// allow supported image files
cb(null, true);
} else {
// throw error for invalid files
cb(new Error('Invalid file type. Only jpg, png and gif image files are allowed.'));
}
};

// setup multer
var upload = multer({
storage: storage,
limits: limits,
fileFilter: fileFilter
});

/* CODE ADDITION ENDS HERE */

ここでは、正方形のトリミング、レスポンシブ画像を有効にし、ストレージエンジンのしきい値を設定しています。 また、Multer構成に制限を追加して、最大ファイルサイズが1 MBになるようにし、非イメージファイルがアップロードされないようにします。

次に、POST /uploadルートを次のように追加しましょう。

/* routes/index.js */

/**
 * CODE ADDITION
 * 
 * The following code is added to configure the POST /upload route
 * to upload files using the already defined Multer configuration
 */

router.post('/upload', upload.single(process.env.AVATAR_FIELD), function(req, res, next) {

var files;
var file = req.file.filename;
var matches = file.match(/^(.+?)_.+?\.(.+)$/i);

if (matches) {
files = _.map(['lg', 'md', 'sm'], function(size) {
return matches[1] + '_' + size + '.' + matches[2];
});
} else {
files = [file];
}

files = _.map(files, function(file) {
var port = req.app.get('port');
var base = req.protocol + '://' + req.hostname + (port ? ':' + port : '');
var url = path.join(req.file.baseUrl, file).replace(/[\\\/]+/g, '/').replace(/^[\/]+/g, '');

return (req.file.storage == 'local' ? base : '') + '/' + url;
});

res.json({
images: files
});

});

/* CODE ADDITION ENDS HERE */

ルートハンドラの前にMulterアップロードミドルウェアをどのように渡したかに注目してください。 single()メソッドでは、req.fileに保存されるファイルを1つだけアップロードできます。 最初のパラメータとして、process.env.AVATAR_FIELDからアクセスするファイル入力フィールドの名前を取ります。

それでは、npm startを使用してアプリを再起動しましょう。

  1. npm start

ブラウザでlocalhost:3000にアクセスして、写真をアップロードしてみてください。 これは、現在の構成オプションを使用してPostmanでアップロードルートをテストして得たスクリーンショットの例です。

Postman-screen

Multerセットアップでストレージエンジンの構成オプションを微調整して、さまざまな結果を得ることができます。

結論

このチュートリアルでは、 Jimp を使用してアップロードされた画像を操作し、それらをストレージに書き込むMulterで使用するカスタムストレージエンジンを作成することができました。 このチュートリアルの完全なコードサンプルについては、GithubのAdvanced-multer-node-sourcecodeリポジトリを確認してください。