مطالب
بررسی روش آپلود فایل‌ها از طریق یک برنامه‌ی Angular به یک برنامه‌ی ASP.NET Core
پیشنیازها
«بررسی روش آپلود فایل‌ها در ASP.NET Core»
«ارسال فایل و تصویر به همراه داده‌های دیگر از طریق jQuery Ajax»
- در مطلب اول، روش دریافت فایل‌ها از کلاینت، در سمت سرور و ذخیره سازی آن‌ها در یک برنامه‌ی ASP.NET Core بررسی شده‌است که کلیات آن در اینجا نیز صادق است.
- در مطلب دوم، روش کار با FormData استاندارد بررسی شده‌است. هرچند در مطلب جاری از jQuery استفاده نمی‌شود، اما نکات نحوه‌ی کار با شیء FormData استاندارد، در اینجا نیز یکی است.


تدارک مقدمات مثال این قسمت

این مثال در ادامه‌ی همین سری کار با فرم‌های مبتنی بر قالب‌ها است. به همین جهت ابتدا ماژول جدید UploadFile را به آن اضافه می‌کنیم:
 >ng g m UploadFile -m app.module --routing
همچنین به فایل app.module.ts مراجعه کرده و UploadFileModule را بجای UploadFileRoutingModule در قسمت imports معرفی می‌کنیم. سپس به این ماژول جدید، کامپوننت فرم ثبت یک درخواست پشتیبانی را اضافه خواهیم کرد:
 >ng g c UploadFile/UploadFileSimple
که اینکار سبب به روز رسانی فایل upload-file.module.ts و افزوده شدن UploadFileSimpleComponent به قسمت declarations آن می‌شود.
در ادامه کلاس مدل معادل فرم ثبت نام یک درخواست پشتیبانی را تعریف می‌کنیم:
 >ng g cl UploadFile/Ticket
با این محتوا:
export class Ticket {
  constructor(public description: string = "") {}
}
در اینجا Ticket تعریف شده دارای یک خاصیت توضیحات است و این فرم به همراه فیلد ارسال چندین فایل نیز می‌باشد که نیازی به درج آن‌ها در کلاس فوق نیست:



ایجاد مقدمات کامپوننت UploadFileSimple و قالب آن

پس از ایجاد ساختار کلاس Ticket، یک وهله از آن‌را به نام model ایجاد کرده و در اختیار قالب آن قرار می‌دهیم:
import { Ticket } from "./../ticket";

export class UploadFileSimpleComponent implements OnInit {
  model = new Ticket();
سپس قالب این کامپوننت و یا همان فایل upload-file-simple.component.html را به صورت ذیل تکمیل می‌کنیم:
<div class="container">
  <h3>Support Form</h3>
  <form #form="ngForm" (submit)="submitForm(form)" novalidate>
    <div class="form-group" [class.has-error]="description.invalid && description.touched">
      <label class="control-label">Description</label>
      <input #description="ngModel" required type="text" class="form-control"
        name="description" [(ngModel)]="model.description">
      <div *ngIf="description.invalid && description.touched">
        <div class="alert alert-danger"  *ngIf="description.errors.required">
          description is required.
        </div>
      </div>
    </div>

    <div class="form-group">
      <label class="control-label">Screenshot(s)</label>
      <input #screenshotInput required type="file" multiple (change)="fileChange($event)"
        class="form-control" name="screenshot">
    </div>

    <button class="btn btn-primary" [disabled]="form.invalid" type="submit">Ok</button>
  </form>
</div>
در اینجا ابتدا فیلد توضیحات درخواست جدید، ارائه و به خاصیت model.description متصل شده‌است. همچنین این فیلد با ویژگی required مزین، و اجباری بودن آن بررسی گردیده‌است.
سپس در انتها، فیلد آپلود را مشاهده می‌کنید؛ با این ویژگی‌ها:
الف) ngModel ایی به آن متصل نشده‌است؛ چون روش کار با آن متفاوت است.
ب) یک template reference variable به نام screenshotInput# در آن تعریف شده‌است. از این متغیر، در کامپوننت قالب استفاده خواهیم کرد.
ج) به رخ‌داد change این کنترل، متد fileChange متصل شده‌است که رخ‌داد جاری را نیز دریافت می‌کند.
د) ذکر ویژگی استاندارد multiple را نیز در اینجا مشاهده می‌کنید. وجود آن سبب خواهد شد تا کاربر بتواند چندین فایل را با هم انتخاب کند. اگر نیازی به ارسال چندین فایل نیست، این ویژگی را حذف کنید.


دسترسی به المان ارسال فایل در کامپوننت متناظر

تا اینجا یک المان ارسال فایل را به فرم، اضافه کرده‌ایم. اما چگونه باید به فایل‌های آن برای ارسال به سرور دسترسی پیدا کنیم؟
برای این منظور در ادامه دو روش را بررسی خواهیم کرد:

1) دسترسی به المان ارسال فایل از طریق رخ‌داد change
در تعریف فیلد ارسال فایل، اتصال به رخ‌داد change تعریف شده‌است:
 (change)="fileChange($event)"
معادل آن در سمت کامپوننت متناظر، به صورت ذیل است:
fileChange(event) {
    const filesList: FileList = event.target.files;
    console.log("fileChange() -> filesList", filesList);
}
همانطور که مشاهده می‌کنید، event.target، امکان دسترسی مستقیم به المان متناظری را در قالب کامپوننت میسر می‌کند. سپس می‌توان به خاصیت files آن دسترسی یافت.


در اینجا ساختار شیء استاندارد FileList و اجزای آن‌را مشاهده می‌کنید. برای مثال چون دو فایل انتخاب شده‌است، این لیست به همراه یک خاصیت طول و دو شیء File است.

تعاریف این اشیاء استاندارد، در فایل ذیل قرار دارند و به همین جهت است که VSCode، بدون نیاز به تنظیمات دیگری، آن‌ها را شناسایی و intellisense متناظری را مهیا می‌کند:
 C:\Program Files (x86)\Microsoft VS Code\resources\app\extensions\node_modules\typescript\lib\lib.dom.d.ts
همچنین اگر به فایل tsconfig.json پروژه نیز مراجعه کنید، یک چنین تعاریفی در آن قرار دارند:
{
    "lib": [
      "es2016",
      "dom"
    ]
  }
}
وجود و تعریف کتابخانه‌ی dom است که سبب کامپایل شدن کدهای فوق، بدون بروز هیچگونه خطایی می‌شود.


2) دسترسی به المان آپلود فایل از طریق یک template reference variable
در حین تعریف المان فایل در فرم برنامه، متغیر screenshotInput# نیز ذکر شده‌است. می‌توان به یک چنین متغیرهایی در کامپوننت متناظر به روش ذیل دسترسی یافت:
import { Component, OnInit, ViewChild, ElementRef } from "@angular/core";

export class UploadFileSimpleComponent implements OnInit {
  @ViewChild("screenshotInput") screenshotInput: ElementRef;

  submitForm(form: NgForm) {
    const fileInput: HTMLInputElement = this.screenshotInput.nativeElement;
    console.log("fileInput.files", fileInput.files);
  }
ابتدا یک خاصیت جدید را به نام screenshotInput از نوع ElementRef که در angular/core@ تعریف شده‌است، اضافه می‌کنیم. سپس برای اتصال آن به template reference variable ایی به نام screenshotInput، از ویژگی به نام ViewChild، با پارامتری مساوی نام همین متغیر، استفاده خواهیم کرد.
اکنون خاصیت screenshotInput کامپوننت، به متغیری به همین نام در قالب متناظر با آن متصل شده‌است. بنابراین با استفاده از خاصیت nativeElement آن همانند کدهایی که در متد submitForm فوق ملاحظه می‌کنید، می‌توان به خاصیت files این کنترل ارسال فایل‌ها دسترسی یافت.
نوع جدید و استاندارد HTMLInputElement نیز در فایل lib.dom.d.ts که پیشتر معرفی شد، ثبت شده‌است.


ارسال فرم درخواست پشتیبانی به سرور

تا اینجا فرمی را تشکیل داده و همچنین به فیلد file آن دسترسی پیدا کردیم. اکنون می‌خواهیم این اطلاعات را به سمت سرور ارسال کنیم. برای این منظور، سرویس جدیدی را ایجاد خواهیم کرد:
 >ng g s UploadFile/UploadFileSimple -m upload-file.module
که سبب به روز رسانی خودکار قسمت providers فایل upload-file.module.ts نیز می‌شود.
در ادامه کدهای کامل این سرویس را مشاهده می‌کنید:
import { Http, RequestOptions, Response, Headers } from "@angular/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs/Observable";
import "rxjs/add/operator/do";
import "rxjs/add/operator/catch";
import "rxjs/add/observable/throw";
import "rxjs/add/operator/map";
import "rxjs/add/observable/of";

import { Ticket } from "./ticket";

@Injectable()
export class UploadFileSimpleService {
  private baseUrl = "api/SimpleUpload";

  constructor(private http: Http) {}

  private extractData(res: Response) {
    const body = res.json();
    return body || {};
  }

  private handleError(error: Response): Observable<any> {
    console.error("observable error: ", error);
    return Observable.throw(error.statusText);
  }

