Ultima attività 1757819774

anduin's Avatar anduin ha revisionato questo gist 1757819774. Vai alla revisione

1 file changed, 551 insertions

FileUploader.md(file creato)

@@ -0,0 +1,551 @@
1 + 我写了个需要上传文件的页面。但是我觉得上传文件这个需求太常见了,于是我抽了个view component的。
2 +
3 + 我给你看看我的思路和实现。
4 +
5 + 我喜欢使用dropify。
6 +
7 + 为了好用的上传,我打算把上传过程分离。
8 +
9 + 首先有一个真正处理文件的controller:
10 +
11 + ```csharp
12 + namespace Aiursoft.CSTools.Attributes;
13 +
14 + public class ValidDomainName : ValidationAttribute
15 + {
16 + private readonly string _domainRegex = "^[-a-z0-9_]+$";
17 +
18 + public override bool IsValid(object? value)
19 + {
20 + Regex regex = new Regex(this._domainRegex, RegexOptions.Compiled);
21 + return value is string input && regex.IsMatch(input);
22 + }
23 +
24 + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
25 + {
26 + return this.IsValid(value) ? ValidationResult.Success : new ValidationResult($"The {validationContext.DisplayName} can only contain numbers, alphabet and underline.");
27 + }
28 + }
29 +
30 + using Aiursoft.CSTools.Attributes;
31 + using Aiursoft.Template.Services;
32 + using Microsoft.AspNetCore.Mvc;
33 +
34 + namespace Aiursoft.Template.Controllers;
35 +
36 + public class FilesController(
37 + StorageService storage) : ControllerBase
38 + {
39 + [Route("upload/{subfolder}")]
40 + public async Task<IActionResult> Index([FromRoute] [ValidDomainName] string subfolder)
41 + {
42 + if (!ModelState.IsValid)
43 + {
44 + return BadRequest();
45 + }
46 +
47 + // Executing here will let the browser upload the file.
48 + try
49 + {
50 + _ = HttpContext.Request.Form.Files.FirstOrDefault()?.ContentType;
51 + }
52 + catch (InvalidOperationException e)
53 + {
54 + return BadRequest(e.Message);
55 + }
56 +
57 + if (HttpContext.Request.Form.Files.Count < 1)
58 + {
59 + return BadRequest("No file uploaded!");
60 + }
61 +
62 + var file = HttpContext.Request.Form.Files.First();
63 + if (!new ValidFolderName().IsValid(file.FileName))
64 + {
65 + return BadRequest("Invalid file name!");
66 + }
67 +
68 + var storePath = Path.Combine(
69 + subfolder,
70 + DateTime.UtcNow.Year.ToString("D4"),
71 + DateTime.UtcNow.Month.ToString("D2"),
72 + DateTime.UtcNow.Day.ToString("D2"),
73 + file.FileName);
74 + var relativePath = await storage.Save(storePath, file);
75 + return Ok(new
76 + {
77 + Path = relativePath,
78 + InternetPath = storage.RelativePathToInternetUrl(relativePath, HttpContext)
79 + });
80 + }
81 +
82 + [Route("download/{**folderNames}")]
83 + public IActionResult Download([FromRoute] string folderNames)
84 + {
85 + if (!ModelState.IsValid)
86 + {
87 + return BadRequest();
88 + }
89 +
90 + if (folderNames.Contains(".."))
91 + {
92 + return BadRequest("Invalid path!");
93 + }
94 +
95 + var physicalPath = storage.GetFilePhysicalPath(folderNames);
96 + var workspaceFullPath = Path.GetFullPath(storage.WorkspaceFolder);
97 + if (!physicalPath.StartsWith(workspaceFullPath))
98 + {
99 + return BadRequest("Attempted to access a restricted path.");
100 + }
101 + if (!System.IO.File.Exists(physicalPath))
102 + {
103 + return NotFound();
104 + }
105 +
106 + return this.WebFile(physicalPath);
107 + }
108 + }
109 +
110 + ```
111 +
112 + 其调用存储服务。
113 +
114 + 这个存储服务的设计思路是不把真正的互联网上的 URL 存储到数据库,而是先将文件保存,再把它的相对路径存储到数据库。而真正在前端展示的时候,再使用 `RelativePathToInternetUrl` 方法转换成真正的 URL。
115 +
116 + ```csharp
117 + using Aiursoft.Scanner.Abstractions;
118 +
119 + namespace Aiursoft.Template.Services;
120 +
121 + /// <summary>
122 + /// Represents a service for storing and retrieving files.
123 + /// </summary>
124 + public class StorageService(IConfiguration configuration) : ISingletonDependency
125 + {
126 + public readonly string WorkspaceFolder = configuration["Storage:Path"]!;
127 +
128 + // Async lock.
129 + private readonly SemaphoreSlim _lock = new(1, 1);
130 +
131 + /// <summary>
132 + /// Saves a file to the storage.
133 + /// </summary>
134 + /// <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>
135 + /// <param name="file">The file to be saved.</param>
136 + /// <returns>The actual path where the file is saved relative to the workspace folder.</returns>
137 + public async Task<string> Save(string savePath, IFormFile file)
138 + {
139 + var finalFilePath = Path.Combine(WorkspaceFolder, savePath);
140 + var finalFolder = Path.GetDirectoryName(finalFilePath);
141 +
142 + // Create the folder if it does not exist.
143 + if (!Directory.Exists(finalFolder))
144 + {
145 + Directory.CreateDirectory(finalFolder!);
146 + }
147 +
148 + // The problem is: What if the file already exists?
149 + await _lock.WaitAsync();
150 + try
151 + {
152 + var expectedFileName = Path.GetFileName(finalFilePath);
153 + while (File.Exists(finalFilePath))
154 + {
155 + expectedFileName = "_" + expectedFileName;
156 + finalFilePath = Path.Combine(finalFolder!, expectedFileName);
157 + }
158 +
159 + // Create a new file.
160 + File.Create(finalFilePath).Close();
161 + }
162 + finally
163 + {
164 + _lock.Release();
165 + }
166 +
167 + await using var fileStream = new FileStream(finalFilePath, FileMode.Create);
168 + await file.CopyToAsync(fileStream);
169 + fileStream.Close();
170 +
171 + return Path.GetRelativePath(WorkspaceFolder, finalFilePath);
172 + }
173 +
174 + public string GetFilePhysicalPath(string fileName)
175 + {
176 + return Path.Combine(WorkspaceFolder, fileName);
177 + }
178 +
179 + public string AbsolutePathToRelativePath(string absolutePath)
180 + {
181 + return Path.GetRelativePath(WorkspaceFolder, absolutePath);
182 + }
183 +
184 + private string RelativePathToUriPath(string relativePath)
185 + {
186 + var urlPath = Uri.EscapeDataString(relativePath)
187 + .Replace("%5C", "/")
188 + .Replace("%5c", "/")
189 + .Replace("%2F", "/")
190 + .Replace("%2f", "/")
191 + .TrimStart('/');
192 + return urlPath;
193 + }
194 +
195 + public string RelativePathToInternetUrl(string relativePath, HttpContext context)
196 + {
197 + return $"{context.Request.Scheme}://{context.Request.Host}/download/{RelativePathToUriPath(relativePath)}";
198 + }
199 + }
200 +
201 + ```
202 +
203 + 然后,上传分两步,先上传文件,得到一个string,也就是其 RelativePath,再提交表单,把 RelativePath 提交上去。
204 +
205 + 为了体验更好,比如能够展示进度条,我先搓了个 JS 的 Uploader
206 +
207 + ```javascript
208 + class Uploader {
209 + constructor({
210 + fileInput,
211 + progress,
212 + progressbar,
213 + addressInput,
214 + sizeInMb,
215 + validExtensions,
216 + uploadUrl,
217 + onFile = () => {
218 + },
219 + onUploaded = () => {
220 + },
221 + onReset = () => {
222 + }
223 + } = {}) {
224 + this.fileInput = fileInput;
225 + this.progress = progress;
226 + this.progressbar = progressbar;
227 + this.addressInput = addressInput;
228 + this.sizeInMb = sizeInMb;
229 + this.validExtensions = validExtensions;
230 + this.uploadUrl = uploadUrl;
231 + this.onFile = onFile;
232 + this.onUploaded = onUploaded;
233 + this.onReset = onReset;
234 + this.onbeforeunloadBackup = window.onbeforeunload;
235 + }
236 +
237 + getExtension(filename) {
238 + const parts = filename.split('.');
239 + return (parts[parts.length - 1]).toLowerCase();
240 + }
241 +
242 + reset(that) {
243 + that.addressInput.val("");
244 + that.progressbar.css('width', '0%');
245 + that.progress.addClass('d-none');
246 + window.onbeforeunload = that.onbeforeunloadBackup;
247 + that.onReset(that);
248 + }
249 +
250 + tryUpload(that) {
251 + that.onFile(that);
252 +
253 + const file = that.fileInput.prop("files")[0];
254 + const ext = that.getExtension(file.name);
255 + if (that.validExtensions.length > 0 && that.validExtensions.indexOf(ext) === -1) {
256 + return;
257 + }
258 +
259 + if (file.size / 1024 / 1024 > that.sizeInMb) {
260 + return;
261 + }
262 +
263 + window.onbeforeunload = () => {
264 + return "Your file is still uploading. Are you sure to quit?";
265 + };
266 +
267 + that.progress.removeClass('d-none');
268 + that.progressbar.css('width', '0%');
269 + that.progressbar.removeClass('bg-success');
270 + that.progressbar.addClass('progress-bar-animated');
271 +
272 + const formData = new FormData();
273 + formData.append("file", file);
274 +
275 + $.ajax({
276 + url: that.uploadUrl,
277 + type: 'post',
278 + enctype: 'multipart/form-data',
279 + data: formData,
280 + cache: false,
281 + contentType: false,
282 + processData: false,
283 + xhr: () => {
284 + const myXhr = $.ajaxSettings.xhr();
285 + if (myXhr.upload) {
286 + myXhr.upload.addEventListener('progress', (e)=> {
287 + if (e.lengthComputable) {
288 + that.progressbar.css('width', 100 * e.loaded / e.total + '%');
289 + }
290 + }, false);
291 + }
292 + return myXhr;
293 + },
294 + success: (data)=> {
295 + window.onbeforeunload = that.onbeforeunloadBackup;
296 + that.addressInput.val(data.Path);
297 + that.progressbar.addClass('bg-success');
298 + that.progressbar.removeClass('progress-bar-animated');
299 + that.progressbar.css('width', '100%');
300 + that.onUploaded(data);
301 + },
302 + error: that.reset
303 + });
304 + }
305 +
306 + init() {
307 + const that = this;
308 + that.fileInput.unbind('change');
309 + that.fileInput.on('change', () => {
310 + that.tryUpload(that);
311 + });
312 + const dropify = that.fileInput.dropify();
313 + dropify.on('dropify.afterClear', () => {
314 + that.reset(that);
315 + });
316 + }
317 + }
318 +
319 + export default Uploader;
320 +
321 + ```
322 +
323 + 当然我也搓了一些 CSS:
324 +
325 + ```css
326 + .dropify-wrapper .dropify-message p {
327 + font-size: 20px !important;
328 + }
329 +
330 + @media (prefers-color-scheme: dark) {
331 + .dropify-preview {
332 + background-color: rgb(42, 43, 45) !important;
333 + }
334 +
335 + .dropify-wrapper {
336 + background-color: rgb(32, 33, 35) !important;
337 + border: 2px solid #666 !important;
338 + }
339 +
340 + .dropify-wrapper:hover {
341 + background-image: linear-gradient(-45deg, #161616 25%, transparent 25%, transparent 50%, #161616 50%, #161616 75%, transparent 75%, transparent) !important;
342 + }
343 + }
344 + ```
345 +
346 + 然后我就可以写我的页面了。我可以先弄个 ViewModel:
347 +
348 + ```csharp
349 + using System.ComponentModel.DataAnnotations;
350 + using Aiursoft.UiStack.Layout;
351 +
352 + namespace Aiursoft.Template.Models.ManageViewModels;
353 +
354 + public class ChangeAvatarViewModel : UiStackLayoutViewModel
355 + {
356 + public ChangeAvatarViewModel()
357 + {
358 + PageTitle = "Change Avatar";
359 + }
360 +
361 + [Display(Name = "Avatar file")]
362 + [Required(ErrorMessage = "The avatar file is required.")]
363 + [RegularExpression(@"^avatar.*", ErrorMessage = "The avatar file is invalid. Please upload it again.")]
364 + public string? AvatarUrl { get; set; }
365 + }
366 +
367 + ```
368 +
369 + 然后我弄个 Controller 负责这个页面:
370 +
371 + ```csharp
372 + //
373 + // GET: /Manage/ChangeAvatar
374 + [HttpGet]
375 + public IActionResult ChangeAvatar()
376 + {
377 + return this.StackView(new ChangeAvatarViewModel());
378 + }
379 +
380 + //
381 + // POST: /Manage/ChangeAvatar
382 + [HttpPost]
383 + [ValidateAntiForgeryToken]
384 + public async Task<IActionResult> ChangeAvatar(ChangeAvatarViewModel model)
385 + {
386 + if (!ModelState.IsValid)
387 + {
388 + return this.StackView(model);
389 + }
390 +
391 + // Save the new avatar in database.
392 +
393 + return this.StackView(model);
394 + }
395 +
396 + ```
397 +
398 + 真正的页面代码就可以调用我的上传 Component 了:
399 +
400 + ```csharp
401 + @model Aiursoft.Template.Models.ManageViewModels.ChangeAvatarViewModel
402 + <h2>@Localizer["Change your avatar"]</h2>
403 +
404 + <form asp-controller="Manage" asp-action="ChangeAvatar" method="post" class="form-horizontal">
405 + <h4>@Localizer["Upload the new avatar file."]</h4>
406 + <hr />
407 + <div asp-validation-summary="All" class="text-danger"></div>
408 + <div class="form-group">
409 + <label asp-for="AvatarUrl" class="col-md-2 control-label"></label>
410 + <div class="col-md-10">
411 + <vc:file-upload asp-for="@Model.AvatarUrl" upload-endpoint="/upload/avatar" allowed-extensions="png bmp jpg" max-size-in-mb="10"></vc:file-upload>
412 + <span asp-validation-for="AvatarUrl" class="text-danger"></span>
413 + </div>
414 + </div>
415 + <div class="form-group">
416 + <div class="col-md-offset-2 col-md-10">
417 + <button type="submit" class="btn btn-secondary">@Localizer["Change avatar"]</button>
418 + </div>
419 + </div>
420 + </form>
421 +
422 + @* ReSharper disable once Razor.SectionNotResolved *@
423 + @section styles{
424 + <link rel="stylesheet" href="~/node_modules/dropify/dist/css/dropify.min.css" />
425 + <link rel="stylesheet" href="~/styles/uploader.css" />
426 + }
427 +
428 + @* ReSharper disable once Razor.SectionNotResolved *@
429 + @section scripts{
430 + <script src="~/node_modules/dropify/dist/js/dropify.min.js"></script>
431 + }
432 + ```
433 +
434 + 当然,就像我说的,真正的上传逻辑不应该出现在业务试图里,而是 `<vc:file-upload>` 里。
435 +
436 + 接下来我也抽了componnet:
437 +
438 + ```csharp
439 + using Microsoft.AspNetCore.Mvc;
440 + using Microsoft.AspNetCore.Mvc.ViewFeatures;
441 +
442 + namespace Aiursoft.Template.Views.Shared.Components.FileUpload;
443 +
444 + public class FileUpload : ViewComponent
445 + {
446 + public IViewComponentResult Invoke(
447 + ModelExpression aspFor,
448 + string uploadEndpoint,
449 + int maxSizeInMb = 2000,
450 + string? allowedExtensions = null)
451 + {
452 + return View(new FileUploadViewModel
453 + {
454 + AspFor = aspFor,
455 + UploadEndpoint = uploadEndpoint,
456 + MaxSizeInMb = maxSizeInMb,
457 + AllowedExtensions = allowedExtensions,
458 + });
459 + }
460 + }
461 +
462 + using Microsoft.AspNetCore.Mvc.ViewFeatures;
463 +
464 + namespace Aiursoft.Template.Views.Shared.Components.FileUpload;
465 +
466 + public class FileUploadViewModel
467 + {
468 + public required ModelExpression AspFor { get; init; }
469 + public required string UploadEndpoint { get; init; }
470 + public required int MaxSizeInMb { get; init; }
471 + public required string? AllowedExtensions { get; init; }
472 + public string UniqueId { get; } = "uploader-" + Guid.NewGuid().ToString("N");
473 + }
474 +
475 + ```
476 +
477 + 而最终的 component 视图代码如下:
478 +
479 + ```razor
480 + @using Aiursoft.Template.Services
481 + @model Aiursoft.Template.Views.Shared.Components.FileUpload.FileUploadViewModel
482 + @inject StorageService Storage
483 + @inject Microsoft.AspNetCore.Mvc.ViewFeatures.IHtmlGenerator HtmlGenerator
484 + @{
485 + var fileInputId = $"file-input-{Model.UniqueId}";
486 + var progressId = $"progress-{Model.UniqueId}";
487 + var progressbarId = $"progressbar-{Model.UniqueId}";
488 +
489 + var extensionAttributes = " ";
490 + if (!string.IsNullOrWhiteSpace(Model.AllowedExtensions))
491 + {
492 + extensionAttributes += $"data-allowed-file-extensions=\"{Model.AllowedExtensions}\" ";
493 + }
494 + var defaultValue = Model.AspFor.Model as string;
495 + if (!string.IsNullOrWhiteSpace(defaultValue))
496 + {
497 + extensionAttributes += $"data-default-file=\"{Storage.RelativePathToInternetUrl(defaultValue, Context)}\" ";
498 + }
499 + if (Model.MaxSizeInMb > 0)
500 + {
501 + extensionAttributes += $"data-max-file-size=\"{Model.MaxSizeInMb}M\"";
502 + }
503 +
504 + var hiddenInputTag = HtmlGenerator.GenerateTextBox(
505 + ViewContext,
506 + Model.AspFor.ModelExplorer,
507 + Model.AspFor.Name,
508 + Model.AspFor.Model,
509 + format: null,
510 + htmlAttributes: new { style = "width:0;height:0;padding:0;border:none;" });
511 + }
512 +
513 + <input
514 + form="fakeForm"
515 + type="file"
516 + id="@fileInputId"
517 + class="dropify"
518 + data-show-remove="false"
519 + @Html.Raw(extensionAttributes) />
520 + <div id="@progressId" class="progress mb-3 mt-3 d-none">
521 + <div id="@progressbarId" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"></div>
522 + </div>
523 + @hiddenInputTag
524 +
525 + <script type="module">
526 + import Uploader from "/scripts/uploader.js";
527 + window.addEventListener('load', function () {
528 +
529 + const fileInput = $(`#@fileInputId`);
530 + const progress = $(`#@progressId`);
531 + const progressbar = $(`#@progressbarId`);
532 +
533 + const addressInput = $(`[name="@Model.AspFor.Name"]`);
534 +
535 + new Uploader({
536 + fileInput: fileInput,
537 + progress: progress,
538 + progressbar: progressbar,
539 + addressInput: addressInput,
540 +
541 + sizeInMb: @Model.MaxSizeInMb,
542 + validExtensions: ('@Model.AllowedExtensions' || '').split(',').filter(Boolean),
543 + uploadUrl: '@Model.UploadEndpoint'
544 + }).init();
545 + })
546 + </script>
547 + ```
548 +
549 + 这样,我就可以维护好这个 Component 后就不用管了。接下来我只需要在任何需要上传文件的地方调用 `<vc:file-upload>` 就行了。并且其支持原生的 model binding,提交上去的内容仍然可以使用 DataAnnotation 来验证。并且最终交给业务 Controller 的只是一个 string。
550 +
551 + 现在你来分析它的优缺点,并且帮我指出可能存在的可以改进的地方和设计上的缺陷。
Più nuovi Più vecchi