概要
本文介绍一种文件切片上传策略,以应对项目开发中的可能遇到的大文件上传的需求。客户端采用Vue 3.0,服务器端提供Express和Asp.Net Core两种实现。
基本思路
Web客户端将用户要上传的文件,按照指定的尺寸进行分片切割,每次只上传一片,并告知服务器端已经上传的文件大小。
服务器端根据客户端告知的已上传文件大小,决定每次是执行文件的创建操作还是继续进行写文件操作。
整个上传操作由客户端主导,当最后一个文件片上传成功后,上传操作完成。服务器端只进行写文件的操作,不进行任何状态管理。
代码及实现
客户端关键代码实现
客户单代码的只要任务是文件切割,并逐个将表单传给服务器端。
客户度采用Vue 3.0,采用表单上传的方式。
响应式数据如下:
const fileUploader = ref(null);
const state = reactive({
uploadedSize : 0,
fileSize: 0,
});
- fileUploader 直接绑定到input:file上
- uploadedSize 表示已经上传的文件大小
- fileSize为整个文件的大小
文件切片上传代码如下:
const createFormData = ({name, uploadedSize, stamp, file}) => {
const fd = new FormData();
fd.append("fileName", name);
fd.append("uploadedSize", uploadedSize);
fd.append("stamp", stamp);
fd.append("file", file);
return fd;
}
该函数用于创建表单,表单数据包括文件名,已上传文件大小,时间戳和文件片。
const uploadFile = async () => {
const {files:[file]} = fileUploader.value;
if (!file){
return;
}
const {name, size } = file;
const stamp = new Date().getTime();
while (state.uploadedSize < size){
let fileChunk = file.slice(state.uploadedSize, state.uploadedSize + CHUNK_SIZE);
let fd = createFormData({
name,
stamp,
uploadedSize: state.uploadedSize,
file: fileChunk
});
try{
await axios.post(API_UPLOAD_URL, fd, {timeout:3000});
state.uploadedSize += fileChunk.size;
}catch(err){
throw err;
}
}
};
- 获取input:file的引用值file
- 如果file为空,返回(UI操作已经略去)
- 从file中解构出文件名name和文件大小size
- 只要已经上传的文件大小小于文件实际大小,进行切片上传:
- 每次上传的切片文件大小是CHUNK_SIZE,本文是64*1024,即64K,该值可以根据实际网络情况进行调整;
- 调用createFormData方法生成表单,时间戳作为文件的唯一性标识;
- 采用Axios的post方法提交表单,注意服务器端的文件写操作可能要消耗的时间比较长,所以适当延长Axios的超时限制;
- 每次上传操作完成后,更新已上传的文件的大小。
服务器端代码实现
服务器端代码的主要任务是写入文件,返回文件的URL。
Express作为服务器端的关键代码如下:
const { StatusCodes } = require('http-status-codes');
const { resolve } = require('path');
const { existsSync, promises } = require('fs');
const uploadFile = async (req,res) => {
const {uploadedSize, fileName ,stamp} = req.body;
const filePath = resolve(__dirname, `static_files/${stamp}/${fileName}`);
const { file } = req.files;
try {
if (Number(uploadedSize) !== 0){
const existed = existsSync(filePath);
if (!existed){
res.status(StatusCodes.NOT_FOUND).json({
msg:`The file ${fileName} does not exist`,
code:1,
});
return;
}
await promises.appendFile(filePath, file.data);
res.status(StatusCodes.OK).json({
msg:`The file has appended to ${fileName}.`,
code:0,
});
return;
}
await promises.mkdir(resolve(__dirname, `static_files/${stamp}`));
await promises.writeFile(filePath, file.data);
res.status(StatusCodes.CREATED).json({
msg:`The file is uploaded.`,
url: `http://localhost:3500/static_files/${stamp}/${fileName}`,
code:0,
});
return;
} catch (error) {
console.log(error);
}
}
module.exports = {
uploadFile
}
- 从Request对象的body中结构出已经上传文件大小,文件名和时间戳;
- 获取文件的绝对路径,时间戳在路径中,避免如果有多个人,同时上传不同的文件,文件内容互相覆盖;
- 如果已经上传的文件大小大于0,但是文件目录不存在,则返回404,表示已经上传的文件丢失;
- 如果已经上传的文件大小大于0,则执行文件的Append操作,将新的文件片内容写入已有的文件中,返回200;
- 如果已经上传的文件大小是0, 先创建目录,再创建文件,返回201。
Asp.Net服务器端代码实现相同的功能,关键代码如下:
[EnableCors("upload")]
[HttpPost("upload"), DisableRequestSizeLimit]
public async Task<IActionResult> uploadFiles(
IFormFile file,
[FromForm] int uploadedSize,
[FromForm] string stamp,
[FromForm] string fileName
){
var pathToSave = Path.Combine(Directory.GetCurrentDirectory(), _uploadSettings.RootFolder);
pathToSave = Path.Combine(pathToSave, _uploadSettings.ImageFolder);
pathToSave = Path.Combine(pathToSave, stamp);
try
{
if (!Directory.Exists(pathToSave) && uploadedSize > 0){
return NotFound($"The file {fileName} does not existed.");
}
if (uploadedSize == 0)
{
Directory.CreateDirectory(pathToSave);
}
var fullPath = Path.Combine(pathToSave, fileName);
using (var fs = new FileStream(fullPath,
uploadedSize > 0 ? FileMode.Append : FileMode.Create)){
await file.CopyToAsync(fs);
}
var urlPath = $"{_uploadSettings.RootFolder}/{_uploadSettings.ImageFolder}/{stamp}/{fileName}";
var uri = new Uri(new Uri($"{Request.Scheme}://{Request.Host}"), urlPath);
if (uploadedSize == 0)
{
return Created(uri,null);
}
return Ok(uri.ToString());
}
catch (System.Exception)
{
throw;
}
}
为了处理大文件,所以增加DisableRequestSizeLimit的Annotation,避免上传大文件,服务器端直接报错。
Asp.Net服务器端代码逻辑与Express的实现逻辑基本相同,不再赘述。
注意:无论使用Express还是Asp.Net,在拼接路径的时候,尽量使用已有的方法,不要直接使用字符串操作。因为服务器端代码可能部署在Windows或Linux的服务器上。
附录
Express跨域代码:
const cors = require("cors");
app.use(cors());
Asp.Net Core 跨域代码,本文使用的Asp.Net Core版本是3.1,如果在开发环境,并不需要https,请将https的中间件代码移除。如果在生产环境中使用https,请按照本文推荐的顺序调用中间件。
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(
options=>{
options.AddPolicy("upload", policy =>
{
policy.WithOrigins("http://localhost:8080/",
"http://192.168.31.206/")
.SetIsOriginAllowed((host)=>true)
.AllowAnyHeader()
.AllowAnyMethod();
});
}) ;
services
.Configure<UploadSettings>(Configuration.GetSection("UploadSettings"))
.AddControllersWithViews();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
var staticFolder = Configuration.GetSection("UploadSettings").Get<UploadSettings>().RootFolder;
app.UseStaticFiles( new StaticFileOptions{
FileProvider = new PhysicalFileProvider(
Path.Combine(env.ContentRootPath, staticFolder)
),
RequestPath = $"/{staticFolder }"
});
app.UseRouting();
app.UseCors("upload");
// app.UseHttpsRedirection();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}