Node.js 文件上传实现详解
1. 问题解析
文件上传是 Web 开发中的常见需求。理解 multipart/form-data 格式、boundary 分隔符以及 HTTP 协议细节,对于实现稳定可靠的文件上传功能至关重要。Node.js 提供了多种方式处理文件上传,从原生实现到成熟的中间件方案。
2. 核心概念
2.1 multipart/form-data 协议
当表单包含文件上传时,enctype 必须设置为 multipart/form-data:
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" />
<input type="text" name="username" />
<button type="submit">上传</button>
</form>
2.2 HTTP 请求结构
POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 1234
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
john_doe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
[二进制文件数据]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
2.3 关键概念
| 概念 | 说明 |
|---|---|
| boundary | 分隔符,用于区分不同表单字段 |
| Content-Disposition | 描述字段信息,包含 name 和 filename |
| Content-Type | 文件 MIME 类型 |
| Stream | 大文件应使用流式处理避免内存溢出 |
3. 详细解答
3.1 原生实现文件上传解析
const http = require('http');
const fs = require('fs');
const path = require('path');
/**
* 简化的 multipart/form-data 解析器
*/
class MultipartParser {
constructor(boundary) {
this.boundary = Buffer.from('--' + boundary);
this.state = 'boundary';
this.parts = [];
this.currentPart = null;
this.buffer = Buffer.alloc(0);
}
parse(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
this.processBuffer();
}
processBuffer() {
while (this.buffer.length > 0) {
if (this.state === 'boundary') {
const idx = this.buffer.indexOf(this.boundary);
if (idx === -1) break;
this.buffer = this.buffer.slice(idx + this.boundary.length);
// 检查是否是最后一个 boundary (以 -- 结尾)
if (this.buffer.slice(0, 2).toString() === '--') {
this.state = 'end';
break;
}
// 跳过 \r\n
if (this.buffer.slice(0, 2).toString() === '\r\n') {
this.buffer = this.buffer.slice(2);
}
this.state = 'header';
this.currentPart = { headers: {}, data: Buffer.alloc(0) };
}
if (this.state === 'header') {
const endIdx = this.buffer.indexOf('\r\n\r\n');
if (endIdx === -1) break;
const headerStr = this.buffer.slice(0, endIdx).toString();
this.parseHeaders(headerStr);
this.buffer = this.buffer.slice(endIdx + 4);
this.state = 'data';
}
if (this.state === 'data') {
const nextBoundary = this.buffer.indexOf(this.boundary);
if (nextBoundary === -1) {
// 保留部分数据,可能包含不完整的 boundary
if (this.buffer.length > this.boundary.length) {
const keep = this.boundary.length;
this.currentPart.data = Buffer.concat([
this.currentPart.data,
this.buffer.slice(0, -keep)
]);
this.buffer = this.buffer.slice(-keep);
}
break;
}
// 提取数据(去掉末尾的 \r\n)
let data = this.buffer.slice(0, nextBoundary - 2);
this.currentPart.data = Buffer.concat([this.currentPart.data, data]);
this.parts.push(this.currentPart);
this.buffer = this.buffer.slice(nextBoundary);
this.state = 'boundary';
}
}
}
parseHeaders(headerStr) {
const lines = headerStr.split('\r\n');
for (const line of lines) {
const [key, ...valueParts] = line.split(':');
if (valueParts.length > 0) {
const value = valueParts.join(':').trim();
this.currentPart.headers[key.toLowerCase()] = value;
// 解析 Content-Disposition
if (key.toLowerCase() === 'content-disposition') {
const match = value.match(/name="([^"]+)"/);
if (match) this.currentPart.name = match[1];
const filenameMatch = value.match(/filename="([^"]+)"/);
if (filenameMatch) this.currentPart.filename = filenameMatch[1];
}
}
}
}
}
// 使用原生方式处理文件上传
const server = http.createServer((req, res) => {
if (req.url === '/upload' && req.method === 'POST') {
const contentType = req.headers['content-type'];
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) {
res.statusCode = 400;
res.end('Missing boundary');
return;
}
const parser = new MultipartParser(boundaryMatch[1]);
const uploadDir = path.join(__dirname, 'uploads');
// 确保上传目录存在
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
req.on('data', chunk => {
parser.parse(chunk);
});
req.on('end', () => {
const results = [];
for (const part of parser.parts) {
if (part.filename) {
// 保存文件
const filename = Date.now() + '-' + part.filename;
const filepath = path.join(uploadDir, filename);
fs.writeFileSync(filepath, part.data);
results.push({
field: part.name,
filename: part.filename,
savedAs: filename,
size: part.data.length
});
} else {
// 普通表单字段
results.push({
field: part.name,
value: part.data.toString()
});
}
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, files: results }));
});
req.on('error', (err) => {
console.error('Upload error:', err);
res.statusCode = 500;
res.end('Upload failed');
});
} else {
res.statusCode = 404;
res.end('Not Found');
}
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
3.2 使用 koa-body 处理文件上传
const Koa = require('koa');
const koaBody = require('koa-body');
const fs = require('fs');
const path = require('path');
const app = new Koa();
// 配置 koa-body
app.use(koaBody({
multipart: true, // 启用文件上传
formidable: {
uploadDir: path.join(__dirname, 'uploads'), // 临时文件目录
keepExtensions: true, // 保留文件扩展名
maxFileSize: 10 * 1024 * 1024, // 最大文件大小 10MB
maxFields: 10, // 最大字段数
hash: 'md5' // 计算文件 hash
}
}));
// 文件上传接口
app.use(async (ctx) => {
if (ctx.path === '/upload' && ctx.method === 'POST') {
const files = ctx.request.files;
const body = ctx.request.body;
console.log('表单字段:', body);
console.log('上传的文件:', files);
const results = [];
// 处理多个文件
for (const key in files) {
const file = files[key];
// 如果是多文件上传,file 是数组
const fileArray = Array.isArray(file) ? file : [file];
for (const f of fileArray) {
// 移动文件到目标目录
const targetDir = path.join(__dirname, 'uploads', 'permanent');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const targetPath = path.join(targetDir, f.newFilename || path.basename(f.filepath));
fs.renameSync(f.filepath, targetPath);
results.push({
field: key,
originalName: f.originalFilename,
savedName: path.basename(targetPath),
size: f.size,
mimeType: f.mimetype,
hash: f.hash
});
}
}
ctx.body = {
success: true,
message: '上传成功',
data: results
};
} else if (ctx.path === '/upload' && ctx.method === 'GET') {
// 返回上传表单
ctx.type = 'html';
ctx.body = `
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="text" name="username" placeholder="用户名" /><br><br>
<input type="file" name="avatar" accept="image/*" /><br><br>
<input type="file" name="documents" multiple /><br><br>
<button type="submit">上传</button>
</form>
`;
} else {
ctx.status = 404;
ctx.body = 'Not Found';
}
});
app.listen(3000);
3.3 使用 koa-multer 处理文件上传
const Koa = require('koa');
const Router = require('@koa/router');
const multer = require('@koa/multer');
const path = require('path');
const app = new Koa();
const router = new Router();
// 配置存储
const storage = multer.diskStorage({
// 目标目录
destination: (req, file, cb) => {
cb(null, path.join(__dirname, 'uploads'));
},
// 文件名
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
// 文件过滤
const fileFilter = (req, file, cb) => {
// 只允许图片
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('只允许上传图片文件'), false);
}
};
// 创建 multer 实例
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5 // 最多5个文件
}
});
// 单文件上传
router.post('/upload/single', upload.single('avatar'), (ctx) => {
ctx.body = {
success: true,
file: ctx.file
};
});
// 多文件上传(相同字段)
router.post('/upload/multiple', upload.array('photos', 5), (ctx) => {
ctx.body = {
success: true,
files: ctx.files
};
});
// 多字段文件上传
router.post('/upload/fields', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 8 }
]), (ctx) => {
ctx.body = {
success: true,
files: ctx.files
};
});
// 错误处理
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof multer.MulterError) {
// Multer 错误
if (err.code === 'LIMIT_FILE_SIZE') {
ctx.status = 400;
ctx.body = { error: '文件大小超过限制' };
} else if (err.code === 'LIMIT_FILE_COUNT') {
ctx.status = 400;
ctx.body = { error: '文件数量超过限制' };
} else {
ctx.status = 400;
ctx.body = { error: err.message };
}
} else {
throw err;
}
}
});
app.use(router.routes());
app.listen(3000);
3.4 流式处理大文件上传
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 流式文件上传处理
* 适合大文件,避免内存溢出
*/
function handleStreamUpload(req, res, uploadDir) {
const contentType = req.headers['content-type'];
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) {
res.statusCode = 400;
res.end('Missing boundary');
return;
}
const boundary = Buffer.from('--' + boundaryMatch[1]);
let state = 'boundary';
let currentFile = null;
let currentField = null;
let buffer = Buffer.alloc(0);
let headerBuffer = '';
// 确保上传目录存在
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
req.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length > 0) {
if (state === 'boundary') {
const idx = buffer.indexOf(boundary);
if (idx === -1) {
// 如果缓冲区太大还没找到 boundary,保留最后一部分
if (buffer.length > boundary.length * 2) {
buffer = buffer.slice(-boundary.length * 2);
}
break;
}
// 关闭之前的文件
if (currentFile) {
currentFile.stream.end();
currentFile = null;
}
buffer = buffer.slice(idx + boundary.length);
// 检查是否结束
if (buffer.slice(0, 2).toString() === '--') {
state = 'end';
break;
}
// 跳过 \r\n
if (buffer.slice(0, 2).toString() === '\r\n') {
buffer = buffer.slice(2);
}
state = 'header';
headerBuffer = '';
}
if (state === 'header') {
const endIdx = buffer.indexOf('\r\n\r\n');
if (endIdx === -1) {
headerBuffer += buffer.toString();
buffer = Buffer.alloc(0);
break;
}
headerBuffer += buffer.slice(0, endIdx).toString();
buffer = buffer.slice(endIdx + 4);
// 解析 headers
const headers = {};
headerBuffer.split('\r\n').forEach(line => {
const [key, ...valueParts] = line.split(':');
if (valueParts.length) {
headers[key.toLowerCase()] = valueParts.join(':').trim();
}
});
const disposition = headers['content-disposition'] || '';
const nameMatch = disposition.match(/name="([^"]+)"/);
const filenameMatch = disposition.match(/filename="([^"]+)"/);
if (filenameMatch) {
// 文件字段
const filename = Date.now() + '-' + filenameMatch[1];
const filepath = path.join(uploadDir, filename);
currentFile = {
name: nameMatch?.[1],
filename: filenameMatch[1],
savedPath: filepath,
stream: fs.createWriteStream(filepath),
hash: crypto.createHash('md5')
};
currentFile.stream.on('error', (err) => {
console.error('File write error:', err);
});
} else if (nameMatch) {
// 普通字段
currentField = { name: nameMatch[1], value: '' };
}
state = 'data';
}
if (state === 'data') {
const nextBoundary = buffer.indexOf(boundary);
if (nextBoundary === -1) {
// 没有完整的 boundary,写入数据并保留部分缓冲区
const keepLength = boundary.length;
const writeLength = Math.max(0, buffer.length - keepLength);
if (writeLength > 0) {
const data = buffer.slice(0, writeLength);
if (currentFile) {
currentFile.stream.write(data);
currentFile.hash.update(data);
} else if (currentField) {
currentField.value += data.toString();
}
buffer = buffer.slice(writeLength);
}
break;
}
// 找到 boundary,写入剩余数据(去掉末尾的 \r\n)
const data = buffer.slice(0, nextBoundary - 2);
if (currentFile) {
currentFile.stream.write(data);
currentFile.stream.end();
currentFile.hash.update(data);
console.log('File saved:', {
field: currentFile.name,
originalName: currentFile.filename,
path: currentFile.savedPath,
md5: currentFile.hash.digest('hex')
});
currentFile = null;
} else if (currentField) {
currentField.value += data.toString();
console.log('Field:', currentField.name, '=', currentField.value);
currentField = null;
}
buffer = buffer.slice(nextBoundary);
state = 'boundary';
}
}
});
req.on('end', () => {
if (currentFile) {
currentFile.stream.end();
}
res.end(JSON.stringify({ success: true }));
});
req.on('error', (err) => {
console.error('Upload error:', err);
if (currentFile) {
currentFile.stream.destroy();
// 删除不完整的文件
fs.unlink(currentFile.savedPath, () => {});
}
res.statusCode = 500;
res.end('Upload failed');
});
}
// 使用
const server = http.createServer((req, res) => {
if (req.url === '/upload' && req.method === 'POST') {
handleStreamUpload(req, res, path.join(__dirname, 'uploads'));
} else {
res.statusCode = 404;
res.end('Not Found');
}
});
server.listen(3000);
4. 深入理解
4.1 文件上传安全风险
const path = require('path');
/**
* 文件上传安全检查
*/
class UploadSecurity {
// 危险的文件扩展名
static dangerousExtensions = [
'.exe', '.dll', '.bat', '.cmd', '.sh', '.php',
'.jsp', '.asp', '.aspx', '.py', '.rb', '.pl'
];
// 检查文件扩展名
static checkExtension(filename) {
const ext = path.extname(filename).toLowerCase();
if (this.dangerousExtensions.includes(ext)) {
throw new Error(`危险的文件类型: ${ext}`);
}
return true;
}
// 检查 MIME 类型与扩展名是否匹配
static checkMimeType(filename, mimetype) {
const ext = path.extname(filename).toLowerCase();
const mimeMap = {
'.jpg': ['image/jpeg', 'image/jpg'],
'.jpeg': ['image/jpeg'],
'.png': ['image/png'],
'.gif': ['image/gif'],
'.pdf': ['application/pdf'],
'.txt': ['text/plain']
};
const allowedMimes = mimeMap[ext];
if (allowedMimes && !allowedMimes.includes(mimetype)) {
throw new Error(`MIME 类型不匹配: ${mimetype} 与 ${ext}`);
}
return true;
}
// 生成安全的文件名
static generateSafeFilename(originalName) {
const ext = path.extname(originalName);
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
return `${timestamp}-${random}${ext}`;
}
// 验证文件内容(魔术数字检查)
static async validateFileContent(filepath, expectedType) {
const fs = require('fs');
const buffer = Buffer.alloc(8);
const fd = fs.openSync(filepath, 'r');
fs.readSync(fd, buffer, 0, 8, 0);
fs.closeSync(fd);
// 文件签名(魔术数字)
const signatures = {
'image/jpeg': [0xFF, 0xD8, 0xFF],
'image/png': [0x89, 0x50, 0x4E, 0x47],
'image/gif': [0x47, 0x49, 0x46, 0x38],
'application/pdf': [0x25, 0x50, 0x44, 0x46]
};
const expected = signatures[expectedType];
if (expected) {
for (let i = 0; i < expected.length; i++) {
if (buffer[i] !== expected[i]) {
throw new Error('文件内容验证失败');
}
}
}
return true;
}
}
// 使用示例
app.use(async (ctx) => {
const file = ctx.request.files?.avatar;
if (!file) return;
try {
// 安全检查
UploadSecurity.checkExtension(file.originalFilename);
UploadSecurity.checkMimeType(file.originalFilename, file.mimetype);
// 生成安全文件名
const safeName = UploadSecurity.generateSafeFilename(file.originalFilename);
const targetPath = path.join(uploadDir, safeName);
// 移动文件
fs.renameSync(file.filepath, targetPath);
// 验证文件内容
await UploadSecurity.validateFileContent(targetPath, file.mimetype);
ctx.body = { success: true, filename: safeName };
} catch (err) {
// 清理临时文件
if (file.filepath && fs.existsSync(file.filepath)) {
fs.unlinkSync(file.filepath);
}
ctx.status = 400;
ctx.body = { error: err.message };
}
});
4.2 断点续传实现
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/**
* 断点续传上传管理器
*/
class ResumableUpload {
constructor(uploadDir) {
this.uploadDir = uploadDir;
this.tempDir = path.join(uploadDir, '.temp');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
// 初始化上传,返回 uploadId
initUpload(filename, fileSize) {
const uploadId = crypto.randomUUID();
const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
const metadata = {
uploadId,
filename,
fileSize,
chunkSize: 1024 * 1024, // 1MB 分片
uploadedChunks: [],
createdAt: Date.now()
};
fs.writeFileSync(metaPath, JSON.stringify(metadata));
return {
uploadId,
chunkSize: metadata.chunkSize,
totalChunks: Math.ceil(fileSize / metadata.chunkSize)
};
}
// 上传分片
uploadChunk(uploadId, chunkIndex, chunkData) {
const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
const chunkPath = path.join(this.tempDir, `${uploadId}.${chunkIndex}`);
if (!fs.existsSync(metaPath)) {
throw new Error('Upload not found');
}
// 保存分片
fs.writeFileSync(chunkPath, chunkData);
// 更新元数据
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
if (!metadata.uploadedChunks.includes(chunkIndex)) {
metadata.uploadedChunks.push(chunkIndex);
}
fs.writeFileSync(metaPath, JSON.stringify(metadata));
return {
uploadedChunks: metadata.uploadedChunks.length,
totalChunks: Math.ceil(metadata.fileSize / metadata.chunkSize)
};
}
// 合并分片
finalizeUpload(uploadId) {
const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const totalChunks = Math.ceil(metadata.fileSize / metadata.chunkSize);
// 检查是否所有分片都已上传
if (metadata.uploadedChunks.length !== totalChunks) {
throw new Error('Not all chunks uploaded');
}
// 合并文件
const finalPath = path.join(this.uploadDir, metadata.filename);
const writeStream = fs.createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(this.tempDir, `${uploadId}.${i}`);
const chunk = fs.readFileSync(chunkPath);
writeStream.write(chunk);
// 清理分片
fs.unlinkSync(chunkPath);
}
writeStream.end();
// 清理元数据
fs.unlinkSync(metaPath);
return { path: finalPath, size: metadata.fileSize };
}
// 获取上传进度
getProgress(uploadId) {
const metaPath = path.join(this.tempDir, `${uploadId}.meta`);
if (!fs.existsSync(metaPath)) {
return null;
}
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const totalChunks = Math.ceil(metadata.fileSize / metadata.chunkSize);
return {
uploadId,
uploadedChunks: metadata.uploadedChunks,
totalChunks,
percent: Math.round((metadata.uploadedChunks.length / totalChunks) * 100)
};
}
}
// API 路由实现
const router = require('@koa/router')();
const uploadManager = new ResumableUpload('./uploads');
// 初始化上传
router.post('/upload/init', (ctx) => {
const { filename, fileSize } = ctx.request.body;
const result = uploadManager.initUpload(filename, fileSize);
ctx.body = result;
});
// 上传分片
router.post('/upload/chunk/:uploadId', (ctx) => {
const { uploadId } = ctx.params;
const { chunkIndex } = ctx.request.body;
const chunkData = ctx.request.files.chunk;
const result = uploadManager.uploadChunk(
uploadId,
parseInt(chunkIndex),
fs.readFileSync(chunkData.filepath)
);
ctx.body = result;
});
// 完成上传
router.post('/upload/finalize/:uploadId', (ctx) => {
const { uploadId } = ctx.params;
const result = uploadManager.finalizeUpload(uploadId);
ctx.body = { success: true, file: result };
});
// 查询进度
router.get('/upload/progress/:uploadId', (ctx) => {
const { uploadId } = ctx.params;
const progress = uploadManager.getProgress(uploadId);
ctx.body = progress || { error: 'Upload not found' };
});
5. 最佳实践
5.1 文件上传配置建议
const koaBody = require('koa-body');
const path = require('path');
// 推荐的 koa-body 配置
const uploadConfig = {
multipart: true,
formidable: {
// 上传目录
uploadDir: path.join(__dirname, 'temp'),
// 保留扩展名
keepExtensions: true,
// 文件大小限制(10MB)
maxFileSize: 10 * 1024 * 1024,
// 总表单大小限制
maxFieldsSize: 20 * 1024 * 1024,
// 最大字段数
maxFields: 10,
// 是否对上传的文件进行哈希计算
hash: 'md5',
// 文件是否可写
writable: true,
// 遇到错误时是否保留临时文件(调试用)
keepExtensions: false
},
// 是否在错误时抛出
onError: (err, ctx) => {
console.error('Upload error:', err);
ctx.throw(400, '文件上传失败');
}
};
app.use(koaBody(uploadConfig));
// 定期清理临时文件
const cron = require('node-cron');
const fs = require('fs');
cron.schedule('0 0 * * *', () => {
const tempDir = path.join(__dirname, 'temp');
const files = fs.readdirSync(tempDir);
const now = Date.now();
files.forEach(file => {
const filePath = path.join(tempDir, file);
const stats = fs.statSync(filePath);
// 删除超过 24 小时的临时文件
if (now - stats.mtime.getTime() > 24 * 60 * 60 * 1000) {
fs.unlinkSync(filePath);
}
});
});
5.2 云存储上传
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { createReadStream } = require('fs');
// AWS S3 上传
const s3Client = new S3Client({
region: 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY,
secretAccessKey: process.env.AWS_SECRET_KEY
}
});
async function uploadToS3(filePath, key, options = {}) {
const stream = createReadStream(filePath);
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: stream,
ContentType: options.contentType || 'application/octet-stream',
ACL: options.isPublic ? 'public-read' : 'private'
});
await s3Client.send(command);
return {
key,
url: options.isPublic
? `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`
: await getSignedUrl(key)
};
}
// 阿里云 OSS 上传
const OSS = require('ali-oss');
const ossClient = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: process.env.OSS_ACCESS_KEY,
accessKeySecret: process.env.OSS_SECRET_KEY,
bucket: process.env.OSS_BUCKET
});
async function uploadToOSS(filePath, key) {
const result = await ossClient.put(key, filePath);
return {
key: result.name,
url: result.url,
etag: result.etag
};
}
6. 面试要点
-
multipart/form-data 格式理解
- 使用 boundary 分隔不同字段
- 每个部分包含 Content-Disposition 头
- 文件字段有 filename 和 Content-Type
- 最后一个 boundary 以
--结尾
-
文件上传的安全考虑
- 验证文件扩展名和 MIME 类型
- 限制文件大小和数量
- 使用魔术数字验证文件内容
- 生成随机文件名防止覆盖
- 存储目录不可执行
-
大文件处理方案
- 使用 Stream 避免内存溢出
- 实现断点续传支持大文件
- 分片上传提高可靠性
- 使用流式解析减少内存占用
-
常用中间件对比
- koa-body:功能全面,支持 JSON、表单、文件
- koa-multer:专注于文件上传,配置灵活
- formidable:底层解析库,koa-body 底层使用
- busboy:纯流式解析,性能最好
-
性能优化
- 使用流式处理大文件
- 设置合理的文件大小限制
- 使用对象存储(S3/OSS)分担服务器压力
- 实现断点续传减少重复上传
- 定期清理临时文件