Netcore webapi + 後端多檔案多引數 multipart/form-data 上傳

做夢都在寫程式碼發表於2020-11-25

這次是因為專案上需要用到多檔案上傳功能,需求是有一個winfrom程式要上傳多個檔案給netcore webapi 並且上傳介面要能夠支援多個引數的傳遞方式;
期間也遇到了很多問題,隨手記錄一下,方便自己也也方便他人;
首先第一步要搞清楚什麼是 multipart/form-data 格式上傳  ;
這裡我寫了個表單,然後用抓包工具來抓取協議;
表單提交頁面如下:

抓包的請求頭資訊 
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryagmh7QIcWZuIziUf
----WebKitFormBoundaryagmh7QIcWZuIziUf #這是用來做邊界的我稱之為分界符;
Content-Type: multipart/form-data; #這就是提交的方式啦;


提交後抓包的總體結構資訊資訊

由圖可見引數格式是如何構成的;

不難看出  ------WebKitFormBoundaryagmh7QIcWZuIziUf    這是個分界線用於和下一個引數資料分隔;

每一行引數格式定義為 如下圖: 
------WebKitFormBoundaryagmh7QIcWZuIziUf    【換行】 #分界符
Content-Disposition: form-data; name="引數欄位名稱"  【換行】
【換行】

下面看看程式碼如何實現的 api服務端程式碼

    /// <summary>
    /// 訂單儲存物件
    /// </summary>
    public class OrderSaveEntity : Add_ChenryOrder_Model
    {
        public List<IFormFile> Files { get; set; }
    }

        /// <summary>
        ///  提交儲存訂單並上傳資訊
        /// </summary>
        /// <param name="m"></param>
        /// <returns></returns>
        [HttpPost, DisableRequestSizeLimit] //DisableRequestSizeLimit 上傳檔案不限制檔案大小
        public async Task<Result> SaveOrder([FromForm] OrderSaveEntity m)
        {
          
            var files = m.Files; 
//base._httpContextAccessor.HttpContext.Request.Form.Files; 你也可以不用定義 List<IFormFile> 直接用這個方式獲取上傳的檔案,但需要注入 httpContextAccessor 物件

            if (m == null) return Result.Error("上傳內容錯誤");
            if (!files.Any()) return Result.Error("沒有檢測到上傳資料");

            string webroot = hostingEnvironment.WebRootPath;//拿到 wwwroot 路徑
            string spac = Path.DirectorySeparatorChar.ToString();//不同的系統會有不同的目錄碟符 如 微軟的系統為\ LINXU系統為/ 

            //定義儲存路徑
            string localpath = $"{spac}{token.UserId}{spac}{m.BuessID}{spac}";//自己定義的儲存目錄結構 相對路徑【看個人業務,如不需要請忽略,我這裡是需要存相對路徑的】
            string path = $"{webroot}{spac}{localpath}"; //絕對儲存路徑

            //批量上傳
            files.ForEach(x =>
           {
               string ext = Path.GetExtension(x.FileName);//檔案字尾
               string filename = Guid.NewGuid().ToString("N");//自定義檔名
               var fullsavepath = $"{path}{filename}{ext}";
               FileUpLoad(x, fullsavepath);
           });

            m.Source = localpath; //路徑
            m.Uid = base.Token.UserId;//使用者ID
            m.FileCount = fcount;//檔案數量
            await chenryOrderService.SaveData(m);//提交業務層儲存
            return Result.Ok($"上傳成功!總計:{ files.Count}個檔案。");
        }

        /// <summary>
        /// 通用上傳方法 PS : 該方法不建議使用 Task 方式,以免引發多個檔案上傳執行緒對流的管道被共享報錯
        /// </summary>
        /// <param name="file">IFormFile  物件</param>
        /// <param name="savepath">儲存的檔案完整物理路徑</param>
        [NonAction]
        public void FileUpLoad(IFormFile file, string savepath)
        {
            
            var index = savepath.LastIndexOf(Path.DirectorySeparatorChar);//拿到最後一個目錄位置
            var dir = savepath.Substring(0, index);//擷取到最後一個目錄位置
            if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); //判斷目錄是否建立不存在則建立

            //從IFromFile檔案流上載到伺服器目錄上
            using (var stream = new FileStream(savepath, FileMode.Create))
            {
                file.CopyTo(stream); 
            }
        }


