net core WebApi——文件分片上傳與跨域請(qǐng)求處理

前言

在之前整理完一套簡(jiǎn)單的后臺(tái)基礎(chǔ)工程后,因?yàn)闃I(yè)務(wù)需要鼓搗了文件上傳跟下載,整理完后就迫不及待的想分享出來(lái),希望有用到文件相關(guān)操作的朋友可以得到些幫助。

開始

我們依然用我們的基礎(chǔ)工程,之前也提到過(guò)后續(xù)如果有測(cè)試功能之類的東西,會(huì)一直不斷的更新這套代碼(如果搞炸了之后那就…),首先我們需要理一下文件分片上傳的思路:

  • 后端
  1. 接收前端文件上傳請(qǐng)求并處理回調(diào)
  2. 根據(jù)前端傳遞的鑰匙判斷,允許后開始接收文件流并保存到臨時(shí)文件夾
  3. 前端最終上傳完成后給予后端合并請(qǐng)求(也稱作上傳完成確認(rèn)),后端合并文件后判斷最終文件是否正確給予回調(diào)。
  • 前端
  1. 讀取文件相關(guān)信息(名稱,擴(kuò)展類型,大小等基本信息)
  2. 根據(jù)需要做片段劃分以及文件的md5值(md5主要用于最終確認(rèn)文件是否缺損)
  3. 請(qǐng)求后端獲取鑰匙
  4. 拿到鑰匙后,我們根據(jù)劃分的片段去循環(huán)上傳文件,并根據(jù)每次回調(diào)判斷是否上傳成功,如失敗則重新上傳
  5. 最終循環(huán)完成后,給予后端合并請(qǐng)求(上傳完成確認(rèn))

ps:這里的鑰匙就是個(gè)文件名,當(dāng)然你可以來(lái)個(gè)token啊什么的根據(jù)自己業(yè)務(wù)需要。

這里還是想分享下敲代碼的經(jīng)驗(yàn),在我們動(dòng)手之前,最好把能考慮到的東西全都想好,思路理清也就是打好提綱后,敲代碼的效率會(huì)高并且錯(cuò)誤率也會(huì)低,行云流水不是天馬行空,而是你的大腦中已經(jīng)有了山水鳥獸。

OK,流程清楚之后,我們開始動(dòng)手敲代碼吧。

首先,我們新建一個(gè)控制器FileController,當(dāng)然名字可以隨意取,根據(jù)我們上述后端的思路,新建三個(gè)接口RequestUploadFileFileSave,FileMerge。

    [Route("api/[controller]")]
    [ApiController]
    public class FileController : ControllerBase
    {
        /// <summary>
        /// 請(qǐng)求上傳文件
        /// </summary>
        /// <param name="requestFile">請(qǐng)求上傳參數(shù)實(shí)體</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">文件參數(shù)信息[name]</param>
        /// <returns></returns>
        [HttpPost, Route("Merge")]
        public async Task<MessageEntity> FileMerge([FromBody]Dictionary<string, object> fileInfo)
        {

        }
    }

如果直接復(fù)制的朋友,這里肯定是滿眼紅彤彤,這里主要用了兩個(gè)類,一個(gè)請(qǐng)求實(shí)體RequestFileUploadEntity,一個(gè)回調(diào)實(shí)體MessageEntity,這兩個(gè)我們到Util工程創(chuàng)建(當(dāng)然也可以放到Entity工程,這里為什么放到Util呢,因?yàn)槲矣X得放到這里公用比較好,畢竟還是有復(fù)用的價(jià)值的)。

    /// <summary>
    /// 文件請(qǐng)求上傳實(shí)體
    /// </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>
        /// 片段數(shù)量
        /// </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>
    /// 返回實(shí)體
    /// </summary>
    public class MessageEntity
    {
        private int _Code = 0;
        private string _Msg = string.Empty;
        private object _Data = new object();

        /// <summary>
        /// 狀態(tài)標(biāo)識(shí)
        /// </summary>
        public int Code { get => _Code; set => _Code = value; }
        /// <summary>
        /// 返回消息
        /// </summary>
        public string Msg { get => _Msg; set => _Msg = value; }
        /// <summary>
        /// 返回?cái)?shù)據(jù)
        /// </summary>
        public object Data { get => _Data; set => _Data = value; }
    }

