Недавно я столкнулся с проблемой локализации своего приложения и задумался над её решением.
Первым на ум приходить самый очевидный и простой способ - словарь, но он был тут же отвергнут, так как никак не нельзя проверить существует ли строка в словаре на момент компиляции.
Куда более изящное решение - создать иерархию классов типа этой:
public class Locale
{
public string Name {get; set;}
public UI UI {get; set;}
}
public class UI
{
public Buttons Buttons {get; set;}
public Messages Messages {get; set;}
}
public class Buttons
{
public string CloseButton {get; set;}
public string DeleteButton {get; set;}
}
public class Messages
{
public string ErrorMessage {get; set;}
}
Дальше можно просто сериализировать/десериализировать xml'ку.
Только есть одно "но". На создание такой иерархии классов может уйти достаточно времени, особенно если проект большой. Так почему бы не генерировать ее из xml файла? Этим мы и займемся.
Приступим
Для начала создадим проект нашего генератора и добавим в него необходимые пакеты
dotnet new classlib -o LocalizationSourceGenerator -f netstandard2.0
dotnet add package Microsoft.CodeAnalysis.CSharp
dotnet add package Microsoft.CodeAnalysis.Analyzers
Важно! Target framework проекта обязательно должен быть netstandard2.0
Далее добавим класс нашего генератора
Он должен реализовать интерфейс ISourceGenerator и быть помеченным атрибутом Generator
Далее добавим интерфейс ILocalizationGenerator и класс XmlLocalizationGenerator, который реализует его:
ILocalizationGenerator.cs
public interface ILocalizationGenerator
{
string GenerateLocalization(string template);
}
XmlLocalizationGenerator.cs
public class XmlLocalizationGenerator : ILocalizationGenerator
{
//список сгенерированых классов
private List<string> classes = new List<string>();
public string GenerateLocalization(string template)
{
//создаем новый xml документ и загружаем шаблон
XmlDocument document = new XmlDocument();
document.LoadXml(template);
var root = document.DocumentElement;
//Получаем имя пространства имен или задаем стандартное
string namespaceName = root.HasAttribute("namespace") ?
root.GetAttribute("namespace") :
"Localization";
GenClass(root); //Рекурсивно генерируем классы
var sb = new StringBuilder();
sb.AppendLine($"namespace {namespaceName}\n{{");
//Каждый сгенерированый клас записываем в результат
foreach(var item in classes)
{
sb.AppendLine(item);
}
sb.Append('}');
return sb.ToString();
}
public void GenClass(XmlElement element)
{
var sb = new StringBuilder();
sb.Append($"public class {element.Name}");
sb.AppendLine("{");
//Для всех дочерних узлов генерируем свойства в классе
foreach (XmlNode item in element.ChildNodes)
{
//если узел не имеет дочерних узлов или
//имеет только один текстовый узел - генерируем свойство-строку
if (item.ChildNodes.Count == 0
|| (item.ChildNodes.Count == 1
&& item.FirstChild.NodeType==XmlNodeType.Text))
{
sb.AppendLine($"public string {item.Name} {{get; set;}}");
}
else
{
//Генерируем класс по имени узла
//и добавляем одноименное свойство
sb.AppendLine($"public {item.Name} {item.Name} {{get; set;}}");
GenClass(item);
}
}
sb.AppendLine("}");
classes.Add(sb.ToString());
}
}
Осталось дело за малым. Необходимо реализовать класс самого генератора
LocalizationSourceGenerator.cs
[Generator]
public class LocalizationSourceGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
//Загружаем файл шаблона из дополнительных файлов
var templateFile = context
.AdditionalFiles
.FirstOrDefault(
x => Path.GetExtension(x.Path) == ".xml")
?.Path;
if (!string.IsNullOrWhiteSpace(templateFile))
{
ILocalizationGenerator generator = new XmlLocalizationGenerator();
var s = generator.GenerateLocalization(File.ReadAllText(templateFile));
//Этот метод и занимается "волшебством"
//Он встраивает сгенерированый код в процесс компиляции
context.AddSource("Localization",s );
}
}
public void Initialize(GeneratorInitializationContext context)
{
//В данном случае нам не нужно ничего инициализировать,
//поэтому оставим реализацию пустой
}
}
Вот и все! Теперь нужно лишь проверить наш генератор. Для этого создадим проект консольного приложения
dotnet new console -o Test
Добавим файл шаблона и локализации
template.xml
<Locale namespace="Program.Localization">
<UI>
<Buttons>
<SendButton/>
</Buttons>
</UI>
<Name/>
</Locale>
ru.xml
<Locale>
<UI>
<Buttons>
<SendButton>Отправить</SendButton>
</Buttons>
</UI>
<Name>Русский</Name>
</Locale>
Отредактируем файл проекта
Test.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference
ReferenceOutputAssembly="false"
OutputItemType="Analyzer"
Include="Путь-к-файлу-проекта-генератора" />
<!--Добавляем файл шаблона локализации в дополнительные файлы-->
<AdditionalFiles Include="template.xml"/>
</ItemGroup>
</Project>
И код программы
Program.cs
using System;
using System.IO;
using System.Xml.Serialization;
using Program.Localization; //Сгенерированое пространство имен
namespace Program
{
public class Program
{
public static void Main()
{
//Тип Locale сгенерирован в момент компиляции
var xs = new XmlSerializer(typeof(Locale));
var locale = xs.Deserialize(File.OpenRead("ru.xml")) as Locale;
Console.WriteLine(locale.Name);
Console.WriteLine(locale.UI.Buttons.SendButton);
}
}
}
Dotnet-And-Happiness/LocalizationSourceGenerator (github.com) - репозиторий генератора
SadOcean
Подход имеет некоторые минусы.
Он работает только по пайплайну «от перевода» — сначала сделай перевод, хотя бы драфтовый, потом сможешь создать интерфейс.
В моем опыте типичный процесс — верстка интерфейса программистом / верстальщиком по макету (тогда они заводят ключи) с последующим заполнением локализаций.
Возможно стоит сделать так, чтобы нужные ключи заводились со стороны программиста и не удалялись просто так, если их нет в шаблоне xml
Обычно мы действительно используем словарь и строковые ключи — с ними есть указанные вами проблемы, но специализированный инструментарий и логи позволяют поймать ошибки и проблемные случаи.