مطالب
پیاده سازی یک MediaTypeFormatter برای پشتیبانی از MultiPart/form-data در Web API

Media Type یا MIME Type نشان دهنده فرمت یک مجموعه داده است. در HTTP، مدیا تایپ بیان کننده فرمت message body یک درخواست / پاسخ است و به دریافت کننده اعلام می‌کند که چطور باید پیام را بخواند. محل استاندارد تعیین Mime Type در هدر Content-Type است. درخواست کننده می‌تواند با استفاده از هدر Accept لیستی از MimeType‌های قابل قبول را به عنوان پاسخ، به سرور اعلام کند.

Asp.net Web API  از MimeType برای تعیین نحوه serialize یا deserialize کردن محتوای دریافتی / ارسالی استفاده می‌کند


MediaTypeFormatter

Web API برای خواندن/درج پیام در بدنه درخواست/پاسخ از MediaTypeFormmater‌‌ها استفاده می‌کند. اینها کلاس‌هایی هستند که نحوه‌ی Serialize کردن و deserialize کردن اطلاعات به فرمت‌های خاص را تعیین می‌کنند. Web API به صورت توکار دارای formatter هایی برای نوع‌های XML ، JSON، BSON و Form-UrlEncoded می‌باشد. همه این‌ها کلاس پایه MediaTypeFormatter را پیاده سازی می‌کنند. 


مسئله

یک پروژه Web API بسازید و view model زیر را در آن تعریف کنید:

public class NewProduct
    {
        [Required]
        public string Name { get; set; }

        public double Price { get; set; }

        public byte[] Pic { get; set; }
    }

همانطور که می‌بینید یک فیلد از نوع byte[] برای تصویر محصول در نظر گرفته شده است.

حالا یک کنترلر API  ساخته و اکشنی برای دریافت اطلاعات محصول جدید از کاربر می‌نویسیم :
public class ProductsController : ApiController
    {
        [HttpPost]
        public HttpResponseMessage PostProduct(NewProduct model)
        {
            if (ModelState.IsValid)
            {
                //  ثبت محصول 

                return new HttpResponseMessage(HttpStatusCode.Created);
            }

            return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
        }

    }

و یک صفحه html به نام index.html که حاوی یک فرم برای ارسال اطلاعات باشد :

<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>
<body>
    <h1>ساخت MediaTypeFormatter برای Multipart/form-data</h1>
    <h2>محصول جدید</h2>

    <form id="newProduct" method="post" action="/api/products" enctype="multipart/form-data">
        <div>
            <label for="name">نام محصول : </label>
            <input type="text" id="name" name="name" />
        </div>
        <div>
            <label for="price">قیمت : </label>
            <input type="number" id="price"  name="price" />
        </div>
        <div>
            <label for="pic">تصویر : </label>
            <input type="file" id="pic" name="pic" />
        </div>
        <div>
            <button type="submit">ثبت</button>
        </div>
    </form>
</body>
</html>

زمانی که فرم حاوی فایلی برای آپلود باشد مشخصه encType باید برابر با Multipart/form-data مقداردهی شود تا اطلاعات فایل به درستی کد شوند. در زمان ارسال فرم Content-type درخواست برابر با Multipart/form-data و فرمت اطلاعات درخواست ارسالی به شکل زیر خواهد بود :


همانطور که می‌بینید هر فیلد در فرم، در یک بخش جداگانه قرار گرفته است که با خط چین هایی از هم جدا شده اند. هر بخش، header‌های جداگانه خود را دارد.

- Content-Disposition که نام فیلد و نام فایل را شامل می‌شود .

- content-type که mime type مخصوص آن بخش از داده‌ها را مشخص می‌کند.

پس از اینکه فرم را تکمیل کرده و ارسال کنید ، با پیام خطای زیر مواجه می‌شوید :

خطای روی داده اعلام می‌کند که Web API فاقد MediaTypeFormatter برای خواندن اطلاعات ارسال شده با فرمتMultiPart/Form-data  است.  Web API برای خواندن و بایند کردن پارامترهای complex Type از درون بدنه پیام یک درخواست از MediaTypeFormatter استفاده می‌کند و همانطور که گفته شد Web API فاقد Formatter توکار برای deserialize کردن داده‌های با فرمت Multipart/form-data است.

راه حل‌ها :

روشی که در سایت asp.net برای آپلود فایل در web api استفاده شده، عدم استفاده از پارامترها و خواندن محتوای Request در درون کنترلر است. که به طبع در صورتی که بخواهیم کنترلرهای تمیز و کوچکی داشته باشیم روش مناسبی نیست. از طرفی امتیاز parameter binding و modelstate را هم از دست خواهیم داد.

روش دیگری که می‌خواهیم در اینجا پیاده سازی کنیم ساختن یک MediaTypeFormatter برای خواندن فرمت Multipart/form-data است. با این روش کد موردنیاز کپسوله شده و امکان استفاده از binding و modelstate را خواهیم داشت.

برای ساختن یک MediaTypeFormatter یکی از 2 کلاس MediaTypeFormatter یا  BufferedMediaTypeFormatter  را باید پیاده سازی کنیم . تفاوت این دو در این است که BufferedMediaTypeFormatter برخلاف MediaTypeFormatter از متدهای synchronous استفاده می‌کند.

 
پیاده سازی :

یک کلاس به نام MultiPartMediaTypeFormatter می‌سازیم و کلاس MediaTypeFormatter را به عنوان کلاس پایه آن قرار می‌دهیم .

public class MultiPartMediaTypeFormatter : MediaTypeFormatter
{
    ...
}

ابتدا در تابع سازنده کلاس فرمت هایی که می‌خواهیم توسط این کلاس خوانده شوند را تعریف می‌کنیم :

        public MultiPartMediaTypeFormatter()
        {
            SupportedMediaTypes.Add(new MediaTypeHeaderValue("multipart/form-data"));
        }
در اینجا Multipart/form-data را به عنوان تنها نوع مجاز تعریف کرده ایم.

سپس با پیاده سازی توابع CanReadType و CanWriteType مربوط به کلاس MediaTypeFormatter مشخص می‌کنیم که چه مدل‌هایی را می‌توان توسط این کلاس  serialize / deserialize کرد. در اینجا چون می‌خواهیم این کلاس محدود به یک مدل خاص نباشد، از یک اینترفیس برای شناسایی کلاس‌های مجاز استفاده می‌کنیم .

    public interface INeedMultiPartMediaTypeFormatter
    {
    }

و آنرا به کلاس newProduct اضافه می‌کنیم :

public class NewProduct : INeedMultiPartMediaTypeFormatter
{
 ...
}
از آنجا که تنها نیاز به خواندن اطلاعات داریم و قصد نوشتن نداریم، در متد CanWriteType مقدار false را برمی گردانیم .
        public override bool CanReadType(Type type)
        {
            return typeof(INeedMultiPartMediaTypeFormatter).IsAssignableFrom(type);
        }

        public override bool CanWriteType(Type type)
        {
            return false;
        }

و اما تابع ReadFromStreamAsync که کار خواندن محتوای ارسال شده و بایند کردن آنها به پارامترها را برعهده دارد 

public async override Task<object> ReadFromStreamAsync(Type type, Stream stream, HttpContent content, IFormatterLogger formatterLogger)
که در آن پارامتر type مربوط به مدل مشخص شده به عنوان پارامتر اکشن (NewProduct)است و پارامتر content محتوای درخواست را در خود دارد.

ابتدا محتوای ارسال شده را خوانده و اطلاعات فرم را استخراج می‌کنیم و از طرف دیگر با استفاده از کلاس Activator یک نمونه از مدل جاری را ساخته و لیست property‌های آنرا استخراج می‌کنیم.

            MultipartMemoryStreamProvider provider = await content.ReadAsMultipartAsync();
            IEnumerable<HttpContent> formData = provider.Contents.AsEnumerable();

            var modelInstance = Activator.CreateInstance(type);
            IEnumerable<PropertyInfo> properties = type.GetProperties();

سپس در یک حلقه به ترتیب برای هر property متعلق به مدل، در میان اطلاعات فرم جستجو می‌کنیم. برای پیدا کردن اطلاعات متناظر با هر property در هدر Content-Disposition که در بالا توضیح داده شد، به دنبال فیلد همنام با property می‌گردیم .

            foreach (PropertyInfo prop in properties)
            {
                var propName = prop.Name.ToLower();
                var propType = prop.PropertyType;

                var data = formData.FirstOrDefault(d => d.Headers.ContentDisposition.Name.ToLower().Contains(propName));
در صورتی که فیلدی وجود داشته باشد کار را ادامه می‌دهیم .

 گفتیم که هر فیلد یک هدر، Content-Type هم می‌تواند داشته باشد. این هدر به صورت پیش فرض معادل text/plain است و برای فیلدهای عادی قرار داده نمی‌شود . در این مثال چون فقط یک فیلد غیر رشته ای داریم فرض را بر این گرفته ایم که در صورت وجود Content-Type، فیلد مربوط به تصویر است. در صورتیکهContentType  وجود داشته باشد، محتوای فیلد را به شکل Stream خوانده به byte[] تبدیل و با استفاده از متد SetValue در property مربوطه قرار می‌دهیم.

                if (data != null)
                {
                    if (data.Headers.ContentType != null)
                    {
                        using (var fileStream = await data.ReadAsStreamAsync())
                        {
                            using (MemoryStream ms = new MemoryStream())
                            {
                                fileStream.CopyTo(ms);
                                prop.SetValue(modelInstance, ms.ToArray());
                            }                            
                        }
                    }

در صورتی که Content-Type غایب باشد بدین معنی است که محتوای فیلد از نوع رشته است ( عدد ، تاریخ ، guid ، رشته ) و باید به نوع مناسب تبدیل شود. ابتدا آن را به صورت یک رشته می‌خوانیم و با استفاده از Convert.ChangeType آنرا به نوع مناسب تبدیل می‌کنیم و در property متناظر قرار می‌دهیم .

                if (data != null)
                {
                    if (data.Headers.ContentType != null)
                    {
                        //...
                    }
                    else
                    {
                        string rawVal = await data.ReadAsStringAsync();
                        object val = Convert.ChangeType(rawVal, propType);

                        prop.SetValue(modelInstance, val);
                    }
                }
و در نهایت نمونه ساخته شده از مدل را برگشت می‌دهیم.
return modelInstance;
برای فعال کردن این Formatter باید آنرا به لیست formmater‌های web api اضافه بکنیم. فایل WebApiConfig در App_Start را باز کرده و خط زیر را به آن اضافه می‌کنیم:
config.Formatters.Add(new MultiPartMediaTypeFormatter());
حال اگر مجددا فرم را به سرور ارسال کنیم، با پیام خطایی، مواجه نشده و عمل binding با موفقیت انجام می‌گیرد.

مطالب
قسمت اول - ساخت گزارش در محیط Telerik Reporting
یکی از ضروریات نرم افزارها وجود گزارشات مختلف در قالب لیست‌ها ، نمودارها و ... در آنها می‌باشد.یک نرم افزار خوب باید توانایی ارائه گزارشات خوب و زیبا را نیز داشته باشد.گزارشات در حقیقت نمایشی از داده‌ها هستند که عموما به چاپ می‌رسند. مورد دیگری که در خصوص گزارشات حائز اهمیت می‌باشد ، تبدیل آنها به فرمت‌های مختلف جهت حمل و جابه جایی آسان از سیستمی به سیستم دیگر می‌باشد. برای مثال تبدیل گزارشات به قالب Pdf مزایایی بسیاری از نظر قابل حمل بودن در پی خواهد داشت. در برنامه نویسی دات نت گزارشات را می‌توان توسط دستورات چاپ در دات تهیه ، نمایش و چاپ نمود. اما تهیه گزارش توسط دستورات دات نت کاری مشکل و طاقت فرسا می‌باشد. همچنین امکان تبدیل این گزارشات به فرمت‌های دیگر نظیر Pdf ، به راحتی انجام نمی‌شود و باید از کلاس‌ها و ابزارهای جانبی که برای این کار تهیه شده اند استفاده نمود . از این رو ابزارهای مختلفی در جهت تهیه گزارشات به وجود آمدند.ابزارهایی نظیر :

• Crystal Report
• Stimul Report
• Telerik Reporting
• … 

در ادامه این سری آموزش‌ها قصد داریم Telerik Reporting و نحوه تهیه گزارش با آن را مورد بررسی قرار دهیم. این ابزار امکانات بسیاری در خصوص تهیه گزارش برنامه‌های دات نتی نظیر Windows Form ، Asp.net و ... در اختیار ما قرار می‌دهد.در ادامه و برای شروع ، ساخت یک گزارش ساده در این محیط را بررسی میکنیم.

 
 نکته : گزارشاتی که توسط Telerik Reporting تهیه می‌شوند به وسیله کدهای C# جنریت می‌شوند.بنابراین همیشه توصیه می‌شود گزارشات خود را درون یک یا چند پروژه Class Library قرار دهیم و از این پس ، این گزارشات از درون پروژه‌های دیگر (ویندوزی ، وب و ...) در دسترس هستند.کافی ست پروژه Class Library را به عنوان Reference به پروژه مورد نظر خود اضافه کنیم..  برای شروع می‌توان یک پروژه جدید  از نوع Class Library  ایجاد کرد.پس از آن روی نام پروژه راست کلیک کنید و گزینه Telerik Report را انتخاب نمایید.پس از تعیین نام گزارش کلید Ok را کلیک نمایید.

انتخاب گزینه Telerik Reporting از پنجره New Item


در این حالت فایل گزارش به پروژه افزوده می‌شود. در ادامه می‌توانید توسط ویزاردی که نمایش داده می‌شود کارهای عمومی مربوط به پیاده سازی گزارش (انتخاب منبع داده(Data Source) ، ساخت Query جهت بارگذاری اطلاعات ، فیلدهایی که باید نمایش داده شوند ، گروه بندی داده‌ها و ...) را توسط این ویزارد انجام دهید. برای اینکار در پنجره ای که نمایش داده می‌شود بر روی کلید Next کلیک نمایید.
جهت ایجاد یک گزارش جدید در پنجره  Report Choose Page گزینه New Report را انتخاب نموده و کلید Next را کلیک نمایید.
جهت انتخاب منبع داده گزارش در پنجره Choose Data Source گزینه Add New Data Source را انتخاب نمایید.در این حالت می‌توانید گزینه‌های متفاوتی را به عنوان منبع داده گزارش خود انتخاب نمایید. گزینه‌های نمایش داده شده به شرح ذیل است:
• Sql Data Source : جهت اتصال مستقیم به بانک اطلاعات Microsoft Sql Server
• Object Data Source :  جهت اتصال به کلاس‌های لایه Business و بارگذاری داده از این کلاس ها
• Entity Data Source : جهت اتصال به  Entity Framework
• Open Access Data Source : جهت اتصال به Open Access ORM ساخت شرکت Telerik
• Cube Data Source : جهت اتصال و نمایش داده‌های تحلیل شده
  در ادامه برای اینکه بتوان مستقیما به Sql Server وصل شد و Query‌های مربوط به گزارش را روی آن اجرا نمود؛ می‌توان گزینه Sql Data Source را انتخاب نمود و بر روی کلید Ok کلیک کرد.سپس در پنجره Choose Your Data Connection گزینه New Connection را کلیک کنید و یک اتصال به بانک مورد نظر خود ایجاد کنید.پس ایجاد و تست Connection ساخته شده روی Next  کلیک کنید.در پنجره Save the connection string می‌توان نامی را جهت Connection string انتخاب کرد تا Connection string با همان نام در فایل Config پروژه ذخیره شود.در ادامه کلید Next را کلیک کرده و وارد مرحله بعد شوید. در پنجره Configure Data Source Command گزینه Query Builder را جهت ساخت Query مورد نظر برای بارگذاری داده‌ها انتخاب نمایید.

انتخاب جداول جهت ساخت Query


پنجره ساخت Query

پس از ساخت Query مورد نظر کلید Ok را کلیک نمایید. در پنجره Configure Data Source Command کوئری ساخته شده به شما نمایش داده می‌شود.کلید Next را کلیک کنید. 

سپس وارد مرحله Preview Data Source Result می‌شوید که در آن قادر خواهید بود پیش نمایشی از داده هایی که بعدا توسط Query ساخته شده بارگذاری خواهند شد را مشاهده نمایید. Next را کلیک نموده تا وارد مرحله بعد شوید.مرحله بعد Standard Report Type می‌باشد که در این مرحله شما می‌توانید نوع گزارش خود را انتخاب نمایید و کلید Next را فشار دهید.در بخش Design Data Layout چند فیلد را از بخش سمت چپ (Available Fields) انتخاب نموده و کلید Details را کلیک نمایید.فیلدهای انتخاب شده به بخش Details گزارش اضافه خواهند شد.در ادامه Next را کلیک کنید تا وارد بخش Choose Report Layout شوید.شما می‌توانید در این بخش یک حالت نمایشی را برای گزارش خود انتخاب نمایید و Next را کلیک نمایید.در بخش Choose Report Style یک قالب بندی جهت گزارش خود انتخاب نمایید.در ادامه Next و سپس Finish را کلیک نمایید.کدهای گزارش Generate شده می‌توان در قسمت Designer گزارش را مشاهده نمود. 

در این حالت کارهای زیر توسط Wizard به صورت اتوماتیک انجام خواهد شد:
• بایند شدن اتوماتیک فیلدهای گزارش به ستوان‌های مرتبط
• اعمال قالب بندی انتخاب شده برای صفحه و سر ستونها
• افزودن تاریخ و شماره صفحه به پایین گزارش
در ادامه پروژه را Rebuild کرده و گزینه Preview را در Designer جهت نمایش ، پیش نمایش گزارش کلیک نمایید.

نکته : در هر برنامه‌ی گزارش سازی بخش Designer گزارش به 4 بخش کلی تقسیم می‌شود:
•  Report Header: مواردی که در این بخش از گزارش قرار میگیرند در بالای صفحه اول گزارش نمایش داده می‌شوند.
•  Page Header: مواردی که در این بخش از گزارش قرار میگیرند در بالای همه صفحات گزارش قرار گرفته و تکرار می‌شوند.
•  Details: داده‌های اصلی گزارش که شامل جزئیات و بخش اصلی گزارش می‌باشند و سطر به سطر نیز تکرار می‌شوند در این بخش قرار می‌گیرند.
•  Page Footer: مواردی که در این بخش از گزارش قرار میگیرند در پایین همه صفحات نمایش داده می‌شوند.
•  Report Footer:مواردی که در این بخش قرار می‌گیرند در پایین صفحه آخر گزارش نمایش داده می‌شوند.  

ادامه دارد ...
مطالب
React 16x - قسمت 16 - مسیریابی - بخش 2 - پارامترهای مسیریابی
در قسمت قبل، نحوه‌ی برپایی و تنظیمات اولیه‌ی کتابخانه‌ی مسیریابی react-router-dom را بررسی کردیم. در ادامه نحوه‌ی دریافت پارامترهای مسیریابی و سایر جزئیات این کتابخانه را مرور می‌کنیم.


دریافت پارامترهای مسیریابی

گاهی از اوقات نیاز به ارسال پارامترهایی به مسیریابی‌های تعریف شده‌است. برای مثال در لیست محصولات تعریف شده، بسته به انتخاب هر کدام، باید id متناظری نیز در URL نهایی ظاهر شود. به این Id، یک Route parameter گفته می‌شود. برای پیاده سازی این نیازمندی به صورت زیر عمل می‌کنیم:
در فایل app.js، یک مسیریابی جدید را برای نمایش جزئیات یک محصول اضافه می‌کنیم:
import ProductDetails from "./components/productDetails";
// ...
class App extends Component {
  render() {
    return (
      <div>
        <NavBar />
        <div className="container">
          <Switch>
            <Route path="/products/:id" component={ProductDetails} />
            <Route
              path="/products"
              render={props => (
                <Products param1="123" param2="456" {...props} />
              )}
            />
            <Route path="/posts/:year/:month" component={Posts} />
            <Route path="/admin" component={Dashboard} />
            <Route path="/" component={Home} />
          </Switch>
        </div>
      </div>
    );
  }
}
در اینجا برای تعریف یک پارامتر مسیریابی، آن‌را با : شروع می‌کنیم؛ مانند id:، در مسیریابی جدید فوق. البته امکان تعریف چندین پارامتر هم در اینجا وجود دارد؛ مانند تعریف پارامترهای سال و ماه برای مسیریابی مطالب. به علاوه چون این نوع مسیریابی‌ها ویژه‌تر هستند، باید در ابتدا قرارگیرند. برای مثال اگر مسیریابی products/ را در اول لیست قرار دهیم، دیگر کار به انتخاب products/:id/ نخواهد رسید.

کامپوننت جدید src\components\productDetails.jsx نیز به صورت زیر تعریف شده‌است:
import React, { Component } from "react";

class ProductDetails extends Component {
  handleSave = () => {
    // Navigate to /products
  };

  render() {
    return (
      <div>
        <h1>Product Details - </h1>
        <button className="btn btn-primary" onClick={this.handleSave}>
      </div>
    );
  }
}

export default ProductDetails;
پس از این تغییرات و ذخیره سازی برنامه، با بارگذاری مجدد برنامه در مرورگر، ابتدا صفحه‌ی products را از منوی راهبری سایت انتخاب کرده و سپس بر روی یکی از محصولات لیست شده کلیک می‌کنیم. سپس در افزونه‌ی react developer tools، کامپوننت نمایش داده شده‌ی ProductDetails را انتخاب می‌کنیم:


در اینجا با گشودن اطلاعات خاصیت match تزریق شده‌ی به کامپوننت ProductDetails، می‌توان اطلاعاتی مانند پارامترهای دریافتی مسیریابی را دقیقا مشاهده کرد. برای مثال در این تصویر id=1 از URL بالای صفحه که به http://localhost:3000/products/1 تنظیم شده‌است، دریافت می‌شود.
بنابراین امکان خواندن اطلاعات پارامترهای مسیریابی، توسط شیء match تزریق شده‌ی به یک کامپوننت وجود دارد. به همین جهت کامپوننت ProductDetails را ویرایش کرده و المان h1 آن‌را جهت نمایش id محصول به صورت زیر تغییر می‌دهیم که در آن شیء match.params، از props کامپوننت تامین می‌شود:
<h1>Product Details - {this.props.match.params.id} </h1>

برای آزمایش آن مجددا از صفحه‌ی products شروع کرده و بر روی لینک یکی از محصولات، کلیک کنید. در اینجا هرچند id محصول به درستی نمایش داده می‌شود، اما ... نمایش جزئیات آن به همراه بارگذاری کامل و مجدد صفحه‌ی آن است که از حالت SPA خارج شده‌است. برای رفع این مشکل به کامپوننت products مراجعه کرده و anchor‌های تعریف شده را همانطور که در قسمت قبل نیز بررسی کردیم، تبدیل به کامپوننت Link می‌کنیم.
از حالت قبلی:
{this.state.products.map(product => (
  <li key={product.id}>
    <a href={`/products/${product.id}`}>{product.name}</a>
  </li>
))}
به حالت جدید:
import { Link } from "react-router-dom";
// ...

<Link to={`/products/${product.id}`}>{product.name}</Link>
با این تغییر دیگر در حین نمایش یک کامپوننت، بارگذاری کامل صفحه رخ نمی‌دهد.


پارامترهای اختیاری مسیریابی

به تعریف مسیریابی زیر، دو پارامتر سال و ماه، اضافه شده‌اند:
<Route path="/posts/:year/:month" component={Posts} />
و برای مثال اگر بر روی لینک posts در منوی راهبری کلیک کنیم، آدرسی مانند http://localhost:3000/posts/2018/06 ایجاد شده و سپس کامپوننت Posts رندر می‌شود. حال اگر پارامتر ماه را حذف کنیم http://localhost:3000/posts/2018 چه اتفاقی رخ می‌دهد؟ در این حالت برنامه کامپوننت Home را نمایش خواهد داد. علت اینجا است که پارامترهای تعریف شده‌ی در مسیریابی، به صورت پیش‌فرض اجباری هستند. به همین جهت URL وارد شده، چون با الگوی تعریفی Route فوق بدلیل نداشتن قسمت ماه، انطباق نیافته و تنها مسیریابی / که کامپوننت Home را نمایش می‌دهد، با آن تطابق یافته‌است.
برای رفع این مشکل می‌توان با اضافه کردن یک ? به هر پارامتر، پارامترهای تعریف شده را اختیاری کرد:
<Route path="/posts/:year?/:month?" component={Posts} />
در regexهای جاوا اسکریپتی زمانیکه یک ? را به یک عبارت باقاعده اضافه می‌کنیم، یعنی آن عبارت اختیاری است.

با این تغییرات اگر مجددا آدرس http://localhost:3000/posts/2018 را درخواست کنیم، کامپوننت Posts بجای کامپوننت Home نمایش داده می‌شود.

اکنون کامپوننت Posts را به صورت زیر تغییر می‌دهیم تا پارامترهای مسیریابی را نیز درج کند:
import React from "react";

const Posts = ({ match }) => {
  return (
    <div>
      <h1>Posts</h1>
      Year: {match.params.year} , Month: {match.params.month}
    </div>
  );
};

export default Posts;
پارامتر ({ match }) در اینجا به این معنا است که شیء props ارسالی به آن، توسط Object Destructuring تجزیه شده و خاصیت match آن در اینجا به صورت یک پارامتر در اختیار کامپوننت بدون حالت تابعی قرار گرفته‌است.

پس از ذخیره سازی این تغییرات و بارگذاری مجدد برنامه در مرورگر، اگر آدرس http://localhost:3000/posts/2018/1 را وارد کنیم، خروجی زیر حاصل می‌شود:



کار با پارامترهای کوئری استرینگ‌های مسیریابی

پارامترهای اختیاری، جزو قابلیت‌هایی هستند که باید تا حد ممکن از بکارگیری آن‌ها اجتناب و آن‌ها را با کوئری استرینگ‌ها تعریف کرد. کوئری استرینگ‌ها با یک ? در انتهای URL شروع می‌شوند و می‌توانند چندین پارامتر را داشته باشند؛ مانند: http://localhost:3000/posts?sortBy=newest&approved=true و یا حتی می‌توان آن‌ها را با پارامترهای اختیاری نیز ترکیب کرد مانند: http://localhost:3000/posts/2018/05?sortBy=newest&approved=true
برای استخراج کوئری استرینگ‌ها در برنامه‌های React باید از شیء location استفاده کرد:


در اینجا مقدار خاصیت search، کل قسمت کوئری استرینگ‌ها را به همراه دارد. البته ما قصد پردازش آن‌را به صورت دستی نداریم. به همین جهت از کتابخانه‌ی زیر برای انجام اینکار استفاده خواهیم کرد:
> npm i query-string --save
پس از نصب کتابخانه‌ی بسیار معروف query-string، به کامپوننت Posts مراجعه کرده و تغییرات زیر را اعمال می‌کنیم:
import queryString from "query-string";

const Posts = ({ match, location }) => {
  const result = queryString.parse(location.search);
  console.log(result);
  // ...
- پیشتر ذکر پارامتر ({ match }) را بررسی کردیم. در اینجا خاصیت location نیز به آن اضافه شده‌است تا پس از Object Destructuring شیء props ارسالی به کامپوننت، بتوان به مقدار شیء location نیز دسترسی یافت.
- سپس شیء queryString را از ماژول مرتبط، import می‌کنیم. در ادامه به کمک متد parse آن، می‌توان location.search را آنالیز کرد که خروجی آن، یک شیء جاوا اسکریپتی به صورت زیر است:
{approved: "true", sortBy: "newest"}
بنابراین در اینجا هم می‌توان توسط Object Destructuring، به این خواص دسترسی یافت:
 const { approved, sortBy } = queryString.parse(location.search);

یک نکته: باید دقت داشت که کتابخانه‌ی query-string، همیشه مقادیر خواص را رشته‌ای بازگشت می‌دهد؛ حتی اگر عدد باشند.


مدیریت مسیرهای نامعتبر درخواستی

فرض کنید کاربری آدرس غیرمعتبر http://localhost:3000/xyz را که هیچ نوع مسیریابی را برای آن تعریف نکرده‌ایم، درخواست می‌کند. در این حالت برنامه کامپوننت home را رندر می‌کند که مرتبط است با تعاریف مسیریابی برنامه در فایل app.js. تنها path تعریف شده‌ای که با این آدرس تطابق پیدا می‌کند، / متناظر با کامپوننت home است.
بجای این رفتار پیش‌فرض، مایل هستیم که کاربر به یک صفحه‌ی سفارشی «پیدا نشد» هدایت شود. به همین جهت ابتدا کامپوننت جدید تابعی بدون حالت src\components\notFound.jsx را با محتوای زیر ایجاد می‌کنیم:
import React from "react";

const NotFound = () => {
  return <h1>Not Found</h1>;
};

export default NotFound;
سپس ابتدا به مسیریابی /، ویژگی exact را هم اضافه می‌کنیم تا دیگر بجز ریشه‌ی سایت، به مسیر دیگری پاسخ ندهد:
<Route path="/" exact component={Home} />
اکنون اگر مجددا مسیر xyz را درخواست کنیم، فقط کامپوننت NavBar در صفحه ظاهر می‌شود. برای بهبود این وضعیت و نمایش کامپوننت NotFound، مراحل زیر را طی می‌کنیم:
- ابتدا شیء Redirect را از react-router-dom باید import کنیم.
- همچنین import کامپوننت NotFound نیز باید ذکر شود.
- سپس پیش از مسیریابی کلی /، مسیریابی جدید not-found را که به کامپوننت NotFound اشاره می‌کند، اضافه می‌کنیم.
- اکنون در انتهای Switch تعریف شده (جائی که دیگر هیچ مسیریابی تعریف شده‌ای، با مسیر درخواستی کاربر، تطابق نداشته)، باید کامپوننت Redirect را جهت هدایت به این مسیریابی جدید، تعریف کرد:
import { Redirect, Route, Switch } from "react-router-dom";
//...
import NotFound from "./components/notFound";
//...

class App extends Component {
  render() {
    return (
      <div>
        <NavBar />
        <div className="container">
          <Switch>
            //...
            <Route path="/not-found" component={NotFound} />
            <Route path="/" exact component={Home} />
            <Redirect to="/not-found" />
          </Switch>
        </div>
      </div>
    );
  }
}
پس از این تغییرات، اگر آدرس نامعتبر http://localhost:3000/xyz درخواست شود، بلافاصله به آدرس http://localhost:3000/not-found هدایت می‌شویم.

کاربرد دیگر کامپوننت Redirect، هدایت کاربران از یک آدرس قدیمی، به یک آدرس جدید است که نحوه‌ی تعریف آن به صورت زیر می‌باشد:
<Redirect from="/messages" to="/posts" />
با این تنظیم اگر کاربری مسیر http://localhost:3000/messages را درخواست دهد، به صورت خودکار به http://localhost:3000/posts هدایت خواهد شد.


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

گاهی از اوقات پس از تکمیل فرمی و یا کلیک بر روی دکمه‌ای، می‌خواهیم کاربر را به آدرس خاصی هدایت کنیم. برای مثال در برنامه‌ی جاری می‌خواهیم زمانیکه کاربری صفحه‌ی جزئیات یک محصول را مشاهده و بر روی دکمه‌ی فرضی Save کلیک کرد، دوباره به همان صفحه‌ی لیست محصولات هدایت شود؛ برای این منظور از شیء history استفاده خواهیم کرد:


در اینجا متدها و خواص شیء history را مشاهده می‌کنید. برای نمونه توسط متد push آن می‌توان آدرس جدیدی را به تاریخچه‌ی آدرس‌های مرور شده‌ی توسط کاربر، اضافه کرد و کاربر را با برنامه نویسی، به صفحه‌ی جدیدی هدایت نمود:
class ProductDetails extends Component {
  handleSave = () => {
    // Navigate to /products
    this.props.history.push("/products");
  };

یک نکته: اگر به تصویر دقت کنید، متد replace هم در اینجا قابل استفاده است. متد push با افزودن رکوردی به تاریخچه‌ی آدرس‌های مرور شده‌ی در مرورگر، امکان بازگشت به محل قبلی را با کلیک بر روی دکمه‌ی back مرورگر، فراهم می‌کند؛ اما replace تنها رکورد آدرس جاری را در تاریخچه‌ی مرورگر به روز رسانی می‌کند. یعنی از داشتن تاریخچه محروم خواهیم شد. عمده‌ی کاربرد این متد، در صفحات لاگین است؛ زمانیکه کاربر به سیستم وارد می‌شود و به صفحه‌ی جدیدی مراجعه می‌کند، با کلیک بر روی دکمه‌ی back، دوباره نمی‌خواهیم او را به صفحه‌ی لاگین هدایت کنیم.


تعریف مسیریابی‌های تو در تو


قصد داریم به صفحه‌ی admin، دو لینک جدید به مطالب و کاربران ادمین را اضافه کنیم، به نحوی که با کلیک بر روی هر کدام، محتوای هر صفحه‌ی متناظر، در همینجا نمایش داده شود (تصویر فوق). به عبارتی در حال حاضر، مسیریابی تعریف شده، جهت مدیریت لینک‌های NavBar بالای صفحه کار می‌کند. اکنون می‌خواهیم مسیریابی دیگری را داخل آن برای پوشش منوی کنار صفحه‌ی ادمین اضافه کنیم. به اینکار، تعریف مسیریابی‌های تو در تو گفته می‌شود و پیاده سازی آن توسط کامپوننت react-router-dom بسیار ساده‌است و شامل این موارد می‌شود:
- ابتدا مسیریابی‌های جدید را همینجا داخل کامپوننت src\components\admin\dashboard.jsx تعریف می‌کنیم:
const Dashboard = ({ match }) => {
  return (
    <div>
      <h1>Admin Dashboard</h1>
      <div className="row">
        <div className="col-3">
          <SideBar />
        </div>
        <div className="col">
          <Route path="/admin/users" component={Users} />
          <Route path="/admin/posts" component={Posts} />
        </div>
      </div>
    </div>
  );
};
در اینجا محتوای کامپوننت بدون حالت تابعی Dashboard را ملاحظه می‌کنید که از یک کامپوننت منوی SideBar و سپس در ستونی دیگر، از 2 کامپوننت Route تشکیل شده‌است که بر اساس URL رسیده، سبب رندر کامپوننت‌های جدید Users و Posts خواهند شد.
تنها نکته‌ی جدید آن، امکان درج کامپوننت Route در قسمت‌های مختلف برنامه، خارج از app.js می‌باشد و این امکان محدود به app.js نیست. در این حالت اگر مسیر /admin/posts توسط کاربر وارد شد، دقیقا در همان محلی که کامپوننت Route درج شده‌است، کامپوننت متناظر با این مسیر یعنی کامپوننت Posts، رندر می‌شود.

در ادامه محتوای این کامپوننت‌های جدید را نیز ملاحظه می‌کنید:
محتوای کامپوننت src\components\admin\sidebar.jsx
import React from "react";
import { Link } from "react-router-dom";

const SideBar = () => {
  return (
    <ul className="list-group">
      <li className="list-group-item">
        <Link to="/admin/posts">Posts</Link>
      </li>
      <li className="list-group-item">
        <Link to="/admin/users">Users</Link>
      </li>
    </ul>
  );
};

export default SideBar;

محتوای کامپوننت src\components\admin\users.jsx
import React from "react";

const Users = () => {
  return <h1>Admin Users</h1>;
};

export default Users;

محتوای کامپوننت src\components\admin\posts.jsx
import React from "react";

const Posts = () => {
  return (
    <div>
      <h1>Admin Posts</h1>
    </div>
  );
};

export default Posts;


کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید: sample-15-part-02.zip
نظرات مطالب
ASP.NET MVC #21
با تشکر از آموزش خوبتون،
امکان ویرایش Confirm در Ajax.BeginForm یا Ajax.ActionLink وجود داره؟
مثلا میخوایم از  jQuery Impromptu استفاده کنیم .
@using(Ajax.BeginForm(MVC.Post.ActionNames.New ,
 MVC.Post.Name,
 new AjaxOptions(){ 
   HttpMethod = "Post" , 
   Confirm ="$.prompt('درخواست تایید - موافقید ؟',{prefix: 'dnt',buttons: { 'تایید': true, 'انصراف': false }});" }))
ولی کار نمیکنه.
یا تنها راهش استفاده از id دکمه‌ی Submit و Confirm دادن با کمک Jquery هستش؟
و یه موضوع دیگه . انجام اینکار درسته؟
@using(Ajax.BeginForm(MVC.Post.ActionNames.New , 
MVC.Post.Name, 
new AjaxOptions(){ 
   HttpMethod = "Post" , 
   OnSuccess = "var noty = window.noty({ text: 'نظر شما ثبت شد بعد از تایید نمایش داده میشود', type: 'success', layout: 'center', timeout: 4000 });"  
}))
منظورم نمایش پیغام به کاربر که عملیات موفق آمیز بوده ، یعنی وقتی متد OnSuccess اجرا میشود اطمینان داشته باشیم که Action مربوطه کاملا اجرا شده و اگه مشکلی در حین اجرای Action پیش بیاد این متد فراخونی نمیشه؟
مطالب
ساخت تم سفارشی در انگیولار متریال ۲ - بخش دوم

در قسمت قبل با نحوه ساخت تم سفارشی در انگیولار متریال ۲، آشنا شدیم. در این قسمت نحوه ساخت چند تم دیگر در کنار تم اصلی، ساخت تم به ازای هر کامپوننت و نحوه تعویض تم از طریق کد را دنبال خواهیم کرد.

ساخت تم اضافی در کنار تم اصلی

ساخت تم اضافی در انگیولار متریال ۲ بسیار ساده است. شما می‌توانید با استفاده مجدد از تابع angular-material-theme داخل یک کلاس CSS، صاحب یک تم اضافی دیگر شوید. برای نمونه در اینجا فایل my-custom-theme.scss را باز کرده و به شکل زیر تغییر می‌دهیم.

@import '~@angular/material/theming';
@include mat-core();
$my-app-primary: mat-palette($mat-teal);
$my-app-accent: mat-palette($mat-amber, 500, A100, A400);
$my-app-warn: mat-palette($mat-deep-orange);

$my-app-theme: mat-light-theme($my-app-primary, $my-app-accent, $my-app-warn);

@include angular-material-theme($my-app-theme);

/*تعریف تم اضافی در کنار تم اصلی*/
$alternate-primary: mat-palette($mat-light-blue);
$alternate-accent: mat-palette($mat-yellow, 500);
$alternate-warn: mat-palette($mat-red, 500);
$alternate-theme: mat-dark-theme($alternate-primary, $alternate-accent);
.alternate-theme {
  @include angular-material-theme($alternate-theme);
}

با این‌کار در کنار تم روشن اصلی، یک تم مشکی به صورت اضافی داخل کلاس CSS به نام alternate-theme تعریف کرده‌ایم. در این حالت تمامی کامپوننتهایی که داخل المنت با کلاس alternate-theme قرار گرفته‌اند، از تم مشکی تعریف شده استفاده خواهند کرد.

با تغییر فایل app.component.html به شکل زیر: 

<md-card>
  <md-card-header>
    <md-card-title>تم اصلی</md-card-title>
  </md-card-header>
  <button md-raised-button color="primary">
    Primary
  </button>
  <button md-raised-button color="accent">
    Accent
  </button>
  <button md-raised-button color="warn">
    Warning
  </button>
</md-card>

<div>
  <md-card>
    <md-card-header>
      <md-card-title>تم اضافی</md-card-title>
    </md-card-header>
    <md-card-content>
      <button md-raised-button color="primary">
        Primary
      </button>
      <button md-raised-button color="accent">
        Accent
      </button>
      <button md-raised-button color="warn">
        Warning
      </button>
    </md-card-content>
  </md-card>
</div>

تصویر زیر را در خروجی خواهید داشت.

 

به همین روش می‌توانید تعداد دلخواهی از تم‌ها را بسازید. همچنین می‌توانید هر تم اضافی را در یک فایل Sass تعریف کنید و از این طریق تم‌های مختلف را از هم جدا کنید. در این حالت به این نکته توجه داشته باشید که نباید mat-core@ در سرتاسر برنامه بیش از یکبار بارگذاری شده باشد. 

ساخت تم به ازای هر کامپوننت

با استفاده از mixin به نام angular-material-theme خروجی تولید شده بر روی تمامی کامپوننت‌های انگیولار متریال ۲ اعمال خواهد شد. اگر از تمامی کامپوننت‌های انگیولار متریال ۲ استفاده نمی‌کنید، می‌توانید برای کاهش حجم فایل CSS تولید شده از mixin مخصوص به هر کامپوننت استفاده کنید. همچنین برای ساخت تم‌های متفاوت به ازای هر کامپوننت نیز می‌توانید از این روش استفاده کنید. 

برای این کار تمامی مراحلی که برای ساخت تم مورد نیاز بود، باید طی شود. فقط به جای استفاده از mixin به نام angular-material-theme بایستی به طریق زیر عمل شود. 

اول: بارگذاری mixin با نام mat-core-them. این mixin تمامی استایل‌های مشترک رفتاری (مانند موج (ripple) در هنگام کلیک) برای کامپوننت‌ها را در بر دارد. این mixin خروجی تابع mat-light-theme یا mat-dark-theme را به عنوان ورودی دریافت می‌کند.

دوم: بارگذاری mixin مربوط به هر کامپوننت. برای مثال برای دکمه از mixin به نام mat-button-theme و برای checkbox از mixin به نام mat-checkbox-theme می‌توانید استفاده کنید. در زیر لیست mixin‌ها به ازای کامپوننت‌های مختلف ذکر شده است. 

mat-autocomplete-theme
mat-button-theme
mat-button-toggle-theme
mat-card-theme
mat-checkbox-theme
mat-chips-theme
mat-datepicker-theme
mat-dialog-theme
mat-grid-list-theme
mat-icon-theme
mat-input-theme
mat-list-theme
mat-menu-theme
mat-progress-bar-theme
mat-progress-spinner-theme
mat-radio-theme
mat-select-theme
mat-sidenav-theme
mat-slide-toggle-theme
mat-slider-theme
mat-tabs-theme
mat-toolbar-theme
mat-tooltip-theme

در مثال زیر می‌خواهیم تمامی کامپوننت‌ها به جز کامپوننت دکمه، تم سبز(در گروه Primary) و دکمه‌ها نیز تم آبی داشته باشند. کافی است کدهای زیر را در فایل Sass خود وارد کنید. 

@import '~@angular/material/theming';
@include mat-core();
$my-app-primary: mat-palette($mat-teal);
$my-app-accent: mat-palette($mat-amber, 500, A100, A400);
$my-app-warn: mat-palette($mat-deep-orange);
$my-app-theme: mat-light-theme($my-app-primary, $my-app-accent, $my-app-warn);
@include mat-material-theme($my-app-theme);

/* تعریف تم برای دکمه */
$button-primary: mat-palette($mat-light-blue);
$button-accent: mat-palette($mat-yellow, 500);
$button-warn: mat-palette($mat-red, 500);
$button-theme: mat-light-theme($button-primary, $button-accent);
@include mat-button-theme($button-theme);

با توجه به اینکه mat-material-theme در داخل خود mat-button-theme را بارگذاری می‌کند دو نتیجه زیر را می‌توان گرفت.

اول: اگر mat-material-theme بعد از هر کدام از mixinهای مربوط به کامپوننت‌ها نوشته شود، تمامی Cssهای تولید شده به ازای کامپوننت را دوباره نویسی کرده و عملا هیچ کدام کارایی نخواهند داشت. برای مثال کافی است فایل Sass خود را به شکل زیر تغییر دهید. در این صورت تم مربوط به دکمه کاریی نخواهد داشت. 

@import '~@angular/material/theming';
@include mat-core();

/* تعریف تم برای دکمه */
$button-primary: mat-palette($mat-light-blue);
$button-accent: mat-palette($mat-yellow, 500);
$button-warn: mat-palette($mat-red, 500);
$button-theme: mat-ligth-theme($button-primary, $button-accent);
@include mat-button-theme($button-theme);

$my-app-primary: mat-palette($mat-teal);
$my-app-accent: mat-palette($mat-amber, 500, A100, A400);
$my-app-warn: mat-palette($mat-deep-orange);
$my-app-theme: mat-light-theme($my-app-primary, $my-app-accent, $my-app-warn);
@include mat-material-theme($my-app-theme);

دوم: همانطور که گفتیم mat-button-theme در mat-material-theme قبلا بارگذاری شده است. با بارگذاری دوباره توسط mat-button-theme کدهای CSS که قبلا برای دکمه تولید شده‌اند را از نو دوباره می‌نویسد و این باعث بزرگ شدن حجم فایل Css تولید شده خواهد شد. پس بهتر است هنگام استفاده از mixinهای مختص کامپوننت‌ها از mat-material-theme استفاده نکنیم. 

  جهت بهبود کدهای قبلی بهتر است از کدهای زیر استفاده کنیم. 
@import '~@angular/material/theming';
@include mat-core();
$my-app-primary: mat-palette($mat-teal);
$my-app-accent: mat-palette($mat-amber, 500, A100, A400);
$my-app-warn: mat-palette($mat-deep-orange);
$my-app-theme: mat-light-theme($my-app-primary, $my-app-accent, $my-app-warn);

$button-primary: mat-palette($mat-light-blue);
$button-accent: mat-palette($mat-yellow, 500);
$button-warn: mat-palette($mat-red, 500);
$button-theme: mat-light-theme($button-primary, $button-accent);

@include mat-core-theme($my-app-theme);
@include mat-autocomplete-theme($my-app-theme);

@include mat-button-theme($button-theme);

@include mat-button-toggle-theme($my-app-theme);
@include mat-card-theme($my-app-theme);
@include mat-checkbox-theme($my-app-theme);
@include mat-chips-theme($my-app-theme);
@include mat-datepicker-theme($my-app-theme);
@include mat-dialog-theme($my-app-theme);
@include mat-grid-list-theme($my-app-theme);
@include mat-icon-theme($my-app-theme);
@include mat-input-theme($my-app-theme);
@include mat-list-theme($my-app-theme);
@include mat-menu-theme($my-app-theme);
@include mat-progress-bar-theme($my-app-theme);
@include mat-progress-spinner-theme($my-app-theme);
@include mat-radio-theme($my-app-theme);
@include mat-select-theme($my-app-theme);
@include mat-sidenav-theme($my-app-theme);
@include mat-slide-toggle-theme($my-app-theme);
@include mat-slider-theme($my-app-theme);
@include mat-tabs-theme($my-app-theme);
@include mat-toolbar-theme($my-app-theme);
@include mat-tooltip-theme($my-app-theme);

تعویض تم از طریق کد

فرض کنید یک تم پیش فرض و یک تم اضافی به نام alternate-theme دارید. برای تعویض تم از طریق کد کافی است کلاس المنت پدر در صفحه html خود را از طریق [ngClass] با نام تم، مقدار دهی کنید. کدهای داخل app.component.ts را به شکل زیر تغییر می‌دهیم. 

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  themes = [
    {value: 'alternate-theme', text: 'تم مشکی'},
    {value: '', text: 'تم سفید'},
  ];
  activeTheme = '';
}

آرایه‌ای جهت نمایش در کامپوننت md-select با دو مقدار تم پیش فرض و تم با نام 'alternate-theme تعریف می‌کنیم. همچنین متغیری با نام activeTheme را تعریف می‌کنیم. این متغیر در هر لحظه نام تم اعمال شده را در خود نگهداری می‌کند. مقدار اولیه این متغیر تم اصلی است.

کامپوننت md-select را به شکل زیر به فایل app.component.html به تگ main اضافه می‌کنیم. 

<md-select dir="rtl" [(ngModel)]="activeTheme" placeholder="تعویض تم">
    <md-option *ngFor="let theme of themes" [value]="theme.value">
            {{ theme.text }}
    </md-option>
</md-select>
حالا کافی است کل تگهای موجود در app.component.html را داخل یک تگ div به شکل زیر قرار دهیم. 
<div [ngClass]="activeTheme">
بعد از اجرای برنامه می‌توانید با تعویض تم از طریق کامپوننت md-select، تم صفحه را تعویض کنید. 

کدهای این قسمت را، از اینجا دریافت کنید: ساخت تم سفارشی در انگولار متریال ۲ - بخش دوم
مطالب
یکی از مزایای استفاده از SVN در یک پروژه تک نفره

حتما لازم نیست که در یک تیم برنامه نویسی مشغول به کار باشید تا به یک سورس کنترل نیاز پیدا کنید. در ادامه یکی از مزایای استفاده از SVN را با هم مرور خواهیم کرد.

چند روز قبل هنگام کار با VS.Net ، ناگهان IDE‌ کرش کرد. (از لطایف استفاده از یک دو جین افزونه و ضعف در برنامه نویسی یکی از این‌ها که می‌تواند سبب ناپایدار شدن IDE شود)
پس از کرش با صفحه‌ی زیر مواجه شدم!


بله! فرم برنامه که با هزار زحمت درست شده بود، پس از کرش نابود شده بود!
در این نوع مواقع چه باید کرد؟ مراجعه به آخرین مجموعه‌ی بک آپ زیپ شده که احتمالا وجود خارجی ندارد؟ ناسزا گفتن به زمین و زمان، یا ... ؟!

چون همیشه از SVN به عنوان سورس کنترل استفاده می‌کنم، به سادگی چند کلیک مشکل برطرف شد.
برای این‌کار می‌توان به صورت زیر عمل کرد:
الف) کلیک راست بر روی فایل frmMain.Designer.cs (این فایل تعاریف رابط کاربر فرم تخریب شده را در خود دارد)
ب) سپس انتخاب گزینه‌ی Showlog از منوی افزونه‌ی Visual SVN (شکل زیر)



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



در ادامه می‌توان بر روی یکی از سطرهای ظاهر شده در گزارش کلیک راست کرد و گزینه‌ی compare with working copy را انتخاب نمود (شکل زیر):



سپس ابزار diff ظاهر شده و می‌توان به سادگی تفاوت فایل تخریب شده فعلی و فایل سالم چند نگارش قبل را مشاهده نمود:



همانطور که در تصویر مشخص است، فایل مورد استفاده (working copy) در دو نقطه اساسی که مربوط به اضافه کردن منوها است تخریب شده. سمت چپ نگارش قدیمی است و سمت راست نگارش فعلی تخریب شده.
اکنون برای اصلاح کد تخریب شده فقط کافی است روی قسمت رنگی سمت راست کلیک راست کرده و گزینه copy to right‌ را انتخاب کنیم. به این صورت در اسرع وقت و به سادگی هر چه تمام‌تر یک فایل تخریب شده به روز اول یا حداقل به یک نگارش قبل بازگشت پیدا کرده و مشکل حل می‌شود. (البته در این مورد تخریب فرم، پس از انجام اصلاح فوق، یکبار باید IDE را کاملا بست و مجددا آنرا گشود تا نتیجه ظاهر شود)



اگر به این مبحث علاقمند شدید، به کتابچه‌ی فارسی راهنمای کار با SVN مراجعه نمائید. (در مورد نحوه‌ی راه اندازی SVN ، افزونه‌های IDE های مختلف و موارد دیگری که در این مطلب کوتاه در مورد آن‌ها بحث نشد، به تفصیل توضیح داده شده است)


مطالب
تشخیص اصالت ردیف‌های یک بانک اطلاعاتی در EF Core
همیشه فرض بر این است که مدیر سیستم، فردی است امین و درستکار. این شخص/اشخاص کارهای شبکه، پشتیبان‌گیری، نگهداری و امثال آن‌را انجام داده و از سیستم‌ها محافظت می‌کنند. اکنون این سناریوهای واقعی را درنظر بگیرید:
- پس از خداحافظی با شرکتی که در آن کار می‌کردی، شخصی با پوزخند به شما می‌گوید که «می‌دونستی در برنامه‌ی حق و دستمزد شما، بچه‌های ادمین شبکه، دیتابیس برنامه رو مستقیما دستکاری می‌کردند و تعداد ساعات کاری بیشتری رو وارد می‌کردند»؟!
- مسئول فروشی/مسئول پذیرشی که یاد گرفته چطور به صورت مستقیم به بانک اطلاعاتی دسترسی پیدا کند و آمار فروش/پذیرش روز خودش را در بانک اطلاعاتی، با دستکاری مستقیم و خارج از برنامه، کمتر از مقدار واقعی نمایش دهد.
- باز هم مدیر سیستمی/شبکه‌ای که دسترسی مستقیم به بانک اطلاعاتی دارد، در ساعاتی مشخص، کلمه‌ی عبور هش شده‌ی خودش را مستقیما، بجای کلمه‌ی عبور ادمین برنامه در بانک اطلاعاتی وارد کرده و پس از آن ...

این موارد متاسفانه واقعی هستند! اکنون سؤال اینجا است که آیا برنامه‌ی شما قادر است تشخیص دهد رکوردهایی که هم اکنون در بانک اطلاعاتی ثبت شده‌اند، واقعا توسط برنامه و تمام سطوح دسترسی که برای آن طراحی کرده‌اید، به این شکل درآمده‌اند، یا اینکه توسط اشخاصی به صورت مستقیم و با دور زدن کامل برنامه، از طریق management studioهای مختلف، در سیستم وارد و دستکاری شده‌اند؟! در ادامه راه حلی را برای بررسی این مشکل مهم، مرور خواهیم کرد.


چگونه تغییرات رکوردها را در بانک‌های اطلاعاتی ردیابی کنیم؟

روش متداولی که برای بررسی تغییرات رکوردها مورد استفاده قرار می‌گیرد، هش کردن تمام اطلاعات یک ردیف از جدول است و سپس مقایسه‌ی این هش‌ها با هم. علت استفاده‌ی از الگوریتم‌های هش نیز، حداقل به دو علت است:
- با تغییر حتی یک بیت از اطلاعات، مقدار هش تولید شده تغییر می‌کند.
- طول نهایی مقدار هش شده‌ی اطلاعاتی حجیم، بسیار کم است و به راحتی توسط بانک‌های اطلاعاتی، قابل مدیریت و جستجو است.

اگر از SQL Server استفاده می‌کنید، یک چنین قابلیتی را به صورت توکار به همراه دارد:
SELECT
    [Id], 
    (SELECT top 1  * FROM  [AppUsers] FOR XML auto),
    HASHBYTES ('SHA2_256', (SELECT top 1  * FROM  [AppUsers] FOR XML auto)) AS [hash] -- varbinary(n), since 2012
FROM
    [AppUsers]
با این خروجی


کاری که این کوئری انجام می‌دهد شامل دو مرحله است:
الف) کوئری "SELECT top 1 * FROM [AppUsers] FOR XML auto" کاری شبیه به serialization را انجام می‌دهد. همانطور که مشاهده می‌کنید، نام و مقادیر تمام فیلدهای یک ردیف را به صورت یک خروجی XML در می‌آورد. بنابراین دیگر نیازی نیست تا کار تبدیل مقادیر تمام ستون‌های یک ردیف را به عبارتی قابل هش، به صورت دستی انجام دهیم؛ رشته‌ی XML ای آن هم اکنون آماده‌است.
ب) متد HASHBYTES، این خروجی serialized را با الگوریتم SHA2_256، هش می‌کند. الگوریتم‌های SHA2_256 و همچنین SHA2_512، از سال 2012 به بعد به SQL Server اضافه شده‌اند.

