مطالب
آشنایی با Refactoring - قسمت 6

در ادامه بحث «حذف کدهای تکراری»، روش Refactoring دیگری به نام "Extract Superclass" وجود دارد که البته در بین برنامه نویس‌های دات نت به نام Base class بیشتر مشهور است تا Superclass. هدف آن هم انتقال کدهای تکراری بین چند کلاس، به یک کلاس پایه و سپس ارث بری از آن می‌باشد.

یک مثال:
در WPF و Silverlight جهت مطلع سازی رابط کاربری از تغییرات حاصل شده در مقادیر داده‌ها، نیاز است کلاس مورد نظر، اینترفیس INotifyPropertyChanged را پیاده سازی کند:

using System.ComponentModel;

namespace Refactoring.Day6.ExtractSuperclass.Before
{
public class User : INotifyPropertyChanged
{
string _name;
public string Name
{
get { return _name; }
set
{
if (_name == value) return;
_name = value;
raisePropertyChanged("Name");
}
}

public event PropertyChangedEventHandler PropertyChanged;
void raisePropertyChanged(string propertyName)
{
var handler = PropertyChanged;
if (handler == null) return;
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}


و نکته‌ی مهم این است که اگر 100 کلاس هم داشته باشید، باید این کدهای تکراری اجباری مرتبط با raisePropertyChanged را در آن‌ها قرار دهید. به همین جهت مرسوم است برای کاهش حجم کدهای تکرای، قسمت‌های تکراری کد فوق را در یک کلاس پایه قرار می‌دهند:

using System.ComponentModel;

namespace Refactoring.Day6.ExtractSuperclass.After
{
public class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void RaisePropertyChanged(string propertyName)
{
var handler = PropertyChanged;
if (handler == null) return;
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}

و سپس از آن ارث بری می‌کنند:

namespace Refactoring.Day6.ExtractSuperclass.After
{
public class User : ViewModelBase
{
string _name;
public string Name
{
get { return _name; }
set
{
if (_name == value) return;
_name = value;
RaisePropertyChanged("Name");
}
}
}
}


به این ترتیب این کلاس پایه در ده‌ها و صدها کلاس قابل استفاده خواهد بود، بدون اینکه مجبور شویم مرتبا یک سری کد تکراری «اجباری» را copy/paste کنیم.

مثالی دیگر:
اگر با ORM های Code first کار کنید، نیاز است تا ابتدا طراحی کار توسط کلاس‌های ساده دات نتی انجام شود؛ که اصطلاحا به آن‌ها POCO یا Plain old CLR objects یا Plain old .NET Classes هم گفته می‌شود. در بین این کلاس‌ها، متداول است که یک سری از خصوصیات، تکراری و مشترک باشد؛ مثلا تمام کلاس‌ها تاریخ ثبت رکورد را هم داشته باشند به همراه نام کاربر و مشخصاتی از این دست. اینجا هم برای حذف کدهای تکراری، یک Base class طراحی می‌شود: (+)

مطالب
به روز رسانی‌های مهم هفته دوم شهریور 89

  • نسخه‌ی جدید برنامه Resharper ارائه شده به همراه بهبودهایی در کارآیی آن.
لیست موارد برطرف شده : +
دریافت : +