創(chuàng)建完成寫好之后我們?cè)诩t的地方Alt+Enter,哪里爆紅點(diǎn)哪里(so easy),好了,不扯犢子了,每個(gè)接口的方法如下。

RequestUploadFile

        public MessageEntity RequestUploadFile([FromBody]RequestFileUploadEntity requestFile)
        {
            LogUtil.Debug($"RequestUploadFile 接收參數(shù):{JsonConvert.SerializeObject(requestFile)}");
            MessageEntity message = new MessageEntity();
            if (requestFile.size <= 0 || requestFile.count <= 0 || string.IsNullOrEmpty(requestFile.filedata))
            {
                message.Code = -1;
                message.Msg = "參數(shù)有誤";
            }
            else
            {
                //這里需要記錄文件相關(guān)信息,并返回文件guid名,后續(xù)請(qǐng)求帶上此參數(shù)
                string guidName = Guid.NewGuid().ToString("N");

                //前期單臺(tái)服務(wù)器可以記錄Cache,多臺(tái)后需考慮redis或數(shù)據(jù)庫(kù)
                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開始執(zhí)行獲取數(shù)據(jù):{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 = "請(qǐng)重新請(qǐng)求上傳文件";
                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;
            }

            //最終上傳完成后,請(qǐng)求合并返回合并消息
            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($"目錄文件下文件總數(shù):{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();
                //這個(gè)地方會(huì)引發(fā)文件被占用異常
                fs = new FileStream(fileFinalName, FileMode.Open);
                string strMd5 = GetCryptoString(fs);
                LogUtil.Debug($"文件數(shù)據(jù)MD5:{strMd5}");
                LogUtil.Debug($"文件上傳數(shù)據(jù):{JsonConvert.SerializeObject(requestFile)}");
                fs.Close();
                Directory.Delete(filePath);
                //如果MD5與原MD5不匹配,提示重新上傳
                if (strMd5 != requestFile.filedata)
                {
                    LogUtil.Debug($"上傳文件md5:{requestFile.filedata},服務(wù)器保存文件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},錯(cuò)誤信息:{ex.Message}");
                message.Code = -1;
                message.Msg = "合并文件失敗,請(qǐng)重新上傳";
            }
            return message;
        }

這里說(shuō)明下,在Merge的時(shí)候,主要校驗(yàn)md5值,用到了一個(gè)方法,我這里沒有放到Util(其實(shí)是因?yàn)閼校a如下:

        /// <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)
        {
            //加密的二進(jìn)制轉(zhuǎn)為string類型返回
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < cryptBytes.Length; i++)
            {
                sb.Append(cryptBytes[i].ToString("x2"));
            }
            return sb.ToString();
        }

測(cè)試

方法寫好了之后,我們需不需要測(cè)試呢,那不是廢話么,自己的代碼不過(guò)一遍等著讓測(cè)試人員搞你呢。

再說(shuō)個(gè)編碼習(xí)慣,就是自己的代碼自己最起碼常規(guī)的過(guò)一遍,也不說(shuō)跟大廠一樣什么KPI啊啥的影響,自己的東西最起碼拿出手讓人一看知道用心了就行,不說(shuō)什么測(cè)試全覆蓋,就是1+1=2這種基本的正常就OK。

程序運(yùn)行之后,我這里寫了個(gè)簡(jiǎn)單的測(cè)試界面,運(yùn)行之后發(fā)現(xiàn)提示OPTIONS,果斷跨域錯(cuò)誤,還記得我們之前提到的跨域問題,這里給出解決方法。


測(cè)試

跨域

跨域,就是我在這個(gè)區(qū)域,想跟另一個(gè)區(qū)域聯(lián)系的時(shí)候,我們會(huì)碰到墻,這堵墻的目的就是,禁止不同區(qū)域的人私下交流溝通,但是現(xiàn)在我們就是不要這堵墻或者說(shuō)要開幾個(gè)門的話怎么做呢,net core有專門設(shè)置的地方,我們回到Startup這里。

我們來(lái)看新增的代碼:

        public IServiceProvider ConfigureServices(IServiceCollection services)
        {
            //…之前的代碼忽略
            
            services.AddCors(options =>
            {
                options.AddPolicy("AllowAll", p =>
                {
                    p.AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .AllowCredentials();
                });
            });
            
            services.AddAspectCoreContainer();
            return services.BuildAspectInjectorProvider();

        }

AddCors來(lái)添加一個(gè)跨域處理方式,AddPolicy就是加個(gè)巡邏官,看看符合規(guī)則的放進(jìn)來(lái),不符合的直接趕出去。

方法 介紹
AllowAnyOrigin 允許所有的域名請(qǐng)求
AllowAnyMethod 允許所有的請(qǐng)求方式GET/POST/PUT/DELETE
AllowAnyHeader 允許所有的頭部參數(shù)
AllowCredentials 允許攜帶Cookie

這里我使用的是允許所有,可以根據(jù)自身業(yè)務(wù)需要來(lái)調(diào)整,比如只允許部分域名訪問,部分請(qǐng)求方式,部分Header:

            //只是示例,具體根據(jù)自身需要
            services.AddCors(options =>
            {
                options.AddPolicy("AllowSome", p =>
                 {
                     p.WithOrigins("https://www.baidu.com")
                     .WithMethods("GET", "POST")
                     .WithHeaders(HeaderNames.ContentType, "x-custom-header");
                 });
            });

寫好之后我們?cè)?strong>Configure中聲明注冊(cè)使用哪個(gè)巡邏官。

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            //…之前的
            app.UseCors("AllowAll");  
            
            app.UseHttpsRedirection();
            app.UseMvc();      
        }

