我写了个需要上传文件的页面。但是我觉得上传文件这个需求太常见了,于是我抽了个view component的。 我给你看看我的思路和实现。 我喜欢使用dropify。 为了好用的上传,我打算把上传过程分离。 首先有一个真正处理文件的controller: ```csharp namespace Aiursoft.CSTools.Attributes; public class ValidDomainName : ValidationAttribute { private readonly string _domainRegex = "^[-a-z0-9_]+$"; public override bool IsValid(object? value) { Regex regex = new Regex(this._domainRegex, RegexOptions.Compiled); return value is string input && regex.IsMatch(input); } protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { return this.IsValid(value) ? ValidationResult.Success : new ValidationResult($"The {validationContext.DisplayName} can only contain numbers, alphabet and underline."); } } using Aiursoft.CSTools.Attributes; using Aiursoft.Template.Services; using Microsoft.AspNetCore.Mvc; namespace Aiursoft.Template.Controllers; public class FilesController( StorageService storage) : ControllerBase { [Route("upload/{subfolder}")] public async Task Index([FromRoute] [ValidDomainName] string subfolder) { if (!ModelState.IsValid) { return BadRequest(); } // Executing here will let the browser upload the file. try { _ = HttpContext.Request.Form.Files.FirstOrDefault()?.ContentType; } catch (InvalidOperationException e) { return BadRequest(e.Message); } if (HttpContext.Request.Form.Files.Count < 1) { return BadRequest("No file uploaded!"); } var file = HttpContext.Request.Form.Files.First(); if (!new ValidFolderName().IsValid(file.FileName)) { return BadRequest("Invalid file name!"); } var storePath = Path.Combine( subfolder, DateTime.UtcNow.Year.ToString("D4"), DateTime.UtcNow.Month.ToString("D2"), DateTime.UtcNow.Day.ToString("D2"), file.FileName); var relativePath = await storage.Save(storePath, file); return Ok(new { Path = relativePath, InternetPath = storage.RelativePathToInternetUrl(relativePath, HttpContext) }); } [Route("download/{**folderNames}")] public IActionResult Download([FromRoute] string folderNames) { if (!ModelState.IsValid) { return BadRequest(); } if (folderNames.Contains("..")) { return BadRequest("Invalid path!"); } var physicalPath = storage.GetFilePhysicalPath(folderNames); var workspaceFullPath = Path.GetFullPath(storage.WorkspaceFolder); if (!physicalPath.StartsWith(workspaceFullPath)) { return BadRequest("Attempted to access a restricted path."); } if (!System.IO.File.Exists(physicalPath)) { return NotFound(); } return this.WebFile(physicalPath); } } ``` 其调用存储服务。 这个存储服务的设计思路是不把真正的互联网上的 URL 存储到数据库,而是先将文件保存,再把它的相对路径存储到数据库。而真正在前端展示的时候,再使用 `RelativePathToInternetUrl` 方法转换成真正的 URL。 ```csharp using Aiursoft.Scanner.Abstractions; namespace Aiursoft.Template.Services; /// /// Represents a service for storing and retrieving files. /// public class StorageService(IConfiguration configuration) : ISingletonDependency { public readonly string WorkspaceFolder = configuration["Storage:Path"]!; // Async lock. private readonly SemaphoreSlim _lock = new(1, 1); /// /// Saves a file to the storage. /// /// The path where the file will be saved. The 'savePath' is the path that the user wants to save. Not related to actual disk path. /// The file to be saved. /// The actual path where the file is saved relative to the workspace folder. public async Task Save(string savePath, IFormFile file) { var finalFilePath = Path.Combine(WorkspaceFolder, savePath); var finalFolder = Path.GetDirectoryName(finalFilePath); // Create the folder if it does not exist. if (!Directory.Exists(finalFolder)) { Directory.CreateDirectory(finalFolder!); } // The problem is: What if the file already exists? await _lock.WaitAsync(); try { var expectedFileName = Path.GetFileName(finalFilePath); while (File.Exists(finalFilePath)) { expectedFileName = "_" + expectedFileName; finalFilePath = Path.Combine(finalFolder!, expectedFileName); } // Create a new file. File.Create(finalFilePath).Close(); } finally { _lock.Release(); } await using var fileStream = new FileStream(finalFilePath, FileMode.Create); await file.CopyToAsync(fileStream); fileStream.Close(); return Path.GetRelativePath(WorkspaceFolder, finalFilePath); } public string GetFilePhysicalPath(string fileName) { return Path.Combine(WorkspaceFolder, fileName); } public string AbsolutePathToRelativePath(string absolutePath) { return Path.GetRelativePath(WorkspaceFolder, absolutePath); } private string RelativePathToUriPath(string relativePath) { var urlPath = Uri.EscapeDataString(relativePath) .Replace("%5C", "/") .Replace("%5c", "/") .Replace("%2F", "/") .Replace("%2f", "/") .TrimStart('/'); return urlPath; } public string RelativePathToInternetUrl(string relativePath, HttpContext context) { return $"{context.Request.Scheme}://{context.Request.Host}/download/{RelativePathToUriPath(relativePath)}"; } } ``` 然后,上传分两步,先上传文件,得到一个string,也就是其 RelativePath,再提交表单,把 RelativePath 提交上去。 为了体验更好,比如能够展示进度条,我先搓了个 JS 的 Uploader ```javascript class Uploader { constructor({ fileInput, progress, progressbar, addressInput, sizeInMb, validExtensions, uploadUrl, onFile = () => { }, onUploaded = () => { }, onReset = () => { } } = {}) { this.fileInput = fileInput; this.progress = progress; this.progressbar = progressbar; this.addressInput = addressInput; this.sizeInMb = sizeInMb; this.validExtensions = validExtensions; this.uploadUrl = uploadUrl; this.onFile = onFile; this.onUploaded = onUploaded; this.onReset = onReset; this.onbeforeunloadBackup = window.onbeforeunload; } getExtension(filename) { const parts = filename.split('.'); return (parts[parts.length - 1]).toLowerCase(); } reset(that) { that.addressInput.val(""); that.progressbar.css('width', '0%'); that.progress.addClass('d-none'); window.onbeforeunload = that.onbeforeunloadBackup; that.onReset(that); } tryUpload(that) { that.onFile(that); const file = that.fileInput.prop("files")[0]; const ext = that.getExtension(file.name); if (that.validExtensions.length > 0 && that.validExtensions.indexOf(ext) === -1) { return; } if (file.size / 1024 / 1024 > that.sizeInMb) { return; } window.onbeforeunload = () => { return "Your file is still uploading. Are you sure to quit?"; }; that.progress.removeClass('d-none'); that.progressbar.css('width', '0%'); that.progressbar.removeClass('bg-success'); that.progressbar.addClass('progress-bar-animated'); const formData = new FormData(); formData.append("file", file); $.ajax({ url: that.uploadUrl, type: 'post', enctype: 'multipart/form-data', data: formData, cache: false, contentType: false, processData: false, xhr: () => { const myXhr = $.ajaxSettings.xhr(); if (myXhr.upload) { myXhr.upload.addEventListener('progress', (e)=> { if (e.lengthComputable) { that.progressbar.css('width', 100 * e.loaded / e.total + '%'); } }, false); } return myXhr; }, success: (data)=> { window.onbeforeunload = that.onbeforeunloadBackup; that.addressInput.val(data.Path); that.progressbar.addClass('bg-success'); that.progressbar.removeClass('progress-bar-animated'); that.progressbar.css('width', '100%'); that.onUploaded(data); }, error: that.reset }); } init() { const that = this; that.fileInput.unbind('change'); that.fileInput.on('change', () => { that.tryUpload(that); }); const dropify = that.fileInput.dropify(); dropify.on('dropify.afterClear', () => { that.reset(that); }); } } export default Uploader; ``` 当然我也搓了一些 CSS: ```css .dropify-wrapper .dropify-message p { font-size: 20px !important; } @media (prefers-color-scheme: dark) { .dropify-preview { background-color: rgb(42, 43, 45) !important; } .dropify-wrapper { background-color: rgb(32, 33, 35) !important; border: 2px solid #666 !important; } .dropify-wrapper:hover { background-image: linear-gradient(-45deg, #161616 25%, transparent 25%, transparent 50%, #161616 50%, #161616 75%, transparent 75%, transparent) !important; } } ``` 然后我就可以写我的页面了。我可以先弄个 ViewModel: ```csharp using System.ComponentModel.DataAnnotations; using Aiursoft.UiStack.Layout; namespace Aiursoft.Template.Models.ManageViewModels; public class ChangeAvatarViewModel : UiStackLayoutViewModel { public ChangeAvatarViewModel() { PageTitle = "Change Avatar"; } [Display(Name = "Avatar file")] [Required(ErrorMessage = "The avatar file is required.")] [RegularExpression(@"^avatar.*", ErrorMessage = "The avatar file is invalid. Please upload it again.")] public string? AvatarUrl { get; set; } } ``` 然后我弄个 Controller 负责这个页面: ```csharp // // GET: /Manage/ChangeAvatar [HttpGet] public IActionResult ChangeAvatar() { return this.StackView(new ChangeAvatarViewModel()); } // // POST: /Manage/ChangeAvatar [HttpPost] [ValidateAntiForgeryToken] public async Task ChangeAvatar(ChangeAvatarViewModel model) { if (!ModelState.IsValid) { return this.StackView(model); } // Save the new avatar in database. return this.StackView(model); } ``` 真正的页面代码就可以调用我的上传 Component 了: ```csharp @model Aiursoft.Template.Models.ManageViewModels.ChangeAvatarViewModel

@Localizer["Change your avatar"]

@Localizer["Upload the new avatar file."]


@* ReSharper disable once Razor.SectionNotResolved *@ @section styles{ } @* ReSharper disable once Razor.SectionNotResolved *@ @section scripts{ } ``` 当然,就像我说的,真正的上传逻辑不应该出现在业务试图里,而是 `` 里。 接下来我也抽了componnet: ```csharp using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; namespace Aiursoft.Template.Views.Shared.Components.FileUpload; public class FileUpload : ViewComponent { public IViewComponentResult Invoke( ModelExpression aspFor, string uploadEndpoint, int maxSizeInMb = 2000, string? allowedExtensions = null) { return View(new FileUploadViewModel { AspFor = aspFor, UploadEndpoint = uploadEndpoint, MaxSizeInMb = maxSizeInMb, AllowedExtensions = allowedExtensions, }); } } using Microsoft.AspNetCore.Mvc.ViewFeatures; namespace Aiursoft.Template.Views.Shared.Components.FileUpload; public class FileUploadViewModel { public required ModelExpression AspFor { get; init; } public required string UploadEndpoint { get; init; } public required int MaxSizeInMb { get; init; } public required string? AllowedExtensions { get; init; } public string UniqueId { get; } = "uploader-" + Guid.NewGuid().ToString("N"); } ``` 而最终的 component 视图代码如下: ```razor @using Aiursoft.Template.Services @model Aiursoft.Template.Views.Shared.Components.FileUpload.FileUploadViewModel @inject StorageService Storage @inject Microsoft.AspNetCore.Mvc.ViewFeatures.IHtmlGenerator HtmlGenerator @{ var fileInputId = $"file-input-{Model.UniqueId}"; var progressId = $"progress-{Model.UniqueId}"; var progressbarId = $"progressbar-{Model.UniqueId}"; var extensionAttributes = " "; if (!string.IsNullOrWhiteSpace(Model.AllowedExtensions)) { extensionAttributes += $"data-allowed-file-extensions=\"{Model.AllowedExtensions}\" "; } var defaultValue = Model.AspFor.Model as string; if (!string.IsNullOrWhiteSpace(defaultValue)) { extensionAttributes += $"data-default-file=\"{Storage.RelativePathToInternetUrl(defaultValue, Context)}\" "; } if (Model.MaxSizeInMb > 0) { extensionAttributes += $"data-max-file-size=\"{Model.MaxSizeInMb}M\""; } var hiddenInputTag = HtmlGenerator.GenerateTextBox( ViewContext, Model.AspFor.ModelExplorer, Model.AspFor.Name, Model.AspFor.Model, format: null, htmlAttributes: new { style = "width:0;height:0;padding:0;border:none;" }); }
@hiddenInputTag ``` 这样,我就可以维护好这个 Component 后就不用管了。接下来我只需要在任何需要上传文件的地方调用 `` 就行了。并且其支持原生的 model binding,提交上去的内容仍然可以使用 DataAnnotation 来验证。并且最终交给业务 Controller 的只是一个 string。 现在你来分析它的优缺点,并且帮我指出可能存在的可以改进的地方和设计上的缺陷。