本地化
随着框架向全球化的演进,为不同国家或地区的用户展示以当地语言和文化偏好表达的内容——本地化,成为一项不可或缺的功能。 本文主要介绍语言本地化的支持情况和升级改造方法。
简介
语言本地化的目标是使用户能看懂显示的文字,使其可以更方便地使用软件系统。
这里的用户包括使用业务功能的普通用户,也包括管理系统的管理员用户。 前者关注系统的界面、提示消息;后者关注系统的运行日志、基本配置。 更重要的是,前者语言多样,需要切换显示;而后者通常保持某个固定语言(以核心管理人员为准)即可。 因此我们可以简单地将本地化的目标分为两类——用户类
与系统类
,在改造过程中需要区别处理。
根据用户地区分布情况,目前支持中文简体(zh-Hans)、中文繁体(zh-Hant)、英文(en)三种语言。
提示
zh-Hans 包含 zh-CN、zh-SG,zh-Hant 包含 zh-TW、zh-MO、zh-HK。严格来讲港、澳、台之间的繁体略有差异, 但此处不再深究。
语言本地化改造主要涉及以下几个步骤:
- 查找中文文本。
- 为每条文本生成唯一标识(key)。
- 将标识和文本保存到资源文件。
- 替换原始文本为包装类的属性访问。
- 将资源文件翻译为其它语言。
在动手改造之前,接下来我们不妨先讨论一下技术选型时的一些考量。
技术选型
在 ASP.NET Core 中可以使用两种不同的本地化方法。 一种是由来已久尤其在窗体程序中广泛使用的 ResourceManager
方式; 另一种是 ASP.NET Core 独有的 IStringLocalizer
方式。
下面简单分析一下这两者之间的差异,给出最终选型的依据。
IStringLocalizer
默认情况下 IStringLocalizer
的实现类是 ResourceManagerStringLocalizer, 从名字可以看出其内部同样使用了 ResourceManager
,于是资源的组织方式依然是 .resx
文件, 这点没有差异。
我们来看看具体的使用方式。
public class FooService(IStringLocalizer localizer)
{
public void Echo()
{
Console.WriteLine(localizer["System.ErrNoOp"]);
}
}
可以看出,使用 IStringLocalizer 需要注入服务,然后调用索引器获取文本。
这里有几个明显问题:
- 需要注入服务,由于行位置不确定,不便于批量自动化改造。
- 资源项通过字符串指定,容易出现错误。
- 资源项无法回显,可读性极差。
- 语言选择在内部处理,无法手动指定。
- 无法兼容所有场景。比如类库静态类不能使用依赖注入。
而它的优势似乎也只有以下两个:
- 更符合 IoC 编码形式。
- 可以更换实现类。
当然,字符串形式的 Key 带来的问题可以通过封装静态类来避免,但其它几个问题似乎是致命伤。
ResourceManager
使用 ResourceManager 则简单粗暴的多,用法如下。
ResourceManager manager = new("Foo.Resources.LocalRes", typeof(LocalRes).Assembly);
Console.WriteLine(manager.GetString("Res221", culture));
相比之下,ResourceManager 有几个明显的优势:
- 无需注入服务
- 可以指定文化(语言)
- 可以在任意场景使用
- 封装后可以原位置行内替换,便于批量自动化执行
而它的缺点,同样是字符串 Key 引起的书写易出错、回显、可读性问题。
我们可以参考 Visual Studio 的默认行为来解决这些问题——为资源文件生成一个类, 每个资源项对应一个只读属性,附带原始文本作为注释。 获取资源时只需要访问包装类的属性即可,这样就很好地解决了字符串标识的问题。
internal class LocalSysRes
{
/// <summary>
/// 返回此类使用的缓存的 ResourceManager 实例。
/// </summary>
internal static ResourceManager ResourceManager {
// 省略初始化代码
}
/// <summary>
/// 开始处理
/// </summary>
internal static string Res221 {
get {
return ResourceManager.GetString("Res221", resourceCulture);
}
}
}
// 获取资源
Console.WriteLine(LocalSysRes.Res221);
当然,我们为了区分用户类资源与系统资源,需要生成两个独立的包装类。 主要区别在于语言的选择机制——用户语言动态,系统语言固定。
总结
无法全场景使用、无法手动指定语言、批量化操作支持较差这几个问题,足以使 IStringLocalizer
成为鸡肋。 最终我们选择了 ResourceManager
。
改造实施
准备
下载解压 LocalLangExtractor 和 ResTranslater 两个工具软件,它们分别负责资源创建和资源翻译。 项目本身不需要做任何配置和依赖方面的调整,只需要关注资源创建与访问。
资源创建
首先打开 LocalLangExtractor,选择一个项目目录(包含.csproj),点击“搜索代码”,即可列出项目中所有包含中文的字符串。
提示
由于资源文件会编译到所在项目的程序集,因此只能逐个项目分别创建资源。
根据设置的规则,软件将找到的文本分为“系统”、“用户”、“忽略”与“未分类”四种,可以根据实际情况修改选择。
- 系统即系统资源,固定使用某种语言。
- 用户即用户资源,由用户指定使用的语言。
- 忽略即不需要本地化的内容,包括注释、模型验证等。
- 未分类为没有匹配任何规则的文本,需要人工分类。
提示
软件已经内置了一些规则,使用时可以随意按需添加。
确认好文本分类后,就可以点击“开始执行”。这一步会完成以下动作:
- 为每条文本生成唯一标识。
- 标识与文本成对写入资源文件
LocalRes.resx
。 - 标识与文本成对写入包装类
LocalSysRes
(系统类资源)或LocalUserRes
(用户类资源)。 - 替换原文本为类属性访问。
提示
软件支持多次搜索、执行,不会覆盖资源文件内已有内容。
最后,编译项目,人工修复一些可能出现的错误。
按照这个步骤,依次为每个项目创建资源,本地化工作就完成了一大半!
资源翻译
经过翻译,一个资源变成一套由不同语言表示的资源,这样才可能按需切换显示。 借助 ResTranslater
批量翻译功能,可以轻松实现资源语言转换。 下面以中文翻译英文为例,介绍 ResTranslater 的使用方式。
将项目
Resources
目录下的LocalRes.zh-Hans.resx
复制到 ResTranslater 程序目录,执行以下命令:shell# ResTranslater -l 输入语言 -i 输入文件名 -t 目标语言 -o 目标文件名 ResTranslater -l zh -i LocalRes.zh-Hans.resx -t en -o LocalRes.en.resx
等待翻译完成后,将
LocalRes.en.resx
覆盖到项目中即可。提示
由于翻译功能使用百度翻译服务,-l、-t 参数需要按官方文档“语种代码对照表”说明传值。
注意
如果
目标文件
存在,程序会尝试读取并跳过已翻译的内容,加快处理过程。因此建议总是将目标资源文件一起复制到 ResTranslater 程序目录。提示
因为机器翻译的局限性,可能有部分内容并不贴切,需要人工检查修改。
依次为每个项目完成资源翻译工作,最终项目资源目录包含以下内容。
|--Resources |--LocalRes.en.resx |--LocalRes.zh-Hans.resx |--LocalRes.zh-Hant.resx |--LocalSysRes.cs |--LocalUserRes.cs
模型验证
模型验证消息的本地化略有特殊,需要单独说明。
首先,框架对于基础的验证特性已经补充了合适的本地化消息,开发人员无需再指定错误消息。
csharppublic class FooDto { [Required(ErrorMessage = "姓名不能为空")] public string Name { get; set; } }
csharppublic class FooDto { [Required] public string Name { get; set; } }
如上所示,直接删除
ErrorMessage
即可。这里给出一个正则表达式,方便大家批量搜索替换:js\(ErrorMessage\s*=\s*"[^"]+"\)
除此之外一些复杂的验证特性,往往与业务数据密切相关,很难由框架统一给出一个贴切的消息。
比如下面的正则表达式,默认消息为“字段{0}必须与正则表达式‘{1}’匹配。”,这明显不是一个友好的提示消息。 因此在这种情况下必须保留自定义的错误消息。
csharppublic class FooDto { [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$", ErrorMessage = "密码必须包含字母数字,长度为6-12位")] public string Password { get; set; } }
同时,又限于特性参数值的常量要求,无法使用属性访问来指定消息。
csharppublic class FooDto { [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$", ErrorMessage = LocalUserRes.Res221)] // 此处报错,不支持变量 public string Password { get; set; } }
此时不得不借助 ASP.NET Core 的模型消息本地化机制——把
ErrorMessage
参数当作 Key 来查找语言资源, 并且把属性访问修改为资源 Key 本身。一时间,字符串做资源 Key 的缺点又弥漫开来……csharppublic class FooDto { [RegularExpression("^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,12}$", ErrorMessage = "Res221")] public string Password { get; set; } }
推荐的做法是仍然使用工具创建资源、替换文本,然后根据错误提示人工修改资源 Key 为对应的字符串, 酌情添加注释。
如果使用了第 2 步介绍的本地化方式,还需要在 Resources 目录添加一个空的占位类
LocalRes.cs
。 此时资源目录类似以下内容。js|--Resources |--LocalRes.cs |--LocalRes.en.resx |--LocalRes.zh-Hans.resx |--LocalRes.zh-Hant.resx |--LocalSysRes.cs |--LocalUserRes.cs
注意
LocalRes 类的命名空间必须与所在项目一致,否则无法定位到正确的资源文件。
结果验证
可以设置 Accept-Language
请求头,指定用户要使用的语言,观察返回结果是否满足预期。
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: zh-Hans" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }"
# {"code":999,"msg":"请求参数异常。Name字段为必填项。|EmpSn字段为必填项。|密码为6至12位,同时包含字母和数字"}
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: zh-Hant" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }"
# {"code":999,"msg":"請求參數异常。Name欄位為必填項。|EmpSn欄位為必填項。|密碼為6至12比特,同時包含字母和數位"}
curl -X "POST" "http://localhost:5001/api/users" -H "Accept-Language: en" -H "Content-Type: application/json" -d "{ \"account\": \"\", \"password\": \"string\" }"
# {"code":999,"msg":"Request parameter exception.The Name field is required.|The EmpSn field is required.|The password consists of 6 to 12 characters, including both letters and numbers"}