C#客戶端上傳程式碼:

        public const string ApiSite = "http://localhost:56253";//介面域名
        public const string Token = "U0pfVG9rZW5fMjRiZGViYjNmZmRlNGYxOGI0MTZkNzhkMTFjNDlmZWU="; //測試token  

        static void Main(string[] args)
        {
            //即將上傳的檔案根路徑
            string filepath = Path.Combine(AppContext.BaseDirectory, "files");
            //鍵值物件引數後續提交使用 【文字型引數 PS 測試只加了必填項】
            Dictionary<string, object> kys = new Dictionary<string, object>();
            kys.Add("BuessID", "1505022b92ec482782f11f2a4108f003");
            kys.Add("OrderNum", "2020402193203958");
            kys.Add("Name", "測試一下有沒有更新");

            //檔案引數鍵值物件 key 檔名  value 檔案所在本地路徑
            Dictionary<string, string> files = new Dictionary<string, string>();
            for (int i = 1; i <= 3; i++) //上傳模型3個 圖片 + 模型檔案
            {
                files.Add($"{i}.jpg", $"{Path.Combine(filepath, i.ToString())}.jpg");//key :圖片名 value :圖片路徑
                files.Add($"{i}.obj", $"{Path.Combine(filepath, i.ToString())}.obj");//key :檔名 value :檔案路徑
            }

            //上傳介面地址
            string url = $"{ApiSite}/DesignOrder/SaveOrder";
            var webRequest = HttpWebRequest.Create(url);

            webRequest.Method = "POST"; //POST提交方式
            webRequest.Timeout = 60000; //請求超時時間
            webRequest.Headers.Add("token", Token); // ** token 必填

            // 邊界符 定義
            var boundary = "------WebKitFormBoundary" + DateTime.Now.Ticks.ToString("x");
            webRequest.ContentType = "multipart/form-data; boundary=" + boundary; //form-data 形式的請求頭型別

            //用於列印的可以忽略
            string start = "--" + boundary + "\r\n";
            string end = "--" + boundary + "--\r\n";
            string newline = "\r\n";

            //寫入流的固定格式值
            var beginBoundary = Encoding.ASCII.GetBytes("--" + boundary + "\r\n");   // 開始邊界符
            var endBoundary = Encoding.ASCII.GetBytes("--" + boundary + "--\r\n");  // 結束結束符
            var newLineBytes = Encoding.UTF8.GetBytes("\r\n");    //換行符 



            //區分上傳型別 獲得 content 型別
            Func<string, string> getcontent = (s) =>
            {
                var ex = Path.GetExtension(s); //拿到字尾
                return FileContentType.GetMimeType(ex); //請求字尾返回對應的 type型別
//PS 獲取Content-Type 的 三種方法傳送門:https://blog.csdn.net/a873744779/article/details/100514010
            };

            using (var stream = new MemoryStream())
            {


                // 寫入欄位引數
                var keyValue = "Content-Disposition: form-data; name=\"{0}\"\r\n\r\n{1}";
                // 裝載form表單欄位【非上傳檔案欄位】
                foreach (string key in kys.Keys)
                {
                    //引數欄位轉位元組
                    var keyValueBytes = Encoding.UTF8.GetBytes(string.Format(keyValue, key, kys[key]));
                    stream.Write(beginBoundary, 0, beginBoundary.Length);//寫入邊界開始
                    stream.Write(keyValueBytes, 0, keyValueBytes.Length);//寫入位元組
                    stream.Write(newLineBytes, 0, newLineBytes.Length);//寫入換行符

                    //列印日誌
                    Console.Write(start);
                    Console.Write(string.Format(keyValue, key, kys[key]));
                }

                //多檔案上傳
                foreach (var item in files)
                {
                    

                    var fileData = File.ReadAllBytes(item.Value); //讀檔案流 

                    // 寫入檔案  name = \"Files\"  這裡對應的是介面引數 List<IfromFile> Files
                    var fileHeader = "Content-Disposition: form-data; name=\"Files\"; filename=\"" + item.Key + "\"\r\n"
                    + "Content-Type: " + getcontent(item.Key) + "\r\n\r\n";
                    var fileHeaderBytes = Encoding.UTF8.GetBytes(fileHeader);
                    stream.Write(beginBoundary, 0, beginBoundary.Length);// 寫入開始邊界
                    stream.Write(fileHeaderBytes, 0, fileHeaderBytes.Length);//寫入檔案格式物件流
                    stream.Write(fileData, 0, fileData.Length);  // 寫入檔案流物件
                    stream.Write(newLineBytes, 0, newLineBytes.Length);//寫入換行符

                    //列印日誌
                    Console.Write(start); 
                    Console.Write(fileHeader);
                    Console.Write(newline);
                }

                Console.WriteLine(end);//列印結束邊界
                // 寫入結束邊界符
                stream.Write(endBoundary, 0, endBoundary.Length);
                webRequest.ContentLength = stream.Length;//設定請求物件,位元組長度總量
                stream.Position = 0;//指定流開始索引
                var tempBuffer = new byte[stream.Length];//定義新位元組流物件,用於提交請求結果
                stream.Read(tempBuffer, 0, tempBuffer.Length); //從0開始索引讀流到新 tempbuffer 物件
                
                //請求結果流
                using (Stream requestStream = webRequest.GetRequestStream())
                {
                    requestStream.Write(tempBuffer, 0, tempBuffer.Length);//寫入請求流
                    using (var response = webRequest.GetResponse())//請求結果流
                    using (StreamReader httpStreamReader = new StreamReader(response.GetResponseStream(), Encoding.UTF8)) //從結果流物件中讀取結果流並設定流格式轉換為 uft8 格式
                    {
                        string result = httpStreamReader.ReadToEnd();//返回伺服器返回json
                        Console.WriteLine(result);//輸出結果  如有需要請 自行 json 序列化返回結果
                    }
                }
            }
        }


