让所有应用程序都为本地化做好准备
无论您有多懒。
引言
多年来,我一直在构建一个主题为“应该内置到 .NET 框架中但却未内置”的库。但我一直推迟撰写有关这些本应内置却未内置的内容的文章。现在不会了!它名为 Loyc.Essentials,您可以通过 NuGet 获取它(它以 Loyc 命名,但这并不重要)。
Loyc.Essentials 有一个 Localize
类,这是一个全局钩子,可以在其中安装字符串映射本地化器。如果您正在使用 Loyc.Essentials,则应该使用它。它几乎毫不费力地为您的程序准备好翻译成其他语言。
其理念是通过使本地化变得极其简单来吸引程序员支持本地化。默认情况下,它不连接到任何翻译器(它只是传递字符串),因此,对于只为一个语言市场编写程序的开发者来说,可以轻松地使他们的代码“为多语言做好准备”,而无需进行任何额外的工作。
您只需调用 .Localized()
扩展方法,这实际上比编写传统的 string.Format()
要短。(另外:using Loyc;
)
编辑:总的来说,由于 C# 6 的设计方式,这与 C# 6 的插值字符串($"..."
)不兼容,但本文末尾描述了一种解决方法。
翻译系统本身与 Localize
分开,并通过委托连接到 Localized()
,因此可以有多个翻译系统。此类应该适用于任何 .NET 程序,并且使用此实用程序的某些程序可能需要使用不同的本地化器。
像这样使用它
string result = "Hello, {0}".Localized(userName);
或者,为了提高清晰度,使用命名占位符
string result = "Hello, {person's name}".Localized("person's name", userName);
无论安装了哪个本地化器,它都会在其数据库中查找文本并返回翻译。如果找不到目标用户语言的翻译,则应返回适当的默认翻译:原始文本,或某种默认语言(例如英语)的翻译。
本地化器将需要一个外部翻译表,概念上如下所示:
键名 | 语言 | 翻译文本 |
---|---|---|
“你好,{0}” | “es” | “Hola, {0}” |
“你好,{0}” | “fr” | “Bonjour, {0}” |
“加载” | “es” | “Cargar” |
“加载” | “fr” | “Charge” |
“保存” | “es” | “Guardar” |
“保存” | “fr” | “Enregistrer” |
许多开发者使用 resx 文件来存储翻译。如下面所述,这是支持的。
本地化长字符串
对于较长的消息,最好使用一个简短的名称来表示该消息,这样,当 英文 文本被编辑时,其他 语言的翻译表就不必更改。要做到这一点,请使用 Symbol
方法
// The translation table will be searched for "ConfirmQuitWithoutSaving"
string result = Localize.Symbol("ConfirmQuitWithoutSaving",
"Are you sure you want to quit without saving '{filename}'?", "filename", fileName);
// Enhanced C# syntax with symbol literal
string result = Localize.Symbol(@@ConfirmQuitWithoutSaving,
"Are you sure you want to quit without saving '{filename}'?", "filename", fileName);
这对于长字符串或段落文本最有用的,但我预计有些项目会作为一项政策,对所有可本地化文本都使用符号。
同样,您可以调用此方法而不设置任何翻译表。但是,实际消息允许为 null
。在这种情况下,如果没有设置本地化器或没有可用的翻译,Localize.Symbol
将返回符号本身(第一个参数)作为最后的手段。
如果变量参数列表不为空,则调用 Localize.Formatter
来从格式字符串构建完整的字符串。可以单独进行格式化 - 例如
Console.WriteLine("{0} is {0:X} in hexadecimal".Localized(), N);
在此示例中,WriteLine
本身负责格式化,而不是 Localized
。
如上所示,Localize
的默认格式化程序 StringExt.FormatCore
具有标准格式化程序所没有的一个额外功能:命名参数。下面是一个示例
...
string verb = (IsFileLoaded ? "parse" : "load").Localized();
MessageBox.Show(
"Not enough memory to {load/parse} '{filename}'.".Localized(
"load/parse", verb, "filename", FileName));
如您所见,命名参数在格式字符串中通过指定参数名称(如 {filename}
)而不是数字(如 {0}
)来提及。变量参数列表包含相同的名称后跟其值,例如 "filename", FileName
。此功能为您(开发人员)提供了向编写翻译的人员说明特定参数用途的机会。
翻译者不得更改任何参数:{filename}
这个词不得翻译。
在运行时,带命名参数的格式字符串会被转换为带数字参数的“普通”格式字符串。上面的示例将变成“无法 {1} 文件:{3}”,然后传递给 string.Format
。
设计理念
许多开发人员不想花时间编写国际化或本地化代码,并倾向于编写仅适用于一种语言的代码。这并不奇怪,因为与硬编码字符串相比,这确实很麻烦。Microsoft 建议代码应携带一个 ResourceManager
对象,并直接从中请求字符串
private ResourceManager rm;
rm = new ResourceManager("RootNamespace.Resources", this.GetType().Assembly);
Console.Writeline(rm.GetString("StringIdentifier"));
这种方法存在缺点
- 在可能包含可本地化字符串的所有类之间传递
ResourceManager
实例可能会很麻烦;全局机制要方便得多。 - 程序员必须将所有翻译放在资源文件中;因此,编写代码很麻烦,因为程序员必须切换到资源文件并向其添加字符串。反过来,阅读代码的人无法知道字符串的内容,并且必须加载资源文件才能找出。
- 更改本地化管理器并不容易;例如,如果有人想将翻译存储在 .ini、.xml 或 .les 文件中而不是程序集中怎么办?如果用户想集中处理一组程序集的全部翻译,而不是在每个程序集中拥有单独的资源怎么办?
- 如果找不到请求的标识符,
GetString
将返回null
,可能导致输出为空或NullReferenceException
。
Microsoft 通过提供 Visual Studio 内置的代码生成器解决了第一个缺点,该生成器为每个字符串提供全局属性;请参阅 此处。
即便如此,您可能会发现此类提供了一种更方便的方法,因为您的母语字符串直接写在代码中,并且您可以确保在运行时获得一个字符串(非 null),如果目标语言不可用。
与 ResourceManager 结合使用
此类通过 UseResourceManager
辅助方法支持 ResourceManager。例如,调用 Localize.UseResourceManager(resourceManager)
后,如果您编写
"Save As...".Localized()
然后调用 resourceManager.GetString("Save As...")
来获取翻译后的字符串,如果找不到翻译,则返回原始字符串(是的,在您的 resx 文件中,您可以在左侧使用空格和标点符号)。您甚至可以添加一个“名称计算器”来编码您的 resx 文件的命名约定,例如通过删除空格和标点符号(有关详细信息,请参阅 UseResourceManager
方法。)
.NET 程序中常见的情况是有一个“主”resx 文件,例如 Resources.resx,它包含默认字符串,以及其他非英语翻译文件(例如,Resources.es.resx 用于西班牙语)。当使用 Localized()
时,您可能会使用一种略有不同的方法:您仍然创建项目的一个 Resources.resx 文件,但将字符串表留空(您仍然可以使用它来存储其他资源,例如图标)。这会导致 Visual Studio 生成一个 Resources
类,其中包含一个 ResourceManager
属性,以便您可以轻松获取所需的 ResourceManager
实例。
- 在程序启动时,调用
Localize.UseResourceManager(Resources.ResourceManager)
。 - 使用
Localized()
扩展方法获取短字符串的翻译。 - 对于长字符串,请使用
Localize.Symbol("ShortAlias", "Long string", params...)
。第一个参数是传递给ResourceManager.GetString()
的字符串
使用字符串插值进行本地化
可以将本地化与 C# 6 的插值字符串结合使用,例如 $"this string {...}"
(并且感谢 Florian Rappl 引起了我的注意)。
不幸的是,Localize()
无法与它们一起使用。
起初我以为这根本不可能,因为字符串插值通常会转换为 string.Format
,而 string.Format
的行为无法自定义。然而,通过与 Lambda 方法有时会变成表达式树 类似的方式,如果目标方法接受 System.FormattableString
对象,编译器将从 string.Format
切换到 FormattableStringFactory.Create
(.NET 4.6 方法)。
问题是,编译器优先调用 string.Format
,所以如果有一个接受 FormattableString
的 Localized()
重载,它将无法与字符串插值一起使用,因为 C# 编译器会简单地忽略它(因为 Localized()
已经可以接受一个字符串)。事实上,情况更糟:编译器在调用扩展方法时也不允许使用 FormattableString
。
如果您使用非扩展方法,它就可以工作。例如
static class Loca { public static string lize(this FormattableString message) { return message.Format.Localized(message.GetArguments()); } }
然后您可以像这样使用它
public class Program { public static void Main(string[] args) { Localize.UseResourceManager(Resources.ResourceManager); var name = "Dave"; Console.WriteLine(Loca.lize($"Hello, {name}")); } }
重要的是要认识到编译器会将 $"..."
字符串转换为一个老式的格式字符串。所以在这个例子中,Loca.lize
实际上收到的是 "Hello, {0}"
作为格式字符串,而不是 "Hello, {name}"
。
不幸的是,我们需要一种完全不同的方法来本地化插值字符串与普通字符串相比,这有点令人困惑,如果您忘记了—如果您写 $"Hello, {name}".Localized()
—您的代码将会出错,因为格式化将发生在本地化之前,因此找不到翻译。
为了避免这种混淆,我不打算扩展我的库来支持字符串插值,但如果您确实希望在您的应用程序中使用字符串插值,您仍然可以通过添加一个像 Loca.lize
这样的辅助方法来本地化它。
源代码
源代码位于此处。不幸的是,它确实使用了一些 Loyc.Essentials 特有的类型(Symbol
、ThreadLocalVariable<T>
、SavedValue<T>
和 ScratchBuffer<T>
),因此,如果您想在不使用 Loyc.Essentials NuGet 包的情况下使用 Localize
,您需要花一些时间将其转换为“纯旧”C#。