Utoljára aktív 1757819774

Revízió ff3e25952a2c87c1c0d9b3998ffcfddac638f200

FileUploader.md Eredeti

我写了个需要上传文件的页面。但是我觉得上传文件这个需求太常见了,于是我抽了个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。

现在你来分析它的优缺点,并且帮我指出可能存在的可以改进的地方和设计上的缺陷。