Silverlight — великолепная инфраструктура для создания полнофункциональных веб-приложений (rich Internet applications, RIA), но до сих пор она не обеспечивает надежной поддержки локализации, которая имеется в других компонентах Microsoft .NET Framework. В Silverlight есть файлы .resx, простой класс ResourceManager и один элемент в файле проекта. И это все — дальше вы предоставлены сами себе. Нет ни расширений собственной разметки, ни поддержки класса DynamicResource.
В этой статье я покажу, как решить все эти проблемы. Я представлю решение, которое позволит разработчику загружать наборы ресурсов в период выполнения, использовать любой формат для хранения ресурсов, изменять ресурсы без повторной компиляции и применять отложенную загрузку ресурсов.
Статья поделена на три части. В первой части я разработаю простое приложение, использующее процесс локализации, который детально документирован Microsoft. Затем я покажу другое решение в области локализации, которое дает некоторые преимущества в сравнении со стандартным процессом. Наконец, мы обсудим внутренние компоненты, необходимые для создания законченного решения.
Стандартный процесс локализации
Начну с создания приложения Silverlight, в котором используется процесс локализации, документированный Microsoft. Подробное описание этого процесса см. по ссылке msdn.microsoft.com/library/cc838238(VS.95).
UI состоит из TextBlock и Image, как показано на рис. 1.
Рис. 1. Приложение
В процессе локализации, документированном Microsoft, данные ресурсов хранятся в файлах .resx. Эти файлы встраиваются в основную или в сопутствующую сборку и загружаются только раз, при запуске приложения. Вы можете создавать приложения, рассчитанные на определенные языки, модифицируя элемент SupportedCultures в файле проекта. Мое приложение-пример будет локализовано для двух языков: английского и французского. После добавления двух файлов ресурсов и двух изображений с флагами соответствующих стран структура проекта выглядит, как показано на рис. 2.
Рис. 2. Структура проекта после добавления файлов .resx
Я сменил действие build для изображений на content, поэтому могу ссылаться на изображения, используя менее детальный синтаксис. В каждый файл будут добавлены два элемента управления: TextBlock (на который я ссылаюсь через свойство Welcome) и Image (на который я ссылаюсь через свойство FlagImage).
Когда в приложении Silverlight создаются файлы ресурсов, модификатором по умолчанию для генерируемого класса ресурса является internal. К сожалению, XAML не может читать внутренние (internal) члены, даже если они находятся в той же сборке. Чтобы исправить ситуацию, модификаторы генерируемых классов нужно сменить на public. Это можно сделать в режиме проектирования файла ресурсов. Раскрывающееся меню Access Modifier позволяет указать область видимости генерируемого класса.
После подготовки файлов ресурсов нужно связать ресурсы в XAML. Для этого создается класс-оболочка со статическим полем, которое ссылается на экземпляр класса ресурса. Этот класс очень прост:
public class StringResources {
private static readonly strings strings = new strings();
public strings Strings { get { return strings; } }
}
Чтобы сделать этот класс доступным из XAML, нужно создать его экземпляр. В данном случае я создам экземпляр в классе App, чтобы он был доступен в рамках всего проекта:
<Application.Resources>
<local:StringResources x:Key="LocalizedStrings"/>
</Application.Resources>
Теперь связывание с данными в XAML становится возможным. XAML для TextBlock и Image выглядит так:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Strings.Welcome, Source={StaticResource LocalizedStrings}}"
FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Source="{Binding Strings.FlagImage, Source={StaticResource LocalizedStrings}}"/>
Путь — это содержимое свойства String с добавлением ключа для записи ресурса. Источником является экземпляр оболочки StringResources из класса App.
Задание культуры
Для приложения нужно настроить три параметра, чтобы оно получало настройку культуры браузера и отображало ресурс с соответствующей культурой.
Первый параметр — элемент SupportedCultures в файле .csproj. В настоящее время в Visual Studio нет диалогового окна, которое позволяло бы редактировать этот элемент, поэтому вам придется вручную изменять файл проекта. Вы можете сделать это, либо открыв файл проекта вне Visual Studio, либо выгрузив проект и выбрав редактирование из контекстного меню в Visual Studio.
Чтобы включить поддержку английского и французского в этом приложении, значение элемента SupportedCultures должно выглядеть так:
<SupportedCultures>fr</SupportedCultures>
Значения культур разделяются запятыми. Нейтральную культуру указывать не требуется; она автоматически компилируется в основную DLL.
Следующие шаги необходимы для того, чтобы выбирать языковые настройки браузера. В Silverlight-объект, встраиваемый в веб-страницу, нужно добавить параметр — текущую культуру UI, получаемую с серверной стороны. Для этого веб-страница должна содержаться в файле .aspx. Синтаксис параметра таков:
<param name="uiculture"
value="<%=Thread.CurrentThread.CurrentCulture.Name %>" />
И последний обязательный шаг в этом процессе — изменение файла web.config и добавление элемента globalization в элемент system.web; при этом значения его атрибутов нужно установить в auto:
<globalization culture="auto" uiCulture="auto"/>
Как уже упоминалось, приложение Silverlight имеет параметр нейтрального языка. Для доступа к этому параметру откройте вкладку Silverlight в свойствах проекта и щелкните Assembly Information. Свойство нейтрального языка находится в нижней части диалога (рис. 3).
Рис. 3. Настройка нейтрального языка
Советую указывать нейтральный язык без региона. Это параметр используется в случае неудачи и гораздо полезнее, если он охватывает широкий спектр потенциально возможных локализующих идентификаторов. Установка нейтрального языка добавляет атрибут assembly в файл assemblyinfo.cs, например:
[assembly: NeutralResourcesLanguageAttribute("en")]
После всего этого вы получите локализованное приложение, которое при запуске будет считывать язык, установленный в браузере, и загружать соответствующие ресурсы.
Собственный процесс локализации
Ограничения стандартного процесса локализации связаны с использованием ResourceManager и файлов .resx. Класс ResourceManager не меняет наборы ресурсов в период выполнения при изменении культуры в текущей среде. А применение файлов .resx привязывает разработчика к одному набору ресурсов на каждый язык и лишает его гибкости в сопровождении ресурсов.
В ответ на эти ограничения рассмотрим альтернативное решение, где применяются динамические ресурсы.
Чтобы сделать ресурсы динамическими, диспетчер ресурсов должен посылать уведомление о смене активного набора ресурсов. Для отправки таких уведомлений в Silverlight вы реализуете интерфейс INotifyPropertyChanged. На внутреннем уровне каждый набор ресурсов будет словарем с ключом и значимым типом String.
В разработке для Silverlight популярны инфраструктуры Prism и Managed Extensibility Framework (MEF), и при их использовании приложение разбивается на несколько файлов .xap. Для локализации в каждом файле .xap нужен свой экземпляр диспетчера ресурсов. Чтобы посылать уведомления каждому файлу .xap (каждому экземпляру диспетчера ресурсов), вы должны отслеживать каждый создаваемый экземпляр и перебирать список, когда возникает необходимость в отправке уведомлений. На рис. 4 показан код для такой функциональности SmartResourceManager.
Рис. 4. SmartResourceManager
public class SmartResourceManager : INotifyPropertyChanged {
private static readonly List<SmartResourceManager> Instances =
new List<SmartResourceManager>();
private static Dictionary<string, string> resourceSet;
private static readonly Dictionary<string,
Dictionary<string, string>> ResourceSets =
new Dictionary<string, Dictionary<string, string>>();
public Dictionary<string, string> ResourceSet {
get { return resourceSet; }
set { resourceSet = value;
// Notify all instances
foreach (var obj in Instances) {
obj.NotifyPropertyChanged("ResourceSet");
}
}
}
public SmartResourceManager() {
Instances.Add(this);
}
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged(string property) {
var evt = PropertyChanged;
if (evt != null) {
evt(this, new PropertyChangedEventArgs(property));
}
}
Как видите, для хранения всех экземпляров диспетчера ресурсов создается статический список. Активный набор ресурсов хранится в поле resourceSet, а каждый загруженный ресурс — в списке ResourceSets. В конструкторе текущий экземпляр помещается в список Instances. Класс реализует INotifyPropertyChanged стандартным образом. Когда меняется активный набор ресурсов, я перебираю список экземпляров и инициирую событие PropertyChanged каждого из экземпляров.
Классу SmartResourceManager нужен какой-либо способ смены культуры в период выполнения, и для этого достаточно метода, который принимает объект CultureInfo:
public void ChangeCulture(CultureInfo culture) {
if (!ResourceSets.ContainsKey(culture.Name)) {
// Load the resource set
}
else {
ResourceSet = ResourceSets[culture.Name];
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture =
culture;
}
}
Этот метод проверяет, загружена ли запрошенная культура. Если не загружена, он загружает ее, а затем делает активной. Если культура уже загружена, этот метод просто устанавливает соответствующий набор ресурсов как активный. Код загрузки ресурса пока опущен.
Для полноты картины я также покажу два метода загрузки ресурса (рис. 5). Первый метод принимает только ключ ресурса и возвращает ресурс из активной культуры, а второй принимает ресурс и имя культуры и возвращает ресурс для этой культуры.
Рис. 5. Загрузка ресурсов
public string GetString(string key) {
if (string.IsNullOrEmpty(key)) return string.Empty;
if (resourceSet.ContainsKey(key)) {
return resourceSet[key];
}
else {
return string.Empty;
}
}
public string GetString(string key, string culture) {
if (ResourceSets.ContainsKey(culture)) {
if (ResourceSets[culture].ContainsKey(key)) {
return ResourceSets[culture][key];
}
else {
return string.Empty;
}
}
else {
return string.Empty;
}
}
Если вы прямо сейчас запустите приложение, все локализованные строки будут пустыми, так как наборы ресурсов еще не загружены. Чтобы загружать начальный набор ресурсов, я создам метод Initialize, который принимает файл с нейтральным языком и идентификатор культуры. Этот метод вызывается лишь раз в ходе выполнения приложения (рис. 6).
Рис. 6. Инициализация нейтрального языка
public SmartResourceManager() {
if (Instances.Count == 0) {
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
Instances.Add(this);
}
public void Initialize(string neutralLanguageFile,
string neutralLanguage) {
lock (lockObject) {
if (isInitialized) return;
isInitialized = true;
}
if (string.IsNullOrWhiteSpace(neutralLanguageFile)) {
// No neutral resources
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
else {
LoadNeutralResources(neutralLanguageFile, neutralLanguage);
}
}
Связывание с XAML
Пользовательские расширения разметки могли бы обеспечить самый гибкий синтаксис связывания для локализованных ресурсов. К сожалению, в Silverlight таких расширений нет. В Silverlight 3 и более поздних версиях поддерживается связывание со словарем, и синтаксис выглядит так:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Path=ResourceSet[Welcome], Source={StaticResource
SmartRM}}" FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Source="{Binding ResourceSet[FlagImage], Source={StaticResource SmartRM}}"/>
Путь содержит имя свойства словаря с ключом в квадратных скобках. Если вы используете Silverlight 2, вам доступны два варианта: создание класса ValueConverter или строго типизированного объекта, используя отражение. Второй вариант выходит за рамки этой статьи. Код ValueConverter мог бы выглядеть, как показано на рис. 7.
Рис. 7. Собственный ValueConverter
public class LocalizeConverter : IValueConverter {
public object Convert(object value,
Type targetType, object parameter,
System.Globalization.CultureInfo culture) {
if (value == null) return string.Empty;
Dictionary<string, string> resources =
value as Dictionary<string, string>;
if (resources == null) return string.Empty;
string param = parameter.ToString();
if (!resources.ContainsKey(param)) return string.Empty;
return resources[param];
}
}
Класс LocalizeConverter принимает словарь и переданный параметр, а возвращает значение ключа в словаря. После создания экземпляра этого конвертера, синтаксис связывания выглядел бы следующим образом:
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="{Binding Path=ResourceSet, Source={StaticResource SmartRM}, Converter={StaticResource LocalizeConverter}, Convert-erParameter=Welcome}" FontSize="24"/>
</StackPanel>
<Image Grid.Row="1" Grid.ColumnSpan="2" HorizontalAlignment="Center"
Source="{Binding ResourceSet, Source={StaticResource SmartRM}, Converter={StaticResource LocalizeConverter}, ConverterParameter=FlagImage}"/>
Синтаксис более подробный при использовании конвертера, но и более гибкий. Однако в остальной части статьи я буду обходиться без конвертера и вместо него использовать словарный синтаксис связывания.
Параметры локализации
Чтобы настройки культуры подхватывались приложением Silverlight, нужно сконфигурировать два параметра в этом приложении. Это все те же параметры, что и в стандартном процессе локализации. Значения culture и uiCulture в элементе globalization в файле web.config должны быть равны auto:
<globalization culture="auto" uiCulture="auto"></globalization>
Кроме того, Silverlight-объекту в файле .aspx нужно передавать как параметр значение текущей культуры в UI-потоке:
<param name="uiculture"
value="<%=Thread.CurrentThread.CurrentCulture.Name %>" />
Чтобы продемонстрировать динамическую локализацию приложения, я добавлю пару кнопок для смены текущей культуры, как показано на рис. 8. Событие щелчка для кнопки English выглядит так:
(App.Current.Resources["SmartRM"] as SmartResourceManager).ChangeCulture(
new CultureInfo("en"));
Рис. 8. Кнопки для смены культуры
При наличии подготовленных данных приложение показывало бы UI на соответствующем языке. Это решение обеспечивает динамическую локализацию в период выполнения и достаточно расширяемое для загрузки ресурсов по задаваемой вами логике.
В следующем разделе мы сосредоточимся на заполнении оставшихся пробелов: где хранятся ресурсы и как они извлекаются.
Компоненты на серверной стороне
Теперь создадим базу данных для хранения ресурсов и WCF-сервис (Windows Communication Foundation) для извлечения этих ресурсов. В более крупных приложениях вы предпочтете создать уровни данных и бизнес-логики, но в этом примере я не стану использовать такие абстракции.
Причины, по которым я выбрал WCF-сервис, — простота его создания и надежность, обеспечиваемая WCF. Ресурсы я помещаю в реляционную базу данных; это тоже упрощает их сопровождение и управление ими. Можно было бы создать соответствующее административное приложение, которое позволило бы переводчикам легко модифицировать ресурсы.
Для этого приложения я использую SQL Server 2008 Express. Схема данных показана на рис. 9.
Рис. 9. Схема таблиц локализации в SQL Server 2008 Express
Tag — это именованная группа ресурсов, StringResource — сущность, представляющая ресурс. Столбец LocaleId представляет имя культуры для данного ресурса. Столбец Comment добавлен для совместимости с форматом .resx. Столбцы CreatedDate и ModifiedDate добавлены для поддержки аудита.
StringResource можно сопоставлять с несколькими Tag. Преимущество этого в том, что вы можете создавать специфические группы (например, ресурсы для одного экрана) и загружать только эти ресурсы. А недостаток — вы можете назначить множеству ресурсов одинаковые LocaleId, Key и Tag. В этом случае вы, вероятно, предпочтете написать триггер для управления созданием или обновление ресурсов либо задействовать столбец ModifiedDate, чтобы при извлечении определять, какой из ресурсов самый новый.
Я буду извлекать данные, используя LINQ to SQL. Первая операция сервиса будет принимать имя культуры и возвращать все ресурсы, сопоставленные с этой культурой. Вот интерфейс:
[ServiceContract]
public interface ILocaleService {
[OperationContract]
Dictionary<string, string> GetResources(string cultureName);
}
А это реализация:
public class LocaleService : ILocaleService {
private acmeDataContext dataContext = new acmeDataContext();
public Dictionary<string, string> GetResources(string cultureName) {
return (from r in dataContext.StringResources
where r.LocaleId == cultureName
select r).ToDictionary(x => x.Key, x => x.Value);
}
}
Эта операция просто находит все ресурсы, чьи LocaleId равны параметру cultureName. Поле dataContext является экземпляром класса LINQ to SQL, подключенного к базе данных SQL Server. Вот и все! Благодаря LINQ и WCF.
Теперь пора связать WCF-сервис с классом SmartResourceManager. Добавив ссылку на сервис в приложение Silverlight, я регистрируюсь в конструкторе на получение события завершения для операции GetResources:
public SmartResourceManager() {
Instances.Add(this);
localeClient.GetResourcesCompleted +=
localeClient_GetResourcesCompleted;
if (Instances.Count == 0) {
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
}
Метод обратного вызова должен добавлять набор ресурсов в список и делать этот набор активным. Соответствующий код показан на рис. 10.
Рис. 10. Добавление ресурсов
private void localeClient_GetResourcesCompleted(object sender,
LocaleService.GetResourcesCompletedEventArgs e) {
if (e.Error != null) {
var evt = CultureChangeError;
if (evt != null)
evt(this, new CultureChangeErrorEventArgs(
e.UserState as CultureInfo, e.Error));
}
else {
if (e.Result == null) return;
CultureInfo culture = e.UserState as CultureInfo;
if (culture == null) return;
ResourceSets.Add(culture.Name, e.Result);
ResourceSet = e.Result;
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture = culture;
}
}
Метод ChangeCulture нужно модифицировать, чтобы он вызывал операцию WCF-сервиса:
public void ChangeCulture(CultureInfo culture) {
if (!ResourceSets.ContainsKey(culture.Name)) {
localeClient.GetResourceSetsAsync(culture.Name, culture);
}
else {
ResourceSet = ResourceSets[culture.Name];
Thread.CurrentThread.CurrentCulture =
Thread.CurrentThread.CurrentUICulture = culture;
}
}
Загрузка ресурсов, нейтральных к конкретному языку
Приложению нужен некий способ восстановления на случай, если веб-сервисы окажутся недоступными по какой-либо причине. Файл с нейтральными языковыми ресурсами должен храниться вне веб-сервисов и загружаться при запуске приложения. Тогда в случае сбоя приложения сможет переключиться на эти ресурсы.
Я создам еще один конструктор SmartResourceManager с двумя параметрами: URL, указывающим на файл с нейтральными языковыми ресурсами, и кодом культуры, идентифицирующим культуру файла ресурсов (рис. 11).
Рис. 11. Загрузка ресурсов, нейтральных к конкретному языку
public SmartResourceManager(string neutralLanguageFile, string neutralLanguage) {
Instances.Add(this);
localeClient.GetResourcesCompleted +=
localeClient_GetResourcesCompleted;
if (Instances.Count == 1) {
if (string.IsNullOrWhiteSpace(neutralLanguageFile)) {
// No neutral resources
ChangeCulture(Thread.CurrentThread.CurrentUICulture);
}
else {
LoadNeutralResources(neutralLanguageFile, neutralLanguage);
}
}
}
Если файла с нейтральными ресурсами нет, выполняется обычный процесс вызова WCF-сервиса. Метод LoadNeutralResources с помощью WebClient получает файл ресурса с сервера. Затем разбирает этот файл и преобразует XML-строку в объект Dictionary. Этот код я здесь не показываю, потому что он весьма длинный и тривиальный, но, если он вас интересует, вы найдете его в полном исходном коде, который можно скачать для этой статьи.
Чтобы вызвать параметризованный конструктор SmartResourceManager, мне нужно переместить создание экземпляра SmartResourceManager в отделенный код класса App (поскольку Silverlight не поддерживает XAML 2009). Однако я не хочу «зашивать» в код файл ресурса или код культуры, поэтому мне придется создать собственный класс ConfigurationManager, который вы тоже найдете в исходном коде для этой статьи.
После интеграции ConfigurationManager в класс App метод обратного вызова события Startup выглядит так:
private void Application_Startup(object sender, StartupEventArgs e) {
ConfigurationManager.Error += ConfigurationManager_Error;
ConfigurationManager.Loaded += ConfigurationManager_Loaded;
ConfigurationManager.LoadSettings();
}
Этот метод обратного вызова теперь служит для загрузки настроек приложения и регистрации на обратные вызовы. Если же вы решите загружать конфигурационные параметры фоновым вызовом, будьте осторожны, чтобы не создать условия для конкуренции потоков. Вот методы обратного вызова для событий ConfigurationManager:
private void ConfigurationManager_Error(object sender, EventArgs e) {
Resources.Add("SmartRM", new SmartResourceManager());
this.RootVisual = new MainPage();
}
private void ConfigurationManager_Loaded(object sender, EventArgs e) {
Resources.Add("SmartRM", new SmartResourceManager(
ConfigurationManager.GetSetting("neutralLanguageFile"),
ConfigurationManager.GetSetting("neutralLanguage")));
this.RootVisual = new MainPage();
}
Метод обратного вызова события Error загружает SmartResourceManager без нейтрального языка, а метод обратного вызова события Loaded осуществляет загрузку с нейтральным языком.
Мне нужно поместить файл ресурсов в такое место, где мне не придется все перекомпилировать при внесении изменений в этот файл. Поэтому я помещу его в каталог ClientBin веб-проекта и после создания файла ресурсов сменю его расширение на .xml, чтобы он был общедоступным и класс WebClient мог обращаться к нему из приложения Silverlight. Поскольку этот файл общедоступный, не храните в нем никаких конфиденциальных данных.
ConfigurationManager тоже читает из каталога ClientBin. Он ищет файл с именем appSettings.xml, содержимое которого выглядит так:
<AppSettings>
<Add Key="neutralLanguageFile" Value="strings.xml"/>
<Add Key="neutralLanguage" Value="en-US"/>
</AppSettings>
Как только appSettings.xml и strings.xml оказываются на месте, ConfigurationManager и SmartResourceManager могут работать совместно для загрузки нейтрального языка. В этом процессе еще много чего надо усовершенствовать.Например, если активная культура потока отличается от нейтрального языка, а веб-сервис недоступен, она будет отличаться и от культуры активного набора ресурсов. Но это упражнение я оставляю читателям.
Заключение
Что я напрочь пропустил в этой статье, так это нормализацию ресурсов на серверной стороне. Допустим, что в ресурсе fr-FR не хватает двух ключей, которые есть в ресурсе fr. При запросе ресурсов fr-FR веб-сервис должен вставить недостающие ключи из более универсального ресурса fr.
Еще один аспект этого решения, о котором я ничего не рассказал, — загрузка ресурсов по культуре и набору ресурсов, а не просто по культуре. Это удобно при загрузке ресурсов индивидуально для каждого экрана или файла .xap.
Тем не менее данное решение позволяет делать ряд полезных вещей, в том числе загружать наборы ресурсов в период выполнения, использовать любой формат для хранения ресурсов, изменять ресурсы без перекомпиляции и выполнять отложенную загрузку ресурсов.
Представленное здесь решение является универсальным, и вы можете подключаться к нему во многих точках и кардинально менять его реализацию. Надеюсь, это поможет сократить вашу ежедневную нагрузку, связанную с программированием.
Для более глубокого изучения интернационализации советую прочитать книгу Гая Смита-Ферье (Guy Smith-Ferrier) «.NET Internationalization: The Developer’s Guide to Building Global Windows and Web Applications» (Addison-Wesley, 2006). Смит-Ферье также выложил на своем веб-сайте великолепный видеоролик по вопросам интернационализации; этот видеоролик называется «Internationalizing Silverlight at SLUGUK» (bit.ly/gJGptU).