راهنمای ذخیره سازی منابع ایجاد کننده و تغییر دهنده داده در Asp.net Core

چگونه در Asp.net Core و الگوی سرور و کلاینت، داده ها را نظارت کنیم؟

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

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


    public abstract class AuditableBaseEntity : BaseEntity
    {
        public int? CreatedUserRef { get; set; }

        public int? ModifiedUserRef { get; set; }

        public DateTimeOffset? CreatedDate { get; set; }

        public DateTimeOffset? ModifiedDate { get; set; }
    }


با توجه به استفاده از الگوی سرور و کلاینت که به ما اجازه می دهد کلاینت های مختلفی داشته باشیم و یا با استفاده از ساختار های توزیع شده چندین سرور داشته باشیم. اهمیت اینکه این داده در کدام سرور و با کدام کلاینت تغییر داشته اند مهم تر از گذشته شده است.

سناریوی زیر را در نظر بگیرید:

  • شما یک وب سایت اپلیکیشن دارید که به صورت مداوم در حال توسعه است.
  • تیم DevOps شما توصیه کرده است برای بروز رسانی بلادرنگ وUptime حداکثری از الگوی میزبانی Master and Slave استفاده کنید.
  • به علت بار ترافیکی از راهکارهای توزیع شدگی استفاده می کنید و همزمان چند وهله از نرم افزار شما اجرا شده است.
  • برای کاربران موبایل دو نسخه Android و IOS را توسعه دادید و به Api محصول شما وصل شده اند.
  • واحدهای نظارتی برای تایید مجوز کسب و کار شما را مجبور کردند که آدرس ir سایت را هم داشته باشید.
  • API را در اختیار بعضی از مشتریان قرار دادید.

بعضی از نیازها و موضوعاتی که در این سناریو پیش آماده است را بررسی می کنیم:

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

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

فیلدهایی که شناسایی شد به شرح زیر می باشد :

HostName
MachineName
RemoteIpAddress
LocalIpAddress
ApplicationName
ApplicationVersion
ClientName
ClientVersion
UserAgent

نام های این فیلدها را مشابه  نام های واقع در فریمورک .Net قرار می دهیم تا خوانایی بیشتری داشته باشند. همچنین فیلد UserAgent را هم اضافه می کنیم ممکن در بعضی از سناریوها مفید باشد. همان طور که می بینید تعداد فیلدهای نظارتی ما زیاد شدن و احتمالا به این فکر می کنید آیا استفاده از این همه فیلد کار درستی هست؟ بله من هم با شما هم عقیده هستم که پیاده سازی ده فیلد نظارتی در یک جدول کار درستی نیست. بیایید این فیلدها را Serialize کنیم و در یک فیلد متنی نگهداری کنیم. مثلا می توانیم فیلدها را با “;” به هم وصل کنیم و یک رشته ایجاد کنیم ولی بهتر است اینکار را نکنیم و بصورت Json سریالایز کنیم تا بتوانیم از امکانات دیتابیس برای خواندن فیلد‌های داخل متن استفاده کنیم.

ابتدا کلاس AuditSourceValues را ایجاد می کنیم. همان طور که می بینید با تعریف کردن نام برای فیلد‌های Json و نادیده گرفتن مقادیر نال سعی کردیم حجم خروجی Json پایین باشد:


public class AuditSourceValues
{
    [JsonPropertyName("hn")]
    public string HostName { get; set; }

    [JsonPropertyName("mn")]
    public string MachineName { get; set; }

    [JsonPropertyName("rip")]
    public string RemoteIpAddress { get; set; }

    [JsonPropertyName("lip")]
    public string LocalIpAddress { get; set; }

    [JsonPropertyName("ua")]
    public string UserAgent { get; set; }

    [JsonPropertyName("an")]
    public string ApplicationName { get; set; }

    [JsonPropertyName("av")]
    public string ApplicationVersion { get; set; }

    [JsonPropertyName("cn")]
    public string ClientName { get; set; }

    [JsonPropertyName("cv")]
    public string ClientVersion { get; set; }

    [JsonPropertyName("o")]
    public string Other { get; set; }

    public string SerializeJson()
    {
        return JsonSerializer.Serialize(this, 
            options: new JsonSerializerOptions { WriteIndented = false, IgnoreNullValues = true });
    }

    public static AuditSourceValues DeserializeJson(string value)
    {
        return JsonSerializer.Deserialize(value);
    }
}

دو اینترفیس زیر را تعریف میکنیم تا بتوانیم بر روی هر موجودیتی که نیاز است، فیلدهای نظارتی را اعمال کنیم. می توانیم این اینترفیس ها را در کلاس BaseEntity و یا کلاسی مشتق شده از آن با نام AuditableBaseEntity تعریف کنیم.


public interface IAuditCreateSources
{
    string CreatedSources { get; set; }
}

public interface IAuditUpdateSources
{
    string ModifiedSources { get; set; }
}


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


    public interface IAuditSourcesProvider
    {
        AuditSourceValues GetAuditSourceValues();
    }

    public class AuditSourcesProvider : IAuditSourcesProvider
    {
        protected readonly IHttpContextAccessor httpContextAccessor;

        public AuditSourcesProvider(IHttpContextAccessor httpContextAccessor)
        {
            this.httpContextAccessor = httpContextAccessor;
        }

        public virtual AuditSourceValues GetAuditSourceValues()
        {
            var httpContext = httpContextAccessor.HttpContext;

            return new AuditSourceValues
            {
                HostName = GetHostName(httpContext),
                MachineName = GetComputerName(httpContext),
                LocalIpAddress = GetLocalIpAddress(httpContext),
                RemoteIpAddress = GetRemoteIpAddress(httpContext),
                UserAgent = GetUserAgent(httpContext),
                ApplicationName = GetApplicationName(httpContext),
                ClientName = GetClientName(httpContext),
                ClientVersion = GetClientVersion(httpContext),
                ApplicationVersion = GetApplicationVersion(httpContext),
                Other = GetOther(httpContext)
            };
        }

        protected virtual string GetUserAgent(HttpContext httpContext)
        {
            return httpContext?.Request?.Headers["User-Agent"].ToString();
        }

        protected virtual string GetRemoteIpAddress(HttpContext httpContext)
        {
            return httpContext?.Connection?.RemoteIpAddress?.ToString();
        }

        protected virtual string GetLocalIpAddress(HttpContext httpContext)
        {
            return httpContext?.Connection?.LocalIpAddress?.ToString();
        }

        protected virtual string GetHostName(HttpContext httpContext)
        {
            return httpContext?.Request.Host.ToString();
        }

        protected virtual string GetComputerName(HttpContext httpContext)
        {
            return Environment.MachineName.ToString();
        }
        protected virtual string GetApplicationName(HttpContext httpContext)
        {
            return Assembly.GetEntryAssembly().GetName().Name.ToString();
        }

        protected virtual string GetApplicationVersion(HttpContext httpContext)
        {
            return Assembly.GetEntryAssembly().GetName().Version.ToString();
        }

        protected virtual string GetClientVersion(HttpContext httpContext)
        {
            return httpContext?.Request?.Headers["client-version"];
        }
        protected virtual string GetClientName(HttpContext httpContext)
        {
            return httpContext?.Request?.Headers["client-name"];
        }

        protected virtual string GetOther(HttpContext httpContext)
        {
            return null;
        }
    }

برای تشخیص نام و نسخه کلاینت می توانیم آنها را به همراه درخواست بر روی هدر ارسال کنیم تا قابلیت ثبت آنها را داشته باشیم.

برای اپ های موبایل بهتر است از سیستم عامل و مدل گوشی و ... یک UserAgent دلخواه بسازید و همراه با درخواست ارسال کنید. شبیه کاری که یک مرورگر بصورت پیش فرض انجام می دهد.

پیشنهاد می کنم موارد بالا را برای حالت پیش فرض در نظر بگیرید و برای هر پروژه با توجه به نیاز و شرایط آن پروژه تغییر بدید

مثلا ممکن است شما نخواهید UserAgent را ثبت کنید کافی است یک کلاس مشتق کنید و برای این فیلد مقدار نال برگردانید و همچنین می توانید از فیلد Other برای موارد خاص و پیش بینی نشده استفاده کنید:



 public class CustomeAuditSourcesProvider : AuditSourcesProvider
    {
 
        public CustomeAuditSourcesProvider(IHttpContextAccessor httpContextAccessor):base(httpContextAccessor)
        {

        }

        protected override string GetUserAgent(HttpContext httpContext)
        {
            return null;
        }

        protected override string GetApplicationName(HttpContext httpContext)
        {
            return "MyAplication";
        }

    }

خوب تا این جای کار همه چیز مشخص شده است و فقط نحوه ثبت اطلاعات را تعریف نکردیم بدیهی است که نمی‌خواهیم برای هر باز ذخیره سازی به صورت دستی مقادیر رو ثبت کنیم. برای ثبت خودکار فیلدهای نظارتی کافی است در Entity Framework  متد Save Changes را بازنویسی کنیم. ولی من این کار را انجام نمی دهم و می خواهم از روش جدیدی که برای EF Core 5 ارائه شده است استفاده کنم:

کافی است از کلاس SaveChangesInterceptor  مشتق بگیریم و SavingChanges  را  که قبل از ذخیره سازی فراخونی می شوند را بازنویسی کنیم:


 public class AuditSourcesSaveChangesInterceptor : SaveChangesInterceptor
    {
        private readonly IAuditSourcesProvider _auditSourcesProvider;

        public AuditSourcesSaveChangesInterceptor(IAuditSourcesProvider auditSourcesProvider)
        {
            _auditSourcesProvider = auditSourcesProvider;
        }

        public override InterceptionResult SavingChanges(DbContextEventData eventData,
            InterceptionResult result)
        {
            ApplayAudits(eventData.Context.ChangeTracker);

            return base.SavingChanges(eventData, result);
        }

        public override ValueTask> SavingChangesAsync(DbContextEventData eventData,
            InterceptionResult result,
            CancellationToken cancellationToken = default)
        {
            ApplayAudits(eventData.Context.ChangeTracker);

            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }

        private void ApplayAudits(ChangeTracker changeTracker)
        {
            var auditSourcevalues = _auditSourcesProvider.GetAuditSourceValues().SerializeJson();

            ApplayCreateAudits(changeTracker, auditSourcevalues);
            ApplayUpdateAudits(changeTracker, auditSourcevalues);
        }

        private void ApplayCreateAudits(ChangeTracker changeTracker, string auditSourcevalues)
        { 
            var addedEntries = changeTracker.Entries()
                                            .Where(x => x.State == EntityState.Added);
             
            foreach (var addedEntry in addedEntries)
            {
                addedEntry.Entity.CreatedSources = auditSourcevalues;
            }
        }

        private void ApplayUpdateAudits(ChangeTracker changeTracker, string auditSourcevalues)
        {
            var modifiedEntries = changeTracker.Entries()
                                .Where(x => x.State == EntityState.Modified);

            foreach (var modifiedEntry in modifiedEntries)
            {
                modifiedEntry.Entity.ModifiedSources = auditSourcevalues;
            }
        }
    }

و به این صورت به سیستم معرفی کنیم:


          services.AddScoped<IAuditSourcesProvider, AuditSourcesProvider>();
//services.AddScoped<IAuditSourcesProvider, CustomeAuditSourcesProvider>();
		services.AddDbContext((serviceProvider, optionBuilder) =>
    {
        //...
        var auditSourcesProvider = serviceProvider.GetRequiredService<IAuditSourcesProvider>()

        optionBuilder.AddInterceptors(new AuditSourcesSaveChangesInterceptor(auditSourcesProvider));
    });

برای خواندن این فیلدها می تونیم آنها را در کلاس های Dto  دسریالایز کنیم و یا با استفاده از دستورات JSON_VALUE در Sqlserver   داده ها را پردازش کنیم.

برای استفاده از فیلدهای Json به صورت مستقیم در Entity Framework می توانیم  DbFunction از نوع JSON_VALUE را تعریف کنیم و بعد به آسانی به صورت زیر استفاده کنیم:


            var auditSources = await dbContext.Companies.AsNoTracking()
                .Where(c => JsonFunctions.Value(c.CreatedSources, "$.cn") == "MyApp")
                .Select(c => new
                {
                    HostName = JsonFunctions.Value(c.CreatedSources, "$.hn"),
                    ClientName = JsonFunctions.Value(c.CreatedSources, "$.cn"),
                    ClientVersion = JsonFunctions.Value(c.CreatedSources, "$.cv"),
                    ApplicationName = JsonFunctions.Value(c.CreatedSources, "$.an"),
                    ApplicationVersion = JsonFunctions.Value(c.CreatedSources, "$.av"),

                })
                .ToListAsync();

در انتها می خواهم به این نکته اشاره کنم ما می توانستیم با استفاده از روش های موجود عملیات Serialize و Deserialize رو به صورت خودکار انجام دهیم ولی با توجه به سربار این قضیه برای این موضوع پیشنهاد نمی‌شود.

برای بررسی سورسDbFunction  و مشاهده نمونه کامل پیاده سازی شده به مخزن گیتهاب مراجعه کنید.

منبع : تحقیق و توسعه هنامیک

  • اشتراک :
  پاسخ
علیرضا ‫۸ ماه قبل، شنبه ۳ آبان ۱۳۹۹، ساعت ۱۱:۲۱

بسیار عالی . ممنون از مقاله مفید

  پاسخ
آرمین ضیاء ‫۶ ماه قبل، سه شنبه ۱۱ آذر ۱۳۹۹، ساعت ۱۷:۰۰

ممنون از پست خوب و مفیدتون. مخزن کد رو هم نگاه کردم و با تغییراتی جزئی چیزی که می خواستم رو در آوردم. گرچه مثال شما روی دات نت 5 کار می کنه، اما متد SqlFunctionExpression.Create() منقضی شده و ویژوال استودیو 2019 اخطار میده که از امضای جدید این متد استفاده کنید. چند ساعتی هست باهاش کلنجار میرم اما توی لاگ های Breaking Changes و SO هیچ چیز مفیدی پیدا نکردم. اگر می دونید لطفا بگین چطور این متد رو بازنویسی کنیم که با دات نت 5 کاملا سازگار باشه. سپاس

  پاسخ
ایمان محمدی ‫۶ ماه قبل، سه شنبه ۱۱ آذر ۱۳۹۹، ساعت ۲۳:۴۶

خواهش می کنم خوشحالم مقاله براتون مفید بوده مخزن بروز شد. https://github.com/imhmdi/AuditSourcesSample/commit/ae5c4f4a15b703fa41206d4f5521a9934f00c1da

  پاسخ
آرمین ضیاء ‫۶ ماه قبل، سه شنبه ۱۱ آذر ۱۳۹۹، ساعت ۲۱:۱۳

درود دوباره، یک سوال دارم که چند وقتی است باهاش دست و پنجه نرم می کنم. گرچه در بیشتر پروژه هام از رویکرد مشابهی برای ذخیره کردن Audit Trail استفاده می کنم، اما فکر می کنم اساسا 2 مشکل در این مدل طراحی ها وجود داره. اکثر مقالات و نمونه پروژه های روی اینترنت هم همین مشکل رو دارند (به نظر بنده). مشکل اول اینکه، فیلد های CreatedUserRef و ModifiedUserRef رشته هستند (string). بنابراین هیچ محدودیت FK Constraint وجود نداره و میشه هر مقداری رو ذخیره کرد. ترجیحا بجای ذخیره کردن UserID یا UserName بصورت متنی، باید ارجاعی به User Entity داشته باشیم. مشکل دوم اینکه، از اونجا که ارجاعی به User Entity نداریم، موقع بارگذاری رکوردها نمی تونیم اونا رو Include کنیم. فرض کنید رکورد یک تیکت پشتیبانی رو می خواهیم لود کنیم. این تیکت کالکشنی از پیام ها داره که توسط کاربران مختلفی فرستاده شدند (مشتری، اپراتور، مدیر سایت). چون ارجاعی به کاربر مربوطه CreatedBy نداریم، تنها داده ای که داریم UserID یا UserName هست. این کافی نیست و مشکل ساز هم هست. مثلا اگر بخواهیم FullName و مقادیر دیگری رو از هر کاربر لود کنیم.

  پاسخ
صابر فتح الهی ‫۶ ماه قبل، پنج شنبه ۱۳ آذر ۱۳۹۹، ساعت ۱۱:۰۳

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

  پاسخ
آرمین ضیاء ‫۶ ماه قبل، سه شنبه ۱۱ آذر ۱۳۹۹، ساعت ۲۱:۱۵

ادامه کامنت قبلی! بنابراین برای هر رکورد، باید یک کوئری به دیتابیس بفرستیم تا User Entity رو لود کنیم و مقادیر مورد نظر رو بخونیم. مسلما چنین کاری نه مطلوب هست نه مقرون به صرفه، خصوصا اگر ترافیک اپلیکیشن و دیتابیس بالا باشند. از طرفی، اگر روی AuditableEntity ارجاع مستقیم به User Entity تعریف کنیم، مشکل Cyclical Relationships خواهیم داشت. مگر اینکه به نحوی (مثلا ویرایش دستی Migration Script) رفتار دیتابیس رو تغییر بدیم و OnDelete.Restrict تعریف کنیم. می خواستم بدونم اگر تجربه ای در این زمینه دارین چه راه حلی پیشنهاد می کنید؟ سپاس فراوان