  postTicket(ticket: Ticket, filesList: FileList): Observable<any> {
    if (!filesList || filesList.length === 0) {
      return Observable.throw("Please select a file.");
    }

    const formData: FormData = new FormData();

    for (const key in ticket) {
      if (ticket.hasOwnProperty(key)) {
        formData.append(key, ticket[key]);
      }
    }

    for (let i = 0; i < filesList.length; i++) {
      formData.append(filesList[i].name, filesList[i]);
    }

    const headers = new Headers();
    headers.append("Accept", "application/json");
    const options = new RequestOptions({ headers: headers });

    return this.http
      .post(`${this.baseUrl}/SaveTicket`, formData, options)
      .map(this.extractData)
      .catch(this.handleError);
  }
}
توضیحات تکمیلی:
روش کار با فرم‌هایی که فیلدهای ارسال فایل را به همراه دارند، متفاوت است با روش کار با فرم‌های معمولی. در فرم‌های معمولی، اصل شیء Ticket را به متد this.http.post واگذار می‌کنیم. مابقی آن خودکار است. در اینجا باید شیء استاندارد FormData را تشکیل داده و سپس اطلاعات را از طریق آن ارسال کنیم:
الف) افزودن مقادیر خواص شیء Ticket به FormData
  postTicket(ticket: Ticket, filesList: FileList): Observable<any> {
    const formData: FormData = new FormData();

    for (const key in ticket) {
      if (ticket.hasOwnProperty(key)) {
        formData.append(key, ticket[key]);
      }
    }
با استفاده از حلقه‌ی for می‌توان بر روی خواص یک شیء جاوا اسکریپتی حرکت کرد. به این ترتیب می‌توان نام و مقدار آن‌ها را یافت و سپس به formData به صورت key/value افزود.

ب) افزودن فایل‌ها به شیء FormData
پس از افزودن اطلاعات ticket به FormData، اکنون نوبت به افزودن فایل‌های فرم است:
    for (let i = 0; i < filesList.length; i++) {
      formData.append(filesList[i].name, filesList[i]);
    }
این مورد نیز به سادگی تشکیل یک حلقه، بر روی خاصیت files المان آپلود فایل است. به همین جهت بود که به دو روش سعی کردیم، به این خاصیت دسترسی پیدا کنیم.

یک نکته: چون در اینجا کلید اضافه شده، نام فایل است، دیگر نمی‌توان در سمت سرور از روش model binding استفاده کرد. چون این نام دیگر ثابت نیست و هربار می‌تواند متغیر باشد (در حالت model binding دقیقا مشخص است که کلید مشخصی قرار است به سرور ارسال شود و بر همین اساس، نام خاصیت یا پارامتر سمت سرور تعیین می‌گردد). به همین جهت در سمت سرور برای دسترسی به این مجموعه، از روش Request.Form.Files استفاده می‌کنیم.

ج) ارسال اطلاعات نهایی به سرور
اکنون که formData را بر اساس اطلاعات اضافی ticket و فایل‌های متصل به آن تشکیل دادیم، روش ارسال آن به سرور همانند قبل است:
    const headers = new Headers();
    headers.append("Accept", "application/json");
    const options = new RequestOptions({ headers: headers });

    return this.http
      .post(`${this.baseUrl}/SaveTicket`, formData, options)
      .map(this.extractData)
      .catch(this.handleError);

یک نکته: در اینجا در روش استفاده از formData نباید Content-Type را به multipart/form-data  تنظیم کرد. در غیراینصورت خطای Missing content-type boundary error را دریافت می‌کنید.


تکمیل کامپوننت ارسال درخواست پشتیبانی

پس از تکمیل سرویس ارسال اطلاعات به سمت سرور، اکنون نوبت به استفاده‌ی از آن در کامپوننت ارسال فرم درخواست پشتیبانی است. بنابراین ابتدا این سرویس جدید را به سازنده‌ی UploadFileSimpleComponent تزریق می‌کنیم:
import { UploadFileSimpleService } from "./../upload-file-simple.service";

export class UploadFileSimpleComponent implements OnInit {
  constructor(private uploadService: UploadFileSimpleService  ) {}
و سپس متد submitForm چنین شکلی را پیدا می‌کند:
  submitForm(form: NgForm) {
    const fileInput: HTMLInputElement = this.screenshotInput.nativeElement;
    console.log("fileInput.files", fileInput.files);

    this.uploadService
      .postTicket(this.model, fileInput.files)
      .subscribe(data => {
        console.log("success: ", data);
      });
  }
در اینجا this.model حاوی اطلاعات شیء ticket است (برای مثال اطلاعات توضیحات آن) و fileInput.files امکان دسترسی به اطلاعات فایل‌های انتخابی توسط کاربر را می‌دهد. پس از آن فراخوانی متدهای this.uploadService.postTicket و subscribe، سبب ارسال این اطلاعات به سمت سرور می‌شوند.


دریافت فرم درخواست پشتیبانی در سمت سرور و ذخیره‌ی فایل‌های آن‌

کدهای کامل SimpleUpload که در سرویس فوق مشخص شده‌است، به صورت ذیل هستند. ابتدا مدل Ticket مشخص شده‌است:
namespace AngularTemplateDrivenFormsLab.Models
{
    public class Ticket
    {
        public int Id { set; get; }
        public string Description { set; get; }
    }
}
و سپس کنترلر ذخیره سازی اطلاعات Ticket را مشاهده می‌کنید:
using System.IO;
using System.Threading.Tasks;
using AngularTemplateDrivenFormsLab.Models;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;

namespace AngularTemplateDrivenFormsLab.Controllers
{
    [Route("api/[controller]")]
    public class SimpleUploadController : Controller
    {
        private readonly IHostingEnvironment _environment;
        public SimpleUploadController(IHostingEnvironment environment)
        {
            _environment = environment;
        }

        [HttpPost("[action]")]
        public async Task<IActionResult> SaveTicket(Ticket ticket)
        {
            //TODO: save the ticket ... get id
            ticket.Id = 1001;

            var uploadsRootFolder = Path.Combine(_environment.WebRootPath, "uploads");
            if (!Directory.Exists(uploadsRootFolder))
            {
                Directory.CreateDirectory(uploadsRootFolder);
            }

            var files = Request.Form.Files;
            foreach (var file in files)
            {
                //TODO: do security checks ...!

                if (file == null || file.Length == 0)
                {
                    continue;
                }

                var filePath = Path.Combine(uploadsRootFolder, file.FileName);
                using (var fileStream = new FileStream(filePath, FileMode.Create))
                {
                    await file.CopyToAsync(fileStream).ConfigureAwait(false);
                }
            }

            return Created("", ticket);
        }
    }
}
توضیحات تکمیلی
- تزریق IHostingEnvironment در سازنده‌ی کلاس کنترلر، سبب می‌شود تا از طریق خاصیت WebRootPath آن، به مسیر wwwroot سایت دسترسی پیدا کنیم و فایل‌های نهایی را در آنجا ذخیره سازی کنیم.
- همانطور که ملاحظه می‌کنید، هنوز هم model binding کار کرده و می‌توان شیء Ticket را به نحو متداولی دریافت کرد:
 SaveTicket(Ticket ticket)
اما همانطور که عنوان شد، چون در حلقه‌ی افزودن فایل‌ها در سمت کلاینت، کلید نام این فایل‌ها هربار متفاوت است:
 formData.append(filesList[i].name, filesList[i]);
مجبور هستیم در سمت سرور بر روی Request.Form.Files یک حلقه را تشکیل داده و تمام فایل‌های رسیده را پردازش کنیم:
var files = Request.Form.Files;
foreach (var file in files)



کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید.
نظرات مطالب
اجرای وظایف زمان بندی شده با Quartz.NET - قسمت دوم
البته نگفتید که منظورتون desktop یا وب هست، با این فرض که در مورد desktop می‌پرسید، یکی از روش‌ها اینه که یک متغیر عمومی تعریف کنید که ارجاعی به فرمی که قرار هست آپدیت باشه داشته باشه:
using System.Windows.Forms;

public static class GlobalData
{
    public static Form ScheduleForm { get; set; }
}
در سازنده‌ی فرم می‌تونید اون رو به فرم جاری مقداردهی کنید:
GlobalData.ScheduleForm = this;
با این فرض که قرار هست عنوان یک دکمه در فرم با نام myButton به My Text تغییر کنه، کلاس پیاده ساز اینترفیس IJob به صورت زیر خواهد بود.
namespace SchedulerDemo.Jobs
{
    using System.Linq;
    using System.Windows.Forms;
    using Quartz;

    public class HelloJob : IJob
    {
        private delegate void ButtonTextWriter(string buttonId, string text);
        
        MainForm form = GlobalData.ScheduleForm as MainForm;

        private void SetButtonText(string buttonId, string text)
        {
            (form.Controls.Find(buttonId, true).FirstOrDefault() as Button).Text = text;
        }

        public void Execute(IJobExecutionContext context)
        {
            form.BeginInvoke(new ButtonTextWriter(SetButtonText), new object[] { "myButton", "My Text" });
        }
    }
}

نظرات مطالب
شروع به کار با EF Core 1.0 - قسمت 15 - نوشتن آزمون‌های واحد
راه حل دوم برای اینکه در کلاس Context بیشتر از یک سازنده تعریف نکنیم

اضافه کردن سازنده‌ی دوم به کلاس Context می‌تواند عملیات Migrations را با مشکل مواجه کند. بنابراین جهت ساده سازی آن، به فایل appsettings.json یک مدخل جدید را اضافه می‌کنیم:
{
   "UseInMemoryDatabase": false
}
سپس این مدخل را در متد OnConfiguring کلاس Context مورد استفاده قرار خواهیم داد:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var useInMemoryDatabase = _configuration["UseInMemoryDatabase"].Equals("true", StringComparison.OrdinalIgnoreCase);
    if (useInMemoryDatabase)
    {
        optionsBuilder.UseInMemoryDatabase();
    }
    else
    {
        optionsBuilder.UseSqlServer(
            _configuration["ConnectionStrings:ApplicationDbContextConnection"]
            , serverDbContextOptionsBuilder =>
            {
                var minutes = (int)TimeSpan.FromMinutes(3).TotalSeconds;
                serverDbContextOptionsBuilder.CommandTimeout(minutes);
            });
    }
}
در اینجا بررسی شده‌است که مقدار کلید سفارشی UseInMemoryDatabase در تنظیمات برنامه چیست و بر این اساس تصمیم گیری شده‌است.
در این حالت در سمت کدهای آزمون واحد، مقدار کلید UseInMemoryDatabase را توسط یک InMemoryCollection تامین می‌کنیم و همچنین افزودن AddDbContext نیز معمولی خواهد بود:
var services = new ServiceCollection();
 
services.AddSingleton<IConfigurationRoot>(provider =>
{
    return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
                    .AddInMemoryCollection(new[]
                    {
                        new KeyValuePair<string,string>("UseInMemoryDatabase", "true"),
                    })
                    .Build();
});
 
services.AddEntityFrameworkInMemoryDatabase().AddDbContext<SampleContext>(ServiceLifetime.Scoped);
نظرات مطالب
پیاده سازی JSON Web Token با ASP.NET Web API 2.x
- تنظیمات IoC Container آن (برنامه‌ای که آزمایش شده و کار می‌کند) در اینجا هست. کدهای خودتان را با آن مقایسه کنید.
- آدرسی که کار می‌کند، یعنی جزو مسیریابی سیستم ثبت شده‌است.
- برای فعال سازی CORS این مراحل باید طی شوند:
در فایل WebApiConfig.cs، در ابتدای متد Register این کدها باید اضافه شوند:
var cors = new EnableCorsAttribute("*", "*", "*");
config.EnableCors(cors);
config.MessageHandlers.Add(new PreflightRequestsHandler());
در گردش کاری CORS، کلاینت قبل از ارسال درخواست‌های delete، put و post، ابتدا درخواستی از نوع option را ارسال می‌کند تا وضعیت دسترسی را بررسی کند:
public class PreflightRequestsHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.Headers.Contains("Origin") && request.Method.Method == "OPTIONS")
        {
            var response = new HttpResponseMessage {StatusCode = HttpStatusCode.OK};
            response.Headers.Add("Access-Control-Allow-Origin", "*");
            response.Headers.Add("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization");
            response.Headers.Add("Access-Control-Allow-Methods", "*");
            var tsc = new TaskCompletionSource<HttpResponseMessage>();
            tsc.SetResult(response);
            return tsc.Task;
        }
        return base.SendAsync(request, cancellationToken);
    }
}
به علاوه در ابتدای کدهای سمت کلاینت هم این سطر باید اضافه شود:
jQuery.support.cors = true;
پاسخ به بازخورد‌های پروژه‌ها
ارسال به JsonResult
ممنون از راهنمایی شما
بالاخره بکمک اسکریپت FileSaver تونستم فایل Pdf فلش کنم البته responseType  از نوع   arraybuffer  انتخاب کردم
public ActionResult PdfReport(ReportViewModel model)
        {
            var file = new GeneratePdfReport()
                .CreatePdfReport(AutoMapperHelper
                .Map<ReportViewModel, Report>(model));
            _files.Add(file.PdfStreamOutput);


            var stream = new MemoryStream();
            new MergePdfDocuments
            {
                WriterCustomizer = info =>
                {
                    info.ImportedPage.PdfWriter.CloseStream = false;
                },
                InputFileStreams = _files,
                OutputFileStream = stream,
                AttachmentsBookmarkLabel = "Attachment(s) ",
            }
            .PerformMerge();

            stream.Flush();
            stream.Position = 0;
            return File(stream, "application/pdf", null);
        }
اسکریپت:
$http({
            url: "Report/PdfReport",
            method: "POST",
            responseType: "arraybuffer",
            data: json,
            headers: { "Content-type": "application/json" }
        }).success(function (data) {
            var blob = new Blob([data], { type: "application/pdf" });
            $scope.progressbar.complete();
            saveAs(blob, "report.pdf");
        }

مطالب
امکان انجام محاسبات سمت کلاینت در EF Core
در دنیای NET. همواره دو نوع LINQ وجود داشته داشته است: LINQ to Objects و ... مابقی.  در حالت اول با <IEnumerable<T‌ها کار می‌کنیم که تمام عملیات در حافظه انجام می‌شود و در مابقی حالات یک <IQueryable<T وجود دارد که عبارت حاصل از آن جهت کاربردهای مختلفی به زبان‌های متفاوتی مانند SQL ترجمه می‌شوند. در هر دو حالت کلی، Syntax نهایی یکی است و تنها اگر به منبع داده‌ی آن‌ها دقت کنیم، می‌توانیم نوع آن‌ها را تشخیص دهیم. برای نمونه کوئری ذیل بر اساس منبع Blogs است که می‌تواند LINQ to Objects باشد و یا حالت <Queryable<Blog که قرار است به زبانی مشخص ترجمه شود:
var blogs = from blog in Blogs
   where blog.Name.Contains("Development")
   select blog;
اکنون فرض کنید که این عبارت قرار است به SQL ترجمه شده و سپس بر روی یک بانک اطلاعاتی اجرا شود. در این حالت مفسر LINQ باید بداند که متد Contains را چگونه به معادل SQL آن ترجمه کند و این ترجمه می‌تواند بر اساس بانک‌های اطلاعاتی مختلف، متفاوت نیز باشد. اما در حالت LINQ to Objects یک چنین مشکلی وجود ندارد و این ترجمه مستقیما بر روی متد Contains کلاس string انجام می‌شود.
اما اکنون چطور؟
var blogs = from blog in Blogs
   where blog.Name.ComputeHash() == 0
   select blog;
فرض کنید یک متد الحاقی را به نام ComputeHash به کلاس string اضافه کرده‌ایم. یک چنین کوئری را اگر بر روی EF 6.x اجرا کنیم، برنامه با یک استثناء متوقف خواهد شد؛ چون امکان ترجمه‌ی متد ComputeHash را به معادل SQL آن ندارد؛ اما EF Core برای انجام یک چنین کوئری‌هایی بهبود یافته‌است که به آن، محاسبات سمت کلاینت گفته می‌شود.


یک مثال: بررسی تاثیر ارزیابی‌های سمت کلاینت در EF Core

فرض کنید ساختار جدول بلاگ‌ها به صورت زیر است:
public class Blog
{
   public int BlogId { get; set; }
   public string Url { get; set; }  
}
همچنین یک متد الحاقی را به نام ComputeHash به صورت ذیل تعریف کرده‌ایم:
    public static class StringExtensions
    {
        public static int ComputeHash(this string str)
        {
            var hash = 0;
            foreach (var ch in str)
            {
                hash += (int)ch;
            }
            return hash;
        }
    }
اکنون می‌خواهیم بلاگ‌هایی را پیدا کنیم که Hash مربوط به Url آن‌ها بیشتر از 10 است (صرفا جهت نمایش این قابلیت جدید):
using (var context = new BloggingContext())
{
   var blogs = context.Blogs
     .Where(blog => blog.Url.ComputeHash() >= 10)
     .ToList();
   Console.WriteLine(blogs.First().Url);
}
اگر این کوئری را اجرا کنیم، یک چنین خروجی SQL ایی تولید خواهد شد و همچنین برنامه کرش هم نمی‌کند:
SELECT [blog].[BlogId], [blog].[Url]
   FROM [Blogs] AS [blog]
به این معنا که در ارزیابی‌های سمت کلاینت:
الف) مفسر LINQ در EF Core، شروع به ارزیابی کوئری نوشته شده می‌کند و هرجائیکه متدی را یافت و از درک آن عاجز بود (معادل SQL ایی را برای آن نیافت)، آن‌را از کوئری حذف می‌کند.
ب) کوئری SQL نهایی بدون متد ComputeHash بر روی بانک اطلاعاتی اجرا شده و نتیجه به سمت کلاینت بازگشت داده می‌شود. به همین جهت است که در خروجی SQL فوق خبری از متد ComputeHash نیست.
ج) اکنون که EF Core اطلاعات لازم را از سمت سرور دریافت کرده‌است، متد ComputeHash را در سمت کلاینت بر روی این نتیجه‌ی دریافتی اعمال می‌کند. یعنی مرحله‌ی آخر همان LINQ to Objects متداول خواهد بود.
به این ترتیب است که EF Core قابلیت اجرای هر نوع متدی را که معادل SQL ایی برای آن وجود ندارد، خواهد یافت.


چگونه متوجه شویم که ارزیابی سمت کلاینت رخ داده‌است؟

EF Core این قابلیت را دارد تا گزارش کاملی را از ارزیابی‌های سمت کلاینت صورت گرفته ارائه دهد. هرچند در مثال فوق متد الحاقی ComputeHash بسیار واضح است، اما برای نمونه متد string.Join نیز معادل SQL ایی ندارد:
var idUrls = context.Blogs
   .Select(b => new
   {
      IdUrlString = string.Join(", ", b.BlogId, b.Url),
   }).ToList();
این مثال بدون مشکل توسط EF Core و قابلیت جدید ارزیابی سمت کلاینت آن اجرا می‌شود، اما بهتر است از وقوع یک چنین رخ‌دادهایی مطلع شویم:
    public class BloggingContext : DbContext
    {
        public BloggingContext()
        { }

        public BloggingContext(DbContextOptions options)
            : base(options)
        { }

        public DbSet<Blog> Blogs { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Demo.ClientSideEvaluation;Trusted_Connection=True;");
                optionsBuilder.ConfigureWarnings(warnings =>
                {
                    warnings.Log(CoreEventId.IncludeIgnoredWarning);
                    warnings.Log(RelationalEventId.QueryClientEvaluationWarning);
                });
            }
        }
    }
برای این منظور تنها کافی است درخواست فعالسازی لاگ کردن QueryClientEvaluationWarning را در قسمت ConfigureWarnings آن ارائه دهیم. در این حالت اگر برنامه را مجددا اجرا کنیم، ابتدا یک چنین خروجی لاگ می‌شود:
 warn: Microsoft.EntityFrameworkCore.Query[200500]
The LINQ expression 'where ([blog].Url.ComputeHash() >= 10)' could not be translated and will be evaluated locally.
عنوان کرده‌است که قابلیت ترجمه‌ی ComputeHash را به SQL نداشته و آن‌را در نهایت به صورت محلی و در سمت کلاینت محاسبه می‌کند.

اگر می‌خواهید ارزیابی سمت کلاینت را ممنوع کنید، در تنظیمات فوق warnings.Log را به warnings.Throw تغییر دهید. این مورد سبب خواهد شد تا اگر برنامه به این نوع ارزیابی‌ها رسید، با یک استثناء متوقف شود (شبیه به حالت EF 6.x).


تاثیر ارزیابی‌های سمت کلاینت بر روی کارآیی برنامه

هرچند قابلیت ارزیابی‌های سمت کلاینت بسیار مفید است اما باید دقت داشت:
الف) در این حالت چون ابتدا متدهایی که قابلیت ارزیابی در سمت سرور را دارا نیستند، حذف خواهند شد، ممکن است تمام رکوردها به سمت کلاینت بازگشت داده شده و سپس فیلترینگ نهایی در سمت کلاینت صورت گیرد. مانند مثال محاسبه‌ی hash که در SQL تولیدی آن، خبری از قسمت where نیست و این شرط در انتهای کار، در سمت کلاینت و به صورت LINQ to Objects اعمال می‌شود.
ب) این قابلیت ممکن است برنامه نویس‌ها را از تفکر در مورد یافتن روش‌های محاسباتی سمت سرور دور کند. برای مثال هر چند مثال string.Join نوشته شده در سمت کلاینت محاسبه خواهد شد و این کوئری بدون مشکل اجرا می‌شود، اما اگر آن‌را به صورت ذیل جایگزین کنیم:
var idUrls2 = context.Blogs
   .Select(b => new
   {
     IdUrlString = b.BlogId + "," + b.Url
   }).ToList();
اینبار به یک خروجی SQL قابل محاسبه‌ی در سمت سرور، خواهیم رسید:
SELECT (CAST([b].[BlogId] AS nvarchar(max)) + N',') + [b].[Url] AS [IdUrlString]
FROM [Blogs] AS [b]
به همین جهت حداقل لاگ کردن ارزیابی‌های سمت کلاینت را روشن کنید تا از وقوع یک چنین مسایلی مطلع گردید.


کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید: ClientSideEvaluation.zip
مطالب
چک لیست ارتقاء به HTTPS مخصوص یک برنامه‌ی ASP.NET MVC 5x
پس از فعالسازی HTTPS بر روی سایت خود، در جهت بهبود امنیت برنامه‌های ASP.NET MVC 5.x، می‌توان درخواست کوکی‌های صرفا ارسال شده‌ی از طریق اتصال‌های HTTPS، اجبار به استفاده‌ی از آدرس‌های HTTPS و هدایت خودکار به آدرس‌های HTTPS را نیز فعالسازی کرد.


کوکی‌هایی که باید HTTPS only شوند

کوکی‌های پیش‌فرض برنامه‌های ASP.NET به صورت HTTP Only به سمت کلاینت ارسال می‌شوند. این کوکی‌ها توسط اسکریپت‌ها قابل خوانده شدن نیستند و به همین جهت یکی از راه‌های مقاومت بیشتر در برابر حملات XSS به شمار می‌روند. پس از ارتقاء به HTTPS، این کوکی‌ها را هم می‌توان HTTPs Only کرد تا فقط به کلاینت‌هایی که از طریق آدرس HTTPS سایت به آن وارد شده‌اند، ارائه شود:
1) کوکی آنتی‌فورجری توکن
 AntiForgeryConfig.RequireSsl = true;
محل تنظیم در متد Application_Start
2) کوکی‌های حالت Forms Authentication
<configuration>
  <system.web>
    <authentication mode="Forms">
      <forms requireSSL="true" cookieless="UseCookies"/>
    </authentication>
  </system.web>
</configuration>

3) تمام httpCookies
<configuration>
  <system.web>
    <httpCookies httpOnlyCookies="true" requireSSL="true" />
  </system.web>
</configuration>

4) کوکی‌های Role manager
<configuration>
  <system.web>
    <roleManager cookieRequireSSL="true" />
  </system.web>
</configuration>
محل تنظیم این سه مورد در فایل web.config است.

5) کوکی‌های OWIN Authentication و ASP.NET Identity 2.x
var options = new CookieAuthenticationOptions()
{
    CookieHttpOnly = true,
    CookieSecure = CookieSecureOption.Always,
    ExpireTimeSpan = TimeSpan.FromMinutes(10)
};


فعالسازی اجبار به استفاده‌ی از HTTPS

با استفاده از فیلتر RequireHttps، دسترسی به تمام اکشن متدهای برنامه تنها به صورت HTTPS میسر خواهد شد:
 filters.Add(new RequireHttpsAttribute(permanent: true));
محل تنظیم در متد RegisterGlobalFilters فایل Global.asax.cs، و یا در کلاس FilterConfig به صورت زیر:
using System.Web.Mvc;
namespace MyWebsite
{
    internal static class FilterConfig
    {
        internal static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new RequireHttpsAttribute(permanent: true));
        }
    }
}
پارامتر permanent آن در چند به روز رسانی آخر MVC 5 به آن اضافه شده‌است (از نگارش 5.2.4 به بعد) و مخصوص موتورهای جستجو است؛ در جهت تصحیح خودکار آدرس‌های قدیمی سایت به آدرس‌های جدید.

همچنین در متد Application_BeginRequest نیز می‌توان بررسی کرد که آیا درخواست ارسالی یک درخواست HTTPS است یا خیر؟ اگر خیر، می‌توان کاربر را به صورت خودکار به نگارش HTTPS آن هدایت دائم کرد:
        protected void Application_BeginRequest(Object sender, EventArgs e)
        {
            if (!HttpContext.Current.Request.IsSecureConnection)
            {
                var builder = new UriBuilder
                {
                    Scheme = "https",
                    Host = Request.Url.Host,
                    // use the RawUrl since it works with URL Rewriting
                    Path = Request.RawUrl
                };
                Response.Status = "301 Moved Permanently";
                Response.AddHeader("Location", builder.ToString());
            }
        }
جهت محکم کاری این قسمت می‌توان دو تنظیم تکمیلی ذیل را نیز به فایل web.config اضافه کرد:
فعالسازی HSTS جهت اطلاع به مرورگر که این سایت تنها از طریق HTTPS قابل دسترسی است و تمام درخواست‌های HTTP را به صورت خودکار از طریق HTTPS انجام بده:
<httpProtocol>
      <customHeaders>
        <add name="Strict-Transport-Security" value="max-age=16070400; includeSubDomains" />
و همچنین اگر ماژول URL Rewite بر روی سرور نصب است، کار هدایت خودکار به آدرس‌های HTTPS را نیز می‌توان توسط آن مدیریت کرد:
<rewrite>
    <rules>        
        <rule name="Redirect to HTTPS" stopProcessing="true">
          <match url="(.*)" />
          <conditions>
            <add input="{HTTPS}" pattern="off" ignoreCase="true" />
            <add input="{HTTP_HOST}" negate="true" pattern="localhost" />
          </conditions>
          <action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Permanent" />
        </rule>


اصلاح تمام آدرس‌های مطلقی که توسط برنامه تولید می‌شوند

اگر در برنامه‌ی خود از Url.Action برای تولید آدرس‌ها استفاده می‌کنید، با ذکر پارامتر protocol آن، آدرس تولیدی آن بجای یک مسیر نسبی، یک مسیر مطلق خواهد بود. اگر پیشتر این پروتکل را به صورت دستی به http تنظیم کرده‌اید، روش صحیح آن به صورت زیر است که با آدرس جدید HTTPS سایت هم سازگار است:
 var fullBaseUrl = Url.Action(result: MVC.Home.Index(), protocol: this.Request.Url.Scheme);


اصلاح تمام آدرس‌هایی که پیشتر توسط برنامه تولید شده‌اند

یک نمونه آن در مطلب «به روز رسانی تمام فیلدهای رشته‌ای تمام جداول بانک اطلاعاتی توسط Entity framework 6.x» بحث شده‌است.


اصلاح فایل robots.txt و درج آدرس HTTPS جدید

اگر در فایل robots.txt سایت، آدرس مطلق Sitemap را به صورت HTTP درج کرده بودید، آن‌را به HTTPS تغییر دهید:
User-agent: *
Sitemap: https://www.dntips.ir/Sitemap
مطالب
Blazor 5x - قسمت چهارم - مبانی Blazor - بخش 1 - Data Binding
عنوان می‌شود که HTML over Web socket آینده‌ی توسعه‌ی برنامه‌های وب است و این آینده هم اکنون توسط Blazor Server در دسترس است. در این مدل توسعه، ابتدا یک اتصال SignalR برقرار شده و سپس تمام تعاملات بین سرور و کلاینت، از طریق همین اتصال که عموما web socket است، مدیریت می‌شود. به همین جهت در ادامه قصد داریم یک پروژه‌ی Blazor Server را تکمیل کنیم. پس از آن یک پروژه‌ی Blazor WASM را نیز بررسی خواهیم کرد. بنابراین هر دو مدل توسعه‌ی برنامه‌های Blazor را پوشش خواهیم داد. برای این منظور در ابتدا مبانی Blazor را بررسی می‌کنیم که در هر دو مدل یکی است.


تعریف مدل برنامه

در همان پروژه‌ی خالی Blazor Server که در قسمت دوم با دستور dotnet new blazorserver ایجاد کردیم، پوشه‌ی Models را افزوده و کلاس BlazorRoom را در آن تعریف می‌کنیم:
namespace BlazorServerSample.Models
{
    public class BlazorRoom
    {
        public int Id { set; get; }

        public string Name { set; get; }

        public decimal Price { set; get; }

        public bool IsActive { set; get; }
    }
}
سپس برای اینکه مدام نیاز به تعریف فضای نام آن در فایل‌های مختلف razor. برنامه نباشد، به فایل Imports.razor_ مراجعه کرده و سطر زیر را به انتهای آن اضافه می‌کنیم:
@using BlazorServerSample.Models
برنامه را نیز توسط دستور dotnet watch run اجرا می‌کنیم.


Data binding یک طرفه

در ادامه به فایل Pages\Index.razor مراجعه کرده و منهای سطر اول مسیریابی آن، مابقی محتوای آن‌را حذف می‌کنیم. در اینجا می‌خواهیم مقادیر نمونه‌ای از شیء BlazorRoom را نمایش دهیم. به همین جهت این شیء را در قسمت code@ فایل razor جاری (همانند نکات قسمت قبل)، ایجاد می‌کنیم:
@page "/"

<h2 class="bg-light border p-2">
    First Room
</h2>
Room: @Room.Name
<br/>
Price: @Room.Price

@code
{
    BlazorRoom Room = new BlazorRoom
    {
        Id = 1,
        Name = "Room 1",
        IsActive = true,
        Price = 499
    };
}
در اینجا در ابتدا شیء Room را در قسمت قطعه کد فایل razor جاری ایجاد کرده و سپس اطلاعات آن‌را با استفاده از زبان Razor نمایش داده‌ایم.


 به این روش نمایش اطلاعات، one-way data-binding نیز گفته می‌شود. اما چطور می‌توان یک طرفه بودن آن‌را متوجه شد؟ برای این منظور یک text-box را نیز در ذیل تعاریف فوق، به صورت زیر اضافه می‌کنیم که مقدارش را از Room.Price دریافت می‌کند:
<input type="number" value="@Room.Price" />
اکنون اگر این مقدار را تغییر دهیم، عدد جدید قیمت اتاق، به خاصیت Room.Price منعکس نمی‌شود و تغییری نمی‌کند:



Data binding دو طرفه

اکنون می‌خواهیم اگر مقدار ورودی Room.Price توسط text-box فوق تغییر کرد، نتیجه‌ی نهایی، به خاصیت متناظر با آن نیز اعمال شود و تغییر کند. برای این منظور فقط کافی است ویژگی value را به bind-value@ تغییر دهیم:
<input type="number" @bind-value="@Room.Price" />
ویژگی bind-value@ سبب برقراری data-binding دو طرفه می‌شود. یعنی در ابتدا مقدار اولیه‌ی خاصیت Room.Price را نمایش می‌دهد. در ادامه‌ی اگر کاربر، مقدار این text-box را تغییر داد، نتیجه‌ی نهایی را به خاصیت Room.Price نیز اعمال می‌کند و همچنین این تغییر، سبب به روز رسانی UI نیز می‌شود؛ یعنی در جائیکه پیشتر مقدار اولیه‌ی Room.Price را نمایش داده بودیم، اکنون مقدار جدید آن نمایش داده خواهد شد:


البته اگر برنامه را اجرا کنیم، با تغییر مقدار text-box، بلافاصله تغییری را مشاهده نخواهیم کرد. برای اعمال تغییرات نیاز خواهد بود تا در جائی خارج از text-box کلیک و focus را به المانی دیگر منتقل کنیم. اگر می‌خواهیم همراه با تایپ اطلاعات درون text-box، رابط کاربری نیز به روز شود، می‌توان bind-value را به یک رخداد خاص، مانند oninput متصل کرد. حالت پیش‌فرض آن onchange است:
<input type="number" @bind-value="@Room.Price" @bind-value:event="oninput" />
اکنون اگر برنامه را اجرا کرده و درون text-box اطلاعاتی را وارد کنیم، بلافاصله UI نیز به روز رسانی خواهد شد.
لیست کامل رخ‌دادها را در اینجا می‌توانید مشاهده کنید. برای مثال برای یک المان input، دو رخداد onchange و oninput قابل تعریف هستند.

یک نکته: در حین کار با bind-value@، نیازی نیست مقدار آن با @ شروع شود. یعنی ذکر "bind-value="Room.Price@ نیز کافی است.


تمرین 1 - خاصیت IsActive یک اتاق را به یک checkbox متصل کرده و همچنین وضعیت جاری آن‌را نیز در یک برچسب نمایش دهید.

در اینجا می‌خواهیم مقدار خاصیت Room.IsActive را توسط یک اتصال دو طرفه، به یک checkbox متصل کنیم:
<input type="checkbox" @bind-value="Room.IsActive"  />
<br/>
This room is @(Room.IsActive? "Active" : "Inactive").
با استفاده از bind-value@، وضعیت جاری خاصیت Room.IsActive را به یک checkbox متصل کرده‌ایم. همچنین در ادامه توسط یک عبارت شرطی، این وضعیت را نمایش داده‌ایم.


بار اولی که برنامه نمایش داده می‌شود، هر چند مقدار IsActive بر اساس مقدار دهی آن در شیء Room، مساوی true است، اما chekbox، علامت نخورده باقی می‌ماند. برای رفع این مشکل نیاز است ویژگی checked این المان را نیز به صورت زیر مقدار دهی کرد:
<input type="checkbox" @bind-value="Room.IsActive"
   checked="@(Room.IsActive? "cheked" : null)" />
در این حالت اگر اتاقی فعال باشد، مقدار ویژگی checked، به checked و در غیراینصورت به null تنظیم می‌شود. به این ترتیب مشکل عدم نمایش checkbox انتخاب شده در بار اول نمایش کامپوننت جاری، برطرف می‌شود.


اتصال خواص مدل‌ها به dropdown‌ها

اکنون می‌خواهیم مدل این مثال را کمی توسعه داده و خواص تو در تویی را به آن اضافه کنیم:
using System.Collections.Generic;

namespace BlazorServerSample.Models
{
    public class BlazorRoom
    {
        // ...

        public List<BlazorRoomProp> RoomProps { set; get; }
    }

    public class BlazorRoomProp
    {
        public int Id { set; get; }

        public string Name { set; get; }

        public string Value { set; get; }
    }
}
برای مثال یک اتاق می‌تواند ویژگی‌هایی مانند مساحت، تعداد نفرات مجاز و غیره را داشته باشد. هدف از ویژگی جدید RoomProps، تعیین لیست این نوع موارد است.
پس از این تعاریف، فیلد Room را به صورت زیر به روز رسانی می‌کنیم تا تعدادی از خواص اتاق را به همراه داشته باشد:
@code
{
    BlazorRoom Room = new BlazorRoom
    {
        Id = 1,
        Name = "Room 1",
        IsActive = true,
        Price = 499,
        RoomProps = new List<BlazorRoomProp>
        {
            new BlazorRoomProp
            {
                Id = 1, Name = "Sq Ft", Value = "100"
            },
            new BlazorRoomProp
            {
                Id = 2, Name = "Occupancy", Value = "3"
            }
        }
    };
}
در ادامه می‌خواهیم این خواص را در یک dropdown نمایش دهیم. همچنین با انتخاب یک خاصیت از دراپ‌داون، مقدار خاصیت انتخابی را در یک برچسب نیز به صورت پویا نمایش خواهیم داد:
<select @bind="SelectedRoomPropValue">
    @foreach (var prop in Room.RoomProps)
    {
        <option value="@prop.Value">@prop.Name</option>
    }
