Koa 文件上传功能的实现方法和最佳实践
Koa 核心不包含文件上传功能,需要通过中间件实现。最常用的文件上传中间件是 koa-body 或 koa-multer,它们提供了强大的文件上传处理能力。1. 使用 koa-body 处理文件上传:安装:npm install koa-body基本配置:const koaBody = require('koa-body');app.use(koaBody({ multipart: true, // 启用文件上传 formidable: { maxFileSize: 100 * 1024 * 1024, // 最大文件大小 100MB keepExtensions: true, // 保留文件扩展名 uploadDir: './uploads', // 上传目录 multiples: true // 支持多文件上传 }}));单文件上传:app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) { ctx.throw(400, 'No file uploaded'); } // 获取文件信息 const fileInfo = { name: file.name, size: file.size, path: file.path, type: file.type, lastModifiedDate: file.lastModifiedDate }; ctx.body = { message: 'File uploaded successfully', file: fileInfo };});多文件上传:app.use(async (ctx) => { const files = ctx.request.files.files; if (!files) { ctx.throw(400, 'No files uploaded'); } // 处理单个文件或多个文件 const fileList = Array.isArray(files) ? files : [files]; const uploadedFiles = fileList.map(file => ({ name: file.name, size: file.size, path: file.path, type: file.type })); ctx.body = { message: `${uploadedFiles.length} files uploaded`, files: uploadedFiles };});2. 使用 koa-multer 处理文件上传:安装:npm install koa-multer基本配置:const multer = require('koa-multer');// 存储配置const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, './uploads/'); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); }});const upload = multer({ storage: storage, limits: { fileSize: 100 * 1024 * 1024 // 100MB }, fileFilter: function (req, file, cb) { // 文件类型过滤 const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file type'), false); } }});单文件上传:app.use(upload.single('file'));app.use(async (ctx) => { const file = ctx.req.file; ctx.body = { message: 'File uploaded successfully', file: { originalname: file.originalname, filename: file.filename, path: file.path, size: file.size, mimetype: file.mimetype } };});多文件上传:// 最多上传 10 个文件app.use(upload.array('files', 10));app.use(async (ctx) => { const files = ctx.req.files; ctx.body = { message: `${files.length} files uploaded`, files: files.map(file => ({ originalname: file.originalname, filename: file.filename, path: file.path, size: file.size, mimetype: file.mimetype })) };});混合上传(文件 + 字段):app.use(upload.fields([ { name: 'avatar', maxCount: 1 }, { name: 'documents', maxCount: 5 }]));app.use(async (ctx) => { const files = ctx.req.files; const body = ctx.req.body; ctx.body = { message: 'Files uploaded successfully', avatar: files.avatar[0], documents: files.documents, data: body };});3. 文件上传安全措施:const path = require('path');const fs = require('fs');app.use(koaBody({ multipart: true, formidable: { maxFileSize: 10 * 1024 * 1024, // 限制文件大小 keepExtensions: true, uploadDir: './uploads', filter: function ({ name, originalFilename, mimetype }) { // 文件类型验证 const allowedTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf' ]; return allowedTypes.includes(mimetype); } }}));// 文件验证中间件async function validateFile(ctx, next) { const file = ctx.request.files.file; if (!file) { ctx.throw(400, 'No file uploaded'); } // 验证文件大小 const maxSize = 10 * 1024 * 1024; // 10MB if (file.size > maxSize) { // 删除已上传的文件 fs.unlinkSync(file.path); ctx.throw(400, 'File size exceeds limit'); } // 验证文件类型 const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!allowedTypes.includes(file.type)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file type'); } // 验证文件扩展名 const ext = path.extname(file.name).toLowerCase(); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif']; if (!allowedExts.includes(ext)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file extension'); } await next();}app.use(validateFile);4. 图片处理:使用 sharp 库处理上传的图片。npm install sharpconst sharp = require('sharp');app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) { ctx.throw(400, 'No file uploaded'); } // 生成缩略图 const thumbnailPath = file.path.replace(/(\.[\w\d]+)$/, '_thumb$1'); await sharp(file.path) .resize(200, 200, { fit: 'cover', position: 'center' }) .toFile(thumbnailPath); // 压缩图片 const compressedPath = file.path.replace(/(\.[\w\d]+)$/, '_compressed$1'); await sharp(file.path) .jpeg({ quality: 80 }) .toFile(compressedPath); ctx.body = { message: 'Image processed successfully', original: file.path, thumbnail: thumbnailPath, compressed: compressedPath };});5. 分片上传:对于大文件,实现分片上传功能。const fs = require('fs');const path = require('path');app.use(async (ctx) => { const { chunkIndex, totalChunks, fileId } = ctx.request.body; const file = ctx.request.files.chunk; const chunkDir = path.join('./uploads', fileId); const chunkPath = path.join(chunkDir, `chunk_${chunkIndex}`); // 创建分片目录 if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } // 保存分片 const reader = fs.createReadStream(file.path); const writer = fs.createWriteStream(chunkPath); reader.pipe(writer); // 检查是否所有分片都已上传 const uploadedChunks = fs.readdirSync(chunkDir).length; if (uploadedChunks === parseInt(totalChunks)) { // 合并分片 const finalPath = path.join('./uploads', `${fileId}${path.extname(file.name)}`); const writeStream = fs.createWriteStream(finalPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `chunk_${i}`); const chunkData = fs.readFileSync(chunkPath); writeStream.write(chunkData); fs.unlinkSync(chunkPath); } writeStream.end(); fs.rmdirSync(chunkDir); ctx.body = { message: 'File upload completed', path: finalPath }; } else { ctx.body = { message: `Chunk ${chunkIndex} uploaded`, progress: `${uploadedChunks}/${totalChunks}` }; }});6. 文件上传最佳实践:安全措施:限制文件大小验证文件类型验证文件扩展名使用随机文件名存储在非 Web 可访问目录性能优化:使用流式处理大文件实现分片上传使用 CDN 存储文件异步处理文件用户体验:提供上传进度支持断点续传显示上传状态提供预览功能错误处理:捕获上传错误清理失败的文件提供友好的错误信息记录上传日志