我写了个需要上传文件的页面。但是我觉得上传文件这个需求太常见了,于是我抽了个view component的。
我给你看看我的思路和实现。
我喜欢使用dropify。
为了好用的上传,我打算把上传过程分离。
首先有一个真正处理文件的controller:
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<IActionResult> 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。
using Aiursoft.Scanner.Abstractions;
namespace Aiursoft.Template.Services;
/// <summary>
/// Represents a service for storing and retrieving files.
/// </summary>
public class StorageService(IConfiguration configuration) : ISingletonDependency
{
public readonly string WorkspaceFolder = configuration["Storage:Path"]!;
// Async lock.
private readonly SemaphoreSlim _lock = new(1, 1);
/// <summary>
/// Saves a file to the storage.
/// </summary>
/// <param name="savePath">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.</param>
/// <param name="file">The file to be saved.</param>
/// <returns>The actual path where the file is saved relative to the workspace folder.</returns>
public async Task<string> 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
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:
.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:
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 负责这个页面:
//
// GET: /Manage/ChangeAvatar
[HttpGet]
public IActionResult ChangeAvatar()
{
return this.StackView(new ChangeAvatarViewModel());
}
//
// POST: /Manage/ChangeAvatar
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangeAvatar(ChangeAvatarViewModel model)
{
if (!ModelState.IsValid)
{
return this.StackView(model);
}
// Save the new avatar in database.
return this.StackView(model);
}
真正的页面代码就可以调用我的上传 Component 了:
@model Aiursoft.Template.Models.ManageViewModels.ChangeAvatarViewModel
<h2>@Localizer["Change your avatar"]</h2>
<form asp-controller="Manage" asp-action="ChangeAvatar" method="post" class="form-horizontal">
<h4>@Localizer["Upload the new avatar file."]</h4>
<hr />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="AvatarUrl" class="col-md-2 control-label"></label>
<div class="col-md-10">
<vc:file-upload asp-for="@Model.AvatarUrl" upload-endpoint="/upload/avatar" allowed-extensions="png bmp jpg" max-size-in-mb="10"></vc:file-upload>
<span asp-validation-for="AvatarUrl" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-secondary">@Localizer["Change avatar"]</button>
</div>
</div>
</form>
@* ReSharper disable once Razor.SectionNotResolved *@
@section styles{
<link rel="stylesheet" href="~/node_modules/dropify/dist/css/dropify.min.css" />
<link rel="stylesheet" href="~/styles/uploader.css" />
}
@* ReSharper disable once Razor.SectionNotResolved *@
@section scripts{
<script src="~/node_modules/dropify/dist/js/dropify.min.js"></script>
}
当然,就像我说的,真正的上传逻辑不应该出现在业务试图里,而是 <vc:file-upload>
里。
接下来我也抽了componnet:
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 视图代码如下:
@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;" });
}
<input
form="fakeForm"
type="file"
id="@fileInputId"
class="dropify"
data-show-remove="false"
@Html.Raw(extensionAttributes) />
<div id="@progressId" class="progress mb-3 mt-3 d-none">
<div id="@progressbarId" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div>
</div>
@hiddenInputTag
<script type="module">
import Uploader from "/scripts/uploader.js";
window.addEventListener('load', function () {
const fileInput = $(`#@fileInputId`);
const progress = $(`#@progressId`);
const progressbar = $(`#@progressbarId`);
const addressInput = $(`[name="@Model.AspFor.Name"]`);
new Uploader({
fileInput: fileInput,
progress: progress,
progressbar: progressbar,
addressInput: addressInput,
sizeInMb: @Model.MaxSizeInMb,
validExtensions: ('@Model.AllowedExtensions' || '').split(',').filter(Boolean),
uploadUrl: '@Model.UploadEndpoint'
}).init();
})
</script>
这样,我就可以维护好这个 Component 后就不用管了。接下来我只需要在任何需要上传文件的地方调用 <vc:file-upload>
就行了。并且其支持原生的 model binding,提交上去的内容仍然可以使用 DataAnnotation 来验证。并且最终交给业务 Controller 的只是一个 string。
现在你来分析它的优缺点,并且帮我指出可能存在的可以改进的地方和设计上的缺陷。