برنامه نویسی

استاندارد کردن نمایش یک شیء ارزش در سی شارپ

مقدمه

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

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

II. مشکل

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

در زمینه توسعه نرم افزار، Competence به عنوان یک شی ارزش دامنه که ترکیب ماه و سال را در بر می گیرد، پیاده سازی می شود. با این حال، این مفهوم ساده در هنگام ذخیره و بازیابی این اطلاعات می تواند بسیار پیچیده شود. نمایش‌های متفاوتی از Competence، مانند یک شیء DateTime با روز اول ماه داده شده، یک رشته با فرمت “MM/yyyy” یا یک تاریخ کامل به عنوان nvarchar “yyyy-MM-dd hh:mm:ss” می‌تواند باشد. به جای یکدیگر استفاده می شود که منجر به سردرگمی، اشتباهات و تبدیل زمان بر می شود. علاوه بر این، توسعه دهندگان بی تجربه یا “توریستی” ممکن است نمایندگی های خود را معرفی کنند که وضعیت را پیچیده تر می کند.

به کد زیر نگاه کنید، زیرا وضعیتی را با مسائل داده شده نشان می دهد:

internal class AccountsReceivable
{
    public AccountsReceivable(string competence, decimal value)
    {
        if (string.IsNullOrWhiteSpace(competence))
        {
            throw new ArgumentException($"'{nameof(competence)}' cannot be null or whitespace.", nameof(competence));
        }

        competence = "01" + competence;

        if (!DateTime.TryParse(competence, out DateTime date))
        {
            throw new ArgumentException("Invalid format.", nameof(competence));
        }

        Competence = date;
        Value = value;
    }

    public decimal Value { get; set; }
    public DateTime Competence { get; }
}

internal class PaymentOrder
{
    public PaymentOrder(string competence, DateTime dueDate, AccountsReceivable[] accountsReceivables)
    {
        if (string.IsNullOrWhiteSpace(competence)) throw new ArgumentNullException(nameof(competence));
        if (dueDate < DateTime.Today) throw new ArgumentOutOfRangeException(nameof(dueDate));
        if (!accountsReceivables.Any()) throw new ArgumentException("Payment order must have at least one account receivable.", nameof(accountsReceivables));

        Competence = competence;
        AccountsReceivables = accountsReceivables;
        DueDate = dueDate;
        Value = accountsReceivables.Sum(a => a.Value);
    }

    public DateTime DueDate { get; }
    public decimal Value { get; }
    public string Competence { get; }

    public bool Paid { get; set; }

    public AccountsReceivable[] AccountsReceivables { get; }
}


internal class PaymentServiceWithIssues
{
    public void PayOrder()
    {
        var service1AccountReceivable = new AccountsReceivable("04/2023", 10);
        var service2AccountReceivable = new AccountsReceivable("04/2023", 15);

        var servicesPaymentOrder = new PaymentOrder("04/2023",
                                                    new DateTime(2023, 04, 15),
                                                    new AccountsReceivable[] {
                                                        service1AccountReceivable,
                                                        service2AccountReceivable
                                                    });

        var paymentCompetence = DateTime.Parse(servicesPaymentOrder.Competence);

        if (service1AccountReceivable.Competence.Month != paymentCompetence.Month ||
           service2AccountReceivable.Competence.Month != paymentCompetence.Month)
        {
            throw new InvalidOperationException("Payment can only happen in the same competence month of accounts receivable.");
        }

        //Some payment logic here.

        servicesPaymentOrder.Paid = true;

        Console.WriteLine("The order was paid successfully!");
    }
}


وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

III. بررسی اجمالی راه حل

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

در اینجا کد ساختار CompetenceMonth آمده است:


