客户端调用 Http 接口实现大文件上传和下载
对于网站开发来说,下载文件一般是比较非常容易的,但是对于上传文件来说,上传大文件是比较困难的,比如上传几百M或几个G的文件。但是对于客户端来说,实现大文件的上传是比较容易的。由于本人在工作中遇到大文件上传的情景比较多,所以就决定写一个 Demo 总结一下客户端实现大文件上传和下载的技术代码,以便后续需要使用时,能够快速找到并提高工作效率。
本篇博客的 Demo 采用基于 .NET5 开发的 Asp.net Core 网站作为服务端提供 Http 接口。客户端采用 WinForm 开发,通过调用 Http 接口实现文件的上传和下载。之所以选择 Asp.net Core 网站作为接口服务端的开发,是因为它的部署比较灵活,可以采用 Windows Service 服务进行部署,开发的时候可以采用控制台进行测试。有关将控制台程序直接安装成 Windows Service 的方法,可以参考我之前编写的博客。在本博客的最后会提供源代码的下载。
一、搭建接口服务
新建一个基于 .NET5 的 Asp.net Core 网站,appsettings.json 配置文件内容如下:
{ //web访问端口 "WebPort": "http://*:9527", //文件上传的目录名称,是网站下的一个目录,该目录必须要存在 "UploadPath": "UploadFiles" }
建议网站采用控制台进行启动,这样开发调试比较方便。然后新建一个 CommonBLL 类,用来读取配置文件:
using Microsoft.Extensions.Configuration; using System; using System.IO; namespace ServerDemo.Models { public static class CommonBLL { /// <summary> /// 获取配置信息 /// </summary> public static IConfiguration Config; /// <summary> /// 文件上传的根路径(在本 Demo 中,要求上传的文件必须在网站的目录下) /// </summary> public static string uploadRootPath; //静态构造函数 static CommonBLL() { //Environment.CurrentDirectory 代表网站程序的根目录 Config = (new ConfigurationBuilder()) .SetBasePath(Environment.CurrentDirectory) .AddJsonFile("appsettings.json", false, true).Build(); //为了确保文件上传的目录存在,所以在启动时就自动创建 uploadRootPath = Path.Combine(Environment.CurrentDirectory, Config["UploadPath"]); if (Directory.Exists(uploadRootPath) == false) { Directory.CreateDirectory(uploadRootPath); } } } }
本博客的 Demo 要求上传的文件,都保存在网站下面的一个固定的文件夹中,并在网站启动时会自动判断该文件夹是否存在,如果不存在的话,就提前创建出来,方便后续实用该文件夹保存所有上传的文件。
然后在网站启动时,使用配置文件中所配置的端口(如果不配置的话,网站默认使用 5000 端口)。
using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using ServerDemo.Models; namespace ServerDemo { public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { //采用 appsettings.json 配置文件中自定义的端口启动 http 服务 webBuilder.UseUrls(CommonBLL.Config["WebPort"]).UseStartup<Startup>(); }); } }
由于本网站只是提供 Http 接口,因此在 ConfigureServices 中只需要使用 services.AddControllers() 即可。
另外上传和下载文件,Asp.net Core 默认情况下是异步的,我们最好让它也支持同步的方式,具体细节如下:
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; namespace ServerDemo { public class Startup { public void ConfigureServices(IServiceCollection services) { //这里只提供接口就可以了,不需要页面 services.AddControllers(); //允许同步 IO 调用 services.Configure<KestrelServerOptions>(options => { //如果部署在非 iis 上的话,使用的是 kestrel 提供 http 服务 //比如以控制台启动,或者部署成 WindowsService,或者在 linux 上部署 options.AllowSynchronousIO = true; }); services.Configure<IISServerOptions>(options => { //如果部署在 iis 上,使用的是 iis 引擎 options.AllowSynchronousIO = true; }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseDeveloperExceptionPage(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=API}/{action=Index}"); }); } } }
最后开发一个 APIController 提供 Http 接口即可,内容如下:
using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.FileProviders; using Newtonsoft.Json; using ServerDemo.Models; using System; using System.Data; using System.IO; using System.Text; namespace ServerDemo.Controllers { public class APIController : Controller { public IActionResult Index() { return Content( $"Asp.Net Core Web 正在运行... {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); } /// <summary> /// 将文件大小转换成可读性好的格式 /// </summary> private string GetSize(long size) { long k = 1024; long m = k * 1024; long g = m * 1024; long t = g * 1024; if (size < k) { return size.ToString() + "b"; } else if (size < m) { return ((size * 1.00) / k).ToString("F2").Replace(".00", "") + "KB"; } else if (size < g) { return ((size * 1.00) / m).ToString("F2").Replace(".00", "") + "MB"; } else if (size < t) { return ((size * 1.00) / g).ToString("F2").Replace(".00", "") + "GB"; } else { return ((size * 1.00) / t).ToString("F2").Replace(".00", "") + "TB"; } } /// <summary> /// 获取一个路径下所有的文件 /// </summary> public string GetAllFile() { DataTable dt = new DataTable("server"); dt.Columns.Add("FileName"); dt.Columns.Add("UpdateTime"); dt.Columns.Add("DataSize"); string[] fileArr = Directory.GetFiles(CommonBLL.uploadRootPath); if (fileArr.Length > 0) { FileInfo fi; foreach (string file in fileArr) { fi = new FileInfo(file); dt.Rows.Add(Path.GetFileName(file), fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"), GetSize(fi.Length)); } } return JsonConvert.SerializeObject(dt); } /// <summary> /// 获取文件的字节总数 /// </summary> public string GetFileSize() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; string path = Path.Combine(CommonBLL.uploadRootPath, filename); if (System.IO.File.Exists(path)) { FileInfo fi = new FileInfo(path); return fi.Length.ToString(); } else { return "0"; } } /// <summary> /// 分批获取文件的字节码 /// </summary> public string GetFileByte() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; int sizeB = obj.sb; long sequence = obj.sq; int end = obj.ef; try { string path = Path.Combine(CommonBLL.uploadRootPath, filename); //以文件的全路径对应的字符串和文件打开模式来初始化FileStream文件流实例 using (FileStream SplitFileStream = new FileStream(path, FileMode.Open)) { //每次分割读取的最大数据 SplitFileStream.Seek(sizeB * sequence, SeekOrigin.Current); if (end == 1) { FileInfo fi = new FileInfo(path); long totalsize = fi.Length; int lastsize = (int)(totalsize - sizeB * sequence); byte[] TempBytes = new byte[lastsize]; SplitFileStream.Read(TempBytes, 0, lastsize); return Convert.ToBase64String(TempBytes); } else { byte[] TempBytes = new byte[sizeB]; //从大文件中读取指定大小数据 SplitFileStream.Read(TempBytes, 0, (int)sizeB); return Convert.ToBase64String(TempBytes); } } } catch { throw; } } /// <summary> /// 获取文件的所有字节码 /// </summary> public string GetFileByteAll() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; string path = Path.Combine(CommonBLL.uploadRootPath, filename); using (FileStream SplitFileStream = new FileStream(path, FileMode.Open)) { SplitFileStream.Seek(0, SeekOrigin.Current); FileInfo fi = new FileInfo(path); int totalsize = (int)fi.Length; byte[] TempBytes = new byte[totalsize]; SplitFileStream.Read(TempBytes, 0, totalsize); return Convert.ToBase64String(TempBytes); } } /// <summary> /// 分批上传文件 /// </summary> public void UpLoadFileByte() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; byte[] fileBlock = obj.fb; int start = obj.flag; string path = Path.Combine(CommonBLL.uploadRootPath, filename); if (start == 1) { using (FileStream TempStream = new FileStream(path, FileMode.Create)) { TempStream.Write(fileBlock, 0, fileBlock.Length); } } else { using (FileStream TempStream = new FileStream(path, FileMode.Append)) { TempStream.Write(fileBlock, 0, fileBlock.Length); } } } /// <summary> /// 上传文件 /// </summary> public void UpLoadFileByteAll() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; byte[] fileBlock = obj.fb; string path = Path.Combine(CommonBLL.uploadRootPath, filename); string dir = Path.GetDirectoryName(path); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } using (FileStream TempStream = new FileStream(path, FileMode.Create)) { TempStream.Write(fileBlock, 0, fileBlock.Length); } } /// <summary> /// 删除文件 /// </summary> public void DeleteFile() { string data = string.Empty; using (StreamReader sr = new StreamReader(Request.Body, Encoding.UTF8)) { data = sr.ReadToEnd(); } dynamic obj = JsonConvert.DeserializeObject(data); string filename = obj.f; string path = Path.Combine(CommonBLL.uploadRootPath, filename); if (System.IO.File.Exists(path)) { System.IO.File.Delete(path); } } /// <summary> /// 敲链接下载单个文件 /// </summary> public IActionResult DownLoadFile(string fileName) { var provider = new PhysicalFileProvider(CommonBLL.uploadRootPath); var fileInfo = provider.GetFileInfo(fileName); var readStream = fileInfo.CreateReadStream(); return File(readStream, "application/octet-stream", fileName); } } }
到此为止,Asp.net Core 服务端网站已经搭建完毕。需要注意的是:我们上面所提供的接口,都是读取提交过来的 body 中的 Json 数据。这就要求客户端在调用接口的时候,要使用 Post 请求并提交 Json 数据。因此无论是客户端和服务端,都需要通过 Nuget 安装 Newtonsoft.json 包。
二、搭建客户端
客户端采用 WinForm 进行开发,实现对服务器上的文件进行上传、下载、删除和获取文件列表功能。
对于客户端来说,只需要配置要请求的接口地址即可:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <appSettings> <!--路径不要以斜线结尾--> <add key="ServerUrl" value="http://localhost:9527/api"/> </appSettings> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" /> </startup> </configuration>
然后我们先提前创建一个 CommonBLL 类,采用 WebClient 发送 Post 请求实现每一个接口方法的调用封装,具体细节如下:
using Newtonsoft.Json; using System; using System.Configuration; using System.Data; using System.Net; using System.Text; using System.Windows.Forms; namespace ClientDemo { public static class CommonBLL { //封装了一些信息弹框,方便在 WinForm 的界面中使用 public static void MessageAlert(string str) { MessageBox.Show(str, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } public static bool MessageConfirm(string str) { return MessageBox.Show(str, "提示信息", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) == DialogResult.OK; } public static void MessageSuccess(string str) { MessageBox.Show(str, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Asterisk); } public static void MessageError(string str) { MessageBox.Show(str, "提示信息", MessageBoxButtons.OK, MessageBoxIcon.Hand); } public static WebClient CreateWebClient() { WebClient wc = new WebClient(); wc.Encoding = Encoding.UTF8; wc.Headers.Add("Content-Type", "application/json"); return wc; } //读取配置节中的接口地址 private static string serverurl = ConfigurationManager.AppSettings["ServerUrl"]; //下面这些方法是对网站提供的每个接口的调用封装 public static DataTable GetAllFile() { using (WebClient wc = CreateWebClient()) { string result = wc.UploadString(serverurl + "/GetAllFile", string.Empty); if (!string.IsNullOrWhiteSpace(result)) { DataTable dt = JsonConvert.DeserializeObject<DataTable>(result); return dt; } else { return null; } } } public static long GetFileSize(string filename) { using (WebClient wc = CreateWebClient()) { var model = new { f = filename }; string json = JsonConvert.SerializeObject(model); string result = wc.UploadString(serverurl + "/GetFileSize", json); return long.Parse(result); } } public static byte[] GetFileByte(string filename, int sizeB, int sequence, bool end) { int endflag = end ? 1 : 0; using (WebClient wc = CreateWebClient()) { var model = new { f = filename, sb = sizeB, sq = sequence, ef = endflag }; string json = JsonConvert.SerializeObject(model); string result = wc.UploadString(serverurl + "/GetFileByte", json); return Convert.FromBase64String(result); } } public static byte[] GetFileByte(string filename) { using (WebClient wc = CreateWebClient()) { var model = new { f = filename }; string json = JsonConvert.SerializeObject(model); string result = wc.UploadString(serverurl + "/GetFileByteAll", json); return Convert.FromBase64String(result); } } public static void UpLoadFileByte(string filename, byte[] fileBlock, bool start) { int startflag = start ? 1 : 0; using (WebClient wc = CreateWebClient()) { var model = new { f = filename, fb = fileBlock, flag = startflag }; string json = JsonConvert.SerializeObject(model); wc.UploadString(serverurl + "/UpLoadFileByte", json); } } public static void UpLoadFileByte(string filename, byte[] fileBlock) { using (WebClient wc = CreateWebClient()) { var model = new { f = filename, fb = fileBlock }; string json = JsonConvert.SerializeObject(model); wc.UploadString(serverurl + "/UpLoadFileByteAll", json); } } public static void DeleteFile(string filename) { using (WebClient wc = CreateWebClient()) { var model = new { f = filename }; string json = JsonConvert.SerializeObject(model); wc.UploadString(serverurl + "/DeleteFile", json); } } } }
下面只列出上传文件和下载文件的代码,具体的逻辑就是:当上传或者下载的文件小于等于 2M 的话,就直接上传或下载,如果大于 2M 的话,就分批次进行上传或下载,每批次上传或下载的数据量为 2M ,具体细节如下:
//自己开发的用于显示进度条的窗体 private ProgressForm pf; /// <summary> /// 上传文件 /// </summary> private void btnUpload_Click(object sender, EventArgs e) { OpenFileDialog ofd = new OpenFileDialog(); ofd.Title = "上传单个文件"; if (ofd.ShowDialog() == DialogResult.OK) { //每次需要上传的字节数 2M long num = 2097152L; //获取所选择的文件全路径名 string fullname = ofd.FileName; //获取文件名(仅仅是文件名,包含扩展名) string fname = Path.GetFileName(fullname); FileInfo fileInfo = new FileInfo(fullname); //获取要上传的文件的总字节数大小 long flen = fileInfo.Length; if (flen <= num) { //如果字节数小于等于 2M ,则直接上传文件 byte[] array = new byte[flen]; using (FileStream fileStream = new FileStream(fullname, FileMode.Open)) { fileStream.Read(array, 0, (int)flen); } CommonBLL.UpLoadFileByte(fname, array); CommonBLL.MessageSuccess("文件上传成功"); BindData(fname); } else { //如果字节数大于 2M ,则分批次上传,每次上传 2M int blocknum = (int)(flen / num); if (flen % num != 0L) { blocknum++; } pf = new ProgressForm(); pf.TopMost = true; pf.ProgressMaxValue = blocknum; pf.ProgressValue = 0; pf.Show(); bwUpSingBigFile = new BackgroundWorker(); bwUpSingBigFile.WorkerReportsProgress = true; bwUpSingBigFile.DoWork += new DoWorkEventHandler(this.bwUpSingBigFile_DoWork); bwUpSingBigFile.ProgressChanged += new ProgressChangedEventHandler(this.bwUpSingBigFile_ProgressChanged); bwUpSingBigFile.RunWorkerCompleted += new RunWorkerCompletedEventHandler(this.bwUpSingBigFile_RunWorkerCompleted); Dictionary<string, object> dicparam = new Dictionary<string, object>(); dicparam.Add("LocalFileName", fullname); //该参数是本地文件名,这里参数是全路径名 dicparam.Add("ServerFileName", fname); //在服务器上保存的文件名称,该参数仅仅是文件名 dicparam.Add("TotalFlieSize", flen); dicparam.Add("eachBlockSize", num); dicparam.Add("FileBlockCount", blocknum); this.bwUpSingBigFile.RunWorkerAsync(dicparam); } } } #region 上传文件后台线程 private BackgroundWorker bwUpSingBigFile; private void bwUpSingBigFile_DoWork(object sender, DoWorkEventArgs e) { Dictionary<string, object> dicparam = e.Argument as Dictionary<string, object>; //该参数是本地文件名,这里参数是全路径名 string clientfile = dicparam["LocalFileName"].ToString(); //在服务器上保存的文件名称,该参数仅仅是文件名 string serverfile = dicparam["ServerFileName"].ToString(); long totalsize = long.Parse(dicparam["TotalFlieSize"].ToString()); int eachsize = int.Parse(dicparam["eachBlockSize"].ToString()); int blocknum = int.Parse(dicparam["FileBlockCount"].ToString()); using (FileStream fileStream = new FileStream(clientfile, FileMode.Open)) { for (int i = 0; i < blocknum; i++) { if (i == 0) { byte[] array = new byte[eachsize]; fileStream.Read(array, 0, eachsize); CommonBLL.UpLoadFileByte(serverfile, array, true); } else { if (i == blocknum - 1) { int leftnum = (int)(totalsize - eachsize * i); byte[] array = new byte[leftnum]; fileStream.Read(array, 0, leftnum); CommonBLL.UpLoadFileByte(serverfile, array, false); } else { byte[] array = new byte[eachsize]; fileStream.Read(array, 0, eachsize); CommonBLL.UpLoadFileByte(serverfile, array, false); } } bwUpSingBigFile.ReportProgress(i + 1); } } e.Result = serverfile; //要上传的文件名,不是全路径名 } private void bwUpSingBigFile_ProgressChanged(object sender, ProgressChangedEventArgs e) { int progressPercentage = e.ProgressPercentage; pf.ProgressValue = progressPercentage; } private void bwUpSingBigFile_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { pf.Close(); if (e.Error != null) { CommonBLL.MessageError(e.Error.Message); } else { CommonBLL.MessageSuccess("上传成功"); BindData(e.Result.ToString()); } } #endregion /// <summary> /// 下载文件 /// </summary> private void tsmDownload_Click(object sender, EventArgs e) { //获取要下载的文件名 string serverFileName = dgvFile.SelectedRows[0].Cells["FileName"].Value.ToString(); SaveFileDialog sfd = new SaveFileDialog(); sfd.FileName = serverFileName; if (sfd.ShowDialog() == DialogResult.OK) { //要保存的文件名(全路径名) string localFileName = sfd.FileName; //每次下载的字节数 2M long num = 2097152L; //获取要下载的文件的总字节数大小 long fileSize = CommonBLL.GetFileSize(serverFileName); if (fileSize <= num) { //所下载的文件小于等于 2M ,则直接下载 byte[] fileByte = CommonBLL.GetFileByte(serverFileName); using (FileStream fileStream = new FileStream(localFileName, FileMode.Create)) { fileStream.Write(fileByte, 0, fileByte.Length); } CommonBLL.MessageSuccess("文件下载成功"); } else { //所下载的文件大于 2M ,则分批次下载,每次下载 2M int maxnum = (int)(fileSize / num); if (fileSize % num != 0L) { maxnum++; } pf = new ProgressForm(); pf.TopMost = true; pf.ProgressMaxValue = maxnum; pf.ProgressValue = 0; pf.Show(); bwDownSingBigFile = new BackgroundWorker(); bwDownSingBigFile.WorkerReportsProgress = true; bwDownSingBigFile.DoWork += new DoWorkEventHandler(bwDownSingBigFile_DoWork); bwDownSingBigFile.ProgressChanged += new ProgressChangedEventHandler(bwDownSingBigFile_ProgressChanged); bwDownSingBigFile.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bwDownSingBigFile_RunWorkerCompleted); Dictionary<string, object> dicparam = new Dictionary<string, object>(); dicparam.Add("ServerFileName", serverFileName); //服务器上的文件名,仅仅是文件名 dicparam.Add("SaveFileName", localFileName); //要保存到本地的文件名,全路径名 dicparam.Add("FileBlockCount", maxnum); dicparam.Add("eachBlockSize", num); bwDownSingBigFile.RunWorkerAsync(dicparam); } } } #region 下载单个大文件 private BackgroundWorker bwDownSingBigFile; private void bwDownSingBigFile_DoWork(object sender, DoWorkEventArgs e) { Dictionary<string, object> dicparam = e.Argument as Dictionary<string, object>; //要保存到本地的文件名,全路径名 string localFileName = dicparam["SaveFileName"].ToString(); //服务器上的文件名,仅仅是文件名 string serverFileName = dicparam["ServerFileName"].ToString(); int num = int.Parse(dicparam["FileBlockCount"].ToString()); int sizeB = int.Parse(dicparam["eachBlockSize"].ToString()); using (FileStream fileStream = new FileStream(localFileName, FileMode.Create)) { for (int i = 0; i < num; i++) { byte[] fileByte; if (i == num - 1) { fileByte = CommonBLL.GetFileByte(serverFileName, sizeB, i, true); } else { fileByte = CommonBLL.GetFileByte(serverFileName, sizeB, i, false); } fileStream.Write(fileByte, 0, fileByte.Length); bwDownSingBigFile.ReportProgress(i + 1); } } } private void bwDownSingBigFile_ProgressChanged(object sender, ProgressChangedEventArgs e) { int progressPercentage = e.ProgressPercentage; pf.ProgressValue = progressPercentage; } private void bwDownSingBigFile_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { pf.Close(); if (e.Error != null) { CommonBLL.MessageError(e.Error.Message); } else { CommonBLL.MessageSuccess("下载成功"); } } #endregion
大于大文件的上传和下载,采用的是 BackgroundWorker 异步线程处理的,因为它自带了一些事件比较好用。
DoWork 事件用于异步处理业务逻辑,在本 Demo 上实现异步后台处理文件的上传和下载。
ProgressChanged 事件用于更新进度条,对于大文件的上传和下载,有进度条的话,体验会好很多。
RunWorkerCompleted 事件用于异步后台业务处理完成后的回调,使我们能够在文件上传和下载完成后给予用户一些提示信息。
到此为止,关键核心点已经介绍完毕,详细的细节请参看源代码吧。
源代码的下载地址为:https://files.cnblogs.com/files/blogs/699532/UpDownFileDemo.zip
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构