
Данная статья будет узконаправленной и покрывает локализацию через БД, поэтому подробно расписывать как делать локализацию с помощью файлов ресурсов (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 .