好了,設(shè)置好跨域之后我們?cè)賮?lái)執(zhí)行下上傳操作。

測(cè)試

我們看到這個(gè)提示之后,是不是能想起來(lái)什么,我們之前做過(guò)中間層不知道還記得不,忘了的朋友可以再看下net core Webapi基礎(chǔ)工程搭建(七)——小試AOP及常規(guī)測(cè)試_Part 1。
在appsettings.json添加上接口白名單。

  "AllowUrl": "/api/Values,/api/File/RequestUpload,/api/File/Upload,/api/File/Merge"

設(shè)置好之后,我們繼續(xù)上傳,這次總算是可以了(文件后綴這個(gè)忽略,測(cè)試使用,js就是做了個(gè)簡(jiǎn)單的substring)。


測(cè)試

我們來(lái)查看上傳文件記錄的日志信息。


測(cè)試

再來(lái)我們看下文件存儲(chǔ)的位置,這個(gè)位置我們?cè)赼ppsettings里面已經(jīng)設(shè)置過(guò),可以根據(jù)自己業(yè)務(wù)需要調(diào)整。
測(cè)試

打開文件看下是否有損壞,壓縮包很容易看出來(lái)是否正常,只要能打開基本上(當(dāng)然可能會(huì)有問題)沒問題。


測(cè)試

解壓出來(lái)如果正常那肯定就是沒問題了吧(壓縮這個(gè)玩意兒真是牛逼,節(jié)省了多少的存儲(chǔ)空間,雖說(shuō)硬盤白菜價(jià))。
測(cè)試

小結(jié)

在整理文件上傳這篇?jiǎng)偤蒙訋е芽缬蛞埠?jiǎn)單了過(guò)了一遍,下來(lái)需要再折騰的東西就是大文件的分片下載,大致的思路與文件上傳一致,畢竟都是一個(gè)大蛋糕,切成好幾塊,你一塊,剩下的都是我的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容