четверг, 5 января 2017 г.

Локализация ASP.NET Core приложения


В этом посте я рассмотрю процесс локализации ASP.NET Core приложения, используя рекомендованный подход с файлами ресурсов resX.

Введение в локализацию

Локализация в ASP.NET Core в целом аналогична тому, как она работает в ASP.NET 4.x. В общем случае вы добавляете в приложение несколько файлов ресурсов с расширением .resx - по одному на каждую культуру, которую вы собираетесь поддерживать. В дальнейшем вы ссылаетесь на ресурсы с использованием ключа и текущей культуры; подходящее значение выбирается из первого подходящего файла ресурсов.

В то время, как концепция одного .resX файла для каждой культуры осталась ASP.NET Core, способ их использования кардинально изменился. В предыдущей версии при добавлении .resX файла в приложении создавался файл дизайна, который предоставлял статический строго типизированный доступ к вашим ресурсам через вызовы наподобие Resources.MyTitleString.

В ASP.NET Core  для доступа к ресурсам используются две абстракций - IStringLocalizer и IStringLocalizer, которые обычно инжектируются в нужные места с помощью механизма внедрения зависимостей. Эти интерфейсы имеют свойство-индексатор, которое позволяет вам получать доступ к ресурсу по строковому ключу. Если для данного ключа не существует ресурса (например, вы не создали подходящий .resX файл с нужным ресурсом), то сам ключ будет возвращен в качестве результата.

Рассмотрим следующий пример:
using Microsoft.AspNet.Mvc;  
using Microsoft.Extensions.Localization;

public class ExampleClass  
{
    private readonly IStringLocalizer<ExampleClass> _localizer;
    public ExampleClass(IStringLocalizer<ExampleClass> localizer)
    {
        _localizer = localizer;
    }

    public string GetLocalizedString()
    {
        return _localizer["My localized string"];
    }
}
В этом примере вызов GetLocalizedString() заставит IStringLocalizer взять текущую культуру и проверить, что подходящий ресурсный файл для ExampleClass содержит ресурс с именем/ключом "My localized string". Если он найдет указанный ресурс, то вернет его локализованную версию, в противном случае вернет "My localized string".

Идея, лежащая в основе этого подхода, состоит в том, чтобы изначально проектировать ваше приложение с учетом будущей локализации без необходимости создавать фиктивный .resX файл. Вместо этого, вы можете просто задавать значения по-умолчанию, а в будущем добавить локализованные ресурсы.

Что касается меня, то мне не очень нравится этот подход - меня раздражают все эти волшебные строки, которые являются ключами в словаре. Любые изменения в этих ключах могут привести к нежелательным последствиям, как я покажу дальше в этом посте.

Добавление локализации в ваше приложение

Теперь я откажусь от этой концепции и рассмотрю подход, который рекомендует Microsoft. Я начну со стандартного шаблона ASP.NET Core Web application без аутентификации - вы можете найти весь код на GitHub.

Первым шагом будет добавление в приложение сервисов локализации. Т.к. мы разрабатываем MVC приложение, то мы также настроим локализацию представлений и локализацию, основанную на DataAnnotations. Нужные пакеты для локализации уже содержатся в пакете Microsoft.AspNetCore.MVC в качестве ссылок, поэтому у вас есть возможность сразу добавлять сервисы и промежуточные уровни (middleware) в класс Startup:
public void ConfigureServices(IServiceCollection services)  
{
    services.AddLocalization(opts => { opts.ResourcesPath = "Resources"; });

    services.AddMvc()
        .AddViewLocalization(
            LanguageViewLocationExpanderFormat.Suffix,
            opts => { opts.ResourcesPath = "Resources"; })
        .AddDataAnnotationsLocalization();
}
Эти сервисы позволят вам инжектировать IStringLocalizer в ваши классы. Они также позволят вам иметь локализованные файлы представлений (т.о. вы можете иметь представления с именами наподобие MyView.fr.cshtml) и инжектировать IViewLocalizer, что даст вам возможность использовать локализацию в ваших файлах представлений. Вызов AddDataAnnotationsLocalization настраивает атрибуты валидации так, чтобы они извлекали ресурсы с помощью IStringLocalizer.

Параметр ResourcePath объекта Options задает директорию вашего приложения, в которой расположены файлы с ресурсами. Если корень вашего приложения расположен по пути ExampleProject, то мы уточнили, что наши ресурсы будут находится в папке ExampleProject/Resources.

Настройка указанных классов - это все, что требуется для того, чтобы использоваться сервисы локализации в приложении. Однако, обычно требуется некоторый способ для того, чтобы определять текущую культуру для заданного запроса.

Чтобы сделать это, мы будем использовать RequestLocalizationMiddleware. Этот промежуточный уровень использует несколько различных провайдеров для определения текущей культуры. Для того, чтобы настроить ее с провайдерами по-умолчанию, нам требуется определить поддерживаемые культуры, и какая культура будет использоваться по-умолчанию.
public void ConfigureServices(IServiceCollection services)  
{
    // ... previous configuration not shown

    services.Configure<RequestLocalizationOptions>(
        opts =>
        {
            var supportedCultures = new[]
            {
                new CultureInfo("en-GB"),
                new CultureInfo("en-US"),
                new CultureInfo("en"),
                new CultureInfo("fr-FR"),
                new CultureInfo("fr"),
            };

            opts.DefaultRequestCulture = new RequestCulture("en-GB");
            // Formatting numbers, dates, etc.
            opts.SupportedCultures = supportedCultures;
            // UI strings that we have localized.
            opts.SupportedUICultures = supportedCultures;
        });
}

public void Configure(IApplicationBuilder app)  
{
    app.UseStaticFiles();

    var options = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
    app.UseRequestLocalization(options.Value);

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

Использование локализации в ваших классах

Теперь у нас есть все части для добавления локализации в приложение. Но у нас по-прежнему отсутствует способ, с помощью которого пользователь может задать требуемую культуру, но мы скоро реализуем его. А пока давайте рассмотрим способ, с помощью которого можно получать локализованные строки.

Контроллеры и сервисы

Каждый раз, когда вам в вашем сервисе или контроллере нужно получить локализованную строку, вы можете инжектировать IStringLocalizer и использовать его свойство-индексатор. Представим, что нам нужно получить локализованную строку в контроллере:
public class HomeController: Controller  
{
    private readonly IStringLocalizer<HomeController> _localizer;

    public HomeController(IStringLocalizer<HomeController> localizer)
    {
        _localizer = localizer;
    }

    public IActionResult Index()
    {
        ViewData["MyTitle"] = _localizer["The localised title of my app!"];
        return View(new HomeViewModel());
    }
}
При вызове _localizer[] будет осуществляться поиск требуемой строки с учетом текущей культуры и типа HomeController. Представим, что наше приложение настроено так, как мы обсуждали выше - HomeController расположен в пространстве имен ExampleProject.Controllers и мы используем fr культуру; в этом случае локализатор будет просматривать следующие ресурсные файлы:
  • Resources/Controller.HomeController.fr.resx
  • Resources/Controller/HomeController.fr.resx
Если один из этих файлов содержит ресурс с ключом "The localised title of my app!", то он будет использован. В противном случае сам ключ будет использован в качестве ресурса. Это означает, что вам не нужно добавлять файлы ресурсов, чтобы начать использовать локализацию - вы можете просто использовать строку на исходном языке в качестве ключа, и вернуться к добавлению файлов .resX в будущем.

Представления

Существуют два способа локализации представлений. Как было описано раньше, вы можете локализовать все представление, скопировав и отредактировав его, добавив суфикс культуры. Этот подход будет полезен, если представления сильно отличаются в различных культурах.

Вы также можете локализовать строки тем же способом, каким мы локализовали HomeController. Вместо IStringLocalizer вы инжектируете IViewLocalizer в представление. Он обрабатывает HTML кодирование немного по-другому, что позволяет вам хранить HTML-разметку в качестве ресурсов и она не будет кодироваться при рендеринге. Как правило, это не требуется и вы будете использовать локализацию строк, а не HTML.

IViewLocaliser использует имя представления для поиска ресурсов, так для представления Index.cshtml контроллера HomeController культуры fr локализатор будет просматривать следующие файлы:
  • Resources/Views.Home.Index.fr.resx
  • Resources/Views/Home/Index.fr.resx
Использование IViewLocaliser совпадает с использованием IStringLocalizer - вы передаете строку на исходном языке в качестве ключа:
@using Microsoft.AspNetCore.Mvc.Localization
@model AddingLocalization.ViewModels.HomeViewModel
@inject IViewLocalizer Localizer
@{
    ViewData["Title"] = Localizer["Home Page"];
}
<h2>@ViewData["MyTitle"]</h2>  

DataAnnotations

Последней часто используемой частью MVC, которая требуется локализация, являются атрибуты DataAnnotations. Эти атрибуты могут быть использованы для валидации, именования и UI-хинтов в ваших MVC моделях. При использовании они предоставляют много декларативных метаданных в MVC, позволяя инфраструктуре выбрать подходящий элемент управления для редактирования свойства модели.

Сообщения об ошибках валидации DataAnnotation проходят через IStringLocalizer, если вы настроили MVC с использованием AddDataAnnotationsLocalization(). Как и раньше, это позволяет вам настроить сообщение об ошибках на исходном языке и использовать его в качестве ключа в будущем.
public class HomeViewModel  
{
    [Required(ErrorMessage = "Required")]
    [EmailAddress(ErrorMessage = "The Email field is not a valid e-mail address")]
    [Display(Name = "Your Email")]
    public string Email { get; set; }
}
В этом коде вы можете увидеть три атрибута DataAnnotation, два из которых являются ValidationAttribute, и один - DisplayAttribute, который им не является. Значение ErrorMessage, которое задано для каждого ValidationAttribute используется в качестве ключа для поиска подходящего ресурса с помощью IStringLocalizer. И снова, следующие файлы будут использоваться для поиска:
  • Resources/ViewModels.HomeViewModel.fr.resx
  • Resources/ViewModels/HomeViewModel.fr.resx
Ключевой особенностью, о которой стоит беспокоиться, является то, что DisplayAttribute не локализуется с использованием IStringLocalizer. Это далеко не идеальное решение, и я вернусь к нему в моем следующем посте.

Возможность для пользователей задавать культуру

Последней частью пазла является возможность для пользователей выбирать свою культуру. RequestLocalizationMiddleware использует расширяемый механизм провайдеров для получения текущей культуры запроса и поставляется с тремя встроенными провайдерами:
  • QueryStringRequestCultureProvider
  • AcceptLanguageHeaderRequestCultureProvider
  • CookieRequestCultureProvider
Они позволяют задавать культуру в параметре запроса (?culture=fr-FR), с помощью заголовка Accept-Language или через cookie-файл. Из всех этих подходов cookie-файлы являются наименее навязчивыми, т.к. они передаются с каждым запросом и не требуют от пользователя установки заголовка Accept-Language или формирования строки запроса.

Опять же, проект-пример Localization.StarterWeb предоставляет удобную реализацию того, как можно добавить поле выбора в нижний колонтитул сайта, которое позволить пользователю задавать язык. Выбор пользователя сохраняется в cookie-файле, который обрабатывается CookieRequestCultureProvider для каждого запроса. Этот провайдер затем устанавливает значение CurrentCulture и CurrentUICulture для потока, который обрабатывает запрос.

Для добавления поля выбора в приложение создайте частичное представление _SelectLanguagePartial.cshtml в папке Shared для представлений:
@using System.Threading.Tasks
@using Microsoft.AspNetCore.Builder
@using Microsoft.AspNetCore.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Options

@inject IViewLocalizer Localizer
@inject IOptions<RequestLocalizationOptions> LocOptions

@{
    var requestCulture = Context.Features.Get<IRequestCultureFeature>();
    var cultureItems = LocOptions.Value.SupportedUICultures
        .Select(c => new SelectListItem { Value = c.Name, Text = c.DisplayName })
        .ToList();
}

<div title="@Localizer["Request culture provider:"] @requestCulture?.Provider?.GetType().Name">  
    <form id="selectLanguage" asp-controller="Home"
          asp-action="SetLanguage" asp-route-returnUrl="@Context.Request.Path"
          method="post" class="form-horizontal" role="form">
        @Localizer["Language:"] <select name="culture"
                                        asp-for="@requestCulture.RequestCulture.UICulture.Name" asp-items="cultureItems"></select>
        <button type="submit" class="btn btn-default btn-xs">Save</button>

    </form>
</div>  
Нам хочется отображать это представление на каждой странице сайта, поэтому добавим его в нижний колонтитул представления _Layout.cshtml:
<footer>  
    <div class="row">
        <div class="col-sm-6">
            <p>&copy; 2016 - Adding Localization</p>
        </div>
        <div class="col-sm-6 text-right">
            @await Html.PartialAsync("_SelectLanguagePartial")
        </div>
    </div>
</footer>  
Наконец, нам нужно добавить код в контроллер для обработки пользовательского выбора. Он ссылается на SetLanguage контроллера HomeController.
[HttpPost]
public IActionResult SetLanguage(string culture, string returnUrl)  
{
    Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
    );

    return LocalRedirect(returnUrl);
}
Вот и все. Если сейчас мы откроем домашнюю страницу нашего сайта, то увидим поле выбора культуры в правом нижнем углу. На этом шаге я не добавлял ни одного ресурсного файла, поэтому если я вызову исключение валидации, то увижу, что ключ ресурса будет использован в качестве значения.


Мой процесс разработки не прервался из-за отсутствия ресурсных файлов. Я просто мог разрабатывать приложение с использованием исходного языка и добавить resX файлы позже. Если в будущем я добавлю подходящие ресурсные файлы для fr культуры, и пользователь изменит свою культуру с помощью поля выбора, то он увидит результаты локализации в атрибутах валидации и других локализованных строках.


Как вы можете видеть, атрибуты валидации и заголовок страницы локализовались, но метка поля 'Your Email' нет, хотя и была помечена атрибутом DisplayAttribute.

Заключение

В этом посте я показал как добавить локализацию в ваше ASP.NET Core приложение с использованием рекомендованного способа с ресурсами в виде ключей на исходном языке и добавлением дополнительных ресурсов в будущем.

Вот приблизительные шаги для локализации вашего приложения:
  1. Добавить требуемые сервисы локализации
  2. Настроить промежуточный уровень локализации и провайдер культуры, если потребуется
  3. Инжектировать IStringLocalizer в ваши контролеры и сервисы для локализации строк
  4. Инжектировать IViewLocalizer в ваши представления для локализации строк в них
  5. Добавить файлы ресурсов для дополнительных культур
  6. Добавить механизм выбора пользователем его культуры
В следующем посте, я обращусь к некоторым проблемам, с которыми столкнулся в процессе локализации нашего приложения, а именно уязвимость волшебных строку в типах и локализация атрибута DisplayAttribute.

Ссылки

Комментариев нет:

Отправить комментарий