Pull to refresh

Локализация ASP.NET MVC приложения с помощью БД

Reading time 8 min
Views 18K

Данная статья будет узконаправленной и покрывает локализацию через БД, поэтому подробно расписывать как делать локализацию с помощью файлов ресурсов (resx) можно посмотреть, например, тут: MVC 2: Полное руководство по локализации. Для локализации с помощью представлений я тоже там ссылки.

Для начала я кратко расскажу о вариантах локализации сайта, покажу пример создания своего ResourceProviderFactory, после чего создам небольшое приложение для демонстрации.


Варианты локализации


Во многих обсуждениях и статьях упоминается лишь только два варианта локализации, например, статья ASP.NET MVC 3 Internationalization, на которую можно встретить множество ссылок выделяет следующие:
— Файлы ресурсов (resx)
— Использовать разные «Представления» (View)
Первый способ как правило применяется для статики: названий полей, валидации и прочего. Существенным минусом использования второго является, необходимость делать много ручной работы по копирования одного и того же кода, в случае, если нужно будет даже незначительно поменять верстку, также сложно представить структуру сайта с множеством языков, количество файлов будет огромным, иногда перевод разбивают по директориям, становится конечно нагляднее, но масштабируемость оставляет желать лучшего. В моём случае мне нужно было переводить динамический контент, который добавляется через админку, вариант редактирования resx файлов из админки я не рассматривал, но реализации Вы можете найти самостоятельно, как говорится затея на любителя, поэтому выделяем третий вариант:
— Локализация с помощью БД
Конечно же можно комбинировать все эти три варианта.

Пример реализации


Сразу скажу, что я создаю пустой проект MVC 3, так как буду использовать Entity Framework Code First, переписывать Membership Provider в данной статье я не буду, пример как это делать можете посмотреть, например, тут: Custom Membership Providers. Просто запомните, что «админка» будет общедоступна, конечно можно было реализовать авторизацию, через конфиг файл как это демонстрирует Стивен Сандерсон в своих книгах, но статья о другом.

Сделаем пародию на склад, у нас будет таблица продуктов с 4-мя полями:
-Идентификатор
-Имя продукта (его мы будем переводить с помощью БД)
-Цена (данное поле нам нужно для демонстрации проблем с валидацией при локализации)
-Дата привоза (аналогично предыдущему)

Следующим этапом создадим класс Product и установим атрибуты с помощью Data Annotations (если Вам не нравится такой вариант, то можете воспользоваться Fluent API, к которому в любом случае придётся обращаться в крупном проекте) и создадим DbContext:

public class Product
{
    public int ProductId { get; set; }

    [Required]
    [StringLength(128)]
    public string Name { get; set; }

    [Required]
    public decimal Price { get; set; }

    [Required]
    public DateTime ImportDate { get; set; }
}

public class ProductDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }
}


Теперь я сгенерирую контроллер и все действия (Actions) автоматически, получилось немного страшновато, поэтому придётся добавить стилей, правда я бы рекомендовал использовать несколько другой подход для генерации. Объединение создания и редактирования в одном представлении, например, как тут: Непутевые заметки о ASP.NET MVC. Часть 1 (и единственная), что уберет одно представление, старайтесь, чтобы у Вас было как можно меньше «копипаста».

В итоге у нас получилась такая таблица:



Переходим к ResourceProviderFactory, немного погуглив я нашёл довольно старую статью в MSDN Extending the ASP.NET 2.0 Resource-Provider Model, а также описание ResourceProviderFactory Class с примером реализации, но уже для 4-го фреймворка. На том же codeproject есть готовый пример, который тоже можно взять за основу: ASP.NET 2.0 Custom SQL Server ResourceProvider.

Создадим теперь класс для хранения переводов:

public class GlobalizationResource
{
    public int GlobalizationResourceId { get; set; }

    [Required]
    [StringLength(128)]
    public string ResourceObject { get; set; }

    [Required]
    [StringLength(128)]
    public string ResourceName { get; set; }

    [Required]
    [StringLength(5)]
    public string Culture { get; set; }

    [Required]
    [StringLength(4000)]
    public string ResourceValue { get; set; }
}


И не забудьте его добавить в контекст БД. У меня получилась средняя реализация между codeproject и примером из MSDN, код можно скачать в конце статьи, так как там около 150 строк. И добавим провайдера в конфиг:

  <system.web>
    <globalization enableClientBasedCulture="true" resourceProviderFactoryType="DbLocalizationExample.Models.CustomResourceProviderFactory" uiCulture="auto" culture="auto" />
	...
  </system.web>


Всё бы ничего, но чтобы проверить работу локализации нам нужна возможность выбора языка, для хранения локализации я буду использовать куки (сессию я бы не советовал использовать, так как вряд ли пользователь обрадуется зайдя через 20 минут (стандартное время жизни насколько я помню) на сайт, что опять нужно выбирать язык). За основу возьмём идею с сайта afana.me и получим такой вот класс:

public static class CultureHelper
{
    private static readonly List<string> Cultures = new List<string> {
        "ru-RU",  // first culture is the DEFAULT
        "en-US",
    };

    /// <summary>
    /// Returns a valid culture name based on "name" parameter. If "name" is not valid, it returns the default culture "en-US"
    /// </summary>
    /// <param name="name">Culture's name (e.g. en-US)</param>
    public static string GetValidCulture(string name)
    {
        if (string.IsNullOrEmpty(name))
            return GetDefaultCulture(); // return Default culture

        if (Cultures.Contains(name))
            return name;

        // Find a close match. For example, if you have "en-US" defined and the user requests "en-GB", 
        // the function will return closes match that is "en-US" because at least the language is the same (ie English)            
        foreach (var c in Cultures)
            if (c.StartsWith(name.Substring(0, 2)))
                return c;

        return GetDefaultCulture(); // return Default culture as no match found
    }

    public static string GetDefaultCulture()
    {
        return Cultures.ElementAt(0); // return Default culture

    }

    public static string GetCultureFromCookies(HttpRequest request)
    {
        string cultureName = null;
        // Attempt to read the culture cookie from Request
        HttpCookie cultureCookie = request.Cookies["_culture"];
        if (cultureCookie != null)
        {
            cultureName = cultureCookie.Value;
        }
        else if (request.UserLanguages != null)
        {
            cultureName = request.UserLanguages[0]; // obtain it from HTTP header AcceptLanguages
        }

        // Validate culture name
        return GetValidCulture(cultureName); // This is safe
    }

    private static string AcceptLanguage()
    {
        return HttpUtility.HtmlAttributeEncode(System.Threading.Thread.CurrentThread.CurrentUICulture.ToString());
    }

    public static IHtmlString MetaAcceptLanguage<T>(this HtmlHelper<T> html)
    {
        return new HtmlString(String.Format(@"<meta name=""accept-language"" content=""{0}"" />", AcceptLanguage()));
    }

    public static IHtmlString GlobalizationLink<T>(this HtmlHelper<T> html)
    {
        return new HtmlString(String.Format(@"<script src=""../../Scripts/globalization/cultures/globalize.culture.{0}.js"" type=""text/javascript""></script>",
            AcceptLanguage()));
    }
}


Теперь нам осталось добавить действия для установки и чтения куков:

public ActionResult SetCulture(string culture)
{
    // Validate input
    culture = CultureHelper.GetValidCulture(culture);

    // Save culture in a cookie
    HttpCookie cookie = Request.Cookies["_culture"];
    if (cookie != null)
    {
        cookie.Value = culture; // update cookie value
    }
    else
    {
        cookie = new HttpCookie("_culture");
        cookie.HttpOnly = false; // Not accessible by JS.
        cookie.Value = culture;
        cookie.Expires = DateTime.Now.AddYears(1);
    }
    Response.Cookies.Add(cookie);

    return RedirectToAction("Index");
}


А также логику в Global.asax для утановки культуры и проверки GetVaryByCustomString для того, чтобы использовать кэширование.

protected void Application_AcquireRequestState(object sender, EventArgs e)
{
    string cultureName = CultureHelper.GetCultureFromCookies(Request);

    // Modify current thread's culture            
    Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(cultureName);
    Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(cultureName);
}

public override string GetVaryByCustomString(HttpContext context, string arg)
{
    // It seems this executes multiple times and early, so we need to extract language again from cookie.
    if (arg == "culture") // culture name (e.g. "en-US") is what should vary caching
    {
        string cultureName = CultureHelper.GetCultureFromCookies(Request);
        return cultureName.ToLower();// use culture name as cache key, "es", "en-us", "es-cl", etc.
    }

    return base.GetVaryByCustomString(context, arg);
}


Пару слов о логике перевода: у меня в базе данных будет храниться язык по умолчанию, т.е. просто объект Product, но когда я захочу добавить ему перевод я напишу в представлении:

@(Culture == "ru-RU" ? item.Name : HttpContext.GetLocalResourceObject("/Home/Index", "Product_" + item.ProductId)) 

Что автоматически добавит значение по умолчанию в БД. Первый параметр похож на путь лишь для наглядности, там может быть любая последовательность символов (ограниченная правда 128 в БД для нашего объявления), второй это уникальный идентификатор.

В Layout добавляем возможность выбора языка:
<div class="language">
    <span>@Html.ActionLink("rus", "SetCulture", "Home", new { culture = "ru-RU" }, null)</span>
    <span>@Html.ActionLink("eng", "SetCulture", "Home", new { culture = "en-US" }, null)</span>
</div>


Можно запускать, но не тут то было, я поменял контекст (добавил класс для ресурсов) и теперь EF отказывается выводить данные из таблицы. Идём в View -> Other Windows -> Package Manager Console и вводим (каждая строка отдельно):

Update-Package EntityFramework
Enable-Migrations

теперь можно создать миграцию и обновить базу:

Add-Migration AddGlobalizationResources
Update-Database

Но тут нас ждёт огорчение, студия говорит, что мы создали БД с более старым EF, где нет истории миграции, поэтому, чтобы руками не удалять нашу базу, добавим в Index такую строчку (если вы противник миграции, то правильнее её добавлять в Application_Start, но помните, что это удаляет все данные):

Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ProductDbContext>());

После компиляции и обращения к нему, удалим её, так как в последующем мы сможем наслаждаться всеми плюшками миграции: EF 4.3 Automatic Migrations Walkthrough.

Результат нашей работы будет выглядеть так:



Английский вариант создаётся в БД автоматически, русский же вариант храниться в Product. Логику редактирования БД я оставлю Вам, там нет ничего сложного.

Клиентская валидация


При переключении языка у нас возникает проблема с decimal и Datetime. Для русского языка мы имеем «4,00», а для английского это «4.00». Даты тоже имеют проблемы: «21.12.2012» и «12/21/2012». Для решения этих проблем мы воспользуемся globalize и подключим jquery ui datapicker, чтобы задавать формат автоматически и упростить ввод дат.

Добавим в Layout следующее («ядро» глобализации, глобализия для конкретного языка, мета тег для клиентской части и общие скрипты для валидации чисел и изменения jquery ui datapicker):
<script src="@Url.Content("~/Scripts/globalization/globalize.js")" type="text/javascript"></script>
@Html.GlobalizationLink()
@Html.MetaAcceptLanguage()
<script src="@Url.Content("~/Scripts/common.js")" type="text/javascript"></script>


Это лишь малая часть клиенской валидации, пример локализации можно посмотреть тут: ASP.NET MVC 3 Internationalization — Part 2 (NerdDinner)

Итог


Я рассказал как можно создать свой собственный провайдер ресурсов, создал небольшое приложение демонстрирующее его работу и поделился ссылками где можно прочитать больше информации по данной теме. Как пишет Jon Skeet в своей книге «C# in Depth», что приведенный здесь код — это лишь примеры, я не гарантирую, что код, который Вы возьмете отсюда будет у Вас работать. У меня используется полное кэширование перевода, скорее всего Вам нужно будет загружать перевод постепенно, если будет большой объём информации, устанавливать время жизни и т.д. Помните, что при редактировании перевода нужно обязательно чистить кэш, чтобы данные отобразились сразу (это когда Вы будете реализовывать логику редактирования перевода).



Проект можно скачать тут (Visual studio 2010): ссылка (2,89 Мб) (пример лишь демонстрирует локализацию динамики, добавить перевод статики на порядок проще, поэтому код содержит лишь описанное в статье)

Источники


Ссылки


Extending the ASP.NET 2.0 Resource-Provider Model
ResourceProviderFactory Class
ASP.NET 2.0 Custom SQL Server ResourceProvider
ASP.NET MVC 3 Internationalization
ASP.NET MVC 3 Internationalization — Part 2 (NerdDinner)

Книги


Freeman A. Sanderson S. — Pro ASP.NET MVC 3 Framework Third Edition — 2011
Julia Lerman and Rowan Miller — Programming Entity Framework:Code First — 2012

Примечание: Если Вы будете делать локализацию по данном руководству ASP.NET MVC 3 Internationalization, то Вам следует помнить, что в MVC 4 ExecuteCore не работает ExecuteCore() in base class not fired in MVC 4 beta .
Tags:
Hubs:
+11
Comments 11
Comments Comments 11

Articles