اکنون اگر این هش را به نحوی ذخیره کنیم (برنامه باید این هش را ذخیره و یا به روز رسانی کند) و سپس شخصی به صورت مستقیم ردیف فوق را در بانک اطلاعاتی تغییر دهد، هش جدید این ردیف، با هش قبلی ذخیره شده‌ی توسط برنامه، یکی نخواهد بود که بیانگر دستکاری مستقیم این ردیف، خارج از برنامه و با دور زدن کامل تمام سطوح دسترسی آن است.


چگونه تغییرات رکوردها را در بانک‌های اطلاعاتی، توسط EF Core ردیابی کنیم؟

مزیت روش فوق، توکار بودن آن است که کارآیی فوق العاده‌ای را نیز به همراه دارد. اما چون در ادامه قصد داریم از یک ORM استفاده کنیم و ORMها نیز قرار است توانایی کار کردن با انواع و اقسام بانک‌های اطلاعاتی را داشته باشند، دو مرحله‌ی serialization و هش کردن را در کدهای برنامه و با مدیریت EF Core، مستقل از بانک اطلاعاتی خاصی، انجام خواهیم داد.


معرفی موجودیت‌های برنامه

در مثالی که بررسی خواهیم کرد، دو موجودیت Blog و Post تعریف شده‌اند:
using System.Collections.Generic;

namespace EFCoreRowIntegrity
{
    public interface IAuditableEntity
    {
        string Hash { set; get; }
    }

    public static class AuditableShadowProperties
    {
        public static readonly string CreatedDateTime = nameof(CreatedDateTime);
        public static readonly string ModifiedDateTime = nameof(ModifiedDateTime);
    }

    public class Blog : IAuditableEntity
    {
        public int BlogId { get; set; }
        public string Url { get; set; }

        public List<Post> Posts { get; set; }

        public string Hash { get; set; }
    }

    public class Post : IAuditableEntity
    {
        public int PostId { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        public int BlogId { get; set; }
        public Blog Blog { get; set; }

        public string Hash { get; set; }
    }
}
- در اینجا اینترفیس IAuditableEntity را نیز مشاهده می‌کنید که دارای یک خاصیت Hash است. تمام موجودیت‌هایی که قرار است دارای فیلد هش باشند، نیاز است این اینترفیس را پیاده سازی کنند؛ مانند دو موجودیت Blog و Post. در ادامه مقدار خاصیت هش را به صورت خودکار توسط سیستم Tracking، محاسبه و به روز رسانی می‌کنیم.
- به علاوه جهت تکمیل بحث، دو خاصیت سایه‌ای نیز تعریف شده‌اند تا بررسی کنیم که آیا هش این‌ها نیز درست محاسبه می‌شود یا خیر.
- علت اینکه خاصیت Hash، سایه‌ای تعریف نشد، سهولت دسترسی و بالا بردن کارآیی آن بود.



معرفی ظرفی برای نگهداری نام خواص و مقادیر متناظر با یک موجودیت

در ادامه دو کلاس AuditEntry و AuditProperty را مشاهده می‌کنید:
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace EFCoreRowIntegrity
{
    public class AuditEntry
    {
        public EntityEntry EntityEntry { set; get; }
        public IList<AuditProperty> AuditProperties { set; get; } = new List<AuditProperty>();

        public AuditEntry() { }

        public AuditEntry(EntityEntry entry)
        {
            EntityEntry = entry;
        }
    }

    public class AuditProperty
    {
        public string Name { set; get; }
        public object Value { set; get; }

        public bool IsTemporary { set; get; }
        public PropertyEntry PropertyEntry { set; get; }

        public AuditProperty() { }

        public AuditProperty(string name, object value, bool isTemporary, PropertyEntry property)
        {
            Name = name;
            Value = value;
            IsTemporary = isTemporary;
            PropertyEntry = property;
        }
    }
}
زمانیکه توسط سیستم Tracking، موجودیت‌های اضافه شده و یا ویرایش شده را استخراج می‌کنیم، AuditEntry همان موجودیت در حال بررسی است که دارای تعدادی خاصیت یا AuditProperty می‌باشد. این‌ها را توسط دو کلاس فوق برای عملیات بعدی، ذخیره و نگهداری می‌کنیم.


معرفی روشی برای هش کردن مقادیر یک شیء

زمانیکه توسط سیستم Tracking، در حال کاربر بر روی موجودیت‌های اضافه شده و یا ویرایش شده هستیم، می‌خواهیم فیلد هش آن‌ها را نیز به صورت خودکار ویرایش و مقدار دهی کنیم. کلاس زیر، منطق ارائه دهنده‌ی این مقدار هش را بیان می‌کند:
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Newtonsoft.Json;

namespace EFCoreRowIntegrity
{
    public static class HashingExtensions
    {
        public static string GenerateObjectHash(this object @object)
        {
            if (@object == null)
            {
                return string.Empty;
            }

            var jsonData = JsonConvert.SerializeObject(@object, Formatting.Indented);
            using (var hashAlgorithm = new SHA256CryptoServiceProvider())
            {
                var byteValue = Encoding.UTF8.GetBytes(jsonData);
                var byteHash = hashAlgorithm.ComputeHash(byteValue);
                return Convert.ToBase64String(byteHash);
            }
        }

        public static string GenerateEntityEntryHash(this EntityEntry entry, string propertyToIgnore)
        {
            var auditEntry = new Dictionary<string, object>();
            foreach (var property in entry.Properties)
            {
                var propertyName = property.Metadata.Name;
                if (propertyName == propertyToIgnore)
                {
                    continue;
                }
                auditEntry[propertyName] = property.CurrentValue;
            }
            return auditEntry.GenerateObjectHash();
        }

        public static string GenerateEntityHash<TEntity>(this DbContext context, TEntity entity, string propertyToIgnore)
        {
            return context.Entry(entity).GenerateEntityEntryHash(propertyToIgnore);
        }
    }
}
- در اینجا توسط متد JsonConvert.SerializeObject کتابخانه‌ی Newtonsoft.Json، شیء موجودیت را تبدیل به یک رشته‌ی JSON کرده و توسط الگوریتم SHA256 هش می‌کنیم. در آخر هم این مقدار را به صورت Base64 ارائه می‌دهیم.
- نکته‌ی مهم: ما نمی‌خواهیم تمام خواص یک موجودیت را هش کنیم. برای مثال اگر موجودیتی دارای چندین رابطه با جداول دیگری بود، ما مقادیر این‌ها را هش نمی‌کنیم (چون رکوردهای متناظر با آن‌ها در جداول خودشان می‌توانند دارای فیلد هش مخصوصی باشند). بنابراین یک Dictionary را از خواص و مقادیر متناظر با آن‌ها تشکیل داده و این Dictionary را تبدیل به JSON می‌کنیم.
- همچنین در این بین، مقدار خود فیلد Hash یک شیء نیز نباید در هش محاسبه شده، حضور داشته باشد. به همین جهت پارامتر propertyToIgnore را مشاهده می‌کنید.


معرفی Context برنامه که کار هش کردن خودکار موجودیت‌ها را انجام می‌دهد

اکنون نوبت استفاده از تنظیمات انجام شده‌ی تا این مرحله‌است:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;

namespace EFCoreRowIntegrity
{
    public class BloggingContext : DbContext
    {
        public BloggingContext()
        { }

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

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

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.EnableSensitiveDataLogging();
                var path = Path.Combine(Directory.GetCurrentDirectory(), "app_data", "EFCore.RowIntegrity.mdf");
                optionsBuilder.UseSqlServer($"Server=(localdb)\\mssqllocaldb;Database=EFCore.RowIntegrity;AttachDbFilename={path};Trusted_Connection=True;");
                optionsBuilder.UseLoggerFactory(new LoggerFactory().AddConsole((message, logLevel) =>
                logLevel == LogLevel.Debug &&
                           message.StartsWith("Microsoft.EntityFrameworkCore.Database.Command")));
            }
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            foreach (var entityType in modelBuilder.Model
                                                   .GetEntityTypes()
                                                   .Where(e => typeof(IAuditableEntity)
                                                   .IsAssignableFrom(e.ClrType)))
            {
                modelBuilder.Entity(entityType.ClrType)
                            .Property<DateTimeOffset?>(AuditableShadowProperties.CreatedDateTime);
                modelBuilder.Entity(entityType.ClrType)
                            .Property<DateTimeOffset?>(AuditableShadowProperties.ModifiedDateTime);
            }
        }

        public override int SaveChanges()
        {
            var auditEntries = OnBeforeSaveChanges();
            var result = base.SaveChanges();
            OnAfterSaveChanges(auditEntries);
            return result;
        }

        private IList<AuditEntry> OnBeforeSaveChanges()
        {
            var auditEntries = new List<AuditEntry>();

            foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
            {
                if (entry.State == EntityState.Detached || entry.State == EntityState.Unchanged)
                {
                    continue;
                }

                var auditEntry = new AuditEntry(entry);
                auditEntries.Add(auditEntry);

                var now = DateTimeOffset.UtcNow;

                foreach (var property in entry.Properties)
                {
                    var propertyName = property.Metadata.Name;
                    if (propertyName == nameof(IAuditableEntity.Hash))
                    {
                        continue;
                    }

                    if (property.IsTemporary)
                    {
                        // It's an auto-generated value and should be retrieved from the DB after calling the base.SaveChanges().
                        auditEntry.AuditProperties.Add(new AuditProperty(propertyName, null, true, property));
                        continue;
                    }

                    switch (entry.State)
                    {
                        case EntityState.Added:
                            entry.Property(AuditableShadowProperties.CreatedDateTime).CurrentValue = now;
                            auditEntry.AuditProperties.Add(new AuditProperty(propertyName, property.CurrentValue, false, property));
                            break;
                        case EntityState.Modified:
                            auditEntry.AuditProperties.Add(new AuditProperty(propertyName, property.CurrentValue, false, property));
                            entry.Property(AuditableShadowProperties.ModifiedDateTime).CurrentValue = now;
                            break;
                    }
                }
            }

            return auditEntries;
        }

        private void OnAfterSaveChanges(IList<AuditEntry> auditEntries)
        {
            foreach (var auditEntry in auditEntries)
            {
                foreach (var auditProperty in auditEntry.AuditProperties.Where(x => x.IsTemporary))
                {
                    // Now we have the auto-generated value from the DB.
                    auditProperty.Value = auditProperty.PropertyEntry.CurrentValue;
                    auditProperty.IsTemporary = false;
                }
                auditEntry.EntityEntry.Property(nameof(IAuditableEntity.Hash)).CurrentValue =
                    auditEntry.AuditProperties.ToDictionary(x => x.Name, x => x.Value).GenerateObjectHash();
            }
            base.SaveChanges();
        }
    }
}
در اینجا اصل کار، در متد بازنویسی شده‌ی SaveChanges انجام می‌شود:
public override int SaveChanges()
{
    var auditEntries = OnBeforeSaveChanges();
    var result = base.SaveChanges();
    OnAfterSaveChanges(auditEntries);
    return result;
}
در متد OnBeforeSaveChanges، تمام موجودیت‌های تغییر کرده‌ی از نوع IAuditableEntity را که دارای فیلد هش هستند، یافته و نام خاصیت و مقدار متناظر با آن‌ها را در ظرف‌های AuditEntry که پیشتر معرفی شدند، ذخیره می‌کنیم. هنوز در این مرحله کار هش کردن را انجام نخواهیم داد. علت را می‌توانید در بررسی خواص موقتی مشاهده کنید:
if (property.IsTemporary)
{
   // It's an auto-generated value and should be retrieved from the DB after calling the base.SaveChanges().
   auditEntry.AuditProperties.Add(new AuditProperty(propertyName, null, true, property));
   continue;
}
خواص موقتی، عموما تولید شده‌ی توسط دیتابیس هستند. برای مثال زمانیکه یک Id عددی خود افزاینده را به عنوان کلید اصلی جدول معرفی می‌کنید، مقدار آن پس از فراخوانی متد base.SaveChanges، از بانک اطلاعاتی دریافت شده و در اختیار برنامه قرار می‌گیرد. به همین جهت است که نیاز داریم لیست این خواص و مقادیر را یکبار پیش از base.SaveChanges ذخیره کنیم و پس از آن، خواص موقتی را که اکنون دارای مقدار هستند، مقدار دهی کرده و سپس هش نهایی شیء را محاسبه کنیم. اگر پیش از base.SaveChanges این هش را محاسبه کنیم، برای مثال حاوی مقدار Id شیء، نخواهد بود.

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


روش بررسی اصالت یک موجودیت

در متد زیر، روش محاسبه‌ی هش واقعی یک موجودیت دریافت شده‌ی از بانک اطلاعاتی را توسط متد الحاقی GenerateEntityHash مشاهده می‌کنید. اگر این هش واقعی (بر اساس مقادیر فعلی این ردیف که حتی ممکن است به صورت دستی و خارج از برنامه تغییر کرده باشد)، با مقدار Hash ثبت شده‌ی پیشین در آن ردیف یکی بود، اصالت این ردیف تائید خواهد شد:
private static void CheckRow1IsAuthentic()
{
    using (var context = new BloggingContext())
    {
        var blog1 = context.Blogs.Single(x => x.BlogId == 1);
        var entityHash = context.GenerateEntityHash(blog1, propertyToIgnore: nameof(IAuditableEntity.Hash));
        var dbRowHash = blog1.Hash;
        Console.WriteLine($"entityHash: {entityHash}\ndbRowHash:  {dbRowHash}");
        if (entityHash == dbRowHash)
        {
            Console.WriteLine("This row is authentic!");
        }
        else
        {
            Console.WriteLine("This row is tampered outside of the application!");
        }
    }
}
یک نمونه خروجی آن به صورت زیر است:
entityHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo=
dbRowHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo=
This row is authentic!

اکنون بانک اطلاعاتی را خارج از برنامه، مستقیما دستکاری می‌کنیم و برای مثال Url اولین ردیف را تغییر می‌دهیم:


در ادامه یکبار دیگر برنامه را اجرا خواهیم کرد:
entityHash: tdiZhKMJRnROGLLam1WpldA0fy/CbjJaR2Y2jNU9izk=
dbRowHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo=
This row is tampered outside of the application!
همانطور که مشاهده می‌کنید، هش واقعی جدید، با هش ثبت شده‌ی در ردیف، یکی نیست؛ که بیانگر ویرایش مستقیم این ردیف می‌باشد.
به علاوه باید درنظر داشت، محاسبه‌ی این هش بدون خود برنامه، کار ساده‌ای نیست. به همین جهت به روز رسانی دستی آن تقریبا غیرممکن است؛ خصوصا اگر متد GenerateObjectHash، کمی با پیچ و تاب بیشتری نیز تهیه شود.


چگونه وضعیت اصالت تعدادی ردیف را بررسی کنیم؟

مثال قبل، در مورد روش بررسی اصالت یک تک ردیف بود. کوئری زیر روش محاسبه‌ی فیلد جدید IsAuthentic را در بین لیستی از ردیف‌ها نمایش می‌دهد:
var blogs = (from blog in context.Blogs.ToList() // Note: this `ToList()` is necessary here for having Shadow properties values, otherwise they will considered `null`.
             let computedHash = context.GenerateEntityHash(blog, nameof(IAuditableEntity.Hash))
             select new
             {
               blog.BlogId,
               blog.Url,
               RowHash = blog.Hash,
               ComputedHash = computedHash,
               IsAuthentic = blog.Hash == computedHash
             }).ToList();


کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید: EFCoreRowIntegrity.zip
مطالب
Blazor 5x - قسمت 14 - کار با فرم‌ها - بخش 2 - تعریف فرم‌ها و اعتبارسنجی آن‌ها
در ادامه قصد داریم از سرویس زیر که در قسمت قبل تکمیل شد، در یک برنامه‌ی Blazor Server استفاده کنیم:
namespace BlazorServer.Services
{
    public interface IHotelRoomService
    {
        Task<HotelRoomDTO> CreateHotelRoomAsync(HotelRoomDTO hotelRoomDTO);

        Task<int> DeleteHotelRoomAsync(int roomId);

        IAsyncEnumerable<HotelRoomDTO> GetAllHotelRoomsAsync();

        Task<HotelRoomDTO> GetHotelRoomAsync(int roomId);

        Task<HotelRoomDTO> IsRoomUniqueAsync(string name);

        Task<HotelRoomDTO> UpdateHotelRoomAsync(int roomId, HotelRoomDTO hotelRoomDTO);
    }
}


تعریف کامپوننت‌های ابتدایی نمایش لیست اتاق‌ها و ثبت و ویرایش آن‌ها


در ابتدا کامپوننت‌های خالی نمایش لیست اتاق‌ها و همچنین فرم خالی ثبت و ویرایش آن‌ها را به همراه مسیریابی‌های مرتبط، ایجاد می‌کنیم. به همین جهت ابتدا داخل پوشه‌ی Pages، پوشه‌ی جدید HotelRoom را ایجاد کرده و فایل جدید HotelRoomList.razor را با محتوای ابتدایی زیر، به آن اضافه می‌کنیم.
@page "/hotel-room"

<div class="row mt-4">
    <div class="col-8">
        <h4 class="card-title text-info">Hotel Rooms</h4>
    </div>
    <div class="col-3 offset-1">
        <NavLink href="hotel-room/create" class="btn btn-info">Add New Room</NavLink>
    </div>
</div>

@code {

}
این کامپوننت در مسیر hotel-room/ قابل دسترسی خواهد بود. بر این اساس، به کامپوننت Shared\NavMenu.razor مراجعه کرده و مدخل منوی آن‌را تعریف می‌کنیم:
<li class="nav-item px-3">
    <NavLink class="nav-link" href="hotel-room">
        <span class="oi oi-list-rich" aria-hidden="true"></span> Hotel Rooms
    </NavLink>
</li>

تا اینجا صفحه‌ی ابتدایی نمایش لیست اتاق‌ها، به همراه یک دکمه‌ی افزودن اتاق جدید نیز هست. به همین جهت فایل جدید Pages\HotelRoom\HotelRoomUpsert.razor را به همراه مسیریابی hotel-room/create/ برای تعریف کامپوننت ابتدایی ثبت و ویرایش اطلاعات اتاق‌ها، اضافه می‌کنیم:
@page "/hotel-room/create"

<h3>HotelRoomUpsert</h3>

@code {

}
- واژه‌ی Upsert در مورد فرمی بکاربرده می‌شود که هم برای ثبت اطلاعات و هم برای ویرایش اطلاعات از آن استفاده می‌شود.
- NavLink تعریف شده‌ی در کامپوننت نمایش لیست اتاق‌ها، به مسیریابی کامپوننت فوق اشاره می‌کند.


ایجاد فرم ثبت یک اتاق جدید

برای ثبت یک اتاق جدید نیاز است به مدل UI آن که همان HotelRoomDTO تعریف شده‌ی در قسمت قبل است، دسترسی داشت. به همین جهت در پروژه‌ی BlazorServer.App، ارجاعی را به پروژه‌ی BlazorServer.Models.csproj اضافه می‌کنیم:
<Project Sdk="Microsoft.NET.Sdk.Web">
  <ItemGroup>
    <ProjectReference Include="..\BlazorServer.Models\BlazorServer.Models.csproj" />
  </ItemGroup>
</Project>
سپس جهت سراسری اعلام کردن فضای نام آن، یک سطر زیر را به انتهای فایل BlazorServer.App\_Imports.razor اضافه می‌کنیم:
@using BlazorServer.Models
اکنون می‌توانیم کامپوننت Pages\HotelRoom\HotelRoomUpsert.razor را به صورت زیر تکمیل کنیم:
@page "/hotel-room/create"

<div class="row mt-2 mb-5">
    <h3 class="card-title text-info mb-3 ml-3">@Title Hotel Room</h3>
    <div class="col-md-12">
        <div class="card">
            <div class="card-body">
                <EditForm Model="HotelRoomModel">
                    <div class="form-group">
                        <label>Name</label>
                        <InputText @bind-Value="HotelRoomModel.Name" class="form-control"></InputText>
                    </div>
                </EditForm>
            </div>
        </div>
    </div>
</div>

@code
{
    private HotelRoomDTO HotelRoomModel = new HotelRoomDTO();
    private string Title = "Create";
}
توضیحات:
- در برنامه‌های Blazor، کامپوننت ویژه‌ی EditForm را بجای تگ استاندارد form، مورد استفاده قرار می‌دهیم.
- این کامپوننت، مدل فرم را از فیلد HotelRoomModel که در قسمت کدها تعریف کردیم، دریافت می‌کند. کار آن تامین اطلاعات فیلدهای فرم است.
- سپس در EditForm تعریف شده، بجای المان استاندارد input، از کامپوننت InputText برای دریافت اطلاعات متنی استفاده می‌شود. با bind-value@ در قسمت چهارم این سری بیشتر آشنا شدیم و کار آن two-way data binding است. در اینجا هر اطلاعاتی که وارد می‌شود، سبب به روز رسانی خودکار مقدار خاصیت HotelRoomModel.Name می‌شود و برعکس.

یک نکته: در قسمت قبل، مدل UI را از نوع رکورد C# 9.0 و init only تعریف کردیم. رکوردها، با EditForm و two-way databinding آن سازگاری ندارند (bind-value@ در اینجا) و بیشتر برای کنترلرهای برنامه‌های Web API که یکبار قرار است کار وهله سازی آن‌ها در زمان دریافت اطلاعات از کاربر صورت گیرد، مناسب هستند و نه با فرم‌های پویای Blazor. به همین جهت به پروژه‌ی BlazorServer.Models مراجعه کرده و نوع آن‌ها را به کلاس و init‌ها را به set معمولی تغییر می‌دهیم تا در فرم‌های Blazor هم قابل استفاده شوند.

تا اینجا کامپوننت ثبت اطلاعات یک اتاق جدید، چنین شکلی را پیدا کرده‌است:



تکمیل سایر فیلدهای فرم ورود اطلاعات اتاق

پس از تعریف فیلد ورود اطلاعات نام اتاق، سایر فیلدهای متناظر با HotelRoomDTO را نیز به صورت زیر به EditForm تعریف شده اضافه می‌کنیم که در اینجا از InputNumber برای دریافت اطلاعات عددی و از InputTextArea، برای دریافت اطلاعات متنی چندسطری استفاده شده‌است:
<EditForm Model="HotelRoomModel">
    <div class="form-group">
        <label>Name</label>
        <InputText @bind-Value="HotelRoomModel.Name" class="form-control"></InputText>
    </div>
    <div class="form-group">
        <label>Occupancy</label>
        <InputNumber @bind-Value="HotelRoomModel.Occupancy" class="form-control"></InputNumber>
    </div>
    <div class="form-group">
        <label>Rate</label>
        <InputNumber @bind-Value="HotelRoomModel.RegularRate" class="form-control"></InputNumber>
    </div>
    <div class="form-group">
        <label>Sq ft.</label>
        <InputText @bind-Value="HotelRoomModel.SqFt" class="form-control"></InputText>
    </div>
    <div class="form-group">
        <label>Details</label>
        <InputTextArea @bind-Value="HotelRoomModel.Details" class="form-control"></InputTextArea>
    </div>
    <div class="form-group">
        <button class="btn btn-primary">@Title Room</button>
        <NavLink href="hotel-room" class="btn btn-secondary">Back to Index</NavLink>
    </div>
</EditForm>
با این خروجی:



تعریف اعتبارسنجی‌های فیلدهای یک فرم Blazor

در حین تعریف یک فرم، برای واکنش نشان دادن به دکمه‌ی submit، می‌توان رویداد OnSubmit را به کامپوننت EditForm اضافه کرد که سبب فراخوانی متدی در قسمت کدهای کامپوننت جاری خواهد شد؛ مانند فراخوانی متد HandleHotelRoomUpsert در مثال زیر:
<EditForm Model="HotelRoomModel" OnSubmit="HandleHotelRoomUpsert">
</EditForm>

@code
{
    private HotelRoomDTO HotelRoomModel = new HotelRoomDTO();

    private async Task HandleHotelRoomUpsert()
    {

    }
}
هرچند HotelRoomDTO تعریف شده به همراه تعریف اعتبارسنجی‌هایی مانند Required است، اما اگر بر روی دکمه‌ی submit کلیک کنیم، متد HandleHotelRoomUpsert فراخوانی می‌شود. یعنی روال رویدادگردان OnSubmit، صرفنظر از وضعیت اعتبارسنجی مدل فرم، همواره با submit فرم، اجرا می‌شود.
اگر این مورد، مدنظر نیست، می‌توان بجای OnSubmit، از رویداد OnValidSubmit استفاده کرد. در این حالت اگر اعتبارسنجی مدل فرم با شکست مواجه شود، دیگر متد HandleHotelRoomUpsert فراخوانی نخواهد شد. همچنین در این حالت می‌توان خطاهای اعتبارسنجی را نیز در فرم نمایش داد:
<EditForm Model="HotelRoomModel" OnValidSubmit="HandleHotelRoomUpsert">
    <DataAnnotationsValidator />
    @*<ValidationSummary />*@
    <div class="form-group">
        <label>Name</label>
        <InputText @bind-Value="HotelRoomModel.Name" class="form-control"></InputText>
        <ValidationMessage For="()=>HotelRoomModel.Name"></ValidationMessage>
    </div>
    <div class="form-group">
        <label>Occupancy</label>
        <InputNumber @bind-Value="HotelRoomModel.Occupancy" class="form-control"></InputNumber>
        <ValidationMessage For="()=>HotelRoomModel.Occupancy"></ValidationMessage>
    </div>
    <div class="form-group">
        <label>Rate</label>
        <InputNumber @bind-Value="HotelRoomModel.RegularRate" class="form-control"></InputNumber>
        <ValidationMessage For="()=>HotelRoomModel.RegularRate"></ValidationMessage>
    </div>
- در اینجا قسمت‌های تغییر کرده را مشاهده می‌کنید که به همراه درج DataAnnotationsValidator و ValidationMessage‌ها است.
- کامپوننت DataAnnotationsValidator، اعتبارسنجی مبتنی بر data annotations را مانند [Required]، در دامنه‌ی دید یک EditForm فعال می‌کند.
- اگر خواستیم تمام خطاهای اعتبارسنجی را به صورت خلاصه‌ای در بالای فرم نمایش دهیم، می‌توان از کامپوننت ValidationSummary استفاده کرد.
- و یا اگر خواستیم خطاها را به صورت اختصاصی‌تری ذیل هر تکست‌باکس نمایش دهیم، می‌توان از کامپوننت ValidationMessage کمک گرفت. خاصیت For آن از نوع <Expression<System.Func تعریف شده‌است که اجازه‌ی تعریف strongly typed نام خاصیت در حال اعتبارسنجی را به صورتی که مشاهده می‌کنید، میسر می‌کند.



ثبت اولین اتاق هتل

در ادامه می‌خواهیم روال رویدادگردان HandleHotelRoomUpsert را مدیریت کنیم. به همین جهت نیاز به کار با سرویس IHotelRoomService ابتدای بحث خواهد بود. بنابراین در ابتدا به فایل BlazorServer.App\_Imports.razor مراجعه کرده و فضای نام سرویس‌های برنامه را اضافه می‌کنیم:
@using BlazorServer.Services
اکنون امکان تزریق IHotelRoomService را که در قسمت قبل پیاده سازی و به سیستم تزریق وابستگی‌های برنامه معرفی کردیم، پیدا می‌کنیم:
@page "/hotel-room/create"

@inject IHotelRoomService HotelRoomService
@inject NavigationManager NavigationManager


@code
{
    private HotelRoomDTO HotelRoomModel = new HotelRoomDTO();
    private string Title = "Create";

    private async Task HandleHotelRoomUpsert()
    {
        var roomDetailsByName = await HotelRoomService.IsRoomUniqueAsync(HotelRoomModel.Name);
        if (roomDetailsByName != null)
        {
            //there is a duplicate room. show an error msg.
            return;
        }

        var createdResult = await HotelRoomService.CreateHotelRoomAsync(HotelRoomModel);
        NavigationManager.NavigateTo("hotel-room");
    }
}
در اینجا در ابتدا، سرویس IHotelRoomService به کامپوننت جاری تزریق شده و سپس از متدهای IsRoomUniqueAsync و CreateHotelRoomAsync آن، جهت بررسی منحصربفرد بودن نام اتاق و ثبت نهایی اطلاعات مدل برنامه که به فرم جاری به صورت دو طرفه‌ای متصل است، استفاده کرده‌ایم. در نهایت پس از ثبت اطلاعات، کاربر به صفحه‌ی نمایش لیست اتاق‌ها، توسط سرویس توکار NavigationManager، هدایت می‌شود.

اگر پیشتر با ASP.NET Web Forms کار کرده باشید (اولین روش توسعه‌ی برنامه‌های وب در دنیای دات نت)، مدل برنامه نویسی Blazor Server، بسیار شبیه به کار با وب فرم‌ها است؛ البته بر اساس آخرین تغییرات دنیای دانت نت مانند برنامه نویسی async، کار با سرویس‌ها، تزریق وابستگی‌های توکار و غیره.


نمایش لیست اتاق‌های ثبت شده


تا اینجا موفق شدیم اطلاعات یک مدل اعتبارسنجی شده را در بانک اطلاعاتی ثبت کنیم. مرحله‌ی بعد، نمایش لیست اطلاعات ثبت شده‌ی در بانک اطلاعاتی است. بنابراین به کامپوننت HotelRoomList.razor مراجعه کرده و آن‌را به صورت زیر تکمیل می‌کنیم:
@page "/hotel-room"

@inject IHotelRoomService HotelRoomService

<div class="row mt-4">
    <div class="col-8">
        <h4 class="card-title text-info">Hotel Rooms</h4>
    </div>
    <div class="col-3 offset-1">
        <NavLink href="hotel-room/create" class="btn btn-info">Add New Room</NavLink>
    </div>
</div>

<div class="row mt-4">
    <div class="col-12">
        <table class="table table-bordered table-hover">
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Occupancy</th>
                    <th>Rate</th>
                    <th>
                        Sqft
                    </th>
                    <th>

                    </th>
                </tr>
            </thead>
            <tbody>
                @if (HotelRooms.Any())
                {
                    foreach (var room in HotelRooms)
                    {
                        <tr>
                            <td>@room.Name</td>
                            <td>@room.Occupancy</td>
                            <td>@room.RegularRate.ToString("c")</td>
                            <td>@room.SqFt</td>
                            <td></td>
                        </tr>
                    }
                }
                else
                {
                    <tr>
                        <td colspan="5">No records found</td>
                    </tr>
                }
            </tbody>
        </table>
    </div>
</div>

@code
{
    private List<HotelRoomDTO> HotelRooms = new List<HotelRoomDTO>();

    protected override async Task OnInitializedAsync()
    {
        await foreach(var room in HotelRoomService.GetAllHotelRoomsAsync())
        {
            HotelRooms.Add(room);
        }
    }
}
توضیحات:
- متد GetAllHotelRoomsAsync، لیست اتاق‌های ثبت شده را بازگشت می‌دهد. البته خروجی آن از نوع <IAsyncEnumerable<HotelRoomDTO است که از زمان C# 8.0 ارائه شد و روش کار با آن اندکی متفاوت است. IAsyncEnumerable‌ها را باید توسط await foreach پردازش کرد.
- همانطور که در مطلب بررسی چرخه‌ی حیات کامپوننت‌ها نیز عنوان شد، متدهای رویدادگران OnInitialized و نمونه‌ی async آن برای دریافت اطلاعات از سرویس‌ها طراحی شده‌اند که در اینجا نمونه‌ای از آن‌را مشاهده می‌کنید.
- پس از تشکیل لیست اتاق‌ها، حلقه‌ی foreach (var room in HotelRooms) تعریف شده، ردیف‌های آن‌را در UI نمایش می‌دهد.


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: Blazor-5x-Part-14.zip
مطالب
Image Annotations
می‌خواهیم با تغییر jQuery Image Annotation  این پلاگین و برای asp.net استفاده کنیم

ایجاد دیتابیس
ابتدا یک دیتابیس به نام Coordinates  ایجاد کنید و سپس جدول زیر رو ایجاد کنید
USE [Coordinates]
GO
CREATE TABLE [dbo].[Coords2](
[top] [int] NULL,
[left] [int] NULL,
[width] [int] NULL,
[height] [int] NULL,
[text] [nvarchar](50) NULL,
[id] [uniqueidentifier] NULL,
[editable] [bit] NULL
) ON [PRIMARY]
GO

ایجاد کلاس Coords برای خواندن و ذخیره اطلاعات
public class Coords
{
    public string top;
    public string left;
    public string width;
    public string height;
    public string text;
    public string id;
    public string editable;

    public Coords(string top, string left, string width, string height, string text, string id, string editable)
    {
        this.top = top;
        this.left = left;
        this.width = width;
        this.height = height;
        this.text = text;
        this.id = id;
        this.editable = editable;


    }
}
فرم اصلی برنامه شامل 3 وب سرویس به شرح زیر می‌باشد

1-GetDynamicContext 
این متد در زمان لود اطلاعات از دیتابیس استفاده می‌شود (وقتی که postback صورت می‌گیرد)
[WebMethod]
    public static List<Coords> GetDynamicContext(string entryId, string entryName)
    {
        List<Coords> CoordsList = new List<Coords>();

        string connect = "Connection String";
        using (SqlConnection conn = new SqlConnection(connect))
        {
            string query = "SELECT [top], [left], width, height, text, id, editable FROM  Coords2";
            using (SqlCommand cmd = new SqlCommand(query, conn))
            {
                conn.Open();
                using (SqlDataReader reader=cmd.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        CoordsList.Add(new Coords(reader["top"].ToString(), reader["left"].ToString(),
                                                  reader["width"].ToString(), reader["height"].ToString(),
                                                  reader["text"].ToString(), reader["id"].ToString(),
                                                  reader["editable"].ToString()));
                    }
                }
               
               conn.Close();
            }
        }

         return CoordsList;


    }

2,3 -SaveCoordsو DeleteCoords 
این دو متد هم واسه ذخیره و حذف می‌باشند که نکته خاصی ندارند و خودتون بهینه اش کنید(در فایل ضمیمه موجودند)

تغییر فایل jquery.annotate.js  جهت فراخوانی وب سرویس ها
فقط لازمه که سه قسمت زیر رو در فایل اصلی تغییر بدید
$.fn.annotateImage.ajaxLoad = function (image) {
        ///<summary>
        ///Loads the annotations from the "getUrl" property passed in on the
        ///     options object.
        ///</summary>
      
        $.ajax({
            type: "POST",
            contentType: "application/json; charset=utf-8",
            url: "Default.aspx/GetDynamicContext",
            data: "{'entryId': '" + 1 + "','entryName': '" + 2 + "'}",
            dataType: "json",
            success: function (msg) {
                image.notes = msg.d;
                $.fn.annotateImage.load(image);
            }
        });
};

 $.fn.SaveCoords = function (note) {
        $.ajax({
            type: "POST",
            contentType: "application/json; charset=utf-8",
            url: "Default.aspx/SaveCoords",
            data: "{'top': '" + note.top + "','left': '" + note.left + "','width': '" + note.width + "','height': '" + note.height + "','text': '" + note.text + "','id': '" + note.id + "','editable': '" + note.editable + "'}",
            dataType: "json",
            success: function (msg) {
                note.id = msg.d;
             }
        });
 };
$.fn.annotateView.prototype.edit = function () {
///<summary>
///Edits the annotation.
///</summary>
if (this.image.mode == 'view') {
this.image.mode = 'edit';
var annotation = this;
// Create/prepare the editable note elements
var editable = new $.fn.annotateEdit(this.image, this.note);
$.fn.annotateImage.createSaveButton(editable, this.image, annotation);
// Add the delete button
var del = $('<a>حذف</a>');
del.click(function () {
var form = $('#image-annotate-edit-form form');
$.fn.annotateImage.appendPosition(form, editable)
if (annotation.image.useAjax) {
$.ajax({
type: "POST",
contentType: "application/json; charset=utf-8",
url: "Default.aspx/DeleteCoords",
// url: annotation.image.deleteUrl,
// data: form.serialize(),
data: "{'id': '" + editable.note.id + "'}",
dataType: "json",
success: function (msg) {
// image.notes = msg.d;
// $.fn.annotateImage.load(image);
},
error: function (e) { alert("An error occured deleting that note.") }
});
}
annotation.image.mode = 'view';
editable.destroy();
annotation.destroy();
});
editable.form.append(del);
$.fn.annotateImage.createCancelButton(editable, this.image);
}
};
این پروژه شامل یه سری فایل css هم هست که می‌تونید کل پروژه رو از  اینجا دانلود کنید
مطالب
آپلود فایل‌های Excel در ASP.NET MVC توسط ExcelDataReader

در برنامه‌های تحت وب، در بعضی موارد نیاز داریم تا برای کاربر، امکان ثبت داده‌هایش را با آپلود فایل‌های Excel فراهم کنیم. برای مثال در مطلب خواندن اطلاعات از فایل اکسل با استفاده از LinqToExcel ، امکان خواندن از Excel توضیح داده شده، اما نقطه ضعف این روش‌ها، وابستگی به Providerهای مایکروسافت است که در صورت عدم نصب آن ها:

Microsoft.Jet.OLEDB.4.0 provider --> Excel 97-2003 format (.xls)
Microsoft.ACE.OLEDB.12.0 provider --> Excel 2007+ format (.xlsx)
با خطاهای زیر روبرو می‌شویم:
The ‘Microsoft.Jet.OLEDB.4.0’ provider is not registered on the local machine
The ‘Microsoft.ACE.OLEDB.12.0’ provider is not registered on the local machine

البته راه حل، نصب  Office 2007 Data Connectivity Components یا Office 2010 Database Engine بر روی سرور می‌باشد. اما اگر هاست اشتراکی بوده و اجازه نصب نداشته باشیم؟

در این مقاله به بررسی کتابخانه ExcelDataReader می‌پردازیم که امکان خواندن فایل‌های اکسل را بدون نیاز به نصب هرگونه پیش نیازی بر روی سرور، برای ما فراهم می‌کند.

برای این کار:

1-  ابتدا یک پروژه خالی Asp.Net MVC  را ایجاد می‌کنیم.
2-  با استفاده از دستورات زیر در Package Manager Console بسته‌های ExcelDataReader و ExcelDataReader.DataSet را نصب می‌کنیم:

PM> Install-Package ExcelDataReader
PM> Install-Package ExcelDataReader.DataSet
توجه: دو روش برای خواندن از فایل‌های اکسل در این کتابخانه وجود دارد که نصب بسته دوم مربوط به روش دوم آن است.


3-  سپس کنترلر مورد نظر (در اینجا HomeController) را ایجاد نموده و اکشن Upload را بصورت زیر در آن قرار می‌دهیم:

public ActionResult Upload()
{
      return View();
}

4- برای آپلود فایل، اکشن دیگری را با نام Upload نیاز داریم که آن را بصورت زیر ایجاد می‌کنیم:

توجه: در قطعه کد زیر سعی شده از حداقل کانفیگ کتابخانه استفاده شود. کانفیگ بیشتر

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Upload(HttpPostedFileBase upload)
{  
           // اعتبار سنجی فایل آپلود شده
            if (upload != null && upload.ContentLength > 0 && (upload.FileName.EndsWith(".xls") || upload.FileName.EndsWith(".xlsx")))
            {
                // با خواندن فایل به صورت باینری، این کتابخانه نیازی به نصب پیش نیازهای آفیس ندارد
                Stream stream = upload.InputStream;

                //  نیازی به نگرانی در مورد پسوند فایل نیست
                // کتابخانه به صورت خودکار کلاس مورد نظر برای پسوند مربوطه را استفاده می‌کند
                // ExcelDataReader.ExcelBinaryReader یا ExcelDataReader.ExcelOpenXmlReader
                IExcelDataReader reader = ExcelReaderFactory.CreateReader(stream);

                // روش ذکر شده در قسمت دوم برای خواندن کل اطلاعات بصورت یکجا
                DataSet result = reader.AsDataSet(new ExcelDataSetConfiguration()
               {
                    ConfigureDataTable = (tableReader) => new ExcelDataTableConfiguration()
                    {
                        // true: ردیف اول از فایل را به عنوان هدر در نظر می‌گیرد
                        // مقدار پیش فرض: false
                        UseHeaderRow = true
                    }
                });

                reader.Close();
                return View(result.Tables[0]);
            }
      ModelState.AddModelError("File", "Please upload Excel file ...");
      return View();
 }  

  روش دیگر خواندن اطلاعات:
do {
        while (reader.Read()) {
       // reader.GetDouble(0);
        }
    } while (reader.NextResult());


5-  خب حالا از یک View (با نام Upload) هم برای ارسال فایل و همچنین نمایش محتویات آپلود شده بصورت زیر استفاده می‌کنیم:
@model System.Data.DataTable
@using System.Data;

<h2>Upload File</h2>

@using (Html.BeginForm("Upload", "Home", null, FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()
    @Html.ValidationSummary()

    <div>
        <input type="file" id="dataFile" name="upload" />
    </div>

    <div>
        <input type="submit" value="Upload" />
    </div>

    if (Model != null)
    {
        <table>
            <thead>
                <tr>
                    @foreach (DataColumn col in Model.Columns)
                    {
                        <th>@col.ColumnName</th>
                    }
                </tr>
            </thead>
            <tbody>
                @foreach (DataRow row in Model.Rows)
                {
                    <tr>
                        @foreach (DataColumn col in Model.Columns)
                        {
                            <td>@row[col.ColumnName]</td>
                        }
                    </tr>
                }
            </tbody>
        </table>
    }
}
توجه داشته باشید که این مثال آموزشی است و در پروژه واقعی، قطعا روش‌های بهتری برای پردازش، پیمایش و نمایش محتوا وجود دارد.


6-  حالا پروژه را اجرا می‌کنیم تا خروجی را مشاهده کنیم.

ابتدا فایل مورد نظر را انتخاب و آپلود می‌کنیم:


انتخا فایل اکسل

و خروجی به صورت زیر خواهد بود:

خروجی

کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید: UploadExcelFiles.rar

منابع: ^ و ^