NodeとExpressで高度な写真のアップロードを追加する方法
序章
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ジェネレーターをインストールする必要があります。
npm install express-generator -g
Expressジェネレーターを入手したら、次のコマンドを実行して新しいExpressアプリを作成し、Expressの依存関係をインストールできます。 ビューエンジンとしてejs
を使用します。
express --view=ejs photo-uploader-app cd photo-uploader-app npm install
次に、プロジェクトに必要な残りの依存関係をインストールします。
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
を使用して初めてアプリを起動しましょう。
npm start
正しくフォローしていれば、すべてがエラーなしで実行されるはずです。 ブラウザでlocalhost:3000
にアクセスするだけです。 ページは次のスクリーンショットのようになります。
ステップ4— MulterStorageEngineを作成する
これまでのところ、アップロードリクエストのハンドラーを作成していないため、フォームから写真をアップロードしようとするとエラーが発生します。 /upload
ルートを実装して実際にアップロードを処理し、そのためにMulterパッケージを使用します。 Multerパッケージにまだ慣れていない場合は、GithubでMulterパッケージを確認できます。
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
です。 画像の最小サイズがこの数値を超えると、最小サイズがしきい値と等しくなるように画像のサイズが変更されます。 - response :
true
に設定すると、サイズの異なる3つの出力画像(lg
、md
、sm
)が作成され、それぞれのフォルダに保存されます。 デフォルトは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タイプを解決して、画像のサイズを取得します。
- 必要に応じて、しきい値の要件に基づいて画像のサイズを変更し、最小のサイズがしきい値を超えないようにします。
- オプションで有効になっている場合は、画像を正方形にトリミングします。
- オプションで有効になっている場合は、画像をグレースケールに変換します。
- オプションから画像出力品質を設定します。
- レスポンシブが有効になっている場合、レスポンシブサイズ(
lg
、md
、sm
)ごとに画像のクローンとスケーリングが行われ、_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
を使用してアプリを再起動しましょう。
npm start
ブラウザでlocalhost:3000
にアクセスして、写真をアップロードしてみてください。 これは、現在の構成オプションを使用してPostmanでアップロードルートをテストして得たスクリーンショットの例です。
Multerセットアップでストレージエンジンの構成オプションを微調整して、さまざまな結果を得ることができます。
結論
このチュートリアルでは、 Jimp を使用してアップロードされた画像を操作し、それらをストレージに書き込むMulterで使用するカスタムストレージエンジンを作成することができました。 このチュートリアルの完全なコードサンプルについては、GithubのAdvanced-multer-node-sourcecodeリポジトリを確認してください。