net core WebApi——檔案分片上傳與跨域請求處理
目錄
- 前言
- 開始
- 測試
- 跨域
- 小結
@
前言
在之前整理完一套簡單的後臺基礎工程後,因為業務需要鼓搗了檔案上傳跟下載,整理完後就迫不及待的想分享出來,希望有用到檔案相關操作的朋友可以得到些幫助。
開始
我們依然用我們的基礎工程,之前也提到過後續如果有測試功能之類的東西,會一直不斷的更新這套程式碼(如果搞炸了之後那就…),首先我們需要理一下檔案分片上傳的思路:
- 後端
- 接收前端檔案上傳請求並處理回撥
- 根據前端傳遞的鑰匙判斷,允許後開始接收檔案流並儲存到臨時資料夾
- 前端最終上傳完成後給予後端合併請求(也稱作上傳完成確認),後端合併檔案後判斷最終檔案是否正確給予回撥。
- 前端
- 讀取檔案相關資訊(名稱,擴充套件型別,大小等基本資訊)
- 根據需要做片段劃分以及檔案的md5值(md5主要用於最終確認檔案是否缺損)
- 請求後端獲取鑰匙
- 拿到鑰匙後,我們根據劃分的片段去迴圈上傳檔案,並根據每次回撥判斷是否上傳成功,如失敗則重新上傳
- 最終迴圈完成後,給予後端合併請求(上傳完成確認)
ps:這裡的鑰匙就是個檔名,當然你可以來個token啊什麼的根據自己業務需要。
這裡還是想分享下敲程式碼的經驗,在我們動手之前,最好把能考慮到的東西全都想好,思路理清也就是打好提綱後,敲程式碼的效率會高並且錯誤率也會低,行雲流水不是天馬行空,而是你的大腦中已經有了山水鳥獸。
OK,流程清楚之後,我們開始動手敲程式碼吧。
首先,我們新建一個控制器FileController,當然名字可以隨意取,根據我們上述後端的思路,新建三個介面RequestUploadFile,FileSave,FileMerge。
[Route("api/[controller]")] [ApiController] public class FileController : ControllerBase { /// <summary> /// 請求上傳檔案 /// </summary> /// <param name="requestFile">請求上傳引數實體</param> /// <returns></returns> [HttpPost, Route("RequestUpload")] public MessageEntity RequestUploadFile([FromBody]RequestFileUploadEntity requestFile) { } /// <summary> /// 檔案上傳 /// </summary> /// <returns></returns> [HttpPost, Route("Upload")] public async Task<MessageEntity> FileSave() { } /// <summary> /// 檔案合併 /// </summary> /// <param name="fileInfo">檔案引數資訊[name]</param> /// <returns></returns> [HttpPost, Route("Merge")] public async Task<MessageEntity> FileMerge([FromBody]Dictionary<string, object> fileInfo) { } }
如果直接複製的朋友,這裡肯定是滿眼紅彤彤,這裡主要用了兩個類,一個請求實體RequestFileUploadEntity,一個回撥實體MessageEntity,這兩個我們到Util工程建立(當然也可以放到Entity工程,這裡為什麼放到Util呢,因為我覺得放到這裡公用比較好,畢竟還是有複用的價值的)。
/// <summary>
/// 檔案請求上傳實體
/// </summary>
public class RequestFileUploadEntity
{
private long _size = 0;
private int _count = 0;
private string _filedata = string.Empty;
private string _fileext = string.Empty;
private string _filename = string.Empty;
/// <summary>
/// 檔案大小
/// </summary>
public long size { get => _size; set => _size = value; }
/// <summary>
/// 片段數量
/// </summary>
public int count { get => _count; set => _count = value; }
/// <summary>
/// 檔案md5
/// </summary>
public string filedata { get => _filedata; set => _filedata = value; }
/// <summary>
/// 檔案型別
/// </summary>
public string fileext { get => _fileext; set => _fileext = value; }
/// <summary>
/// 檔名
/// </summary>
public string filename { get => _filename; set => _filename = value; }
}
/// <summary>
/// 返回實體
/// </summary>
public class MessageEntity
{
private int _Code = 0;
private string _Msg = string.Empty;
private object _Data = new object();
/// <summary>
/// 狀態標識
/// </summary>
public int Code { get => _Code; set => _Code = value; }
/// <summary>
/// 返回訊息
/// </summary>
public string Msg { get => _Msg; set => _Msg = value; }
/// <summary>
/// 返回資料
/// </summary>
public object Data { get => _Data; set => _Data = value; }
}
建立完成寫好之後我們在紅的地方Alt+Enter,哪裡爆紅點哪裡(so easy),好了,不扯犢子了,每個介面的方法如下。
RequestUploadFile
public MessageEntity RequestUploadFile([FromBody]RequestFileUploadEntity requestFile)
{
LogUtil.Debug($"RequestUploadFile 接收引數:{JsonConvert.SerializeObject(requestFile)}");
MessageEntity message = new MessageEntity();
if (requestFile.size <= 0 || requestFile.count <= 0 || string.IsNullOrEmpty(requestFile.filedata))
{
message.Code = -1;
message.Msg = "引數有誤";
}
else
{
//這裡需要記錄檔案相關資訊,並返回檔案guid名,後續請求帶上此引數
string guidName = Guid.NewGuid().ToString("N");
//前期單臺伺服器可以記錄Cache,多臺後需考慮redis或資料庫
CacheUtil.Set(guidName, requestFile, new TimeSpan(0, 10, 0), true);
message.Code = 0;
message.Msg = "";
message.Data = new { filename = guidName };
}
return message;
}
FileSave
public async Task<MessageEntity> FileSave()
{
var files = Request.Form.Files;
long size = files.Sum(f => f.Length);
string fileName = Request.Form["filename"];
int fileIndex = 0;
int.TryParse(Request.Form["fileindex"], out fileIndex);
LogUtil.Debug($"FileSave開始執行獲取資料:{fileIndex}_{size}");
MessageEntity message = new MessageEntity();
if (size <= 0 || string.IsNullOrEmpty(fileName))
{
message.Code = -1;
message.Msg = "檔案上傳失敗";
return message;
}
if (!CacheUtil.Exists(fileName))
{
message.Code = -1;
message.Msg = "請重新請求上傳檔案";
return message;
}
long fileSize = 0;
string filePath = $".{AprilConfig.FilePath}{DateTime.Now.ToString("yyyy-MM-dd")}/{fileName}";
string saveFileName = $"{fileName}_{fileIndex}";
string dirPath = Path.Combine(filePath, saveFileName);
if (!Directory.Exists(filePath))
{
Directory.CreateDirectory(filePath);
}
foreach (var file in files)
{
//如果有檔案
if (file.Length > 0)
{
fileSize = 0;
fileSize = file.Length;
using (var stream = new FileStream(dirPath, FileMode.OpenOrCreate))
{
await file.CopyToAsync(stream);
}
}
}
message.Code = 0;
message.Msg = "";
return message;
}
FileMerge
public async Task<MessageEntity> FileMerge([FromBody]Dictionary<string, object> fileInfo)
{
MessageEntity message = new MessageEntity();
string fileName = string.Empty;
if (fileInfo.ContainsKey("name"))
{
fileName = fileInfo["name"].ToString();
}
if (string.IsNullOrEmpty(fileName))
{
message.Code = -1;
message.Msg = "檔名不能為空";
return message;
}
//最終上傳完成後,請求合併返回合併訊息
try
{
RequestFileUploadEntity requestFile = CacheUtil.Get<RequestFileUploadEntity>(fileName);
if (requestFile == null)
{
message.Code = -1;
message.Msg = "合併失敗";
return message;
}
string filePath = $".{AprilConfig.FilePath}{DateTime.Now.ToString("yyyy-MM-dd")}/{fileName}";
string fileExt = requestFile.fileext;
string fileMd5 = requestFile.filedata;
int fileCount = requestFile.count;
long fileSize = requestFile.size;
LogUtil.Debug($"獲取檔案路徑:{filePath}");
LogUtil.Debug($"獲取檔案型別:{fileExt}");
string savePath = filePath.Replace(fileName, "");
string saveFileName = $"{fileName}{fileExt}";
var files = Directory.GetFiles(filePath);
string fileFinalName = Path.Combine(savePath, saveFileName);
LogUtil.Debug($"獲取檔案最終路徑:{fileFinalName}");
FileStream fs = new FileStream(fileFinalName, FileMode.Create);
LogUtil.Debug($"目錄檔案下檔案總數:{files.Length}");
LogUtil.Debug($"目錄檔案排序前:{string.Join(",", files.ToArray())}");
LogUtil.Debug($"目錄檔案排序後:{string.Join(",", files.OrderBy(x => x.Length).ThenBy(x => x))}");
byte[] finalBytes = new byte[fileSize];
foreach (var part in files.OrderBy(x => x.Length).ThenBy(x => x))
{
var bytes = System.IO.File.ReadAllBytes(part);
await fs.WriteAsync(bytes, 0, bytes.Length);
bytes = null;
System.IO.File.Delete(part);//刪除分塊
}
fs.Close();
//這個地方會引發檔案被佔用異常
fs = new FileStream(fileFinalName, FileMode.Open);
string strMd5 = GetCryptoString(fs);
LogUtil.Debug($"檔案資料MD5:{strMd5}");
LogUtil.Debug($"檔案上傳資料:{JsonConvert.SerializeObject(requestFile)}");
fs.Close();
Directory.Delete(filePath);
//如果MD5與原MD5不匹配,提示重新上傳
if (strMd5 != requestFile.filedata)
{
LogUtil.Debug($"上傳檔案md5:{requestFile.filedata},伺服器儲存檔案md5:{strMd5}");
message.Code = -1;
message.Msg = "MD5值不匹配";
return message;
}
CacheUtil.Remove(fileInfo["name"].ToString());
message.Code = 0;
message.Msg = "";
}
catch (Exception ex)
{
LogUtil.Error($"合併檔案失敗,檔名稱:{fileName},錯誤資訊:{ex.Message}");
message.Code = -1;
message.Msg = "合併檔案失敗,請重新上傳";
}
return message;
}
這裡說明下,在Merge的時候,主要校驗md5值,用到了一個方法,我這裡沒有放到Util(其實是因為懶),程式碼如下:
/// <summary>
/// 檔案流加密
/// </summary>
/// <param name="fileStream"></param>
/// <returns></returns>
private string GetCryptoString(Stream fileStream)
{
MD5 md5 = new MD5CryptoServiceProvider();
byte[] cryptBytes = md5.ComputeHash(fileStream);
return GetCryptoString(cryptBytes);
}
private string GetCryptoString(byte[] cryptBytes)
{
//加密的二進位制轉為string型別返回
StringBuilder sb = new StringBuilder();
for (int i = 0; i < cryptBytes.Length; i++)
{
sb.Append(cryptBytes[i].ToString("x2"));
}
return sb.ToString();
}
測試
方法寫好了之後,我們需不需要測試呢,那不是廢話麼,自己的程式碼不過一遍等著讓測試人員搞你呢。
再說個編碼習慣,就是自己的程式碼自己最起碼常規的過一遍,也不說跟大廠一樣什麼KPI啊啥的影響,自己的東西最起碼拿出手讓人一看知道用心了就行,不說什麼測試全覆蓋,就是1+1=2這種基本的正常就OK。
程式執行之後,我這裡寫了個簡單的測試介面,執行之後發現提示OPTIONS,果斷跨域錯誤,還記得我們之前提到的跨域問題,這裡給出解決方法。
跨域
跨域,就是我在這個區域,想跟另一個區域聯絡的時候,我們會碰到牆,這堵牆的目的就是,禁止不同區域的人私下交流溝通,但是現在我們就是不要這堵牆或者說要開幾個門的話怎麼做呢,net core有專門設定的地方,我們回到Startup這裡。
我們來看新增的程式碼:
public IServiceProvider ConfigureServices(IServiceCollection services)
{
//…之前的程式碼忽略
services.AddCors(options =>
{
options.AddPolicy("AllowAll", p =>
{
p.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
services.AddAspectCoreContainer();
return services.BuildAspectInjectorProvider();
}
AddCors來新增一個跨域處理方式,AddPolicy就是加個巡邏官,看看符合規則的放進來,不符合的直接趕出去。
方法 | 介紹 |
---|---|
AllowAnyOrigin | 允許所有的域名請求 |
AllowAnyMethod | 允許所有的請求方式GET/POST/PUT/DELETE |
AllowAnyHeader | 允許所有的頭部引數 |
AllowCredentials | 允許攜帶Cookie |
這裡我使用的是允許所有,可以根據自身業務需要來調整,比如只允許部分域名訪問,部分請求方式,部分Header:
//只是示例,具體根據自身需要
services.AddCors(options =>
{
options.AddPolicy("AllowSome", p =>
{
p.WithOrigins("https://www.baidu.com")
.WithMethods("GET", "POST")
.WithHeaders(HeaderNames.ContentType, "x-custom-header");
});
});
寫好之後我們在Configure中宣告註冊使用哪個巡邏官。
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
//…之前的
app.UseCors("AllowAll");
app.UseHttpsRedirection();
app.UseMvc();
}
好了,設定好跨域之後我們再來執行下上傳操作。
我們看到這個提示之後,是不是能想起來什麼,我們之前做過中間層不知道還記得不,忘了的朋友可以再看下net core Webapi基礎工程搭建(七)——小試AOP及常規測試_Part 1。
在appsettings.json新增上介面白名單。
"AllowUrl": "/api/Values,/api/File/RequestUpload,/api/File/Upload,/api/File/Merge"
設定好之後,我們繼續上傳,這次總算是可以了(檔案字尾這個忽略,測試使用,js就是做了個簡單的substring)。
我們來檢視上傳檔案記錄的日誌資訊。
再來我們看下檔案儲存的位置,這個位置我們在appsettings裡面已經設定過,可以根據自己業務需要調整。
開啟檔案看下是否有損壞,壓縮包很容易看出來是否正常,只要能開啟基本上(當然可能會有問題)沒問題。
解壓出來如果正常那肯定就是沒問題了吧(壓縮這個玩意兒真是牛逼,節省了多少的儲存空間,雖說硬碟白菜價)。
小結
在整理檔案上傳這篇剛好捎帶著把跨域也簡單了過了一遍,下來需要再折騰的東西就是大檔案的分片下載,大致的思路與檔案上傳一致,畢竟都是一個大蛋糕,切成好幾塊,你一塊,剩下的都是我