  • به روز رسانی‌هایی هم در مورد سیلورلایت 4 ارائه شده و اگر آپدیت ویندوز شما روشن بوده باشد، حتما حداقل runtime آن‌را به صورت خودکار دریافت کرده‌اید و از آنجائیکه visual studio LightSwitch هم مبتنی بر سیلورلایت 4 و WCF RIA Services است؛ این به روز رسانی‌ها شامل حال این برنامه نیز می‌گردد.

دریافت SDK جدید: +
دریافت Runtime جدید: +
توضیحات بیشتر در مورد موارد فیکس شده: +


مطالب
آشنایی با mocking frameworks (چارچوب‌های تقلید) - قسمت اول

این مطلب در ادامه‌ی مطالب آزمو‌ن‌های واحد یا unit testing است.
نوشتن آزمون واحد برای کلاس‌هایی که با یک سری از الگوریتم‌ها ، مسایل ریاضی و امثال آن سر و کار دارند، ساده است. عموما این نوع کلاس‌ها وابستگی خارجی آنچنانی ندارند؛ اما در عمل کلاس‌های ما ممکن است وابستگی‌های خارجی بسیاری پیدا کنند؛ برای مثال کار با دیتابیس، اتصال به یک وب سرویس، دریافت فایل از اینترنت، خواندن اطلاعات از انواع فایل‌ها و غیره.
مطابق اصول آزمایشات واحد، یک آزمون واحد خوب باید ایزوله باشد. نباید به مرزهای سیستم‌های دیگر وارد شده و عملکرد سیستم‌های خارج از کلاس را بررسی کند.
این مثال ساده را در نظر بگیرید:
فرض کنید برنامه شما قرار است از یک وب سرویس لیستی از آدرس‌های IP یک کشور خاص را دریافت کند و در یک دیتابیس محلی آن‌ها را ذخیره نماید. به صورت متداول این کلاس باید اتصالی را به وب سرویس گشوده و اطلاعات را دریافت کند و همچنین آن‌ها را خارج از مرز کلاس در یک دیتابیس ثبت کند. نوشتن آزمون واحد برای این کلاس مطابق اصول مربوطه غیر ممکن است. اگر کلاس آزمون واحد آن‌را تهیه نمائید، این آزمون، integration test نام خواهد گرفت زیرا از مرزهای سیستم باید عبور نماید.

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

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

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

public static string GetComparisonText(IComparable a, IComparable b)
{
if (a.CompareTo(b) == 1)
return "a is bigger";
if (a.CompareTo(b) == -1)
return "b is bigger";
return "same";
}
در این مثال چون از IComparable استفاده شده، متد ما از هر نوع داده‌ای جهت مقایسه می‌تواند استفاده کند. تنها موردی که برای آن مهم خواهد بود این است که a راهی را برای مقایسه با b ارائه دهد.

اکنون با توجه به این توضیحات، برای ایزوله کردن ارتباط با دیتابیس و وب سرویس در مثال فوق، می‌توان اینترفیس‌های زیر را تدارک دید:
    public interface IEmailSource
{
IEnumerable<string> GetEmailAddresses();
}

public interface IEmailDataStore
{
void SaveEmailAddresses(IEnumerable<string> emailAddresses);

}
در اینجا استفاده و تعریف اینترفیس‌ها چندین خاصیت را به همراه خواهد داشت :
الف) به این صورت تنها مشخص می‌شود که چه کاری را قصد داریم انجام دهیم و کاری به پیاده سازی آن نداریم.
ب) ساخت کلاس بدون وجود یا دسترسی به یک دیتابیس میسر می‌شود. این مورد خصوصا در یک کار تیمی که قسمت‌های مختلف کار به صورت همزمان در حالت پیشرفت و تهیه است حائز اهمیت می‌شود.
ج) با توجه به اینکه در اینجا به پیاده سازی توجهی نداریم، می‌توان از این اینترفیس‌ها جهت تقلید دنیای واقعی استفاده کنیم. (که در اینجا mocking نام گرفته است)

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

public class MockEmailSource : IEmailSource
{
public IEnumerable<string> EmailAddressesToReturn { get; set; }
public IEnumerable<string> GetEmailAddresses()
{
return EmailAddressesToReturn;
}
}

public class MockEmailDataStore : IEmailDataStore
{
public IEnumerable<string> SavedEmailAddresses { get; set; }
public void SaveEmailAddresses(IEnumerable<string> emailAddresses)
{
SavedEmailAddresses = emailAddresses;
}

}
تا اینجا اولین قدم در مورد ایزوله سازی کلاس‌هایی که به مرز سیستم‌های دیگر وارد می‌شوند، برداشته شد. اما به مرور زمان مدیریت این اینترفیس‌ها و افزودن رفتارهای جدید به کلاس‌های مشتق شده از آن‌ها مشکل می‌شود. به همین جهت تا حد ممکن از پیاده سازی دستی آن‌ها خودداری شده و روش پیشنهادی استفاده از mocking frameworks است.

ادامه دارد ....

نظرات مطالب
استفاده از GitHub Actions برای Build و توزیع خودکار پروژه‌های NET Core.
یک نکته تکمیلی:
برای Build پروژه‌های NET Framework Full. (مانند پروژه‌های ASP.NET MVC قدیم) باید به شکل زیر عمل کنید.
jobs:
  build:

    runs-on: windows-latest
    
    steps:
    - name: Checkout
      uses: actions/checkout@v2
        
    - name: Setup MSBuild
      uses: microsoft/setup-msbuild@v1.0.0  
       
    - name: Setup NuGet
      uses: nuget/setup-nuget@v1.0.2
     
    - name: Restore NuGet Packages
      run: nuget restore src/WebApplication1.sln
 
    - name: Build (Release)
      run: msbuild src/WebApplication1.sln /p:Configuration=Release
  • دستور runs-on: windows-latest این workflow را بر روی ویندوز اجرا میکنه (بدیهیه که پروژه‌های دات نت Full بر روی غیر از ویندوز قابل build شدن نیست)
  • در step دوم MSBuild رو توسط اکشن microsoft/setup-msbuild نصب میکنیم.
  • در step سوم Nuget رو توسطاکشن nuget/setup-nuget نصب میکنیم.
  • در step چهارم وابستگی (پکیج)‌های پروژه رو restore میکنیم. 
  • در step چهارم پروژه رو توسط msbuild و در مود Release بیلد میکنیم.
  • عبارت src/WebApplication1.sln به پوشه و نام فایل سلوشن شما اشاره میکنه.

و برای Test پروژه‌های خود میتونین از اکشن  Malcolmnixon/Setup-VSTest به نحو زیر استفاده کنین
    - name: Setup VSTest
      uses: Malcolmnixon/Setup-VSTest@v2

    - name: VSTest
      run: vstest.console ClassLibrary.Test\bin\Debug\ClassLibrary.Test.dll
عبارت ClassLibrary.Test\bin\Debug\ClassLibrary.Test.dll   به مسیر و نام dll تست شما اشاره میکنه.
اشتراک‌ها
طراحی جدید Entity Framework Profiler
نکته جالب آن، استفاده از قابلیت اجرای خارج از مرورگر برنامه‌های سیلورلایت است. کل برنامه با سیلورلایت تهیه شده. هم نسخه دسکتاپ را فراهم آورده و هم نسخه تحت وب را.
طراحی جدید Entity Framework Profiler
مطالب
C# 8.0 - Default implementations in interfaces
اگر مطلب «تفاوت بین Interface و کلاس Abstract در چیست؟» را مطالعه کرده باشید، به این نتیجه می‌رسید که طراحی یک کتابخانه‌ی عمومی با اینترفیس‌ها، بسیار شکننده‌است. اگر عضو جدیدی را به یک اینترفیس عمومی اضافه کنیم، تمام پیاده سازی کننده‌های آن‌را از درجه‌ی اعتبار ساقط می‌کند و آن‌ها نیز باید این عضو را حتما پیاده سازی کنند تا برنامه‌ای که پیش از این به خوبی کار می‌کرده، باز هم بدون مشکل کامپایل شده و کار کند. هدف از ویژگی جدید «پیاده سازی‌های پیش‌فرض در اینترفیس‌ها» در C# 8.0، پایان دادن به این مشکل مهم است. با استفاده از این ویژگی جدید، می‌توان یک عضو جدید را با پیاده سازی پیش‌فرضی داخل خود اینترفیس قرار داد. به این ترتیب تمام برنامه‌هایی که از کتابخانه‌های عمومی شما استفاده می‌کنند، با به روز رسانی آن، به یکباره از کار نخواهند افتاد.
همچنین مزیت دیگر آن، انتقال ساده‌تر کدهای جاوا به سی‌شارپ است؛ از این لحاظ که ویژگی مشابهی در زبان جاوا تحت عنوان «Default Methods» سال‌ها است که وجود دارد.


یک مثال از ویژگی «پیاده سازی‌های پیش‌فرض در اینترفیس‌ها»

interface ILogger
{
    void Log(string message);
}

class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}
فرض کنید کتابخانه‌ی شما، اینترفیس ILogger را ارائه داده‌است و در برنامه‌ای دیگر، استفاده کننده، کلاس ConsoleLogger را بر مبنای آن پیاده سازی و استفاده کرده‌است.
مدتی بعد بر اساس نیازمندی‌های مشخصی به این نتیجه خواهید رسید که بهتر است overload دیگری را برای متد Log در اینترفیس ILogger، درنظر بگیریم. مشکلی که این تغییر به همراه دارد، کامپایل نشدن کلاس ConsoleLogger در یک برنامه‌ی ثالث است و این کلاس باید الزاما این overload جدید را پیاده سازی کند؛ در غیراینصورت قادر به کامپایل برنامه‌ی خود نخواهد شد. اکنون در C# 8.0 می‌توان برای این نوع تغییرات، در همان اینترفیس اصلی، یک پیاده سازی پیش‌فرض را نیز قرار داد:
interface ILogger
{
    void Log(string message);
    void Log(Exception exception) => Console.WriteLine(exception);
}
به این ترتیب استفاده کنندگان از این اینترفیس، برای کامپایل برنامه‌ی خود به مشکلی برنخواهند خورد و اگر از این overload جدید استفاده کنند، از همان پیاده سازی پیش‌فرض آن بهره خواهند برد. بدیهی است هنوز هم پیاده سازی کننده‌های اینترفیس ILogger می‌توانند پیاده سازی‌های سفارشی خودشان را در مورد این overload جدید ارائه دهند. در این حالت از پیاده سازی پیش‌فرض صرفنظر خواهد شد.


ویژگی «پیاده سازی‌های پیش‌فرض در اینترفیس‌ها» چگونه پیاده سازی شده‌است؟

واقعیت این است که امکان پیاده سازی این ویژگی، سال‌ها است که در سطح کدهای IL دات نت وجود داشته (از زمان دات نت 2) و اکنون از طریق کدهای برنامه با بهبود کامپایلر آن، قابل دسترسی شده‌است.


تاثیر زمینه‌ی کاری بر روی دسترسی به پیاده سازی‌های پیش‌فرض

مثال زیر را درنظر بگیرید:
    interface IDeveloper
    {
        void LearnNewLanguage(string language, DateTime dueDate);

        void LearnNewLanguage(string language)
        {
            // default implementation
            LearnNewLanguage(language, DateTime.Now.AddMonths(6));
        }
    }

    class BackendDev : IDeveloper // compiles OK
    {
        public void LearnNewLanguage(string language, DateTime dueDate)
        {
            // Learning new language...
        }
    }
در اینجا اینترفیس IDeveloper، به همراه یک پیاده سازی پیش‌فرض است و بر این اساس، کلاس BackendDev پیاده سازی کننده‌ی آن، دیگر نیازی به پیاده سازی اجباری متد LearnNewLanguage ای که تنها یک رشته را می‌پذیرد، ندارد.
سؤال: به نظر شما اکنون کدامیک از کاربردهای زیر از کلاس BackendDev، کامپایل می‌شود و کدامیک خیر؟
IDeveloper dev1 = new BackendDev();
dev1.LearnNewLanguage("Rust");

var dev2 = new BackendDev();
dev2.LearnNewLanguage("Rust");
پاسخ: فقط مورد اول. مورد دوم با خطای کامپایلر زیر مواجه خواهد شد:
 There is no argument given that corresponds to the required formal parameter 'dueDate' of 'BackendDev.LearnNewLanguage(string, DateTime)' (CS7036) [ConsoleApp]
به این معنا که اگر کلاس BackendDev را به خودی خود (دقیقا از نوع BackendDev) و بدون معرفی آن از نوع اینترفیس IDeveloper، بکار بگیریم، فقط همان متدهایی که داخل این کلاس تعریف شده‌اند، قابل دسترسی می‌باشند و نه متدهای پیش‌فرض تعریف شده‌ی در اینترفیس مشتق شده‌ی از آن.


ارث‌بری چندگانه چطور؟

احتمالا حدس زده‌اید که این قابلیت ممکن است ارث‌بری چندگانه را که در سی‌شارپ ممنوع است، میسر کند. تا C# 8.0، یک کلاس تنها از یک کلاس دیگر می‌تواند مشتق شود؛ اما این محدودیت در مورد اینترفیس‌ها وجود ندارد. به علاوه تاکنون اینترفیس‌ها مانند کلاس‌ها، امکان تعریف پیاده سازی خاصی را نداشتند و صرفا یک قرارداد بیشتر نبودند. بنابراین اکنون این سؤال مطرح می‌شود که آیا می‌توان با ارائه‌ی پیاده سازی پیش‌فرض متدها در اینترفیس‌ها، ارث‌بری چندگانه را در سی‌شارپ پیاده سازی کرد؛ مانند مثال زیر؟!
using System;

namespace ConsoleApp
{
    public interface IDev
    {
        void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a default way.");
    }

    public interface IBackendDev : IDev
    {
        void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a backend way.");
    }

    public interface IFrontendDev : IDev
    {
        void LearnNewLanguage(string language) => Console.Write($"Learning {language} in a frontend way.");
    }

    public interface IFullStackDev : IBackendDev, IFrontendDev { }

    public class Dev : IFullStackDev { }
}
سؤال: کد فوق بدون مشکل کامپایل می‌شود. اما در فراخوانی زیر، دقیقا از کدام متد LearnNewLanguage استفاده خواهد شد؟ آیا پیاده سازی آن از IBackendDev فراهم می‌شود و یا از IFrontendDev؟
IFullStackDev dev = new Dev();
dev.LearnNewLanguage("TypeScript");
پاسخ: هیچکدام! برنامه با خطای زیر کامپایل نخواهد شد:
The call is ambiguous between the following methods or properties: 'IBackendDev.LearnNewLanguage(string)' and 'IFrontendDev.LearnNewLanguage(string)' (CS0121)
کامپایلر سی‌شارپ در این مورد خاص از قانونی به نام «the most specific override rule» استفاده می‌کند. یعنی اگر برای مثال در IFullStackDev متد LearnNewLanguage به صورت صریحی بازنویسی و تامین شد، آنگاه امکان استفاده‌ی از آن وجود خواهد داشت. یا حتی می‌توان این پیاده سازی را در کلاس Dev نیز ارائه داد و از نوع آن (بجای نوع اینترفیس) استفاده کرد.


تفاوت امکانات کلاس‌های Abstract با متدهای پیش‌فرض اینترفیس‌ها چیست؟

اینترفیس‌ها هنوز نمی‌توانند مانند کلاس‌ها، سازنده‌ای را تعریف کنند. نمی‌توانند متغیرها/فیلدهایی را در سطح اینترفیس داشته باشند. همچنین در اینترفیس‌ها همه‌چیز public است و امکان تعریف سطح دسترسی دیگری وجود ندارد.
بنابراین باید بخاطر داشت که هدف از تعریف اینترفیس‌ها، ارائه‌ی «یک رفتار» است و هدف از تعریف کلاس‌ها، ارائه «یک حالت».


یک نکته: در نگارش‌های پیش از C# 8.0 هم می‌توان ویژگی «متدهای پیش‌فرض» را شبیه سازی کرد

واقعیت این است که توسط ویژگی «متدهای الحاقی»، سال‌ها است که امکان افزودن «متدهای پیش‌فرضی» به اینترفیس‌ها در زبان سی‌شارپ وجود دارد:
namespace MyNamespace
{
    public interface IMyInterface
    {
        IList<int> Values { get; set; }
    }

    public static class MyInterfaceExtensions
    {
        public static int CountGreaterThan(this IMyInterface myInterface, int threshold)
        {
            return myInterface.Values?.Where(p => p > threshold).Count() ?? 0;
        }
    }
}
و در این حالت هرچند به نظر اینترفیس IMyInterface دارای متدی نیست، اما فراخوانی زیر مجاز است:
var myImplementation = new MyInterfaceImplementation();
// Note that there's no typecast to IMyInterface required
var countGreaterThanFive = myImplementation.CountGreaterThan(5);
نظرات مطالب
VS Code برای توسعه دهندگان ASP.NET Core - قسمت دوم - ایجاد و اجرای اولین برنامه
احتمال دارد که دو نسخه‌ی 32 بیتی و 64 بیتی از دات نت بر روی سیستم شما نصب هست و همچنین مسیر 32 بیتی به اشتباه به path ویندوز اضافه شده. این نسخه‌ی 32 بیتی باید حذف شود. اطلاعات بیشتر
به صورت خلاصه، متغیرهای محیطی ویندوز را باز کنید (System Environment). بعد متغیر Path را بررسی کنید که آیا به مسیر
C:\Program Files (x86)\dotnet
اشاره می‌کند؟ اگر بله، آن‌را به
C:\Program Files\dotnet
ویرایش کنید.
نظرات مطالب
نحوه کار با ftp - بخش اول
ممنون از مطلب شما.
چند نکته جزئی در مورد کدهای تهیه شده:
- وجود این try/catch در اینجا هیچ هدفی رو برآورده نکرده. از قسمت throw ex هم توصیه می‌شود که استفاده نکنید. از thow خالی استفاده کنید تا stack trace پاک نشه.  به علاوه زمانیکه مشغول به طراحی یک کتابخانه هستید تا حد ممکن از ذکر try/catch خودداری کنید. وظیفه بررسی این مسایل مرتبط است به لایه‌های بالاتر استفاده کننده و نه کتابخانه پایه.
- if ابتدای متد هم ضرورتی ندارد. اگر قرار است باشد، باید به استفاده کننده در طی یک استثناء اعلام شود که چرا فایل درخواستی او آپلود نشده. در کل استفاده از متد File.Exists به همراه صدور استثناء در صورت عدم وجود فایل، در اینجا مناسب‌تر است.
- نامگذاری‌هایی مانند obj_ftp مربوط به دوران C است. در سی‌شارپ روش دیگری رو باید استفاده کنید که در مطلب اصول نامگذاری در دات نت به تفصیل بررسی شده.
- بررسی صفر بودن readBytes بهتر است پیش از فراخوانی متد Write انجام شود.
- یک سری از اشیاء در دات نت پیاده سازی کننده IDispoable هستند. به این معنا که بهتر است از using برای استفاده از آن‌ها کمک گرفته شود تا کامپایلر قسمت finally به همراه آزاد سازی منابع را به صورت خودکار اضافه کند. این نکته برای مواردی که در بین کار استثنایی رخ می‌دهد جهت آزاد سازی منابع لازم است. یعنی بهتر بود بجای try/catch از try/finally و یا using در مکان‌های مناسب استفاده می‌شد.
- علت استفاده از شیء Application دراینجا چه چیزی بوده؟ AppSettings خوانده شده از وب کانفیگ برنامه و کل اطلاعات آن در آغاز به کار یک برنامه ASP.NET به صورت خودکار کش می‌شوند. به همین جهت است که اگر حتی یک نقطه در فایل وب کانفیگ تغییر کند برنامه ASP.NET ری استارت می‌شود (تا دوباره تنظیمات را بخواند). بنابراین مستقیما از همان امکانات ConfigurationManager بدون انتساب آن به شیء سراسری Application استفاده کنید. اینکار سرباری آنچنانی هم ندارد؛ چون از حافظه خوانده می‌شود و نه از فایل. هر بار فراخوانی ConfigurationManager.AppSettings به معنای مراجعه به فایل web.config نیست. فقط بار اول اینکار انجام می‌شود؛ تا زمانیکه این فایل تغییر کند یا برنامه ری استارت شود.
مطالب
خروجی Excel با حجم بالا در برنامه‌های ‌ASP.NET Core با استفاده از MiniExcel

امکان خروجی اکسل از گزارشات سیستم، یکی از بایدهای بیشتر سیستم‌های اطلاعاتی می‌باشد؛ یکی از چالش‌های اصلی در تولید این نوع خروجی، افزایش مصرف حافظه متناسب با افزایش حجم دیتا می‌باشد. از آنجایی‌که بیشتر راهکارهای موجود از جمله ClosedXml یا Epplus کل ساختار را ابتدا تولید کرده و اصطلاحا خروجی مورد نظر را بافر می‌کنند، برای حجم بالای اطلاعات مناسب نخواهند بود. راهکار برای خروجی CSV به عنوان مثال خیلی سرراست می‌باشد و می‌توان با چند خط کد، به نتیجه دلخواه از طریق مکانیزم Streaming رسید؛ ولی ساختار Excel به سادگی فرمت CSV نیست و برای مثال فرمت Excel Workbook با پسوند xlsx یک بسته Zip شده‌ای از فایل‌های XML می‌باشد.

معرفی MiniExcel

MiniExcel یک کتابخانه سورس باز با هدف به حداقل رساندن مصرف حافظه در زمان پردازش فایل‌های Excel در دات نت می‌باشد. در مقایسه با Aspose از منظر امکانات شاید حرفی برای گفتن نداشته باشد، ولی از جهت خواندن اطلاعات فایل‌های Excel با قابلیت پشتیبانی از ‌LINQ و Deferred Execution در کنار مصرف کم حافظه و جلوگیری از مشکل OOM خیلی خوب عمل می‌کند. در تصویر زیر مشخص است که برای عمده عملیات پیاده‌سازی شده، از استریم‌ها بهره برده شده است.

همچنین در زیر مقایسه‌ای روی خروجی ۱ میلیون رکورد با تعداد ۱۰ ستون در هر ردیف انجام شده‌است که قابل توجه می‌باشد:

Logic : create a total of 10,000,000 "HelloWorld" excel
LibraryMethodMax Memory UsageMean
MiniExcel'MiniExcel Create Xlsx'15 MB11.53181 sec
Epplus'Epplus Create Xlsx'1,204 MB22.50971 sec
OpenXmlSdk'OpenXmlSdk Create Xlsx'2,621 MB42.47399 sec
ClosedXml'ClosedXml Create Xlsx'7,141 MB140.93992 sec

به شدت API خوش دستی برای استفاده دارد و شاید مطالعه سورس کد آن از جهت طراحی نیز درس آموزی داشته باشد. در ادامه چند مثال از مستندات آن را می‌توانید ملاحظه کنید:

var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx");
MiniExcel.SaveAs(path, new[] {
    new { Column1 = "MiniExcel", Column2 = 1 },
    new { Column1 = "Github", Column2 = 2}
});

// DataReader export multiple sheets (recommand by Dapper ExecuteReader)

using (var cnn = Connection)
{
    cnn.Open();
    var sheets = new Dictionary<string,object>();
    sheets.Add("sheet1", cnn.ExecuteReader("select 1 id"));
    sheets.Add("sheet2", cnn.ExecuteReader("select 2 id"));
    MiniExcel.SaveAs("Demo.xlsx", sheets);
}

طراحی یک ActionResult سفارشی برای استفاده از MiniExcel

برای این منظور نیاز است تا Stream مربوط به Response درخواست جاری را در اختیار این کتابخانه قرار دهیم و از سمت دیگر دیتای مورد نیاز را به نحوی که بافر نشود و از طریق مکانیزم Streaming در EF (استفاده از Deferred Execution و Enumerableها) مهیا کنیم. برای امکان تعویض پذیری (این سناریو در پروژه واقعی و باتوجه به جهت وابستگی‌ها می‌تواند ضروری باشد) از دو واسط زیر استفاده خواهیم کرد:

public interface IExcelDocumentFactory
{
    ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> headers, Stream stream);
}


public interface ILargeExcelDocument : IAsyncDisposable, IDisposable
{
    Task Write<T>(
        PaginatedEnumerable<T> items,
        int count,
        int sizeLimit,
        CancellationToken cancellationToken = default) where T : notnull;
}

متد CreateLargeDocument یک وهله از ILargeExcelDocument را در اختیار مصرف کننده قرار می‌دهد که قابلیت نوشتن روی آن از طریق متد Write را خواهد داشت. روش واکشی دیتا از طریق Delegate تعریف شده با نام PaginatedEnumerable به مصرف کننده محول شده‌است که در ادامه امضای آن را می‌توانید مشاهده کنید:

public delegate IEnumerable<T> PaginatedEnumerable<out T>(int page, int pageSize);

در ادامه پیاده‌سازی واسط ILargeExcelDocument برای MiniExcel به شکل زیر خواهد بود:

internal sealed class MiniExcelDocument(Stream stream, IEnumerable<ExcelColumn> columns) : ILargeExcelDocument
{
    private const int SheetLimit = 1_048_576;
    private bool _disposedValue;

    public async Task Write<T>(
        PaginatedEnumerable<T> items,
        int count,
        int sizeLimit,
        CancellationToken cancellationToken = default)
        where T : notnull
    {
        ThrowIfDisposed();
        
        // TODO: apply sizeLimit
        var properties = FastReflection.Instance.GetProperties(typeof(T))
            .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);

        var sheets = new Dictionary<string, object>();
        var index = 1;
        while (count > 0)
        {
            cancellationToken.ThrowIfCancellationRequested();

            IEnumerable<Dictionary<string, object>> reader = items(index, SheetLimit)
                .Select(item =>
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    return columns.ToDictionary(h => h.Title, h => ValueOf(item, h.Name, properties));
                });

            sheets.Add($"sheet_{index}", reader);
            count -= SheetLimit;
            index++;
        }

        // This part is forward-only, and we are pretty sure that streaming will happen without buffering.
        await stream.SaveAsAsync(sheets, cancellationToken: cancellationToken);
    }

    private void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                // TODO: dispose managed state (managed objects)
            }

            // TODO: free unmanaged resources (unmanaged objects) and override finalizer
            // TODO: set large fields to null
            _disposedValue = true;
        }
    }

    ~MiniExcelDocument()
    {
        Dispose(disposing: false);
    }

    public void Dispose()
    {
        // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    public async ValueTask DisposeAsync()
    {
        Dispose();
        await ValueTask.CompletedTask;
    }

    private void ThrowIfDisposed()
    {
        if (!_disposedValue) return;
        
        throw new ObjectDisposedException(nameof(MiniExcelDocument));
    }
    private static object ValueOf<T>(T record, string prop, IDictionary<string, FastPropertyInfo> properties)
        where T : notnull
    {
        var property = properties[prop] ??
                       throw new InvalidOperationException($"There is no property with given name [{prop}]");

        return NormalizeValue(property.GetValue?.Invoke(record));
    }

    private static object NormalizeValue(object? value)
    {
        if (value == null) return null!;

        return value switch
        {
            DateTime dateTime => dateTime.ToShortPersianDateTimeString(),
            TimeSpan time => time.ToString(@"hh\:mm\:ss"),
            DateOnly dateTime => dateTime.ToShortPersianDateString(false),
            TimeOnly time => time.ToString(@"hh\:mm\:ss"),
            bool boolean => boolean ? "بلی" : "خیر",
            IEnumerable<object> values => string.Join(',', values.Select(NormalizeValue).ToList()),
            Enum enumField => enumField.GetEnumStringValue(),
            _ => value
        };
    }
}

در بدنه متد Write باتوجه به تعداد کل رکوردها، یک کوئری برای هر شیت از طریق فراخوانی متد منتسب به پارامتر items اجرا خواهد شد؛ توجه کنید که اجرای این کوئری مشخصا به تعویق افتاده و تا زمان اولین MoveNext، اجرایی صورت نخواهد گرفت (مفهوم Deferred Execution). به این ترتیب باقی کارها از جمله فرمت کردن مقادیر در سمت برنامه و از طریق Linq To Object انجام خواهد شد. همچنین پیاده‌سازی Factory مرتبط با آن به شکل زیر خواهد بود:

internal sealed class ExcelDocumentFactory : IExcelDocumentFactory
{
    public ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> columns, Stream stream)
    {
        return new MiniExcelDocument(stream, columns);
    }
}

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

public class ExcelExportResult<T>(PaginatedEnumerable<T> items, int count, ExportMetadata metadata) : ActionResult
    where T : notnull
{
    private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
    private const string Extension = ".xlsx";
    private const int SizeLimit = int.MaxValue;

    private readonly IReadOnlyList<FastPropertyInfo> _properties = FastReflection.Instance.GetProperties(typeof(T));

    public override async Task ExecuteResultAsync(ActionContext context)
    {
        var sp = context.HttpContext.RequestServices;
        var factory = sp.GetRequiredService<IExcelDocumentFactory>();

        var disposition = new ContentDispositionHeaderValue(DispositionTypeNames.Attachment);
        disposition.SetHttpFileName(MakeFilename());

        context.HttpContext.Response.Headers[HeaderNames.ContentDisposition] = disposition.ToString();
        context.HttpContext.Response.Headers.Append(HeaderNames.ContentType, ContentType);
        context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;

        //TODO: deal with exception, because our global exception handling cannot take into account while the response is started.

        await using var bodyStream = context.HttpContext.Response.BodyWriter.AsStream();
        await context.HttpContext.Response.StartAsync(context.HttpContext.RequestAborted);
        await using (var document = factory.CreateLargeDocument(MakeColumns(), bodyStream))
        {
            await document.Write(items, count, SizeLimit, context.HttpContext.RequestAborted);
        }

        await context.HttpContext.Response.CompleteAsync();
    }

    private string MakeFilename()
    {
        return
            $"{metadata.Title} - {DateTime.UtcNow.ToEpochSeconds()}{Extension}";
    }

    private IEnumerable<ExcelColumn> MakeColumns()
    {
        var types = _properties.ToDictionary(p => p.Name, p => p.PropertyType, StringComparer.OrdinalIgnoreCase);
        return metadata.Fields.Select(f =>
        {
            var type = types[f.Name];

            type = Nullable.GetUnderlyingType(type) ?? type;

            if (type.IsEnum ||
                type == typeof(DateOnly) ||
                type == typeof(TimeOnly) ||
                type == typeof(bool) ||
                type == typeof(TimeSpan) ||
                type == typeof(DateTime))
            {
                type = typeof(string);
            }

            return new ExcelColumn(f.Name, f.Title, type);
        });
    }
}

در اینجا از طریق ExportMetadata که از سمت کاربر تعیین می‌شود، مشخص خواهد شد که کدام فیلدها در فایل نهایی حضور داشته باشند. در بدنه متد ExecuteResultAsync یکسری هدر مرتبط با کار با فایل‌ها تنظیم شده‌است و سپس از طریق BodyWriter و متد AsStream به استریم مورد نظر دست یافته و در اختیار متد Write مربوط به document ایجاد شده، قرار داده‌ایم. یک نمونه استفاده از آن برای موجودیت فرضی مشتری می تواند به شکل زیر باشد:

[ApiController, Route("api/customers")]
public class CustomersController(IDbContext dbContext) : ControllerBase
{
    [HttpGet("export")]
    public async Task<ActionResult> ExportCustomers([FromQuery] ExportMetadata metadata,
        CancellationToken cancellationToken)
    {
        var count = await dbContext.Set<Customer>().CountAsync(cancellationToken);
        return this.Export(
            (page, pageSize) => dbContext.Set<Customer>()
                .OrderBy(c => c.Id)
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .AsNoTracking()
                .AsEnumerable(), // Enable streaming instead of buffering through deferred execution
            count,
            metadata);
    }
}

در اینجا از طریق Extension Method مهیا شده روش کوئری کردن برای هر شیت را مشخص کرده‌ایم؛ نکته مهم در ایجاد استفاده از ‌متد AsEnumerable می باشد که در عمل یک Type Casting انجام می دهد که باقی متدهای استفاده شده روی خروجی، از طریق Linq To Object اعمال شود و همچنین نیاز به استفاده از ToList و یا موارد مشابه را نخواهیم داشت. نمونه درخواست GET برای این API می تواند به شکل زیر باشد:

http://localhost:5118/api/customers/export?Title=Test&Fields[0].Name=FirstName&Fields[0].Title=First name&Fields[1].Name=LastName&Fields[1].Title=Last name&Fields[2].Name=BirthDate&Fields[2].Title=BirthDate

سورس کد مثال قابل اجرا از طریق مخزن زیر قابل دسترس می باشد:

https://github.com/rabbal/large-excel-streaming

در این مثال در زمان آغاز برنامه، ۱۰ میلیون رکورد در جدول Customer ثبت خواهد شد که در ادامه می توان از آن خروجی Excel تهیه کرد.

نکته مهم: توجه داشته باشید که استفاده از این روش قابلیت از سرگیری مجدد برای دانلود را نخواهد داشت و شاید بهتر است این فرآیند را از طریق یک Job انجام داده و با استفاده از قابلیت‌های Multipart Upload مربوط به یک BlobStroage مانند Minio، خروجی مورد نظر از قبل ذخیره کرده و لینک دانلودی را در اختیار کاربر قرار دهید.