另外可能會遇到上傳被伺服器拒絕的問題  因為檔案太大了
下面是解除限制方法:

Startup.CS 設定

 public void ConfigureServices(IServiceCollection services)
        {
            //上傳檔案不做限制可以上傳最大
            services.Configure<FormOptions>(options =>
            {
                options.ValueLengthLimit = int.MaxValue;
                options.MultipartBodyLengthLimit = long.MaxValue;
                options.MemoryBufferThreshold = int.MaxValue;
            });
        }

Program.cs 設定

 public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.ConfigureKestrel((context, options) =>
                    {
                        //設定應用伺服器Kestrel請求體最大
                        options.Limits.MaxRequestBodySize = long.MaxValue;
                    });
                    webBuilder.UseStartup<Startup>().UseUrls("http://*:5000;");
                });
    }

還有一種方式是在 Conntroller 的 Action 上打標記 

 /// <summary>
        ///  提交儲存訂單並上傳資訊
        /// </summary>
        /// <param name="m"></param>
        /// <returns></returns>
        [HttpPost, DisableRequestSizeLimit] //DisableRequestSizeLimit 上傳檔案不限制檔案大小
        public async Task<Result> SaveOrder([FromForm] OrderSaveEntity m)



到此結束了,第一次寫技術文章, 歡迎各界大佬的批評指正。


 

相關文章