淺析NopCommerce的多語言方案


前言

這段時間在研究多語言的實現,就找了NopCommerce這個開源項目來研究了一下,並把自己對這個項目的粗淺認識與大家分享一下。

挺碰巧的是昨天收到了NopCommerce 3.90 發布測試版的郵件:

nop390

不啰嗦了,開始正題了!

其實對於Nop的多語言,最主要的元素有下面兩個:

  • WebWorkContext(IWorkContext的實現類)

  • LocalizationService(ILocalizationService的實現類)

其他相關的元素可以說都是在這兩個的基礎上體現價值的。

下面先來介紹一下WebWorkContext的WorkingLanguage屬性,這個是貫穿整個應用的,所以必須要先從這個講起。

WorkingLanguage

WebWorkContext中對多語言來說最為重要的一個屬性就是WorkingLanguage,它決定了我們當前瀏覽頁面所采用的是那種語言。

每次打開一個頁面,包括切換語言時,都是讀取這個WorkingLanguage的值。當然在讀的時候,也做了不少操作:

  1. 從當前上下文中的_cachedLanguage變量是否有值,有就直接讀取了這個值。

  2. GenericAttribute表中查詢當前用戶的語言ID,這張表中的字段Key對應的值是LanguageId時,就表明是某個用戶當前正在使用的語言ID。

  3. Language表中查詢出語言信息(當前店鋪->當前店鋪默認->當前店鋪的第一個->所有語言的第一個)

查詢語言表時,首先查出店鋪支持的所有語言,然后找到當前用戶正在使用的語言ID,根據這兩個條件組合得到的Language實體就是當前的WorkingLanguage。

如果說這兩個條件的組合拿不到相應的語言實體,就會根據當前Store的默認語言ID(如下圖所示)去找。

image

如果根據Store的默認語言還是不能找到,就會取這個Store語言列表的第一個。

如果還是沒有查找到相應的語言,那就不會根據Store去找語言,而是直接取所有發布語言中的第一個,這就要確保在數據庫中必須存在一個初始化的語言。

初始化對任何一個系統都是必不可少的!!

下面是這個屬性get具體的實現片段:

if (_cachedLanguage != null)
    return _cachedLanguage;

Language detectedLanguage = null;
if (_localizationSettings.SeoFriendlyUrlsForLanguagesEnabled)
{
    //get language from URL
    detectedLanguage = GetLanguageFromUrl();
}
if (detectedLanguage == null && _localizationSettings.AutomaticallyDetectLanguage)
{
    //get language from browser settings
    //but we do it only once
    if (!this.CurrentCustomer.GetAttribute<bool>(SystemCustomerAttributeNames.LanguageAutomaticallyDetected, 
        _genericAttributeService, _storeContext.CurrentStore.Id))
    {
        detectedLanguage = GetLanguageFromBrowserSettings();
        if (detectedLanguage != null)
        {
            _genericAttributeService.SaveAttribute(this.CurrentCustomer, SystemCustomerAttributeNames.LanguageAutomaticallyDetected,
                 true, _storeContext.CurrentStore.Id);
        }
    }
}
if (detectedLanguage != null)
{
    //the language is detected. now we need to save it
    if (this.CurrentCustomer.GetAttribute<int>(SystemCustomerAttributeNames.LanguageId,
        _genericAttributeService, _storeContext.CurrentStore.Id) != detectedLanguage.Id)
    {
        _genericAttributeService.SaveAttribute(this.CurrentCustomer, SystemCustomerAttributeNames.LanguageId,
            detectedLanguage.Id, _storeContext.CurrentStore.Id);
    }
}

var allLanguages = _languageService.GetAllLanguages(storeId: _storeContext.CurrentStore.Id);
//find current customer language
var languageId = this.CurrentCustomer.GetAttribute<int>(SystemCustomerAttributeNames.LanguageId,
    _genericAttributeService, _storeContext.CurrentStore.Id);