</select>
<span>The value of the selected room prop is: @SelectedRoomPropValue</span>

@code
{
    string SelectedRoomPropValue = "";
    // ...
همانطور که مشاهده می‌کنید، انجام یک چنین کاری با Blazor بسیار ساده‌است و نیازی به استفاده از جاوا اسکریپت و یا جی‌کوئری ندارد.
در اینجا یک فیلد را در قطعه کد برنامه تعریف کرده و به المان select متصل کرده‌ایم. هرگاه آیتمی در این دراپ داون انتخاب شود، این فیلد، مقدار آن آیتم انتخابی را خواهد داشت. در ادامه توسط یک حلقه‌ی foreach، تمام خواص یک اتاق را دریافت کرده و به صورت options‌های یک select استاندارد، نمایش می‌دهیم. در آخر نیز مقدار SelectedRoomPropValue را نمایش داده‌ایم که این مقدار به صورت پویا تغییر می‌کند:



تعریف لیستی از اتاق‌ها

عموما در یک برنامه‌ی واقعی، با یک تک اتاق کار نمی‌کنیم. به همین جهت در ادامه لیستی از اتاق‌ها را تعریف و مقدار دهی اولیه خواهیم کرد:
@code
{
    string SelectedRoomPropValue = "";

    List<BlazorRoom> Rooms = new List<BlazorRoom>();

    protected override void OnInitialized()
    {
        base.OnInitialized();

        Rooms.Add(new BlazorRoom
        {
            Id = 1,
            Name = "Room 1",
            IsActive = true,
            Price = 499,
            RoomProps = new List<BlazorRoomProp>
            {
                new BlazorRoomProp
                {
                    Id = 1, Name = "Sq Ft", Value = "100"
                },
                new BlazorRoomProp
                {
                    Id = 2, Name = "Occupancy", Value = "3"
                }
            }
        });

        Rooms.Add(new BlazorRoom
        {
            Id = 2,
            Name = "Room 2",
            IsActive = true,
            Price = 399,
            RoomProps = new List<BlazorRoomProp>
            {
                new BlazorRoomProp
                {
                    Id = 1, Name = "Sq Ft", Value = "250"
                },
                new BlazorRoomProp
                {
                    Id = 2, Name = "Occupancy", Value = "4"
                }
            }
        });
    }
}
در ابتدا فیلد Rooms تعریف شده که لیستی از BlazorRoomها است. در ادامه بجای مقدار دهی مستقیم آن در همان سطح قطعه کد، آن‌را در یک متد life-cycle کامپوننت جاری به نام OnInitialized که مخصوص این نوع مقدار دهی‌های اولیه است، مقدار دهی کرده‌ایم.


نمایش لیست قابل ویرایش اتاق‌ها

اکنون می‌خواهیم به عنوان تمرین 2، لیست جزئیات اتاق‌های تعریف شده را نمایش دهیم؛ با این شرط که نام و قیمت هر اتاق، قابل ویرایش باشد. همچنین خواص تعریف شده نیز به صورت ستون‌هایی مجزا، نمایش داده شوند. برای مثال اگر دو خاصیت در اینجا تعریف شده، 2 ستون اضافه‌تر نیز برای نمایش آن‌ها وجود داشته باشد. به علاوه از آنجائیکه می‌خواهیم اتصال دوطرفه را نیز آزمایش کنیم، نام و قیمت هر اتاق را نیز در پایین جدول، مجددا به صورت برچسب‌هایی نمایش خواهیم داد.


برای رسیدن به تصویر فوق می‌توان به صورت زیر عمل کرد:
<div class="border p-2 mt-3">
    <h2 class="text-info">Rooms List</h2>
    <table class="table table-dark">
        @foreach(var room in Rooms)
        {
            <tr>
                <td>
                    <input type="text" @bind-value="room.Name" @bind-value:event="oninput"/>
                </td>
                <td>
                    <input type="text" @bind-value="room.Price" @bind-value:event="oninput"/>
                </td>
                @foreach (var roomProp in room.RoomProps)
                {
                    <td>
                        @roomProp.Name, @roomProp.Value
                    </td>
                }
            </tr>
        }
    </table>

    @foreach(var room in Rooms)
    {
        <p>@room.Name's price is @room.Price.</p>
    }
</div>
در اینجا یک حلقه‌ی تو در تو را مشاهده می‌کنید. حلقه‌ی بیرونی، ردیف‌های جدول را که شامل نام و قیمت هر اتاق است، به صورت input-boxهای متصل به خواص متناظر با آن‌ها نمایش می‌دهد. سپس برای اینکه بتوانیم خواص هر ردیف را نیز نمایش دهیم، حلقه‌ی دومی را بر روی room.RoomProps تشکیل داده‌ایم.
هدف از foreach پس از جدول، نمایش تغییرات انجام شده‌ی در input-boxها است. برای مثال اگر نام یک ردیف را تغییر دادیم، چون یک اتصال دو طرفه برقرار است، خاصیت متناظر با آن به روز رسانی شده و بلافاصله در برچسب‌های ذیل جدول، منعکس می‌شود.


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: Blazor-5x-Part-04.zip
مطالب
فعال سازی قسمت آپلود تصویر و فایل Kendo UI Editor
یکی دیگر از ویجت‌های Kendo UI یک HTML Editor کامل است به همراه امکانات ارسال فایل، تصویر و ... پشتیبانی از راست به چپ. در ادامه قصد داریم نحوه‌ی مدیریت نمایش لیست فایل‌ها، افزودن و حذف آن‌ها را از طریق این ادیتور بررسی کنیم.


تنظیمات ابتدایی Kendo UI Editor

در ذیل کدهای سمت کاربر فعال سازی مقدماتی Kendo UI را مشاهده می‌کنید. در قسمت tools آن، لیست امکانات و نوار ابزار مهیای آن درج شده‌اند.
دو مورد insertImage و insertFile آن نیاز به تنظیمات سمت کاربر و سرور بیشتری دارند.
<!--نحوه‌ی راست به چپ سازی -->
<div class="k-rtl">
    <textarea id="editor" rows="10" cols="30" style="height: 440px"></textarea>
</div>
 
@section JavaScript
{
    <script type="text/javascript">
        $(function () {
            $("#editor").kendoEditor({
                tools: [
                    "bold", "italic", "underline", "strikethrough", "justifyLeft",
                    "justifyCenter", "justifyRight", "justifyFull", "insertUnorderedList",
                    "insertOrderedList", "indent", "outdent", "createLink", "unlink",
                    "insertImage", "insertFile",
                    "subscript", "superscript", "createTable", "addRowAbove", "addRowBelow",
                    "addColumnLeft", "addColumnRight", "deleteRow", "deleteColumn", "viewHtml",
                    "formatting", "cleanFormatting", "fontName", "fontSize", "foreColor",
                    "backColor", "print"
                ],
                imageBrowser: {
                    messages: {
                        dropFilesHere: "فایل‌های خود را به اینجا کشیده و رها کنید"
                    },
                    transport: {
                        read: {
                            url: "@Url.Action("GetFilesList", "KendoEditorImages")",
                            dataType: "json",
                            contentType: 'application/json; charset=utf-8',
                            type: 'GET',
                            cache: false
                        },
                        destroy: {
                            url: "@Url.Action("DestroyFile", "KendoEditorImages")",
                            type: "POST"
                        },
                        create: {
                            url: "@Url.Action("CreateFolder", "KendoEditorImages")",
                            type: "POST"
                        },
                        thumbnailUrl: "@Url.Action("GetThumbnail", "KendoEditorImages")",
                        uploadUrl: "@Url.Action("UploadFile", "KendoEditorImages")",
                        imageUrl: "@Url.Action("GetFile", "KendoEditorImages")?path={0}"
                    }
                },
                fileBrowser: {
                    messages: {
                        dropFilesHere: "فایل‌های خود را به اینجا کشیده و رها کنید"
                    },
                    transport: {
                        read: {
                            url: "@Url.Action("GetFilesList", "KendoEditorFiles")",
                            dataType: "json",
                            contentType: 'application/json; charset=utf-8',
                            type: 'GET',
                            cache: false
                        },
                        destroy: {
                            url: "@Url.Action("DestroyFile", "KendoEditorFiles")",
                            type: "POST"
                        },
                        create: {
                            url: "@Url.Action("CreateFolder", "KendoEditorFiles")",
                            type: "POST"
                        },
                        uploadUrl: "@Url.Action("UploadFile", "KendoEditorFiles")",
                        fileUrl: "@Url.Action("GetFile", "KendoEditorFiles")?path={0}"
                    }
                }
            });
        });
    </script>
}
در اینجا نحوه‌ی تنظیم مسیرهای مختلف ارسال فایل و تصویر Kendo UI Editor را ملاحظه می‌کنید.
منهای قسمت thumbnailUrl، عملکرد قسمت‌های مختلف افزودن فایل و تصویر این ادیتور یکسان هستند. به همین جهت می‌توان برای مثال کنترلی مانند KendoEditorFilesController را ایجاد و سپس در کنترلر KendoEditorImagesController از آن ارث بری کرد و متد دریافت و نمایش بند انگشتی تصاویر را افزود. به این ترتیب دیگر نیازی به تکرار کدهای مشترک بین این دو قسمت نخواهد بود.


نمایش لیست پوشه‌ها و تصویر در ابتدای باز شدن صفحه‌ی درج تصویر

با کلیک بر روی دکمه‌ی نمایش لیست تصاویر، صفحه دیالوگی مانند شکل زیر ظاهر خواهد شد:


تنظیمات خواندن این فایل‌ها، از قسمت read مربوط به imageBrowser دریافت می‌شود که cache آن نیز به false تنظیم شده‌است تا در این بین مرورگر اطلاعات را کش نکند. این مورد در حین حذف فایل‌ها و پوشه‌ها مهم است. زیرا اگر cache:false تنظیم نشده باشد، حذف یک فایل یا پوشه در سمت کاربر تاثیری نخواهد داشت.
imageBrowser: {
    transport: {
        read: {
            url: "@Url.Action("GetFilesList", "KendoEditorImages")",
            dataType: "json",
            contentType: 'application/json; charset=utf-8',
            type: 'GET',
            cache: false
        }
    }
},
در ادامه نیاز است اکشن متد GetFilesList را به نحو ذیل در سمت سرور تهیه کرد:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpGet]
        public ActionResult GetFilesList(string path)
        {
            path = GetSafeDirPath(path);
            var imagesList = new DirectoryInfo(path)
                                .GetFiles()
                                .Select(fileInfo => new KendoFile
                                {
                                    Name = fileInfo.Name,
                                    Size = fileInfo.Length,
                                    Type = KendoFileType
                                }).ToList();
 
            var foldersList = new DirectoryInfo(path)
                                .GetDirectories()
                                .Select(directoryInfo => new KendoFile
                                {
                                    Name = directoryInfo.Name,
                                    Type = KendoDirType
                                }).ToList();
 
            return new ContentResult
            {
                Content = JsonConvert.SerializeObject(imagesList.Union(foldersList), new JsonSerializerSettings
                {
                    ContractResolver = new CamelCasePropertyNamesContractResolver()
                }),
                ContentType = "application/json",
                ContentEncoding = Encoding.UTF8
            };
        }
 
 
        protected string GetSafeDirPath(string path)
        {
            // path = مسیر زیر پوشه‌ی وارد شده
            if (string.IsNullOrWhiteSpace(path))
            {
                return Server.MapPath(FilesFolder);
            }
 
            //تمیز سازی امنیتی
            path = Path.GetDirectoryName(path);
            path = Path.Combine(Server.MapPath(FilesFolder), path);
            return path;
        } 
    }
}
در اینجا کدهای کلاس پایه KendoEditorFilesController را مشاهده می‌کنید. به این جهت فیلد FilesFolder آن protected تعریف شده‌است تا در کلاسی که از آن ارث بری می‌کند نیز قابل دسترسی باشد. سپس لیست فایل‌ها و پوشه‌های path دریافتی با فرمت لیستی از KendoFile تهیه شده و با فرمت JSON بازگشت داده می‌شوند. ساختار KendoFile را در ذیل مشاهده می‌کنید:
namespace KendoUI13.Models
{
    public class KendoFile
    {
        public string Name { set; get; }
        public string Type { set; get; }
        public long Size { set; get; }
    }
}
- در اینجا Type می‌تواند از نوع فایل با مقدار f و یا از نوع پوشه با مقدار d باشد.
- علت استفاده از CamelCasePropertyNamesContractResolver در حین بازگشت JSON نهایی، تبدیل خواص دات نتی، به نام‌های سازگار با JavaScript است. برای مثال به صورت خودکار Name را تبدیل به name می‌کند.
- پارامتر path در ابتدای کار خالی است. اما کاربر می‌تواند در بین پوشه‌های باز شده‌ی توسط مرورگر تصاویر Kendo UI حرکت کند. به همین جهت مقدار آن باید هربار بررسی شده و بر این اساس لیست فایل‌ها و پوشه‌های جاری بازگشت داده شوند.


مدیریت حذف تصاویر و پوشه‌ها

همانطور که در شکل فوق نیز مشخص است، با انتخاب یک پوشه یا فایل، دکمه‌ای با آیکن ضربدر جهت فراهم آوردن امکان حذف، ظاهر می‌شود. این دکمه متصل است به قسمت destroy تنظیمات ادیتور:
imageBrowser: {
    transport: {
        destroy: {
            url: "@Url.Action("DestroyFile", "KendoEditorImages")",
            type: "POST"
        }
    }
},
این تنظیمات سمت کاربر را باید به نحو ذیل در سمت سرور مدیریت کرد:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpPost]
        public ActionResult DestroyFile(string name, string path)
        {
            //تمیز سازی امنیتی
            name = Path.GetFileName(name);
            path = GetSafeDirPath(path);
 
            var pathToDelete = Path.Combine(path, name);
 
            var attr = System.IO.File.GetAttributes(pathToDelete);
            if ((attr & FileAttributes.Directory) == FileAttributes.Directory)
            {
                Directory.Delete(pathToDelete, recursive: true);
            }
            else
            {
                System.IO.File.Delete(pathToDelete);
            }
 
            return Json(new object[0]);
        } 
    }
}
- استفاده از Path.GetFileName جهت دریافت نام فایل‌ها در اینجا بسیار مهم است. زیرا اگر این تمیز سازی امنیتی صورت نگیرد، ممکن است با کمی تغییر در آن، فایل web.config برنامه، دریافت یا حذف شود.
- پارامتر name دریافتی مساوی است با نام فایل انتخاب شده و path مشخص می‌کند که در کدام پوشه قرار داریم.
- چون در اینجا امکان حذف یک پوشه یا فایل وجود دارد، حتما نیاز است بررسی کنیم، مسیر دریافتی پوشه‌است یا فایل و سپس بر این اساس جهت حذف آن‌ها اقدام صورت گیرد.


مدیریت ایجاد یک پوشه‌ی جدید

تنظیمات قسمت create مرورگر تصاویر، مرتبط است به زمانیکه کاربر با کلیک بر روی دکمه‌ی +، درخواست ایجاد یک پوشه‌ی جدید را کرده‌است:
imageBrowser: {
    transport: {
        create: {
            url: "@Url.Action("CreateFolder", "KendoEditorImages")",
            type: "POST"
        }
    }
},
کدهای اکشن متد متناظر با این عمل را در ذیل مشاهده می‌کنید:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpPost]
        public ActionResult CreateFolder(string name, string path)
        {
            //تمیز سازی امنیتی
            name = Path.GetFileName(name);
            path = GetSafeDirPath(path);
            var dirToCreate = Path.Combine(path, name);
 
            Directory.CreateDirectory(dirToCreate);
 
            return KendoFile(new KendoFile
            {
                Name = name,
                Type = KendoDirType
            });
        }
 
        protected ActionResult KendoFile(KendoFile file)
        {
            return new ContentResult
            {
                Content = JsonConvert.SerializeObject(file,
                    new JsonSerializerSettings
                    {
                        ContractResolver = new CamelCasePropertyNamesContractResolver()
                    }),
                ContentType = "application/json",
                ContentEncoding = Encoding.UTF8
            };
        }
    }
}
- در اینجا نیز name مساوی نام پوشه‌ی درخواستی است و path به مسیر تو در توی پوشه‌ی جاری اشاره می‌کند.
- پس از ایجاد پوشه، باید نام آن‌را با فرمت KendoFile به صورت JSON بازگشت داد. همچنین در اینجا Type را نیز باید به d (پوشه) تنظیم کرد.


مدیریت قسمت ارسال فایل و تصویر

زمانیکه کاربر بر روی دکمه‌ی upload file یا بارگذاری تصاویر در اینجا کلیک می‌کند، اطلاعات فایل آپلودی به مسیر uploadUrl ارسال می‌گردد.
imageBrowser: {
    transport: {
        thumbnailUrl: "@Url.Action("GetThumbnail", "KendoEditorImages")",
        uploadUrl: "@Url.Action("UploadFile", "KendoEditorImages")",
        imageUrl: "@Url.Action("GetFile", "KendoEditorImages")?path={0}"
    }
},
دو تنظیم دیگر thumbnailUrl و imageUrl، برای نمایش بند انگشتی و نمایش کامل تصویر کاربرد دارند.
در ادامه کدهای مدیریت سمت سرور قسمت آپلود این ادیتور را مشاهده می‌کنید:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";

 
        [HttpPost]
        public ActionResult UploadFile(HttpPostedFileBase file, string path)
        {
            //تمیز سازی امنیتی
            var name = Path.GetFileName(file.FileName);
            path = GetSafeDirPath(path);
            var pathToSave = Path.Combine(path, name);
 
            file.SaveAs(pathToSave);
 
            return KendoFile(new KendoFile
            {
                Name = name,
                Size = file.ContentLength,
                Type = KendoFileType
            });
        } 
    }
}
- در اینجا path مشخص می‌کند که در کدام پوشه‌ی تو در تو قرار داریم و file نیز حاوی محتوای ارسالی به سرور است.
- پس از ذخیره سازی اطلاعات فایل، نیاز است اطلاعات فایل نهایی را با فرمت KendoFile به صورت JSON بازگشت دهیم.


ارث بری از KendoEditorFilesController جهت تکمیل قسمت مدیریت تصاویر

تا اینجا کدهایی را که ملاحظه کردید، برای هر دو قسمت ارسال تصویر و فایل کاربرد دارند. قسمت ارسال تصاویر برای تکمیل نیاز به متد دریافت تصاویر به صورت بند انگشتی نیز دارد که به صورت ذیل قابل تعریف است و چون از کلاس پایه KendoEditorFilesController ارث بری کرده‌است، این کنترلر به صورت خودکار حاوی اکشن متدهای کلاس پایه نیز خواهد بود.
using System.Web.Mvc;
 
namespace KendoUI13.Controllers
{
    public class KendoEditorImagesController : KendoEditorFilesController
    {
        public KendoEditorImagesController()
        {
            // بازنویسی مسیر پوشه‌ی فایل‌ها
            FilesFolder = "~/images";
        }
 
        [HttpGet]
        [OutputCache(Duration = 3600, VaryByParam = "path")]
        public ActionResult GetThumbnail(string path)
        {
            //todo: create thumb/ resize image
 
            path = GetSafeFileAndDirPath(path);
            return File(path, "image/png");
        }
    }
}

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید.
مطالب
نوشتن TagHelperهای سفارشی برای ASP.NET Core
در مطلب «ارتقاء به ASP.NET Core 1.0 - قسمت 12 - معرفی Tag Helpers» با مفهوم جدید Tag Helpers و همچنین نحوه‌ی استفاده‌ی از نمونه‌های پیش فرض و توکار آن در ASP.NET Core آشنا شدیم. در ادامه قصد داریم با نحوه‌ی پیاده سازی نمونه‌های سفارشی آن‌ها نیز آشنا شویم.


نوشتن یک Tag Helper سفارشی، برای رندر کردن لیست‌های بوت استرپی

فرض کنید می‌خواهیم یک tag helper جدید را جهت رندر کردن لیست بوت استرپی ذیل تهیه کنیم:
<ul class="list-group"> 
  <li class="list-group-item">Item 1</li> 
  <li class="list-group-item">Item 2</li> 
  <li class="list-group-item">Item 3</li> 
</ul>
برای اینکار یک کتابخانه‌ی جدید را به پروژه‌ی جاری اضافه کرده و سپس وابستگی‌های ذیل را نیز به آن اضافه می‌کنیم. این‌ها حداقل‌هایی هستند که جهت دسترسی به امکانات MVC و Tag Helpers، در یک پروژه‌ی مجزای Class library نیاز داریم:
{
  "version": "1.0.0-*",
 
    "dependencies": {
        "NETStandard.Library": "1.6.0",
        "Microsoft.AspNetCore.Http.Extensions": "1.0.0",
        "Microsoft.AspNetCore.Mvc.Abstractions": "1.0.1",
        "Microsoft.AspNetCore.Mvc.Core": "1.0.1",
        "Microsoft.AspNetCore.Mvc.ViewFeatures": "1.0.1",
        "Microsoft.AspNetCore.Razor.Runtime": "1.0.0"
    },
 
  "frameworks": {
    "netstandard1.6": {
      "imports": "dnxcore50"
    }
  }
}


بررسی آناتومی یک کلاس TagHelper

یک کلاس Tag Helper سفارشی، در حالت کلی می‌تواند شکل زیر را داشته باشد:
namespace Core1RtmEmptyTest.TagHelpers
{
    [HtmlTargetElement("list-group")]
    public class ListGroupTagHelper : TagHelper
    {
        [HtmlAttributeName("asp-items")]
        public List<string> Items { get; set; }
 
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
        }
    }
}
در اینجا نام کلاس، به TagHelper ختم می‌شود و همچنین این کلاس از کلاس پایه‌ی TagHelper ارث بری می‌کند. ذکر HtmlTargetElement الزامی بوده و در صورت عدم تعریف آن، TagHelper تعریف شده توسط ASP.NET Core شناسایی و بارگذاری نخواهد شد.
توسط HtmlTargetElement نام نهایی تگ مرتبط با TagHelper سفارشی را تعریف و سفارشی سازی کرده‌ایم. در این حالت این TagHelper جدید در Viewهای برنامه، توسط تگ ذیل شنایی می‌شود (بجای نام پیش فرض کلاس):
 <list-group></list-group>
همچنین در اینجا، یک خاصیت عمومی نیز تعریف شده‌است. تمام خواص عمومی تعریف شده‌ی در اینجا به صورت ویژگی‌هایی در تگ نهایی TagHelper قابل دسترسی و مقدار دهی خواهند بود:
 <list-group asp-items="Model.Items"></list-group>
 برای لغو این حالت می‌توان از ویژگی HtmlAttributeNotBound استفاده کرد.
برای اینکه نام این ویژگی را نیز بتوانیم سفارشی سازی کنیم، می‌توان از ویژگی HtmlAttributeName استفاده کرد. در صورت عدم ذکر آن، از نام پیش فرض این خاصیت عمومی جهت تعریف ویژگی‌های تگ نهایی استفاده می‌گردد.
عملیات نهایی افزودن تگ‌های HTML، به View برنامه، در متد Process انجام می‌شود. در اینجا توسط متدهایی مانند output.Content.AppendHtml می‌توان خروجی دلخواهی را به صفحه اضافه کرد.


تکمیل کدهای Tag Helper سفارشی رندر کردن لیست‌های بوت استرپی

پس از آشنایی با ساختار کلی یک کلاس TagHelper، اکنون می‌توان کدهای آن را به نحو ذیل تکمیل کرد:
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
 
namespace Core1RtmEmptyTest.TagHelpers
{
    [HtmlTargetElement("list-group")]
    public class ListGroupTagHelper : TagHelper
    {
        [HtmlAttributeName("asp-items")]
        public List<string> Items { get; set; }
 
        protected HttpRequest Request => ViewContext.HttpContext.Request;
 
        [ViewContext, HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }
 
            if (output == null)
            {
                throw new ArgumentNullException(nameof(output));
            }
 
            if (Items == null)
            {
                throw new InvalidOperationException($"{nameof(Items)} must be provided");
            }
 
            output.TagName = "ul";
            output.TagMode = TagMode.StartTagAndEndTag;
            output.Attributes.Add("class", "list-group");
 
            foreach (var item in Items)
            {
                TagBuilder itemBuilder = new TagBuilder("li");
                itemBuilder.AddCssClass("list-group-item");
                itemBuilder.InnerHtml.Append(item);
                output.Content.AppendHtml(itemBuilder);
            }
        }
    }
}
توضیحات:
 - چون می‌خواهیم تگ نهایی آن، list-group نام داشته باشد، آن‌را توسط ویژگی HtmlTargetElement به صورت صریحی مشخص کرده‌ایم.
 - همچنین علاقمندیم تا ویژگی دریافت لیست آیتم‌ها، نامی معادل asp-items داشته باشد. بنابراین آن‌را نیز توسط ویژگی HtmlAttributeName، دقیقا مشخص کرده‌ایم.
 - در این کلاس، یک خاصیت اضافه‌ی ViewContext را نیز مشاهده می‌کنید. ویژگی ViewContext اعمالی به آن، سبب خواهد شد تا اطلاعات درخواست جاری، به این خاصیت عمومی، به صورت خودکار تزریق شود. بنابراین اگر نیاز به اطلاعاتی مانند Request جاری دارید، شیء ViewContext.HttpContext.Request، این مقادیر را در اختیار شما قرار می‌دهد. به علاوه اگر دقت کرده باشید، این خاصیت با ویژگی HtmlAttributeNotBound مزین شده‌است. از این جهت که نمی‌خواهیم این خاصیت عمومی، در لیست ویژگی‌های تگ نهایی TagHelper در حال تهیه، ظاهر شود.
 - پس از آن کاری که انجام شده، تکمیل متد Process است. در اینجا توسط output.TagName مشخص می‌کنیم که TagHelper جاری، در بین تگ‌های ul قرار گیرد (مفهوم TagMode.StartTagAndEndTag ذکر شده) و همچنین این تگ محصور کننده دارای کلاس list-group بوت استرپ نیز خواهد بود.
 - سپس بر روی لیست آیتم‌های دریافت شده، یک حلقه را تشکیل داده و به کمک TagBuilder، تگ‌های li داخل ul برونی را تکمیل می‌کنیم. این TagBuilder در نهایت توسط متد output.Content.AppendHtml به View برنامه اضافه خواهد شد. در اینجا، متد Append هم وجود دارد. اگر از آن استفاده شود، خروجی نهایی HTML Encoded خواهد بود.
 

ثبت و استفاده‌ی از TagHelper سفارشی تهیه شده

پس از تعریف TagHelper سفارشی فوق، ابتدا ارجاعی از اسمبلی آن‌را به پروژه‌ی جاری اضافه می‌کنیم و سپس به فایل ViewImports.cshtml_ برنامه مراجعه و یک سطر ذیل را به آن اضافه می‌کنیم:
 @addTagHelper *,Core1RtmEmptyTest.TagHelpers
در اینجا عبارت Core1RtmEmptyTest.TagHelpers همان نام فضای نام اصلی پروژه‌ی Class library دربرگیرنده‌ی TagHelper سفارشی است.
اکنون که این TagHelper در Viewهای برنامه قابل شناسایی است، روش افزودن آن، بر اساس همان سفارشی سازی‌هایی است که انجام دادیم: