ساختار دیتابیس
برای پیادهسازی منابع دیتابیسی روشهای مختلفی برای آرایش جداول جهت ذخیره انواع ورودیها میتوان درنظر گرفت.
مثلا درصورتیکه حجم و تعداد منابع بسیار باشد و نیز منابع دیتابیسی به اندازه کافی در دسترس باشد، میتوان به ازای هر منبع یک جدول درنظر گرفت.
یا درصورتیکه منابع دادهای محدودتر باشند میتوان به ازای هر کالچر یک جدول درنظر گرفت و تمام منابع مربوط به یک کالچر را درون یک جدول ذخیره کرد. درهرصورت نحوه انتخاب آرایش جداول منابع کاملا بستگی به شرایط کاری و سلایق برنامهنویسی دارد.
برای مطلب جاری به عنوان یک راهحل ساده و کارآمد برای پروژههای کوچک و متوسط، تمام ورودیهای منابع درون یک جدول با ساختاری مانند زیر ذخیره میشود:
نام این جدول را با درنظر گرفتن شرایط موجود میتوان Resources گذاشت.
ستون Name برای ذخیره نام منبع درنظر گرفته شده است. این نام برابر نام منابع درخواستی در سیستم مدیریت منابع ASP.NET است که درواقع برابر همان نام فایل منبع اما بدون پسوند resx. است.
ستون Key برای نگهداری کلید ورودی منبع استفاده میشود که دقیقا برابر همان مقداری است که درون فایلهای resx. ذخیره میشود.
ستون Culture برای ذخیره کالچر ورودی منبع به کار میرود. این مقدار میتواند برای کالچر پیشفرض برنامه برابر رشته خالی باشد.
ستون Value نیز برای نگهداری مقدار ورودی منبع استفاده میشود.
برای ستون Id میتوان از GUID نیز استفاده کرد. در اینجا برای راحتی کار از نوع داده bigint و خاصیت Identity برای تولید خودکار آن در Sql Server استفاده شده است.
نکته: برای امنیت بیشتر میتوان یک Unique Constraint بر روی سه فیلد Name و Key و Culture اعمال کرد.
برای نمونه به تصویر زیر که ذخیره تعدای ورودی منبع را درون جدول Resources نمایش میدهد دقت کنید:
اصلاح کلاس DbResourceProviderFactory
برای ذخیره منابع محلی، جهت اطمینان از یکسان بودن نام منبع، متد مربوطه در کلاس DbResourceProviderFactory باید بهصورت زیر تغییر کند:
public override IResourceProvider CreateLocalResourceProvider(string virtualPath)
{
if (!string.IsNullOrEmpty(virtualPath))
{
virtualPath = virtualPath.Remove(0, virtualPath.IndexOf('/') + 1); // removes everything from start to the first '/'
}
return new LocalDbResourceProvider(virtualPath);
}
با این تغییر مسیرهای درخواستی چون "Default.aspx/~" و یا "Default.aspx/" هر دو به صورت "Default.aspx" در میآیند تا با نام ذخیره شده در دیتابیس یکسان شوند.
ارتباط با دیتابیس
خوشبختانه برای تبادل اطلاعات با جدول بالا امروزه راههای زیادی وجود دارد. برای پیادهسازی آن مثلا میتوان از یک اینترفیس استفاده کرد. سپس با استفاده از سازوکارهای موجود مثلا بهکارگیری IoC، نمونه مناسبی از پیادهسازی اینترفیس مذبور را در اختیار برنامه قرار داد.
اما برای جلوگیری از پیچیدگی بیش از حد و دور شدن از مبحث اصلی، برای پیادهسازی فعلی از EF Code First به صورت مستقیم در پروژه استفاده شده است که سری آموزشی کاملی از آن در همین سایت وجود دارد.
پس از پیادهسازی کلاسهای مرتبط برای استفاده از EF Code First، از کلاس ResourceData که در بخش اول نیز نشان داده شده بود، برای کپسوله کردن ارتباط با دادهها استفاده میشود که نمونهای ابتدایی از آن در زیر آورده شده است:
using System.Collections.Generic;
using System.Linq;
using DbResourceProvider.Models;
namespace DbResourceProvider.Data
{
public class ResourceData
{
private readonly string _resourceName;
public ResourceData(string resourceName)
{
_resourceName = resourceName;
}
public Resource GetResource(string resourceKey, string culture)
{
using (var data = new TestContext())
{
return data.Resources.SingleOrDefault(r => r.Name == _resourceName && r.Key == resourceKey && r.Culture == culture);
}
}
public List<Resource> GetResources(string culture)
{
using (var data = new TestContext())
{
return data.Resources.Where(r => r.Name == _resourceName && r.Culture == culture).ToList();
}
}
}
}
کلاس فوق نسبت به نمونهای که در قسمت قبل نشان داده شد کمی فرق دارد. بدین صورت که برای راحتی بیشتر نام منبع درخواستی به جای پارامتر متدها، در اینجا به عنوان پارامتر کانستراکتور وارد میشود.
نکته: درصورتیکه این کلاسها در پروژهای جداگانه قرار دارند، باید ConnectionString مربوطه در فایل کانفیگ برنامه مقصد نیز تنظیم شود.
کش کردن ورودیها
برای کش کردن ورودیها این نکته را که قبلا هم به آن اشاره شده بود باید درنظر داشت:
پس از اولین درخواست برای هر منبع، نمونه تولیدشده از پرووایدر مربوطه در حافظه سرور کش خواهد شد.
یعنی متدهای کلاس DbResourceProviderFactory بهازای هر منبع تنها یکبار فراخوانی میشود. نمونههای کششده از پروایدرهای کلی و محلی به همراه تمام محتویاتشان (مثلا نمونه تولیدی از کلاس DbResourceManager) تا زمان Unload شدن سایت در حافظه سرور باقی میمانند. بنابراین عملیات کشینگ ورودیها را میتوان درون خود کلاس DbResourceManager به ازای هر منبع انجام داد.
برای کش کردن ورودیهای هر منبع میتوان چند روش را درپیش گرفت. روش اول این است که به ازای هر کلید درخواستی تنها ورودی مربوطه از دیتابیس فراخوانی شده و در برنامه کش شود. این روش برای حالاتی که تعداد ورودیها یا تعداد درخواستهای کلیدهای هر منبع کم باشد مناسب خواهد بود.
یکی از پیادهسازی این روش این است که ورودیها به ازای هر کالچر ذخیره شوند. پیادهسازی اولیه این نوع فرایند کشینگ در کلاس DbResourceManager به صورت زیر است:
using System.Collections.Generic;
using System.Globalization;
using DbResourceProvider.Data;
namespace DbResourceProvider
{
public class DbResourceManager
{
private readonly string _resourceName;
private readonly Dictionary<string, Dictionary<string, object>> _resourceCacheByCulture;
public DbResourceManager(string resourceName)
{
_resourceName = resourceName;
_resourceCacheByCulture = new Dictionary<string, Dictionary<string, object>>();
}
public object GetObject(string resourceKey, CultureInfo culture)
{
return GetCachedObject(resourceKey, culture.Name);
}
private object GetCachedObject(string resourceKey, string cultureName)
{
if (!_resourceCacheByCulture.ContainsKey(cultureName))
_resourceCacheByCulture.Add(cultureName, new Dictionary<string, object>());
var cachedResource = _resourceCacheByCulture[cultureName];
lock (this)
{
if (!cachedResource.ContainsKey(resourceKey))
{
var data = new ResourceData(_resourceName);
var dbResource = data.GetResource(resourceKey, cultureName);
if (dbResource == null) return null;
var cachedResources = _resourceCacheByCulture[cultureName];
cachedResources.Add(dbResource.Key, dbResource.Value);
}
}
return cachedResource[resourceKey];
}
}
}
همانطور که قبلا توضیح داده شد کشِ پرووایدرهای منابع به ازای هر منبع درخواستی (و به تبع آن نمونههای موجود در آن مثل DbResourceManager) برعهده خود ASP.NET است. بنابراین برای کش کردن ورودیهای درخواستی هر منبع در کلاس DbResourceManager تنها کافی است آنها را درون یک متغیر محلی در سطح کلاس (فیلد) ذخیره کرد. کاری که در کد بالا در متغیر resourceCacheByCulture_ انجام شده است. در این متغیر که از نوع دیکشنری تعریف شده است کلیدهای هر عضو آن برابر نام کالچر مربوطه است. مقادیر هر عضو این دیکشنری نیز خود یک دیکشنری است که ورودیهای منابع مربوط به کالچر مربوطه در آن ذخیره میشوند.
عملیات در متد GetCachedObject انجام میشود. همانطور که میبینید ابتدا وجود ورودی موردنظر در متغیر کشینگ بررسی میشود و درصورت عدم وجود، مقدار آن مستقیما از دیتابیس درخواست میشود. سپس این مقدار درخواستی ابتدا درون متغیر کشینگ ذخیره شده (به همراه بلاک lock) و درنهایت برگشت داده میشود.
نکته: کل فرایند بررسی وجود کلید در متغیر کشینگ (شرط دوم در متد GetCachedObject) درون بلاک lock قرار داده شده است تا در درخواستهای همزمان احتمال افزودن چندباره یک کلید ازبین برود.
پیادهسازی دیگر این فرایند کشینگ، ذخیره ورودیها براساس نام کلید به جای نام کالچر است. یعنی کلید دیکشنری اصلی نام کلید و کلید دیکشنری داخلی نام کالچر است که این روش زیاد جالب نیست.
روش دوم که بیشتر برای برنامههای بزرگ با ورودیها و درخواستهای زیاد بهکار میرود این است که درهر بار درخواست به دیتابیس به جای دریافت تنها همان ورودی درخواستی، تمام ورودیهای منبع و کالچر درخواستی استخراج شده و کش میشود تا تعداد درخواستهای به سمت دیتابیس کاهش یابد. برای پیادهسازی این روش کافی است تغییرات زیر در متد GetCachedObject اعمال شود:
private object GetCachedObject(string resourceKey, string cultureName)
{
lock (this)
{
if (!_resourceCacheByCulture.ContainsKey(cultureName))
{
_resourceCacheByCulture.Add(cultureName, new Dictionary<string, object>());
var cachedResources = _resourceCacheByCulture[cultureName];
var data = new ResourceData(_resourceName);
var dbResources = data.GetResources(cultureName);
foreach (var dbResource in dbResources)
{
cachedResources.Add(dbResource.Key, dbResource.Value);
}
}
}
var cachedResource = _resourceCacheByCulture[cultureName];
return !cachedResource.ContainsKey(resourceKey) ? null : cachedResource[resourceKey];
}
دراینجا هم میتوان به جای استفاده از نام کالچر برای کلید دیکشنری اصلی از نام کلید ورودی منبع استفاده کرد که چندان توصیه نمیشود.
نکته: انتخاب یکی از دو روش فوق برای فرایند کشینگ کاملا به شرایط موجود و سلیقه برنامه نویس بستگی دارد.
فرایند Fallback
درباره فرایند fallback به اندازه کافی در قسمتهای قبلی توضیح داده شده است. برای پیادهسازی این فرایند ابتدا باید به نوعی به سلسله مراتب کالچرهای موجود از کالچر جاری تا کالچر اصلی و پیش فرض سیستم دسترسی پیدا کرد. برای اینکار ابتدا باید با استفاده از روشی کالچر والد یک کالچر را بدست آورد. کالچر والد کالچری است که عمومیت بیشتری نسبت به کالچر موردنظر دارد. مثلا کالچر fa، کالچر والد fa-IR است. همچنین کالچر Invariant به عنوان والد تمام کالچرها شناخته میشود.
خوشبختانه در کلاس CultureInfo (که در قسمتهای قبلی شرح داده شده است) یک پراپرتی با عنوان Parent وجود دارد که کالچر والد را برمیگرداند.
برای رسیدن به سلسله مراتب مذبور در کلاس ResourceManager دات نت، از کلاسی با عنوان ResourceFallbackManager استفاده میشود. هرچند این کلاس با سطح دسترسی internal تعریف شده است اما نامگذاری نامناسبی دارد زیرا کاری که میکند به عنوان Manager هیچ ربطی ندارد. این کلاس با استفاده از یک کالچر ورودی، یک enumerator از سلسله مراتب کالچرها که در بالا صحبت شد تهیه میکند.
با استفاده پیادهسازی موجود در کلاس ResourceFallbackManager کلاسی با عنوان CultureFallbackProvider تهیه کردم که به صورت زیر است:
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
namespace DbResourceProvider
{
public class CultureFallbackProvider : IEnumerable<CultureInfo>
{
private readonly CultureInfo _startingCulture;
private readonly CultureInfo _neutralCulture;
private readonly bool _tryParentCulture;
public CultureFallbackProvider(CultureInfo startingCulture = null,
CultureInfo neutralCulture = null,
bool tryParentCulture = true)
{
_startingCulture = startingCulture ?? CultureInfo.CurrentUICulture;
_neutralCulture = neutralCulture;
_tryParentCulture = tryParentCulture;
}
#region Implementation of IEnumerable<CultureInfo>
public IEnumerator<CultureInfo> GetEnumerator()
{
var reachedNeutralCulture = false;
var currentCulture = _startingCulture;
do
{
if (_neutralCulture != null && currentCulture.Name == _neutralCulture.Name)
{
yield return CultureInfo.InvariantCulture;
reachedNeutralCulture = true;
break;
}
yield return currentCulture;
currentCulture = currentCulture.Parent;
} while (_tryParentCulture && !HasInvariantCultureName(currentCulture));
if (!_tryParentCulture || HasInvariantCultureName(_startingCulture) || reachedNeutralCulture)
yield break;
yield return CultureInfo.InvariantCulture;
}
#endregion
#region Implementation of IEnumerable
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endregion
private bool HasInvariantCultureName(CultureInfo culture)
{
return culture.Name == CultureInfo.InvariantCulture.Name;
}
}
}
این کلاس که اینترفیس <IEnumerable<CultureInfo را پیادهسازی کرده است، سه پارامتر کانستراکتور دارد.
اولین پارامتر، کالچر جاری یا آغازین را مشخص میکند. این کالچری است که تولید enumerator مربوطه از آن آغاز میشود. درصورتیکه این پارامتر نال باشد مقدار کالچر UI در ثرد جاری برای آن درنظر گرفته میشود. مقدار پیشفرضی که برای این پارامتر درنظر گرفته شده است، null است.
پارامتر بعدی کالچر خنثی موردنظر کاربر است. این کالچری است که درصورت رسیدن enumerator به آن کار پایان خواهد یافت. درواقع کالچر پایانی enumerator است. این پارامتر میتواند نال باشد. مقدار پیشفرضی که برای این پارامتر درنظر گرفته شده است، null است.
پارمتر آخر هم تعیین میکند که آیا enumerator از کالچرهای والد استفاده بکند یا خیر. مقدار پیشفرضی که برای این پارامتر درنظر گرفته شده است، true است.
کار اصلی کلاس فوق در متد GetEnumerator انجام میشود. در این کلاس یک حلقه do-while وجود دارد که enumerator را با استفاده از کلمه کلیدی yield تولید میکند. در این متد ابتدا درصورت نال نبودن کالچر خنثی ورودی، بررسی میشود که آیا نام کالچر جاری حلقه (که در متغیر محلی currentCulture ذخیره شده است) برابر نام کالچر خنثی است یا خیر. درصورت برقراری شرط، کار این حلقه با برگشت CultureInfo.InvariantCulture پایان مییابد. InvariantCulture کالچر بدون زبان و فرهنگ و موقعیت مکانی است که درواقع به عنوان کالچر والد تمام کالچرها درنظر گرفته میشود. پراپرتی Name این کالچر برابر string.Empty است.
کار حلقه با برگشت مقدار کالچر جاری enumerator ادامه مییابد. سپس کالچر جاری با کالچر والدش مقداردهی میشود. شرط قسمت while حلقه تعیین میکند که درصورتیکه کلاس برای استفاده از کالچرهای والد تنظیم شده باشد، تا زمانی که نام کالچر جاری برابر نام کالچر Invariant نباشد، تولید اعضای enumerator ادامه یابد.
درانتها نیز درصورتیکه با شرایط موجود، قبلا کالچر Invariant برگشت داده نشده باشد این کالچر نیز yield میشود. درواقع درصورتیکه استفاده از کالچرهای والد اجازه داده نشده باشد یا کالچر آغازین برابر کالچر Invariant باشد و یا قبلا به دلیل رسیدن به کالچر خنثی ورودی، مقدار کالچر Invariant برگشت داده شده باشد، enumerator قطع شده و عملیات پایان مییابد. در غیر اینصورت کالچر Invariant به عنوان کالچر پایانی برگشت داده میشود.
استفاده از CultureFallbackProvider
با استفاده از کلاس CultureFallbackProvider میتوان عملیات جستجوی ورودیهای درخواستی را با ترتیبی مناسب بین تمام کالچرهای موجود به انجام رسانید.
برای استفاده از این کلاس باید تغییراتی در متد GetObject کلاس DbResourceManager به صورت زیر اعمال کرد:
public object GetObject(string resourceKey, CultureInfo culture)
{
foreach (var currentCulture in new CultureFallbackProvider(culture))
{
var value = GetCachedObject(resourceKey, currentCulture.Name);
if (value != null) return value;
}
throw new KeyNotFoundException("The specified 'resourceKey' not found.");
}
با استفاده از یک حلقه foreach درون enumerator کلاس CultureFallbackProvider، کالچرهای موردنیاز برای fallback یافته میشوند. در اینجا از مقادیر پیشفرض دو پارامتر دیگر کانستراکتور کلاس CultureFallbackProvider استفاده شده است.
سپس به ازای هر کالچر یافته شده مقدار ورودی درخواستی بدست آمده و درصورتیکه نال نباشد (یعنی ورودی موردنظر برای کالچر جاری یافته شود) آن مقدار برگشت داده میشود و درصورتیکه نال باشد عملیات برای کالچر بعدی ادامه مییابد.
درصورتیکه ورودی درخواستی یافته نشود (خروج از حلقه بدون برگشت مقداری برای ورودی منبع درخواستی) استثنای KeyNotFoundException صادر میشود تا کاربر را از اشتباه رخداده مطلع سازد.
آزمایش پرووایدر سفارشی
ابتدا تنظیمات موردنیاز فایل کانفیگ را که در قسمت قبل نشان داده شد، در برنامه خود اعمال کنید.
دادههای نمونه نشان داده شده در ابتدای این مطلب را درنظر بگیرید. حال اگر در یک برنامه وب اپلیکیشن، صفحه Default.aspx در ریشه سایت حاوی دو کنترل زیر باشد:
<asp:Label ID="Label1" runat="server" meta:resourcekey="Label1" />
<asp:Label ID="Label2" runat="server" meta:resourcekey="Label2" />
خروجی برای کالچر "en-US" (معمولا پیشفرض، اگر تنظیمات سیستم عامل تغییر نکرده باشد) چیزی شبیه تصویر زیر خواهد بود:
سپس تغییر زیر را در فایل web.config اعمال کنید تا کالچر UI سایت به fa تغییر یابد (به بخش "uiCulture="fa دقت کنید):
<globalization uiCulture="fa" resourceProviderFactoryType = "DbResourceProvider.DbResourceProviderFactory, DbResourceProvider" />
بنابراین صفحه Default.aspx با همان دادههای نشان داده شده در بالا به صورت زیر تغییر خواهد کرد:
میبینید که با توجه به عدم وجود مقداری برای Label2.Text برای کالچر fa، عملیات fallback اتفاق افتاده است.
بحث و نتیجهگیری