public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    public CompetenceMonth(int month, int year)
    {
        if (year <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(year));
        }

        if (month <= 0 || month > 12)
        {
            throw new ArgumentOutOfRangeException(nameof(month));
        }

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(DateTime dt)
    {
        year = dt.Year;
        month = dt.Month;
    }

    public CompetenceMonth(string txt)
    {
        if (string.IsNullOrWhiteSpace(txt))
        {
            throw new ArgumentNullException(nameof(txt));
        }

        var txtDt = "01" + txt;

        if (!DateTime.TryParse(txtDt, out var dt))
        {
            throw new ArgumentException("Invalid format", nameof(txt));
        }

        this.year = dt.Year;
        this.month = dt.Month;
    }

    public int Year => year;
    public int Month => month;

    public string ToCompetenceText() => $"{month:D2}/{year}";

    public override bool Equals(object? obj) 
        => obj is CompetenceMonth month && Equals(month);

    public bool Equals(CompetenceMonth other) 
        => year == other.year && month == other.month;

    public override int GetHashCode() 
        => HashCode.Combine(year, month);

    public override string ToString() 
        => ToCompetenceText();


    public static bool operator ==(CompetenceMonth left, 
                                   CompetenceMonth right) 
        => left.Equals(right);

    public static bool operator !=(CompetenceMonth left, 
                                   CompetenceMonth right)
        => !(left == right);
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در حالی که ساختار CompetenceMonth یک پایه محکم برای نمایش و دستکاری صلاحیت های ماه فراهم می کند، در حال حاضر فاقد برخی از عملکردهای مهم است. به عنوان مثال، اگر بخواهیم از ساختار CompetenceMonth با داده هایی که در یک پایگاه داده ذخیره می شوند استفاده کنیم، باید بتوانیم آن را به فرمت های مختلف مانند nvarchar و datetime تبدیل کنیم.

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

برای رفع این مشکل، باید چند قابلیت اضافی به آن اضافه کنیم.

public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    public CompetenceMonth(int month, int year)
    {
        if (year <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(year));
        }

        if (month <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(month));
        }

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(int monthCount)
    {
        if (monthCount <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(monthCount));
        }

        CalculateCompetenceFromMonthCount(monthCount, out int month, out int year);

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(string txt)
    {
        if (string.IsNullOrWhiteSpace(txt))
        {
            throw new ArgumentNullException(nameof(txt));
        }

        ConvertCompetenceFromText(txt, out int month, out int year);

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(DateTime dt)
    {
        year = dt.Year;
        month = dt.Month;
    }

    public int Year => year;
    public int Month => month;


    public int ToMonthCount() => (year * 12) + month;
    public DateTime ToDateTime() => new(year, month, 1);
    public string ToCompetenceText() => $"{month:D2}/{year}";

    //IEquatable and other equality comparers here.

    private static void CalculateCompetenceFromMonthCount(int monthCount, out int month, out int year)
    {
        year = monthCount / 12;
        month = monthCount % 12;

        while (month < 1)
        {
            month += 12;
            year--;
        }

        while (month > 12)
        {
            month -= 12;
            year++;
        }
    }

    private static readonly char[] validChars = new char[] { "https://dev.to/", '-' };

    private static void ConvertCompetenceFromText(string txt, out int month, out int year)
    {
        var index = txt.IndexOfAny(validChars);

        if (index < 0)
        {
            throw new ArgumentException("Invalid format.");
        }

        var txtSpn = txt.AsSpan();

        var p1 = txtSpn[..index];
        var p2 = txtSpn[(index + 1)..];

        if (p1.Length == 2)
        {
            if (!int.TryParse(p1, out month))
            {
                throw new InvalidOperationException();
            }

            if (!int.TryParse(p2, out year))
            {
                throw new InvalidOperationException();
            }
        }
        else if (p1.Length == 4)
        {
            if (!int.TryParse(p2, out month))
            {
                throw new InvalidOperationException();
            }

            if (!int.TryParse(p1, out year))
            {
                throw new InvalidOperationException();
            }
        }
        else
        {
            throw new ArgumentException("Invalid format.");
        }
    }
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

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

با این حال، راه حل فعلی ممکن است هنوز فاقد تبدیل های عملی برای کار با اشیاء دیگر باشد. برای غلبه بر این محدودیت، می‌توانیم از یک ویژگی سی شارپ به نام لغو عملگر ضمنی استفاده کنیم.

نادیده گرفتن عملگر ضمنی به ما امکان می دهد تبدیل های ضمنی را بین انواع تعریف کنیم، به این معنی که می توانیم نمونه ای از یک نوع را بدون فراخوانی صریح یک روش تبدیل به نوع دیگری تبدیل کنیم. با تعریف این نادیده گرفتن عملگر ضمنی، می‌توانیم فرآیند تبدیل را ساده‌تر کرده و آن را بصری‌تر کنیم. به عنوان مثال، به‌جای فراخوانی روش تبدیلی مانند ToDateTime() برای تبدیل نمونه CompetenceMonth به DateTime، می‌توانیم به سادگی نمونه CompetenceMonth را به متغیر DateTime اختصاص دهیم، و نادیده گرفتن عملگر ضمنی تبدیل را به‌طور خودکار مدیریت می‌کند.

به طور خلاصه، نادیده گرفتن عملگر ضمنی یک ویژگی قدرتمند در سی شارپ است که امکان تبدیل بصری و آسان بین انواع را فراهم می کند. با استفاده از این ویژگی در ساختار CompetenceMonth، می‌توانیم تبدیل‌های عملی بیشتری را به فرمت‌های پایگاه داده مانند nvarchar و datetime اضافه کنیم و ساختار را چندمنظورتر و مفیدتر کنیم.

public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    //Constructors here

    //Conversion methods here

    //IEquatable and other equality comparers here.

    public static implicit operator int(CompetenceMonth competenceMonth)
       => competenceMonth.ToMonthCount();

    public static implicit operator CompetenceMonth(int monthCount)
        => new(monthCount);

    public static implicit operator DateTime(CompetenceMonth competenceMonth)
        => competenceMonth.ToDateTime();

    public static implicit operator CompetenceMonth(DateTime dateTime)
        => new(dateTime);

    public static implicit operator string(CompetenceMonth competenceMonth)
        => competenceMonth.ToCompetenceText();

    public static implicit operator CompetenceMonth(string txt)
        => new(txt);

}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

IV. ذخیره و بازیابی داده ها

هنگام کار با پایگاه‌های داده، ممکن است با جداولی با ستون‌هایی مواجه شویم که فرمت‌های متفاوتی با انواع C# دارند که در کد خود استفاده می‌کنیم. دو مورد از رایج ترین راه حل های C# برای مدیریت پایگاه داده EntityFramework و Dapper هستند.

هنگام استفاده از Dapper برای خواندن داده ها از پایگاه داده، به طور خودکار انواع پایگاه داده را به انواع C# مربوطه تبدیل می کند. با کمک عملگرهای ضمنی، ما می توانیم به راحتی آن انواع را به نمونه های CompetenceMonth تبدیل کنیم. با این حال، ممکن است با سناریوهایی مواجه شویم که در آن ها باید داده ها را به روشی تخصصی تر مدیریت کنیم. در چنین مواردی، می‌توانیم با استفاده از کلاس Dapper’s TypeHandler، نگاشت‌های تخصصی ایجاد کنیم.

به عنوان مثال، کد زیر یک TypeHandler ایجاد می کند که نمونه های CompetenceMonth را به قالب پایگاه داده شمارش ماه نگاشت می کند:

public class CompetenceMonthTypeHandlerAsMonthCount : SqlMapper.TypeHandler<CompetenceMonth>
{
    public override CompetenceMonth Parse(object value)
    {
        if (value is int v)
        {
            return new CompetenceMonth(v);
        }

        return new CompetenceMonth();
    }

    public override void SetValue(IDbDataParameter parameter, CompetenceMonth value)
    {
        parameter.Value = value.ToMonthCount();
    }  
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

هنگام استفاده از EntityFramework Core، می‌توانیم ویژگی CompetenceMonth را مستقیماً با استفاده از Fluent API ترسیم کنیم، همانطور که در مثال زیر نشان داده شده است:

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public CompetenceMonth Competence { get; set; }
}

public class EmployeeContext : DbContext
{
    public DbSet<Employee> Employees { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>()
            .Property(e => e.Competence)
            .HasConversion(
                v => v.ToMonthCount(),
                v => new CompetenceMonth(v));
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

از طرف دیگر، می‌توانیم یک مبدل مقدار عمومی ایجاد کنیم که می‌تواند برای هر موجودیتی با ویژگی CompetenceMonth استفاده شود:

public class CompetenceMonthValueConverterAsMonthCount : ValueConverter<CompetenceMonth, int>
{
    public CompetenceMonthValueConverterAsMonthCount() : base(
        v => v.ToMonthCount(),
        v => new CompetenceMonth(v))
    { }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

سپس می توانیم این مبدل را در سطح DbContext با فراخوانی متد افزونه زیر اضافه کنیم:

  public static ModelConfigurationBuilder AddDefaultMonthCompetenceConverter(this ModelConfigurationBuilder configurationBuilder)
    {
        configurationBuilder.Properties<CompetenceMonth>().HaveConversion<CompetenceMonthValueConverterAsMonthCount>();
        return configurationBuilder;
    }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

یکی از جنبه های مهمی که باید در اجرای ساختار CompetenceMonth در نظر گرفت، استفاده از نمایش شمارش ماه به عنوان یک عدد صحیح است. این تصمیم یک راه بسیار عملی برای ذخیره و بازیابی اطلاعات از پایگاه های داده فراهم می کند، زیرا اکثر موتورهای پایگاه داده می توانند به طور موثر انواع داده های عدد صحیح را مدیریت کنند.

ذخیره CompetenceMonth به‌عنوان یک عدد صحیح می‌تواند راه آسانی برای انتخاب محدوده‌ای از داده‌ها نیز فراهم کند. به عنوان مثال، بازیابی همه رکوردها از یک پایگاه داده در محدوده معینی از مقادیر CompetenceMonth با استفاده از یک پرس و جو ساده SQL با عملگر BETWEEN امکان پذیر است.

از طرف دیگر، اگر CompetenceMonth به عنوان یک رشته ذخیره می شد، انتخاب یک محدوده از داده ها نیاز به استفاده از عبارت IN دارد که می تواند عملکرد کمتری داشته باشد. به طور مشابه، ذخیره CompetenceMonth به عنوان یک شیء DateTime نیاز به رسیدگی بیشتر به روزها دارد که می تواند مستعد خطا باشد.

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

روش‌های توسعه زیر روشی آسان برای یافتن همه محدوده‌ها در زمان مورد نیاز ارائه می‌دهند.

public static IEnumerable<string> CompetenceTextRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        while (cmFrom <= cmTo)
        {
            yield return cmFrom.ToCompetenceText();
            cmFrom = cmFrom.AddMonths(1);
        }
    }    

    public static (DateTime, DateTime) DateTimeRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        DateTime fromDate = cmFrom;
        DateTime toDate = cmTo;

        toDate = toDate.AddMonths(1).AddDays(-1);

        return (fromDate, toDate);
    }

    public static (int, int) MonthCountRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        int fromDate = cmFrom;
        int toDate = cmTo;        

        return (fromDate, toDate);
    }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

نتیجه گیری

در این پست، مشکل ذخیره سازی و بازیابی ناهماهنگ داده ها در توسعه نرم افزار و اینکه چگونه می توان با ایجاد و استانداردسازی اشیاء ارزش برطرف کرد، بحث کردیم. به طور خاص، ساختار CompetenceMonth را معرفی کردیم که روشی استاندارد برای نمایش و تبدیل صلاحیت های ماه در کد C# ارائه می دهد.

ما پیاده‌سازی گام به گام ساختار CompetenceMonth را طی کردیم که شامل تعریف ساختار، پیاده‌سازی سازنده‌ها و اعتبارسنجی آرگومان، تعریف ویژگی‌ها و روش‌ها برای دسترسی آسان و تبدیل صلاحیت‌های ماه، اجرای مقایسه‌های برابری و هش کردن، و اجرای عملگرها برای تبدیل آسان بین انواع CompetenceMonth و int/DateTime. ما همچنین در مورد اهمیت پیاده‌سازی رابط‌های IEquatable و IComparable، و همچنین لغو ضمنی عملگر، روش‌های توسعه، مبدل‌های پایگاه داده و نقشه‌برداران برای EF Core و Dapper بحث کردیم.

ما همچنین تأکید کردیم که این تکنیک‌ها را می‌توان برای هر شی مقدار DDD، نه فقط CompetenceMonth، اعمال کرد. با ایجاد و استانداردسازی اشیاء ارزش، می‌توانیم خوانایی کد را بهبود بخشیم، از سازگاری داده‌ها اطمینان حاصل کنیم و پیاده‌سازی الگوریتم‌ها و مجموعه‌هایی را که با مفاهیم دامنه کار می‌کنند آسان‌تر کنیم.

در خاتمه، ما خوانندگان را تشویق می‌کنیم که تکنیک‌های مورد بحث را برای اشیاء ارزش DDD خود اعمال کنند، زیرا می‌تواند به جلوگیری از مشکلات رایج با سازگاری داده‌ها و ارائه یک روش استاندارد برای نمایش مفاهیم دامنه در کد کمک کند.

VI. مخزن GitHub

من یک مخزن GitHub با نمونه کدهای مورد بحث در این پست و همچنین پیاده سازی های اضافی، تست های واحد، نمونه هایی از نحوه استفاده از ساختار CompetenceMonth با Entity Framework Core و Dapper و البته راه حلی برای مشکل ارائه شده ایجاد کرده ام. ما خوانندگان را تشویق می کنیم که مخزن را بررسی کنند و کد را آزمایش کنند تا درک عمیق تری از نحوه پیاده سازی و استفاده از اشیاء ارزش در پروژه های خود به دست آورند. مخزن را می توان در github.com/nelsonciofi/Competence یافت.

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

PS: این پست با کمک هوش مصنوعی نوشته شده است، اما پیاده سازی ها همه مال من است.
PS2: تصویر جلد نیز با هوش مصنوعی ایجاد شده است.
PS3: آیا پس از اختراع GPT هنوز به وبلاگ نیاز داریم؟

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا