如何在Node和Express中添加高级照片上传
介绍
在构建我们的 Node 应用程序时,我们有时会遇到上传照片(通常来自表单)以用作我们应用程序中用户的个人资料照片。 此外,我们通常必须将照片存储在本地文件系统中(在开发期间),甚至存储在云端,以便于访问。 由于这是一项非常常见的任务,因此我们可以利用许多工具来处理流程的各个部分。
在本教程中,我们将了解如何上传照片并在将其写入存储之前对其进行操作(调整大小、裁剪、灰度等)。 为简单起见,我们将限制自己将文件存储在本地文件系统中。
先决条件
我们将使用以下包来构建我们的应用程序:
- express:非常流行的Node服务器。
- lodash:一个非常流行的 JavaScript 库,具有许多用于处理数组、字符串、对象和函数式编程的实用函数。
- multer:用于从
multipart/form-data
请求中提取文件的包。 - jimp:图像处理包。
- dotenv:将
.env
变量添加到process.env
的包。 - mkdirp:用于创建嵌套目录结构的包。
- concat-stream:用于创建可写流的包,它连接流中的所有数据并使用结果调用回调。
- streamifier:将 Buffer/String 转换为可读流的包。
项目目标
我们想从 Multer 接管上传的文件流,然后操作流缓冲区 (image) 但是我们希望在将图像写入存储之前使用 Jimp (本地文件系统)。 这将要求我们创建一个自定义存储引擎以与 Multer 一起使用——我们将在本教程中这样做。
这是我们将在本教程中构建的最终结果:
第 1 步 — 入门
我们将首先使用 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 应用程序实例之前。
应用程序.js
var dotenv = require('dotenv').config();
现在我们可以使用 process.env
访问我们的环境变量。 例如:process.env.AVATAR_STORAGE
应该包含值 uploads/avatars
。 我们将继续编辑我们的索引路由文件 routes/index.js
以添加我们在视图中需要的一些局部变量。 我们将添加两个局部变量:
- 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 }); });
第三步——准备视图
让我们首先通过修改 views/index.ejs
文件为我们的照片上传表单创建基本标记。 为了简单起见,我们将直接在我们的视图上添加样式,只是为了让它看起来更漂亮。 有关我们页面的标记,请参见以下代码。
意见/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"
,因为我们将上传一个文件。 您还将看到我们已将表单设置为在提交时向 /upload
路由(我们将在稍后实现)发出 POST
请求。
现在让我们使用 npm start
首次启动应用程序。
npm start
如果您一直正确地遵循一切应该运行没有错误。 只需在浏览器上访问 localhost:3000
。 该页面应类似于以下屏幕截图:
第 4 步 — 创建 Multer 存储引擎
到目前为止,尝试通过我们的表单上传照片会导致错误,因为我们还没有为上传请求创建处理程序。 我们将实现 /upload
路由来实际处理上传,我们将为此使用 Multer 包。 如果您还不熟悉 Multer 包,您可以查看 Github 上的 Multer 包。
我们将不得不创建一个自定义存储引擎来与 Multer 一起使用。 让我们在项目根目录中创建一个名为 helpers
的新文件夹,并在其中为我们的自定义存储引擎创建一个新文件 AvatarStorage.js
。 该文件应包含以下蓝图代码片段:
助手/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
)。 - 输出:图像输出格式。 可以是
'jpg'
或'png'
。 默认为'png'
。 - greyscale:如果设置为
true
,输出图像将为灰度。 默认为false
。 - quality:一个介于 0: 100 之间的数字,用于确定输出图像的质量。 默认为
70
。 - square:如果设置为
true
,图像将被裁剪为正方形。 默认为false
。 - threshold:限制输出图像最小维度(在
px
中)的数字。 默认值为500
。 如果图像的最小尺寸超过此数字,则调整图像大小以使最小尺寸等于阈值。 - responsive:如果设置为
true
,将创建三个不同大小的输出图像(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
中的文件。 它以我们从 process.env.AVATAR_FIELD
访问的文件输入字段的名称作为第一个参数。
现在让我们使用 npm start
再次启动应用程序。
npm start
在浏览器上访问 localhost:3000
并尝试上传照片。 这是我使用当前配置选项在 Postman 上测试上传路由时得到的示例截图:
您可以在 Multer 设置中调整存储引擎的配置选项以获得不同的结果。
结论
在本教程中,我们已经能够创建一个自定义存储引擎以与 Multer 一起使用,它使用 Jimp 操作上传的图像,然后将它们写入存储。 有关本教程的完整代码示例,请查看 Github 上的 advanced-multer-node-sourcecode 存储库。