var language = allLanguages.FirstOrDefault(x => x.Id == languageId);
if (language == null)
{
    //it not found, then let's load the default currency for the current language (if specified)
    languageId = _storeContext.CurrentStore.DefaultLanguageId;
    language = allLanguages.FirstOrDefault(x => x.Id == languageId);
}
if (language == null)
{
    //it not specified, then return the first (filtered by current store) found one
    language = allLanguages.FirstOrDefault();
}
if (language == null)
{
    //it not specified, then return the first found one
    language = _languageService.GetAllLanguages().FirstOrDefault();
}

//cache
_cachedLanguage = language;
return _cachedLanguage;

因為這里目前不涉及對這個屬性的set操作,只有在切換語言的時候會涉及,所以set的內容會放到切換語言的小節說明。並且在大部分情況下,用到的都是get操作。

視圖中常規的用法

來看看Nop中比較常規的用法:

我拿了BlogMonths.cshtml中的一小段代碼做演示:

image

在視圖中,可以看到很多這樣的寫法,幾乎每個cshtml文件都會有!

這里的T其實是一個delegate。這個delegate有2個輸入參數,並最終返回一個LocalizedString對象。

比較經常的都是只用到了第一個參數。第一個參數就是對應 LocaleStringResource表中的ResourceName字段

可以把這個對應關系理解為一個key-value,就像用網上不少資料用資源文件處理多語言那樣。

下圖是在LocaleStringResource表中用Blog做模糊查詢的示例結果:

image

至於第二個參數怎么用,想想我們string.Format的用法就知道個所以然了。只要在ResourcesValue中存儲一個帶有占位符的字符串即可!

上圖中也有部分ResourcesValue用到了這個占位符的寫法。

其實我們看了它的實現會更加清晰的理解:

public Localizer T
{
    get
    {
        if (_localizer == null)
        {
            //null localizer
            //_localizer = (format, args) => new LocalizedString((args == null || args.Length == 0) ? format : string.Format(format, args));

            //default localizer
            _localizer = (format, args) =>
                             {
                                 var resFormat = _localizationService.GetResource(format);
                                 if (string.IsNullOrEmpty(resFormat))
                                 {
                                     return new LocalizedString(format);
                                 }
                                 return
                                     new LocalizedString((args == null || args.Length == 0)
                                                             ? resFormat
                                                             : string.Format(resFormat, args));
                             };
        }
        return _localizer;
    }
}

此時可能大家會有個疑問,這里返回的是一個LocalizedString對象,並不是一個字符串,那么,它是怎么輸出到頁面並呈現到我們面前的呢??

最開始的時候我也遲疑了一下,因為源碼在手,所以查看了一下類的定義:

public class LocalizedString : MarshalByRefObject, IHtmlString
{}

看到這個類繼承了IHtmlString接口,應該就知道個七七八八了!這個接口的ToHtmlString方法就是問題的本質所在!

當斷點在LocalizedString實現的ToHtmlString方法時會發現,大部分都是走的這個方法,返回的內容也就是所謂鍵值對中的值。

其中還有部分是顯式調用Text等其他屬性的。

有興趣深入了解這個接口的內容,可以去看看msdn上面相關的內容。

視圖中強類型的使用

說起強類型,大家應該也不會陌生,畢竟大部分的MVC教程都會涉及。

在System.Web.Mvc.Html這個命名空間下,有不少靜態類(如InputExtensions,SelectExtensions等)和靜態方法(如TextBoxFor,PasswordFor等)。

其中這些靜態方法中,以For結尾的都是歸屬於強類型。

看看它們的方法簽名就知道了為什么叫強類型了。

public static MvcHtmlString TextBoxFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression);

下面就來看看,Nop在多語言這一塊是怎么個強類型法。

Nop在強類型這一塊的就一個擴展:NopLabelFor

Nop只在Nop.Admin這個項目中用到這個擴展的,在Nop.Web是沒有用到的。

在我個人看來,這一塊的實現可以說是挺妙的!下面來看看它是怎么個妙法:

先來看看它的用法,既然是強類型的,就必然有兩個方面,一個是View,一個是Model

View中的用法

@Html.NopLabelFor(model => model.Name)

Model的定義

[NopResourceDisplayName("Admin.Configuration.Languages.Fields.Name")]
[AllowHtml]
public string Name { get; set; }

在View中的用法和其他強類型的寫法並沒有什么太大的區別!只是在Model定義的時候要加上一個Attribute做為標識

下面來看看它的實現,其實這個的實現主要涉及的相關類就只有兩個:

  • 一個是視圖的擴展-HtmlExtensions

  • 一個是模型相關的Attribute-NopResourceDisplayName

先來看一下NopResourceDisplayName的實現

public class NopResourceDisplayName : System.ComponentModel.DisplayNameAttribute, IModelAttribute
{
    private string _resourceValue = string.Empty;
    //private bool _resourceValueRetrived;

    public NopResourceDisplayName(string resourceKey)
        : base(resourceKey)
    {
        ResourceKey = resourceKey;
    }

    public string ResourceKey { get; set; }

    public override string DisplayName
    {
        get
        {
            //do not cache resources because it causes issues when you have multiple languages
            //if (!_resourceValueRetrived)
            //{
            var langId = EngineContext.Current.Resolve<IWorkContext>().WorkingLanguage.Id;
                _resourceValue = EngineContext.Current
                    .Resolve<ILocalizationService>()
                    .GetResource(ResourceKey, langId, true, ResourceKey);
            //    _resourceValueRetrived = true;
            //}
            return _resourceValue;
        }
    }

    public string Name
    {
        get { return "NopResourceDisplayName"; }
    }
}

重寫了DisplayNameAttribute的DisplayName ,這樣在界面中展示的時候就會顯示這個值 , 實現了IModelAttribute的Name。

其中DisplayName中是根據ResourcesKey去數據庫中找到要顯示的文字。Name是在HtmlExtensions中用於拿到對應的NopResourceDisplayName對象。

然后是擴展的具體寫法:

public static MvcHtmlString NopLabelFor<TModel, TValue>(this HtmlHelper<TModel> helper, Expression<Func<TModel, TValue>> expression, bool displayHint = true)
{
    var result = new StringBuilder();
    var metadata = ModelMetadata.FromLambdaExpression(expression, helper.ViewData);
    var hintResource = string.Empty;
    object value;
    if (metadata.AdditionalValues.TryGetValue("NopResourceDisplayName", out value))
    {
        var resourceDisplayName = value as NopResourceDisplayName;
        if (resourceDisplayName != null && displayHint)
        {
            var langId = EngineContext.Current.Resolve<IWorkContext>().WorkingLanguage.Id;
            hintResource = EngineContext.Current.Resolve<ILocalizationService>()
                .GetResource(resourceDisplayName.ResourceKey + ".Hint", langId);

            result.Append(helper.Hint(hintResource).ToHtmlString());
        }
    }
    result.Append(helper.LabelFor(expression, new { title = hintResource }));
    return MvcHtmlString.Create(result.ToString());
}

這個擴展做的事其實也很簡單,根據模型的NopResourceDisplayName這個Attribute去顯示對應的信息。

不過要注意的是在這里還做了一個額外的操作:在文字的前面添加了一個小圖標!

可以看到這句代碼helper.Hint(hintResource).ToHtmlString(),它調用了另一個Html的擴展,這個擴展就只是創建了一個img標簽。

最后的效果如下:

image

這里還有一個關於驗證相關的實現,這里的多語言實現與強類型的實現相類似,就不重復了,它的實現依賴於FluentValidation

模型Property的用法

上面提到的基本都是在頁面上的操作的多語言,Nop中還有不少是直接在controller等地方將多語言的結果查出來賦值給對應的視圖模型再呈現到界面上的!這一點十分感謝 Spraus 前輩的評論提醒!

下面以首頁的Featured products為例補充說明一下這種用法。

foreach (var product in products)
{
    var model = new ProductOverviewModel
    {
        Id = product.Id,
        Name = product.GetLocalized(x => x.Name),
        ShortDescription = product.GetLocalized(x => x.ShortDescription),
        FullDescription = product.GetLocalized(x => x.FullDescription),
        //...
    };
    //other code
}

通過上面的代碼片段,可以看出,它也是用了一個泛型的擴展方法來實現的。這個擴展方法就是GetLocalized

大家應該已經發現這里的寫法與我們前面提到的強類型寫法有那么一點類似~~都是我們熟悉的lambda表達式。

有那么一點不同的是,這里的實現是借助了Linq的Expression。

var member = keySelector.Body as MemberExpression;
var propInfo = member.Member as PropertyInfo;

TPropType result = default(TPropType);
string resultStr = string.Empty;

string localeKeyGroup = typeof(T).Name;
string localeKey = propInfo.Name;

if (languageId > 0)
{
    //localized value
    if (loadLocalizedValue)
    {
        var leService = EngineContext.Current.Resolve<ILocalizedEntityService>();
        resultStr = leService.GetLocalizedValue(languageId, entity.Id, localeKeyGroup, localeKey);
        if (!String.IsNullOrEmpty(resultStr))
            result = CommonHelper.To<TPropType>(resultStr);
    }
}

//set default value if required
if (String.IsNullOrEmpty(resultStr) && returnDefaultValue)
{
    var localizer = keySelector.Compile();
    result = localizer(entity);
}

return result;

上面是這種方式的核心代碼片段。這里還涉及到了另外的一張數據表LocalizedProperty

image

對商品這一塊來說,這樣做的意義就是維護多套不同語言的商品資料。有專人來維護這一塊可以做到更好的分工!

  • EntityId -> 實體id(例:商品的id)
  • LanguageId -> 語言id
  • LocaleKeyGroup -> 所在分組(例:商品組,這里以類名或表名作為定義)
  • LocaleKey -> 鍵(例:商品名稱,這里是類的屬性名或表的字段名)
  • LocalValue ->值(例:Lumia 950XL,這里是類的屬性值或表的字段值)

當然這樣子的做法會導致這個表的數據量飆升!尤其是商品基數太大的時候。這個時候就可以采用分庫分為表的方式來處理這個問題。

切換語言

Nop中的切換語言是通過在一個下拉框中選中后通過js跳轉來完成。

window.location.href=/Common/SetLanguage/{langid}?returnUrl=xxx

可以看到,它是由CommonController下面的SetLanguage這個Action來處理的。

在setlanguage處理的時候,主要有4大步(第三步是Nop.Web這個項目用的),大致的流程如下:

image

其中還給當前上下文(workcontext)的WorkingLanguage屬性為找到的那個Language實體。

同時會向GenericAttribute這個表中添加或者更新記錄,這個表就像是一個配置表那樣,存着許多的配置信息。這里添加或更新的依據是KeyGroup為Customer,Key為LanguageId。

image

具體設置的片段代碼如下:

var languageId = value != null ? value.Id : 0;
_genericAttributeService.SaveAttribute(this.CurrentCustomer,
    SystemCustomerAttributeNames.LanguageId,
    languageId, _storeContext.CurrentStore.Id);

//reset cache
_cachedLanguage = null;

總結

多語言的解決方案有很多,但是不乎下面這幾種情況居多:

  • 資源文件、XML文件等外部文件
  • 基於數據庫(字段級別、表級別等)
  • 為每種語言單獨生成一個頁面
  • 為每種語言單獨做一個站點
  • 第三方的翻譯API

Nop的多語言是基於數據庫實現的,我個人也是比較偏向於這種實現!

最后用一張思維導圖來概括本文的內容

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM